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