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