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