Skip to main content

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}