ll_hls_runtime/server/engine.rs
1//! Blocking-reload/part-availability *decision* logic and playlist rendering
2//! for the LL-HLS origin — moved out of `multimux::output::llhls` (issue
3//! #663/#717 Stage 2). Sans-IO: every function here is a synchronous poll
4//! returning an outcome enum, never a `Future` — the caller (an async
5//! adapter, e.g. `multimux`) turns `WouldBlock` into an actual wait using
6//! [`super::MediaStore::listen`] (see this module's parent doc for the wait
7//! loop shape).
8//!
9//! Master/media playlist tags are RFC 8216 §4.3.4 (`#EXT-X-STREAM-INF`) and
10//! §4.3.3 (`#EXTM3U`/`#EXT-X-VERSION`, rendered by [`media_playlist_m3u8`]);
11//! the blocking reload query parameters (`_HLS_msn`/`_HLS_part`) are the
12//! Blocking Playlist Reload mechanism of RFC 8216bis §6.2.5.2 — the client
13//! asks the origin to hold the response open until the requested Media
14//! Sequence Number/part is available, bounded so the origin never hangs
15//! indefinitely (the bound itself — a wall-clock timeout — is the adapter's
16//! job, not this module's: sans-IO code has no clock).
17
18use transmux::hls::{LowLatencyConfig, MediaPlaylist, MediaSegment, OpenSegment, PartSpec};
19
20use super::store::MediaStore;
21
22/// Track id for the single rendition served per stream (no multi-track/
23/// multi-rendition support yet).
24pub const DEFAULT_TRACK_ID: u32 = 1;
25
26/// Placeholder `BANDWIDTH` (bits/second) advertised in the master playlist's
27/// `#EXT-X-STREAM-INF` — actual encoded bitrate isn't measured, so a single
28/// fixed estimate is used for the single variant served.
29const PLACEHOLDER_BANDWIDTH_BPS: u64 = 5_000_000;
30
31/// RFC 8216bis §6.2.5.2 (SHOULD): a `_HLS_msn` unreasonably far in the future
32/// should be rejected rather than always blocking to the caller's timeout — a
33/// legitimate client only ever asks for the segment/part right after the one
34/// it already has, so anything more than a few segments beyond the current
35/// live edge is either a malfunctioning client or abuse.
36const ABUSE_MSN_FUTURE_BOUND: u64 = 4;
37
38/// HLS requires HLS protocol version 9 (RFC 8216bis §4.4.3.7/§4.4.3.8: the
39/// `#EXT-X-PART-INF`/`#EXT-X-PART` directives this renderer always emits
40/// require it).
41const LL_HLS_VERSION: u8 = 9;
42
43/// RFC 8216bis / Apple LL-HLS §4.4.3.7: `#EXT-X-SERVER-CONTROL`'s
44/// `PART-HOLD-BACK` attribute MUST be at least 3x the part target duration
45/// (`#EXT-X-PART-INF`'s `PART-TARGET`).
46const PART_HOLD_BACK_MULTIPLIER: f64 = 3.0;
47
48/// Render the LL-HLS media playlist for `track_id` from `store`'s current
49/// segments/live parts.
50///
51/// RFC 8216bis §4.4.4.9: an in-progress (not yet closed) segment MUST NOT
52/// be advertised with an `#EXTINF`/URI pair — that segment has no fetchable
53/// resource yet — it may only appear as trailing `#EXT-X-PART` lines.
54/// `transmux::hls::MediaPlaylist::open_segment` is exactly this
55/// representation: its parts render as trailing `#EXT-X-PART` lines with
56/// no `#EXTINF`/URI, so the in-progress segment's parts and the
57/// `#EXT-X-PRELOAD-HINT` for the next, not-yet-available part are both
58/// rendered by `to_m3u8()` itself — this function only supplies the URI
59/// scheme (`part-<track>-<seq>.<idx>.m4s`) and the part metadata.
60pub fn media_playlist_m3u8(store: &MediaStore, track_id: u32) -> String {
61 // Read these *before* taking `with_segments_and_parts`'s lock below —
62 // `MediaStore::max_segment_duration` takes the same `inner` mutex
63 // itself, and `std::sync::Mutex` is not reentrant, so calling it from
64 // inside the `with_segments_and_parts` closure (as a previous version of
65 // this function did) self-deadlocks the calling thread the first time
66 // this function is ever invoked with any segment present. Caught by a
67 // real network round trip against a live `MediaStore` (issue #717 slice
68 // 5's acceptance test) — the existing test suite only ever called this
69 // function directly (never over HTTP with two concurrently-scheduled
70 // tasks), which happened to never trip the deadlock detector but hung
71 // just the same once actually exercised end-to-end. **Preserve this
72 // ordering** — see `docs/superpowers/specs/2026-07-18-multimux-hub-design.md`
73 // and issue #663/#717.
74 let target_duration_secs = store.target_duration_secs();
75 let max_segment_duration = store.max_segment_duration();
76 store.with_segments_and_parts(|store_segments, live_parts| {
77 let media_sequence = store_segments
78 .front()
79 .map(|s| u64::from(s.segment_seq))
80 .or_else(|| live_parts.first().map(|p| u64::from(p.segment_seq)))
81 .unwrap_or(1);
82 let segments: Vec<MediaSegment> = store_segments
83 .iter()
84 .map(|s| MediaSegment {
85 uri: format!("seg-{track_id}-{}.m4s", s.segment_seq),
86 duration: s.duration,
87 discontinuous: false,
88 parts: Vec::new(),
89 ..Default::default()
90 })
91 .collect();
92 let part_target = f64::from(store.part_target_ms()) / 1000.0;
93 // The in-progress segment's live parts + the next (not yet available)
94 // part's preload-hint URI.
95 let open_seq = live_parts.first().map(|p| p.segment_seq);
96 let open_segment = open_seq.map(|seq| {
97 OpenSegment::new(
98 live_parts
99 .iter()
100 .filter(|p| p.segment_seq == seq)
101 .map(|p| PartSpec {
102 uri: format!("part-{track_id}-{}.{}.m4s", p.segment_seq, p.part_index),
103 duration: p.duration,
104 independent: p.independent,
105 ..Default::default()
106 })
107 .collect(),
108 )
109 });
110 let next_part_hint = open_seq.map(|seq| {
111 let next_idx = live_parts
112 .iter()
113 .filter(|p| p.segment_seq == seq)
114 .map(|p| p.part_index)
115 .max()
116 .map(|idx| idx + 1)
117 .unwrap_or(0);
118 format!("part-{track_id}-{seq}.{next_idx}.m4s")
119 });
120 // RFC 8216bis §4.4.3.1 (MUST): every Media Segment's EXTINF duration,
121 // rounded to the nearest integer, MUST be <= TARGETDURATION. The
122 // segmenter cuts on the next keyframe *after* the configured target,
123 // so a real segment routinely exceeds it — advertising the
124 // configured target alone can under-declare. Use whichever is
125 // larger, rounded (not the configured value's `ceil()` alone).
126 let target_duration = target_duration_secs.max(max_segment_duration).round() as u32;
127 let playlist = MediaPlaylist {
128 version: LL_HLS_VERSION,
129 target_duration,
130 media_sequence,
131 discontinuity_sequence: 0,
132 segments,
133 open_segment,
134 endlist: false,
135 extra_tags: vec![format!("#EXT-X-MAP:URI=\"init-{track_id}.mp4\"")],
136 low_latency: Some(LowLatencyConfig {
137 part_target,
138 part_hold_back: part_target * PART_HOLD_BACK_MULTIPLIER,
139 preload_hint_part: next_part_hint,
140 ..Default::default()
141 }),
142 iframes_only: false,
143 ..Default::default()
144 };
145 playlist.to_m3u8()
146 })
147}
148
149/// A minimal single-variant master playlist pointing at `media_playlist_name`
150/// (the caller's configured media-playlist filename — e.g. multimux's
151/// `Config::playlist_name`, defaulting to `"media.m3u8"`) — the same
152/// regardless of `MediaStore` state (no multi-rendition support yet), so this
153/// takes no store argument.
154pub fn master_playlist_m3u8(media_playlist_name: &str) -> String {
155 format!(
156 "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH={PLACEHOLDER_BANDWIDTH_BPS}\n{media_playlist_name}\n"
157 )
158}
159
160/// Blocking playlist reload query parameters (RFC 8216bis §6.2.5.2) — the
161/// sans-IO counterpart of an adapter's own (likely serde-`Deserialize`)
162/// query-string type; the adapter maps its wire query params into this.
163#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
164pub struct BlockingQuery {
165 /// The Media Sequence Number the client already has, plus one — the
166 /// origin should not respond until a segment/part beyond this is ready.
167 pub hls_msn: Option<u64>,
168 /// The part index (within `hls_msn`) the client is waiting for.
169 pub hls_part: Option<u32>,
170}
171
172/// The result of [`MediaStore::resolve_playlist`]: either the rendered
173/// playlist is ready now, the request is malformed/abusive (RFC 8216bis
174/// §6.2.5.2 abuse prevention — reject immediately, no wait), or the awaited
175/// segment/part isn't available *yet* (the caller should wait for the next
176/// change notification and re-resolve).
177#[derive(Debug, Clone, PartialEq, Eq)]
178#[non_exhaustive]
179pub enum PlaylistOutcome {
180 /// The rendered media playlist body.
181 Ready(String),
182 /// The awaited condition (`hls_msn`/`hls_part`) isn't satisfied yet —
183 /// wait for [`super::MediaStore::listen`] and re-resolve.
184 WouldBlock,
185 /// The request is malformed (`hls_part` without `hls_msn`) or abusive
186 /// (`hls_msn` unreasonably far beyond the live edge) — reject now, don't
187 /// wait.
188 BadRequest,
189}
190
191/// `Cache-Control` policy an adapter applies to a resolved resource —
192/// playlists are always re-fetched for liveness (not modeled here since
193/// [`PlaylistOutcome::Ready`] is playlist-only), while a produced init/
194/// segment/part byte range never changes once produced.
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196#[non_exhaustive]
197pub enum CachePolicy {
198 /// Safe to cache indefinitely — a given URI's bytes never change once
199 /// produced (each segment/part is generated exactly once under a unique
200 /// filename).
201 Immutable,
202 /// Must always be re-fetched (liveness-sensitive).
203 NoCache,
204}
205
206impl CachePolicy {
207 /// The spec/field-enum label (workspace #204 convention): a stable,
208 /// lowercase token per policy, suitable for logs/metrics/`Cache-Control`
209 /// diagnostics.
210 pub fn name(&self) -> &'static str {
211 match self {
212 CachePolicy::Immutable => "immutable",
213 CachePolicy::NoCache => "no-cache",
214 }
215 }
216}
217
218broadcast_common::impl_spec_display!(CachePolicy);
219
220/// The result of [`MediaStore::resolve_resource`]: the resource's bytes are
221/// ready, the request should wait (a preload-hinted part not yet produced),
222/// or the resource does not (and, for a part whose segment already closed
223/// without it, will never) exist.
224#[derive(Debug, Clone, PartialEq, Eq)]
225#[non_exhaustive]
226pub enum ResourceOutcome {
227 /// The resource's bytes, plus the cache policy an adapter should apply.
228 Ready {
229 /// The resolved bytes.
230 bytes: Vec<u8>,
231 /// `Cache-Control` policy for these bytes.
232 cache: CachePolicy,
233 },
234 /// A preload-hinted part that hasn't been produced yet — wait for
235 /// [`super::MediaStore::listen`] and re-resolve.
236 WouldBlock,
237 /// The named resource does not exist and never will (unknown filename
238 /// shape, or a part whose segment closed without ever producing it).
239 NotFound,
240}
241
242impl MediaStore {
243 /// Resolve a `GET media.m3u8` request against `_HLS_msn`/`_HLS_part`
244 /// blocking-reload semantics (RFC 8216bis §6.2.5.2), rendering
245 /// [`media_playlist_m3u8`] for `track_id` once the awaited condition is
246 /// satisfied (or immediately, if `query` carries no blocking
247 /// parameters).
248 ///
249 /// `_HLS_msn` alone waits for segment `msn` to **close**; `_HLS_msn`+
250 /// `_HLS_part` waits only for that part of the (possibly still open)
251 /// segment — these are genuinely different conditions (treating a bare
252 /// `_HLS_msn` as `_HLS_part=0` would resolve as soon as the segment
253 /// merely opens with one live part, before it has an `#EXTINF`/URI at
254 /// all). `_HLS_part` without `_HLS_msn` is meaningless (a part is only
255 /// addressable relative to a segment) and a `_HLS_msn` unreasonably far
256 /// beyond the live edge is either a broken client or abuse — both
257 /// [`PlaylistOutcome::BadRequest`] immediately rather than
258 /// [`PlaylistOutcome::WouldBlock`]ing.
259 pub fn resolve_playlist(&self, track_id: u32, query: BlockingQuery) -> PlaylistOutcome {
260 if query.hls_part.is_some() && query.hls_msn.is_none() {
261 return PlaylistOutcome::BadRequest;
262 }
263 if let Some(msn) = query.hls_msn {
264 let (current_max_msn, _) = self.latest_progress();
265 if msn > u64::from(current_max_msn) + ABUSE_MSN_FUTURE_BOUND {
266 return PlaylistOutcome::BadRequest;
267 }
268 let satisfied = match query.hls_part {
269 Some(part) => {
270 let (in_progress_seg_seq, part_count) = self.latest_progress();
271 u64::from(in_progress_seg_seq) > msn
272 || (u64::from(in_progress_seg_seq) == msn && part_count > part)
273 }
274 None => u64::from(self.last_closed_segment_seq()) >= msn,
275 };
276 if !satisfied {
277 return PlaylistOutcome::WouldBlock;
278 }
279 }
280 PlaylistOutcome::Ready(media_playlist_m3u8(self, track_id))
281 }
282
283 /// Resolve a dynamic origin filename (`init-{track}.mp4`, `seg-{track}-
284 /// {seq}.m4s`, `part-{track}-{seq}.{idx}.m4s`) to its bytes.
285 ///
286 /// A part request is the preload-hinted Partial Segment a client fetches
287 /// ahead of time (RFC 8216bis §6.2.2, §6.3.1). If the origin promised it
288 /// via `#EXT-X-PRELOAD-HINT` but hasn't produced it yet,
289 /// [`ResourceOutcome::WouldBlock`] — the caller should hold the request
290 /// open (not 404 immediately, which spams errors and defeats low
291 /// latency). [`ResourceOutcome::NotFound`] is returned **promptly**
292 /// (without the caller needing to wait out its own timeout) once the
293 /// part can no longer appear: its segment has closed (now only
294 /// addressable as a whole segment via `seg-…`), or the in-progress
295 /// segment has advanced past it — a legitimate 404 the client answers by
296 /// fetching the next segment/part.
297 pub fn resolve_resource(&self, name: &str) -> ResourceOutcome {
298 if let Some((seq, idx)) = parse_part(name) {
299 return match self.part_bytes(seq, idx) {
300 Some(bytes) => ResourceOutcome::Ready {
301 bytes,
302 cache: CachePolicy::Immutable,
303 },
304 None => {
305 let (in_progress_seg_seq, _) = self.latest_progress();
306 if in_progress_seg_seq > seq || self.segment_bytes(seq).is_some() {
307 ResourceOutcome::NotFound
308 } else {
309 ResourceOutcome::WouldBlock
310 }
311 }
312 };
313 }
314 match resolve_file(self, name) {
315 Some(bytes) => ResourceOutcome::Ready {
316 bytes,
317 cache: CachePolicy::Immutable,
318 },
319 None => ResourceOutcome::NotFound,
320 }
321 }
322}
323
324/// Parse a `part-{track}-{seq}.{idx}.m4s` dynamic filename into `(seq, idx)`,
325/// or `None` if it isn't a part filename (or its numeric fields don't parse).
326/// `{track}` is validated but unused (see [`resolve_file`]).
327fn parse_part(file: &str) -> Option<(u32, u32)> {
328 let rest = file.strip_prefix("part-")?.strip_suffix(".m4s")?;
329 let (track_seq, idx) = rest.rsplit_once('.')?;
330 let (track, seq) = track_seq.split_once('-')?;
331 track.parse::<u32>().ok()?;
332 Some((seq.parse().ok()?, idx.parse().ok()?))
333}
334
335/// Parse a dynamic origin filename and fetch its bytes from `store`:
336/// - `init-{track}.mp4` -> [`MediaStore::init_bytes`]
337/// - `seg-{track}-{seq}.m4s` -> [`MediaStore::segment_bytes`]
338///
339/// Part filenames (`part-{track}-{seq}.{idx}.m4s`) are handled separately in
340/// [`MediaStore::resolve_resource`] (they can block until available — see
341/// [`parse_part`]), not here. `{track}` is validated as a number but
342/// otherwise unused: `store` holds a single track's data (see
343/// [`DEFAULT_TRACK_ID`]). Returns `None` (-> 404) for any filename that
344/// doesn't match one of these shapes, or whose numeric fields don't parse.
345fn resolve_file(store: &MediaStore, file: &str) -> Option<Vec<u8>> {
346 if let Some(rest) = file.strip_prefix("init-") {
347 let track = rest.strip_suffix(".mp4")?;
348 track.parse::<u32>().ok()?;
349 return store.init_bytes();
350 }
351 if let Some(rest) = file.strip_prefix("seg-") {
352 let rest = rest.strip_suffix(".m4s")?;
353 let (track, seq) = rest.split_once('-')?;
354 track.parse::<u32>().ok()?;
355 let seq: u32 = seq.parse().ok()?;
356 return store.segment_bytes(seq);
357 }
358 None
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use transmux::ll_hls::{PartInfo, SegmentInfo};
365
366 fn part(seq: u32, idx: u32) -> PartInfo {
367 PartInfo {
368 bytes: vec![0x10 + idx as u8; 4],
369 duration: 0.5,
370 independent: idx == 0,
371 segment_seq: seq,
372 part_index: idx,
373 }
374 }
375
376 fn seg(seq: u32) -> SegmentInfo {
377 SegmentInfo {
378 bytes: vec![0x20 + seq as u8; 8],
379 duration: 4.0,
380 segment_seq: seq,
381 part_count: 2,
382 }
383 }
384
385 /// A populated store: a closed segment 1, plus two live parts of
386 /// in-progress segment 2 -- so `latest_progress()` is `(2, 2)`.
387 fn make_store() -> MediaStore {
388 let store = MediaStore::new(4.0, 500, 4);
389 store.set_init(vec![0xAA; 8]);
390 store.add_segment(seg(1));
391 store.add_part(part(2, 0));
392 store.add_part(part(2, 1));
393 store
394 }
395
396 #[test]
397 fn cache_policy_name_and_display_agree() {
398 for (policy, label) in [
399 (CachePolicy::Immutable, "immutable"),
400 (CachePolicy::NoCache, "no-cache"),
401 ] {
402 assert_eq!(policy.name(), label);
403 assert_eq!(policy.to_string(), label);
404 }
405 }
406
407 #[test]
408 fn master_playlist_has_stream_inf() {
409 let m = master_playlist_m3u8("media.m3u8");
410 assert!(m.contains("#EXTM3U"));
411 assert!(m.contains("#EXT-X-STREAM-INF"));
412 assert!(m.contains("media.m3u8"));
413 }
414
415 #[test]
416 fn master_playlist_points_at_configured_playlist_name() {
417 let m = master_playlist_m3u8("index.m3u8");
418 assert!(m.contains("index.m3u8"));
419 assert!(!m.contains("media.m3u8"));
420 }
421
422 #[test]
423 fn resolve_playlist_no_query_is_ready_now() {
424 let store = make_store();
425 let outcome = store.resolve_playlist(DEFAULT_TRACK_ID, BlockingQuery::default());
426 match outcome {
427 PlaylistOutcome::Ready(body) => assert!(body.contains("#EXT-X-PART"), "body: {body}"),
428 other => panic!("expected Ready, got {other:?}"),
429 }
430 }
431
432 #[test]
433 fn resolve_playlist_already_satisfied_earlier_msn_is_ready() {
434 // latest_progress() for the store is (2, 2): asking for msn=1 (an
435 // earlier segment) is already satisfied and must not WouldBlock.
436 let store = make_store();
437 let outcome = store.resolve_playlist(
438 DEFAULT_TRACK_ID,
439 BlockingQuery {
440 hls_msn: Some(1),
441 hls_part: Some(0),
442 },
443 );
444 assert!(matches!(outcome, PlaylistOutcome::Ready(_)));
445 }
446
447 #[test]
448 fn resolve_playlist_already_satisfied_same_msn_lower_part_is_ready() {
449 // in_progress_seg_seq == msn and part_count(2) > part(1): satisfied.
450 let store = make_store();
451 let outcome = store.resolve_playlist(
452 DEFAULT_TRACK_ID,
453 BlockingQuery {
454 hls_msn: Some(2),
455 hls_part: Some(1),
456 },
457 );
458 assert!(matches!(outcome, PlaylistOutcome::Ready(_)));
459 }
460
461 #[test]
462 fn resolve_playlist_msn_only_waits_for_closed_segment_not_just_open_parts() {
463 // make_store()'s segment 2 is OPEN with 2 live parts
464 // (latest_progress() == (2, 2)) but not yet CLOSED. RFC 8216bis
465 // §6.2.5.2: a bare `_HLS_msn=2` (no `_HLS_part`) must WouldBlock, not
466 // resolve merely because it has live parts — treating this as
467 // `_HLS_part=0` (satisfied by part_count(2) > 0) would wrongly return
468 // Ready here.
469 let store = make_store();
470 let outcome = store.resolve_playlist(
471 DEFAULT_TRACK_ID,
472 BlockingQuery {
473 hls_msn: Some(2),
474 hls_part: None,
475 },
476 );
477 assert_eq!(outcome, PlaylistOutcome::WouldBlock);
478
479 // Once segment 2 actually closes, the same query resolves.
480 store.add_segment(seg(2));
481 let outcome = store.resolve_playlist(
482 DEFAULT_TRACK_ID,
483 BlockingQuery {
484 hls_msn: Some(2),
485 hls_part: None,
486 },
487 );
488 match outcome {
489 PlaylistOutcome::Ready(body) => assert!(
490 body.contains("seg-1-2.m4s"),
491 "resolved playlist must show segment 2 as closed: {body}"
492 ),
493 other => panic!("expected Ready after close, got {other:?}"),
494 }
495 }
496
497 #[test]
498 fn resolve_playlist_msn_within_bound_would_block_until_part_lands() {
499 // Sanity check for the abuse-bound logic: a legitimate
500 // just-ahead-of-live-edge msn/part WouldBlocks (not BadRequest), then
501 // resolves once the part lands.
502 let store = make_store(); // latest_progress() == (2, 2)
503 let outcome = store.resolve_playlist(
504 DEFAULT_TRACK_ID,
505 BlockingQuery {
506 hls_msn: Some(2),
507 hls_part: Some(2),
508 },
509 );
510 assert_eq!(outcome, PlaylistOutcome::WouldBlock);
511
512 store.add_part(part(2, 2));
513 let outcome = store.resolve_playlist(
514 DEFAULT_TRACK_ID,
515 BlockingQuery {
516 hls_msn: Some(2),
517 hls_part: Some(2),
518 },
519 );
520 assert!(matches!(outcome, PlaylistOutcome::Ready(_)));
521 }
522
523 #[test]
524 fn resolve_playlist_far_future_msn_rejected() {
525 // latest_progress() for make_store() is (2, 2). A `_HLS_msn` 1000
526 // ahead of the live edge is not a legitimate blocking-reload request
527 // (RFC 8216bis §6.2.5.2 abuse prevention) — BadRequest immediately.
528 let store = make_store();
529 let outcome = store.resolve_playlist(
530 DEFAULT_TRACK_ID,
531 BlockingQuery {
532 hls_msn: Some(1002),
533 hls_part: None,
534 },
535 );
536 assert_eq!(outcome, PlaylistOutcome::BadRequest);
537 }
538
539 #[test]
540 fn resolve_playlist_part_without_msn_rejected() {
541 // RFC 8216bis §6.2.5.2: `_HLS_part` without `_HLS_msn` is
542 // meaningless (a part is only addressable relative to a segment).
543 let store = make_store();
544 let outcome = store.resolve_playlist(
545 DEFAULT_TRACK_ID,
546 BlockingQuery {
547 hls_msn: None,
548 hls_part: Some(0),
549 },
550 );
551 assert_eq!(outcome, PlaylistOutcome::BadRequest);
552 }
553
554 #[test]
555 fn resolve_resource_init_present() {
556 let store = make_store();
557 let outcome = store.resolve_resource("init-1.mp4");
558 match outcome {
559 ResourceOutcome::Ready { bytes, cache } => {
560 assert_eq!(bytes, vec![0xAA; 8]);
561 assert_eq!(cache, CachePolicy::Immutable);
562 }
563 other => panic!("expected Ready, got {other:?}"),
564 }
565 }
566
567 #[test]
568 fn resolve_resource_segment_present_and_absent() {
569 let store = make_store();
570 match store.resolve_resource("seg-1-1.m4s") {
571 ResourceOutcome::Ready { bytes, .. } => assert_eq!(bytes, vec![0x21; 8]),
572 other => panic!("expected Ready, got {other:?}"),
573 }
574 assert_eq!(
575 store.resolve_resource("seg-1-99.m4s"),
576 ResourceOutcome::NotFound
577 );
578 }
579
580 #[test]
581 fn resolve_resource_part_present() {
582 let store = make_store();
583 match store.resolve_resource("part-1-2.0.m4s") {
584 ResourceOutcome::Ready { bytes, .. } => assert_eq!(bytes, vec![0x10; 4]),
585 other => panic!("expected Ready, got {other:?}"),
586 }
587 }
588
589 #[test]
590 fn resolve_resource_part_not_yet_produced_would_block() {
591 // part-1-2.2 is the preload-hinted next part of in-progress segment 2
592 // (which currently has parts .0 and .1). Not yet produced -> WouldBlock,
593 // not NotFound (the caller waits, doesn't 404 immediately).
594 let store = make_store();
595 assert_eq!(
596 store.resolve_resource("part-1-2.2.m4s"),
597 ResourceOutcome::WouldBlock
598 );
599 store.add_part(part(2, 2));
600 match store.resolve_resource("part-1-2.2.m4s") {
601 ResourceOutcome::Ready { bytes, .. } => assert_eq!(bytes, vec![0x12; 4]),
602 other => panic!("expected Ready once produced, got {other:?}"),
603 }
604 }
605
606 #[test]
607 fn resolve_resource_part_not_found_once_segment_closes_without_it() {
608 // part-1-2.9 will never be produced. Once segment 2 closes (advancing
609 // the in-progress segment), the part must resolve NotFound promptly —
610 // not WouldBlock forever.
611 let store = make_store();
612 assert_eq!(
613 store.resolve_resource("part-1-2.9.m4s"),
614 ResourceOutcome::WouldBlock,
615 "not yet decidable while segment 2 is still open"
616 );
617 store.add_segment(seg(2));
618 assert_eq!(
619 store.resolve_resource("part-1-2.9.m4s"),
620 ResourceOutcome::NotFound,
621 "must resolve NotFound once segment 2 has closed without producing it"
622 );
623 }
624
625 #[test]
626 fn resolve_resource_part_served_from_recent_after_close() {
627 // Segment 2 has live parts .0 and .1; close it. Its final part must
628 // still resolve Ready (from recent_parts) — an in-flight
629 // preload-hint request racing the segment close must not NotFound.
630 let store = make_store();
631 store.add_segment(seg(2)); // close segment 2, moving its parts to recent_parts
632 match store.resolve_resource("part-1-2.1.m4s") {
633 ResourceOutcome::Ready { bytes, .. } => assert_eq!(bytes, vec![0x11; 4]),
634 other => panic!("a just-closed segment's part must still resolve Ready, got {other:?}"),
635 }
636 }
637
638 #[test]
639 fn resolve_resource_part_of_old_segment_not_found() {
640 // Segment 1 closed in make_store() with no parts recorded and is old
641 // enough to be past the recent-parts retention window, so its parts
642 // resolve NotFound without ever WouldBlocking (they will never be
643 // produced and aren't individually addressable anymore).
644 let store = make_store();
645 assert_eq!(
646 store.resolve_resource("part-1-1.0.m4s"),
647 ResourceOutcome::NotFound
648 );
649 }
650
651 #[test]
652 fn resolve_resource_unmatched_filename_not_found() {
653 let store = make_store();
654 assert_eq!(
655 store.resolve_resource("not-a-thing.txt"),
656 ResourceOutcome::NotFound
657 );
658 }
659
660 // --- Playlist-rendering content tests (moved from
661 // `multimux::output::llhls`, which now delegates rendering here) ---
662
663 fn plain_seg(seq: u32, parts: u32) -> SegmentInfo {
664 SegmentInfo {
665 bytes: vec![seq as u8; 8],
666 duration: 4.0,
667 segment_seq: seq,
668 part_count: parts,
669 }
670 }
671 fn plain_part(seq: u32, idx: u32) -> PartInfo {
672 PartInfo {
673 bytes: vec![idx as u8; 4],
674 duration: 0.5,
675 independent: idx == 0,
676 segment_seq: seq,
677 part_index: idx,
678 }
679 }
680
681 #[test]
682 fn playlist_has_llhls_tags_and_parts() {
683 let s = MediaStore::new(4.0, 500, 4);
684 s.set_init(vec![0; 4]);
685 s.add_part(plain_part(1, 0));
686 s.add_part(plain_part(1, 1));
687 let m = media_playlist_m3u8(&s, 1);
688 assert!(m.contains("#EXT-X-PART-INF"), "PART-INF present");
689 assert!(
690 m.contains("#EXT-X-SERVER-CONTROL"),
691 "SERVER-CONTROL present"
692 );
693 assert!(m.contains("#EXT-X-PART"), "at least one PART");
694 assert!(
695 m.contains("part-1-1.0.m4s") || m.contains("part-1-1.1.m4s"),
696 "part URI"
697 );
698 }
699
700 #[test]
701 fn open_segment_has_parts_but_no_extinf() {
702 let s = MediaStore::new(4.0, 500, 4);
703 s.set_init(vec![0; 4]);
704 s.add_part(plain_part(1, 0));
705 s.add_part(plain_part(1, 1));
706 let m = media_playlist_m3u8(&s, 1);
707 // The in-progress segment's parts are advertised...
708 assert!(m.contains("#EXT-X-PART"), "at least one PART line");
709 assert!(m.contains("part-1-1.0.m4s"), "part 0 URI present");
710 assert!(m.contains("part-1-1.1.m4s"), "part 1 URI present");
711 // ...but RFC 8216bis §4.4.4.9: no premature #EXTINF/URI for the
712 // not-yet-closed segment itself — "seg-1-1.m4s" must not appear
713 // anywhere (it isn't fetchable; that segment hasn't been closed).
714 assert!(
715 !m.contains("seg-1-1.m4s"),
716 "no full-segment URI for the open segment: {m}"
717 );
718 assert!(
719 !m.contains("#EXTINF"),
720 "no EXTINF for the open segment: {m}"
721 );
722 }
723
724 #[test]
725 fn final_part_fetchable_after_its_segment_closes() {
726 // The segmenter emits a segment's final part and then closes the
727 // segment in the same step. A preload-hint request for that final part
728 // is typically in flight when the close happens, so it must remain
729 // fetchable afterwards (from recent_parts) rather than 404 — the LL-HLS
730 // preload-hint boundary bug.
731 let s = MediaStore::new(4.0, 500, 4);
732 s.set_init(vec![0; 4]);
733 s.add_part(plain_part(1, 0));
734 s.add_part(plain_part(1, 1)); // .1 is this segment's final part
735 s.add_segment(plain_seg(1, 2)); // close segment 1 (moves its parts to recent_parts)
736 assert_eq!(
737 s.resolve_resource("part-1-1.1.m4s"),
738 ResourceOutcome::Ready {
739 bytes: vec![1; 4],
740 cache: CachePolicy::Immutable
741 },
742 "final part of a just-closed segment must still be individually fetchable"
743 );
744 assert_eq!(
745 s.resolve_resource("part-1-1.0.m4s"),
746 ResourceOutcome::Ready {
747 bytes: vec![0; 4],
748 cache: CachePolicy::Immutable
749 },
750 "earlier parts too"
751 );
752 // A genuinely-nonexistent part of the closed segment is NotFound.
753 assert_eq!(
754 s.resolve_resource("part-1-1.9.m4s"),
755 ResourceOutcome::NotFound
756 );
757 // Closing does not resurrect parts into the rendered open segment: the
758 // playlist advertises the whole segment, not its parts.
759 let m = media_playlist_m3u8(&s, 1);
760 assert!(
761 m.contains("seg-1-1.m4s"),
762 "closed segment rendered whole: {m}"
763 );
764 assert!(
765 !m.contains("part-1-1."),
766 "closed parts not rendered as open: {m}"
767 );
768 }
769
770 #[test]
771 fn live_parts_capped_when_segment_never_closes() {
772 // target_duration_secs=4.0, part_target_ms=500 -> cap =
773 // ceil(4.0 / 0.5) + 4 margin = 12 (see
774 // `super::super::store::compute_max_live_parts`).
775 let s = MediaStore::new(4.0, 500, 4);
776 let cap = super::super::store::compute_max_live_parts(4.0, 500);
777 assert_eq!(cap, 12, "sanity-check the expected cap for these params");
778 s.set_init(vec![0; 4]);
779
780 // Push far more parts than the cap into a single never-closed
781 // segment (no add_segment call) — RAM must stay bounded.
782 for i in 0..(cap as u32 * 5) {
783 s.add_part(plain_part(1, i));
784 }
785 assert_eq!(
786 s.live_part_count(),
787 cap,
788 "live_parts must stay capped even though the segment never closed"
789 );
790
791 // The playlist must still render correctly from the capped parts:
792 // only the most recent (highest-index) parts survive.
793 let m = media_playlist_m3u8(&s, 1);
794 assert!(m.contains("#EXT-X-PART"), "still has PART lines: {m}");
795 let last_idx = cap as u32 * 5 - 1;
796 assert!(
797 m.contains(&format!("part-1-1.{last_idx}.m4s")),
798 "most recent part must survive the cap: {m}"
799 );
800 let first_idx = cap as u32 * 5 - cap as u32;
801 assert!(
802 !m.contains(&format!("part-1-1.{}.m4s", first_idx - 1)),
803 "an older part beyond the cap must have been dropped: {m}"
804 );
805 }
806
807 // --- P2 LL-HLS spec-conformance fixes (audit-llhls #1/#2/#3/#4) ---
808
809 #[test]
810 fn target_duration_is_max_of_configured_and_actual_segment_duration() {
811 // Configured target is 4.0s, but the segmenter cuts on the next
812 // keyframe after the target so a real segment can run long (7.5s
813 // here) — RFC 8216bis §4.4.3.1 (MUST) requires TARGETDURATION to be
814 // >= every EXTINF, rounded. The old hardcoded
815 // `ceil(target_duration_secs)` would render `4`, violating the MUST.
816 let s = MediaStore::new(4.0, 500, 4);
817 s.set_init(vec![0; 4]);
818 let mut long_seg = plain_seg(1, 2);
819 long_seg.duration = 7.5;
820 s.add_segment(long_seg);
821 let m = media_playlist_m3u8(&s, 1);
822 assert!(
823 m.contains("#EXT-X-TARGETDURATION:8"),
824 "TARGETDURATION must be round(7.5)=8, not the configured target (4): {m}"
825 );
826 }
827
828 #[test]
829 fn target_duration_falls_back_to_configured_when_segments_are_short() {
830 let s = MediaStore::new(4.0, 500, 4);
831 s.set_init(vec![0; 4]);
832 s.add_segment(plain_seg(1, 2)); // plain_seg's fixed duration is 4.0
833 let m = media_playlist_m3u8(&s, 1);
834 assert!(
835 m.contains("#EXT-X-TARGETDURATION:4"),
836 "unchanged behaviour when no segment exceeds the configured target: {m}"
837 );
838 }
839}