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