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}