ll_hls_runtime/server/engine.rs
1//! [`LlHlsOrigin`] — the LL-HLS origin [`ServedEgress`] (plan step 4):
2//! blocking-reload/part-availability *decision* logic and playlist rendering,
3//! rendered directly from a shared [`Trunk`] instead of the deleted
4//! `MediaStore` push-fed rolling window.
5//!
6//! Master/media playlist tags are RFC 8216 §4.3.4 (`#EXT-X-STREAM-INF`) and
7//! §4.3.3 (`#EXTM3U`/`#EXT-X-VERSION`, rendered by [`MediaPlaylist::to_m3u8`]);
8//! the blocking reload query parameters (`_HLS_msn`/`_HLS_part`) are the
9//! Blocking Playlist Reload mechanism of RFC 8216bis §6.2.5.2 — the client
10//! asks the origin to hold the response open until the requested Media
11//! Sequence Number/part is available, bounded by the caller's own
12//! [`AwaitPolicy`] so the origin never hangs indefinitely.
13//!
14//! # What comes straight from the `Trunk`, with no cache at all
15//!
16//! Every part-availability and blocking-reload decision reads the `Trunk`
17//! directly, `&self`-shaped, every call:
18//!
19//! - **Live parts of the open segment** — [`Trunk::part_bytes`]/
20//! [`Trunk::parts_in_segment`] (step 3b-iv's live-part log). This is the
21//! whole reason step 3b-iv exists: before it, nothing in `Trunk` could
22//! answer "does part 3 of the segment currently being written exist",
23//! which is exactly what forced `MediaStore` to keep its own
24//! `live_parts`/`recent_parts` buffers in the first place.
25//! - **Whether a segment has closed** — [`Trunk::last_closed_segment`].
26//! - **The "in-progress-or-last-active segment" `MediaStore::latest_progress`
27//! used to track as a push-fed field** — [`LlHlsOrigin::live_edge`] derives
28//! it from the two queries above alone (`last_closed_segment() + 1`, probed
29//! via `parts_in_segment`), needing no field of its own. See that method's
30//! doc for the derivation and why it is exact, not a heuristic.
31//! - **A just-closed segment's final part still resolving** — falls out of
32//! [`Trunk::part_bytes`] for free: [`media_plane::trunk::SegmentWriter::publish_segment`]
33//! deliberately never touches the live-part log (see `trunk`'s own module
34//! doc, "The live-part log"), so this crate no longer needs `MediaStore`'s
35//! separate `recent_parts` buffer at all — that buffer existed *only* to
36//! simulate exactly the guarantee the `Trunk` now gives natively.
37//!
38//! # The one thing that genuinely cannot come from the `Trunk` alone
39//!
40//! [`Trunk::subscribe_segments`] hands back a moving, single-consumer
41//! [`SegmentCursor`] — there is no snapshot query over the segment log the
42//! way [`Trunk::events_between`] gives the event log (see
43//! `media_plane::egress`'s own module doc, "`ServedEgress::resolve` does not
44//! take `&Trunk`", which anticipated exactly this). Rendering a Media
45//! Playlist needs the **window** of currently-advertised closed segments
46//! (their bytes, durations, and discontinuity bits), plus two numbers that
47//! must survive eviction from that window: the lifetime-max segment
48//! duration (RFC 8216bis §4.4.3.1's `TARGETDURATION` MUST) and the
49//! cumulative discontinuity count that has rolled off the front
50//! (`#EXT-X-DISCONTINUITY-SEQUENCE`, RFC 8216 §4.3.3.3). None of that is
51//! answerable by a fresh `&self` call on `Trunk` — it has to be assembled by
52//! draining a cursor over time.
53//!
54//! `Window` is that assembly, and it is **not** a second `MediaStore`: it
55//! holds only bytes/duration/discontinuity-bit for the segments currently in
56//! the advertised window, fed by exactly **one** [`SegmentCursor`] this
57//! `LlHlsOrigin` owns — precisely the shape `media_plane::egress`'s module
58//! doc prescribes ("a `ServedEgress` implementation... keeps its own
59//! resolvable window in sync by draining [cursors]... `resolve` only ever
60//! reads that already-synced state"). It carries none of `MediaStore`'s
61//! other fields (`health`, `track_specs`, `created_at`, `window_segments()`
62//! diagnostics) — those served `multimux`'s DASH/ll-DASH outputs, not
63//! LL-HLS rendering, and are out of this step's scope (Step 5's problem, if
64//! still needed once `multimux` is rewritten).
65//!
66//! The fMP4 **init segment** bytes are the other thing this module holds
67//! outside the `Trunk`: an init segment is neither a sample, a finished
68//! segment, an event, nor a live part — it is produced once by the
69//! segmenter and never changes, so it was never in scope for any of
70//! `Trunk`'s four rings. [`LlHlsOrigin::set_init`] is the (small, honest) side
71//! channel for it — not a duplicate of anything `Trunk` holds.
72
73use std::collections::VecDeque;
74use std::num::NonZeroUsize;
75use std::sync::{Arc, Mutex};
76
77use broadcast_common::Timestamp;
78use bytes::Bytes;
79use media_plane::egress::{AwaitPolicy, CachePolicy, EgressResponse, ServedEgress};
80use media_plane::trunk::{PartEntry, SegmentCursor, SegmentCursorItem, SegmentEntry, Trunk};
81use transmux::hls::{LowLatencyConfig, MediaPlaylist, MediaSegment, OpenSegment, PartSpec};
82
83/// Track id for the single rendition served per stream (no multi-track/
84/// multi-rendition support yet).
85pub const DEFAULT_TRACK_ID: u32 = 1;
86
87/// Placeholder `BANDWIDTH` (bits/second) advertised in the master playlist's
88/// `#EXT-X-STREAM-INF` — actual encoded bitrate isn't measured, so a single
89/// fixed estimate is used for the single variant served.
90const PLACEHOLDER_BANDWIDTH_BPS: u64 = 5_000_000;
91
92/// RFC 8216bis §6.2.5.2 (SHOULD): a `_HLS_msn` unreasonably far in the future
93/// should be rejected rather than always blocking to the caller's timeout — a
94/// legitimate client only ever asks for the segment/part right after the one
95/// it already has, so anything more than a few segments beyond the current
96/// live edge is either a malfunctioning client or abuse.
97const ABUSE_MSN_FUTURE_BOUND: u64 = 4;
98
99/// HLS requires HLS protocol version 9 (RFC 8216bis §4.4.3.7/§4.4.3.8: the
100/// `#EXT-X-PART-INF`/`#EXT-X-PART` directives this renderer always emits
101/// require it).
102const LL_HLS_VERSION: u8 = 9;
103
104/// RFC 8216bis / Apple LL-HLS §4.4.3.7: `#EXT-X-SERVER-CONTROL`'s
105/// `PART-HOLD-BACK` attribute MUST be at least 3x the part target duration
106/// (`#EXT-X-PART-INF`'s `PART-TARGET`).
107const PART_HOLD_BACK_MULTIPLIER: f64 = 3.0;
108
109/// A minimal single-variant master playlist pointing at `media_playlist_name`
110/// (the caller's configured media-playlist filename — e.g. multimux's
111/// `Config::playlist_name`, defaulting to `"media.m3u8"`) — the same
112/// regardless of any stream state (no multi-rendition support yet), so this
113/// takes no `Trunk`/origin argument.
114pub fn master_playlist_m3u8(media_playlist_name: &str) -> String {
115 format!(
116 "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH={PLACEHOLDER_BANDWIDTH_BPS}\n{media_playlist_name}\n"
117 )
118}
119
120/// Blocking playlist reload query parameters (RFC 8216bis §6.2.5.2) — the
121/// sans-IO counterpart of an adapter's own (likely serde-`Deserialize`)
122/// query-string type; the adapter maps its wire query params into this.
123#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
124pub struct BlockingQuery {
125 /// The Media Sequence Number the client already has, plus one — the
126 /// origin should not respond until a segment/part beyond this is ready.
127 pub hls_msn: Option<u64>,
128 /// The part index (within `hls_msn`) the client is waiting for.
129 pub hls_part: Option<u32>,
130}
131
132/// [`ServedEgress::Request`] for [`LlHlsOrigin`]: which wire resource is
133/// being asked for. A data-carrying dispatch ADT (matches this crate's
134/// `client::action::Action`/`ResourceId` convention) — see
135/// `tests/label_coverage.rs`'s SKIP list.
136#[derive(Debug, Clone, PartialEq, Eq)]
137#[non_exhaustive]
138pub enum LlHlsRequest {
139 /// `GET <media playlist>`, optionally carrying a blocking-reload query.
140 Playlist {
141 /// The track id to render the playlist for (a naming parameter only —
142 /// see [`DEFAULT_TRACK_ID`]).
143 track_id: u32,
144 /// The blocking-reload query parameters, if any.
145 query: BlockingQuery,
146 },
147 /// `GET` a dynamic origin resource by its wire filename (`init-{track}.mp4`,
148 /// `seg-{track}-{seq}.m4s`, `part-{track}-{seq}.{idx}.m4s`).
149 Resource {
150 /// The requested filename, exactly as it appeared in the request path.
151 name: String,
152 },
153}
154
155/// [`ServedEgress::Body`] for [`LlHlsOrigin`]: the resolved body, typed by
156/// which [`LlHlsRequest`] produced it. A data-carrying ADT — see
157/// `tests/label_coverage.rs`'s SKIP list.
158#[derive(Debug, Clone, PartialEq, Eq)]
159#[non_exhaustive]
160pub enum LlHlsBody {
161 /// A rendered Media Playlist (`#EXTM3U` text).
162 Playlist(String),
163 /// Resolved resource bytes (init/segment/part).
164 Resource(Bytes),
165}
166
167/// One playlist-window-resident **closed** segment's identity/bytes —
168/// `Window`'s per-entry shape. Deliberately narrower than the old
169/// `MediaStore`'s `SegmentInfo`-derived window entries: this crate only ever
170/// needs bytes + duration + the discontinuity bit to render a Media
171/// Playlist, so that is all this holds.
172struct WindowSegment {
173 sequence_number: u32,
174 bytes: Bytes,
175 duration_secs: f64,
176 discontinuous: bool,
177}
178
179/// The small per-[`LlHlsOrigin`] synced window this module's own doc
180/// ("The one thing that genuinely cannot come from the `Trunk` alone")
181/// explains the need for — fed by draining exactly one [`SegmentCursor`],
182/// never pushed into directly.
183struct Window {
184 segments: VecDeque<WindowSegment>,
185 capacity: usize,
186 /// Largest segment duration ever drained, surviving window eviction —
187 /// RFC 8216bis §4.4.3.1's `TARGETDURATION` MUST holds for *every*
188 /// segment this origin has ever advertised, not just the ones still in
189 /// the window (mirrors the deleted `MediaStore::max_segment_duration`).
190 max_segment_duration_secs: f64,
191 /// Cumulative count of discontinuities that have rolled off the front of
192 /// the window — RFC 8216 §4.3.3.3's `#EXT-X-DISCONTINUITY-SEQUENCE`.
193 /// Incremented exactly once per **evicted** entry whose
194 /// [`WindowSegment::discontinuous`] was `true`; a discontinuity still
195 /// inside the window is rendered as a per-segment `#EXT-X-DISCONTINUITY`
196 /// tag instead (see [`MediaPlaylist::to_m3u8`]), never double-counted
197 /// here.
198 discontinuity_sequence: u64,
199}
200
201impl Window {
202 fn new(capacity: NonZeroUsize) -> Self {
203 Window {
204 segments: VecDeque::new(),
205 capacity: capacity.get(),
206 max_segment_duration_secs: 0.0,
207 discontinuity_sequence: 0,
208 }
209 }
210
211 /// Absorb one drained [`SegmentEntry`], evicting the oldest window entry
212 /// first if already at `capacity` — same evict-then-push shape as every
213 /// ring in `trunk.rs` itself.
214 fn push(&mut self, entry: SegmentEntry) {
215 let duration_secs = entry.duration.as_secs_f64();
216 self.max_segment_duration_secs = self.max_segment_duration_secs.max(duration_secs);
217 if self.segments.len() == self.capacity {
218 if let Some(evicted) = self.segments.pop_front() {
219 if evicted.discontinuous {
220 self.discontinuity_sequence += 1;
221 }
222 }
223 }
224 self.segments.push_back(WindowSegment {
225 sequence_number: entry.sequence_number,
226 bytes: entry.bytes,
227 duration_secs,
228 discontinuous: entry.meta.discontinuous,
229 });
230 }
231
232 fn bytes_of(&self, sequence_number: u32) -> Option<Bytes> {
233 self.segments
234 .iter()
235 .find(|s| s.sequence_number == sequence_number)
236 .map(|s| s.bytes.clone())
237 }
238}
239
240/// Parse a `part-{track}-{seq}.{idx}.m4s` dynamic filename into `(seq, idx)`,
241/// or `None` if it isn't a part filename (or its numeric fields don't parse).
242/// `{track}` is validated but unused (matches every other dynamic-filename
243/// resource in this module).
244fn parse_part(file: &str) -> Option<(u32, u32)> {
245 let rest = file.strip_prefix("part-")?.strip_suffix(".m4s")?;
246 let (track_seq, idx) = rest.rsplit_once('.')?;
247 let (track, seq) = track_seq.split_once('-')?;
248 track.parse::<u32>().ok()?;
249 Some((seq.parse().ok()?, idx.parse().ok()?))
250}
251
252/// Parse a `init-{track}.mp4`/`seg-{track}-{seq}.m4s` dynamic filename;
253/// `part-…` filenames are handled separately by [`parse_part`] (they can
254/// block until available). `{track}` is validated as a number but otherwise
255/// unused: an [`LlHlsOrigin`] holds a single track's data (see
256/// [`DEFAULT_TRACK_ID`]).
257enum ImmediateResource {
258 Init,
259 Segment(u32),
260}
261
262fn parse_immediate(file: &str) -> Option<ImmediateResource> {
263 if let Some(rest) = file.strip_prefix("init-") {
264 let track = rest.strip_suffix(".mp4")?;
265 track.parse::<u32>().ok()?;
266 return Some(ImmediateResource::Init);
267 }
268 if let Some(rest) = file.strip_prefix("seg-") {
269 let rest = rest.strip_suffix(".m4s")?;
270 let (track, seq) = rest.split_once('-')?;
271 track.parse::<u32>().ok()?;
272 return Some(ImmediateResource::Segment(seq.parse().ok()?));
273 }
274 None
275}
276
277/// The LL-HLS origin [`ServedEgress`]: renders playlists and resolves
278/// blocking-reload/part-availability requests for one stream, backed by a
279/// shared [`Trunk`]. See this module's own doc for exactly what comes
280/// straight from the `Trunk` and what needs the small synced `Window`.
281pub struct LlHlsOrigin {
282 trunk: Arc<Trunk>,
283 /// This origin's **one** [`SegmentCursor`] — see [`Trunk::subscribe_segments`]'s
284 /// own docs (and this crate's `media_plane::egress` module doc) for why a
285 /// `ServedEgress` must never take one per request/peer.
286 cursor: Mutex<SegmentCursor>,
287 window: Mutex<Window>,
288 /// The fMP4 init segment — see this module's doc for why this, alone, is
289 /// not answerable by any `Trunk` ring.
290 init: Mutex<Option<Bytes>>,
291 target_duration_secs: f64,
292 part_target_ms: u32,
293}
294
295impl LlHlsOrigin {
296 /// Build a fresh origin over `trunk`, subscribing its one [`SegmentCursor`]
297 /// immediately (so the window starts empty but never misses a segment
298 /// published from this point on).
299 ///
300 /// `window_segments` bounds how many closed segments this origin
301 /// advertises in a rendered Media Playlist — independent of
302 /// [`media_plane::trunk::TrunkConfig::segment_capacity`] (the `Trunk`'s own
303 /// retention bound): a caller may legitimately want a shorter advertised
304 /// window than the `Trunk` retains for other consumers (e.g. a DVR
305 /// `SegmentEgress` reading the same `Trunk`).
306 pub fn new(
307 trunk: Arc<Trunk>,
308 target_duration_secs: f64,
309 part_target_ms: u32,
310 window_segments: NonZeroUsize,
311 ) -> Self {
312 let cursor = trunk.subscribe_segments();
313 LlHlsOrigin {
314 trunk,
315 cursor: Mutex::new(cursor),
316 window: Mutex::new(Window::new(window_segments)),
317 init: Mutex::new(None),
318 target_duration_secs,
319 part_target_ms,
320 }
321 }
322
323 /// Store the fMP4 init segment bytes — see this module's doc for why an
324 /// init segment is not something any `Trunk` ring holds.
325 pub fn set_init(&self, bytes: impl Into<Bytes>) {
326 *self.init.lock().unwrap() = Some(bytes.into());
327 }
328
329 /// The fMP4 init segment bytes, if set.
330 pub fn init_bytes(&self) -> Option<Bytes> {
331 self.init.lock().unwrap().clone()
332 }
333
334 /// Drain this origin's [`SegmentCursor`] into `Window` — called at the
335 /// top of every [`ServedEgress::resolve`] so a render always reflects
336 /// whatever has published since the last call. Non-blocking, bounded by
337 /// however many segments actually published since the last drain.
338 ///
339 /// A [`SegmentCursorItem::Lagged`] report (this origin's `window_segments`/
340 /// polling cadence fell behind the `Trunk`'s own
341 /// `segment_capacity` eviction) is accepted, not treated as an error:
342 /// exactly like every other lossy cursor in this workspace, the honest
343 /// response is to resume from the next segment, not to fabricate the
344 /// lost entries' duration/discontinuity data.
345 fn drain(&self) {
346 let mut cursor = self.cursor.lock().unwrap();
347 let mut window = self.window.lock().unwrap();
348 while let Some(item) = cursor.poll() {
349 if let SegmentCursorItem::Segment(entry) = item {
350 window.push(entry);
351 }
352 }
353 }
354
355 /// `(in-progress-or-last-active segment sequence number, its currently
356 /// resident live parts)` — the `Trunk`-only replacement for the deleted
357 /// `MediaStore::latest_progress`.
358 ///
359 /// Derivation: the only segment that can possibly have live, not-yet-
360 /// closed parts is the one immediately after
361 /// [`Trunk::last_closed_segment`] (a segmenter never opens segment N+2's
362 /// parts before N+1 closes) — so probing exactly that one candidate via
363 /// [`Trunk::parts_in_segment`] is exact, not a heuristic. If that probe
364 /// is empty (nothing has started for the next segment yet — e.g. the
365 /// instant after a close, before its successor's first part lands), the
366 /// answer falls back to `last_closed_segment` itself, with an empty part
367 /// list — exactly the degenerate state `MediaStore::latest_progress`
368 /// also returned right after `add_segment` cleared `live_parts`.
369 fn live_edge(&self) -> (u32, Vec<PartEntry>) {
370 let last_closed = self.trunk.last_closed_segment().unwrap_or(0);
371 let candidate = last_closed + 1;
372 let parts = self.trunk.parts_in_segment(candidate);
373 if parts.is_empty() {
374 (last_closed, Vec::new())
375 } else {
376 (candidate, parts)
377 }
378 }
379
380 /// Render the LL-HLS media playlist for `track_id` from this origin's
381 /// current `Window` (closed segments) and the `Trunk`'s live edge (open
382 /// segment's parts + preload hint).
383 ///
384 /// RFC 8216bis §4.4.4.9: an in-progress (not yet closed) segment MUST NOT
385 /// be advertised with an `#EXTINF`/URI pair — that segment has no
386 /// fetchable resource yet — it may only appear as trailing `#EXT-X-PART`
387 /// lines. `transmux::hls::MediaPlaylist::open_segment` is exactly this
388 /// representation: its parts render as trailing `#EXT-X-PART` lines with
389 /// no `#EXTINF`/URI, so the in-progress segment's parts and the
390 /// `#EXT-X-PRELOAD-HINT` for the next, not-yet-available part are both
391 /// rendered by `to_m3u8()` itself — this method only supplies the URI
392 /// scheme (`part-<track>-<seq>.<idx>.m4s`) and the part metadata.
393 fn render_playlist(&self, track_id: u32) -> String {
394 self.drain();
395 let window = self.window.lock().unwrap();
396 let (open_seq, open_parts) = self.live_edge();
397 // Only render an open segment/preload-hint once the live edge is
398 // genuinely a not-yet-closed segment with at least one live part —
399 // never re-render an already-closed segment's lingering parts (the
400 // `Trunk`'s live-part log deliberately does not evict them on close;
401 // see `trunk`'s own module doc) as if they were still open.
402 let has_open_parts = !open_parts.is_empty();
403
404 let media_sequence = window
405 .segments
406 .front()
407 .map(|s| u64::from(s.sequence_number))
408 .or_else(|| has_open_parts.then_some(u64::from(open_seq)))
409 .unwrap_or(1);
410 let segments: Vec<MediaSegment> = window
411 .segments
412 .iter()
413 .map(|s| MediaSegment {
414 uri: format!("seg-{track_id}-{}.m4s", s.sequence_number),
415 duration: s.duration_secs,
416 discontinuous: s.discontinuous,
417 parts: Vec::new(),
418 ..Default::default()
419 })
420 .collect();
421 let part_target = f64::from(self.part_target_ms) / 1000.0;
422 let open_segment = has_open_parts.then(|| {
423 OpenSegment::new(
424 open_parts
425 .iter()
426 .map(|p| PartSpec {
427 uri: format!("part-{track_id}-{}.{}.m4s", p.segment_number, p.part_index),
428 duration: p.duration.as_secs_f64(),
429 independent: p.independent,
430 ..Default::default()
431 })
432 .collect(),
433 )
434 });
435 let next_part_hint = has_open_parts.then(|| {
436 let next_idx = open_parts
437 .iter()
438 .map(|p| p.part_index)
439 .max()
440 .map(|idx| idx + 1)
441 .unwrap_or(0);
442 format!("part-{track_id}-{open_seq}.{next_idx}.m4s")
443 });
444 // RFC 8216bis §4.4.3.1 (MUST): every Media Segment's EXTINF duration,
445 // rounded to the nearest integer, MUST be <= TARGETDURATION. The
446 // segmenter cuts on the next keyframe *after* the configured target,
447 // so a real segment routinely exceeds it — advertising the
448 // configured target alone can under-declare. Use whichever is
449 // larger, rounded (not the configured value's `ceil()` alone).
450 let target_duration = self
451 .target_duration_secs
452 .max(window.max_segment_duration_secs)
453 .round() as u32;
454 let playlist = MediaPlaylist {
455 version: LL_HLS_VERSION,
456 target_duration,
457 media_sequence,
458 discontinuity_sequence: window.discontinuity_sequence,
459 segments,
460 open_segment,
461 endlist: false,
462 extra_tags: vec![format!("#EXT-X-MAP:URI=\"init-{track_id}.mp4\"")],
463 low_latency: Some(LowLatencyConfig {
464 part_target,
465 part_hold_back: part_target * PART_HOLD_BACK_MULTIPLIER,
466 preload_hint_part: next_part_hint,
467 ..Default::default()
468 }),
469 iframes_only: false,
470 ..Default::default()
471 };
472 playlist.to_m3u8()
473 }
474
475 fn resolve_playlist(
476 &self,
477 track_id: u32,
478 query: BlockingQuery,
479 now: Timestamp,
480 await_policy: AwaitPolicy,
481 ) -> EgressResponse<LlHlsBody> {
482 if query.hls_part.is_some() && query.hls_msn.is_none() {
483 return EgressResponse::BadRequest {
484 reason: "_HLS_part without _HLS_msn is meaningless",
485 };
486 }
487 if let Some(msn) = query.hls_msn {
488 let (in_progress_seg, live_parts) = self.live_edge();
489 if msn > u64::from(in_progress_seg) + ABUSE_MSN_FUTURE_BOUND {
490 return EgressResponse::BadRequest {
491 reason: "_HLS_msn unreasonably far beyond the live edge",
492 };
493 }
494 let satisfied = match query.hls_part {
495 Some(part) => {
496 u64::from(in_progress_seg) > msn
497 || (u64::from(in_progress_seg) == msn
498 && live_parts.len() as u64 > u64::from(part))
499 }
500 None => self.trunk.last_closed_segment().unwrap_or(0) as u64 >= msn,
501 };
502 if !satisfied {
503 return EgressResponse::pending(await_policy, now, now);
504 }
505 }
506 EgressResponse::Ready {
507 body: LlHlsBody::Playlist(self.render_playlist(track_id)),
508 cache: CachePolicy::NoCache,
509 }
510 }
511
512 /// A part request is the preload-hinted Partial Segment a client fetches
513 /// ahead of time (RFC 8216bis §6.2.2, §6.3.1). If the origin promised it
514 /// via `#EXT-X-PRELOAD-HINT` but hasn't produced it yet,
515 /// [`EgressResponse::Await`] — the caller should hold the request open
516 /// (not 404 immediately, which spams errors and defeats low latency).
517 /// [`EgressResponse::NotFound`] is returned **promptly** (without the
518 /// caller needing to wait out its own [`AwaitPolicy`]) once the part can
519 /// no longer appear: its segment has closed (now only addressable as a
520 /// whole segment via `seg-…`) — a legitimate 404 the client answers by
521 /// fetching the next segment/part.
522 fn resolve_resource(
523 &self,
524 name: &str,
525 now: Timestamp,
526 await_policy: AwaitPolicy,
527 ) -> EgressResponse<LlHlsBody> {
528 if let Some((seq, idx)) = parse_part(name) {
529 if let Some(bytes) = self.trunk.part_bytes(seq, idx) {
530 return EgressResponse::Ready {
531 body: LlHlsBody::Resource(bytes),
532 cache: CachePolicy::Immutable,
533 };
534 }
535 // The requested part's segment has already closed (whether or
536 // not this origin's own `Window` still retains its bytes) -> it
537 // will never be produced. `Trunk::last_closed_segment` answers
538 // this exactly, with no dependence on `Window`'s retention.
539 let never_will = self.trunk.last_closed_segment().is_some_and(|c| c >= seq);
540 return if never_will {
541 EgressResponse::NotFound
542 } else {
543 EgressResponse::pending(await_policy, now, now)
544 };
545 }
546 self.drain();
547 let bytes = match parse_immediate(name) {
548 Some(ImmediateResource::Init) => self.init_bytes(),
549 Some(ImmediateResource::Segment(seq)) => self.window.lock().unwrap().bytes_of(seq),
550 None => None,
551 };
552 match bytes {
553 Some(bytes) => EgressResponse::Ready {
554 body: LlHlsBody::Resource(bytes),
555 cache: CachePolicy::Immutable,
556 },
557 None => EgressResponse::NotFound,
558 }
559 }
560}
561
562impl ServedEgress for LlHlsOrigin {
563 type Request = LlHlsRequest;
564 type Body = LlHlsBody;
565
566 fn resolve(
567 &self,
568 request: LlHlsRequest,
569 now: Timestamp,
570 await_policy: AwaitPolicy,
571 ) -> EgressResponse<LlHlsBody> {
572 match request {
573 LlHlsRequest::Playlist { track_id, query } => {
574 self.resolve_playlist(track_id, query, now, await_policy)
575 }
576 LlHlsRequest::Resource { name } => self.resolve_resource(&name, now, await_policy),
577 }
578 }
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584 use media_plane::trunk::TrunkConfig;
585 use std::time::{Duration, Instant};
586 use transmux::SegmentMeta;
587
588 fn nz(n: usize) -> NonZeroUsize {
589 NonZeroUsize::new(n).expect("test capacity must be non-zero")
590 }
591
592 /// A fresh `Trunk` sized generously for these tests, plus the one
593 /// `LlHlsOrigin` under test.
594 fn make_origin() -> (Arc<Trunk>, LlHlsOrigin, media_plane::trunk::SegmentWriter) {
595 let trunk = Trunk::new(TrunkConfig::new(nz(64), nz(8), nz(8), nz(8), nz(64)));
596 let writer = trunk.segment_writer().expect("first segment writer");
597 let origin = LlHlsOrigin::new(Arc::clone(&trunk), 4.0, 500, nz(4));
598 origin.set_init(vec![0xAAu8; 8]);
599 (trunk, origin, writer)
600 }
601
602 fn seg(
603 writer: &media_plane::trunk::SegmentWriter,
604 seq: u32,
605 duration_secs: f64,
606 discontinuous: bool,
607 ) {
608 writer.publish_segment(SegmentEntry::new(
609 Bytes::from(vec![seq as u8; 8]),
610 seq,
611 Duration::from_secs_f64(duration_secs),
612 Timestamp::from_nanos(0),
613 SegmentMeta { discontinuous },
614 ));
615 }
616
617 fn part(writer: &media_plane::trunk::SegmentWriter, seg_no: u32, idx: u32, independent: bool) {
618 writer.publish_part(PartEntry::new(
619 Bytes::from(vec![idx as u8; 4]),
620 seg_no,
621 idx,
622 Duration::from_millis(500),
623 independent,
624 ));
625 }
626
627 fn resolve_now(origin: &LlHlsOrigin, request: LlHlsRequest) -> EgressResponse<LlHlsBody> {
628 origin.resolve(
629 request,
630 Timestamp::from_nanos(0),
631 AwaitPolicy::new(Timestamp::from_nanos(0)),
632 )
633 }
634
635 // --- master playlist (unaffected by the Trunk migration) -------------
636
637 #[test]
638 fn master_playlist_has_stream_inf() {
639 let m = master_playlist_m3u8("media.m3u8");
640 assert!(m.contains("#EXTM3U"));
641 assert!(m.contains("#EXT-X-STREAM-INF"));
642 assert!(m.contains("media.m3u8"));
643 }
644
645 #[test]
646 fn master_playlist_points_at_configured_playlist_name() {
647 let m = master_playlist_m3u8("index.m3u8");
648 assert!(m.contains("index.m3u8"));
649 assert!(!m.contains("media.m3u8"));
650 }
651
652 // --- 1. playlist rendered from a populated Trunk matches the expected
653 // shape ---------------------------------------------------------
654
655 /// MUTATION VERIFIED: changing `render_playlist`'s
656 /// `low_latency: Some(...)` to `None` makes this test's
657 /// `assert!(m.contains("#EXT-X-PART-INF"))` (and every other
658 /// LL-HLS-tag assertion) fail — `to_m3u8()` omits the entire
659 /// low-latency header block when `low_latency` is `None`, so none of
660 /// `#EXT-X-PART-INF`/`#EXT-X-SERVER-CONTROL`/`#EXT-X-PART` appear in the
661 /// rendered body. Recompiled and re-run to confirm the failure, then
662 /// reverted.
663 #[test]
664 fn playlist_rendered_from_populated_trunk_matches_expected_shape() {
665 let (_trunk, origin, writer) = make_origin();
666 seg(&writer, 1, 4.0, false);
667 part(&writer, 2, 0, true);
668 part(&writer, 2, 1, false);
669
670 let body = match resolve_now(
671 &origin,
672 LlHlsRequest::Playlist {
673 track_id: DEFAULT_TRACK_ID,
674 query: BlockingQuery::default(),
675 },
676 ) {
677 EgressResponse::Ready {
678 body: LlHlsBody::Playlist(m),
679 cache,
680 } => {
681 assert_eq!(cache, CachePolicy::NoCache);
682 m
683 }
684 other => panic!("expected Ready(Playlist), got {other:?}"),
685 };
686
687 assert!(body.contains("#EXT-X-VERSION:9"), "body: {body}");
688 assert!(body.contains("#EXT-X-TARGETDURATION:4"), "body: {body}");
689 assert!(
690 body.contains("#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.5"),
691 "body: {body}"
692 );
693 assert!(
694 body.contains("#EXT-X-PART-INF:PART-TARGET=0.5"),
695 "body: {body}"
696 );
697 assert!(
698 body.contains("#EXT-X-MAP:URI=\"init-1.mp4\""),
699 "body: {body}"
700 );
701 assert!(body.contains("seg-1-1.m4s"), "body: {body}");
702 assert!(
703 body.contains("#EXT-X-PART:DURATION=0.5") && body.contains("INDEPENDENT=YES"),
704 "body: {body}"
705 );
706 assert!(body.contains("#EXT-X-PRELOAD-HINT"), "body: {body}");
707 assert!(
708 body.contains("part-1-2.2.m4s"),
709 "preload hint for the next part: {body}"
710 );
711 }
712
713 // --- 2. a preload-hinted part BLOCKS until produced, then serves -----
714
715 /// MUTATION VERIFIED: changing `resolve_resource`'s `never_will` check
716 /// (whether the requested part's segment has already closed, via
717 /// `last_closed_segment`) to always `true` ("never will produce this
718 /// part") makes this test's first assertion fail: the not-yet-produced
719 /// part resolves `NotFound` immediately instead of `Await`, so
720 /// `assert!(matches!(first, EgressResponse::Await { .. }))` sees
721 /// `NotFound` and fails. Recompiled and re-run to confirm the failure,
722 /// then reverted. This is the RFC 8216bis section 6.2.2 behaviour that
723 /// shipped as multimux 0.2.1's bug fix — regressing it would break the
724 /// live camera route.
725 #[test]
726 fn preload_hinted_part_blocks_until_produced_then_serves() {
727 let (trunk, origin, writer) = make_origin();
728 let origin = Arc::new(origin);
729
730 // Not produced yet: must Await, not NotFound.
731 let deadline = Timestamp::from_nanos(5_000_000_000);
732 let policy = AwaitPolicy::new(deadline);
733 let first = origin.resolve(
734 LlHlsRequest::Resource {
735 name: "part-1-1.0.m4s".to_string(),
736 },
737 Timestamp::from_nanos(0),
738 policy,
739 );
740 assert!(
741 matches!(first, EgressResponse::Await { .. }),
742 "expected Await before the part exists, got {first:?}"
743 );
744
745 // Register a real Trunk::listen() wake-up and block a worker thread
746 // on it -- the actual mechanism a real adapter (Step 5) uses, not a
747 // poll loop -- to prove the part genuinely blocks rather than
748 // merely returning Await once and never resolving.
749 let listener = trunk.listen().expect("listener slot available");
750 let woken = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
751 let woken2 = std::sync::Arc::clone(&woken);
752 // HANG GUARD (issue #807): deliberately generous, same reasoning as
753 // `media-plane/src/trunk.rs`'s own `Trunk::listen()` wake tests --
754 // the claim is "wakes rather than parking forever", not "wakes
755 // within N seconds"; the publish happens on another thread, so a
756 // tight bound would measure the machine's scheduler, not this code.
757 let waiter = std::thread::spawn(move || {
758 let ok = listener.wait_deadline(Instant::now() + Duration::from_secs(60));
759 woken2.store(ok, std::sync::atomic::Ordering::SeqCst);
760 });
761
762 // Produce the part the request was waiting on.
763 part(&writer, 1, 0, true);
764
765 waiter.join().expect("waiter thread must not panic");
766 assert!(
767 woken.load(std::sync::atomic::Ordering::SeqCst),
768 "Trunk::listen() must wake once publish_part lands"
769 );
770
771 // Re-resolving now must serve it -- not 404.
772 match origin.resolve(
773 LlHlsRequest::Resource {
774 name: "part-1-1.0.m4s".to_string(),
775 },
776 Timestamp::from_nanos(1),
777 policy,
778 ) {
779 EgressResponse::Ready {
780 body: LlHlsBody::Resource(bytes),
781 cache,
782 } => {
783 assert_eq!(bytes, Bytes::from(vec![0u8; 4]));
784 assert_eq!(cache, CachePolicy::Immutable);
785 }
786 other => panic!("expected Ready once produced, got {other:?}"),
787 }
788 }
789
790 /// MUTATION VERIFIED: removing `EgressResponse::pending`'s expiry check
791 /// (i.e. always returning `Await`) would make a client wait forever for
792 /// a part that will never exist -- this test proves the OTHER half of
793 /// the bound: once `now` reaches the caller's own `AwaitPolicy::deadline`,
794 /// resolve must stop Awaiting. Changing the deadline comparison in
795 /// `resolve_resource`'s `EgressResponse::pending(await_policy, now, now)`
796 /// call to ignore `now` (always pass `Timestamp::from_nanos(0)`) makes
797 /// this test's final assertion fail: `resolve` at `now == deadline`
798 /// keeps returning `Await` instead of `NotFound`. Recompiled and re-run
799 /// to confirm the failure, then reverted.
800 #[test]
801 fn awaiting_part_is_bounded_by_await_policy_deadline() {
802 let (_trunk, origin, _writer) = make_origin();
803 let deadline = Timestamp::from_nanos(1_000_000_000);
804 let policy = AwaitPolicy::new(deadline);
805
806 let still_waiting = origin.resolve(
807 LlHlsRequest::Resource {
808 name: "part-1-9.0.m4s".to_string(),
809 },
810 Timestamp::from_nanos(999_999_999),
811 policy,
812 );
813 assert!(matches!(still_waiting, EgressResponse::Await { .. }));
814
815 let expired = origin.resolve(
816 LlHlsRequest::Resource {
817 name: "part-1-9.0.m4s".to_string(),
818 },
819 deadline,
820 policy,
821 );
822 assert!(
823 matches!(expired, EgressResponse::NotFound),
824 "expected NotFound once the deadline passed, got {expired:?}"
825 );
826 }
827
828 // --- 3. a just-closed segment's final part still serves ---------------
829
830 /// MUTATION VERIFIED: this behaviour depends entirely on
831 /// `media_plane::trunk::SegmentWriter::publish_segment` (`media-plane/src/trunk.rs`) never
832 /// touching the live-part log. Simulating the old `MediaStore` bug here
833 /// by having `resolve_resource` check `last_closed_segment() >= seq`
834 /// ("this segment already closed -> NotFound") **before** checking
835 /// `Trunk::part_bytes` (i.e. swapping the two checks' order) makes this
836 /// test's first assertion fail: the just-closed segment's final part
837 /// resolves `NotFound` instead of `Ready` (`panicked at ...: the
838 /// just-closed segment's final part must still serve, got NotFound`),
839 /// because the eager closed-check now shadows the still-valid
840 /// `part_bytes` hit. Recompiled and re-run to confirm the failure, then
841 /// reverted. This is the RFC 8216bis boundary behaviour that shipped as
842 /// multimux 0.2.2's bug fix — regressing it would break the live camera
843 /// route (its own `#EXT-X-PRELOAD-HINT` part races exactly this
844 /// boundary every segment).
845 #[test]
846 fn just_closed_segment_final_part_still_serves() {
847 let (_trunk, origin, writer) = make_origin();
848 part(&writer, 1, 0, true);
849 part(&writer, 1, 1, false); // segment 1's final part
850 seg(&writer, 1, 4.0, false); // close segment 1
851
852 match resolve_now(
853 &origin,
854 LlHlsRequest::Resource {
855 name: "part-1-1.1.m4s".to_string(),
856 },
857 ) {
858 EgressResponse::Ready {
859 body: LlHlsBody::Resource(bytes),
860 ..
861 } => assert_eq!(bytes, Bytes::from(vec![1u8; 4])),
862 other => panic!("the just-closed segment's final part must still serve, got {other:?}"),
863 }
864
865 // A genuinely-nonexistent part of the closed segment is NotFound.
866 assert_eq!(
867 resolve_now(
868 &origin,
869 LlHlsRequest::Resource {
870 name: "part-1-1.9.m4s".to_string(),
871 }
872 ),
873 EgressResponse::NotFound
874 );
875
876 // The playlist must not resurrect the closed segment's parts as
877 // "open" -- it is rendered whole.
878 let body = match resolve_now(
879 &origin,
880 LlHlsRequest::Playlist {
881 track_id: DEFAULT_TRACK_ID,
882 query: BlockingQuery::default(),
883 },
884 ) {
885 EgressResponse::Ready {
886 body: LlHlsBody::Playlist(m),
887 ..
888 } => m,
889 other => panic!("expected Ready(Playlist), got {other:?}"),
890 };
891 assert!(
892 body.contains("seg-1-1.m4s"),
893 "closed segment rendered whole: {body}"
894 );
895 assert!(
896 !body.contains("part-1-1."),
897 "closed parts not rendered as open: {body}"
898 );
899 }
900
901 // --- 4. MEDIA-SEQUENCE / DISCONTINUITY-SEQUENCE advance as the window
902 // rolls -----------------------------------------------------
903
904 /// MUTATION VERIFIED: changing `Window::push`'s eviction guard from
905 /// `if evicted.discontinuous` to `if false` (never counting an evicted
906 /// discontinuity) makes this test's
907 /// `assert!(body.contains("#EXT-X-DISCONTINUITY-SEQUENCE:1"))` fail --
908 /// the tag is omitted entirely (the renderer only emits it when
909 /// `discontinuity_sequence > 0`), because the counter never advances
910 /// past `0`. Recompiled and re-run to confirm the failure, then
911 /// reverted.
912 #[test]
913 fn media_sequence_and_discontinuity_sequence_advance_as_window_rolls() {
914 let (_trunk, origin, writer) = make_origin(); // window_segments = 4
915
916 seg(&writer, 1, 4.0, false);
917 seg(&writer, 2, 4.0, true); // discontinuous
918 seg(&writer, 3, 4.0, false);
919 seg(&writer, 4, 4.0, false);
920
921 // Window (capacity 4) holds exactly 1..=4 -- MEDIA-SEQUENCE=1, and
922 // segment 2's own #EXT-X-DISCONTINUITY renders in-window (no
923 // DISCONTINUITY-SEQUENCE yet, nothing has rolled off).
924 let body = match resolve_now(
925 &origin,
926 LlHlsRequest::Playlist {
927 track_id: DEFAULT_TRACK_ID,
928 query: BlockingQuery::default(),
929 },
930 ) {
931 EgressResponse::Ready {
932 body: LlHlsBody::Playlist(m),
933 ..
934 } => m,
935 other => panic!("expected Ready(Playlist), got {other:?}"),
936 };
937 assert!(body.contains("#EXT-X-MEDIA-SEQUENCE:1"), "body: {body}");
938 assert!(
939 !body.contains("#EXT-X-DISCONTINUITY-SEQUENCE"),
940 "nothing has rolled off the window yet: {body}"
941 );
942 assert!(body.contains("#EXT-X-DISCONTINUITY\n"), "body: {body}");
943
944 // Roll the window: segment 5 evicts segment 1 (not discontinuous;
945 // DISCONTINUITY-SEQUENCE stays 0), segment 6 evicts segment 2
946 // (discontinuous -- DISCONTINUITY-SEQUENCE becomes 1).
947 seg(&writer, 5, 4.0, false);
948 let body = match resolve_now(
949 &origin,
950 LlHlsRequest::Playlist {
951 track_id: DEFAULT_TRACK_ID,
952 query: BlockingQuery::default(),
953 },
954 ) {
955 EgressResponse::Ready {
956 body: LlHlsBody::Playlist(m),
957 ..
958 } => m,
959 other => panic!("expected Ready(Playlist), got {other:?}"),
960 };
961 assert!(body.contains("#EXT-X-MEDIA-SEQUENCE:2"), "body: {body}");
962 assert!(
963 !body.contains("#EXT-X-DISCONTINUITY-SEQUENCE"),
964 "evicted segment 1 was not discontinuous: {body}"
965 );
966
967 seg(&writer, 6, 4.0, false);
968 let body = match resolve_now(
969 &origin,
970 LlHlsRequest::Playlist {
971 track_id: DEFAULT_TRACK_ID,
972 query: BlockingQuery::default(),
973 },
974 ) {
975 EgressResponse::Ready {
976 body: LlHlsBody::Playlist(m),
977 ..
978 } => m,
979 other => panic!("expected Ready(Playlist), got {other:?}"),
980 };
981 assert!(body.contains("#EXT-X-MEDIA-SEQUENCE:3"), "body: {body}");
982 assert!(
983 body.contains("#EXT-X-DISCONTINUITY-SEQUENCE:1"),
984 "segment 2 (discontinuous) has now rolled off the window: {body}"
985 );
986 }
987
988 // --- misc: target-duration MUST, abuse bound, bad request -------------
989
990 #[test]
991 fn target_duration_is_max_of_configured_and_actual_segment_duration() {
992 let (_trunk, origin, writer) = make_origin(); // configured target 4.0
993 seg(&writer, 1, 7.5, false);
994 let body = match resolve_now(
995 &origin,
996 LlHlsRequest::Playlist {
997 track_id: DEFAULT_TRACK_ID,
998 query: BlockingQuery::default(),
999 },
1000 ) {
1001 EgressResponse::Ready {
1002 body: LlHlsBody::Playlist(m),
1003 ..
1004 } => m,
1005 other => panic!("expected Ready(Playlist), got {other:?}"),
1006 };
1007 assert!(
1008 body.contains("#EXT-X-TARGETDURATION:8"),
1009 "TARGETDURATION must be round(7.5)=8, not the configured target: {body}"
1010 );
1011 }
1012
1013 #[test]
1014 fn far_future_msn_rejected() {
1015 let (_trunk, origin, writer) = make_origin();
1016 seg(&writer, 1, 4.0, false);
1017 let outcome = resolve_now(
1018 &origin,
1019 LlHlsRequest::Playlist {
1020 track_id: DEFAULT_TRACK_ID,
1021 query: BlockingQuery {
1022 hls_msn: Some(1002),
1023 hls_part: None,
1024 },
1025 },
1026 );
1027 assert!(matches!(outcome, EgressResponse::BadRequest { .. }));
1028 }
1029
1030 #[test]
1031 fn part_without_msn_rejected() {
1032 let (_trunk, origin, _writer) = make_origin();
1033 let outcome = resolve_now(
1034 &origin,
1035 LlHlsRequest::Playlist {
1036 track_id: DEFAULT_TRACK_ID,
1037 query: BlockingQuery {
1038 hls_msn: None,
1039 hls_part: Some(0),
1040 },
1041 },
1042 );
1043 assert!(matches!(outcome, EgressResponse::BadRequest { .. }));
1044 }
1045
1046 #[test]
1047 fn resolve_resource_init_present() {
1048 let (_trunk, origin, _writer) = make_origin();
1049 match resolve_now(
1050 &origin,
1051 LlHlsRequest::Resource {
1052 name: "init-1.mp4".to_string(),
1053 },
1054 ) {
1055 EgressResponse::Ready {
1056 body: LlHlsBody::Resource(bytes),
1057 cache,
1058 } => {
1059 assert_eq!(bytes, Bytes::from(vec![0xAAu8; 8]));
1060 assert_eq!(cache, CachePolicy::Immutable);
1061 }
1062 other => panic!("expected Ready, got {other:?}"),
1063 }
1064 }
1065
1066 #[test]
1067 fn resolve_resource_unmatched_filename_not_found() {
1068 let (_trunk, origin, _writer) = make_origin();
1069 assert_eq!(
1070 resolve_now(
1071 &origin,
1072 LlHlsRequest::Resource {
1073 name: "not-a-thing.txt".to_string(),
1074 }
1075 ),
1076 EgressResponse::NotFound
1077 );
1078 }
1079}