multimux/source/mod.rs
1//! Ingest sources feeding the segmentation pipeline. `RtspSource` (RTSP
2//! pull), `RtpUdpSource` (raw RTP over UDP, uni/multicast), `TsUdpSource`
3//! (MPEG-2 TS over UDP, uni/multicast), `ts_http::TsHttpSource` (MPEG-2 TS
4//! over HTTP), `hls_pull::HlsPullRoute` (pull a remote (LL-)HLS origin),
5//! `dash_pull::DashPullRoute` (pull a remote MPEG-DASH origin, issue #758),
6//! `smooth_pull::SmoothPullRoute` (pull a remote Microsoft Smooth Streaming
7//! origin, issue #759), `rtmp::RtmpRoute` (RTMP push ingest, issue #738 — a
8//! *push* source implementing `media_plane::ingress::Listener` since issue
9//! #805 task 4; every other source above dials out), and `srt::SrtRoute`
10//! (SRT-carried MPEG-2 TS ingest, issue #739 — listener *or* caller mode) all
11//! implement the `Source` marker trait; every one — including
12//! `InputSpec::Custom`, via a [`crate::registry::SchemeRegistry`]-provided
13//! factory, since issue #805 task 5 deleted the old
14//! `SourceConnector`/`supervise`/`pipeline` path — is driven over
15//! `media_plane::ingress` (`Dialer`/`Listener` + `IngestSession`) by
16//! [`crate::origin::supervisor::supervise_driver`]. [`advance_route`] is the
17//! one per-iteration call every driver-backed `run_*` in this module makes
18//! (and that a `SchemeRegistry`-registered `Custom` factory's own driver loop
19//! must make too — see `examples/custom_scheme.rs`); it is `pub` for exactly
20//! that reason, not just for this crate's own in-tree sources.
21//! `report_driver_progress`/`segment::drive_program_segmenters` are the two
22//! steps it bundles — both `pub(crate)` (issue #805 task 6 narrowed them back
23//! from `pub`; see [`advance_route`]'s own doc for why one call replaced two).
24//! `http_auth` is shared auth glue for the HTTP-based sources (issue #663
25//! P3c).
26
27pub mod dash_pull;
28pub mod file_reader;
29pub mod hls_pull;
30pub mod http_auth;
31pub mod rtmp;
32pub mod rtp_udp;
33pub mod rtsp;
34pub mod sdp;
35pub mod segment;
36pub mod smooth_pull;
37pub mod srt;
38pub mod ts_http;
39pub mod ts_program;
40pub mod ts_udp;
41pub(crate) mod udp;
42// WHIP push input (issue #740) — needs `webrtc_runtime::media`, MSRV 1.88 (see
43// the `whip` feature's doc in `Cargo.toml`); kept out of the default,
44// MSRV-1.86-clean build entirely.
45#[cfg(feature = "whip")]
46pub mod whip;
47
48use std::time::Duration;
49
50/// Read-size hint every MPEG-2 TS transport reports via
51/// [`broadcast_common::Stage::demand`], and the read-buffer size the
52/// datagram transports allocate — comfortably above a typical 7×188-byte
53/// (1316-byte) TS-over-UDP payload and any legal UDP datagram (65 507 bytes
54/// over IPv4), so a single `recv` always captures a whole datagram.
55pub const MAX_TS_READ: usize = 65_536;
56
57/// Hard cap on concurrently in-flight HTTP fetches a pull source
58/// (`hls_pull`/`dash_pull`/`smooth_pull`, plan step 5a round 3) keeps open at
59/// once.
60///
61/// A pull source's sans-IO session can hand back many `poll_transmit`
62/// requests in one drain — an LL-HLS playlist reload can reveal a dozen
63/// already-available parts at once; a DASH/Smooth manifest refresh can extend
64/// several Representations'/StreamIndexes' plans simultaneously — with
65/// nothing in the session itself limiting how many the driver launches as
66/// concurrent requests. This project has already shipped five
67/// unbounded-allocation vectors in code driven by remote input (see
68/// `media_plane::ingress`'s own `max_programs`/`max_sessions` docs); an
69/// uncapped fan-out of concurrent fetches against a single origin is exactly
70/// that class of bug (a hostile or malformed playlist/manifest could turn one
71/// route into an unbounded number of open sockets), so each pull source's own
72/// tokio drive loop launches at most this many fetches at once, queuing the
73/// rest until a slot frees up — never blocking the sans-IO session from
74/// producing more requests, only how many the IO side acts on concurrently.
75pub const MAX_INFLIGHT_FETCHES: usize = 8;
76
77/// `true` while a pull source's drive loop may launch one more concurrent
78/// fetch — i.e. `inflight` is still below [`MAX_INFLIGHT_FETCHES`].
79///
80/// A named predicate rather than an inline `<` in each of the three loops so
81/// the bound is one testable decision instead of three copies of a comparison
82/// (the shape that lets one of them silently drift). Every
83/// `source::{hls_pull, dash_pull, smooth_pull}` loop gates its
84/// `JoinSet::spawn` on this.
85pub fn may_spawn_fetch(inflight: usize) -> bool {
86 inflight < MAX_INFLIGHT_FETCHES
87}
88
89#[cfg(test)]
90mod inflight_tests {
91 use super::{MAX_INFLIGHT_FETCHES, may_spawn_fetch};
92
93 /// The in-flight cap actually caps. Bites on the two mutations that
94 /// matter: dropping the gate (making this always `true`) and inverting
95 /// the comparison.
96 #[test]
97 fn may_spawn_fetch_stops_exactly_at_the_cap() {
98 assert!(may_spawn_fetch(0), "an idle loop must be able to spawn");
99 assert!(
100 may_spawn_fetch(MAX_INFLIGHT_FETCHES - 1),
101 "one slot short of the cap must still spawn"
102 );
103 assert!(
104 !may_spawn_fetch(MAX_INFLIGHT_FETCHES),
105 "at the cap, no further fetch may be launched"
106 );
107 assert!(
108 !may_spawn_fetch(MAX_INFLIGHT_FETCHES + 1),
109 "past the cap (a caller that over-spawned) must not spawn more"
110 );
111 }
112}
113
114use transmux::pipeline::CodecConfig;
115use transmux::rtp::RtpMediaKind;
116
117/// Default bound on how long a source's `connect()` waits for the ingest
118/// handshake to complete (TCP/TLS connect, plus any protocol handshake —
119/// RTSP DESCRIBE/SETUP/PLAY, or waiting for the first PMT/init segment) —
120/// issue #663 P5 (audit-ingest #3): a stalled/half-open server (accepts the
121/// TCP connection but never replies) must not hang `connect()` forever,
122/// starving [`crate::origin::supervisor::supervise_driver`]'s backoff of a
123/// chance to retry.
124pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
125
126/// Default bound on how long a source's per-read step (one RTSP interleaved
127/// frame, one HTTP body chunk, one UDP datagram, one HLS-pull client output)
128/// waits before the read is treated as a stall — issue #663 P5 (audit-ingest
129/// #3): the supervisor already reconnects on an `Err`, but only if one is
130/// ever produced; without a read timeout a source that goes silent (wedged
131/// server, dropped multicast feed) never signals anything and the route
132/// silently stops advancing forever. Generous relative to any real source's
133/// normal packet cadence (even a low-bitrate stream sends *something* well
134/// within 30 s) while still bounding a genuinely dead connection.
135pub const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(30);
136
137/// Ingest connect/read timeout bounds (issue #663 P5, audit-ingest #3),
138/// shared by every source kind so [`crate::config::Config`] only needs two
139/// process-wide knobs rather than one pair per input type — mirrors
140/// [`crate::origin::HttpLimits`]'s "one config-surfaced struct, sane
141/// [`Default`], per-source `with_timeouts` builder" shape.
142///
143/// A source's `connect()` wraps its whole connect handshake in
144/// [`Self::connect`]; its `next_samples()`/read loop wraps each individual
145/// read in [`Self::read`]. Either expiring surfaces as a
146/// [`crate::error::MultimuxError`], which
147/// [`crate::origin::supervisor::supervise_driver`] treats exactly like any
148/// other ingest error — log, mark the route reconnecting, retry with
149/// backoff — never a silent hang.
150#[derive(Debug, Clone, Copy)]
151pub struct IngestTimeouts {
152 /// Bound on the whole connect handshake.
153 pub connect: Duration,
154 /// Bound on a single read/receive step once connected.
155 pub read: Duration,
156}
157
158impl Default for IngestTimeouts {
159 fn default() -> Self {
160 IngestTimeouts {
161 connect: DEFAULT_CONNECT_TIMEOUT,
162 read: DEFAULT_READ_TIMEOUT,
163 }
164 }
165}
166
167impl From<&crate::config::Config> for IngestTimeouts {
168 fn from(cfg: &crate::config::Config) -> Self {
169 IngestTimeouts {
170 connect: Duration::from_secs_f64(cfg.ingest_connect_timeout_secs),
171 read: Duration::from_secs_f64(cfg.ingest_read_timeout_secs),
172 }
173 }
174}
175
176// --- issue #805 task 2: driver-backed ingest <-> RouteHandle registry glue ---
177//
178// Shared by every `run_*` entry point in this module (`rtsp::run_rtsp`,
179// `rtp_udp::run_rtp_udp`, `ts_udp::run_ts_udp`, `ts_http::run_ts_http`,
180// `srt::drive_socket`, `hls_pull::run_hls_pull`, `dash_pull::run_dash_pull`,
181// `smooth_pull::run_smooth_pull`) so none of them re-implements the "flip the
182// route Live the first time the driver establishes" / "publish each
183// newly-announced program" bookkeeping independently.
184
185fn source_nz(n: usize) -> std::num::NonZeroUsize {
186 std::num::NonZeroUsize::new(n).expect("source::mod.rs capacity constants are all non-zero")
187}
188
189/// Ring capacities for a driver-minted per-program `Trunk`
190/// ([`driver_trunk_config`]) — chosen to match [`crate::route::RouteHandle`]'s
191/// own defaults (that struct's own, private, `DEFAULT_*_CAPACITY` constants)
192/// so a driver-backed route's `Trunk` behaves comparably to the legacy
193/// segmenter-fed one, even though nothing here shares the constants directly
194/// (a driver-minted `Trunk` is a distinct instance per program, never
195/// `RouteHandle`'s own).
196const DRIVER_TIMED_CAPACITY: usize = 64;
197const DRIVER_SPARSE_CAPACITY: usize = 16;
198const DRIVER_EVENT_CAPACITY: usize = 64;
199const DRIVER_PART_CAPACITY: usize = 64;
200
201/// Builds the [`media_plane::trunk::TrunkConfig`] every driver-backed `run_*`
202/// entry point passes to its `IngestDriver::new` — see [`DRIVER_TIMED_CAPACITY`]
203/// et al. for the chosen ring sizes. `window_segments` (the segment log's
204/// capacity) is the one caller-supplied knob, mirroring
205/// [`crate::config::Config::window_segments`]/[`crate::route::RouteHandle::new`]'s
206/// own "advertised window == retained window" depth.
207pub(crate) fn driver_trunk_config(window_segments: usize) -> media_plane::trunk::TrunkConfig {
208 let window_segments =
209 std::num::NonZeroUsize::new(window_segments).unwrap_or(std::num::NonZeroUsize::MIN);
210 media_plane::trunk::TrunkConfig::new(
211 source_nz(DRIVER_TIMED_CAPACITY),
212 source_nz(DRIVER_SPARSE_CAPACITY),
213 window_segments,
214 source_nz(DRIVER_EVENT_CAPACITY),
215 source_nz(DRIVER_PART_CAPACITY),
216 )
217}
218
219/// Builds a production [`media_plane::ingress::HandshakePolicy`] bounding a
220/// fresh session's handshake by `timeout`, expressed as nanoseconds-since-zero
221/// rather than a real wall-clock [`broadcast_common::Timestamp`].
222///
223/// Every driver-backed `run_*` entry point measures its own
224/// `Stage::feed`/`Stage::on_deadline` `now` from an internal `Instant` it
225/// captures at entry (e.g. `rtsp::run_rtsp`'s own `start`) — an instant this
226/// caller cannot observe in advance, since it doesn't exist until `run_*` is
227/// actually called. Expressing the deadline as "nanoseconds since a start
228/// near zero" (matching this crate's own test fixtures, e.g.
229/// `ts_program::test_support::handshake`) rather than a predicted absolute
230/// instant sidesteps that: both clocks start within microseconds of each
231/// other in practice (this function runs immediately before the `run_*` call
232/// it bounds), which is immaterial against a multi-second `timeout`.
233pub(crate) fn handshake_policy(timeout: Duration) -> media_plane::ingress::HandshakePolicy {
234 let nanos = u64::try_from(timeout.as_nanos()).unwrap_or(u64::MAX);
235 media_plane::ingress::HandshakePolicy::establish_by(broadcast_common::Timestamp::from_nanos(
236 nanos,
237 ))
238}
239
240/// After draining a driver-backed session (`IngestDriver::feed`/
241/// `on_deadline`), every `run_*` entry point calls this once per iteration to
242/// keep `route_handle` in sync with what the driver has actually observed
243/// (issue #805 task 2):
244///
245/// - The first time `driver.health()` reaches
246/// [`media_plane::ingress::HealthState::Live`], flips `route_handle` to
247/// [`crate::route::HealthState::Live`] — the driver-backed equivalent of
248/// `origin::supervisor::supervise_driver`'s own health flip right after an
249/// attempt reaches `Live`. Guarded on `route_handle.health()` rather than a
250/// separate flag, since `route_handle`'s own health *is* the single source
251/// of truth `crate::origin::supervisor::supervise_driver` reads back after
252/// this attempt ends (see that function's own doc).
253/// - Every [`media_plane::ingress::ProgramId`] `driver` has announced (via
254/// `SessionEvent::NewProgram`) that this run hasn't already published gets
255/// published into `route_handle`'s registry
256/// (`RouteHandle::publish_program`, crate-private).
257///
258/// # `pub(crate)`, not `pub` (issue #805 task 6 narrowed this back)
259///
260/// This used to be `pub` so a [`crate::registry::SchemeRegistry`] `Custom`
261/// factory driving its own `Dialer`/`IngestSession` could call it directly.
262/// That left a plugin author hand-assembling `report_driver_progress` +
263/// [`segment::drive_program_segmenters`] themselves, in the right order,
264/// every iteration — a wrong order, or calling one without the other, could
265/// silently ingest with nothing ever becoming servable. [`advance_route`] is
266/// now the one supported call for that; this function (and
267/// `drive_program_segmenters`) are its private implementation.
268pub(crate) fn report_driver_progress<S: media_plane::ingress::IngestSession>(
269 driver: &media_plane::ingress::IngestDriver<S>,
270 route_handle: &crate::route::RouteHandle,
271 published: &mut std::collections::HashSet<media_plane::ingress::ProgramId>,
272 track_generations: &mut std::collections::HashMap<media_plane::ingress::ProgramId, u64>,
273) {
274 if matches!(driver.health(), media_plane::ingress::HealthState::Live)
275 && route_handle.health() != crate::route::HealthState::Live
276 {
277 route_handle.set_health(crate::route::HealthState::Live);
278 }
279 for program in driver.programs() {
280 if published.insert(program)
281 && let Some(trunk) = driver.trunk(program)
282 {
283 route_handle.publish_program(program, std::sync::Arc::clone(trunk));
284 }
285 }
286 // Sync track specs from each published program's trunk into the route
287 // handle — the one piece of codec metadata the DASH/LL-DASH renderers
288 // need that no Trunk ring holds (issue #831: this sync was missing,
289 // shipping every driver-backed route with 503-forever DASH/LL-DASH).
290 for program in driver.programs() {
291 let Some(trunk) = driver.trunk(program) else {
292 continue;
293 };
294 let generation = trunk.track_generation();
295 if generation == 0 {
296 continue;
297 }
298 let last = track_generations.get(&program).copied();
299 if last == Some(generation) {
300 continue;
301 }
302 let tracks = trunk.tracks();
303 route_handle.set_track_specs(program, tracks.to_vec());
304 track_generations.insert(program, generation);
305 }
306}
307
308/// Opaque per-attempt state [`advance_route`] threads across every call for
309/// one connection attempt: the dedup set `report_driver_progress` needs, plus
310/// the `segment::ProgramSegmenter` map `segment::drive_program_segmenters`
311/// needs. A caller (every in-tree `run_*`, or an external
312/// [`crate::registry::SchemeRegistry`] `Custom` factory's own drive loop —
313/// see `examples/custom_scheme.rs`) declares one fresh [`DriverProgress::new`]
314/// per connection attempt and passes it, by `&mut` reference, to
315/// [`advance_route`] on every iteration for that attempt's whole lifetime —
316/// never constructing or reading either of its fields directly (both are
317/// private; this type exists precisely so a caller never has to know their
318/// shape).
319#[derive(Default)]
320pub struct DriverProgress {
321 published: std::collections::HashSet<media_plane::ingress::ProgramId>,
322 segmenters:
323 std::collections::HashMap<media_plane::ingress::ProgramId, segment::ProgramSegmenter>,
324 /// Last-seen [`media_plane::trunk::Trunk::track_generation`] per program
325 /// — compared each call to avoid an unconditional `set_track_specs` on
326 /// every poll (issue #831: a missing sync here shipped DASH/LL-DASH 503
327 /// forever for every driver-backed route).
328 track_generations: std::collections::HashMap<media_plane::ingress::ProgramId, u64>,
329}
330
331impl DriverProgress {
332 /// Fresh, empty state for a new connection attempt.
333 pub fn new() -> Self {
334 DriverProgress::default()
335 }
336}
337
338/// **The one facade call** a driver-backed drive loop makes once per
339/// iteration, after every [`media_plane::ingress::IngestDriver::feed`]/
340/// `on_deadline`/`finish` — replaces what used to be a caller-assembled pair,
341/// `report_driver_progress` then `segment::drive_program_segmenters`,
342/// over two separately-declared collections (`published`/`segmenters`) a
343/// caller had to know to build, order correctly, and pass consistently.
344///
345/// Both steps still exist (as `pub(crate)` internals of this crate — see
346/// their own docs) because they are genuinely two different jobs (registry
347/// publish + health flip; sample-to-segment/part turning), but a caller
348/// outside this crate has exactly one thing to call, over exactly one opaque
349/// state value ([`DriverProgress`]), so the order can never be gotten wrong
350/// and neither step can be silently skipped. See `examples/custom_scheme.rs`
351/// for the supported shape this replaces (that example used to call
352/// `report_driver_progress`/`drive_program_segmenters` directly; it now calls
353/// only this).
354pub fn advance_route<S: media_plane::ingress::IngestSession>(
355 driver: &media_plane::ingress::IngestDriver<S>,
356 route_handle: &crate::route::RouteHandle,
357 state: &mut DriverProgress,
358) {
359 report_driver_progress(
360 driver,
361 route_handle,
362 &mut state.published,
363 &mut state.track_generations,
364 );
365 segment::drive_program_segmenters(driver, route_handle, &mut state.segmenters);
366 // Drain DVR cursors for every published program — recording happens
367 // after segmenters have published new segments to the Trunk.
368 route_handle.drain_dvr();
369}
370
371#[cfg(test)]
372mod driver_progress_tests {
373 //! Coverage for [`report_driver_progress`] — the shared ingest-side
374 //! registry/health bookkeeping every driver-backed `run_*` entry point
375 //! calls (issue #805 task 2). Uses `media_plane::ingress`'s own
376 //! `ScriptedSession`-style construction indirectly via a minimal fake
377 //! `IngestSession`, so this is a fast, deterministic unit test rather
378 //! than a real-socket loopback one.
379
380 use super::*;
381 use broadcast_common::{Demand, Stage, Timestamp};
382 use media_plane::ingress::{
383 Dialer, HandshakePolicy, IngestDriver, IngestSession, ProgramId, SessionEvent,
384 };
385 use media_plane::trunk::TrunkConfig;
386 use std::collections::HashSet;
387
388 fn nz(n: usize) -> std::num::NonZeroUsize {
389 std::num::NonZeroUsize::new(n).unwrap()
390 }
391
392 fn trunk_config() -> TrunkConfig {
393 TrunkConfig::new(nz(4), nz(4), nz(4), nz(4), nz(4))
394 }
395
396 /// A session that queues `Established` at construction, then a single
397 /// `NewProgram { program: ProgramId(0), tracks: vec![] }` on its
398 /// *second* `feed` call (not its first, which only drains `Established`)
399 /// — enough to drive `report_driver_progress` through both of its jobs
400 /// (health flip, then program publish) as two separate, observable
401 /// steps, without a real transport.
402 struct FakeSession {
403 pending: std::collections::VecDeque<SessionEvent>,
404 feed_count: u32,
405 }
406
407 impl Stage for FakeSession {
408 type In<'a> = &'a [u8];
409 type Out = SessionEvent;
410 type Error = std::convert::Infallible;
411
412 fn demand(&self) -> Demand {
413 Demand::new(4096)
414 }
415
416 fn feed(&mut self, _input: &[u8], _now: Timestamp) -> Result<(), Self::Error> {
417 self.feed_count += 1;
418 if self.feed_count == 2 {
419 self.pending.push_back(SessionEvent::NewProgram {
420 program: ProgramId(0),
421 tracks: Vec::new(),
422 });
423 }
424 Ok(())
425 }
426
427 fn poll(&mut self) -> Option<SessionEvent> {
428 self.pending.pop_front()
429 }
430
431 fn next_deadline(&self) -> Option<Timestamp> {
432 None
433 }
434
435 fn on_deadline(&mut self, _now: Timestamp) {}
436
437 fn finish(&mut self) -> Result<(), Self::Error> {
438 Ok(())
439 }
440 }
441
442 impl IngestSession for FakeSession {
443 type Request = bytes::Bytes;
444 }
445
446 struct FakeDialer;
447
448 impl Dialer for FakeDialer {
449 type Session = FakeSession;
450 type Error = std::convert::Infallible;
451
452 fn dial(&mut self) -> Result<FakeSession, std::convert::Infallible> {
453 let mut pending = std::collections::VecDeque::new();
454 pending.push_back(SessionEvent::Established);
455 Ok(FakeSession {
456 pending,
457 feed_count: 0,
458 })
459 }
460 }
461
462 fn driver() -> IngestDriver<FakeSession> {
463 let mut dialer = FakeDialer;
464 let session = dialer.dial().unwrap();
465 IngestDriver::new(
466 session,
467 trunk_config(),
468 HandshakePolicy::establish_by(Timestamp::from_nanos(u64::MAX)),
469 nz(4),
470 )
471 }
472
473 /// MUTATION VERIFIED: changing the health-flip guard from
474 /// `route_handle.health() != crate::route::HealthState::Live` to `true`
475 /// (always overwrite) still passes this specific assertion (both reach
476 /// `Live`), but changing `matches!(driver.health(), ...HealthState::Live)`
477 /// to unconditionally `false` (i.e. never flip on Live) makes this test
478 /// fail: `assert_eq!(route.health(), crate::route::HealthState::Live)`
479 /// fails, comparing actual `HealthState::Connecting` against expected
480 /// `HealthState::Live` — the route never leaves its constructed default.
481 /// Recompiled and re-run to confirm the failure, then reverted.
482 #[test]
483 fn first_live_flips_route_health_to_live() {
484 let mut driver = driver();
485 let route = crate::route::RouteHandle::new(4.0, 500, 4);
486 let mut published = HashSet::new();
487 let mut track_generations = std::collections::HashMap::new();
488
489 // Establish: driver becomes Live once it drains SessionEvent::Established.
490 driver.feed(&[], Timestamp::from_nanos(1));
491 report_driver_progress(&driver, &route, &mut published, &mut track_generations);
492
493 assert_eq!(
494 route.health(),
495 crate::route::HealthState::Live,
496 "route must flip to Live the moment the driver itself reaches Live"
497 );
498 }
499
500 /// MUTATION VERIFIED: changing `published.insert(program)` to always
501 /// evaluate to `true` regardless of prior membership (i.e. dropping the
502 /// dedup and calling `route_handle.publish_program` unconditionally every
503 /// call) does not break this test's assertions (both are still
504 /// `Found`+identical `Arc`), but changing the loop's body to skip calling
505 /// `route_handle.publish_program` entirely (i.e. deleting the `if let
506 /// Some(trunk) = driver.trunk(program) { ... }` publish) makes this test
507 /// fail: `resolve_program` returns `NotYetAnnounced` (registry never
508 /// populated), so `match ... { Found(_) => ..., other => panic!(...) }`
509 /// panics naming the actual variant, not `Found`. Recompiled and re-run
510 /// to confirm the failure, then reverted.
511 #[test]
512 fn new_program_is_published_into_the_registry() {
513 let mut driver = driver();
514 let route = crate::route::RouteHandle::new(4.0, 500, 4);
515 let mut published = HashSet::new();
516 let mut track_generations = std::collections::HashMap::new();
517
518 driver.feed(&[], Timestamp::from_nanos(1)); // Established
519 report_driver_progress(&driver, &route, &mut published, &mut track_generations);
520 driver.feed(&[], Timestamp::from_nanos(2)); // NewProgram(0)
521 report_driver_progress(&driver, &route, &mut published, &mut track_generations);
522
523 let expected = driver.trunk(ProgramId(0)).expect("driver minted a Trunk");
524 match route.resolve_program(crate::route::SPTS_PROGRAM_ID) {
525 crate::route::ProgramResolution::Found(resolved) => {
526 assert_eq!(
527 std::sync::Arc::as_ptr(&resolved.trunk()),
528 std::sync::Arc::as_ptr(expected),
529 "published Trunk must be the exact Arc the driver minted"
530 );
531 }
532 _ => panic!("expected ProgramResolution::Found, got a variant that is not Found"),
533 }
534 }
535
536 /// MUTATION VERIFIED: replacing the `for program in driver.programs()`
537 /// loop body's dedup check (`if published.insert(program)`) with an
538 /// unconditional `true` still republishes correctly, but replacing the
539 /// whole loop with a no-op (never calling `driver.programs()` at all)
540 /// makes this test fail identically to the previous one — `resolve_program`
541 /// never sees an entry, so the `match` panics on the actual
542 /// (`NotYetAnnounced`) variant instead of matching `Found`. This test
543 /// additionally proves a *second* call with an already-published set does
544 /// not clear or corrupt the registry (calling `report_driver_progress`
545 /// twice in a row is exactly what a real per-iteration `run_*` loop does).
546 /// Recompiled and re-run to confirm the failure, then reverted.
547 #[test]
548 fn repeated_calls_with_no_new_programs_are_idempotent() {
549 let mut driver = driver();
550 let route = crate::route::RouteHandle::new(4.0, 500, 4);
551 let mut published = HashSet::new();
552 let mut track_generations = std::collections::HashMap::new();
553
554 driver.feed(&[], Timestamp::from_nanos(1));
555 report_driver_progress(&driver, &route, &mut published, &mut track_generations);
556 driver.feed(&[], Timestamp::from_nanos(2));
557 report_driver_progress(&driver, &route, &mut published, &mut track_generations);
558 // No new SessionEvents fed; calling again must be a harmless no-op.
559 report_driver_progress(&driver, &route, &mut published, &mut track_generations);
560
561 match route.resolve_program(crate::route::SPTS_PROGRAM_ID) {
562 crate::route::ProgramResolution::Found(_) => {}
563 _ => panic!("expected ProgramResolution::Found"),
564 }
565 }
566
567 /// When a program's track set goes from populated to **empty**, the
568 /// route's track specs must reflect the empty set — not keep serving the
569 /// stale old specs ([`crate::source::report_driver_progress`] issue #831
570 /// fix 1: the `if !tracks.is_empty()` guard skipped `set_track_specs`
571 /// when `tracks` was empty, leaving stale specs forever).
572 ///
573 /// MUTATION VERIFIED: adding the `if !tracks.is_empty()` guard back
574 /// (reverting fix 1) makes this test's `assert_eq!(specs.len(), 0, ...)`
575 /// fail: `left: [1], right: []` — the route's track specs still hold the
576 /// now-removed track from the first NewProgram, because the sync loop
577 /// silently skipped the empty set.
578 #[test]
579 fn empty_track_set_replaces_previous_populated_set() {
580 struct TrackSetSession {
581 pending: std::collections::VecDeque<SessionEvent>,
582 feed_count: u32,
583 }
584
585 impl Stage for TrackSetSession {
586 type In<'a> = &'a [u8];
587 type Out = SessionEvent;
588 type Error = std::convert::Infallible;
589
590 fn demand(&self) -> Demand {
591 Demand::new(4096)
592 }
593
594 fn feed(&mut self, _input: &[u8], _now: Timestamp) -> Result<(), Self::Error> {
595 self.feed_count += 1;
596 if self.feed_count == 2 {
597 self.pending.push_back(SessionEvent::NewProgram {
598 program: ProgramId(0),
599 tracks: vec![crate::source::ts_program::test_support::track_spec(1)],
600 });
601 } else if self.feed_count == 3 {
602 self.pending.push_back(SessionEvent::TracksChanged {
603 program: ProgramId(0),
604 tracks: Vec::new(),
605 });
606 }
607 Ok(())
608 }
609
610 fn poll(&mut self) -> Option<SessionEvent> {
611 self.pending.pop_front()
612 }
613 fn next_deadline(&self) -> Option<Timestamp> {
614 None
615 }
616 fn on_deadline(&mut self, _now: Timestamp) {}
617 fn finish(&mut self) -> Result<(), Self::Error> {
618 Ok(())
619 }
620 }
621
622 impl IngestSession for TrackSetSession {
623 type Request = bytes::Bytes;
624 }
625
626 let mut pending = std::collections::VecDeque::new();
627 pending.push_back(SessionEvent::Established);
628 let session = TrackSetSession {
629 pending,
630 feed_count: 0,
631 };
632 let mut driver = IngestDriver::new(
633 session,
634 trunk_config(),
635 HandshakePolicy::establish_by(Timestamp::from_nanos(u64::MAX)),
636 nz(4),
637 );
638 let route = crate::route::RouteHandle::new(4.0, 500, 4);
639 let mut published = HashSet::new();
640 let mut track_generations = std::collections::HashMap::new();
641
642 // Feed 1: Established.
643 driver.feed(&[], Timestamp::from_nanos(1));
644 report_driver_progress(&driver, &route, &mut published, &mut track_generations);
645 // Feed 2: NewProgram(0, [track_spec(1)]) — populates tracks.
646 driver.feed(&[], Timestamp::from_nanos(2));
647 report_driver_progress(&driver, &route, &mut published, &mut track_generations);
648 let specs = route.track_specs(ProgramId(0));
649 assert_eq!(
650 specs.len(),
651 1,
652 "track specs must reflect the announced track"
653 );
654
655 // Feed 3: TracksChanged(0, []) — clears tracks.
656 driver.feed(&[], Timestamp::from_nanos(3));
657 report_driver_progress(&driver, &route, &mut published, &mut track_generations);
658 let specs = route.track_specs(ProgramId(0));
659 assert_eq!(
660 specs.len(),
661 0,
662 "empty TracksChanged must replace the old track set — \
663 the route's track specs must reflect empty, not the stale old set"
664 );
665 }
666}
667
668#[cfg(test)]
669mod advance_route_tests {
670 //! Coverage for [`advance_route`] — the one facade call replacing a
671 //! caller-assembled `report_driver_progress` + `segment::drive_program_segmenters`
672 //! pair (issue #805 task 6). Drives a real muxed TS stream through it,
673 //! exactly mirroring `segment`'s own
674 //! `driver_backed_route_serves_real_media_through_ll_hls` test, to prove
675 //! the facade performs *both* steps (registry publish AND
676 //! sample-to-segment turning), not just one.
677
678 use super::*;
679 use crate::route::{ProgramResolution, RouteHandle, SPTS_PROGRAM_ID};
680 use crate::source::ts_program::TsIngestSession;
681 use crate::source::ts_program::test_support::{build_ts_bytes, handshake, trunk_config};
682 use broadcast_common::Timestamp;
683 use media_plane::ingress::IngestDriver;
684
685 /// MUTATION VERIFIED: changing `advance_route`'s body to call only
686 /// `report_driver_progress` (dropping the
687 /// `segment::drive_program_segmenters` line entirely) makes this test's
688 /// `assert!(route.init_bytes(SPTS_PROGRAM_ID).is_some_and(|b| !b.is_empty()), ...)`
689 /// fail: actual value `None` — the program is `Found` in the registry
690 /// (the first assertion below still passes), but nothing ever turned its
691 /// raw samples into a segmenter/init segment, exactly the "ingest
692 /// observable, playback not" gap this facade exists to make impossible to
693 /// half-wire. Recompiled and re-run to confirm the failure, then
694 /// reverted.
695 #[test]
696 fn advance_route_both_publishes_and_segments() {
697 let route = RouteHandle::new(1.0, 250, 8);
698 let mut driver = IngestDriver::new(
699 TsIngestSession::new(),
700 trunk_config(),
701 handshake(),
702 media_plane::DEFAULT_MAX_PROGRAMS,
703 );
704 let mut progress = DriverProgress::new();
705
706 let ts_bytes = build_ts_bytes(1, 0xAB, 90);
707 driver.feed(&ts_bytes, Timestamp::ZERO);
708 advance_route(&driver, &route, &mut progress);
709 let more = build_ts_bytes(1, 0xCD, 90);
710 driver.feed(&more, Timestamp::from_nanos(1));
711 advance_route(&driver, &route, &mut progress);
712
713 assert!(
714 matches!(
715 route.resolve_program(SPTS_PROGRAM_ID),
716 ProgramResolution::Found(_)
717 ),
718 "advance_route must publish the program into the registry"
719 );
720 assert!(
721 route
722 .init_bytes(SPTS_PROGRAM_ID)
723 .is_some_and(|b| !b.is_empty()),
724 "advance_route must also turn samples into a real, servable init segment"
725 );
726 }
727}
728
729/// Per-track init derived from an SDP (RTSP's DESCRIBE body, or the
730/// out-of-band SDP configured for [`rtp_udp::RtpUdpRoute`]).
731#[derive(Debug, Clone)]
732pub struct TrackInit {
733 /// 1-based track id used across the segmenter + playlist URIs.
734 pub track_id: u32,
735 /// Payload kind (H.264 / AAC).
736 pub kind: RtpMediaKind,
737 /// Codec config built from the SDP fmtp.
738 pub config: CodecConfig,
739 /// RTP clock rate (Hz) = IR timescale.
740 pub clock_rate: u32,
741 /// Per-media `a=control` URL suffix for SETUP (RTSP only; unused by
742 /// [`rtp_udp::RtpUdpRoute`], which has no control plane).
743 pub control: Option<String>,
744 /// Interleaved RTP channel assigned to this media (RTCP = channel + 1).
745 /// RTSP-only framing; unused by [`rtp_udp::RtpUdpRoute`].
746 pub channel: u8,
747 /// The media's declared RTP payload type (`m=<kind> <port> <proto>
748 /// <fmt>`, RFC 4566 §5.14) — the only signal a raw RTP/UDP source has to
749 /// route an incoming packet to its track (there is no interleaved
750 /// channel framing outside RTSP). RTSP ignores this field today (it
751 /// routes by interleaved channel instead) but it is populated
752 /// identically for both ingest paths since both go through the same
753 /// [`sdp::parse_sdp_tracks`].
754 pub payload_type: u8,
755}
756
757/// An ingest source that can be identified by name (e.g. for logging/metrics).
758///
759/// Kept minimal here; Task 5's `RtspSource` extends the ingest surface with
760/// the actual RTSP session driving.
761pub trait Source {
762 /// Human-readable stream name (e.g. the RTSP URL or config-file key).
763 fn stream_name(&self) -> &str;
764}