Skip to main content

hls_runtime/server/
engine.rs

1//! [`HlsOrigin`] — 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** — [`HlsOrigin::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//! `HlsOrigin` 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. [`HlsOrigin::set_init`] is the (small, honest) side
71//! channel for it — not a duplicate of anything `Trunk` holds.
72
73use std::collections::{BTreeMap, VecDeque};
74use std::num::NonZeroUsize;
75use std::sync::atomic::{AtomicU64, Ordering};
76use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
77use std::time::Duration;
78
79use broadcast_common::{Timestamp, Unpackage};
80use broadcast_hls::{
81    DecimalSeconds, LowLatencyConfig, MapTag, MasterPlaylist, MediaPlaylist, MediaSegment,
82    OpenSegment, PartSpec, Variant,
83};
84use bytes::Bytes;
85use media_plane::egress::{AwaitPolicy, CachePolicy, EgressResponse, ServedEgress};
86use media_plane::trunk::{PartEntry, SegmentCursor, SegmentCursorItem, SegmentEntry, Trunk};
87
88/// [`HlsOrigin`]'s `drained_through` before any drain has run: no real head
89/// (`u32::MAX + 1` at most) equals it, so the first drain always happens.
90const UNDRAINED: u64 = u64::MAX;
91
92/// Process-wide high-water mark of [`next_instance_token`].
93static LAST_INSTANCE: AtomicU64 = AtomicU64::new(0);
94
95/// A token that differs for every [`HlsOrigin`] ever built, in this process
96/// **and across process restarts**: the wall clock in milliseconds, forced
97/// strictly above the previous token handed out (two origins built in the
98/// same millisecond, or after the clock stepped back, still differ). It is
99/// part of every resource name served `immutable` (see
100/// [`HlsOrigin::instance`]).
101fn next_instance_token() -> u64 {
102    let now_ms = std::time::SystemTime::now()
103        .duration_since(std::time::UNIX_EPOCH)
104        .ok()
105        .and_then(|d| u64::try_from(d.as_millis()).ok())
106        .unwrap_or(0);
107    let mut previous = LAST_INSTANCE.load(Ordering::Relaxed);
108    loop {
109        let token = now_ms.max(previous.saturating_add(1));
110        match LAST_INSTANCE.compare_exchange_weak(
111            previous,
112            token,
113            Ordering::AcqRel,
114            Ordering::Relaxed,
115        ) {
116            Ok(_) => return token,
117            Err(actual) => previous = actual,
118        }
119    }
120}
121
122/// Lock `m`, recovering the guard from a poisoned mutex.
123///
124/// Every critical section in this module leaves its data structurally
125/// consistent at each step (plain pushes/pops/assignments), so a panic in
126/// some other thread that held the lock must not turn into a panic in every
127/// later request (audit r09-O4 / T6, issues #1089/#1134).
128fn locked<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
129    m.lock().unwrap_or_else(PoisonError::into_inner)
130}
131
132/// Track id for the single rendition served per stream (no multi-track/
133/// multi-rendition support yet).
134pub const DEFAULT_TRACK_ID: u32 = 1;
135
136/// Which container [`HlsOrigin`] serves segments/parts as — orthogonal to
137/// whether LL-HLS is enabled ([`HlsOriginBuilder::low_latency`]); issue #873.
138///
139/// RFC 8216bis §3.1.1 / §3.1.2 give the two containers different
140/// `#EXT-X-MAP` obligations:
141///
142/// - fMP4 (§3.1.2): "Each fMP4 Segment in a Media Playlist MUST have an
143///   `EXT-X-MAP` tag applied to it" — unconditional, so [`Container::Fmp4`]
144///   always emits one.
145/// - MPEG-2 TS (§3.1.1): "Each Transport Stream Segment MUST contain a PAT
146///   and a PMT, **or** have an `EXT-X-MAP` tag applied to it" — a
147///   disjunction, not a container restriction. `EXT-X-MAP` is legal for TS;
148///   it is not required when the segments carry their own PAT/PMT.
149///
150/// [`Container::MpegTs`] omits `#EXT-X-MAP` **by default**, on the
151/// assumption that segments come from a self-initialising source (e.g.
152/// `transmux`'s TS segmenter, which re-emits PAT+PMT at the head of every
153/// segment) — it does not *forbid* the tag; a future caller feeding
154/// pre-segmented TS without in-band PSI would need a way to opt back in,
155/// which is not implemented here (out of scope for issue #873; the current
156/// wiring never calls `set_init` from a `MpegTs`-configured pipeline, so the
157/// gap has no live caller yet).
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159#[non_exhaustive]
160pub enum Container {
161    /// Fragmented MP4 / CMAF segments (`.m4s`), with an fMP4 init segment
162    /// (`.mp4`) referenced by an always-present `#EXT-X-MAP`.
163    Fmp4,
164    /// Whole MPEG-2 Transport Stream segments (`.ts`), self-initialising
165    /// (in-band PAT/PMT) — no `#EXT-X-MAP`, no init segment served.
166    MpegTs,
167}
168
169impl Container {
170    /// The spec token for this container — `"fmp4"` / `"mpeg-ts"`.
171    pub fn name(&self) -> &'static str {
172        match self {
173            Container::Fmp4 => "fmp4",
174            Container::MpegTs => "mpeg-ts",
175        }
176    }
177
178    /// The dynamic-filename extension (without the leading `.`) this
179    /// container's segments/parts are named with.
180    fn segment_extension(self) -> &'static str {
181        match self {
182            Container::Fmp4 => "m4s",
183            Container::MpegTs => "ts",
184        }
185    }
186}
187
188broadcast_common::impl_spec_display!(Container);
189
190impl Default for Container {
191    /// [`Container::Fmp4`] — preserves every pre-#873 caller's behaviour.
192    fn default() -> Self {
193        Container::Fmp4
194    }
195}
196
197/// Placeholder `BANDWIDTH` (bits/second) advertised in a master playlist's
198/// `#EXT-X-STREAM-INF` only while no closed segment exists to measure a real
199/// peak from (and by the context-free [`master_playlist_m3u8`]).
200const PLACEHOLDER_BANDWIDTH_BPS: u64 = 5_000_000;
201
202/// How many superseded init segments stay resolvable by their versioned name
203/// (`init-{track}-{instance}-{generation}.mp4`): segments and playlists that name an
204/// older generation are still cached by players and CDNs for a while after a
205/// mid-stream codec change.
206const INIT_HISTORY: usize = 8;
207
208/// Nanoseconds per second, for the integer bitrate arithmetic.
209const NANOS_PER_SEC: u128 = 1_000_000_000;
210
211/// RFC 8216bis §6.2.2: the server MUST NOT remove a Media Segment from a live
212/// Playlist if that would leave a Playlist shorter than three times the
213/// Target Duration. A segment is cut at (about) the target duration, so a
214/// window of fewer than three segments cannot satisfy that; the advertised
215/// window is never shorter than this.
216const MIN_WINDOW_SEGMENTS: usize = 3;
217
218/// RFC 8216bis §6.2.2: `EXT-X-PART` tags SHOULD be removed once they are
219/// greater than three Target Durations from the end of the Playlist (and the
220/// part must stay downloadable for as long again).
221const PART_RETENTION_TARGET_DURATIONS: f64 = 3.0;
222
223/// RFC 8216bis §6.2.6 (SHOULD): "Not Found" for a resource the server cannot
224/// find and that is not specified by an `EXT-X-PRELOAD-HINT`. The live-edge
225/// (open) segment is the one after the last closed segment, and a hinted part
226/// is in it or in the one right after it — so a part more than this many
227/// segments past the last closed one is refused rather than held open.
228const FUTURE_PART_SEGMENT_BOUND: u32 = 2;
229
230/// RFC 8216bis §6.2.5.2: the Advance Part Limit is "three divided by the Part
231/// Target Duration if the Part Target Duration is less than one second, or
232/// three otherwise". Expressed against the part target in milliseconds.
233const ADVANCE_PART_LIMIT_NUMERATOR: u64 = 3;
234const MILLIS_PER_SEC: u64 = 1_000;
235
236/// RFC 8216bis §6.2.5.2 (SHOULD): a `_HLS_msn` greater than "the Media
237/// Sequence Number of the last Media Segment in the current Playlist plus
238/// two" should be rejected rather than always blocking to the caller's
239/// timeout — a legitimate client only ever asks for the segment/part right
240/// after the one it already has, so anything more than two segments beyond
241/// the current last closed segment is either a malfunctioning client or abuse.
242const ABUSE_MSN_FUTURE_BOUND: u64 = 2;
243
244/// RFC 8216bis / Apple LL-HLS §4.4.3.7: `#EXT-X-SERVER-CONTROL`'s
245/// `PART-HOLD-BACK` attribute MUST be at least 3x the part target duration
246/// (`#EXT-X-PART-INF`'s `PART-TARGET`).
247const PART_HOLD_BACK_MULTIPLIER: f64 = 3.0;
248
249/// A minimal single-variant master playlist pointing at `media_playlist_name`
250/// (the caller's configured media-playlist filename — e.g. multimux's
251/// `Config::playlist_name`, defaulting to `"media.m3u8"`) — the same
252/// regardless of any stream state (no multi-rendition support yet), so this
253/// takes no `Trunk`/origin argument.
254pub fn master_playlist_m3u8(media_playlist_name: &str) -> String {
255    format!(
256        "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH={PLACEHOLDER_BANDWIDTH_BPS}\n{media_playlist_name}\n"
257    )
258}
259
260/// Blocking playlist reload query parameters (RFC 8216bis §6.2.5.2) — the
261/// sans-IO counterpart of an adapter's own (likely serde-`Deserialize`)
262/// query-string type; the adapter maps its wire query params into this.
263#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
264pub struct BlockingQuery {
265    /// The Media Sequence Number the client already has, plus one — the
266    /// origin should not respond until a segment/part beyond this is ready.
267    pub hls_msn: Option<u64>,
268    /// The part index (within `hls_msn`) the client is waiting for.
269    pub hls_part: Option<u32>,
270}
271
272/// [`ServedEgress::Request`] for [`HlsOrigin`]: which wire resource is
273/// being asked for. A data-carrying dispatch ADT (matches this crate's
274/// `client::action::Action`/`ResourceId` convention) — see
275/// `tests/label_coverage.rs`'s SKIP list.
276#[derive(Debug, Clone, PartialEq, Eq)]
277#[non_exhaustive]
278pub enum HlsRequest {
279    /// `GET <media playlist>`, optionally carrying a blocking-reload query.
280    Playlist {
281        /// The track id to render the playlist for (a naming parameter only —
282        /// see [`DEFAULT_TRACK_ID`]).
283        track_id: u32,
284        /// The blocking-reload query parameters, if any.
285        query: BlockingQuery,
286    },
287    /// `GET` a dynamic origin resource by its wire filename (`init-{track}.mp4`,
288    /// `seg-{track}-{seq}.m4s`, `part-{track}-{seq}.{idx}.m4s`).
289    Resource {
290        /// The requested filename, exactly as it appeared in the request path.
291        name: String,
292    },
293}
294
295/// [`ServedEgress::Body`] for [`HlsOrigin`]: the resolved body, typed by
296/// which [`HlsRequest`] produced it. A data-carrying ADT — see
297/// `tests/label_coverage.rs`'s SKIP list.
298#[derive(Debug, Clone, PartialEq, Eq)]
299#[non_exhaustive]
300pub enum HlsBody {
301    /// A rendered Media Playlist (`#EXTM3U` text).
302    Playlist(String),
303    /// Resolved resource bytes (init/segment/part).
304    Resource(Bytes),
305}
306
307/// One playlist-window-resident **closed** segment's identity/bytes —
308/// `Window`'s per-entry shape. Deliberately narrower than the old
309/// `MediaStore`'s `SegmentInfo`-derived window entries: this crate only ever
310/// needs bytes + duration + the discontinuity bit to render a Media
311/// Playlist, so that is all this holds.
312#[derive(Clone)]
313struct WindowSegment {
314    sequence_number: u32,
315    /// The init-segment generation this segment was cut against (see
316    /// [`InitStore`]).
317    init_gen: u32,
318    bytes: Bytes,
319    duration_secs: f64,
320    discontinuous: bool,
321    /// This segment's `SegmentEntry::timeline_position`, in nanoseconds —
322    /// carried through so [`HlsOrigin::closed_segments`] can hand it to a
323    /// caller doing time-based windowing over a *different* source of
324    /// segments (e.g. multimux's DVR archive, issue #900), without that
325    /// caller needing a second cursor of its own just to learn it.
326    start_ns: u64,
327}
328
329/// One closed segment's identity/metadata as tracked by this origin's
330/// `Window` — [`HlsOrigin::closed_segments`]'s return shape. Deliberately
331/// excludes the segment's bytes: a caller merging this with another
332/// segment source (issue #900) fetches bytes through the normal
333/// [`ServedEgress::resolve`] resource path when it needs them, not through
334/// this snapshot.
335#[derive(Debug, Clone, Copy, PartialEq)]
336#[non_exhaustive]
337pub struct ClosedSegment {
338    /// This segment's sequence number (`_HLS_msn`-addressable).
339    pub sequence_number: u32,
340    /// `SegmentEntry::timeline_position`, in nanoseconds — the `Trunk`'s
341    /// absolute timeline, for time-based windowing across sources that
342    /// share the same `Trunk` (e.g. multimux's DVR archive, whose own
343    /// `IndexEntry::start_pts_ns` is exactly this same clock).
344    pub start_ns: u64,
345    /// Segment duration in seconds.
346    pub duration_secs: f64,
347    /// Whether `#EXT-X-DISCONTINUITY` precedes this segment (RFC 8216
348    /// §4.3.4.3).
349    pub discontinuous: bool,
350    /// The init-segment generation this segment was cut against (see
351    /// [`HlsOrigin::init_name`]).
352    pub init_generation: u32,
353}
354
355impl ClosedSegment {
356    /// The same segment, cut against init generation `generation`.
357    #[must_use]
358    pub fn with_init_generation(mut self, generation: u32) -> Self {
359        self.init_generation = generation;
360        self
361    }
362
363    /// Construct a [`ClosedSegment`] — needed because the type is
364    /// `#[non_exhaustive]` (a struct-literal outside this crate does not
365    /// typecheck), for a caller building test fixtures over the shape
366    /// [`HlsOrigin::closed_segments`] returns (e.g. multimux's own
367    /// catch-up merge tests, issue #900) without depending on this
368    /// module's private `Window`.
369    pub fn new(
370        sequence_number: u32,
371        start_ns: u64,
372        duration_secs: f64,
373        discontinuous: bool,
374    ) -> Self {
375        ClosedSegment {
376            sequence_number,
377            start_ns,
378            duration_secs,
379            discontinuous,
380            init_generation: 1,
381        }
382    }
383}
384
385/// The small per-[`HlsOrigin`] synced window this module's own doc
386/// ("The one thing that genuinely cannot come from the `Trunk` alone")
387/// explains the need for — fed by draining exactly one [`SegmentCursor`],
388/// never pushed into directly.
389struct Window {
390    segments: VecDeque<WindowSegment>,
391    capacity: usize,
392    /// Largest segment duration ever drained, surviving window eviction —
393    /// RFC 8216bis §4.4.3.1's `TARGETDURATION` MUST holds for *every*
394    /// segment this origin has ever advertised, not just the ones still in
395    /// the window (mirrors the deleted `MediaStore::max_segment_duration`).
396    max_segment_duration_secs: f64,
397    /// Highest per-segment bitrate (bits/second, rounded up) ever drained,
398    /// surviving eviction — what a master playlist's `BANDWIDTH` (RFC 8216
399    /// §4.3.4.2: an upper bound of the peak segment bitrate) is built from.
400    /// `0` until a segment with a measurable duration has been drained.
401    peak_bandwidth_bps: u64,
402    /// Cumulative count of discontinuities that have rolled off the front of
403    /// the window — RFC 8216 §4.3.3.3's `#EXT-X-DISCONTINUITY-SEQUENCE`.
404    /// Incremented exactly once per **evicted** entry whose
405    /// [`WindowSegment::discontinuous`] was `true`; a discontinuity still
406    /// inside the window is rendered as a per-segment `#EXT-X-DISCONTINUITY`
407    /// tag instead (see [`MediaPlaylist::to_m3u8`]), never double-counted
408    /// here.
409    discontinuity_sequence: u64,
410    /// The cursor reported lost segments (`Lagged`/`Gap`) since the last
411    /// push: the next segment does not follow the window's last one.
412    gap_pending: bool,
413}
414
415impl Window {
416    fn new(capacity: NonZeroUsize) -> Self {
417        Window {
418            segments: VecDeque::new(),
419            capacity: capacity.get().max(MIN_WINDOW_SEGMENTS),
420            max_segment_duration_secs: 0.0,
421            peak_bandwidth_bps: 0,
422            discontinuity_sequence: 0,
423            gap_pending: false,
424        }
425    }
426
427    /// Record that the cursor lost segments: whatever is pushed next does not
428    /// follow the current window contents.
429    fn note_gap(&mut self) {
430        self.gap_pending = true;
431    }
432
433    /// Drop every window entry (they roll off the front, so their
434    /// discontinuities count towards the discontinuity sequence).
435    fn clear_rolling_off(&mut self) {
436        let discontinuities = self.segments.iter().filter(|s| s.discontinuous).count();
437        self.discontinuity_sequence = self
438            .discontinuity_sequence
439            .saturating_add(u64::try_from(discontinuities).unwrap_or(u64::MAX));
440        self.segments.clear();
441    }
442
443    /// Absorb one drained [`SegmentEntry`], evicting the oldest window entry
444    /// first if already at `capacity` — same evict-then-push shape as every
445    /// ring in `trunk.rs` itself.
446    ///
447    /// A playlist numbers its segments implicitly (`EXT-X-MEDIA-SEQUENCE` +
448    /// index, RFC 8216 §4.3.3.2), so the window must hold consecutive
449    /// sequence numbers. An entry that does not directly follow the last one
450    /// (numbers skipped, a repeat or a decrease), or that follows reported
451    /// lost segments, starts a new run: the old entries are cleared and the
452    /// entry is marked discontinuous (RFC 8216 §4.3.4.3) rather than being
453    /// appended under a wrong implied number.
454    fn push(&mut self, entry: SegmentEntry, init_gen: u32) {
455        let follows = self
456            .segments
457            .back()
458            .is_none_or(|last| last.sequence_number.checked_add(1) == Some(entry.sequence_number));
459        let mut discontinuous = entry.meta.discontinuous;
460        if !follows || self.gap_pending {
461            self.clear_rolling_off();
462            discontinuous = true;
463        }
464        self.gap_pending = false;
465        // RFC 8216bis §4.4.4.5: a different Media Initialization Section
466        // starts a new timeline segment — `EXT-X-DISCONTINUITY` precedes the
467        // first segment that uses it (audit r09-C2, issue #1030).
468        if self
469            .segments
470            .back()
471            .is_some_and(|last| last.init_gen != init_gen)
472        {
473            discontinuous = true;
474        }
475
476        let duration_secs = entry.duration.as_secs_f64();
477        self.max_segment_duration_secs = self.max_segment_duration_secs.max(duration_secs);
478        if let Some(bps) = segment_bandwidth_bps(entry.bytes.len(), entry.duration) {
479            self.peak_bandwidth_bps = self.peak_bandwidth_bps.max(bps);
480        }
481        if self.segments.len() >= self.capacity
482            && let Some(evicted) = self.segments.pop_front()
483            && evicted.discontinuous
484        {
485            self.discontinuity_sequence = self.discontinuity_sequence.saturating_add(1);
486        }
487        self.segments.push_back(WindowSegment {
488            sequence_number: entry.sequence_number,
489            init_gen,
490            bytes: entry.bytes,
491            duration_secs,
492            discontinuous,
493            start_ns: entry.timeline_position.as_nanos(),
494        });
495    }
496
497    /// The bytes of segment `sequence_number`. The window holds consecutive
498    /// sequence numbers (see [`Self::push`]), so the number identifies at
499    /// most one entry; it is addressed by offset from the front and the
500    /// entry's own number is re-checked.
501    fn bytes_of(&self, sequence_number: u32) -> Option<Bytes> {
502        let front = self.segments.front()?.sequence_number;
503        let index = usize::try_from(sequence_number.checked_sub(front)?).ok()?;
504        let segment = self.segments.get(index)?;
505        (segment.sequence_number == sequence_number).then(|| segment.bytes.clone())
506    }
507}
508
509/// A copy of the window state `render_playlist` reads (see
510/// [`HlsOrigin::window_view`]).
511struct WindowView {
512    segments: VecDeque<WindowSegment>,
513    max_segment_duration_secs: f64,
514    discontinuity_sequence: u64,
515}
516
517/// The init segments this origin has served, by generation.
518///
519/// A resource name must always map to the same bytes for `Immutable` to be
520/// true (audit r09-C2, issue #1030), so a changed init segment gets a new
521/// generation and a new versioned name,
522/// `init-{track}-{instance}-{generation}.mp4`; the
523/// bare `init-{track}.mp4` always means "the current one" and is served
524/// [`CachePolicy::NoCache`].
525#[derive(Default)]
526struct InitStore {
527    /// Generation of `current` (`0` = no init set yet); starts at `1`.
528    generation: u32,
529    current: Option<Bytes>,
530    /// The last [`INIT_HISTORY`] generations' bytes, oldest first.
531    history: VecDeque<(u32, Bytes)>,
532    /// `(first origin sequence number cut against it, generation)`, oldest
533    /// first: segment `seq` belongs to the last entry whose first number is
534    /// `<= seq`.
535    starts: VecDeque<(u32, u32)>,
536}
537
538impl InitStore {
539    /// Record `bytes`; a new generation only if they differ from the current
540    /// init. `next_seq` is the first segment not yet closed.
541    fn set(&mut self, bytes: Bytes, next_seq: u32) {
542        if self.current.as_ref() == Some(&bytes) {
543            return;
544        }
545        self.generation = self.generation.saturating_add(1);
546        // The very first init covers every segment ever seen.
547        let first_seq = if self.current.is_none() { 0 } else { next_seq };
548        self.current = Some(bytes.clone());
549        self.history.push_back((self.generation, bytes));
550        self.starts.push_back((first_seq, self.generation));
551        while self.history.len() > INIT_HISTORY {
552            self.history.pop_front();
553        }
554        while self.starts.len() > INIT_HISTORY {
555            self.starts.pop_front();
556        }
557    }
558
559    /// The generation segment `seq` was cut against (`1` before any init).
560    fn generation_for(&self, seq: u32) -> u32 {
561        self.starts
562            .iter()
563            .rev()
564            .find(|(first, _)| *first <= seq)
565            .or_else(|| self.starts.front())
566            .map_or(1, |&(_, generation)| generation)
567    }
568
569    fn bytes_of(&self, generation: u32) -> Option<Bytes> {
570        self.history
571            .iter()
572            .find(|(g, _)| *g == generation)
573            .map(|(_, b)| b.clone())
574    }
575}
576
577/// A segment's bitrate in bits/second, rounded up (so a `BANDWIDTH` built
578/// from it is never below the true value), or `None` for a zero duration or
579/// an overflowing figure.
580fn segment_bandwidth_bps(len: usize, duration: Duration) -> Option<u64> {
581    let nanos = duration.as_nanos();
582    if nanos == 0 {
583        return None;
584    }
585    let bits = u128::try_from(len).ok()?.checked_mul(8)?;
586    let bps = bits.checked_mul(NANOS_PER_SEC)?.div_ceil(nanos);
587    u64::try_from(bps).ok()
588}
589
590/// Parse `part-{track}-{instance}-{seq}.{idx}.{ext}` (instance-named, the form
591/// the playlist uses) or the legacy `part-{track}-{seq}.{idx}.{ext}` into
592/// `(instance, seq, idx)`; `instance` is `None` for the legacy form. `None` if it
593/// isn't a part filename in `container`'s extension or a field does not parse.
594fn parse_part(file: &str, container: Container) -> Option<(Option<u64>, u64, u32)> {
595    let suffix = format!(".{}", container.segment_extension());
596    let rest = file.strip_prefix("part-")?.strip_suffix(suffix.as_str())?;
597    let (names, idx) = rest.rsplit_once('.')?;
598    let (epoch, seq) = parse_epoch_seq(names)?;
599    Some((epoch, seq, idx.parse().ok()?))
600}
601
602/// `{track}-{seq}` or `{track}-{instance}-{seq}` -> `(instance, seq)`; `{track}` is
603/// validated but unused (an [`HlsOrigin`] holds a single track).
604fn parse_epoch_seq(names: &str) -> Option<(Option<u64>, u64)> {
605    let mut fields = names.split('-');
606    fields.next()?.parse::<u32>().ok()?;
607    let first = fields.next()?;
608    match fields.next() {
609        None => Some((None, first.parse().ok()?)),
610        Some(seq) => {
611            if fields.next().is_some() {
612                return None;
613            }
614            Some((Some(first.parse().ok()?), seq.parse().ok()?))
615        }
616    }
617}
618
619/// What [`parse_immediate`] recognised.
620enum ImmediateResource {
621    /// `init-{track}.mp4`: the current init (changes, never `immutable`).
622    CurrentInit,
623    /// `init-{track}-{instance}-{generation}.mp4` (`epoch` is the instance
624    /// token).
625    VersionedInit { epoch: u64, generation: u32 },
626    /// `seg-{track}-{seq}.{ext}` (`epoch: None`, legacy) or
627    /// `seg-{track}-{instance}-{seq}.{ext}` (`epoch` is the instance token).
628    Segment { epoch: Option<u64>, seq: u64 },
629}
630
631/// Parse an `init-…`/`seg-…` dynamic filename; `part-…` filenames are handled
632/// separately by [`parse_part`] (they can block until available).
633///
634/// The init forms are only recognised under [`Container::Fmp4`] — a `MpegTs`
635/// origin's segments are self-initialising (see [`Container`]'s own doc), so
636/// `init-*.mp4` under `MpegTs` is `None` whether or not
637/// [`HlsOrigin::set_init`] was ever called (issue #873's cross-container
638/// refusal: advertised == servable).
639fn parse_immediate(file: &str, container: Container) -> Option<ImmediateResource> {
640    if container == Container::Fmp4
641        && let Some(rest) = file.strip_prefix("init-")
642    {
643        let rest = rest.strip_suffix(".mp4")?;
644        let mut fields = rest.split('-');
645        fields.next()?.parse::<u32>().ok()?;
646        return match (fields.next(), fields.next(), fields.next()) {
647            (None, ..) => Some(ImmediateResource::CurrentInit),
648            (Some(epoch), Some(generation), None) => Some(ImmediateResource::VersionedInit {
649                epoch: epoch.parse().ok()?,
650                generation: generation.parse().ok()?,
651            }),
652            _ => None,
653        };
654    }
655    if let Some(rest) = file.strip_prefix("seg-") {
656        let suffix = format!(".{}", container.segment_extension());
657        let (epoch, seq) = parse_epoch_seq(rest.strip_suffix(suffix.as_str())?)?;
658        return Some(ImmediateResource::Segment { epoch, seq });
659    }
660    None
661}
662
663/// Error returned by [`HlsOriginBuilder::build`] when a required field was
664/// never set — never a silently-defaulted value (issue #873).
665#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
666#[non_exhaustive]
667pub enum HlsOriginBuildError {
668    /// [`HlsOriginBuilder::target_duration_secs`] was never called.
669    #[error("HlsOrigin::builder(...).target_duration_secs(...) is required but was never called")]
670    MissingTargetDurationSecs,
671    /// [`HlsOriginBuilder::window_segments`] was never called.
672    #[error("HlsOrigin::builder(...).window_segments(...) is required but was never called")]
673    MissingWindowSegments,
674    /// [`HlsOriginBuilder::target_duration_secs`] was NaN, infinite, zero or
675    /// negative: `EXT-X-TARGETDURATION` must be a positive integer (RFC
676    /// 8216bis §4.4.3.1).
677    #[error("HlsOrigin::builder(...).target_duration_secs(...) must be finite and positive")]
678    InvalidTargetDuration,
679    /// [`HlsOriginBuilder::low_latency`] was given a part target of `0` ms:
680    /// `#EXT-X-PART-INF`'s `PART-TARGET` must be a positive duration (RFC
681    /// 8216bis §4.4.3.7), and the Advance Part Limit divides by it.
682    #[error("HlsOrigin::builder(...).low_latency(0): the part target must be positive")]
683    ZeroPartTarget,
684    /// [`HlsOriginBuilder::media_sequence_offset`] is so large that
685    /// `offset + u32::MAX` (the largest origin sequence number) would not fit
686    /// a `u64` Media Sequence Number.
687    #[error(
688        "HlsOrigin::builder(...).media_sequence_offset(...) leaves no room for u32 sequence numbers"
689    )]
690    MediaSequenceOffsetTooLarge,
691}
692
693/// Error returned by [`HlsOrigin::master_playlist`].
694#[derive(Debug, PartialEq, thiserror::Error)]
695#[non_exhaustive]
696pub enum HlsMasterError {
697    /// The measured peak segment bitrate does not fit the `u32` `BANDWIDTH`
698    /// of a master playlist variant.
699    #[error("measured BANDWIDTH {bandwidth} exceeds u32")]
700    BandwidthOverflow {
701        /// The measured peak segment bitrate, bits per second.
702        bandwidth: u64,
703    },
704    /// The master playlist could not be rendered (a field carries a
705    /// character forbidden in an HLS quoted string).
706    #[error("master playlist render failed: {0}")]
707    Render(broadcast_hls::Error),
708}
709
710/// Fluent builder for [`HlsOrigin`] (issue #873) — replaces the old
711/// four-positional `HlsOrigin::new` (deleted; this crate is at 0.4.0
712/// unpublished, so there is no compatibility burden), which could not
713/// express "classic HLS, no low latency" at all since `part_target_ms` was a
714/// mandatory positional argument.
715///
716/// ```
717/// # use std::num::NonZeroUsize;
718/// # use std::sync::Arc;
719/// # use hls_runtime::server::{Container, HlsOrigin};
720/// # use media_plane::trunk::{Trunk, TrunkConfig};
721/// # let nz = |n: usize| NonZeroUsize::new(n).unwrap();
722/// # let trunk = Arc::new(Trunk::new(TrunkConfig::new(nz(16), nz(4), nz(8), nz(4), nz(16))));
723/// let classic_ts = HlsOrigin::builder(Arc::clone(&trunk))
724///     .target_duration_secs(6.0)
725///     .window_segments(nz(4))
726///     .container(Container::MpegTs)
727///     // `.low_latency(..)` omitted entirely -> classic HLS.
728///     .build()
729///     .expect("both required fields were set");
730/// # let _ = classic_ts;
731/// ```
732pub struct HlsOriginBuilder {
733    trunk: Arc<Trunk>,
734    target_duration_secs: Option<f64>,
735    window_segments: Option<NonZeroUsize>,
736    container: Container,
737    part_target_ms: Option<u32>,
738    media_sequence_offset: u64,
739    instance: Option<u64>,
740}
741
742impl HlsOriginBuilder {
743    fn new(trunk: Arc<Trunk>) -> Self {
744        HlsOriginBuilder {
745            trunk,
746            target_duration_secs: None,
747            window_segments: None,
748            container: Container::default(),
749            part_target_ms: None,
750            media_sequence_offset: 0,
751            instance: None,
752        }
753    }
754
755    /// `#EXT-X-TARGETDURATION`'s configured floor (RFC 8216bis §4.4.3.1) —
756    /// required; [`HlsOriginBuilder::build`] errors if this is never called.
757    /// The actually-rendered value is raised to the largest real segment
758    /// duration seen, if that ever exceeds this (see `render_playlist`).
759    pub fn target_duration_secs(mut self, target_duration_secs: f64) -> Self {
760        self.target_duration_secs = Some(target_duration_secs);
761        self
762    }
763
764    /// How many closed segments this origin advertises in a rendered Media
765    /// Playlist — required; independent of
766    /// [`media_plane::trunk::TrunkConfig::segment_capacity`] (the `Trunk`'s
767    /// own retention bound): a caller may legitimately want a shorter
768    /// advertised window than the `Trunk` retains for other consumers (e.g. a
769    /// DVR `SegmentEgress` reading the same `Trunk`).
770    pub fn window_segments(mut self, window_segments: NonZeroUsize) -> Self {
771        self.window_segments = Some(window_segments);
772        self
773    }
774
775    /// Which container this origin serves segments/parts as. Defaults to
776    /// [`Container::Fmp4`] if never called, matching every pre-#873 caller's
777    /// behaviour. Orthogonal to [`Self::low_latency`] — all four
778    /// `{Fmp4, MpegTs} x {classic, low-latency}` combinations are valid.
779    pub fn container(mut self, container: Container) -> Self {
780        self.container = container;
781        self
782    }
783
784    /// Opt into LL-HLS: `part_target_ms` becomes `#EXT-X-PART-INF`'s
785    /// `PART-TARGET` (milliseconds). **Omit this call entirely for classic
786    /// HLS** — no `#EXT-X-PART`/`#EXT-X-PART-INF`/`#EXT-X-SERVER-CONTROL`/
787    /// `#EXT-X-PRELOAD-HINT` tags are then rendered, regardless of
788    /// [`Self::container`]. This is what the old constructor's mandatory
789    /// `part_target_ms` positional could not express.
790    pub fn low_latency(mut self, part_target_ms: u32) -> Self {
791        self.part_target_ms = Some(part_target_ms);
792        self
793    }
794
795    /// Add `offset` to every Media Sequence Number this origin shows: the
796    /// playlist's `EXT-X-MEDIA-SEQUENCE`, the numbers in segment/part URIs and
797    /// the `_HLS_msn` it answers to. Default `0`.
798    ///
799    /// A fresh [`Trunk`] restarts its segment numbers at `1`; the offset is
800    /// the mechanism that keeps the Media Sequence Number a client sees
801    /// increasing across a replacement origin (RFC 8216bis §6.2.2: it MUST NOT
802    /// decrease). Pass `previous.next_media_sequence()` — not `- 1`: the number
803    /// the previous origin's *open* segment was shown with is skipped too, as
804    /// its parts were already served (see [`HlsOrigin::next_media_sequence`]).
805    pub fn media_sequence_offset(mut self, offset: u64) -> Self {
806        self.media_sequence_offset = offset;
807        self
808    }
809
810    /// Override the per-origin [`HlsOrigin::instance`] token (default: a fresh
811    /// wall-clock-seeded one per build). For deterministic tests and for an
812    /// embedder that wants names stable across a rebuild it knows is the
813    /// *same* media.
814    pub fn instance(mut self, token: u64) -> Self {
815        self.instance = Some(token);
816        self
817    }
818
819    /// Build the [`HlsOrigin`], subscribing its one [`SegmentCursor`]
820    /// immediately (so the window starts empty but never misses a segment
821    /// published from this point on).
822    ///
823    /// Errors, never silently defaults, if [`Self::target_duration_secs`] or
824    /// [`Self::window_segments`] was never called, for a zero
825    /// [`Self::low_latency`] part target, or for an offset that cannot hold
826    /// the `u32` sequence range.
827    ///
828    /// The advertised window is never shorter than three segments, whatever
829    /// [`Self::window_segments`] says (RFC 8216bis §6.2.2: a live Playlist
830    /// MUST NOT be shortened below three Target Durations). Part retention is
831    /// not checked here: the part ring's capacity belongs to the [`Trunk`]
832    /// and is not observable from it; the playlist only advertises the parts
833    /// of a closed segment while all of them are still resident.
834    pub fn build(self) -> Result<HlsOrigin, HlsOriginBuildError> {
835        let target_duration_secs = self
836            .target_duration_secs
837            .ok_or(HlsOriginBuildError::MissingTargetDurationSecs)?;
838        if !target_duration_secs.is_finite() || target_duration_secs <= 0.0 {
839            return Err(HlsOriginBuildError::InvalidTargetDuration);
840        }
841        let window_segments = self
842            .window_segments
843            .ok_or(HlsOriginBuildError::MissingWindowSegments)?;
844        if self.part_target_ms == Some(0) {
845            return Err(HlsOriginBuildError::ZeroPartTarget);
846        }
847        if self
848            .media_sequence_offset
849            .checked_add(u64::from(u32::MAX))
850            .is_none()
851        {
852            return Err(HlsOriginBuildError::MediaSequenceOffsetTooLarge);
853        }
854        let cursor = self.trunk.subscribe_segments();
855        Ok(HlsOrigin {
856            trunk: self.trunk,
857            cursor: Mutex::new(cursor),
858            drained_through: AtomicU64::new(UNDRAINED),
859            window: Mutex::new(Window::new(window_segments)),
860            init: Mutex::new(InitStore::default()),
861            codecs: Mutex::new(None),
862            target_duration_secs,
863            container: self.container,
864            part_target_ms: self.part_target_ms,
865            media_sequence_offset: self.media_sequence_offset,
866            instance: self.instance.unwrap_or_else(next_instance_token),
867        })
868    }
869}
870
871/// The LL-HLS origin [`ServedEgress`]: renders playlists and resolves
872/// blocking-reload/part-availability requests for one stream, backed by a
873/// shared [`Trunk`]. See this module's own doc for exactly what comes
874/// straight from the `Trunk` and what needs the small synced `Window`.
875pub struct HlsOrigin {
876    trunk: Arc<Trunk>,
877    /// This origin's **one** [`SegmentCursor`] — see [`Trunk::subscribe_segments`]'s
878    /// own docs (and this crate's `media_plane::egress` module doc) for why a
879    /// `ServedEgress` must never take one per request/peer.
880    cursor: Mutex<SegmentCursor>,
881    /// `last closed segment + 1` (`0` = none) at the end of the last full
882    /// drain; [`Self::drain`] returns at once while the `Trunk` still shows
883    /// the same head. [`UNDRAINED`] before the first drain.
884    drained_through: AtomicU64,
885    // Lock order (never the reverse): `cursor` -> `window` -> `init`;
886    // `codecs` is taken alone. No `Trunk` call is made while `window` is held
887    // except inside `drain` (the cursor poll).
888    window: Mutex<Window>,
889    /// The fMP4 init segment — see this module's doc for why this, alone, is
890    /// not answerable by any `Trunk` ring.
891    init: Mutex<InitStore>,
892    /// The `CODECS` value (RFC 6381, comma-joined) for the master playlist,
893    /// known once track specs have been supplied; `None` = unknown, so the
894    /// attribute is omitted rather than invented.
895    codecs: Mutex<Option<String>>,
896    target_duration_secs: f64,
897    container: Container,
898    /// `Some(part_target_ms)` enables LL-HLS; `None` renders classic HLS —
899    /// no `#EXT-X-PART`/`#EXT-X-PART-INF`/`#EXT-X-SERVER-CONTROL`/
900    /// `#EXT-X-PRELOAD-HINT` at all, orthogonal to [`Self::container`] (issue
901    /// #873).
902    part_target_ms: Option<u32>,
903    /// Added to every shown Media Sequence Number (see
904    /// [`HlsOriginBuilder::media_sequence_offset`]); validated at build so
905    /// `offset + u32::MAX` fits a `u64`.
906    media_sequence_offset: u64,
907    /// See [`Self::instance`].
908    instance: u64,
909}
910
911impl HlsOrigin {
912    /// Start building an [`HlsOrigin`] over `trunk` — see [`HlsOriginBuilder`]
913    /// for the required fields (`target_duration_secs`/`window_segments`),
914    /// the container choice, and how to opt into LL-HLS.
915    pub fn builder(trunk: Arc<Trunk>) -> HlsOriginBuilder {
916        HlsOriginBuilder::new(trunk)
917    }
918
919    /// Store the init segment bytes — see this module's doc for why an init
920    /// segment is not something any `Trunk` ring holds.
921    ///
922    /// **Documented no-op under [`Container::MpegTs`]**: this origin's
923    /// `MpegTs` grammar has no init resource and never emits `#EXT-X-MAP`
924    /// by default (see [`Container`]'s own doc), so bytes stored here are
925    /// never advertised or served in that mode. The method stays callable
926    /// regardless of container so a caller sharing one code path across
927    /// both (e.g. a segmenter that always calls `set_init` once available)
928    /// does not need to branch on which container it configured.
929    ///
930    /// Under [`Container::Fmp4`] the init segment is also parsed for its
931    /// tracks, from which the master playlist's `CODECS` is derived (see
932    /// [`Self::master_playlist`]); bytes that do not parse (or a track whose
933    /// codec string cannot be built) make `CODECS` unknown (omitted, and any
934    /// value from an earlier init is dropped), never guessed at.
935    pub fn set_init(&self, bytes: impl Into<Bytes>) {
936        let bytes = bytes.into();
937        if self.container == Container::Fmp4 {
938            let derived = transmux::Fmp4Demux::new()
939                .unpackage(bytes.as_ref())
940                .ok()
941                .map(|media| {
942                    media
943                        .tracks
944                        .into_iter()
945                        .map(|t| t.spec)
946                        .collect::<Vec<transmux::TrackSpec>>()
947                })
948                .and_then(|specs| self.set_track_specs(&specs).ok());
949            if derived.is_none() {
950                // A new init that cannot be read must not leave the previous
951                // init's codecs advertised.
952                *locked(&self.codecs) = None;
953            }
954        }
955        // The first segment not yet closed is the first one cut against
956        // these bytes: call this before publishing it.
957        let next_seq = self
958            .trunk
959            .last_closed_segment()
960            .unwrap_or(0)
961            .saturating_add(1);
962        locked(&self.init).set(bytes, next_seq);
963    }
964
965    /// Tell the origin which tracks it serves, so the master playlist can
966    /// carry `CODECS` (RFC 8216 §4.3.4.2: every `EXT-X-STREAM-INF` SHOULD) —
967    /// derived from each track's codec config with the RFC 6381 builders
968    /// `transmux` uses for DASH. Opaque data/subtitle tracks have no codec
969    /// string and are left out. [`Self::set_init`] does this itself for
970    /// fMP4; call this for a [`Container::MpegTs`] origin, whose segments
971    /// carry no init segment to read the codecs from.
972    ///
973    /// # Errors
974    /// The `transmux` error for a track whose codec string cannot be built
975    /// (for example a malformed AAC `esds`); `CODECS` is then left as it was.
976    pub fn set_track_specs(&self, specs: &[transmux::TrackSpec]) -> Result<(), transmux::Error> {
977        let mut ordered: Vec<&transmux::TrackSpec> = specs.iter().collect();
978        ordered.sort_by_key(|spec| spec.track_id);
979        let mut codecs: Vec<String> = Vec::new();
980        for spec in ordered {
981            match transmux::rfc6381_codec_string(&spec.config) {
982                Ok(codec) => codecs.push(codec),
983                Err(transmux::Error::UnsupportedCodec { .. }) => {}
984                Err(other) => return Err(other),
985            }
986        }
987        *locked(&self.codecs) = (!codecs.is_empty()).then(|| codecs.join(","));
988        Ok(())
989    }
990
991    /// The master playlist for this origin, pointing at `media_playlist_name`:
992    /// one `#EXT-X-STREAM-INF` whose `BANDWIDTH` is the highest segment
993    /// bitrate this origin has published (RFC 8216 §4.3.4.2: an upper bound
994    /// of the peak segment bitrate, measured as segment bytes over its
995    /// duration, rounded up) and whose `CODECS` comes from
996    /// [`Self::set_init`]/[`Self::set_track_specs`] (omitted while unknown).
997    /// Until a segment with a measurable duration has closed there is
998    /// nothing to measure and `BANDWIDTH` is the fixed 5 Mb/s estimate
999    /// [`master_playlist_m3u8`] uses.
1000    ///
1001    /// # Errors
1002    /// [`HlsMasterError::BandwidthOverflow`] when the measured `BANDWIDTH`
1003    /// does not fit the `u32` of a master playlist variant;
1004    /// [`HlsMasterError::Render`] when the playlist cannot be rendered.
1005    pub fn master_playlist(&self, media_playlist_name: &str) -> Result<String, HlsMasterError> {
1006        self.drain();
1007        let measured = locked(&self.window).peak_bandwidth_bps;
1008        let bandwidth = if measured == 0 {
1009            PLACEHOLDER_BANDWIDTH_BPS
1010        } else {
1011            measured
1012        };
1013        let bandwidth = u32::try_from(bandwidth)
1014            .map_err(|_| HlsMasterError::BandwidthOverflow { bandwidth })?;
1015        let master = MasterPlaylist {
1016            variants: vec![Variant {
1017                bandwidth,
1018                codecs: locked(&self.codecs).clone(),
1019                uri: media_playlist_name.to_string(),
1020                ..Default::default()
1021            }],
1022            ..Default::default()
1023        };
1024        master.to_m3u8().map_err(HlsMasterError::Render)
1025    }
1026
1027    /// This origin's instance token: a number that differs for every origin
1028    /// built, in this process and across restarts (see
1029    /// [`HlsOriginBuilder::instance`]), and is part of **every resource name
1030    /// served `immutable`** — `init-{track}-{instance}-{generation}.mp4`,
1031    /// `seg-{track}-{instance}-{msn}.{ext}`,
1032    /// `part-{track}-{instance}-{msn}.{idx}.{ext}`. A name therefore maps to
1033    /// one origin's bytes for ever: a reconnect (new `Trunk`, numbers
1034    /// restarting, possibly a different SPS/ASC), a process restart with no
1035    /// DVR, a replacement origin reusing the open segment's number — none can
1036    /// put new bytes under a name a cache may already hold. Playlists name
1037    /// these URIs, so players are unaffected. A request carrying another
1038    /// instance's token is `NotFound`. The unnamed legacy forms
1039    /// (`seg-{track}-{msn}`, `part-{track}-{msn}.{idx}`, used by DASH/Smooth
1040    /// templates that cannot carry the token) still resolve but are
1041    /// [`CachePolicy::NoCache`]; the bare `init-{track}.mp4` is the current
1042    /// init, also [`CachePolicy::NoCache`].
1043    pub fn instance(&self) -> u64 {
1044        self.instance
1045    }
1046
1047    /// The wire name of init generation `generation` for `track_id`.
1048    pub fn init_name(&self, track_id: u32, generation: u32) -> String {
1049        format!("init-{track_id}-{}-{generation}.mp4", self.instance)
1050    }
1051
1052    fn segment_name(&self, track_id: u32, seq: u32, ext: &str) -> String {
1053        format!(
1054            "seg-{track_id}-{}-{}.{ext}",
1055            self.instance,
1056            self.public_msn(seq)
1057        )
1058    }
1059
1060    fn part_name(&self, track_id: u32, seq: u32, idx: u32, ext: &str) -> String {
1061        format!(
1062            "part-{track_id}-{}-{}.{idx}.{ext}",
1063            self.instance,
1064            self.public_msn(seq)
1065        )
1066    }
1067
1068    /// The offset added to every Media Sequence Number this origin shows (see
1069    /// [`HlsOriginBuilder::media_sequence_offset`]): the number in a segment's
1070    /// URI/playlist is this plus its origin sequence number
1071    /// ([`ClosedSegment::sequence_number`]).
1072    pub fn media_sequence_offset(&self) -> u64 {
1073        self.media_sequence_offset
1074    }
1075
1076    /// The Media Sequence Number the next closed segment will be shown with
1077    /// (last closed segment + 1, plus the configured offset).
1078    ///
1079    /// A fresh [`Trunk`] numbers its segments from `1` again, so an origin
1080    /// replacing this one over a new `Trunk` (a reconnect) must pass
1081    /// `next_media_sequence()` to
1082    /// [`HlsOriginBuilder::media_sequence_offset`]: the new origin's first
1083    /// segment (sequence number one) is then shown one above this value — the
1084    /// number this origin's *open* segment was shown with is skipped, since
1085    /// its parts were already served — and the Media Sequence Number clients
1086    /// see keeps increasing (RFC 8216bis §6.2.2).
1087    pub fn next_media_sequence(&self) -> u64 {
1088        self.public_msn(
1089            self.trunk
1090                .last_closed_segment()
1091                .unwrap_or(0)
1092                .saturating_add(1),
1093        )
1094    }
1095
1096    /// The Media Sequence Number shown for origin sequence number `seq`.
1097    fn public_msn(&self, seq: u32) -> u64 {
1098        // `media_sequence_offset + u32::MAX` fits a `u64` (checked in `build`).
1099        self.media_sequence_offset.saturating_add(u64::from(seq))
1100    }
1101
1102    /// The origin sequence number a shown Media Sequence Number refers to, or
1103    /// `None` when it lies outside the `u32` range this origin numbers in.
1104    fn internal_seq(&self, public: u64) -> Option<u32> {
1105        u32::try_from(public.checked_sub(self.media_sequence_offset)?).ok()
1106    }
1107
1108    /// How many parts past the live edge a request may name (RFC 8216bis
1109    /// §6.2.5.2's Advance Part Limit): three over the part target in seconds
1110    /// when that is under one second, else three. Zero when this origin is
1111    /// not low-latency.
1112    fn advance_part_limit(&self) -> u64 {
1113        match self.part_target_ms {
1114            None | Some(0) => 0,
1115            Some(ms) if u64::from(ms) < MILLIS_PER_SEC => {
1116                (ADVANCE_PART_LIMIT_NUMERATOR * MILLIS_PER_SEC).div_ceil(u64::from(ms))
1117            }
1118            Some(_) => ADVANCE_PART_LIMIT_NUMERATOR,
1119        }
1120    }
1121
1122    /// The fMP4 init segment bytes, if set.
1123    pub fn init_bytes(&self) -> Option<Bytes> {
1124        locked(&self.init).current.clone()
1125    }
1126
1127    /// Drain this origin's [`SegmentCursor`] into `Window` — called at the
1128    /// top of every [`ServedEgress::resolve`] so a render always reflects
1129    /// whatever has published since the last call. Non-blocking, bounded by
1130    /// however many segments actually published since the last drain.
1131    ///
1132    /// A [`SegmentCursorItem::Lagged`] report (this origin's `window_segments`/
1133    /// polling cadence fell behind the `Trunk`'s own
1134    /// `segment_capacity` eviction) is accepted, not treated as an error:
1135    /// exactly like every other lossy cursor in this workspace, the honest
1136    /// response is to resume from the next segment, not to fabricate the
1137    /// lost entries' duration/discontinuity data.
1138    fn drain(&self) {
1139        // Nothing published since the last drain: skip both locks. The head
1140        // is read *before* draining and stored after, so a segment published
1141        // in between just makes the next call drain once more (audit r09-O4).
1142        let head = self
1143            .trunk
1144            .last_closed_segment()
1145            .map_or(0, |n| u64::from(n).saturating_add(1));
1146        if self.drained_through.load(Ordering::Acquire) == head {
1147            return;
1148        }
1149        let mut cursor = locked(&self.cursor);
1150        let mut window = locked(&self.window);
1151        while let Some(item) = cursor.poll() {
1152            match item {
1153                SegmentCursorItem::Segment(entry) => {
1154                    let init_gen = locked(&self.init).generation_for(entry.sequence_number);
1155                    window.push(entry, init_gen);
1156                }
1157                // `Lagged`/`Gap`/anything newer: segments were lost, so the
1158                // next one does not follow the window's contents.
1159                _ => window.note_gap(),
1160            }
1161        }
1162        self.drained_through.store(head, Ordering::Release);
1163    }
1164
1165    /// What `render_playlist` needs from the window, copied out so the window
1166    /// lock is not held while the `Trunk` is queried. Segment bytes are
1167    /// `Bytes` (reference-counted), so the copy shares them.
1168    fn window_view(&self) -> WindowView {
1169        let w = locked(&self.window);
1170        WindowView {
1171            segments: w.segments.clone(),
1172            max_segment_duration_secs: w.max_segment_duration_secs,
1173            discontinuity_sequence: w.discontinuity_sequence,
1174        }
1175    }
1176
1177    /// A snapshot of this origin's currently-advertised closed segments
1178    /// (drains the cursor first, same as `render_playlist`) — ascending by
1179    /// sequence number.
1180    ///
1181    /// Exists for a caller that needs to merge this origin's live window
1182    /// with a *different* source of segments over the same numbering
1183    /// (multimux's DVR archive, issue #900: catch-up serving must present
1184    /// one continuous playlist spanning the archive and the still-live
1185    /// tail that hasn't been archived yet). Reuses the one cursor `drain`
1186    /// already maintains rather than making the caller open a second
1187    /// cursor on the same `Trunk` just to learn the same window
1188    /// `render_playlist` itself renders — `media_plane`'s own module doc:
1189    /// writer cost is O(N) in cursor count, so a cursor is per distinct
1190    /// consumer, never per peer, and never duplicated for data another
1191    /// cursor already tracks.
1192    pub fn closed_segments(&self) -> Vec<ClosedSegment> {
1193        self.drain();
1194        locked(&self.window)
1195            .segments
1196            .iter()
1197            .map(|s| ClosedSegment {
1198                sequence_number: s.sequence_number,
1199                start_ns: s.start_ns,
1200                duration_secs: s.duration_secs,
1201                discontinuous: s.discontinuous,
1202                init_generation: s.init_gen,
1203            })
1204            .collect()
1205    }
1206
1207    /// `(in-progress-or-last-active segment sequence number, its currently
1208    /// resident live parts)` — the `Trunk`-only replacement for the deleted
1209    /// `MediaStore::latest_progress`.
1210    ///
1211    /// Derivation: the only segment that can possibly have live, not-yet-
1212    /// closed parts is the one immediately after
1213    /// [`Trunk::last_closed_segment`] (a segmenter never opens segment N+2's
1214    /// parts before N+1 closes) — so probing exactly that one candidate via
1215    /// [`Trunk::parts_in_segment`] is exact, not a heuristic. If that probe
1216    /// is empty (nothing has started for the next segment yet — e.g. the
1217    /// instant after a close, before its successor's first part lands), the
1218    /// answer falls back to `last_closed_segment` itself, with an empty part
1219    /// list — exactly the degenerate state `MediaStore::latest_progress`
1220    /// also returned right after `add_segment` cleared `live_parts`.
1221    fn live_edge(&self) -> (u32, Vec<PartEntry>) {
1222        let last_closed = self.trunk.last_closed_segment().unwrap_or(0);
1223        let candidate = last_closed.saturating_add(1);
1224        let parts = self.trunk.parts_in_segment(candidate);
1225        if parts.is_empty() {
1226            (last_closed, Vec::new())
1227        } else {
1228            (candidate, parts)
1229        }
1230    }
1231
1232    /// The `EXT-X-PART` entries of a closed segment, taken from the parts the
1233    /// `Trunk` still holds. Empty unless *all* of them are resident, as a
1234    /// run `0..n`: a part ring that already evicted some of this segment's
1235    /// parts cannot back the playlist's promise that they stay downloadable
1236    /// (RFC 8216bis §6.2.2), and a run with a hole would number the parts
1237    /// wrongly.
1238    fn resident_part_specs(&self, track_id: u32, seq: u32, ext: &str) -> Vec<PartSpec> {
1239        // Keyed by index: a stale entry reusing a number is overwritten by
1240        // the newer one (publish order).
1241        let by_index: BTreeMap<u32, PartEntry> = self
1242            .trunk
1243            .parts_in_segment(seq)
1244            .into_iter()
1245            .map(|p| (p.part_index, p))
1246            .collect();
1247        let contiguous = by_index
1248            .keys()
1249            .copied()
1250            .zip(0u32..)
1251            .all(|(index, expected)| index == expected);
1252        if by_index.is_empty() || !contiguous {
1253            return Vec::new();
1254        }
1255        by_index
1256            .values()
1257            .map(|p| PartSpec {
1258                uri: self.part_name(track_id, p.segment_number, p.part_index, ext),
1259                // `Duration::as_secs_f64`: finite and >= 0 (issue #1140).
1260                duration: DecimalSeconds::new(p.duration.as_secs_f64())
1261                    .expect("Duration::as_secs_f64 is finite, >= 0"),
1262                independent: p.independent,
1263                ..Default::default()
1264            })
1265            .collect()
1266    }
1267
1268    /// Render the LL-HLS media playlist for `track_id` from this origin's
1269    /// current `Window` (closed segments) and the `Trunk`'s live edge (open
1270    /// segment's parts + preload hint).
1271    ///
1272    /// RFC 8216bis §4.4.4.9: an in-progress (not yet closed) segment MUST NOT
1273    /// be advertised with an `#EXTINF`/URI pair — that segment has no
1274    /// fetchable resource yet — it may only appear as trailing `#EXT-X-PART`
1275    /// lines. `broadcast_hls::MediaPlaylist::open_segment` is exactly this
1276    /// representation: its parts render as trailing `#EXT-X-PART` lines with
1277    /// no `#EXTINF`/URI, so the in-progress segment's parts and the
1278    /// `#EXT-X-PRELOAD-HINT` for the next, not-yet-available part are both
1279    /// rendered by `to_m3u8()` itself — this method only supplies the URI
1280    /// scheme (`part-<track>-<seq>.<idx>.m4s`) and the part metadata.
1281    /// Returns the rendered playlist, or a human-readable reason the
1282    /// playlist could not be rendered (a field carrying a character
1283    /// forbidden in an HLS quoted-string/URI — audit BH-W7, issue
1284    /// #1111; unreachable for this origin, which only ever supplies
1285    /// its own generated URIs).
1286    fn render_playlist(&self, track_id: u32) -> core::result::Result<String, String> {
1287        self.drain();
1288        // Snapshot the window and release its lock before any `Trunk` call:
1289        // `live_edge`, the part lookups and the event lookups below take the
1290        // `Trunk`'s own lock, which must not nest inside this origin's
1291        // (audit r09-O4).
1292        let window = self.window_view();
1293        let (open_seq, open_parts) = self.live_edge();
1294        // Only render an open segment/preload-hint once the live edge is
1295        // genuinely a not-yet-closed segment with at least one live part —
1296        // never re-render an already-closed segment's lingering parts (the
1297        // `Trunk`'s live-part log deliberately does not evict them on close;
1298        // see `trunk`'s own module doc) as if they were still open.
1299        // Classic HLS (no `.low_latency(...)` call, issue #873) never
1300        // advertises an in-progress segment at all — RFC 8216bis §4.4.4.9's
1301        // trailing-`#EXT-X-PART`-only representation is itself an LL-HLS
1302        // directive, so it is gated on low latency being enabled, not merely
1303        // on the Trunk happening to have live parts.
1304        let low_latency_enabled = self.part_target_ms.is_some();
1305        let has_open_parts = low_latency_enabled && !open_parts.is_empty();
1306        let ext = self.container.segment_extension();
1307
1308        let media_sequence = window
1309            .segments
1310            .front()
1311            .map(|s| self.public_msn(s.sequence_number))
1312            .or_else(|| has_open_parts.then(|| self.public_msn(open_seq)))
1313            .unwrap_or_else(|| self.public_msn(1));
1314        // RFC 8216bis §4.4.3.1 (MUST): every Media Segment's EXTINF duration,
1315        // rounded to the nearest integer, MUST be <= TARGETDURATION. The
1316        // segmenter cuts on the next keyframe *after* the configured target,
1317        // so a real segment routinely exceeds it — advertising the
1318        // configured target alone can under-declare. Use whichever is
1319        // larger, rounded (not the configured value's `ceil()` alone).
1320        let target_duration = self
1321            .target_duration_secs
1322            .max(window.max_segment_duration_secs);
1323        // `target_duration_secs` is validated finite and positive at build
1324        // and segment durations come from `Duration`, so this is finite;
1325        // the clamp makes the float->int conversion exact-or-saturating.
1326        let target_duration = target_duration.round().clamp(0.0, f64::from(u32::MAX)) as u32;
1327        // RFC 8216bis §6.2.2: EXT-X-PART tags stay in the Playlist until they
1328        // are more than three Target Durations from its end — so a closed
1329        // segment keeps its parts while the media after it (later closed
1330        // segments plus the open segment's parts) is within that distance.
1331        let retention_secs = PART_RETENTION_TARGET_DURATIONS * f64::from(target_duration);
1332        let mut behind_secs: f64 = if has_open_parts {
1333            open_parts.iter().map(|p| p.duration.as_secs_f64()).sum()
1334        } else {
1335            0.0
1336        };
1337        let mut closed_parts: Vec<Vec<PartSpec>> = Vec::with_capacity(window.segments.len());
1338        for s in window.segments.iter().rev() {
1339            let keep = low_latency_enabled && behind_secs <= retention_secs;
1340            closed_parts.push(if keep {
1341                self.resident_part_specs(track_id, s.sequence_number, ext)
1342            } else {
1343                Vec::new()
1344            });
1345            behind_secs += s.duration_secs;
1346        }
1347        closed_parts.reverse();
1348        let fmp4 = self.container == Container::Fmp4;
1349        let map_for = |generation: u32| {
1350            fmp4.then(|| MapTag {
1351                uri: self.init_name(track_id, generation),
1352                byte_range: None,
1353                extra_attrs: Vec::new(),
1354            })
1355        };
1356        let segments: Vec<MediaSegment> = window
1357            .segments
1358            .iter()
1359            .zip(closed_parts)
1360            .map(|(s, parts)| MediaSegment {
1361                map: map_for(s.init_gen),
1362                uri: self.segment_name(track_id, s.sequence_number, ext),
1363                // `duration_secs` is always `Duration::as_secs_f64()`
1364                // (issue #1140): a `std::time::Duration` can never be
1365                // negative or non-finite.
1366                duration: DecimalSeconds::new(s.duration_secs)
1367                    .expect("Duration::as_secs_f64 is finite, >= 0"),
1368                discontinuous: s.discontinuous,
1369                parts,
1370                ..Default::default()
1371            })
1372            .collect();
1373        // The open segment is cut against the init generation that covers its
1374        // sequence number (`#EXT-X-MAP` carries forward, so this repeats the
1375        // last closed segment's unless the init changed in between).
1376        let open_gen = locked(&self.init).generation_for(open_seq);
1377        let open_segment = has_open_parts.then(|| {
1378            let open = OpenSegment::new(
1379                open_parts
1380                    .iter()
1381                    .map(|p| PartSpec {
1382                        uri: self.part_name(track_id, p.segment_number, p.part_index, ext),
1383                        // `p.duration` is a `std::time::Duration`: always
1384                        // finite and non-negative (issue #1140).
1385                        duration: DecimalSeconds::new(p.duration.as_secs_f64())
1386                            .expect("Duration::as_secs_f64 is finite, >= 0"),
1387                        independent: p.independent,
1388                        ..Default::default()
1389                    })
1390                    .collect(),
1391            );
1392            match map_for(open_gen) {
1393                Some(map) => open.with_map(map),
1394                None => open,
1395            }
1396        });
1397        let next_part_hint = has_open_parts.then(|| {
1398            let next_idx = open_parts
1399                .iter()
1400                .map(|p| p.part_index)
1401                .max()
1402                .map(|idx| idx.saturating_add(1))
1403                .unwrap_or(0);
1404            self.part_name(track_id, open_seq, next_idx, ext)
1405        });
1406        // `#EXT-X-MAP`: unconditional under Fmp4 (RFC 8216bis §3.1.2 MUST);
1407        // omitted under MpegTs by default (§3.1.1's PAT/PMT-or-MAP
1408        // disjunction — see `Container`'s own doc for why this is a default,
1409        // not a hard restriction). Then any SCTE-35 cues published to the
1410        // trunk's event ring for this window render as `#EXT-X-DATERANGE`
1411        // tag lines (issue #965): they carry a wall-clock `START-DATE` only
1412        // once the trunk's `time_anchor` has been set, so events are
1413        // silently skipped while the anchor is absent (their `to_daterange`
1414        // fails the same way it does for a non-SCTE-35 source).
1415        // The map rides on the segments (each one names the init generation
1416        // it was cut against, so a changed init is announced together with its
1417        // `EXT-X-DISCONTINUITY`); only a playlist with no segment at all
1418        // carries it as a bare tag.
1419        let mut extra_tags = Vec::new();
1420        if fmp4 && segments.is_empty() && open_segment.is_none() {
1421            let generation = locked(&self.init).generation.max(1);
1422            extra_tags.push(format!(
1423                "#EXT-X-MAP:URI=\"{}\"",
1424                self.init_name(track_id, generation)
1425            ));
1426        }
1427        if let Some(anchor) = self.trunk.time_anchor() {
1428            let timeline = timed_metadata::Timeline::with_anchor(anchor);
1429            for seg in &window.segments {
1430                for entry in self.trunk.events_in_segment(seg.sequence_number) {
1431                    if let Ok(dr) = timeline.to_daterange(&entry.event) {
1432                        // Same treatment as `to_daterange` above (issue #1140):
1433                        // an event whose id/attributes can't be rendered as a
1434                        // valid DATERANGE (e.g. `"`/CR/LF from an upstream
1435                        // segmentation_upid, or a non-finite duration) is
1436                        // skipped for this window rather than corrupting the
1437                        // playlist or aborting the whole render.
1438                        if let Ok(tag) = dr.to_tag_line() {
1439                            extra_tags.push(tag);
1440                        }
1441                    }
1442                }
1443            }
1444        }
1445        let low_latency = low_latency_enabled.then(|| {
1446            let part_target_ms = self
1447                .part_target_ms
1448                .expect("low_latency_enabled implies Some");
1449            // `part_target_ms: u32` / 1000.0 (issue #1140): always finite
1450            // and non-negative, as is the product with the positive
1451            // `PART_HOLD_BACK_MULTIPLIER` constant.
1452            let part_target = DecimalSeconds::new(f64::from(part_target_ms) / 1000.0)
1453                .expect("u32 / 1000.0 is finite, >= 0");
1454            let part_hold_back = DecimalSeconds::new(part_target.get() * PART_HOLD_BACK_MULTIPLIER)
1455                .expect("finite, >= 0 times a positive constant is finite, >= 0");
1456            LowLatencyConfig {
1457                part_target: Some(part_target),
1458                part_hold_back: Some(part_hold_back),
1459                preload_hint_part: next_part_hint,
1460                ..Default::default()
1461            }
1462        });
1463        let playlist = MediaPlaylist {
1464            // No explicit floor: `broadcast_hls::MediaPlaylist::to_m3u8`
1465            // computes `EXT-X-VERSION` from the content actually emitted
1466            // (RFC 8216bis §8) rather than this origin choosing a value
1467            // ahead of time (issue #871) — none of the LL-HLS directives
1468            // below (`EXT-X-PART`/`EXT-X-PART-INF`/`EXT-X-PRELOAD-HINT`/
1469            // `EXT-X-SERVER-CONTROL`) carry any version requirement at all.
1470            target_duration,
1471            media_sequence,
1472            discontinuity_sequence: window.discontinuity_sequence,
1473            segments,
1474            open_segment,
1475            endlist: false,
1476            extra_tags,
1477            low_latency,
1478            iframes_only: false,
1479            ..Default::default()
1480        };
1481        playlist.to_m3u8().map_err(|error| error.to_string())
1482    }
1483
1484    fn resolve_playlist(
1485        &self,
1486        track_id: u32,
1487        query: BlockingQuery,
1488        now: Timestamp,
1489        await_policy: AwaitPolicy,
1490    ) -> EgressResponse<HlsBody> {
1491        if query.hls_part.is_some() && query.hls_msn.is_none() {
1492            return EgressResponse::BadRequest {
1493                reason: "_HLS_part without _HLS_msn is meaningless",
1494            };
1495        }
1496        if let Some(msn) = query.hls_msn {
1497            let (in_progress_seg, live_parts) = self.live_edge();
1498            let in_progress = self.public_msn(in_progress_seg);
1499            if msn > in_progress.saturating_add(ABUSE_MSN_FUTURE_BOUND) {
1500                return EgressResponse::BadRequest {
1501                    reason: "_HLS_msn unreasonably far beyond the live edge",
1502                };
1503            }
1504            // RFC 8216bis §6.2.5.2 (SHOULD): an `_HLS_part` that exceeds the
1505            // last Partial Segment in the Playlist by the Advance Part Limit
1506            // is a Bad Request at once, not a request to park. Measured
1507            // against the live-edge segment's parts (none exist yet for a
1508            // later `_HLS_msn`).
1509            if let Some(part) = query.hls_part
1510                && msn >= in_progress
1511            {
1512                let existing = if msn == in_progress {
1513                    u64::try_from(live_parts.len()).unwrap_or(u64::MAX)
1514                } else {
1515                    0
1516                };
1517                let limit = existing
1518                    .saturating_sub(1)
1519                    .saturating_add(self.advance_part_limit());
1520                if u64::from(part) > limit {
1521                    return EgressResponse::BadRequest {
1522                        reason: "_HLS_part beyond the Advance Part Limit",
1523                    };
1524                }
1525            }
1526            let satisfied = match query.hls_part {
1527                Some(part) => {
1528                    in_progress > msn
1529                        || (in_progress == msn
1530                            && u64::try_from(live_parts.len()).unwrap_or(u64::MAX)
1531                                > u64::from(part))
1532                }
1533                None => self.public_msn(self.trunk.last_closed_segment().unwrap_or(0)) >= msn,
1534            };
1535            if !satisfied {
1536                return EgressResponse::pending(await_policy, now, now);
1537            }
1538        }
1539        match self.render_playlist(track_id) {
1540            Ok(playlist) => EgressResponse::Ready {
1541                body: HlsBody::Playlist(playlist),
1542                cache: CachePolicy::NoCache,
1543            },
1544            // A field the renderer would have to quote carries a character
1545            // forbidden there (audit BH-W7, issue #1111) — unreachable for
1546            // this origin's own generated URIs, but never emitted mangled.
1547            // Answered as a plain miss, never a debug panic: a hostile or
1548            // malformed data-driven field must not take the origin down.
1549            Err(_) => EgressResponse::NotFound,
1550        }
1551    }
1552
1553    /// A part request is the preload-hinted Partial Segment a client fetches
1554    /// ahead of time (RFC 8216bis §6.2.2, §6.3.1). If the origin promised it
1555    /// via `#EXT-X-PRELOAD-HINT` but hasn't produced it yet,
1556    /// [`EgressResponse::Await`] — the caller should hold the request open
1557    /// (not 404 immediately, which spams errors and defeats low latency).
1558    /// [`EgressResponse::NotFound`] is returned **promptly** (without the
1559    /// caller needing to wait out its own [`AwaitPolicy`]) once the part can
1560    /// no longer appear: its segment has closed (now only addressable as a
1561    /// whole segment via `seg-…`) — a legitimate 404 the client answers by
1562    /// fetching the next segment/part.
1563    fn resolve_resource(
1564        &self,
1565        name: &str,
1566        now: Timestamp,
1567        await_policy: AwaitPolicy,
1568    ) -> EgressResponse<HlsBody> {
1569        if let Some((epoch, public_seq, idx)) = parse_part(name, self.container) {
1570            // Another instance's token never resolves here; the legacy
1571            // (token-less) form resolves but is not `immutable`.
1572            if epoch.is_some_and(|e| e != self.instance) {
1573                return EgressResponse::NotFound;
1574            }
1575            let Some(seq) = self.internal_seq(public_seq) else {
1576                return EgressResponse::NotFound;
1577            };
1578            if let Some(bytes) = self.trunk.part_bytes(seq, idx) {
1579                return EgressResponse::Ready {
1580                    body: HlsBody::Resource(bytes),
1581                    cache: if epoch.is_some() {
1582                        CachePolicy::Immutable
1583                    } else {
1584                        CachePolicy::NoCache
1585                    },
1586                };
1587            }
1588            // A classic (non-LL) origin has no parts at all.
1589            if self.part_target_ms.is_none() {
1590                return EgressResponse::NotFound;
1591            }
1592            // The requested part's segment has already closed (whether or
1593            // not this origin's own `Window` still retains its bytes) -> it
1594            // will never be produced. `Trunk::last_closed_segment` answers
1595            // this exactly, with no dependence on `Window`'s retention.
1596            let never_will = self.trunk.last_closed_segment().is_some_and(|c| c >= seq);
1597            if never_will {
1598                return EgressResponse::NotFound;
1599            }
1600            // Only a part a client could have been hinted at is worth holding
1601            // a request open for (RFC 8216bis §6.2.6): the hinted next part,
1602            // in the open segment or the one after it. Anything else is not
1603            // found at once rather than parked for the whole blocking
1604            // timeout.
1605            let last_closed = self.trunk.last_closed_segment().unwrap_or(0);
1606            if seq > last_closed.saturating_add(FUTURE_PART_SEGMENT_BOUND) {
1607                return EgressResponse::NotFound;
1608            }
1609            // Exactly one part is ever hinted: the one after the last part
1610            // the ring holds for that segment (index 0 when it holds none).
1611            // A lower index that is not resident was evicted (or never
1612            // existed) and will not come back; a higher one was never hinted.
1613            let next_hinted = self
1614                .trunk
1615                .parts_in_segment(seq)
1616                .iter()
1617                .map(|p| p.part_index)
1618                .max()
1619                .map_or(0, |last| last.saturating_add(1));
1620            if idx != next_hinted {
1621                return EgressResponse::NotFound;
1622            }
1623            return EgressResponse::pending(await_policy, now, now);
1624        }
1625        self.drain();
1626        // `Immutable` only where the name can never map to other bytes: the
1627        // instance-named init/segment forms (see `Self::instance`). The
1628        // token-less forms and the bare `init-{track}.mp4` can.
1629        let (bytes, cache) = match parse_immediate(name, self.container) {
1630            Some(ImmediateResource::CurrentInit) => (self.init_bytes(), CachePolicy::NoCache),
1631            Some(ImmediateResource::VersionedInit { epoch, generation })
1632                if epoch == self.instance =>
1633            {
1634                (
1635                    locked(&self.init).bytes_of(generation),
1636                    CachePolicy::Immutable,
1637                )
1638            }
1639            Some(ImmediateResource::Segment { epoch, seq })
1640                if epoch.is_none_or(|e| e == self.instance) =>
1641            {
1642                (
1643                    self.internal_seq(seq)
1644                        .and_then(|s| locked(&self.window).bytes_of(s)),
1645                    if epoch.is_some() {
1646                        CachePolicy::Immutable
1647                    } else {
1648                        CachePolicy::NoCache
1649                    },
1650                )
1651            }
1652            _ => (None, CachePolicy::NoCache),
1653        };
1654        match bytes {
1655            Some(bytes) => EgressResponse::Ready {
1656                body: HlsBody::Resource(bytes),
1657                cache,
1658            },
1659            None => EgressResponse::NotFound,
1660        }
1661    }
1662}
1663
1664impl ServedEgress for HlsOrigin {
1665    type Request = HlsRequest;
1666    type Body = HlsBody;
1667
1668    fn resolve(
1669        &self,
1670        request: HlsRequest,
1671        now: Timestamp,
1672        await_policy: AwaitPolicy,
1673    ) -> EgressResponse<HlsBody> {
1674        match request {
1675            HlsRequest::Playlist { track_id, query } => {
1676                self.resolve_playlist(track_id, query, now, await_policy)
1677            }
1678            HlsRequest::Resource { name } => self.resolve_resource(&name, now, await_policy),
1679        }
1680    }
1681}
1682
1683#[cfg(test)]
1684mod tests {
1685    use super::*;
1686    use media_plane::trunk::{EventAnchor, TrunkConfig};
1687    use std::time::{Duration, Instant};
1688    use timed_metadata::{MediaTime, TimeAnchor};
1689    use transmux::SegmentMeta;
1690
1691    fn nz(n: usize) -> NonZeroUsize {
1692        NonZeroUsize::new(n).expect("test capacity must be non-zero")
1693    }
1694
1695    /// A fresh `Trunk` sized generously for these tests, plus the one
1696    /// `HlsOrigin` under test — `Fmp4` + low-latency, matching every
1697    /// pre-#873 test in this module (regression guard).
1698    fn make_origin() -> (Arc<Trunk>, HlsOrigin, media_plane::trunk::SegmentWriter) {
1699        let trunk = Trunk::new(TrunkConfig::new(nz(64), nz(8), nz(8), nz(8), nz(64)));
1700        let writer = trunk.segment_writer().expect("first segment writer");
1701        let origin = HlsOrigin::builder(Arc::clone(&trunk))
1702            .target_duration_secs(4.0)
1703            .window_segments(nz(4))
1704            .low_latency(500)
1705            .instance(7)
1706            .build()
1707            .expect("both required fields set");
1708        origin.set_init(vec![0xAAu8; 8]);
1709        (trunk, origin, writer)
1710    }
1711
1712    fn seg(
1713        writer: &media_plane::trunk::SegmentWriter,
1714        seq: u32,
1715        duration_secs: f64,
1716        discontinuous: bool,
1717    ) {
1718        writer
1719            .publish_segment(SegmentEntry::new(
1720                Bytes::from(vec![seq as u8; 8]),
1721                seq,
1722                Duration::from_secs_f64(duration_secs),
1723                Timestamp::from_nanos(0),
1724                SegmentMeta { discontinuous },
1725            ))
1726            .unwrap();
1727    }
1728
1729    fn part(writer: &media_plane::trunk::SegmentWriter, seg_no: u32, idx: u32, independent: bool) {
1730        writer.publish_part(PartEntry::new(
1731            Bytes::from(vec![idx as u8; 4]),
1732            seg_no,
1733            idx,
1734            Duration::from_millis(500),
1735            independent,
1736        ));
1737    }
1738
1739    fn resolve_now(origin: &HlsOrigin, request: HlsRequest) -> EgressResponse<HlsBody> {
1740        origin.resolve(
1741            request,
1742            Timestamp::from_nanos(0),
1743            AwaitPolicy::new(Timestamp::from_nanos(0)),
1744        )
1745    }
1746
1747    // --- EXT-X-DATERANGE rendering from the trunk event ring (#965) -----
1748
1749    /// MUTATION VERIFIED: dropping the `events_in_segment` loop (or the
1750    /// `time_anchor()` gate) from `render_playlist` makes all three
1751    /// assertions below fail — the playlist no longer carries the
1752    /// `#EXT-X-DATERANGE` line, its `SCTE35-OUT=` attribute, or its
1753    /// `ID="2002"`. `if let Ok(dr)` (rather than `unwrap`) is the deliberate
1754    /// skip, not asserted here, because `time_anchor` is set in this test so
1755    /// no event legitimately fails conversion.
1756    #[test]
1757    fn trunk_events_render_as_ext_x_daterange() {
1758        // splice_insert, event_id=2002, pts=2_160_000 (~24s at 90kHz) — the
1759        // same fixture timed-metadata's own timeline tests use.
1760        let hex = "FC302100000000000000FFF01005000007D27FEF7F7E0020F580C0000000000088B9661D";
1761        let splice_bytes: Vec<u8> = (0..hex.len())
1762            .step_by(2)
1763            .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
1764            .collect();
1765
1766        let (trunk, origin, writer) = make_origin();
1767
1768        // Give the event log a wall-clock anchor so drafts can carry a
1769        // START-DATE, and a media-clock one so the event resolves immediately.
1770        writer.set_time_anchor(TimeAnchor {
1771            pts_90k: 0,
1772            utc_epoch_ms: 1_000_000_000_000,
1773        });
1774
1775        // Parse the splice into a TimedEvent and publish it to the event ring,
1776        // Media-anchored at its own PTS (2_160_000, unwrapped on first call).
1777        let mut timeline = timed_metadata::Timeline::new();
1778        let ev = timeline.push_scte35(&splice_bytes).unwrap();
1779        let trunk_writer = trunk.writer().expect("first trunk writer");
1780        trunk_writer.publish_event(
1781            ev.clone(),
1782            EventAnchor::Media(ev.at.unwrap_or(MediaTime(0))),
1783        );
1784
1785        // Segment 1 spans [0, 4_000_000) — includes the cue's PTS.
1786        writer.note_segment_start(1, MediaTime(0));
1787        writer.note_segment_start(2, MediaTime(4_000_000));
1788        seg(&writer, 1, 4.0, false);
1789
1790        let playlist = origin.render_playlist(DEFAULT_TRACK_ID).expect("render");
1791        assert!(
1792            playlist.contains("#EXT-X-DATERANGE"),
1793            "playlist should contain EXT-X-DATERANGE, got: {playlist}"
1794        );
1795        assert!(
1796            playlist.contains("SCTE35-OUT="),
1797            "daterange should carry SCTE35-OUT attribute: {playlist}"
1798        );
1799        assert!(
1800            playlist.contains("ID=\"2002\""),
1801            "daterange ID should be the event_id: {playlist}"
1802        );
1803    }
1804
1805    /// MUTATION VERIFIED: replacing the `if let Ok(dr)` guard in
1806    /// `render_playlist` with a bare `timeline.to_daterange(&entry.event)`
1807    /// (i.e. unwrapping the `Err`) makes this test fail — an Emsg-sourced
1808    /// event that `to_daterange` rejects ("event is not SCTE-35-sourced")
1809    /// would panic `render_playlist` instead of being skipped. This test
1810    /// pins that skip: with a time anchor set and the event inside the
1811    /// segment span, no `#EXT-X-DATERANGE` line may appear (and rendering
1812    /// must not panic).
1813    #[test]
1814    fn non_scte35_events_are_silently_skipped() {
1815        use timed_metadata::event::{EventKind, SourcePayload};
1816        use timed_metadata::{MediaDuration, TimedEvent};
1817
1818        let (trunk, origin, writer) = make_origin();
1819        writer.set_time_anchor(TimeAnchor {
1820            pts_90k: 0,
1821            utc_epoch_ms: 1_000_000_000_000,
1822        });
1823
1824        // An Emsg-sourced event, Media-anchored inside segment 1's span — the
1825        // kinds of event that legitimately cannot become a DATERANGE.
1826        let emsg_ev = TimedEvent {
1827            id: Some(7),
1828            kind: EventKind::Unspecified,
1829            at: Some(MediaTime(2_000_000)),
1830            duration: Some(MediaDuration(2_000_000)),
1831            source: SourcePayload::Emsg {
1832                scheme_id_uri: "urn:example".to_string(),
1833                value: "1".to_string(),
1834                raw: vec![0x01, 0x02],
1835            },
1836        };
1837        let trunk_writer = trunk.writer().expect("first trunk writer");
1838        trunk_writer.publish_event(emsg_ev, EventAnchor::Media(MediaTime(2_000_000)));
1839
1840        writer.note_segment_start(1, MediaTime(0));
1841        writer.note_segment_start(2, MediaTime(4_000_000));
1842        seg(&writer, 1, 4.0, false);
1843
1844        let playlist = origin.render_playlist(DEFAULT_TRACK_ID).expect("render");
1845        assert!(
1846            !playlist.contains("#EXT-X-DATERANGE"),
1847            "no DATERANGE expected for a non-SCTE-35 event: {playlist}"
1848        );
1849    }
1850
1851    // --- master playlist (unaffected by the Trunk migration) -------------
1852
1853    #[test]
1854    fn master_playlist_has_stream_inf() {
1855        let m = master_playlist_m3u8("media.m3u8");
1856        assert!(m.contains("#EXTM3U"));
1857        assert!(m.contains("#EXT-X-STREAM-INF"));
1858        assert!(m.contains("media.m3u8"));
1859    }
1860
1861    #[test]
1862    fn master_playlist_points_at_configured_playlist_name() {
1863        let m = master_playlist_m3u8("index.m3u8");
1864        assert!(m.contains("index.m3u8"));
1865        assert!(!m.contains("media.m3u8"));
1866    }
1867
1868    // --- 1. playlist rendered from a populated Trunk matches the expected
1869    //        shape ---------------------------------------------------------
1870
1871    /// MUTATION VERIFIED: changing `render_playlist`'s
1872    /// `low_latency: Some(...)` to `None` makes this test's
1873    /// `assert!(m.contains("#EXT-X-PART-INF"))` (and every other
1874    /// LL-HLS-tag assertion) fail — `to_m3u8()` omits the entire
1875    /// low-latency header block when `low_latency` is `None`, so none of
1876    /// `#EXT-X-PART-INF`/`#EXT-X-SERVER-CONTROL`/`#EXT-X-PART` appear in the
1877    /// rendered body. Recompiled and re-run to confirm the failure, then
1878    /// reverted.
1879    #[test]
1880    fn playlist_rendered_from_populated_trunk_matches_expected_shape() {
1881        let (_trunk, origin, writer) = make_origin();
1882        seg(&writer, 1, 4.0, false);
1883        part(&writer, 2, 0, true);
1884        part(&writer, 2, 1, false);
1885
1886        let body = match resolve_now(
1887            &origin,
1888            HlsRequest::Playlist {
1889                track_id: DEFAULT_TRACK_ID,
1890                query: BlockingQuery::default(),
1891            },
1892        ) {
1893            EgressResponse::Ready {
1894                body: HlsBody::Playlist(m),
1895                cache,
1896            } => {
1897                assert_eq!(cache, CachePolicy::NoCache);
1898                m
1899            }
1900            other => panic!("expected Ready(Playlist), got {other:?}"),
1901        };
1902
1903        // RFC 8216bis §8 (issue #871): this playlist's true minimum is 6
1904        // (EXT-X-MAP without EXT-X-I-FRAMES-ONLY) — none of the LL-HLS
1905        // directives it also carries require any version at all. The old
1906        // hardcoded `EXT-X-VERSION:9` over-declared and would have locked
1907        // out every client on protocol version 6, 7, or 8.
1908        assert!(body.contains("#EXT-X-VERSION:6"), "body: {body}");
1909        assert!(!body.contains("#EXT-X-VERSION:9"), "body: {body}");
1910        assert!(body.contains("#EXT-X-TARGETDURATION:4"), "body: {body}");
1911        assert!(
1912            body.contains("#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.5"),
1913            "body: {body}"
1914        );
1915        assert!(
1916            body.contains("#EXT-X-PART-INF:PART-TARGET=0.5"),
1917            "body: {body}"
1918        );
1919        assert!(
1920            body.contains("#EXT-X-MAP:URI=\"init-1-7-1.mp4\""),
1921            "body: {body}"
1922        );
1923        assert!(body.contains("seg-1-7-1.m4s"), "body: {body}");
1924        assert!(
1925            body.contains("#EXT-X-PART:DURATION=0.5") && body.contains("INDEPENDENT=YES"),
1926            "body: {body}"
1927        );
1928        assert!(body.contains("#EXT-X-PRELOAD-HINT"), "body: {body}");
1929        assert!(
1930            body.contains("part-1-7-2.2.m4s"),
1931            "preload hint for the next part: {body}"
1932        );
1933    }
1934
1935    // --- 2. a preload-hinted part BLOCKS until produced, then serves -----
1936
1937    /// MUTATION VERIFIED: changing `resolve_resource`'s `never_will` check
1938    /// (whether the requested part's segment has already closed, via
1939    /// `last_closed_segment`) to always `true` ("never will produce this
1940    /// part") makes this test's first assertion fail: the not-yet-produced
1941    /// part resolves `NotFound` immediately instead of `Await`, so
1942    /// `assert!(matches!(first, EgressResponse::Await { .. }))` sees
1943    /// `NotFound` and fails. Recompiled and re-run to confirm the failure,
1944    /// then reverted. This is the RFC 8216bis section 6.2.2 behaviour that
1945    /// shipped as multimux 0.2.1's bug fix — regressing it would break the
1946    /// live camera route.
1947    #[test]
1948    fn preload_hinted_part_blocks_until_produced_then_serves() {
1949        let (trunk, origin, writer) = make_origin();
1950        let origin = Arc::new(origin);
1951
1952        // Not produced yet: must Await, not NotFound.
1953        let deadline = Timestamp::from_nanos(5_000_000_000);
1954        let policy = AwaitPolicy::new(deadline);
1955        let first = origin.resolve(
1956            HlsRequest::Resource {
1957                name: "part-1-7-1.0.m4s".to_string(),
1958            },
1959            Timestamp::from_nanos(0),
1960            policy,
1961        );
1962        assert!(
1963            matches!(first, EgressResponse::Await { .. }),
1964            "expected Await before the part exists, got {first:?}"
1965        );
1966
1967        // Register a real Trunk::listen() wake-up and block a worker thread
1968        // on it -- the actual mechanism a real adapter (Step 5) uses, not a
1969        // poll loop -- to prove the part genuinely blocks rather than
1970        // merely returning Await once and never resolving.
1971        let listener = trunk.listen().expect("listener slot available");
1972        let woken = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1973        let woken2 = std::sync::Arc::clone(&woken);
1974        // HANG GUARD (issue #807): deliberately generous, same reasoning as
1975        // `media-plane/src/trunk.rs`'s own `Trunk::listen()` wake tests --
1976        // the claim is "wakes rather than parking forever", not "wakes
1977        // within N seconds"; the publish happens on another thread, so a
1978        // tight bound would measure the machine's scheduler, not this code.
1979        let waiter = std::thread::spawn(move || {
1980            let ok = listener.wait_deadline(Instant::now() + Duration::from_secs(60));
1981            woken2.store(ok, std::sync::atomic::Ordering::SeqCst);
1982        });
1983
1984        // Produce the part the request was waiting on.
1985        part(&writer, 1, 0, true);
1986
1987        waiter.join().expect("waiter thread must not panic");
1988        assert!(
1989            woken.load(std::sync::atomic::Ordering::SeqCst),
1990            "Trunk::listen() must wake once publish_part lands"
1991        );
1992
1993        // Re-resolving now must serve it -- not 404.
1994        match origin.resolve(
1995            HlsRequest::Resource {
1996                name: "part-1-7-1.0.m4s".to_string(),
1997            },
1998            Timestamp::from_nanos(1),
1999            policy,
2000        ) {
2001            EgressResponse::Ready {
2002                body: HlsBody::Resource(bytes),
2003                cache,
2004            } => {
2005                assert_eq!(bytes, Bytes::from(vec![0u8; 4]));
2006                assert_eq!(cache, CachePolicy::Immutable);
2007            }
2008            other => panic!("expected Ready once produced, got {other:?}"),
2009        }
2010    }
2011
2012    /// MUTATION VERIFIED: removing `EgressResponse::pending`'s expiry check
2013    /// (i.e. always returning `Await`) would make a client wait forever for
2014    /// a part that will never exist -- this test proves the OTHER half of
2015    /// the bound: once `now` reaches the caller's own `AwaitPolicy::deadline`,
2016    /// resolve must stop Awaiting. Changing the deadline comparison in
2017    /// `resolve_resource`'s `EgressResponse::pending(await_policy, now, now)`
2018    /// call to ignore `now` (always pass `Timestamp::from_nanos(0)`) makes
2019    /// this test's final assertion fail: `resolve` at `now == deadline`
2020    /// keeps returning `Await` instead of `NotFound`. Recompiled and re-run
2021    /// to confirm the failure, then reverted.
2022    #[test]
2023    fn awaiting_part_is_bounded_by_await_policy_deadline() {
2024        let (_trunk, origin, _writer) = make_origin();
2025        let deadline = Timestamp::from_nanos(1_000_000_000);
2026        let policy = AwaitPolicy::new(deadline);
2027
2028        let still_waiting = origin.resolve(
2029            HlsRequest::Resource {
2030                name: "part-1-7-1.0.m4s".to_string(),
2031            },
2032            Timestamp::from_nanos(999_999_999),
2033            policy,
2034        );
2035        assert!(matches!(still_waiting, EgressResponse::Await { .. }));
2036
2037        let expired = origin.resolve(
2038            HlsRequest::Resource {
2039                name: "part-1-7-1.0.m4s".to_string(),
2040            },
2041            deadline,
2042            policy,
2043        );
2044        assert!(
2045            matches!(expired, EgressResponse::NotFound),
2046            "expected NotFound once the deadline passed, got {expired:?}"
2047        );
2048    }
2049
2050    // --- 3. a just-closed segment's final part still serves ---------------
2051
2052    /// MUTATION VERIFIED: this behaviour depends entirely on
2053    /// `media_plane::trunk::SegmentWriter::publish_segment` (`media-plane/src/trunk.rs`) never
2054    /// touching the live-part log. Simulating the old `MediaStore` bug here
2055    /// by having `resolve_resource` check `last_closed_segment() >= seq`
2056    /// ("this segment already closed -> NotFound") **before** checking
2057    /// `Trunk::part_bytes` (i.e. swapping the two checks' order) makes this
2058    /// test's first assertion fail: the just-closed segment's final part
2059    /// resolves `NotFound` instead of `Ready` (`panicked at ...: the
2060    /// just-closed segment's final part must still serve, got NotFound`),
2061    /// because the eager closed-check now shadows the still-valid
2062    /// `part_bytes` hit. Recompiled and re-run to confirm the failure, then
2063    /// reverted. This is the RFC 8216bis boundary behaviour that shipped as
2064    /// multimux 0.2.2's bug fix — regressing it would break the live camera
2065    /// route (its own `#EXT-X-PRELOAD-HINT` part races exactly this
2066    /// boundary every segment).
2067    #[test]
2068    fn just_closed_segment_final_part_still_serves() {
2069        let (_trunk, origin, writer) = make_origin();
2070        part(&writer, 1, 0, true);
2071        part(&writer, 1, 1, false); // segment 1's final part
2072        seg(&writer, 1, 4.0, false); // close segment 1
2073
2074        match resolve_now(
2075            &origin,
2076            HlsRequest::Resource {
2077                name: "part-1-7-1.1.m4s".to_string(),
2078            },
2079        ) {
2080            EgressResponse::Ready {
2081                body: HlsBody::Resource(bytes),
2082                ..
2083            } => assert_eq!(bytes, Bytes::from(vec![1u8; 4])),
2084            other => panic!("the just-closed segment's final part must still serve, got {other:?}"),
2085        }
2086
2087        // A genuinely-nonexistent part of the closed segment is NotFound.
2088        assert_eq!(
2089            resolve_now(
2090                &origin,
2091                HlsRequest::Resource {
2092                    name: "part-1-7-1.9.m4s".to_string(),
2093                }
2094            ),
2095            EgressResponse::NotFound
2096        );
2097
2098        // The playlist must not resurrect the closed segment's parts as
2099        // "open" -- it is rendered whole.
2100        let body = match resolve_now(
2101            &origin,
2102            HlsRequest::Playlist {
2103                track_id: DEFAULT_TRACK_ID,
2104                query: BlockingQuery::default(),
2105            },
2106        ) {
2107            EgressResponse::Ready {
2108                body: HlsBody::Playlist(m),
2109                ..
2110            } => m,
2111            other => panic!("expected Ready(Playlist), got {other:?}"),
2112        };
2113        assert!(
2114            body.contains("seg-1-7-1.m4s"),
2115            "closed segment rendered whole: {body}"
2116        );
2117        // RFC 8216bis §6.2.2: the closed segment keeps its EXT-X-PART tags
2118        // (inside the segment, before its EXTINF) for three Target
2119        // Durations. They are not an *open* segment: no preload hint, and
2120        // every part line precedes the segment's own EXTINF/URI.
2121        assert_eq!(
2122            body.lines().skip(4).collect::<Vec<_>>(),
2123            vec![
2124                "#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.5",
2125                "#EXT-X-PART-INF:PART-TARGET=0.5",
2126                "#EXT-X-MAP:URI=\"init-1-7-1.mp4\"",
2127                "#EXT-X-PART:DURATION=0.5,URI=\"part-1-7-1.0.m4s\",INDEPENDENT=YES",
2128                "#EXT-X-PART:DURATION=0.5,URI=\"part-1-7-1.1.m4s\"",
2129                "#EXTINF:4,",
2130                "seg-1-7-1.m4s",
2131            ],
2132            "{body}"
2133        );
2134    }
2135
2136    // --- 4. MEDIA-SEQUENCE / DISCONTINUITY-SEQUENCE advance as the window
2137    //        rolls -----------------------------------------------------
2138
2139    /// MUTATION VERIFIED: changing `Window::push`'s eviction guard from
2140    /// `if evicted.discontinuous` to `if false` (never counting an evicted
2141    /// discontinuity) makes this test's
2142    /// `assert!(body.contains("#EXT-X-DISCONTINUITY-SEQUENCE:1"))` fail --
2143    /// the tag is omitted entirely (the renderer only emits it when
2144    /// `discontinuity_sequence > 0`), because the counter never advances
2145    /// past `0`. Recompiled and re-run to confirm the failure, then
2146    /// reverted.
2147    #[test]
2148    fn media_sequence_and_discontinuity_sequence_advance_as_window_rolls() {
2149        let (_trunk, origin, writer) = make_origin(); // window_segments = 4
2150
2151        seg(&writer, 1, 4.0, false);
2152        seg(&writer, 2, 4.0, true); // discontinuous
2153        seg(&writer, 3, 4.0, false);
2154        seg(&writer, 4, 4.0, false);
2155
2156        // Window (capacity 4) holds exactly 1..=4 -- MEDIA-SEQUENCE=1, and
2157        // segment 2's own #EXT-X-DISCONTINUITY renders in-window (no
2158        // DISCONTINUITY-SEQUENCE yet, nothing has rolled off).
2159        let body = match resolve_now(
2160            &origin,
2161            HlsRequest::Playlist {
2162                track_id: DEFAULT_TRACK_ID,
2163                query: BlockingQuery::default(),
2164            },
2165        ) {
2166            EgressResponse::Ready {
2167                body: HlsBody::Playlist(m),
2168                ..
2169            } => m,
2170            other => panic!("expected Ready(Playlist), got {other:?}"),
2171        };
2172        assert!(body.contains("#EXT-X-MEDIA-SEQUENCE:1"), "body: {body}");
2173        assert!(
2174            !body.contains("#EXT-X-DISCONTINUITY-SEQUENCE"),
2175            "nothing has rolled off the window yet: {body}"
2176        );
2177        assert!(body.contains("#EXT-X-DISCONTINUITY\n"), "body: {body}");
2178
2179        // Roll the window: segment 5 evicts segment 1 (not discontinuous;
2180        // DISCONTINUITY-SEQUENCE stays 0), segment 6 evicts segment 2
2181        // (discontinuous -- DISCONTINUITY-SEQUENCE becomes 1).
2182        seg(&writer, 5, 4.0, false);
2183        let body = match resolve_now(
2184            &origin,
2185            HlsRequest::Playlist {
2186                track_id: DEFAULT_TRACK_ID,
2187                query: BlockingQuery::default(),
2188            },
2189        ) {
2190            EgressResponse::Ready {
2191                body: HlsBody::Playlist(m),
2192                ..
2193            } => m,
2194            other => panic!("expected Ready(Playlist), got {other:?}"),
2195        };
2196        assert!(body.contains("#EXT-X-MEDIA-SEQUENCE:2"), "body: {body}");
2197        assert!(
2198            !body.contains("#EXT-X-DISCONTINUITY-SEQUENCE"),
2199            "evicted segment 1 was not discontinuous: {body}"
2200        );
2201
2202        seg(&writer, 6, 4.0, false);
2203        let body = match resolve_now(
2204            &origin,
2205            HlsRequest::Playlist {
2206                track_id: DEFAULT_TRACK_ID,
2207                query: BlockingQuery::default(),
2208            },
2209        ) {
2210            EgressResponse::Ready {
2211                body: HlsBody::Playlist(m),
2212                ..
2213            } => m,
2214            other => panic!("expected Ready(Playlist), got {other:?}"),
2215        };
2216        assert!(body.contains("#EXT-X-MEDIA-SEQUENCE:3"), "body: {body}");
2217        assert!(
2218            body.contains("#EXT-X-DISCONTINUITY-SEQUENCE:1"),
2219            "segment 2 (discontinuous) has now rolled off the window: {body}"
2220        );
2221    }
2222
2223    // --- misc: target-duration MUST, abuse bound, bad request -------------
2224
2225    #[test]
2226    fn target_duration_is_max_of_configured_and_actual_segment_duration() {
2227        let (_trunk, origin, writer) = make_origin(); // configured target 4.0
2228        seg(&writer, 1, 7.5, false);
2229        let body = match resolve_now(
2230            &origin,
2231            HlsRequest::Playlist {
2232                track_id: DEFAULT_TRACK_ID,
2233                query: BlockingQuery::default(),
2234            },
2235        ) {
2236            EgressResponse::Ready {
2237                body: HlsBody::Playlist(m),
2238                ..
2239            } => m,
2240            other => panic!("expected Ready(Playlist), got {other:?}"),
2241        };
2242        assert!(
2243            body.contains("#EXT-X-TARGETDURATION:8"),
2244            "TARGETDURATION must be round(7.5)=8, not the configured target: {body}"
2245        );
2246    }
2247
2248    #[test]
2249    fn far_future_msn_rejected() {
2250        let (_trunk, origin, writer) = make_origin();
2251        seg(&writer, 1, 4.0, false);
2252        let outcome = resolve_now(
2253            &origin,
2254            HlsRequest::Playlist {
2255                track_id: DEFAULT_TRACK_ID,
2256                query: BlockingQuery {
2257                    hls_msn: Some(1002),
2258                    hls_part: None,
2259                },
2260            },
2261        );
2262        assert!(matches!(outcome, EgressResponse::BadRequest { .. }));
2263    }
2264
2265    /// RFC 8216bis §6.2.5.2: `_HLS_msn` at `last_closed + 2` is the
2266    /// spec's bound — it MUST be accepted. With one segment closed (seq=1),
2267    /// the last closed is 1, so `_HLS_msn=3` (last_closed + 2) is accepted
2268    /// and the request blocks (returns Pending, not BadRequest).
2269    #[test]
2270    fn msn_at_spec_bound_is_accepted() {
2271        let (_trunk, origin, writer) = make_origin();
2272        seg(&writer, 1, 4.0, false);
2273        let outcome = resolve_now(
2274            &origin,
2275            HlsRequest::Playlist {
2276                track_id: DEFAULT_TRACK_ID,
2277                query: BlockingQuery {
2278                    hls_msn: Some(3),
2279                    hls_part: None,
2280                },
2281            },
2282        );
2283        assert!(
2284            !matches!(outcome, EgressResponse::BadRequest { .. }),
2285            "msn at spec bound (last_closed+2) must be accepted, not rejected"
2286        );
2287    }
2288
2289    /// RFC 8216bis §6.2.5.2: `_HLS_msn` one beyond the spec's +2 SHOULD
2290    /// boundary is rejected. With one segment closed (seq=1, last_closed=1),
2291    /// the live edge in_progress_seg is 1 (not 2, because no parts exist to
2292    /// advance it), so `_HLS_msn=4` (1 + 2 + 1) is rejected.
2293    #[test]
2294    fn msn_one_beyond_spec_bound_is_rejected() {
2295        let (_trunk, origin, writer) = make_origin();
2296        seg(&writer, 1, 4.0, false);
2297        // With segments up to 1 closed, in_progress_seg is 1.
2298        // _HLS_msn > 1 + 2 == 3 → rejected. So msn=4 is rejected.
2299        let outcome = resolve_now(
2300            &origin,
2301            HlsRequest::Playlist {
2302                track_id: DEFAULT_TRACK_ID,
2303                query: BlockingQuery {
2304                    hls_msn: Some(4),
2305                    hls_part: None,
2306                },
2307            },
2308        );
2309        assert!(
2310            matches!(outcome, EgressResponse::BadRequest { .. }),
2311            "msn at spec bound + 1 (last_closed+3) must be rejected"
2312        );
2313    }
2314
2315    #[test]
2316    fn part_without_msn_rejected() {
2317        let (_trunk, origin, _writer) = make_origin();
2318        let outcome = resolve_now(
2319            &origin,
2320            HlsRequest::Playlist {
2321                track_id: DEFAULT_TRACK_ID,
2322                query: BlockingQuery {
2323                    hls_msn: None,
2324                    hls_part: Some(0),
2325                },
2326            },
2327        );
2328        assert!(matches!(outcome, EgressResponse::BadRequest { .. }));
2329    }
2330
2331    #[test]
2332    fn resolve_resource_init_present() {
2333        let (_trunk, origin, _writer) = make_origin();
2334        match resolve_now(
2335            &origin,
2336            HlsRequest::Resource {
2337                name: "init-1.mp4".to_string(),
2338            },
2339        ) {
2340            EgressResponse::Ready {
2341                body: HlsBody::Resource(bytes),
2342                cache,
2343            } => {
2344                assert_eq!(bytes, Bytes::from(vec![0xAAu8; 8]));
2345                // The bare name means "the current init" and can change.
2346                assert_eq!(cache, CachePolicy::NoCache);
2347            }
2348            other => panic!("expected Ready, got {other:?}"),
2349        }
2350    }
2351
2352    #[test]
2353    fn resolve_resource_unmatched_filename_not_found() {
2354        let (_trunk, origin, _writer) = make_origin();
2355        assert_eq!(
2356            resolve_now(
2357                &origin,
2358                HlsRequest::Resource {
2359                    name: "not-a-thing.txt".to_string(),
2360                }
2361            ),
2362            EgressResponse::NotFound
2363        );
2364    }
2365
2366    // --- issue #873: Container x low-latency matrix -----------------------
2367    //
2368    // Every cell below asserts on rendered playlist text AND on request
2369    // resolution (fetch every URI the rendered text itself advertised, never
2370    // a hard-coded filename) -- "advertised == servable" is the entire
2371    // reason `HlsOrigin` exists, per the issue. `EXT-X-VERSION` is checked
2372    // against `broadcast_hls::MediaPlaylist::computed_version()` re-derived
2373    // from a round-trip parse of the rendered text -- never an independently
2374    // guessed integer literal, which is exactly the bug issue #871 removed.
2375
2376    fn make_origin_with(
2377        container: Container,
2378        low_latency_ms: Option<u32>,
2379    ) -> (Arc<Trunk>, HlsOrigin, media_plane::trunk::SegmentWriter) {
2380        let trunk = Trunk::new(TrunkConfig::new(nz(64), nz(8), nz(8), nz(8), nz(64)));
2381        let writer = trunk.segment_writer().expect("first segment writer");
2382        let mut builder = HlsOrigin::builder(Arc::clone(&trunk))
2383            .target_duration_secs(4.0)
2384            .window_segments(nz(4))
2385            .container(container);
2386        if let Some(ms) = low_latency_ms {
2387            builder = builder.low_latency(ms);
2388        }
2389        let origin = builder
2390            .instance(7)
2391            .build()
2392            .expect("both required fields set");
2393        (trunk, origin, writer)
2394    }
2395
2396    fn render_body(origin: &HlsOrigin) -> String {
2397        match resolve_now(
2398            origin,
2399            HlsRequest::Playlist {
2400                track_id: DEFAULT_TRACK_ID,
2401                query: BlockingQuery::default(),
2402            },
2403        ) {
2404            EgressResponse::Ready {
2405                body: HlsBody::Playlist(m),
2406                ..
2407            } => m,
2408            other => panic!("expected Ready(Playlist), got {other:?}"),
2409        }
2410    }
2411
2412    /// The `#EXT-X-VERSION:<n>` value present in `body`, if any.
2413    fn extract_version_tag(body: &str) -> Option<u8> {
2414        body.lines()
2415            .find_map(|l| l.strip_prefix("#EXT-X-VERSION:")?.parse::<u8>().ok())
2416    }
2417
2418    /// Every segment URI (the line immediately after a `#EXTINF:` line), in
2419    /// playlist order -- exactly what a real client would fetch next.
2420    fn segment_uris(body: &str) -> Vec<String> {
2421        let lines: Vec<&str> = body.lines().collect();
2422        let mut out = Vec::new();
2423        for i in 0..lines.len() {
2424            if lines[i].starts_with("#EXTINF:")
2425                && let Some(next) = lines.get(i + 1)
2426                && !next.starts_with('#')
2427            {
2428                out.push((*next).to_string());
2429            }
2430        }
2431        out
2432    }
2433
2434    /// Every `#EXT-X-PART:` line's `URI="..."` value, in order.
2435    fn part_uris(body: &str) -> Vec<String> {
2436        body.lines()
2437            .filter(|l| l.starts_with("#EXT-X-PART:"))
2438            .filter_map(|l| {
2439                let start = l.find("URI=\"")? + "URI=\"".len();
2440                let rest = &l[start..];
2441                let end = rest.find('"')?;
2442                Some(rest[..end].to_string())
2443            })
2444            .collect()
2445    }
2446
2447    /// Assert the rendered `#EXT-X-VERSION` equals `broadcast_hls`'s own
2448    /// `computed_version()`, re-derived by round-trip parsing the rendered
2449    /// text -- never an integer this test independently guessed. Returns the
2450    /// rendered value so a caller can make a further, non-vacuous claim
2451    /// about it.
2452    fn assert_version_matches_broadcast_hls_derivation(body: &str) -> Option<u8> {
2453        let parsed = MediaPlaylist::parse(body).expect("rendered body must round-trip parse");
2454        let rendered = extract_version_tag(body);
2455        assert_eq!(
2456            rendered,
2457            parsed.computed_version(),
2458            "rendered #EXT-X-VERSION must equal broadcast_hls's own derivation, body: {body}"
2459        );
2460        rendered
2461    }
2462
2463    /// [`assert_version_matches_broadcast_hls_derivation`] **plus a
2464    /// non-vacuity guard**: the playlist must actually trigger at least one
2465    /// RFC 8216bis §8 version rule, so the equality above cannot pass by
2466    /// comparing `None` against `None`.
2467    ///
2468    /// Without this, a cell whose every `#EXTINF` is integral (e.g. a
2469    /// duration of exactly `4.0`, which renders `#EXTINF:4,`) trips no §8
2470    /// row at all, and the derivation could be entirely broken while the
2471    /// test stayed green. Real segmenters cut on keyframes, not whole
2472    /// seconds, so a fractional `#EXTINF` is also the realistic shape --
2473    /// cf. `fixtures/hls/spec/9.1-simple-media-playlist.m3u8` (`#EXTINF:9.009`,
2474    /// `#EXT-X-VERSION:3`).
2475    fn assert_version_present_and_matches_derivation(body: &str) -> u8 {
2476        assert_version_matches_broadcast_hls_derivation(body).unwrap_or_else(|| {
2477            panic!(
2478                "this cell must trigger a real RFC 8216bis §8 version rule -- a \
2479                 missing #EXT-X-VERSION makes the derivation check vacuous, body: {body}"
2480            )
2481        })
2482    }
2483
2484    /// MUTATION VERIFIED (issue #873): making `HlsOriginBuilder::container`
2485    /// a no-op (so every origin renders as `Fmp4` regardless of the
2486    /// `Container` passed to it) makes this test's
2487    /// `assert!(!body.contains("#EXT-X-MAP"))` fail -- the mutated build
2488    /// unconditionally emits `#EXT-X-MAP:URI="init-1.mp4"`, and the `.ts`
2489    /// URI assertions fail too (segments render as `seg-1-7-1.m4s` instead of
2490    /// `seg-1-7-1.ts`). Recompiled and re-run to confirm the failure (see the
2491    /// PR description for the pasted `cargo test` output), then reverted.
2492    /// The mutation bites four tests in total -- this one, the low-latency
2493    /// `MpegTs` cell, the integral-`EXTINF` version case, and the
2494    /// cross-container refusal.
2495    #[test]
2496    fn mpegts_classic_no_map_ts_uris_no_ll_tags() {
2497        let (_trunk, origin, writer) = make_origin_with(Container::MpegTs, None);
2498        // Fractional, like every real keyframe-cut segment (and like the
2499        // RFC's own classic examples) -- so RFC 8216bis §8 row 3
2500        // (floating-point EXTINF) genuinely fires and the version assertion
2501        // below is not `None == None`.
2502        seg(&writer, 1, 4.004, false);
2503
2504        let body = render_body(&origin);
2505        assert!(!body.contains("#EXT-X-MAP"), "body: {body}");
2506        assert!(!body.contains("#EXT-X-PART"), "body: {body}");
2507        assert!(!body.contains("#EXT-X-SERVER-CONTROL"), "body: {body}");
2508        assert!(!body.contains("#EXT-X-PRELOAD-HINT"), "body: {body}");
2509        assert!(!body.contains(".m4s"), "body: {body}");
2510        assert!(body.contains("seg-1-7-1.ts"), "body: {body}");
2511        assert_version_present_and_matches_derivation(&body);
2512
2513        // advertised == servable: fetch every URI the rendered text itself
2514        // named, and check its bytes against what was actually published
2515        // (via this crate's own `parse_immediate`, not a hard-coded filename).
2516        let uris = segment_uris(&body);
2517        assert_eq!(uris, vec!["seg-1-7-1.ts".to_string()]);
2518        for uri in uris {
2519            let ImmediateResource::Segment { seq, .. } = parse_immediate(&uri, Container::MpegTs)
2520                .expect("advertised segment URI must parse under MpegTs")
2521            else {
2522                panic!("expected a Segment resource for {uri}");
2523            };
2524            match resolve_now(&origin, HlsRequest::Resource { name: uri.clone() }) {
2525                EgressResponse::Ready {
2526                    body: HlsBody::Resource(bytes),
2527                    ..
2528                } => assert_eq!(bytes, Bytes::from(vec![seq as u8; 8])),
2529                other => panic!("expected Ready for {uri}, got {other:?}"),
2530            }
2531        }
2532    }
2533
2534    /// MUTATION VERIFIED (issue #873): making `HlsOriginBuilder::container`
2535    /// a no-op makes this test's `assert!(!body.contains("#EXT-X-MAP"))`
2536    /// fail identically to the classic-`MpegTs` test above, and the part
2537    /// URIs render as `part-1-7-2.0.m4s` instead of `part-1-7-2.0.ts`, so
2538    /// `assert!(body.contains("part-1-7-2.0.ts"))` also fails. Recompiled and
2539    /// re-run to confirm, then reverted.
2540    #[test]
2541    fn mpegts_low_latency_part_ts_uris_blocking_part_requests_resolve() {
2542        let (trunk, origin, writer) = make_origin_with(Container::MpegTs, Some(500));
2543        let origin = Arc::new(origin);
2544        // A closed segment with a fractional (keyframe-cut) duration, so
2545        // RFC 8216bis §8 row 3 fires and the version assertion below is not
2546        // `None == None`; segment 2 is then the open one carrying live parts.
2547        seg(&writer, 1, 4.004, false);
2548        part(&writer, 2, 0, true);
2549        part(&writer, 2, 1, false);
2550
2551        let body = render_body(&origin);
2552        assert!(!body.contains("#EXT-X-MAP"), "body: {body}");
2553        assert!(body.contains("#EXT-X-PART-INF"), "body: {body}");
2554        assert!(body.contains("#EXT-X-PART:"), "body: {body}");
2555        assert!(body.contains("seg-1-7-1.ts"), "body: {body}");
2556        assert!(body.contains("part-1-7-2.0.ts"), "body: {body}");
2557        assert!(!body.contains(".m4s"), "body: {body}");
2558        // LL-HLS directives add no §8 version requirement of their own --
2559        // the finding that killed the old hardcoded `EXT-X-VERSION:9` --
2560        // so this must derive to exactly the same value as the classic
2561        // MpegTs cell above.
2562        assert_version_present_and_matches_derivation(&body);
2563
2564        // advertised == servable for every advertised part.
2565        let parts = part_uris(&body);
2566        assert!(!parts.is_empty(), "body: {body}");
2567        for uri in &parts {
2568            let (_epoch, seq, idx) = parse_part(uri, Container::MpegTs)
2569                .unwrap_or_else(|| panic!("advertised part URI {uri} must parse under MpegTs"));
2570            match resolve_now(&origin, HlsRequest::Resource { name: uri.clone() }) {
2571                EgressResponse::Ready {
2572                    body: HlsBody::Resource(bytes),
2573                    ..
2574                } => {
2575                    assert_eq!(bytes, Bytes::from(vec![idx as u8; 4]));
2576                    assert_eq!(seq, 2);
2577                }
2578                other => panic!("expected Ready for {uri}, got {other:?}"),
2579            }
2580        }
2581
2582        // A blocking request for a `.ts` part not yet produced must Await,
2583        // then resolve once produced -- the same guarantee the pre-#873
2584        // fMP4-only test proves, now exercised over the `.ts` URI scheme.
2585        let deadline = Timestamp::from_nanos(5_000_000_000);
2586        let policy = AwaitPolicy::new(deadline);
2587        let pending = origin.resolve(
2588            HlsRequest::Resource {
2589                name: "part-1-7-2.2.ts".to_string(),
2590            },
2591            Timestamp::from_nanos(0),
2592            policy,
2593        );
2594        assert!(
2595            matches!(pending, EgressResponse::Await { .. }),
2596            "expected Await before the part exists, got {pending:?}"
2597        );
2598
2599        let listener = trunk.listen().expect("listener slot available");
2600        let woken = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
2601        let woken2 = std::sync::Arc::clone(&woken);
2602        let waiter = std::thread::spawn(move || {
2603            let ok = listener.wait_deadline(Instant::now() + Duration::from_secs(60));
2604            woken2.store(ok, std::sync::atomic::Ordering::SeqCst);
2605        });
2606        part(&writer, 2, 2, false);
2607        waiter.join().expect("waiter thread must not panic");
2608        assert!(
2609            woken.load(std::sync::atomic::Ordering::SeqCst),
2610            "Trunk::listen() must wake once publish_part lands"
2611        );
2612
2613        match origin.resolve(
2614            HlsRequest::Resource {
2615                name: "part-1-7-2.2.ts".to_string(),
2616            },
2617            Timestamp::from_nanos(1),
2618            policy,
2619        ) {
2620            EgressResponse::Ready {
2621                body: HlsBody::Resource(bytes),
2622                ..
2623            } => assert_eq!(bytes, Bytes::from(vec![2u8; 4])),
2624            other => panic!("expected Ready once produced, got {other:?}"),
2625        }
2626    }
2627
2628    /// RFC 8216bis §8's opening rule: a playlist that triggers no version
2629    /// row at all is version-1 compatible and need not carry the tag. Kept
2630    /// as its own case (rather than as the `MpegTs` cells' only version
2631    /// check, which made those assertions vacuous) because an integral
2632    /// `#EXTINF` is a real, if unusual, shape.
2633    ///
2634    /// MUTATION VERIFIED (issue #873): making `HlsOriginBuilder::container`
2635    /// a no-op makes this fail with `left: Some(6), right: None` -- the
2636    /// mutated build emits `#EXT-X-MAP`, which trips §8 row 6. Recompiled
2637    /// and re-run to confirm, then reverted.
2638    #[test]
2639    fn mpegts_classic_integral_extinf_emits_no_version_tag() {
2640        let (_trunk, origin, writer) = make_origin_with(Container::MpegTs, None);
2641        seg(&writer, 1, 4.0, false);
2642        let body = render_body(&origin);
2643        // A whole number of seconds renders as an integer, so the playlist
2644        // genuinely contains no floating-point EXTINF value -- §8 row 3
2645        // does not fire, and omitting `EXT-X-VERSION` is honest rather than
2646        // a lie to a v1/v2 client. (`broadcast-hls` used to render `4.000`
2647        // here while still reporting no version requirement; fixed in the
2648        // same PR as this test.)
2649        assert!(body.contains("#EXTINF:4,"), "body: {body}");
2650        assert!(!body.contains("4.000"), "body: {body}");
2651        assert_eq!(
2652            assert_version_matches_broadcast_hls_derivation(&body),
2653            None,
2654            "nothing in this playlist triggers an RFC 8216bis §8 row: {body}"
2655        );
2656    }
2657
2658    /// LL-HLS directives carry no RFC 8216bis §8 version requirement of
2659    /// their own -- the finding that killed the old hardcoded
2660    /// `EXT-X-VERSION:9`. Enabling low latency must therefore not change
2661    /// the derived version for otherwise-identical content.
2662    #[test]
2663    fn low_latency_does_not_raise_the_derived_version() {
2664        let (_t1, classic, w1) = make_origin_with(Container::MpegTs, None);
2665        seg(&w1, 1, 4.004, false);
2666        let classic_version = assert_version_present_and_matches_derivation(&render_body(&classic));
2667
2668        let (_t2, low_latency, w2) = make_origin_with(Container::MpegTs, Some(500));
2669        seg(&w2, 1, 4.004, false);
2670        part(&w2, 2, 0, true);
2671        let ll_body = render_body(&low_latency);
2672        assert!(ll_body.contains("#EXT-X-PART:"), "body: {ll_body}");
2673        assert_eq!(
2674            assert_version_present_and_matches_derivation(&ll_body),
2675            classic_version,
2676            "enabling low latency must not raise the derived version"
2677        );
2678    }
2679
2680    #[test]
2681    fn fmp4_classic_map_present_no_ll_tags() {
2682        let (_trunk, origin, writer) = make_origin_with(Container::Fmp4, None);
2683        origin.set_init(vec![0xBBu8; 8]);
2684        // Fractional, so §8 row 3 fires alongside row 6 (EXT-X-MAP without
2685        // EXT-X-I-FRAMES-ONLY). `max(6, 3) = 6`, so the rendered value is
2686        // unchanged -- this removes the ambiguity of an integral EXTINF
2687        // leaving row 3 entirely untested here.
2688        seg(&writer, 1, 4.004, false);
2689
2690        let body = render_body(&origin);
2691        assert!(
2692            body.contains("#EXT-X-MAP:URI=\"init-1-7-1.mp4\""),
2693            "body: {body}"
2694        );
2695        assert!(!body.contains("#EXT-X-PART"), "body: {body}");
2696        assert!(!body.contains("#EXT-X-SERVER-CONTROL"), "body: {body}");
2697        assert!(!body.contains("#EXT-X-PRELOAD-HINT"), "body: {body}");
2698        assert!(body.contains("seg-1-7-1.m4s"), "body: {body}");
2699        assert_version_present_and_matches_derivation(&body);
2700
2701        // advertised == servable, including the init segment the MAP names.
2702        match resolve_now(
2703            &origin,
2704            HlsRequest::Resource {
2705                name: "init-1.mp4".to_string(),
2706            },
2707        ) {
2708            EgressResponse::Ready {
2709                body: HlsBody::Resource(bytes),
2710                ..
2711            } => assert_eq!(bytes, Bytes::from(vec![0xBBu8; 8])),
2712            other => panic!("expected Ready(init), got {other:?}"),
2713        }
2714        for uri in segment_uris(&body) {
2715            let ImmediateResource::Segment { seq, .. } = parse_immediate(&uri, Container::Fmp4)
2716                .expect("advertised segment URI must parse under Fmp4")
2717            else {
2718                panic!("expected a Segment resource for {uri}");
2719            };
2720            match resolve_now(&origin, HlsRequest::Resource { name: uri.clone() }) {
2721                EgressResponse::Ready {
2722                    body: HlsBody::Resource(bytes),
2723                    ..
2724                } => assert_eq!(bytes, Bytes::from(vec![seq as u8; 8])),
2725                other => panic!("expected Ready for {uri}, got {other:?}"),
2726            }
2727        }
2728    }
2729
2730    /// Regression guard (issue #873, matrix cell 4): the pre-#873 default
2731    /// shape (`Fmp4` + low-latency) must render unchanged.
2732    #[test]
2733    fn fmp4_low_latency_existing_behaviour_preserved() {
2734        let (_trunk, origin, writer) = make_origin_with(Container::Fmp4, Some(500));
2735        origin.set_init(vec![0xAAu8; 8]);
2736        // Fractional for the same reason as the Fmp4-classic cell above.
2737        seg(&writer, 1, 4.004, false);
2738        part(&writer, 2, 0, true);
2739        part(&writer, 2, 1, false);
2740
2741        let body = render_body(&origin);
2742        assert!(
2743            body.contains("#EXT-X-MAP:URI=\"init-1-7-1.mp4\""),
2744            "body: {body}"
2745        );
2746        assert!(body.contains("#EXT-X-PART-INF"), "body: {body}");
2747        assert!(body.contains("#EXT-X-SERVER-CONTROL"), "body: {body}");
2748        assert!(body.contains("#EXT-X-PRELOAD-HINT"), "body: {body}");
2749        assert!(body.contains("seg-1-7-1.m4s"), "body: {body}");
2750        assert!(body.contains("part-1-7-2.0.m4s"), "body: {body}");
2751        assert!(!body.contains(".ts\""), "body: {body}");
2752        assert_version_present_and_matches_derivation(&body);
2753
2754        match resolve_now(
2755            &origin,
2756            HlsRequest::Resource {
2757                name: "init-1.mp4".to_string(),
2758            },
2759        ) {
2760            EgressResponse::Ready {
2761                body: HlsBody::Resource(bytes),
2762                ..
2763            } => assert_eq!(bytes, Bytes::from(vec![0xAAu8; 8])),
2764            other => panic!("expected Ready(init), got {other:?}"),
2765        }
2766        for uri in segment_uris(&body) {
2767            let ImmediateResource::Segment { seq, .. } = parse_immediate(&uri, Container::Fmp4)
2768                .expect("advertised segment URI must parse under Fmp4")
2769            else {
2770                panic!("expected a Segment resource for {uri}");
2771            };
2772            match resolve_now(&origin, HlsRequest::Resource { name: uri.clone() }) {
2773                EgressResponse::Ready {
2774                    body: HlsBody::Resource(bytes),
2775                    ..
2776                } => assert_eq!(bytes, Bytes::from(vec![seq as u8; 8])),
2777                other => panic!("expected Ready for {uri}, got {other:?}"),
2778            }
2779        }
2780        for uri in part_uris(&body) {
2781            let (_epoch, _seq, idx) = parse_part(&uri, Container::Fmp4)
2782                .unwrap_or_else(|| panic!("advertised part URI {uri} must parse under Fmp4"));
2783            match resolve_now(&origin, HlsRequest::Resource { name: uri.clone() }) {
2784                EgressResponse::Ready {
2785                    body: HlsBody::Resource(bytes),
2786                    ..
2787                } => assert_eq!(bytes, Bytes::from(vec![idx as u8; 4])),
2788                other => panic!("expected Ready for {uri}, got {other:?}"),
2789            }
2790        }
2791    }
2792
2793    /// MUTATION VERIFIED (issue #873): making `HlsOriginBuilder::container`
2794    /// a no-op makes every origin behave as `Fmp4`, so `init-1.mp4` under a
2795    /// nominally-`MpegTs` origin resolves `Ready` instead of `NotFound` --
2796    /// this test's final assertion fails. Recompiled and re-run to confirm,
2797    /// then reverted.
2798    #[test]
2799    fn cross_container_refusal_mp4_init_under_mpegts_not_found() {
2800        let (_trunk, origin, _writer) = make_origin_with(Container::MpegTs, None);
2801        // Still callable (documented no-op) -- the bytes are simply never
2802        // advertised or served under `MpegTs`.
2803        origin.set_init(vec![0xCCu8; 8]);
2804        assert_eq!(
2805            resolve_now(
2806                &origin,
2807                HlsRequest::Resource {
2808                    name: "init-1.mp4".to_string(),
2809                }
2810            ),
2811            EgressResponse::NotFound
2812        );
2813    }
2814
2815    #[test]
2816    fn builder_errors_on_missing_required_fields() {
2817        let trunk = Trunk::new(TrunkConfig::new(nz(64), nz(8), nz(8), nz(8), nz(64)));
2818        match HlsOrigin::builder(Arc::clone(&trunk))
2819            .window_segments(nz(4))
2820            .instance(7)
2821            .build()
2822        {
2823            Err(e) => assert_eq!(e, HlsOriginBuildError::MissingTargetDurationSecs),
2824            Ok(_) => panic!("expected an error: target_duration_secs was never set"),
2825        }
2826        match HlsOrigin::builder(Arc::clone(&trunk))
2827            .target_duration_secs(4.0)
2828            .instance(7)
2829            .build()
2830        {
2831            Err(e) => assert_eq!(e, HlsOriginBuildError::MissingWindowSegments),
2832            Ok(_) => panic!("expected an error: window_segments was never set"),
2833        }
2834    }
2835
2836    #[test]
2837    fn container_label_and_display() {
2838        assert_eq!(Container::Fmp4.name(), "fmp4");
2839        assert_eq!(Container::MpegTs.name(), "mpeg-ts");
2840        assert_eq!(Container::Fmp4.to_string(), "fmp4");
2841        assert_eq!(Container::default(), Container::Fmp4);
2842    }
2843
2844    // --- issue #900: `closed_segments()` — the snapshot multimux's DVR
2845    //     catch-up serving merges with its on-disk archive ---
2846
2847    /// MUTATION VERIFIED: changing `Window::push`'s
2848    /// `start_ns: entry.timeline_position.as_nanos()` to a constant `0`
2849    /// makes this test's `assert_eq!(snapshot[1].start_ns, 4_000_000_000)`
2850    /// fail (`left: 0, right: 4000000000`) — `closed_segments()` would then
2851    /// report every segment as starting at time zero, which is exactly the
2852    /// bug that would make a caller's time-based catch-up window (issue
2853    /// #900) unable to tell segments apart. Recompiled and re-run to
2854    /// confirm the failure, then reverted.
2855    #[test]
2856    fn closed_segments_snapshot_matches_published_segments_ascending() {
2857        let (_trunk, origin, writer) = make_origin();
2858        writer
2859            .publish_segment(SegmentEntry::new(
2860                Bytes::from(vec![1u8; 8]),
2861                1,
2862                Duration::from_secs_f64(4.0),
2863                Timestamp::from_nanos(0),
2864                SegmentMeta {
2865                    discontinuous: false,
2866                },
2867            ))
2868            .unwrap();
2869        writer
2870            .publish_segment(SegmentEntry::new(
2871                Bytes::from(vec![2u8; 8]),
2872                2,
2873                Duration::from_secs_f64(4.0),
2874                Timestamp::from_nanos(4_000_000_000),
2875                SegmentMeta {
2876                    discontinuous: true,
2877                },
2878            ))
2879            .unwrap();
2880
2881        let snapshot = origin.closed_segments();
2882        assert_eq!(snapshot.len(), 2);
2883        assert_eq!(snapshot[0].sequence_number, 1);
2884        assert_eq!(snapshot[0].start_ns, 0);
2885        assert!(!snapshot[0].discontinuous);
2886        assert_eq!(snapshot[1].sequence_number, 2);
2887        assert_eq!(snapshot[1].start_ns, 4_000_000_000);
2888        assert!(snapshot[1].discontinuous);
2889    }
2890
2891    /// A segment evicted from the window (beyond `window_segments`
2892    /// capacity) no longer appears in the snapshot — `closed_segments()`
2893    /// reports the *advertised* window, the same one `render_playlist`
2894    /// renders, not every segment ever published.
2895    #[test]
2896    fn closed_segments_reflects_window_eviction() {
2897        let (_trunk, origin, writer) = make_origin(); // window_segments = 4
2898        for seq in 1..=5 {
2899            seg(&writer, seq, 4.0, false);
2900        }
2901        let snapshot = origin.closed_segments();
2902        let seqs: Vec<u32> = snapshot.iter().map(|s| s.sequence_number).collect();
2903        assert_eq!(
2904            seqs,
2905            vec![2, 3, 4, 5],
2906            "oldest segment 1 must have rolled off"
2907        );
2908    }
2909
2910    fn window_entry(seq: u32, tag: u8, discontinuous: bool) -> SegmentEntry {
2911        SegmentEntry::new(
2912            Bytes::from(vec![tag; 4]),
2913            seq,
2914            Duration::from_secs(4),
2915            Timestamp::from_nanos(0),
2916            SegmentMeta { discontinuous },
2917        )
2918    }
2919
2920    fn window_seqs(w: &Window) -> Vec<(u32, bool)> {
2921        w.segments
2922            .iter()
2923            .map(|s| (s.sequence_number, s.discontinuous))
2924            .collect()
2925    }
2926
2927    // r09-W11: the Trunk's `publish_segment` refuses a decreasing number, but
2928    // the window must not depend on that: a repeat or decrease clears it
2929    // instead of holding two entries under one number (the old `bytes_of`
2930    // served the stale one).
2931    #[test]
2932    fn window_restarts_on_a_repeated_or_decreasing_sequence_number() {
2933        let mut w = Window::new(nz(6));
2934        w.push(window_entry(5, 0xA5, false), 1);
2935        w.push(window_entry(6, 0xA6, true), 1);
2936        assert_eq!(window_seqs(&w), vec![(5, false), (6, true)]);
2937
2938        // Decrease: 3 after 6.
2939        w.push(window_entry(3, 0xB3, false), 1);
2940        assert_eq!(window_seqs(&w), vec![(3, true)]);
2941        // The discontinuous 6 rolled off the front.
2942        assert_eq!(w.discontinuity_sequence, 1);
2943        assert_eq!(w.bytes_of(3), Some(Bytes::from(vec![0xB3; 4])));
2944        assert_eq!(w.bytes_of(5), None);
2945        assert_eq!(w.bytes_of(6), None);
2946
2947        // Repeat: 3 again with different bytes replaces, never duplicates.
2948        w.push(window_entry(3, 0xC3, false), 1);
2949        assert_eq!(window_seqs(&w), vec![(3, true)]);
2950        assert_eq!(w.bytes_of(3), Some(Bytes::from(vec![0xC3; 4])));
2951    }
2952
2953    // Hostile edge: the largest sequence number has no successor; a repeat of
2954    // it must restart the window, not overflow `last + 1`.
2955    #[test]
2956    fn window_handles_u32_max_sequence_numbers() {
2957        let mut w = Window::new(nz(4));
2958        w.push(window_entry(u32::MAX - 1, 1, false), 1);
2959        w.push(window_entry(u32::MAX, 2, false), 1);
2960        assert_eq!(
2961            window_seqs(&w),
2962            vec![(u32::MAX - 1, false), (u32::MAX, false)]
2963        );
2964        w.push(window_entry(u32::MAX, 3, false), 1);
2965        assert_eq!(window_seqs(&w), vec![(u32::MAX, true)]);
2966        assert_eq!(w.bytes_of(u32::MAX), Some(Bytes::from(vec![3; 4])));
2967    }
2968
2969    #[test]
2970    fn window_capacity_is_never_below_three_segments() {
2971        let mut w = Window::new(nz(1));
2972        for seq in 1..=5 {
2973            w.push(window_entry(seq, 0, false), 1);
2974        }
2975        assert_eq!(window_seqs(&w), vec![(3, false), (4, false), (5, false)]);
2976    }
2977
2978    /// A measured peak bitrate above `u32::MAX` must surface as the typed
2979    /// [`HlsMasterError::BandwidthOverflow`] (was an untyped `String`).
2980    #[test]
2981    fn master_playlist_reports_typed_bandwidth_overflow() {
2982        let (_trunk, origin, writer) = make_origin();
2983        // 8 bytes (64 bits) in 10 ns = 6.4e9 b/s > u32::MAX.
2984        writer
2985            .publish_segment(SegmentEntry::new(
2986                Bytes::from(vec![1u8; 8]),
2987                1,
2988                Duration::from_nanos(10),
2989                Timestamp::from_nanos(0),
2990                SegmentMeta {
2991                    discontinuous: false,
2992                },
2993            ))
2994            .unwrap();
2995        assert_eq!(
2996            origin.master_playlist("media.m3u8"),
2997            Err(HlsMasterError::BandwidthOverflow {
2998                bandwidth: 6_400_000_000
2999            })
3000        );
3001    }
3002
3003    #[test]
3004    fn segment_bandwidth_is_rounded_up_and_rejects_degenerate_input() {
3005        // 1001 B * 8 / 3 s = 2669.33 -> 2670.
3006        assert_eq!(
3007            segment_bandwidth_bps(1001, Duration::from_secs(3)),
3008            Some(2670)
3009        );
3010        // Exact division stays exact.
3011        assert_eq!(
3012            segment_bandwidth_bps(1_500_000, Duration::from_secs(1)),
3013            Some(12_000_000)
3014        );
3015        // Zero duration: nothing to measure.
3016        assert_eq!(segment_bandwidth_bps(10, Duration::ZERO), None);
3017        // A one-nanosecond segment of huge size overflows u64 b/s.
3018        assert_eq!(
3019            segment_bandwidth_bps(usize::MAX, Duration::from_nanos(1)),
3020            None
3021        );
3022    }
3023
3024    #[test]
3025    fn bytes_of_addresses_exactly_the_matching_entry() {
3026        let mut w = Window::new(nz(6));
3027        for (seq, tag) in [(5u32, 0x15u8), (6, 0x16), (7, 0x17)] {
3028            w.push(window_entry(seq, tag, false), 1);
3029        }
3030        assert_eq!(w.bytes_of(5), Some(Bytes::from(vec![0x15; 4])));
3031        assert_eq!(w.bytes_of(6), Some(Bytes::from(vec![0x16; 4])));
3032        assert_eq!(w.bytes_of(7), Some(Bytes::from(vec![0x17; 4])));
3033        assert_eq!(w.bytes_of(4), None);
3034        assert_eq!(w.bytes_of(8), None);
3035        assert_eq!(w.bytes_of(u32::MAX), None);
3036    }
3037
3038    // --- r09-O4 / T6 (#1089, #1134): lock discipline ---------------------
3039
3040    fn segment_request(name: &str) -> HlsRequest {
3041        HlsRequest::Resource {
3042            name: name.to_string(),
3043        }
3044    }
3045
3046    /// With nothing published since the last drain, a request must not need
3047    /// the cursor lock. Holding that lock from the test thread makes the old
3048    /// always-drain code block (the request thread then misses the timeout).
3049    #[test]
3050    fn a_request_with_nothing_new_does_not_take_the_cursor_lock() {
3051        let (_trunk, origin, writer) = make_origin();
3052        seg(&writer, 1, 4.0, false);
3053        assert!(matches!(
3054            resolve_now(&origin, segment_request("seg-1-7-1.m4s")),
3055            EgressResponse::Ready { .. }
3056        ));
3057
3058        let guard = locked(&origin.cursor);
3059        let (tx, rx) = std::sync::mpsc::channel();
3060        std::thread::scope(|s| {
3061            s.spawn(|| {
3062                let _ = tx.send(resolve_now(&origin, segment_request("seg-1-7-1.m4s")));
3063            });
3064            let got = rx.recv_timeout(Duration::from_secs(10));
3065            drop(guard); // always release, so the scope can join
3066            assert!(
3067                matches!(got, Ok(EgressResponse::Ready { .. })),
3068                "request blocked on the cursor lock: {got:?}"
3069            );
3070        });
3071    }
3072
3073    /// The skip never hides a real publish: a segment closed after the last
3074    /// drain is served by the very next request.
3075    #[test]
3076    fn a_segment_published_after_a_drain_is_served_by_the_next_request() {
3077        let (_trunk, origin, writer) = make_origin();
3078        seg(&writer, 1, 4.0, false);
3079        assert!(matches!(
3080            resolve_now(&origin, segment_request("seg-1-7-1.m4s")),
3081            EgressResponse::Ready { .. }
3082        ));
3083        assert_eq!(
3084            resolve_now(&origin, segment_request("seg-1-7-2.m4s")),
3085            EgressResponse::NotFound
3086        );
3087        seg(&writer, 2, 4.0, false);
3088        assert!(matches!(
3089            resolve_now(&origin, segment_request("seg-1-7-2.m4s")),
3090            EgressResponse::Ready { .. }
3091        ));
3092    }
3093
3094    /// A panic while some thread held an origin lock must not become a panic
3095    /// in every later request.
3096    #[test]
3097    fn a_poisoned_lock_does_not_cascade_into_later_requests() {
3098        let (_trunk, origin, writer) = make_origin();
3099        seg(&writer, 1, 4.0, false);
3100        std::thread::scope(|s| {
3101            let origin = &origin;
3102            for which in 0..4 {
3103                let handle = s.spawn(move || {
3104                    // Hold one of the origin's mutexes across a panic.
3105                    match which {
3106                        0 => drop(origin.window.lock().map(|_g| panic!("poison window"))),
3107                        1 => drop(origin.cursor.lock().map(|_g| panic!("poison cursor"))),
3108                        2 => drop(origin.init.lock().map(|_g| panic!("poison init"))),
3109                        _ => drop(origin.codecs.lock().map(|_g| panic!("poison codecs"))),
3110                    }
3111                });
3112                assert!(handle.join().is_err(), "setup thread {which} must panic");
3113            }
3114        });
3115        assert!(origin.window.is_poisoned());
3116        assert!(origin.cursor.is_poisoned());
3117        assert!(origin.init.is_poisoned());
3118        assert!(origin.codecs.is_poisoned());
3119        // Every public entry point still answers.
3120        seg(&writer, 2, 4.0, false);
3121        assert_eq!(origin.closed_segments().len(), 2);
3122        assert!(origin.init_bytes().is_some());
3123        assert!(origin.master_playlist("media.m3u8").is_ok());
3124        origin.set_init(vec![0xCC; 8]);
3125        assert!(render_body(&origin).contains("seg-1-7-2.m4s"));
3126        assert!(matches!(
3127            resolve_now(&origin, segment_request("seg-1-7-1.m4s")),
3128            EgressResponse::Ready { .. }
3129        ));
3130    }
3131}