media_plane/ingress.rs
1//! `Dialer`/`Listener`/`IngestSession` — the ingress traits, and the generic
2//! `run_dial`/`run_listen` drivers that pump them into a [`crate::Trunk`]
3//! (plan step 3c;
4//! `docs/superpowers/specs/2026-07-26-media-plane-architecture.md` §2).
5//!
6//! Traits and generic drivers only — no real protocol is ported here (that
7//! is plan step 5, "port the 9 sources"). This module's job is to define a
8//! shape those nine `multimux::source::*` implementations can all actually
9//! be squeezed into, and to own the feed/poll/deadline/dispatch loop so no
10//! protocol has to reimplement it (every one of the nine today hand-rolls
11//! its own `while let Some(event) = demux.poll_event() { match event { .. } }`
12//! drain — see e.g. `multimux::source::ts_udp::TsUdpSession::next_samples`).
13//!
14//! `#[cfg(feature = "std")]`: like [`crate::trunk`], this module wires
15//! straight to [`crate::Trunk`] (`Arc`/`HashMap`), and every real consumer is
16//! `std`+`tokio` per the architecture (see `crate::trunk`'s module docs).
17//!
18//! # `IngestSession` is a `Stage`, matching `ByteStage`'s precedent
19//!
20//! Like [`crate::ByteStage`], `IngestSession` builds on
21//! [`broadcast_common::Stage`] — but, unlike `ByteStage`, it is not a bare
22//! blanket alias over it. Only the output is pinned:
23//!
24//! ```text
25//! pub trait IngestSession: for<'a> Stage<Out = SessionEvent> + Send {
26//! type Request: Send;
27//! fn poll_transmit(&mut self) -> Option<Self::Request> { None }
28//! }
29//! ```
30//!
31//! `Stage::In<'a>` is deliberately **not** pinned to `&'a [u8]` (round 3;
32//! it was through round 2) — see
33//! [Pull sources need a typed request/response identity](#pull-sources-need-a-typed-requestresponse-identity-round-3)
34//! below for why. This still buys the same things `ByteStage` documents for
35//! the byte-stream sources that make up most of the plane: one drive model,
36//! `finish()` for a clean end-of-input flush, and `demand()` for
37//! back-pressure — [`run_dial`]/[`run_listen`] drive any `IngestSession` with
38//! the same "feed, drain `poll()`, repeat" loop
39//! [`crate::byte_stage`]'s own tests already validate against a real `Stage`,
40//! whatever `In<'a>` an implementor chooses. The two things every
41//! `IngestSession` adds over a bare `Stage` are
42//! [`poll_transmit`](IngestSession::poll_transmit) (with a `None`-returning default —
43//! see
44//! [Why `poll_transmit` exists](#why-poll_transmit-exists-and-most-sources-will-never-override-it)
45//! below) and [`Request`](IngestSession::Request) (with **no** default — every
46//! implementor names its own request type explicitly; see the pull-sources
47//! section below for why it has none).
48//!
49//! [`ByteStage`]: crate::ByteStage
50//!
51//! ## Why `poll_transmit` exists, and most sources will never override it
52//!
53//! `RtspSession` (`multimux/src/source/rtsp.rs`) is driven over an
54//! interleaved TCP connection where the client is also expected to send
55//! (RTCP receiver reports, periodic keepalive) — a pure `feed(bytes) ->
56//! poll() -> SessionEvent` consumer has nowhere to hand that back. Rather
57//! than invent a second trait for the two or three sources that need it,
58//! [`IngestSession::poll_transmit`] is a plain method with a `None` default:
59//! a driver (this module's [`IngestDriver`]/[`ListenDriver`], or a future
60//! Step 5 adapter) drains it after every `feed`/`on_deadline` exactly like
61//! [`Stage::poll`], and a session with nothing to send simply never
62//! overrides it.
63//!
64//! # The program dimension (B5) — `SessionEvent::NewProgram` at any time
65//!
66//! Rev 1 of the architecture assumed one connection maps to exactly one
67//! timeline; the audit's finding B5 is that MPTS and T2-MI multi-PLP break
68//! this outright (`parse_pat` flattens every program; `program_number`
69//! appears nowhere in the demuxed IR). [`SessionEvent::NewProgram`] is how an
70//! `IngestSession` announces one: [`IngestDriver`]/[`ListenDriver`] mint a
71//! **fresh [`Trunk`]** for every [`ProgramId`] the moment it is announced —
72//! including the second, third, ... program on the *same* connection, and
73//! including one announced only after other programs (or samples for them)
74//! have already been flowing. There is no "known programs" list supplied up
75//! front and no special first-poll path: `NewProgram` is just another
76//! [`SessionEvent`] variant, driven through the exact same `poll()` drain as
77//! every `Sample`, so "a program appears mid-session" is not a distinct code
78//! path from "a program was there from the start" — it is the *only* path.
79//!
80//! This is deliberately more general than architecture §1.3's steady-state
81//! design (program-splitting as a `ByteStage` upstream of demux, so each
82//! `IngestSession` only ever sees one already-known program). That design is
83//! still the right Step 5 target for MPTS — it lets each program's demux run
84//! independently — but it presupposes the program table has already been
85//! read once to know how many `ByteStage`s to build, which is exactly the
86//! chicken-and-egg `multimux::source::ts_udp::TsUdpSession::next_samples`
87//! hits *today*: a PMT version bump that adds a **track** after `connect()`'s
88//! one-shot `track_specs()` snapshot is already only handled by logging a
89//! warning and dropping it (see that function's `DemuxEvent::TrackAdded`
90//! arm) — there is no live wiring for "new track" today, let alone "new
91//! program". `SessionEvent::NewProgram` is what closes the "new program"
92//! half of that gap generically: whether a program is split upstream (known
93//! before the session starts) or discovered while demuxing an MPTS in one
94//! session, the driver's reaction is identical — mint a `Trunk`, keep going.
95//! [`SessionEvent::TracksChanged`] (issue #781) closes the other half — the
96//! `ts_udp` warn-and-drop case cited above is exactly what an `IngestSession`
97//! now has a real event to emit instead of logging and discarding.
98//!
99//! # Supervision: EOF is not an error (the `HealthState` fix)
100//!
101//! Today's bug, concretely: `multimux::origin::supervisor::supervise` treats
102//! `run_pipeline`'s `Ok(())` (clean source EOF) and `Err(_)` (a real failure)
103//! identically — both fall into the same `set_health(HealthState::Reconnecting)`
104//! arm (`multimux/src/origin/supervisor.rs`) — and
105//! `hls_runtime::server::store::HealthState::Failed`'s own doc comment
106//! admits *"the loop here does not currently produce it"*. Nothing
107//! distinguishes "the stream ended" from "the stream broke" because both are
108//! folded into one `Result<(), Error>` before the health state is even set.
109//!
110//! [`HealthState`] here is not a copy of that enum — it is what the fold
111//! above should have been: an `IngestSession`'s [`Stage::finish`] returning
112//! `Ok(())` (clean end of input, no error ever raised) drives
113//! [`HealthState::Ended`]; its [`Stage::feed`]/[`finish`](Stage::finish)
114//! returning `Err` drives [`HealthState::Failed`] carrying that concrete error, generically
115//! (`HealthState<E>`, `E = S::Error`) rather than losing it to a formatted
116//! string. Both are reachable and observed via [`IngestDriver::health`]/
117//! [`ListenDriver::health`] — see this module's tests for a mutation-checked
118//! proof that ending cleanly is never mistaken for failing.
119//!
120//! # `Listener` and `max_sessions`: enforced by the driver, not by convention
121//!
122//! `max_sessions` lives on the [`Listener`] trait as a fixed accessor, but
123//! **[`ListenDriver::poll_accept`] is the only place it is checked** — a
124//! concrete `Listener` cannot forget to enforce it (there is nothing for it
125//! to enforce; `poll_accept` just hands back whatever the transport
126//! accepted). Once `max_sessions` live sessions are admitted, every further
127//! accepted connection is dropped **immediately, before being fed a single
128//! byte** — this project has already shipped four unbounded-allocation
129//! vectors, and an unbounded listener is exactly that class of bug, so the
130//! bound is structural (checked in one generic place) rather than a
131//! per-protocol discipline.
132//!
133//! # `max_programs`: the fifth unbounded-allocation vector, and why it needs
134//! its own bound rather than reusing `max_sessions` or a `Trunk` capacity
135//!
136//! [`SessionEvent::NewProgram`] (above) mints a **fresh [`Trunk`]** — five
137//! bounded rings — per distinct [`ProgramId`] a session reports, with
138//! nothing capping how many distinct ids one session may report. Every
139//! individual ring is bounded ([`TrunkConfig`]'s five [`NonZeroUsize`]
140//! capacities), which is exactly what makes this easy to miss: the unbounded
141//! quantity is the *number of `Trunk`s*, not anything inside one, so no
142//! per-ring capacity — however carefully chosen — helps at all. A malformed
143//! or hostile multiplex announcing thousands of `program_number`s allocates
144//! thousands of trunks. This is the fifth vector of this class this project
145//! has shipped, all in code consuming remote input; the other two knobs
146//! already documented in this module do not cover it:
147//!
148//! - **Not `max_sessions`.** That bounds concurrent *connections*; this is a
149//! count of *programs announced within one already-admitted connection* —
150//! a different axis entirely, and B5's whole premise is that one session
151//! can legitimately report many programs.
152//! - **Not a `TrunkConfig` capacity.** Those bound *entries within one
153//! program's rings*; none of them says anything about how many programs
154//! may exist.
155//!
156//! So [`IngestDriver`] (and [`ListenDriver`], which embeds one per admitted
157//! session) takes its own `max_programs: NonZeroUsize` — [`NonZeroUsize`] for
158//! the same reason [`TrunkConfig`]'s five capacities are: zero is
159//! unrepresentable rather than merely rejected, so there is no fallible
160//! constructor to remember to call. It is enforced in exactly one place,
161//! [`IngestDriver`]'s internal `drain()`, mirroring `max_sessions`'
162//! placement: a `NewProgram` is checked against the bound **inside the
163//! driver that owns the `programs`/`writers` maps**, not by any convention an
164//! `IngestSession` implementor could forget — indeed an `IngestSession` has
165//! no visibility into those maps at all, so there is nothing for it to
166//! bypass even in principle.
167//!
168//! **The (N+1)th program is refused, not fatal — the admitted programs keep
169//! flowing.** The alternative (failing the whole session once its program
170//! count exceeds the bound) was rejected: a 200-program hostile or malformed
171//! multiplex would then take down ingest for the 8 programs a real caller
172//! asked for, which is a worse outcome than simply not admitting the extra
173//! 192. Concretely: a `NewProgram` past the bound gets no [`Trunk`] — no
174//! [`Trunk::new`] call happens for it at all, not merely an unstored one —
175//! and any later `Sample` for it is dropped by the *already-existing,
176//! already-tested* "sample for an unannounced program" path (see
177//! [`SessionEvent::Sample`]'s docs), because a refused program never gets a
178//! `writers` entry either. No new drop path was invented; refusal reuses the
179//! one this module already had to have.
180//!
181//! **Refusal is reported, not silent** — this project's own #781 postmortem
182//! (a silently dropped item that stayed invisible for a long time) is the
183//! reason a bare `if len >= max { return; }` is not acceptable here.
184//! [`IngestDriver::refused_program_count`] is a monotonically increasing
185//! counter, incremented once per refused `NewProgram`, queryable at any time
186//! — the same shape as [`DialSupervisor::attempts`]/[`DialAttempt::Exhausted`]
187//! (a bounded count of "how many times has this happened", not a stored list
188//! of each occurrence). A list of every refused [`ProgramId`] was considered
189//! and rejected: retaining one entry per refusal is the *exact same*
190//! unbounded-growth shape this fix exists to close, just moved from `Trunk`s
191//! to a `Vec<ProgramId>` — a counter is `O(1)` in memory regardless of how
192//! many programs a flood announces, which the accompanying flood test
193//! proves directly.
194//!
195//! **Default: [`DEFAULT_MAX_PROGRAMS`].** A real DVB MPTS typically carries a
196//! single-digit to low-tens program count; ATSC and cable multiplexes can run
197//! somewhat higher (a handful of dozens is a realistic outer bound for a
198//! legitimate stream). [`DEFAULT_MAX_PROGRAMS`]`= 64` sits comfortably above
199//! any legitimate multiplex this project's fixtures or docs describe, while
200//! still capping a hostile "thousands of programs" stream to 64 trunks (320
201//! rings) rather than an unbounded count.
202//!
203//! # Establishment is ordinary driving — `dial()` performs no I/O
204//!
205//! **[`Dialer::dial`] does not connect anything.** It *constructs* a session
206//! in a not-yet-established state, along with whatever first bytes that
207//! session wants sent (queued for [`IngestSession::poll_transmit`]). The
208//! handshake then completes through the **same feed/poll pump as everything
209//! else**: the driver writes [`IngestSession::poll_transmit`]'s bytes to the
210//! socket, reads the peer's reply, hands it to [`Stage::feed`], and the
211//! session either queues the next request or announces
212//! [`SessionEvent::Established`]. No I/O happens inside any trait method, so
213//! the plane stays genuinely sans-IO and tokio stays out of this layer.
214//!
215//! This is deliberately **the same pattern `rtsp-runtime` already uses**, not
216//! a second invention: `rtsp_runtime::client::ClientSession` is a sans-IO
217//! engine whose request builders (`describe`/`setup`/`play`) *return the
218//! outbound bytes to send* and whose `handle_data` consumes inbound bytes and
219//! returns typed `ClientEvent`s, with the RFC 2326 Appendix A.1 state machine
220//! held internally and exposed via `state()`. `IngestSession` is that shape
221//! expressed through `Stage`: `poll_transmit` ≙ "the bytes to send",
222//! `feed` ≙ `handle_data`, `poll` ≙ the returned events, and
223//! [`IngestDriver::health`] ≙ `state()`. `hls-runtime` splits its client
224//! and server engines the same way.
225//!
226//! An earlier revision of this module had `dial()` "perform the whole
227//! connect/handshake" and return an already-live session. That was wrong and
228//! is recorded here rather than quietly changed: a sans-IO trait cannot do
229//! I/O, so such a `dial()` only ever fits sources whose "connect" is a purely
230//! local operation (binding a UDP socket). Every genuinely multi-round-trip
231//! source — RTSP (DESCRIBE → SETUP × N → PLAY), an SRT caller handshake,
232//! TS-UDP's read-until-PMT-resolves — would have needed an executor bridge
233//! (`block_on`, or a handshake thread) to be callable at all, dragging tokio
234//! back into the layer that was kept free of it on purpose.
235//!
236//! ## One session type, not a separate `PendingSession`
237//!
238//! A distinct `PendingSession` type that `poll()`s into an `IngestSession`
239//! was considered and rejected. It would have to duplicate
240//! `feed`/`poll_transmit`/`next_deadline`/`on_deadline` (a handshake needs
241//! every one of them — that is the whole point), doubling the trait surface
242//! for one bit of state; it would force each driver to hold a
243//! `Pending | Established` enum and re-dispatch every call through it; and
244//! `rtsp-runtime` — the in-repo precedent this mirrors — deliberately does
245//! *not* do it either: `ClientSession` is one type from `Init` through
246//! `Playing`, with the phase readable via `state()`. One type, with the phase
247//! visible as [`HealthState::Establishing`] vs [`HealthState::Live`], keeps
248//! establishment on exactly the code path everything else already uses, which
249//! was the goal.
250//!
251//! # The handshake is bounded by a caller-supplied deadline
252//!
253//! A peer that opens a connection and then goes quiet must not pin a session
254//! forever. [`HandshakePolicy::establish_by`] is an **absolute
255//! [`Timestamp`]** by which [`SessionEvent::Established`] must have arrived;
256//! past it, a still-establishing session terminates as
257//! [`HealthState::HandshakeTimedOut`] — and in a [`ListenDriver`] is reaped,
258//! freeing its `max_sessions` slot, so a flood of half-open connections
259//! cannot squat the bound.
260//!
261//! **Why a deadline rather than an attempt or pump-iteration cap** (the two
262//! alternatives, both rejected): the failure mode is wall-clock — "the peer
263//! stopped talking" — which is exactly what `multimux`'s already-proven
264//! `IngestTimeouts::connect` (`DEFAULT_CONNECT_TIMEOUT`, 10 s) bounds today,
265//! so this matches a shape known to work on real cameras and encoders. An
266//! iteration cap would be a proxy that misfires in both directions: a real
267//! RTSP handshake is DESCRIBE + SETUP × N + PLAY where **N comes from the
268//! SDP and is not knowable when the cap would have to be chosen**, so any
269//! fixed number is either too small for an 8-track presentation (breaking a
270//! legitimate handshake) or too large to bound a stalled one usefully. The
271//! deadline also costs no new parameter — [`Timestamp`] is already threaded
272//! through [`Stage::feed`]/[`Stage::on_deadline`] — and [`IngestDriver::next_deadline`]
273//! surfaces it, so a real driver knows when to fire the check without
274//! polling. Per this crate's sans-IO rule there is no internal timer: the
275//! deadline is only observed on a `feed`/`on_deadline` the caller makes,
276//! exactly like [`crate::byte_merge::MergePolicy::Failover`]'s
277//! `silence_timeout`.
278//!
279//! *Memory* during a handshake is bounded separately and deliberately not
280//! here: it is the session's own `demand()`/internal-buffer bound (the
281//! `FixedFramer` precedent in [`crate::byte_stage`]'s tests), because only
282//! the session knows how much partial handshake state it is legitimately
283//! holding.
284//!
285//! # Pull sources need a typed request/response identity (round 3)
286//!
287//! Round 2 recorded this as a seam and stopped, deliberately, with no caller
288//! yet to design against. Round 3 has the caller —
289//! `multimux::source::{hls_pull, dash_pull, smooth_pull}` — and resolves it.
290//!
291//! The seam was correctly diagnosed back then: **`feed` was never the
292//! problem.** `Stage`'s contract says nothing about chunk size and explicitly
293//! decouples `poll` from `feed`, so handing one whole downloaded segment body
294//! to `feed` is entirely within contract — no different from a 1316-byte UDP
295//! datagram except in size. **`poll_transmit() -> Option<Bytes>` was the real
296//! gap**: it expresses "send these bytes on the connection you already have",
297//! right for RTSP/RTMP/SRT, but it cannot express "issue a GET for *this
298//! URL*", and there is no way to route an arriving response back to the
299//! request it answers when several are outstanding at once and they can
300//! complete out of order.
301//!
302//! Two shapes were considered for closing it:
303//!
304//! 1. **A per-source pull method**, sitting next to `feed` rather than
305//! replacing it (e.g. `IngestSession::feed_response(id, bytes)`).
306//! Rejected: it would make `feed(&[u8])` itself unreachable for a pull
307//! session (nothing ever calls it — every real input arrives through the
308//! new method instead) while `IngestSession: Stage<In<'a> = &'a [u8]>`
309//! still advertises that `feed` as the way in. **A session whose `feed`
310//! is never called, with every real input arriving out-of-band, is a type
311//! that lies about the contract it implements.** That is not a style
312//! objection — it means the trait bound stops meaning what it says for
313//! exactly the implementors that need the escape hatch, which defeats the
314//! point of having one drive contract for the whole plane.
315//! 2. **An `Inbound` enum** (`enum Inbound<'a> { Bytes(&'a [u8]), Response {
316//! id: ResourceId, bytes: &'a [u8] } }`) as the one fixed `In<'a>` every
317//! `IngestSession` uses. Rejected too: it forces every stream source
318//! (RTSP/RTMP/SRT/TS-*) to match a `Response` variant that can never occur
319//! for it, and it bakes pull vocabulary (`ResourceId`) into `media-plane`
320//! itself — this crate would then need to know what a *resource* is, which
321//! is exactly the kind of protocol knowledge the plane exists to stay free
322//! of (`ResourceId`/`Action` belong to `hls-runtime`, and the analogous
323//! DASH/Smooth identities belong to `multimux`, not here).
324//!
325//! What round 3 actually did: relax `Stage::In<'a>`'s pin (it is no longer
326//! `&'a [u8]`, only `Out = SessionEvent` is pinned) and add
327//! [`IngestSession::Request`], an opaque associated type with **no default**.
328//! A byte-stream source states `type In<'a> = &'a [u8]; type Request =
329//! Bytes;` — one extra line, no behaviour change (see
330//! `multimux::source::ts_program::TsIngestSession`). A pull source states its
331//! own honest shape instead — e.g. `type In<'a> = (HlsResourceId, &'a [u8]);
332//! type Request = hls_runtime::client::Action;` — and correlates an
333//! arriving response to the request that caused it via whatever identity type
334//! it chose, entirely inside its own `feed`. The plane never sees a
335//! `ResourceId` or an `Action`; it only ever sees "some `S::In<'_>` went in,
336//! some `Option<S::Request>` came out", which is the same shape `feed`/
337//! `poll_transmit` always had, just no longer forced to agree on `&[u8]`
338//! across every implementor.
339//!
340//! # Reconnect: caller-chosen backoff, never a hardcoded sleep
341//!
342//! [`DialSupervisor`] bounds *how many times* [`Dialer::dial`] is retried
343//! (`ReconnectPolicy::max_attempts`) but never sleeps, blocks, or otherwise
344//! decides *how long* to wait between attempts — that stays entirely with
345//! the caller (an async `sleep`, a `tokio::time::sleep`, nothing at all in a
346//! test), matching every other bounded-but-caller-driven knob in this crate
347//! ([`crate::byte_merge::MergePolicy::Failover`]'s `silence_timeout`, driven
348//! by the caller's own `on_deadline` calls, not an internal timer). Once
349//! [`ReconnectPolicy::max_attempts`] is exhausted, every further
350//! [`DialSupervisor::try_dial`] call is an `O(1)` no-op
351//! ([`DialAttempt::Exhausted`]) — it does not call [`Dialer::dial`] again,
352//! so a permanently-failing dialer cannot spin the attempt count or allocate
353//! per call, however many times it is polled.
354
355use std::collections::HashMap;
356use std::num::NonZeroUsize;
357use std::sync::Arc;
358
359use broadcast_common::{Stage, Timestamp};
360// Only the test-only `Bytes`-`Request` sessions below reference this type
361// directly now: since round 3, `IngestSession::Request` is generic, so
362// production code in this module no longer names `Bytes` itself.
363#[cfg(test)]
364use bytes::Bytes;
365use transmux::{Sample, TrackSpec};
366
367use crate::trunk::{RetentionClass, Trunk, TrunkConfig, TrunkWriter};
368
369/// Identifies one program within one ingest connection — see
370/// [the program dimension](self#the-program-dimension-b5-sessionevent-newprogram-at-any-time).
371///
372/// Meaningless outside the [`IngestSession`] that assigned it: two sessions
373/// each reporting `ProgramId(1)` are two unrelated programs, each getting
374/// its own [`Trunk`].
375#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
376pub struct ProgramId(pub u32);
377
378/// Suggested [`IngestDriver`]/[`ListenDriver`] `max_programs` bound — see
379/// [the module docs](self#max_programs-the-fifth-unbounded-allocation-vector-and-why-it-needs-its-own-bound-rather-than-reusing-max_sessions-or-a-trunk-capacity)
380/// for the justification. Not applied automatically (there is no default
381/// constructor for `max_programs`, matching [`TrunkConfig::new`]'s and
382/// [`HandshakePolicy::establish_by`]'s own all-explicit-arguments shape) —
383/// a caller passes it like any other driver parameter.
384///
385/// Typed [`NonZeroUsize`], not `usize`: every consumer of this constant feeds
386/// it to a `max_programs: NonZeroUsize` parameter, so handing back a plain
387/// `usize` made every call site re-wrap it and made a forgotten wrap a
388/// compile error at the *call*, not here. The default for a `NonZeroUsize`
389/// parameter should already be one.
390pub const DEFAULT_MAX_PROGRAMS: NonZeroUsize = match NonZeroUsize::new(64) {
391 Some(n) => n,
392 // Unreachable: 64 is a non-zero literal. `match` rather than `expect`
393 // keeps this a `const` on the 1.86 MSRV.
394 None => panic!("64 is non-zero"),
395};
396
397/// What an [`IngestSession`]'s [`Stage::poll`] hands back to a driver.
398///
399/// `#[non_exhaustive]`: a later step (egress track-set negotiation, §1.3's
400/// upstream program-split) may still need a `ProgramEnded` variant —
401/// [`SessionEvent::TracksChanged`] (issue #781) closed the mid-stream
402/// track-set half of this gap, but this step does not add a variant it has
403/// no correct producer for yet (this crate's own precedent —
404/// [`crate::byte_merge`]'s `Hitless2022_7` note).
405#[derive(Debug, Clone)]
406#[non_exhaustive]
407pub enum SessionEvent {
408 /// The handshake finished: this session's transport is usable and it is
409 /// now live. Exactly one of these per session lifetime — see
410 /// [Establishment is ordinary driving](self#establishment-is-ordinary-driving--dial-performs-no-io).
411 /// Until it arrives the driver reports [`HealthState::Establishing`];
412 /// after it, [`HealthState::Live`].
413 ///
414 /// # Why not called `TracksResolved`
415 ///
416 /// `transmux::DemuxEvent::TracksResolved { generation }` already owns that
417 /// name for the analogous *demux-side* question, and this is deliberately
418 /// not a synonym for it: **tracks here are a per-program fact** carried by
419 /// [`SessionEvent::NewProgram`] (finding B5 — one connection, N programs),
420 /// whereas establishment is a per-*connection* fact. Folding tracks into
421 /// this variant would force a session ingesting an MPTS to nominate some
422 /// arbitrary program as "the one that resolved the connection", and would
423 /// leave a session that has finished its handshake but not yet seen a PAT
424 /// with no way to say so. It carries no payload for the same
425 /// "no field without a correct producer" reason.
426 ///
427 /// The two events do line up on the awkward case, though, and this is
428 /// where that lands: `DemuxEvent::TracksResolved`'s own docs note that a
429 /// container with no up-front track declaration (FLV/RTMP) legitimately
430 /// never emits it, and that the asymmetry "is for the media plane's
431 /// ingress layer to handle explicitly (e.g. gating on the first
432 /// `DemuxEvent::Sample`)". This variant is that explicit handling: an
433 /// RTMP session decides for itself when it is ready — gating on the first
434 /// sample, exactly as those docs suggest — and says so here.
435 Established,
436 /// A new program was discovered — mint a fresh [`Trunk`] for it. May be
437 /// the very first event a session ever produces, or arrive after many
438 /// samples for other programs already have — both are the same case;
439 /// see [the module docs](self#the-program-dimension-b5-sessionevent-newprogram-at-any-time).
440 NewProgram {
441 /// Identifies this program for every subsequent
442 /// [`SessionEvent::Sample`] carrying it.
443 program: ProgramId,
444 /// The demuxed track specs for this program, so far. Reuses
445 /// [`transmux::TrackSpec`] rather than inventing a parallel type —
446 /// this crate's established pattern (see `crate::trunk::SegmentEntry`'s
447 /// module doc for the same reuse-don't-duplicate reasoning).
448 tracks: Vec<TrackSpec>,
449 },
450 /// One decoded sample for `track_id`, belonging to `program` (must have
451 /// been announced via a prior `NewProgram` with this `program` — a
452 /// `Sample` for an unannounced program is a contract violation by the
453 /// `IngestSession` implementor, and a driver drops it rather than
454 /// panicking; see [`IngestDriver`]'s docs).
455 Sample {
456 /// Which program this sample belongs to.
457 program: ProgramId,
458 /// Track id within that program, matching
459 /// [`TrunkWriter::publish`]'s own `track_id`.
460 track_id: u32,
461 /// Which ring this sample's track publishes into — the publisher
462 /// (ultimately, the `IngestSession` implementor) decides this, same
463 /// as any other [`TrunkWriter::publish`] caller.
464 retention: RetentionClass,
465 /// The decoded sample itself.
466 sample: Sample,
467 },
468 /// `program`'s track set changed mid-stream (issue #781) — e.g.
469 /// transmux's PMT version diffing detects a broadcaster adding an audio
470 /// language. Must have been announced via a prior `NewProgram` with this
471 /// `program`, exactly like [`SessionEvent::Sample`] — a `TracksChanged`
472 /// for an unannounced program is the identical contract violation, and a
473 /// driver drops it the identical way (see [`IngestDriver`]'s docs); it
474 /// never mints a `Trunk` on its own.
475 ///
476 /// # Why `tracks` is the complete replacement set, not a delta
477 ///
478 /// A PMT carries the **whole** elementary-stream list on every version
479 /// bump, not just what changed — there is no "here is the one track
480 /// that was added" signal at that layer, only "here is the program's
481 /// full track list, as of now". Carrying the complete set here mirrors
482 /// that fact rather than fighting it, and buys two things a delta
483 /// encoding cannot:
484 ///
485 /// - **Idempotence.** Re-delivering the same `TracksChanged` twice (a
486 /// retried demux pass, a duplicate event) leaves the trunk's track set
487 /// unchanged in content — replacing a set with an identical set is a
488 /// no-op in substance, whereas replaying an "add track" delta twice
489 /// would double-add it.
490 /// - **Immunity to delta-ordering bugs.** A dropped or reordered
491 /// `TrackAdded`/`TrackRemoved` pair (exactly the demux-layer events
492 /// `transmux` already emits — see below) can never leave a consumer's
493 /// view of the track set permanently wrong: the next `TracksChanged`
494 /// is a fresh, authoritative snapshot, not an increment on top of
495 /// whatever state happened to accumulate.
496 ///
497 /// A consumer that cares *which* track appeared or vanished diffs this
498 /// snapshot against the previous one it already holds (or against
499 /// [`crate::Trunk::tracks`], which this event's application updates) —
500 /// that comparison is the consumer's to make, not this event's to
501 /// pre-compute.
502 ///
503 /// # Why this layer does not mirror `transmux::DemuxEvent`'s three events
504 ///
505 /// `transmux` already emits `DemuxEvent::TrackAdded`/`TrackRemoved`/
506 /// `TrackUpdated` at the demux layer — finer-grained, delta-shaped
507 /// events aimed at a caller that wants to react to *what changed*. This
508 /// layer deliberately does not mirror that shape: `IngestSession`
509 /// implementors translate whatever demux-layer deltas they see into one
510 /// full snapshot per change, for the same reason [`SessionEvent::NewProgram`]
511 /// carries a full `tracks: Vec<TrackSpec>` rather than a "here is track
512 /// N" event per track — see [the module docs](self#the-program-dimension-b5-sessionevent-newprogram-at-any-time).
513 ///
514 /// # Scope: ingress→`Trunk` plumbing only
515 ///
516 /// This variant and [`IngestDriver`]'s handling of it stop at storing
517 /// the new set on the program's `Trunk` (see
518 /// [`crate::TrunkWriter::set_tracks`]). Deciding whether/when to admit a
519 /// newly-appeared track into an egress manifest (LL-HLS/DASH rendering)
520 /// is a separate, deliberate decision belonging to its own issue — not
521 /// something a track-set snapshot arriving at the `Trunk` should trigger
522 /// implicitly.
523 TracksChanged {
524 /// Which program's track set changed — must match a program already
525 /// announced via [`SessionEvent::NewProgram`].
526 program: ProgramId,
527 /// The complete replacement track set — see this variant's own doc
528 /// for why this is a full snapshot rather than a delta.
529 tracks: Vec<TrackSpec>,
530 },
531}
532
533/// The sans-IO ingress drive contract: a specialisation of [`Stage`] whose
534/// output is [`SessionEvent`] — see
535/// [the module docs](self#ingestsession-is-a-stage-matching-bytestages-precedent).
536/// `Stage::In<'a>` is **not** pinned here (round 3 relaxed it from `&'a
537/// [u8]`) — see
538/// [Pull sources need a typed request/response identity](self#pull-sources-need-a-typed-requestresponse-identity-round-3)
539/// for why: a byte-stream source still states `type In<'a> = &'a [u8]`, but a
540/// pull source (HLS/DASH/Smooth) states its own request/response identity
541/// instead.
542///
543/// # Why this is explicitly implemented, unlike [`crate::ByteStage`]
544///
545/// `ByteStage` gets a blanket `impl<T> ByteStage for T where T: Stage<…>`
546/// because it adds **nothing** to `Stage` — it is a pure alias, so a blanket
547/// impl costs nothing and saves every implementor a line. `IngestSession`
548/// adds [`poll_transmit`](Self::poll_transmit) (which has a default) and
549/// [`Request`](Self::Request) (which, being an associated type, cannot have
550/// one). A blanket impl here would make `poll_transmit`'s default
551/// **impossible to override** (the blanket would already be the one impl for
552/// every type, and a second manual impl would collide), quietly breaking the
553/// handshake mechanism it exists for — and could not exist at all once
554/// `Request` has no default value to blanket-supply. So implementors write at
555/// least one extra line — `type Request = Bytes;` for every byte-stream
556/// source, plus a real `poll_transmit` body for the two or three that send
557/// back. This asymmetry with `ByteStage` is deliberate and is the reason it
558/// is documented rather than "fixed".
559pub trait IngestSession: for<'a> Stage<Out = SessionEvent> + Send {
560 /// What [`poll_transmit`](Self::poll_transmit) hands back — opaque to the
561 /// plane, deliberately: see
562 /// [the module docs](self#pull-sources-need-a-typed-requestresponse-identity-round-3)
563 /// for why this is not a fixed enum. A byte-stream source (RTSP/RTMP/SRT/
564 /// TS-*) sets this to [`bytes::Bytes`]; a pull source sets it to its own
565 /// protocol's action type (e.g. `hls_runtime::client::Action`).
566 ///
567 /// No default: unlike `poll_transmit`, there is no value every
568 /// implementor could reasonably start from, so every `IngestSession`
569 /// names its own type explicitly, one line, even the sessions that never
570 /// override `poll_transmit`'s body.
571 type Request: Send;
572
573 /// The next outbound request this session wants performed — bytes
574 /// written to an already-open connection, or (for a pull source) a
575 /// fetch/wait action for the driver to carry out.
576 ///
577 /// Two uses of the byte-stream case, one mechanism: the **handshake**
578 /// requests that establish the session (an RTSP `DESCRIBE`, then `SETUP`,
579 /// then `PLAY` — see
580 /// [Establishment is ordinary driving](self#establishment-is-ordinary-driving--dial-performs-no-io)),
581 /// and in-session traffic afterwards (RTCP receiver reports, an RTSP
582 /// keepalive `OPTIONS`, an SRT ACK). This is exactly what
583 /// `rtsp_runtime::client::ClientSession`'s request builders return, only
584 /// pulled rather than returned. A pull source's own [`Request`](Self::Request)
585 /// plays the identical role in its own protocol — see e.g.
586 /// `hls_runtime::client::Action`, returned unchanged through this
587 /// method by an HLS-pull `IngestSession`.
588 ///
589 /// A driver drains this in a loop after every
590 /// [`Stage::feed`]/[`Stage::on_deadline`] (and once immediately after
591 /// [`Dialer::dial`], to send the first handshake request), exactly like
592 /// [`Stage::poll`]. A session with nothing to send never overrides it.
593 fn poll_transmit(&mut self) -> Option<Self::Request> {
594 None
595 }
596}
597
598/// Outbound connect: RTSP, raw RTP/UDP, TS-over-UDP, an SRT caller, an
599/// HLS/DASH/Smooth pull client.
600pub trait Dialer: Send {
601 /// The session a dial produces.
602 type Session: IngestSession;
603 /// Why constructing the session failed.
604 type Error;
605
606 /// **Construct** a session — performing no I/O and completing no
607 /// handshake.
608 ///
609 /// The returned session is *not yet established*: it starts in
610 /// [`HealthState::Establishing`], and the handshake completes through the
611 /// ordinary pump ([`IngestSession::poll_transmit`] out,
612 /// [`Stage::feed`] in) until it emits [`SessionEvent::Established`] — see
613 /// [Establishment is ordinary driving](self#establishment-is-ordinary-driving--dial-performs-no-io).
614 /// An implementation should queue its first handshake request for
615 /// `poll_transmit` here.
616 ///
617 /// The `Err` path is for purely local construction failures — a URL that
618 /// will not parse, contradictory config — **not** for connect failures,
619 /// which this method never attempts and therefore cannot observe. A peer
620 /// that refuses or never answers surfaces later, as
621 /// [`HealthState::Failed`] or [`HealthState::HandshakeTimedOut`].
622 fn dial(&mut self) -> Result<Self::Session, Self::Error>;
623}
624
625/// Identifies one session a [`ListenDriver`] currently has admitted, for
626/// every call that needs to name which one ([`ListenDriver::feed`],
627/// [`ListenDriver::health`], ...).
628#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
629pub struct SessionId(pub u64);
630
631/// Inbound accept: RTMP push, an SRT listener, a future WHIP. See
632/// [`max_sessions` is enforced by the driver](self#listener-and-max_sessions-enforced-by-the-driver-not-by-convention).
633pub trait Listener: Send {
634 /// The session one accepted connection produces.
635 type Session: IngestSession;
636 /// Why an accept attempt failed.
637 type Error;
638
639 /// The hard bound on concurrently admitted sessions — see the module
640 /// docs. A fixed accessor, not a mutable knob: this step has no use case
641 /// for changing it mid-flight, and a fixed value is what lets
642 /// [`ListenDriver`] reason about it as a real bound rather than a
643 /// point-in-time snapshot that might already be stale.
644 fn max_sessions(&self) -> usize;
645
646 /// Try to accept the next inbound connection. `Ok(None)` means nothing
647 /// is waiting right now (a non-blocking poll, matching [`Stage::poll`]'s
648 /// shape) — not an error and not end-of-input; a [`Listener`] has no
649 /// "end" the way a single connection does.
650 fn poll_accept(&mut self) -> Result<Option<Self::Session>, Self::Error>;
651}
652
653/// A session's phase, distinguishing "still handshaking" from "live", and a
654/// clean end from a real failure — see
655/// [Supervision: EOF is not an error](self#supervision-eof-is-not-an-error-the-healthstate-fix).
656///
657/// [`Establishing`](Self::Establishing) and [`Live`](Self::Live) are the two
658/// running states; the other three are terminal (in a [`ListenDriver`],
659/// reaching any of them reaps the session and frees its `max_sessions` slot).
660///
661/// `#[non_exhaustive]`: a later step may add `Reconnecting` once a driver owns
662/// a full redial loop rather than just the bounded initial dial
663/// [`DialSupervisor`] covers in this step.
664#[derive(Debug)]
665#[non_exhaustive]
666pub enum HealthState<E> {
667 /// Constructed by [`Dialer::dial`] (or accepted by a [`Listener`]) but
668 /// the handshake has not finished: the session has not yet emitted
669 /// [`SessionEvent::Established`]. Bounded by
670 /// [`HandshakePolicy::establish_by`] — see
671 /// [the handshake is bounded](self#the-handshake-is-bounded-by-a-caller-supplied-deadline).
672 Establishing,
673 /// Established and actively driving; no end or error observed yet.
674 Live,
675 /// An [`IngestSession`]'s [`Stage::finish`] returned `Ok(())` with no
676 /// prior error — the source ended on its own; this is not a failure.
677 Ended,
678 /// An [`IngestSession`]'s [`Stage::feed`] or [`Stage::finish`] returned
679 /// `Err`. Carries the concrete error rather than a formatted string, so a
680 /// caller that cares can match on it.
681 Failed(E),
682 /// [`HandshakePolicy::establish_by`] passed while the session was still
683 /// [`Establishing`](Self::Establishing) — the peer opened a connection and
684 /// never completed the handshake.
685 ///
686 /// Deliberately **not** folded into [`Failed`](Self::Failed): that variant
687 /// carries the *session's* own error type, and a handshake that simply
688 /// never progressed produced no session error to carry — the session did
689 /// nothing wrong, it was starved of input. Inventing an `E` to put here
690 /// would mean either fabricating one or forcing every implementor's error
691 /// type to grow a timeout variant it cannot itself raise.
692 HandshakeTimedOut {
693 /// The deadline that passed.
694 deadline: Timestamp,
695 },
696}
697
698impl<E: PartialEq> PartialEq for HealthState<E> {
699 fn eq(&self, other: &Self) -> bool {
700 match (self, other) {
701 (HealthState::Establishing, HealthState::Establishing) => true,
702 (HealthState::Live, HealthState::Live) => true,
703 (HealthState::Ended, HealthState::Ended) => true,
704 (HealthState::Failed(a), HealthState::Failed(b)) => a == b,
705 (
706 HealthState::HandshakeTimedOut { deadline: a },
707 HealthState::HandshakeTimedOut { deadline: b },
708 ) => a == b,
709 _ => false,
710 }
711 }
712}
713
714impl<E> HealthState<E> {
715 /// `true` while this session is still being driven —
716 /// [`Establishing`](Self::Establishing) or [`Live`](Self::Live). `false`
717 /// once it has reached a terminal state.
718 pub fn is_running(&self) -> bool {
719 matches!(self, HealthState::Establishing | HealthState::Live)
720 }
721}
722
723/// Bounds how long a session may stay in [`HealthState::Establishing`] — see
724/// [the handshake is bounded](self#the-handshake-is-bounded-by-a-caller-supplied-deadline)
725/// for why this is a wall-clock deadline rather than an attempt or
726/// pump-iteration cap.
727#[derive(Debug, Clone, Copy, PartialEq, Eq)]
728#[non_exhaustive]
729pub struct HandshakePolicy {
730 /// Absolute [`Timestamp`], on the same driver-chosen epoch as
731 /// [`Stage::feed`]'s `now`, by which [`SessionEvent::Established`] must
732 /// have arrived.
733 pub establish_by: Timestamp,
734}
735
736impl HandshakePolicy {
737 /// Require the handshake to complete by the absolute timestamp
738 /// `establish_by`.
739 pub fn establish_by(establish_by: Timestamp) -> Self {
740 HandshakePolicy { establish_by }
741 }
742}
743
744/// Drives one connected [`IngestSession`], dispatching every
745/// [`SessionEvent`] it yields into a fresh per-[`ProgramId`] [`Trunk`], and
746/// tracking [`HealthState`] — the "pump that owns the feed/poll/deadline
747/// loop" for a single dialed-out connection (see [`run_dial`]). [`ListenDriver`]
748/// embeds one of these per admitted session, so this is also where an
749/// accepted connection's per-program `Trunk` bookkeeping actually lives.
750pub struct IngestDriver<S: IngestSession> {
751 session: S,
752 trunk_config: TrunkConfig,
753 handshake: HandshakePolicy,
754 max_programs: NonZeroUsize,
755 programs: HashMap<ProgramId, Arc<Trunk>>,
756 writers: HashMap<ProgramId, TrunkWriter>,
757 /// Count of `NewProgram` events refused because `max_programs` was
758 /// already reached — see
759 /// [the module docs](self#max_programs-the-fifth-unbounded-allocation-vector-and-why-it-needs-its-own-bound-rather-than-reusing-max_sessions-or-a-trunk-capacity).
760 /// A counter, not a stored list, so this field is itself `O(1)` no
761 /// matter how large a flood of refused programs is.
762 refused_programs: u64,
763 health: HealthState<S::Error>,
764}
765
766impl<S: IngestSession> IngestDriver<S> {
767 /// Wrap a freshly-constructed (**not yet established**) `session`, ready
768 /// to be pumped: it starts in [`HealthState::Establishing`] and reaches
769 /// [`HealthState::Live`] when it emits [`SessionEvent::Established`],
770 /// bounded by `handshake`. Every program it later announces gets a fresh
771 /// [`Trunk`] built from `trunk_config`, up to `max_programs` distinct
772 /// [`ProgramId`]s — see
773 /// [the module docs](self#max_programs-the-fifth-unbounded-allocation-vector-and-why-it-needs-its-own-bound-rather-than-reusing-max_sessions-or-a-trunk-capacity)
774 /// for what happens past that bound.
775 pub fn new(
776 session: S,
777 trunk_config: TrunkConfig,
778 handshake: HandshakePolicy,
779 max_programs: NonZeroUsize,
780 ) -> Self {
781 IngestDriver {
782 session,
783 trunk_config,
784 handshake,
785 max_programs,
786 programs: HashMap::new(),
787 writers: HashMap::new(),
788 refused_programs: 0,
789 health: HealthState::Establishing,
790 }
791 }
792
793 /// The bound this driver enforces on distinct admitted programs.
794 pub fn max_programs(&self) -> NonZeroUsize {
795 self.max_programs
796 }
797
798 /// Currently-admitted distinct program count. Never exceeds
799 /// [`Self::max_programs`], however many `NewProgram` events this session
800 /// reports.
801 pub fn program_count(&self) -> usize {
802 self.programs.len()
803 }
804
805 /// How many `NewProgram` events this driver has refused because
806 /// [`Self::max_programs`] was already reached — see
807 /// [the module docs](self#max_programs-the-fifth-unbounded-allocation-vector-and-why-it-needs-its-own-bound-rather-than-reusing-max_sessions-or-a-trunk-capacity).
808 /// Monotonically increasing; never resets.
809 pub fn refused_program_count(&self) -> u64 {
810 self.refused_programs
811 }
812
813 /// Feed more input read from this session's connection — a handshake
814 /// response while [`HealthState::Establishing`], media once
815 /// [`HealthState::Live`]; the same call either way. Generic over
816 /// `Stage::In` (round 3: no longer pinned to `&[u8]`) so a pull
817 /// source can feed its own `(id, bytes)` response shape through the same
818 /// method a byte-stream source feeds raw bytes through — see
819 /// [the module docs](self#pull-sources-need-a-typed-requestresponse-identity-round-3).
820 /// A no-op once the session has reached a terminal state — it is never
821 /// fed again.
822 pub fn feed(&mut self, input: S::In<'_>, now: Timestamp) {
823 if !self.health.is_running() {
824 return;
825 }
826 match self.session.feed(input, now) {
827 Ok(()) => {
828 self.drain();
829 self.check_handshake_deadline(now);
830 }
831 Err(e) => self.health = HealthState::Failed(e),
832 }
833 }
834
835 /// Let the session act on the passage of time (an in-flight handshake
836 /// retransmit, rate-scheduled re-emission, a keepalive interval) — see
837 /// [`Stage::on_deadline`]. Also where a blown
838 /// [`HandshakePolicy::establish_by`] is observed for a peer that has gone
839 /// silent mid-handshake and so is producing no `feed` calls at all. A
840 /// no-op once terminated, matching [`Self::feed`].
841 pub fn on_deadline(&mut self, now: Timestamp) {
842 if !self.health.is_running() {
843 return;
844 }
845 self.session.on_deadline(now);
846 self.drain();
847 self.check_handshake_deadline(now);
848 }
849
850 /// Signal clean end-of-input. `Ok(())` from the session drives
851 /// [`HealthState::Ended`] (not a failure); `Err` drives
852 /// [`HealthState::Failed`] — this is the method the mutation-checked
853 /// EOF-vs-failure test in this module drives directly. A no-op once
854 /// already terminated.
855 pub fn finish(&mut self) {
856 if !self.health.is_running() {
857 return;
858 }
859 match self.session.finish() {
860 Ok(()) => {
861 self.drain();
862 self.health = HealthState::Ended;
863 }
864 Err(e) => self.health = HealthState::Failed(e),
865 }
866 }
867
868 /// Drain the next outbound request the session wants performed —
869 /// handshake requests included, and (for a pull source) a fetch/wait
870 /// action. See [`IngestSession::poll_transmit`]/[`IngestSession::Request`].
871 pub fn poll_transmit(&mut self) -> Option<S::Request> {
872 self.session.poll_transmit()
873 }
874
875 /// The next point in time this driver has work to do: the earlier of the
876 /// session's own [`Stage::next_deadline`] and — while still
877 /// [`HealthState::Establishing`] — [`HandshakePolicy::establish_by`], so a
878 /// caller driving off this value alone still learns about a stalled
879 /// handshake at the right moment rather than never.
880 pub fn next_deadline(&self) -> Option<Timestamp> {
881 let session = self.session.next_deadline();
882 let handshake =
883 matches!(self.health, HealthState::Establishing).then_some(self.handshake.establish_by);
884 match (session, handshake) {
885 (Some(a), Some(b)) => Some(a.min(b)),
886 (a, b) => a.or(b),
887 }
888 }
889
890 /// This session's current health.
891 pub fn health(&self) -> &HealthState<S::Error> {
892 &self.health
893 }
894
895 /// Consume this driver, yielding its final [`HealthState`] **by value** —
896 /// so a caller that is tearing the route down can move the concrete
897 /// `S::Error` out of [`HealthState::Failed`] and return it.
898 ///
899 /// [`Self::health`] only lends a `&HealthState`, which is right for
900 /// polling but cannot hand back the error: a session error type is not
901 /// required to be [`Clone`] (`multimux::MultimuxError` is not), so a
902 /// borrowing accessor forces a caller to degrade the typed error into a
903 /// formatted string — exactly the loss this module's docs call out as
904 /// the bug `HealthState<E>` exists to fix. Without this, an
905 /// [`IngestSession`] whose `feed` returns `Err` is *unreportable* by its
906 /// own driver loop: `feed` records the error in `health` and returns
907 /// `()`, so a loop that only ever calls `feed` sees no failure at all and
908 /// spins forever. (That is not hypothetical — it is exactly how
909 /// `multimux::source::smooth_pull`'s PlayReady-detection error escaped
910 /// its drive loop until this method existed.)
911 ///
912 /// Consuming (rather than a `&mut` "take the error out") is deliberate:
913 /// every terminal state is final, so there is no valid use for a driver
914 /// whose failure has been moved out from under it. Any [`Trunk`] this
915 /// driver minted stays alive independently — they are [`Arc`]s a caller
916 /// will already have cloned out via [`Self::trunk`].
917 pub fn into_health(self) -> HealthState<S::Error> {
918 self.health
919 }
920
921 /// Terminate a still-`Establishing` session whose deadline has passed.
922 ///
923 /// Called *after* the session has been fed/drained, never before, so a
924 /// handshake response that arrives exactly at the deadline and completes
925 /// the handshake still establishes rather than being rejected by a
926 /// millisecond.
927 fn check_handshake_deadline(&mut self, now: Timestamp) {
928 if matches!(self.health, HealthState::Establishing) && now >= self.handshake.establish_by {
929 self.health = HealthState::HandshakeTimedOut {
930 deadline: self.handshake.establish_by,
931 };
932 }
933 }
934
935 /// The [`Trunk`] for `program`, if it has been announced yet.
936 pub fn trunk(&self, program: ProgramId) -> Option<&Arc<Trunk>> {
937 self.programs.get(&program)
938 }
939
940 /// Every program this session has announced so far.
941 pub fn programs(&self) -> impl Iterator<Item = ProgramId> + '_ {
942 self.programs.keys().copied()
943 }
944
945 /// Read-only access to the underlying session — for state a driver loop
946 /// needs to observe beyond what [`SessionEvent`]/[`HealthState`] already
947 /// expose. Concretely: a pull source (HLS/DASH/Smooth) knows it has
948 /// reached true end-of-stream (the origin's playlist/manifest said so
949 /// *and* every outstanding fetch it named is accounted for) entirely from
950 /// its own protocol bookkeeping — unlike a byte-stream transport, whose
951 /// "ended" signal is external (the HTTP body closed, the socket peer
952 /// disconnected) and therefore already known to the driver loop without
953 /// reaching in here at all. `SessionEvent` deliberately has no `Ended`
954 /// variant to carry that (see its own doc's "no field/variant without a
955 /// correct producer" discipline), so a pull source's own inherent
956 /// accessor (e.g. `HlsIngestSession::ended`) is what a driver loop reads
957 /// to decide when to call [`Self::finish`].
958 pub fn session(&self) -> &S {
959 &self.session
960 }
961
962 /// Drain every ready [`SessionEvent`], dispatching each into its
963 /// program's `Trunk`. A `Sample` for a program never announced via
964 /// `NewProgram` is dropped (documented `IngestSession` contract
965 /// violation, not a panic — see [`SessionEvent::Sample`]'s docs); a
966 /// `NewProgram` past `max_programs` is refused the exact same way — see
967 /// [the module docs](self#max_programs-the-fifth-unbounded-allocation-vector-and-why-it-needs-its-own-bound-rather-than-reusing-max_sessions-or-a-trunk-capacity).
968 fn drain(&mut self) {
969 while let Some(event) = self.session.poll() {
970 match event {
971 SessionEvent::Established => {
972 // Only ever a promotion out of Establishing: a duplicate
973 // Established from a misbehaving session must not resurrect
974 // an already-terminal driver, and must not be treated as a
975 // fresh start for a live one.
976 if matches!(self.health, HealthState::Establishing) {
977 self.health = HealthState::Live;
978 }
979 }
980 SessionEvent::NewProgram { program, tracks } => {
981 // A repeat announcement of an already-admitted program
982 // does not grow `self.programs`, so it is never refused
983 // here regardless of how full the bound is — only a
984 // genuinely new `ProgramId` can hit the cap.
985 if !self.programs.contains_key(&program)
986 && self.programs.len() >= self.max_programs.get()
987 {
988 // Refused: no `Trunk::new` call happens at all (not
989 // merely an unstored one), and no `writers` entry is
990 // created, so this program's later `Sample`s fall
991 // through the existing "unannounced program" drop
992 // path below rather than needing a second one.
993 self.refused_programs += 1;
994 continue;
995 }
996 // A REPEAT announcement updates the existing `Trunk` in
997 // place; it must never mint a replacement. Re-minting
998 // would swap a fresh, empty `Trunk` into `self.programs`
999 // while every already-issued cursor kept reading the
1000 // orphaned one that no longer receives writes — existing
1001 // subscribers would see a permanently stalled stream, and
1002 // whatever the old `Trunk` still held (samples, segments,
1003 // parts, and so the DVR window) would be silently dropped.
1004 //
1005 // Treating the repeat as a track-set update is exactly
1006 // what `TracksChanged` does, so this defers to the same
1007 // path rather than duplicating it: a re-announcement is a
1008 // restatement of the program's tracks, not a new program.
1009 if let Some(writer) = self.writers.get(&program) {
1010 writer.set_tracks(tracks);
1011 continue;
1012 }
1013 let trunk = Trunk::new(self.trunk_config);
1014 let writer = trunk
1015 .writer()
1016 .expect("a freshly constructed Trunk always has an unclaimed writer");
1017 // Seed the freshly-minted Trunk's track set from this
1018 // event's `tracks` (issue #781) — previously discarded
1019 // entirely (the `..` this match arm used to bind with),
1020 // leaving every Trunk's track set permanently empty.
1021 writer.set_tracks(tracks);
1022 self.programs.insert(program, trunk);
1023 self.writers.insert(program, writer);
1024 }
1025 SessionEvent::Sample {
1026 program,
1027 track_id,
1028 retention,
1029 sample,
1030 } => {
1031 if let Some(writer) = self.writers.get(&program) {
1032 writer.publish(track_id, retention, sample);
1033 }
1034 }
1035 SessionEvent::TracksChanged { program, tracks } => {
1036 // Same "unannounced program is a dropped contract
1037 // violation, not a panic" contract as `Sample` above —
1038 // reuses the exact same `writers` lookup, not a second
1039 // drop path.
1040 if let Some(writer) = self.writers.get(&program) {
1041 writer.set_tracks(tracks);
1042 }
1043 }
1044 }
1045 }
1046 }
1047}
1048
1049/// Construct a session via [`Dialer::dial`] and wrap it for driving — see
1050/// [`IngestDriver`]. Performs **no I/O and completes no handshake**: the
1051/// returned driver starts in [`HealthState::Establishing`], and the caller
1052/// pumps it ([`IngestDriver::poll_transmit`] out, [`IngestDriver::feed`] in)
1053/// until it reports [`HealthState::Live`]. [`DialSupervisor`] adds bounded
1054/// retry on top for a `Dialer` whose local construction fails outright.
1055pub fn run_dial<D: Dialer>(
1056 dialer: &mut D,
1057 trunk_config: TrunkConfig,
1058 handshake: HandshakePolicy,
1059 max_programs: NonZeroUsize,
1060) -> Result<IngestDriver<D::Session>, D::Error> {
1061 let session = dialer.dial()?;
1062 Ok(IngestDriver::new(
1063 session,
1064 trunk_config,
1065 handshake,
1066 max_programs,
1067 ))
1068}
1069
1070/// Bounded retry policy for [`DialSupervisor`] — how many times
1071/// [`Dialer::dial`] is retried before giving up, never how long to wait
1072/// between attempts (that stays with the caller; see
1073/// [Reconnect](self#reconnect-caller-chosen-backoff-never-a-hardcoded-sleep)).
1074#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1075#[non_exhaustive]
1076pub struct ReconnectPolicy {
1077 /// Maximum number of consecutive [`Dialer::dial`] attempts before
1078 /// [`DialSupervisor::try_dial`] gives up.
1079 pub max_attempts: u32,
1080}
1081
1082impl ReconnectPolicy {
1083 /// Build a policy bounded to `max_attempts` consecutive dial failures.
1084 ///
1085 /// Panics if `max_attempts == 0` — a policy that never even tries once
1086 /// is a construction mistake, not a real bound.
1087 pub fn new(max_attempts: u32) -> Self {
1088 assert!(max_attempts > 0, "ReconnectPolicy max_attempts must be > 0");
1089 ReconnectPolicy { max_attempts }
1090 }
1091}
1092
1093/// The result of one [`DialSupervisor::try_dial`] call.
1094///
1095/// Not `Debug`: [`DialAttempt::Connected`] carries an [`IngestDriver`], which
1096/// carries a `Trunk`/`TrunkWriter` — neither implements `Debug` (a `Trunk`
1097/// holds live synchronization primitives, not inspectable state), so this
1098/// type cannot either without breaking that up.
1099#[non_exhaustive]
1100pub enum DialAttempt<S: IngestSession, E> {
1101 /// A session was constructed — a driveable [`IngestDriver`], starting in
1102 /// [`HealthState::Establishing`]. Named `Connected` for the caller's
1103 /// mental model of "the dial step succeeded"; nothing is connected yet in
1104 /// the I/O sense (see [`Dialer::dial`]).
1105 Connected(IngestDriver<S>),
1106 /// This attempt failed, but retries remain: wait (your own chosen
1107 /// backoff) and call [`DialSupervisor::try_dial`] again.
1108 Retry(E),
1109 /// This attempt failed and it was the last one allowed by
1110 /// [`ReconnectPolicy::max_attempts`] — carries the error that caused
1111 /// this final attempt to fail.
1112 GaveUp(E),
1113 /// [`DialAttempt::GaveUp`] already fired on an earlier call: no new
1114 /// dial was attempted this time, and none ever will be again from this
1115 /// [`DialSupervisor`] — see
1116 /// [Reconnect](self#reconnect-caller-chosen-backoff-never-a-hardcoded-sleep)
1117 /// for why this is what keeps a permanently-failing dialer from
1118 /// spinning.
1119 Exhausted,
1120}
1121
1122/// Bounds [`Dialer::dial`] retry — see [`ReconnectPolicy`] and
1123/// [Reconnect](self#reconnect-caller-chosen-backoff-never-a-hardcoded-sleep).
1124pub struct DialSupervisor<D: Dialer> {
1125 dialer: D,
1126 policy: ReconnectPolicy,
1127 attempts: u32,
1128 exhausted: bool,
1129}
1130
1131impl<D: Dialer> DialSupervisor<D> {
1132 /// Build a supervisor over `dialer`, bounded by `policy`.
1133 pub fn new(dialer: D, policy: ReconnectPolicy) -> Self {
1134 DialSupervisor {
1135 dialer,
1136 policy,
1137 attempts: 0,
1138 exhausted: false,
1139 }
1140 }
1141
1142 /// Consecutive failed attempts so far. Reset to `0` on a successful
1143 /// dial; never exceeds [`ReconnectPolicy::max_attempts`], regardless of
1144 /// how many times [`Self::try_dial`] is called afterward — see
1145 /// [`DialAttempt::Exhausted`].
1146 pub fn attempts(&self) -> u32 {
1147 self.attempts
1148 }
1149
1150 /// `true` once [`ReconnectPolicy::max_attempts`] has been exhausted —
1151 /// every subsequent [`Self::try_dial`] call returns
1152 /// [`DialAttempt::Exhausted`] without touching [`Dialer::dial`] again.
1153 pub fn is_exhausted(&self) -> bool {
1154 self.exhausted
1155 }
1156
1157 /// Try once more to dial, wrapping success into a driveable
1158 /// [`IngestDriver`] (starting in [`HealthState::Establishing`], bounded by
1159 /// `handshake`; every program it later announces gets a `Trunk` built from
1160 /// `trunk_config`). Never sleeps — see the module docs.
1161 pub fn try_dial(
1162 &mut self,
1163 trunk_config: TrunkConfig,
1164 handshake: HandshakePolicy,
1165 max_programs: NonZeroUsize,
1166 ) -> DialAttempt<D::Session, D::Error> {
1167 if self.exhausted {
1168 return DialAttempt::Exhausted;
1169 }
1170 self.attempts += 1;
1171 match self.dialer.dial() {
1172 Ok(session) => {
1173 self.attempts = 0;
1174 DialAttempt::Connected(IngestDriver::new(
1175 session,
1176 trunk_config,
1177 handshake,
1178 max_programs,
1179 ))
1180 }
1181 Err(e) => {
1182 if self.attempts >= self.policy.max_attempts {
1183 self.exhausted = true;
1184 DialAttempt::GaveUp(e)
1185 } else {
1186 DialAttempt::Retry(e)
1187 }
1188 }
1189 }
1190 }
1191}
1192
1193/// The outcome of one [`ListenDriver::poll_accept`] call.
1194#[derive(Debug)]
1195#[non_exhaustive]
1196pub enum AcceptOutcome<E> {
1197 /// A new connection was accepted and admitted under `max_sessions`; use
1198 /// this id with [`ListenDriver::feed`]/[`ListenDriver::health`]/etc.
1199 Admitted(SessionId),
1200 /// Nothing was waiting to be accepted right now — not an error.
1201 Idle,
1202 /// A connection was accepted, but `max_sessions` was already reached: it
1203 /// was dropped immediately, without ever being fed a byte — see
1204 /// [`max_sessions` is enforced by the driver](self#listener-and-max_sessions-enforced-by-the-driver-not-by-convention).
1205 Refused,
1206 /// [`Listener::poll_accept`] itself reported a failure (distinct from a
1207 /// refusal: the transport-level accept failed, not the admission bound).
1208 Error(E),
1209}
1210
1211/// Drives a [`Listener`], admitting up to its [`Listener::max_sessions`]
1212/// concurrently and dispatching every admitted session's [`SessionEvent`]s
1213/// into per-[`ProgramId`] [`Trunk`]s exactly like [`IngestDriver`] (one is
1214/// embedded per admitted session). See
1215/// [`max_sessions` is enforced by the driver](self#listener-and-max_sessions-enforced-by-the-driver-not-by-convention).
1216pub struct ListenDriver<L: Listener> {
1217 listener: L,
1218 trunk_config: TrunkConfig,
1219 handshake: HandshakePolicy,
1220 max_programs: NonZeroUsize,
1221 sessions: HashMap<SessionId, IngestDriver<L::Session>>,
1222 next_id: u64,
1223}
1224
1225impl<L: Listener> ListenDriver<L> {
1226 /// Build a driver over `listener`. Every admitted session starts in
1227 /// [`HealthState::Establishing`] bounded by `handshake` — which is what
1228 /// stops a flood of half-open inbound connections from squatting the
1229 /// `max_sessions` bound indefinitely — and every program any of them
1230 /// announces gets a `Trunk` built from `trunk_config`, up to
1231 /// `max_programs` distinct programs per session — see
1232 /// [the module docs](self#max_programs-the-fifth-unbounded-allocation-vector-and-why-it-needs-its-own-bound-rather-than-reusing-max_sessions-or-a-trunk-capacity).
1233 pub fn new(
1234 listener: L,
1235 trunk_config: TrunkConfig,
1236 handshake: HandshakePolicy,
1237 max_programs: NonZeroUsize,
1238 ) -> Self {
1239 ListenDriver {
1240 listener,
1241 trunk_config,
1242 handshake,
1243 max_programs,
1244 sessions: HashMap::new(),
1245 next_id: 0,
1246 }
1247 }
1248
1249 /// The per-session program bound this driver enforces — see
1250 /// [`IngestDriver::max_programs`].
1251 pub fn max_programs(&self) -> NonZeroUsize {
1252 self.max_programs
1253 }
1254
1255 /// How many `NewProgram` events session `id` has refused because
1256 /// `max_programs` was already reached — see
1257 /// [`IngestDriver::refused_program_count`]. `None` for an unknown or
1258 /// already-reaped `id`.
1259 pub fn refused_program_count(&self, id: SessionId) -> Option<u64> {
1260 self.sessions
1261 .get(&id)
1262 .map(IngestDriver::refused_program_count)
1263 }
1264
1265 /// Currently-admitted (not yet terminated) session count. Never exceeds
1266 /// [`Listener::max_sessions`].
1267 pub fn session_count(&self) -> usize {
1268 self.sessions.len()
1269 }
1270
1271 /// The bound this driver enforces, from the underlying [`Listener`].
1272 pub fn max_sessions(&self) -> usize {
1273 self.listener.max_sessions()
1274 }
1275
1276 /// Try to accept one more connection, enforcing `max_sessions` (see the
1277 /// module docs). Calling this in a tight loop with nothing waiting, or
1278 /// with the bound already reached, is `O(1)` per call and never grows
1279 /// [`Self::session_count`] past [`Listener::max_sessions`] — flooding it
1280 /// is exactly the scenario this method exists to make safe.
1281 pub fn poll_accept(&mut self) -> AcceptOutcome<L::Error> {
1282 match self.listener.poll_accept() {
1283 Ok(None) => AcceptOutcome::Idle,
1284 Ok(Some(session)) => {
1285 if self.sessions.len() >= self.listener.max_sessions() {
1286 // Dropped right here: `session` is never fed, never
1287 // polled, never stored.
1288 drop(session);
1289 AcceptOutcome::Refused
1290 } else {
1291 let id = SessionId(self.next_id);
1292 self.next_id += 1;
1293 self.sessions.insert(
1294 id,
1295 IngestDriver::new(
1296 session,
1297 self.trunk_config,
1298 self.handshake,
1299 self.max_programs,
1300 ),
1301 );
1302 AcceptOutcome::Admitted(id)
1303 }
1304 }
1305 Err(e) => AcceptOutcome::Error(e),
1306 }
1307 }
1308
1309 /// Feed bytes read for session `id`. Returns `Some(health)` exactly when
1310 /// this call caused the session to terminate (`Ended`, `Failed`, or
1311 /// `HandshakeTimedOut`) — at which point it is removed from this driver
1312 /// (its slot is free for a future [`Self::poll_accept`]); returns `None`
1313 /// for an unknown `id` or a session still running (`Establishing` or
1314 /// `Live`) after this call.
1315 ///
1316 /// Pinned to `&[u8]` (unlike [`IngestDriver::feed`], generic over
1317 /// `Stage::In` since round 3): every [`Listener`] implementor
1318 /// today is a push accept over a byte-stream transport (RTMP/SRT
1319 /// listener), never a pull source (a pull source dials out via
1320 /// [`Dialer`], it does not accept — see `multimux::source::srt`'s "Why
1321 /// listener mode is not a `Listener` yet"), so there is no caller needing
1322 /// anything else here yet.
1323 pub fn feed(
1324 &mut self,
1325 id: SessionId,
1326 input: &[u8],
1327 now: Timestamp,
1328 ) -> Option<HealthState<<L::Session as Stage>::Error>>
1329 where
1330 L::Session: for<'a> Stage<In<'a> = &'a [u8]>,
1331 {
1332 self.drive(id, |d| d.feed(input, now))
1333 }
1334
1335 /// Let session `id` act on the passage of time — see
1336 /// [`IngestDriver::on_deadline`]. Same removal-on-termination contract
1337 /// as [`Self::feed`].
1338 pub fn on_deadline(
1339 &mut self,
1340 id: SessionId,
1341 now: Timestamp,
1342 ) -> Option<HealthState<<L::Session as Stage>::Error>> {
1343 self.drive(id, |d| d.on_deadline(now))
1344 }
1345
1346 /// Signal clean end-of-input for session `id` — see
1347 /// [`IngestDriver::finish`]. Same removal-on-termination contract as
1348 /// [`Self::feed`].
1349 pub fn finish(&mut self, id: SessionId) -> Option<HealthState<<L::Session as Stage>::Error>> {
1350 self.drive(id, IngestDriver::finish)
1351 }
1352
1353 /// This session's current health, if it is still admitted (a terminated
1354 /// session is removed by the call that terminated it — see
1355 /// [`Self::feed`] — so query the return value of that call for the
1356 /// terminal state).
1357 pub fn health(&self, id: SessionId) -> Option<&HealthState<<L::Session as Stage>::Error>> {
1358 self.sessions.get(&id).map(IngestDriver::health)
1359 }
1360
1361 /// The `Trunk` for `program` under session `id`, if announced yet.
1362 pub fn trunk(&self, id: SessionId, program: ProgramId) -> Option<&Arc<Trunk>> {
1363 self.sessions.get(&id).and_then(|d| d.trunk(program))
1364 }
1365
1366 /// Read-only access to session `id`'s underlying [`IngestDriver`], if
1367 /// still admitted.
1368 ///
1369 /// # Why this (and [`Self::driver_mut`]/[`Self::reap_if_terminal`]) exist
1370 ///
1371 /// [`Self::feed`] is pinned to `&[u8]` because it bundles three steps —
1372 /// feed, then check `is_running()`, then remove if not — into one call,
1373 /// which only works when the caller has nothing it needs to observe
1374 /// *between* "the session just went terminal" and "the session is gone".
1375 /// RTMP (issue #805 task 4) is exactly a caller that does: its
1376 /// `Listener::Session` is fed already-parsed-and-replied-to
1377 /// `rtmp_runtime::server::ServerEvent`s (`Stage::In<'a> = &'a
1378 /// [ServerEvent]`, not `&'a [u8]` — see `multimux::source::rtmp`'s module
1379 /// doc for why `RtmpConnection` makes that the honest shape), so
1380 /// [`Self::feed`]'s bound does not apply; and every driver-backed `run_*`
1381 /// entry point (`crate::source::report_driver_progress`,
1382 /// `crate::source::segment::drive_program_segmenters` in the `multimux`
1383 /// crate) needs to publish this session's newly-announced programs and
1384 /// flush its segmenter *before* a just-terminated session is reaped,
1385 /// exactly like the single-`IngestDriver` drive loops
1386 /// (`multimux::source::rtsp::run_rtsp` et al.) already do against an
1387 /// `IngestDriver` they own outright.
1388 ///
1389 /// These three methods let a driving loop reassemble that same
1390 /// feed → observe → reap sequence for a session admitted by a
1391 /// [`ListenDriver`], for any `Stage::In` shape: `driver_mut(id)` to feed
1392 /// (via [`IngestDriver::feed`], generic since round 3) or finish, `driver(id)`
1393 /// to read back `programs()`/`trunk()`/`health()`/[`IngestDriver::session`]
1394 /// afterward, then `reap_if_terminal(id)` to perform the exact removal
1395 /// [`Self::feed`] would have, once the caller is done observing.
1396 pub fn driver(&self, id: SessionId) -> Option<&IngestDriver<L::Session>> {
1397 self.sessions.get(&id)
1398 }
1399
1400 /// Mutable access to session `id`'s underlying [`IngestDriver`], if still
1401 /// admitted — see [`Self::driver`] for why this exists. Does **not**
1402 /// reap a session that becomes terminal as a result of a call made
1403 /// through this reference; call [`Self::reap_if_terminal`] afterward.
1404 pub fn driver_mut(&mut self, id: SessionId) -> Option<&mut IngestDriver<L::Session>> {
1405 self.sessions.get_mut(&id)
1406 }
1407
1408 /// If session `id` is admitted and has reached a terminal
1409 /// [`HealthState`] (`is_running() == false`), removes it and returns that
1410 /// final state — exactly the same removal step [`Self::feed`] performs
1411 /// internally, exposed standalone for a caller using [`Self::driver_mut`]
1412 /// instead of
1413 /// [`Self::feed`]/[`Self::on_deadline`]/[`Self::finish`] (see
1414 /// [`Self::driver`]'s doc). `None` for an unknown `id` or one still
1415 /// running — a no-op either way, so calling this speculatively every
1416 /// iteration is always safe.
1417 pub fn reap_if_terminal(
1418 &mut self,
1419 id: SessionId,
1420 ) -> Option<HealthState<<L::Session as Stage>::Error>> {
1421 let driver = self.sessions.get(&id)?;
1422 if driver.health().is_running() {
1423 None
1424 } else {
1425 self.sessions.remove(&id).map(|d| d.health)
1426 }
1427 }
1428
1429 /// Runs `op` against session `id`'s driver, then — if that call left it in
1430 /// a terminal state ([`HealthState::is_running`] `== false`) — removes it
1431 /// and returns that final state. This is the one place a session leaves
1432 /// `self.sessions`, which is what keeps this driver's resident memory
1433 /// bounded to [`Listener::max_sessions`] rather than accumulating every
1434 /// session that has ever ended, failed, or timed out mid-handshake.
1435 fn drive(
1436 &mut self,
1437 id: SessionId,
1438 op: impl FnOnce(&mut IngestDriver<L::Session>),
1439 ) -> Option<HealthState<<L::Session as Stage>::Error>> {
1440 let driver = self.sessions.get_mut(&id)?;
1441 op(driver);
1442 if driver.health().is_running() {
1443 None
1444 } else {
1445 self.sessions.remove(&id).map(|d| d.health)
1446 }
1447 }
1448}
1449
1450/// Build a [`ListenDriver`] over `listener` — the whole of `run_listen`.
1451pub fn run_listen<L: Listener>(
1452 listener: L,
1453 trunk_config: TrunkConfig,
1454 handshake: HandshakePolicy,
1455 max_programs: NonZeroUsize,
1456) -> ListenDriver<L> {
1457 ListenDriver::new(listener, trunk_config, handshake, max_programs)
1458}
1459
1460#[cfg(test)]
1461mod tests {
1462 use super::*;
1463 use broadcast_common::Demand;
1464 use std::collections::VecDeque;
1465 use transmux::pipeline::{CodecConfig, DataCarriage};
1466
1467 /// `NonZeroUsize` from a literal capacity — see `trunk`'s identical test
1468 /// helper.
1469 fn nz(n: usize) -> std::num::NonZeroUsize {
1470 std::num::NonZeroUsize::new(n).expect("test capacity must be non-zero")
1471 }
1472
1473 /// Minimal config every test's `Trunk`s share — capacities are irrelevant
1474 /// to these tests beyond "large enough that nothing evicts mid-test".
1475 fn trunk_config() -> TrunkConfig {
1476 TrunkConfig::new(nz(64), nz(16), nz(8), nz(8), nz(8))
1477 }
1478
1479 /// A handshake deadline far enough out that it never fires — for the
1480 /// tests that are not about the handshake bound. The two that *are* about
1481 /// it set their own tight deadline explicitly.
1482 fn handshake() -> HandshakePolicy {
1483 HandshakePolicy::establish_by(Timestamp::from_nanos(u64::MAX))
1484 }
1485
1486 /// An ambient `max_programs` bound for tests that are not about the
1487 /// program cap itself — large enough that no test relying on this
1488 /// helper ever hits it. The tests that *are* about the cap pass their
1489 /// own small `nz(N)` explicitly.
1490 fn max_programs() -> std::num::NonZeroUsize {
1491 nz(1024)
1492 }
1493
1494 fn sample(byte: u8) -> Sample {
1495 Sample::new(Bytes::from(vec![byte; 4]), Some(0), Some(0), Some(1), true)
1496 }
1497
1498 fn opaque_track(track_id: u32) -> TrackSpec {
1499 TrackSpec::new(
1500 track_id,
1501 90_000,
1502 CodecConfig::Data {
1503 stream_type: 0x06,
1504 descriptors: Vec::new(),
1505 carriage: DataCarriage::Pes,
1506 },
1507 )
1508 }
1509
1510 /// A fake, `#[cfg(test)]`-only error type for scripted sessions/dialers —
1511 /// carries a reason string purely for assertion messages.
1512 #[derive(Debug, Clone, PartialEq, Eq)]
1513 struct FakeError(&'static str);
1514
1515 /// What one `feed()` call on a [`ScriptedSession`] does.
1516 enum FeedOutcome {
1517 /// Succeed, queuing these events for `poll()` to hand back.
1518 Events(Vec<SessionEvent>),
1519 /// Fail outright with this error.
1520 Err(FakeError),
1521 }
1522
1523 /// A fully scripted [`IngestSession`]: each `feed()` call consumes the
1524 /// next entry of `script`, either queuing its events or failing; `finish`
1525 /// hands back `finish_outcome` (defaults to a clean `Ok(())`).
1526 ///
1527 /// Starts with [`SessionEvent::Established`] already queued — modelling a
1528 /// source whose handshake is a purely local operation with nothing to
1529 /// negotiate (binding a UDP socket), which is a real case, not a shortcut.
1530 /// The genuinely multi-round-trip case has its own session type below
1531 /// ([`HandshakeSession`]).
1532 struct ScriptedSession {
1533 script: VecDeque<FeedOutcome>,
1534 pending: VecDeque<SessionEvent>,
1535 finish_outcome: Result<(), FakeError>,
1536 }
1537
1538 impl ScriptedSession {
1539 fn new(script: Vec<FeedOutcome>) -> Self {
1540 ScriptedSession {
1541 script: script.into(),
1542 pending: VecDeque::from(vec![SessionEvent::Established]),
1543 finish_outcome: Ok(()),
1544 }
1545 }
1546
1547 fn failing_finish(mut self, err: FakeError) -> Self {
1548 self.finish_outcome = Err(err);
1549 self
1550 }
1551 }
1552
1553 impl Stage for ScriptedSession {
1554 type In<'a> = &'a [u8];
1555 type Out = SessionEvent;
1556 type Error = FakeError;
1557
1558 fn feed(&mut self, _input: &[u8], _now: Timestamp) -> Result<(), FakeError> {
1559 match self.script.pop_front() {
1560 Some(FeedOutcome::Events(evs)) => {
1561 self.pending.extend(evs);
1562 Ok(())
1563 }
1564 Some(FeedOutcome::Err(e)) => Err(e),
1565 None => Ok(()),
1566 }
1567 }
1568
1569 fn poll(&mut self) -> Option<SessionEvent> {
1570 self.pending.pop_front()
1571 }
1572
1573 fn finish(&mut self) -> Result<(), FakeError> {
1574 self.finish_outcome.clone()
1575 }
1576
1577 fn next_deadline(&self) -> Option<Timestamp> {
1578 None
1579 }
1580
1581 fn on_deadline(&mut self, _now: Timestamp) {}
1582
1583 fn demand(&self) -> Demand {
1584 Demand::new(4096)
1585 }
1586 }
1587
1588 /// Nothing to send: takes `poll_transmit`'s default.
1589 impl IngestSession for ScriptedSession {
1590 type Request = Bytes;
1591 }
1592
1593 /// A fake [`Dialer`] yielding one pre-built session then erroring on
1594 /// every call after (or always erroring, for the reconnect test).
1595 struct ScriptedDialer {
1596 sessions: VecDeque<ScriptedSession>,
1597 fail_with: FakeError,
1598 }
1599
1600 impl Dialer for ScriptedDialer {
1601 type Session = ScriptedSession;
1602 type Error = FakeError;
1603
1604 fn dial(&mut self) -> Result<ScriptedSession, FakeError> {
1605 self.sessions
1606 .pop_front()
1607 .ok_or_else(|| self.fail_with.clone())
1608 }
1609 }
1610
1611 // --- run_dial: happy path, samples land in the Trunk ------------------
1612
1613 #[test]
1614 fn run_dial_drives_fake_session_end_to_end_samples_land_in_trunk() {
1615 let session = ScriptedSession::new(vec![
1616 FeedOutcome::Events(vec![SessionEvent::NewProgram {
1617 program: ProgramId(1),
1618 tracks: vec![opaque_track(7)],
1619 }]),
1620 FeedOutcome::Events(vec![SessionEvent::Sample {
1621 program: ProgramId(1),
1622 track_id: 7,
1623 retention: RetentionClass::Timed,
1624 sample: sample(0xAB),
1625 }]),
1626 ]);
1627 let mut dialer = ScriptedDialer {
1628 sessions: VecDeque::from(vec![session]),
1629 fail_with: FakeError("unused"),
1630 };
1631
1632 let mut driver = run_dial(&mut dialer, trunk_config(), handshake(), max_programs())
1633 .expect("fake dial succeeds");
1634 let trunk_before = driver.trunk(ProgramId(1)).cloned();
1635 assert!(
1636 trunk_before.is_none(),
1637 "no Trunk before NewProgram is announced"
1638 );
1639
1640 driver.feed(b"pat", Timestamp::ZERO);
1641 let trunk = driver
1642 .trunk(ProgramId(1))
1643 .cloned()
1644 .expect("NewProgram announced a Trunk for program 1");
1645 let mut cursor = trunk.subscribe();
1646
1647 driver.feed(b"pes", Timestamp::from_nanos(1));
1648
1649 let item = cursor.poll().expect("the published sample is on the ring");
1650 match item {
1651 crate::SampleCursorItem::Timed { track_id, sample } => {
1652 assert_eq!(track_id, 7);
1653 assert_eq!(sample.data.as_ref(), &[0xAB; 4]);
1654 }
1655 other => panic!("expected Timed, got {other:?}"),
1656 }
1657 }
1658
1659 // --- Track-set plumbing (issue #781): NewProgram seeds, TracksChanged --
1660 // --- replaces, unannounced TracksChanged drops -------------------------
1661
1662 /// The discarded-track-list fix, made to fail against the pre-fix code:
1663 /// before `drain()`'s `NewProgram` arm called `writer.set_tracks(tracks)`,
1664 /// `tracks` was bound with `..` and never touched a `Trunk` at all, so
1665 /// every `Trunk::tracks()` stayed permanently empty.
1666 ///
1667 /// MUTATION VERIFIED: reverting `drain()`'s `NewProgram` arm to bind
1668 /// `SessionEvent::NewProgram { program, .. }` (dropping `tracks`, as it
1669 /// was before this change) and removing the `writer.set_tracks(tracks)`
1670 /// call makes the `assert_eq!` below fail — `track_ids` reads back `[]`
1671 /// instead of `[3, 9]`. Recompiled and re-run to confirm the failure,
1672 /// then reverted.
1673 #[test]
1674 fn new_program_seeds_the_trunk_with_exactly_the_announced_tracks() {
1675 let session =
1676 ScriptedSession::new(vec![FeedOutcome::Events(vec![SessionEvent::NewProgram {
1677 program: ProgramId(1),
1678 tracks: vec![opaque_track(3), opaque_track(9)],
1679 }])]);
1680 let mut dialer = ScriptedDialer {
1681 sessions: VecDeque::from(vec![session]),
1682 fail_with: FakeError("unused"),
1683 };
1684 let mut driver = run_dial(&mut dialer, trunk_config(), handshake(), max_programs())
1685 .expect("fake dial succeeds");
1686
1687 driver.feed(b"pat", Timestamp::ZERO);
1688
1689 let trunk = driver
1690 .trunk(ProgramId(1))
1691 .cloned()
1692 .expect("NewProgram announced a Trunk for program 1");
1693 let track_ids: Vec<u32> = trunk.tracks().iter().map(|t| t.track_id).collect();
1694 assert_eq!(
1695 track_ids,
1696 vec![3, 9],
1697 "the Trunk must expose exactly the tracks NewProgram carried"
1698 );
1699 }
1700
1701 /// `TracksChanged` replaces the previously-seeded set wholesale and
1702 /// bumps `track_generation` — NewProgram's own seeding call counts as
1703 /// the first `set_tracks`, so generation reads `1` right after
1704 /// admission and `2` after the `TracksChanged`.
1705 ///
1706 /// MUTATION VERIFIED: changing `drain()`'s `TracksChanged` arm from
1707 /// `writer.set_tracks(tracks)` to `writer.publish_event(..)`-style no-op
1708 /// (concretely: commenting out the `writer.set_tracks(tracks);` call,
1709 /// leaving the event silently absorbed) makes both assertions below
1710 /// fail — `track_ids` still reads back `[1]` instead of `[1, 2]`, and
1711 /// `track_generation()` stays at `1` instead of advancing to `2`.
1712 /// Recompiled and re-run to confirm the failure, then reverted.
1713 #[test]
1714 fn tracks_changed_replaces_the_set_and_bumps_generation() {
1715 let session = ScriptedSession::new(vec![
1716 FeedOutcome::Events(vec![SessionEvent::NewProgram {
1717 program: ProgramId(1),
1718 tracks: vec![opaque_track(1)],
1719 }]),
1720 FeedOutcome::Events(vec![SessionEvent::TracksChanged {
1721 program: ProgramId(1),
1722 tracks: vec![opaque_track(1), opaque_track(2)],
1723 }]),
1724 ]);
1725 let mut dialer = ScriptedDialer {
1726 sessions: VecDeque::from(vec![session]),
1727 fail_with: FakeError("unused"),
1728 };
1729 let mut driver = run_dial(&mut dialer, trunk_config(), handshake(), max_programs())
1730 .expect("fake dial succeeds");
1731
1732 driver.feed(b"pat", Timestamp::from_nanos(0));
1733 let trunk = driver.trunk(ProgramId(1)).cloned().unwrap();
1734 assert_eq!(
1735 trunk.track_generation(),
1736 1,
1737 "NewProgram's seed counts as the first set_tracks call"
1738 );
1739
1740 driver.feed(b"pmt-bump", Timestamp::from_nanos(1));
1741 let track_ids: Vec<u32> = trunk.tracks().iter().map(|t| t.track_id).collect();
1742 assert_eq!(
1743 track_ids,
1744 vec![1, 2],
1745 "TracksChanged must replace the set with the new complete snapshot"
1746 );
1747 assert_eq!(
1748 trunk.track_generation(),
1749 2,
1750 "TracksChanged must bump the generation exactly once"
1751 );
1752 }
1753
1754 /// A `TracksChanged` for a program never announced via `NewProgram` is a
1755 /// contract violation, handled exactly like an unannounced `Sample`:
1756 /// dropped, not panicking, and never minting a `Trunk` on its own.
1757 ///
1758 /// MUTATION VERIFIED: changing `drain()`'s `TracksChanged` arm from
1759 /// `if let Some(writer) = self.writers.get(&program) { .. }` to
1760 /// unconditionally minting a fresh `Trunk`/`writer` for `program`
1761 /// (mirroring what `NewProgram` does) makes the `assert!` below fail —
1762 /// `driver.trunk(ProgramId(1))` comes back `Some(..)` instead of `None`.
1763 /// Recompiled and re-run to confirm the failure, then reverted.
1764 #[test]
1765 fn tracks_changed_for_an_unannounced_program_is_dropped_not_panicking_and_mints_nothing() {
1766 let session = ScriptedSession::new(vec![FeedOutcome::Events(vec![
1767 SessionEvent::TracksChanged {
1768 program: ProgramId(1),
1769 tracks: vec![opaque_track(1)],
1770 },
1771 ])]);
1772 let mut driver = IngestDriver::new(session, trunk_config(), handshake(), max_programs());
1773
1774 // Must not panic.
1775 driver.feed(b"stray", Timestamp::ZERO);
1776
1777 assert!(
1778 driver.trunk(ProgramId(1)).is_none(),
1779 "TracksChanged must never mint a Trunk on its own"
1780 );
1781 assert_eq!(
1782 driver.program_count(),
1783 0,
1784 "an unannounced program must not be admitted by TracksChanged"
1785 );
1786 }
1787
1788 /// `track_generation` is stable across everything that is *not* a
1789 /// `NewProgram` seed or a `TracksChanged` — ordinary samples and no-op
1790 /// feed calls must never bump it, so a consumer polling the generation
1791 /// as a cheap "did the track set change" check sees no false positives.
1792 ///
1793 /// MUTATION VERIFIED: adding a `writer.set_tracks(Vec::new())` call to
1794 /// `drain()`'s `Sample` arm (simulating "generation accidentally bumped
1795 /// by unrelated activity") makes the final `assert_eq!` below fail —
1796 /// `track_generation()` reads back `2` instead of `1` after the sample
1797 /// is published. Recompiled and re-run to confirm the failure, then
1798 /// reverted.
1799 #[test]
1800 fn track_generation_is_stable_when_nothing_changes() {
1801 let session = ScriptedSession::new(vec![
1802 FeedOutcome::Events(vec![SessionEvent::NewProgram {
1803 program: ProgramId(1),
1804 tracks: vec![opaque_track(1)],
1805 }]),
1806 FeedOutcome::Events(vec![SessionEvent::Sample {
1807 program: ProgramId(1),
1808 track_id: 1,
1809 retention: RetentionClass::Timed,
1810 sample: sample(0xAB),
1811 }]),
1812 FeedOutcome::Events(vec![]),
1813 ]);
1814 let mut driver = IngestDriver::new(session, trunk_config(), handshake(), max_programs());
1815
1816 driver.feed(b"1", Timestamp::from_nanos(0));
1817 let trunk = driver.trunk(ProgramId(1)).cloned().unwrap();
1818 assert_eq!(trunk.track_generation(), 1);
1819
1820 driver.feed(b"2", Timestamp::from_nanos(1));
1821 driver.feed(b"3", Timestamp::from_nanos(2));
1822
1823 assert_eq!(
1824 trunk.track_generation(),
1825 1,
1826 "samples and no-op feeds must never bump track_generation"
1827 );
1828 }
1829
1830 /// A REPEAT `NewProgram` for an already-admitted program must update the
1831 /// existing `Trunk` in place, never mint a replacement.
1832 ///
1833 /// This asserts continuity from the *subscriber's* side, which is the
1834 /// property that actually matters and the one a track-set assertion
1835 /// alone would miss: `Trunk` is a cloneable handle over shared state, so
1836 /// re-minting swaps a fresh empty `Trunk` into `programs` while every
1837 /// already-issued cursor keeps reading the orphaned one that no longer
1838 /// receives writes. The stream does not error — it silently stops, which
1839 /// is far harder to diagnose in production than a crash, and whatever
1840 /// the old `Trunk` still buffered (and so the DVR window) goes with it.
1841 ///
1842 /// MUTATION VERIFIED: removing the `if let Some(writer) =
1843 /// self.writers.get(&program) { .. continue }` early-return from
1844 /// `drain()`'s `NewProgram` arm (restoring the unconditional
1845 /// `Trunk::new`) fails this test on the track-set assertion first —
1846 /// `left: [7], right: [7, 8]`, the re-announcement's tracks having
1847 /// landed on a replacement `Trunk` the subscriber cannot see.
1848 ///
1849 /// Both assertions were confirmed to bite independently: re-running the
1850 /// mutation with the track-set assertion suppressed then fails on
1851 /// `cursor.poll()` returning `None` ("a cursor subscribed before the
1852 /// re-announcement must still receive samples"), which is the direct
1853 /// proof of subscriber stranding rather than an inference from the
1854 /// track set. Recompiled and re-run for each, then reverted.
1855 #[test]
1856 fn repeat_new_program_updates_in_place_and_does_not_strand_subscribers() {
1857 let session = ScriptedSession::new(vec![
1858 FeedOutcome::Events(vec![SessionEvent::NewProgram {
1859 program: ProgramId(1),
1860 tracks: vec![opaque_track(7)],
1861 }]),
1862 // The same program announced again, with a grown track set --
1863 // what a session that re-states its program on a PMT change
1864 // emits, rather than using `TracksChanged`.
1865 FeedOutcome::Events(vec![SessionEvent::NewProgram {
1866 program: ProgramId(1),
1867 tracks: vec![opaque_track(7), opaque_track(8)],
1868 }]),
1869 FeedOutcome::Events(vec![SessionEvent::Sample {
1870 program: ProgramId(1),
1871 track_id: 7,
1872 retention: RetentionClass::Timed,
1873 sample: sample(0xCD),
1874 }]),
1875 ]);
1876 let mut dialer = ScriptedDialer {
1877 sessions: VecDeque::from(vec![session]),
1878 fail_with: FakeError("unused"),
1879 };
1880 let mut driver = run_dial(&mut dialer, trunk_config(), handshake(), max_programs())
1881 .expect("fake dial succeeds");
1882
1883 driver.feed(b"pat", Timestamp::ZERO);
1884 let trunk = driver
1885 .trunk(ProgramId(1))
1886 .cloned()
1887 .expect("NewProgram announced a Trunk for program 1");
1888 let mut cursor = trunk.subscribe();
1889
1890 driver.feed(b"pat-again", Timestamp::from_nanos(1));
1891
1892 // The re-announcement is an update, not a new program.
1893 assert_eq!(
1894 driver.program_count(),
1895 1,
1896 "a repeat announcement must not add a program"
1897 );
1898 let track_ids: Vec<u32> = trunk.tracks().iter().map(|t| t.track_id).collect();
1899 assert_eq!(
1900 track_ids,
1901 vec![7, 8],
1902 "the re-announcement's track set must land on the SAME Trunk the \
1903 subscriber already holds"
1904 );
1905
1906 driver.feed(b"pes", Timestamp::from_nanos(2));
1907
1908 let item = cursor
1909 .poll()
1910 .expect("a cursor subscribed before the re-announcement must still receive samples");
1911 match item {
1912 crate::SampleCursorItem::Timed { track_id, sample } => {
1913 assert_eq!(track_id, 7);
1914 assert_eq!(sample.data.as_ref(), &[0xCD; 4]);
1915 }
1916 other => panic!("expected Timed, got {other:?}"),
1917 }
1918 }
1919
1920 // --- EOF vs failure: the test that makes HealthState::Failed real -----
1921
1922 #[test]
1923 fn clean_finish_yields_ended_not_failed() {
1924 let session = ScriptedSession::new(vec![]);
1925 let mut driver = IngestDriver::new(session, trunk_config(), handshake(), max_programs());
1926 assert!(
1927 matches!(driver.health(), HealthState::Establishing),
1928 "a freshly dialled session has not established yet"
1929 );
1930
1931 driver.finish();
1932
1933 // MUTATION-CHECKED: flip this to `Failed` in the impl (or make
1934 // `finish()`'s `Ok` arm also set `Failed`) and this assertion is the
1935 // one that catches it.
1936 assert!(
1937 matches!(driver.health(), HealthState::Ended),
1938 "a session that finished cleanly must be Ended, not Failed: {:?}",
1939 driver.health()
1940 );
1941 }
1942
1943 #[test]
1944 fn erroring_feed_yields_failed_not_ended() {
1945 let session = ScriptedSession::new(vec![FeedOutcome::Err(FakeError("bad continuity"))]);
1946 let mut driver = IngestDriver::new(session, trunk_config(), handshake(), max_programs());
1947
1948 driver.feed(b"garbage", Timestamp::ZERO);
1949
1950 match driver.health() {
1951 HealthState::Failed(FakeError(reason)) => assert_eq!(*reason, "bad continuity"),
1952 other => panic!("expected Failed(\"bad continuity\"), got {other:?}"),
1953 }
1954 }
1955
1956 #[test]
1957 fn erroring_finish_yields_failed_not_ended() {
1958 let session = ScriptedSession::new(vec![]).failing_finish(FakeError("truncated tail"));
1959 let mut driver = IngestDriver::new(session, trunk_config(), handshake(), max_programs());
1960
1961 driver.finish();
1962
1963 match driver.health() {
1964 HealthState::Failed(FakeError(reason)) => assert_eq!(*reason, "truncated tail"),
1965 other => panic!("expected Failed(\"truncated tail\"), got {other:?}"),
1966 }
1967 }
1968
1969 #[test]
1970 fn terminated_driver_ignores_further_feed_and_finish() {
1971 let session = ScriptedSession::new(vec![FeedOutcome::Err(FakeError("boom"))]);
1972 let mut driver = IngestDriver::new(session, trunk_config(), handshake(), max_programs());
1973 driver.feed(b"x", Timestamp::ZERO);
1974 assert!(matches!(driver.health(), HealthState::Failed(_)));
1975
1976 // Once Failed, feed/finish must be no-ops: no panic, and health does
1977 // not flip back to Ended via a stray finish() call.
1978 driver.finish();
1979 assert!(matches!(driver.health(), HealthState::Failed(_)));
1980 }
1981
1982 // --- Multi-program (B5): two programs -> two Trunks; late program -----
1983
1984 #[test]
1985 fn one_connection_two_programs_yields_two_trunks_including_one_announced_late() {
1986 let session = ScriptedSession::new(vec![
1987 FeedOutcome::Events(vec![
1988 SessionEvent::NewProgram {
1989 program: ProgramId(1),
1990 tracks: vec![opaque_track(1)],
1991 },
1992 SessionEvent::Sample {
1993 program: ProgramId(1),
1994 track_id: 1,
1995 retention: RetentionClass::Timed,
1996 sample: sample(0x01),
1997 },
1998 ]),
1999 // Nothing new this round: proves NewProgram isn't required on
2000 // every feed call.
2001 FeedOutcome::Events(vec![]),
2002 // Program 2 appears only now, after program 1's samples were
2003 // already flowing — the exact "announced after ingest started"
2004 // case B5 requires.
2005 FeedOutcome::Events(vec![
2006 SessionEvent::NewProgram {
2007 program: ProgramId(2),
2008 tracks: vec![opaque_track(9)],
2009 },
2010 SessionEvent::Sample {
2011 program: ProgramId(2),
2012 track_id: 9,
2013 retention: RetentionClass::Timed,
2014 sample: sample(0x02),
2015 },
2016 ]),
2017 ]);
2018 let mut dialer = ScriptedDialer {
2019 sessions: VecDeque::from(vec![session]),
2020 fail_with: FakeError("unused"),
2021 };
2022 let mut driver =
2023 run_dial(&mut dialer, trunk_config(), handshake(), max_programs()).unwrap();
2024
2025 driver.feed(b"1", Timestamp::from_nanos(0));
2026 assert!(driver.trunk(ProgramId(1)).is_some());
2027 assert!(
2028 driver.trunk(ProgramId(2)).is_none(),
2029 "program 2 must not exist before it is announced"
2030 );
2031
2032 driver.feed(b"2", Timestamp::from_nanos(1));
2033 assert!(
2034 driver.trunk(ProgramId(2)).is_none(),
2035 "a no-op feed must not fabricate a program"
2036 );
2037
2038 driver.feed(b"3", Timestamp::from_nanos(2));
2039 let trunk1 = driver.trunk(ProgramId(1)).cloned().unwrap();
2040 let trunk2 = driver
2041 .trunk(ProgramId(2))
2042 .cloned()
2043 .expect("program 2 announced mid-session must get its own Trunk");
2044 assert!(
2045 !Arc::ptr_eq(&trunk1, &trunk2),
2046 "each program must get a genuinely distinct Trunk"
2047 );
2048
2049 let mut programs: Vec<_> = driver.programs().collect();
2050 programs.sort();
2051 assert_eq!(programs, vec![ProgramId(1), ProgramId(2)]);
2052
2053 // Both Trunks actually carry their own program's sample, subscribed
2054 // fresh now (after the fact) — proving the two rings are genuinely
2055 // independent, not aliases of the same one.
2056 assert_eq!(trunk1.timed_len(), 1);
2057 assert_eq!(trunk2.timed_len(), 1);
2058 }
2059
2060 // --- max_programs: the fifth unbounded-allocation vector, bounded ------
2061
2062 #[test]
2063 fn programs_up_to_max_get_a_trunk_each_the_next_one_is_refused_and_reported() {
2064 let cap = 2;
2065 let session = ScriptedSession::new(vec![FeedOutcome::Events(vec![
2066 SessionEvent::NewProgram {
2067 program: ProgramId(1),
2068 tracks: vec![opaque_track(1)],
2069 },
2070 SessionEvent::NewProgram {
2071 program: ProgramId(2),
2072 tracks: vec![opaque_track(2)],
2073 },
2074 // The (cap+1)th distinct program in the same drain() call.
2075 SessionEvent::NewProgram {
2076 program: ProgramId(3),
2077 tracks: vec![opaque_track(3)],
2078 },
2079 ])]);
2080 let mut driver = IngestDriver::new(session, trunk_config(), handshake(), nz(cap));
2081
2082 driver.feed(b"pat", Timestamp::ZERO);
2083
2084 assert!(driver.trunk(ProgramId(1)).is_some(), "program 1 admitted");
2085 assert!(driver.trunk(ProgramId(2)).is_some(), "program 2 admitted");
2086 assert!(
2087 driver.trunk(ProgramId(3)).is_none(),
2088 "the (cap+1)th program must be refused a Trunk"
2089 );
2090 assert_eq!(
2091 driver.program_count(),
2092 cap,
2093 "admitted program count must sit exactly at the cap, not above it"
2094 );
2095 // MUTATION-CHECKED: dropping the `refused_programs += 1` (or the
2096 // whole cap check) in `drain()`'s `NewProgram` arm makes this fail —
2097 // reported, not a silent drop.
2098 assert_eq!(
2099 driver.refused_program_count(),
2100 1,
2101 "the refusal must be reported via a queryable counter, never silent"
2102 );
2103 }
2104
2105 #[test]
2106 fn repeat_announcement_of_an_already_admitted_program_is_never_refused() {
2107 // A program re-announcing itself (e.g. a PMT version bump reiterating
2108 // the same program_number) must not be treated as a new admission and
2109 // so must never count against the cap or be refused.
2110 let cap = 1;
2111 let session = ScriptedSession::new(vec![FeedOutcome::Events(vec![
2112 SessionEvent::NewProgram {
2113 program: ProgramId(1),
2114 tracks: vec![opaque_track(1)],
2115 },
2116 SessionEvent::NewProgram {
2117 program: ProgramId(1),
2118 tracks: vec![opaque_track(1)],
2119 },
2120 ])]);
2121 let mut driver = IngestDriver::new(session, trunk_config(), handshake(), nz(cap));
2122
2123 driver.feed(b"pat", Timestamp::ZERO);
2124
2125 assert_eq!(driver.program_count(), 1);
2126 assert_eq!(
2127 driver.refused_program_count(),
2128 0,
2129 "re-announcing an already-admitted program must not be refused"
2130 );
2131 }
2132
2133 #[test]
2134 fn refusal_does_not_disturb_already_admitted_programs_their_samples_keep_flowing() {
2135 let cap = 1;
2136 let session = ScriptedSession::new(vec![
2137 FeedOutcome::Events(vec![SessionEvent::NewProgram {
2138 program: ProgramId(1),
2139 tracks: vec![opaque_track(1)],
2140 }]),
2141 // In the SAME drain() call: program 2 is refused, and a sample
2142 // for the already-admitted program 1 is published — proving the
2143 // refusal of one program does not interrupt delivery for another
2144 // already flowing, which is the whole justification for
2145 // "refuse the extra program" over "fail the session".
2146 FeedOutcome::Events(vec![
2147 SessionEvent::NewProgram {
2148 program: ProgramId(2),
2149 tracks: vec![opaque_track(2)],
2150 },
2151 SessionEvent::Sample {
2152 program: ProgramId(1),
2153 track_id: 1,
2154 retention: RetentionClass::Timed,
2155 sample: sample(0x01),
2156 },
2157 ]),
2158 ]);
2159 let mut driver = IngestDriver::new(session, trunk_config(), handshake(), nz(cap));
2160
2161 driver.feed(b"1", Timestamp::from_nanos(0));
2162 let trunk1 = driver.trunk(ProgramId(1)).cloned().unwrap();
2163 let mut cursor = trunk1.subscribe();
2164
2165 driver.feed(b"2", Timestamp::from_nanos(1));
2166
2167 assert!(
2168 driver.trunk(ProgramId(2)).is_none(),
2169 "program 2 must be refused, not given a Trunk"
2170 );
2171 assert_eq!(driver.refused_program_count(), 1);
2172 // MUTATION-CHECKED: if refusing program 2 were implemented by
2173 // failing the whole session (e.g. setting `self.health =
2174 // HealthState::Failed(..)`) instead of just skipping the one
2175 // `NewProgram`, this poll would come back empty because `feed`
2176 // would have stopped draining before the Sample event — this is
2177 // the assertion that would catch that.
2178 match cursor
2179 .poll()
2180 .expect("program 1's sample must still land despite program 2 being refused")
2181 {
2182 crate::SampleCursorItem::Timed { track_id, sample } => {
2183 assert_eq!(track_id, 1);
2184 assert_eq!(sample.data.as_ref(), &[0x01; 4]);
2185 }
2186 other => panic!("expected Timed, got {other:?}"),
2187 }
2188 assert!(
2189 matches!(driver.health(), HealthState::Live),
2190 "refusing an extra program must not fail the session: {:?}",
2191 driver.health()
2192 );
2193 }
2194
2195 #[test]
2196 fn newprogram_flood_is_bounded_admits_exactly_max_programs_and_allocates_no_more_trunks() {
2197 let cap = 3;
2198 let mut events = Vec::with_capacity(10_000);
2199 for i in 0..10_000u32 {
2200 events.push(SessionEvent::NewProgram {
2201 program: ProgramId(i),
2202 tracks: vec![opaque_track(i)],
2203 });
2204 }
2205 let session = ScriptedSession::new(vec![FeedOutcome::Events(events)]);
2206 let mut driver = IngestDriver::new(session, trunk_config(), handshake(), nz(cap));
2207
2208 driver.feed(b"flood", Timestamp::ZERO);
2209
2210 // The count, not merely the outcome: exactly `cap` Trunks exist no
2211 // matter how many thousands of distinct ProgramIds were announced —
2212 // this is the assertion a `Vec<ProgramId>`-of-refusals regression
2213 // (unbounded in the same way the bug itself was) would still pass,
2214 // which is why it is checked here rather than only "some program
2215 // beyond the cap has no Trunk".
2216 assert_eq!(
2217 driver.program_count(),
2218 cap,
2219 "a 10,000-program flood must admit exactly max_programs Trunks, never more"
2220 );
2221 assert_eq!(
2222 driver.refused_program_count(),
2223 10_000 - cap as u64,
2224 "every program past the cap must be counted as refused"
2225 );
2226 }
2227
2228 // --- run_listen: max_sessions is a hard bound --------------------------
2229
2230 /// A [`Listener`] that always has a fresh session ready to accept —
2231 /// models an unbounded flood of inbound connections.
2232 struct FloodingListener {
2233 max_sessions: usize,
2234 }
2235
2236 impl Listener for FloodingListener {
2237 type Session = ScriptedSession;
2238 type Error = FakeError;
2239
2240 fn max_sessions(&self) -> usize {
2241 self.max_sessions
2242 }
2243
2244 fn poll_accept(&mut self) -> Result<Option<ScriptedSession>, FakeError> {
2245 Ok(Some(ScriptedSession::new(vec![])))
2246 }
2247 }
2248
2249 #[test]
2250 fn run_listen_admits_up_to_max_sessions_then_refuses_and_stays_bounded() {
2251 let max_sessions = 3;
2252 let mut driver = run_listen(
2253 FloodingListener { max_sessions },
2254 trunk_config(),
2255 handshake(),
2256 max_programs(),
2257 );
2258
2259 for _ in 0..max_sessions {
2260 assert!(matches!(driver.poll_accept(), AcceptOutcome::Admitted(_)));
2261 }
2262 assert_eq!(driver.session_count(), max_sessions);
2263
2264 // Flood far beyond the bound: every one of these must be refused,
2265 // and — the memory-growth assertion — session_count must never
2266 // exceed max_sessions, checked on every single iteration, not just
2267 // at the end.
2268 for _ in 0..10_000 {
2269 assert!(matches!(driver.poll_accept(), AcceptOutcome::Refused));
2270 assert!(
2271 driver.session_count() <= max_sessions,
2272 "session_count grew past max_sessions under flood"
2273 );
2274 }
2275 assert_eq!(driver.session_count(), max_sessions);
2276 }
2277
2278 #[test]
2279 fn ended_session_is_reaped_freeing_a_slot() {
2280 let mut driver = run_listen(
2281 FloodingListener { max_sessions: 1 },
2282 trunk_config(),
2283 handshake(),
2284 max_programs(),
2285 );
2286 let AcceptOutcome::Admitted(id) = driver.poll_accept() else {
2287 panic!("expected admission");
2288 };
2289 assert_eq!(driver.session_count(), 1);
2290 assert!(matches!(driver.poll_accept(), AcceptOutcome::Refused));
2291
2292 let health = driver.finish(id).expect("finish terminates the session");
2293 assert!(matches!(health, HealthState::Ended));
2294 assert_eq!(
2295 driver.session_count(),
2296 0,
2297 "a terminated session must be reaped, freeing its slot"
2298 );
2299 assert!(
2300 driver.health(id).is_none(),
2301 "a reaped session is no longer queryable by id"
2302 );
2303
2304 // The freed slot admits a new connection.
2305 assert!(matches!(driver.poll_accept(), AcceptOutcome::Admitted(_)));
2306 }
2307
2308 /// `driver`/`driver_mut`/`reap_if_terminal` (issue #805 task 4) must let a
2309 /// caller reassemble exactly what `Self::feed`/`Self::finish` do in one
2310 /// call, but with the observe step exposed *between* the state change and
2311 /// the reap — the whole reason these exist (see `Self::driver`'s doc: a
2312 /// `Listener::Session` whose `Stage::In` isn't `&[u8]`, e.g.
2313 /// `multimux::source::rtmp`'s `RtmpIngestSession`, cannot use
2314 /// `Self::feed` at all).
2315 ///
2316 /// MUTATION-CHECKED: dropping the `driver_mut(id).finish()` call (so the
2317 /// session is never actually finished) would leave `driver(id)`'s health
2318 /// at `Live`, failing the `Ended` assertion below; dropping the
2319 /// `reap_if_terminal` call would leave `session_count()` at 1 and
2320 /// `driver(id)` still `Some(_)`, failing the assertions after it.
2321 #[test]
2322 fn driver_mut_and_reap_if_terminal_mirror_feed_semantics() {
2323 let mut driver = run_listen(
2324 FloodingListener { max_sessions: 1 },
2325 trunk_config(),
2326 handshake(),
2327 max_programs(),
2328 );
2329 let AcceptOutcome::Admitted(id) = driver.poll_accept() else {
2330 panic!("expected admission");
2331 };
2332 assert!(
2333 matches!(
2334 driver.driver(id).map(IngestDriver::health),
2335 Some(HealthState::Establishing)
2336 ),
2337 "a freshly admitted session must be Establishing, observable via driver()"
2338 );
2339
2340 // `driver_mut` feeds through the exact same `IngestDriver::feed`
2341 // `Self::feed` calls internally, but does NOT reap on termination.
2342 driver
2343 .driver_mut(id)
2344 .expect("session just admitted")
2345 .feed(b"reply", Timestamp::from_nanos(1));
2346 assert!(
2347 matches!(
2348 driver.driver(id).map(IngestDriver::health),
2349 Some(HealthState::Live)
2350 ),
2351 "driver_mut's feed must reach the session exactly like Self::feed would"
2352 );
2353 assert_eq!(driver.session_count(), 1, "not reaped: still Live");
2354
2355 // Finishing must be observable via `driver()` BEFORE `reap_if_terminal`
2356 // removes it -- the exact ordering a driving loop that must
2357 // publish/flush before reaping depends on.
2358 driver.driver_mut(id).expect("still admitted").finish();
2359 assert!(
2360 matches!(
2361 driver.driver(id).map(IngestDriver::health),
2362 Some(HealthState::Ended)
2363 ),
2364 "finish() through driver_mut must be observable via driver() before reaping"
2365 );
2366 assert_eq!(driver.session_count(), 1, "not yet reaped");
2367
2368 let health = driver
2369 .reap_if_terminal(id)
2370 .expect("a terminal session must be reaped");
2371 assert!(matches!(health, HealthState::Ended));
2372 assert_eq!(
2373 driver.session_count(),
2374 0,
2375 "reap_if_terminal must free the slot"
2376 );
2377 assert!(driver.driver(id).is_none());
2378
2379 // A second call on an already-reaped id is a no-op, not a panic.
2380 assert!(driver.reap_if_terminal(id).is_none());
2381 }
2382
2383 // --- Reconnect: bounded, caller-configurable, never spins --------------
2384
2385 #[test]
2386 fn permanently_failing_dial_is_bounded_and_does_not_spin_or_grow() {
2387 let dialer = ScriptedDialer {
2388 sessions: VecDeque::new(),
2389 fail_with: FakeError("connection refused"),
2390 };
2391 let mut supervisor = DialSupervisor::new(dialer, ReconnectPolicy::new(3));
2392
2393 assert!(matches!(
2394 supervisor.try_dial(trunk_config(), handshake(), max_programs()),
2395 DialAttempt::Retry(_)
2396 ));
2397 assert_eq!(supervisor.attempts(), 1);
2398 assert!(matches!(
2399 supervisor.try_dial(trunk_config(), handshake(), max_programs()),
2400 DialAttempt::Retry(_)
2401 ));
2402 assert_eq!(supervisor.attempts(), 2);
2403 assert!(matches!(
2404 supervisor.try_dial(trunk_config(), handshake(), max_programs()),
2405 DialAttempt::GaveUp(_)
2406 ));
2407 assert_eq!(supervisor.attempts(), 3);
2408 assert!(supervisor.is_exhausted());
2409
2410 // Flood: however many more times this is called, it must never dial
2411 // again (no growth in `attempts`) and must always report Exhausted,
2412 // not spin back into Retry/GaveUp.
2413 for _ in 0..10_000 {
2414 assert!(matches!(
2415 supervisor.try_dial(trunk_config(), handshake(), max_programs()),
2416 DialAttempt::Exhausted
2417 ));
2418 assert_eq!(
2419 supervisor.attempts(),
2420 3,
2421 "attempts must not grow past max_attempts under flood"
2422 );
2423 }
2424 }
2425
2426 #[test]
2427 fn dial_supervisor_succeeds_within_the_bound_and_resets_attempts() {
2428 let good_session = ScriptedSession::new(vec![]);
2429 let dialer = ScriptedDialer {
2430 sessions: VecDeque::from(vec![good_session]),
2431 fail_with: FakeError("refused"),
2432 };
2433 let mut supervisor = DialSupervisor::new(dialer, ReconnectPolicy::new(2));
2434
2435 // First attempt succeeds immediately (the scripted dialer's one
2436 // queued session comes out on the very first `dial()` call).
2437 match supervisor.try_dial(trunk_config(), handshake(), max_programs()) {
2438 DialAttempt::Connected(_) => {}
2439 DialAttempt::Retry(_) => panic!("expected Connected, got Retry"),
2440 DialAttempt::GaveUp(_) => panic!("expected Connected, got GaveUp"),
2441 DialAttempt::Exhausted => panic!("expected Connected, got Exhausted"),
2442 }
2443 assert_eq!(supervisor.attempts(), 0);
2444 assert!(!supervisor.is_exhausted());
2445 }
2446
2447 // --- Establishment: a real multi-round-trip handshake, no I/O ----------
2448
2449 /// A genuinely multi-round-trip [`IngestSession`], shaped exactly like
2450 /// `rtsp_runtime::client::ClientSession`'s DESCRIBE → SETUP → PLAY
2451 /// sequence: it emits one request at a time via `poll_transmit`, consumes
2452 /// the peer's reply via `feed`, and only announces
2453 /// [`SessionEvent::Established`] after the third exchange completes.
2454 ///
2455 /// **It performs no I/O of any kind** — it has no socket, no runtime, and
2456 /// no `async fn`; it only moves bytes between its own two queues. The test
2457 /// below owns the "wire" itself, which is what makes the no-I/O claim an
2458 /// observable property rather than a promise.
2459 struct HandshakeSession {
2460 /// How many peer replies have been consumed so far.
2461 step: usize,
2462 outbound: VecDeque<Bytes>,
2463 pending: VecDeque<SessionEvent>,
2464 }
2465
2466 /// The three requests `HandshakeSession` sends, in order — named after
2467 /// the RTSP sequence they stand in for.
2468 const HANDSHAKE_REQUESTS: [&[u8]; 3] = [b"DESCRIBE", b"SETUP", b"PLAY"];
2469
2470 impl HandshakeSession {
2471 /// Queues the *first* request only. Note this is all `dial()` does —
2472 /// no connection, no negotiation.
2473 fn new() -> Self {
2474 HandshakeSession {
2475 step: 0,
2476 outbound: VecDeque::from(vec![Bytes::from_static(HANDSHAKE_REQUESTS[0])]),
2477 pending: VecDeque::new(),
2478 }
2479 }
2480 }
2481
2482 impl Stage for HandshakeSession {
2483 type In<'a> = &'a [u8];
2484 type Out = SessionEvent;
2485 type Error = FakeError;
2486
2487 fn feed(&mut self, input: &[u8], _now: Timestamp) -> Result<(), FakeError> {
2488 if self.step >= HANDSHAKE_REQUESTS.len() {
2489 // Post-handshake media.
2490 self.pending.push_back(SessionEvent::Sample {
2491 program: ProgramId(1),
2492 track_id: 7,
2493 retention: RetentionClass::Timed,
2494 sample: sample(0xAB),
2495 });
2496 return Ok(());
2497 }
2498 // Each reply must be the 200 for the request we actually sent —
2499 // a real state machine correlates, so this fake does too.
2500 let expected = format!(
2501 "200 {}",
2502 String::from_utf8_lossy(HANDSHAKE_REQUESTS[self.step])
2503 );
2504 if input != expected.as_bytes() {
2505 return Err(FakeError("handshake reply out of sequence"));
2506 }
2507 self.step += 1;
2508 match HANDSHAKE_REQUESTS.get(self.step) {
2509 // More handshake to do: queue the next request.
2510 Some(next) => self.outbound.push_back(Bytes::from_static(next)),
2511 // Final reply consumed: now established, and the track set is
2512 // known (it came from the DESCRIBE-equivalent).
2513 None => {
2514 self.pending.push_back(SessionEvent::Established);
2515 self.pending.push_back(SessionEvent::NewProgram {
2516 program: ProgramId(1),
2517 tracks: vec![opaque_track(7)],
2518 });
2519 }
2520 }
2521 Ok(())
2522 }
2523
2524 fn poll(&mut self) -> Option<SessionEvent> {
2525 self.pending.pop_front()
2526 }
2527
2528 fn finish(&mut self) -> Result<(), FakeError> {
2529 Ok(())
2530 }
2531
2532 fn next_deadline(&self) -> Option<Timestamp> {
2533 None
2534 }
2535
2536 fn on_deadline(&mut self, _now: Timestamp) {}
2537
2538 fn demand(&self) -> Demand {
2539 Demand::new(4096)
2540 }
2541 }
2542
2543 /// The handshake's outbound side: this is the *only* way a request leaves
2544 /// the session — it has no socket to write to.
2545 impl IngestSession for HandshakeSession {
2546 type Request = Bytes;
2547
2548 fn poll_transmit(&mut self) -> Option<Bytes> {
2549 self.outbound.pop_front()
2550 }
2551 }
2552
2553 /// A [`Dialer`] over [`HandshakeSession`] — `dial()` constructs and
2554 /// returns immediately, connecting nothing.
2555 struct HandshakeDialer;
2556
2557 impl Dialer for HandshakeDialer {
2558 type Session = HandshakeSession;
2559 type Error = FakeError;
2560
2561 fn dial(&mut self) -> Result<HandshakeSession, FakeError> {
2562 Ok(HandshakeSession::new())
2563 }
2564 }
2565
2566 #[test]
2567 fn multi_round_trip_handshake_completes_through_feed_and_poll_transmit_only() {
2568 let mut dialer = HandshakeDialer;
2569 let mut driver = run_dial(&mut dialer, trunk_config(), handshake(), max_programs())
2570 .expect("dial constructs a session");
2571
2572 // `dial()` did no I/O and did not establish anything.
2573 assert!(
2574 matches!(driver.health(), HealthState::Establishing),
2575 "dial() must not establish the session: {:?}",
2576 driver.health()
2577 );
2578
2579 // The whole "network" is this Vec — every byte the session sends is
2580 // recorded here by the test, and every reply is handed back through
2581 // `feed`. Nothing else can move bytes, so a session that tried to do
2582 // its own I/O simply would not progress.
2583 let mut wire: Vec<Bytes> = Vec::new();
2584 let mut now = 0u64;
2585
2586 // Pump: drain poll_transmit, answer each request, feed the reply.
2587 // Three round trips, driven entirely by this loop.
2588 for _ in 0..HANDSHAKE_REQUESTS.len() {
2589 let req = driver
2590 .poll_transmit()
2591 .expect("the session has a handshake request to send");
2592 assert!(
2593 driver.poll_transmit().is_none(),
2594 "one request in flight at a time"
2595 );
2596 wire.push(req.clone());
2597
2598 let reply = format!("200 {}", String::from_utf8_lossy(&req));
2599 now += 1;
2600 driver.feed(reply.as_bytes(), Timestamp::from_nanos(now));
2601 }
2602
2603 // The exact request sequence went out, in order, through
2604 // poll_transmit — nowhere else.
2605 let sent: Vec<&[u8]> = wire.iter().map(|b| b.as_ref()).collect();
2606 assert_eq!(sent, HANDSHAKE_REQUESTS, "handshake request sequence");
2607
2608 // MUTATION-CHECKED: the promotion out of Establishing lives in
2609 // `drain()`'s `Established` arm.
2610 assert!(
2611 matches!(driver.health(), HealthState::Live),
2612 "after the final handshake reply the session must be Live: {:?}",
2613 driver.health()
2614 );
2615
2616 // And it is genuinely usable: the program announced with Established
2617 // has a Trunk, and post-handshake media lands in it.
2618 let trunk = driver
2619 .trunk(ProgramId(1))
2620 .cloned()
2621 .expect("the handshake announced program 1");
2622 let mut cursor = trunk.subscribe();
2623 driver.feed(b"media", Timestamp::from_nanos(now + 1));
2624 match cursor.poll().expect("post-handshake sample on the ring") {
2625 crate::SampleCursorItem::Timed { track_id, .. } => assert_eq!(track_id, 7),
2626 other => panic!("expected Timed, got {other:?}"),
2627 }
2628 }
2629
2630 /// A session that sends its first request and then never establishes,
2631 /// whatever it is fed — the stalled/half-open peer.
2632 struct StallingSession {
2633 outbound: VecDeque<Bytes>,
2634 }
2635
2636 impl Stage for StallingSession {
2637 type In<'a> = &'a [u8];
2638 type Out = SessionEvent;
2639 type Error = FakeError;
2640
2641 fn feed(&mut self, _input: &[u8], _now: Timestamp) -> Result<(), FakeError> {
2642 Ok(()) // never errors, never establishes — just silence
2643 }
2644
2645 fn poll(&mut self) -> Option<SessionEvent> {
2646 None
2647 }
2648
2649 fn finish(&mut self) -> Result<(), FakeError> {
2650 Ok(())
2651 }
2652
2653 fn next_deadline(&self) -> Option<Timestamp> {
2654 None
2655 }
2656
2657 fn on_deadline(&mut self, _now: Timestamp) {}
2658
2659 fn demand(&self) -> Demand {
2660 Demand::new(4096)
2661 }
2662 }
2663
2664 impl IngestSession for StallingSession {
2665 type Request = Bytes;
2666
2667 fn poll_transmit(&mut self) -> Option<Bytes> {
2668 self.outbound.pop_front()
2669 }
2670 }
2671
2672 struct StallingListener {
2673 max_sessions: usize,
2674 }
2675
2676 impl Listener for StallingListener {
2677 type Session = StallingSession;
2678 type Error = FakeError;
2679
2680 fn max_sessions(&self) -> usize {
2681 self.max_sessions
2682 }
2683
2684 fn poll_accept(&mut self) -> Result<Option<StallingSession>, FakeError> {
2685 // Queues its first request, exactly like a real session would —
2686 // so this models "we sent our opening request and the peer went
2687 // silent", not "nothing ever happened".
2688 Ok(Some(StallingSession {
2689 outbound: VecDeque::from(vec![Bytes::from_static(HANDSHAKE_REQUESTS[0])]),
2690 }))
2691 }
2692 }
2693
2694 #[test]
2695 fn never_completing_handshake_is_bounded_and_reported_not_leaked() {
2696 const DEADLINE: Timestamp = Timestamp::from_nanos(1_000);
2697 let mut driver = run_listen(
2698 StallingListener { max_sessions: 1 },
2699 trunk_config(),
2700 HandshakePolicy::establish_by(DEADLINE),
2701 max_programs(),
2702 );
2703
2704 let AcceptOutcome::Admitted(id) = driver.poll_accept() else {
2705 panic!("expected admission");
2706 };
2707 assert!(matches!(driver.health(id), Some(HealthState::Establishing)));
2708 // The one slot is taken, so nothing else gets in while this peer
2709 // stalls — which is exactly why the bound below must exist.
2710 assert!(matches!(driver.poll_accept(), AcceptOutcome::Refused));
2711
2712 // Before the deadline, feeding it more silence must NOT terminate it:
2713 // a slow-but-progressing handshake is legitimate.
2714 assert!(
2715 driver
2716 .feed(id, b"...", Timestamp::from_nanos(DEADLINE.as_nanos() - 1))
2717 .is_none(),
2718 "must not time out before the deadline"
2719 );
2720 assert!(matches!(driver.health(id), Some(HealthState::Establishing)));
2721 assert_eq!(driver.session_count(), 1);
2722
2723 // At the deadline, with the handshake still incomplete, it terminates
2724 // — reported, with the deadline that was blown.
2725 // MUTATION-CHECKED: `check_handshake_deadline`.
2726 let health = driver
2727 .on_deadline(id, DEADLINE)
2728 .expect("the blown deadline must terminate the session");
2729 assert_eq!(
2730 health,
2731 HealthState::HandshakeTimedOut { deadline: DEADLINE },
2732 "a never-completing handshake must be reported as HandshakeTimedOut"
2733 );
2734
2735 // And it is REAPED, not leaked: the slot is free again, so a flood of
2736 // half-open connections cannot squat max_sessions forever.
2737 assert_eq!(
2738 driver.session_count(),
2739 0,
2740 "a timed-out session must be reaped, not left pinning its slot"
2741 );
2742 assert!(driver.health(id).is_none());
2743 assert!(matches!(driver.poll_accept(), AcceptOutcome::Admitted(_)));
2744 }
2745
2746 #[test]
2747 fn handshake_completing_exactly_at_the_deadline_still_establishes() {
2748 const DEADLINE: Timestamp = Timestamp::from_nanos(500);
2749 // A locally-established session (Established already queued), fed at
2750 // exactly the deadline: the deadline check runs *after* the feed is
2751 // drained, so this must be Live, not HandshakeTimedOut.
2752 let session = ScriptedSession::new(vec![]);
2753 let mut driver = IngestDriver::new(
2754 session,
2755 trunk_config(),
2756 HandshakePolicy::establish_by(DEADLINE),
2757 max_programs(),
2758 );
2759 driver.feed(b"reply", DEADLINE);
2760 assert!(
2761 matches!(driver.health(), HealthState::Live),
2762 "a handshake completing exactly at the deadline must establish, \
2763 not be rejected by a nanosecond: {:?}",
2764 driver.health()
2765 );
2766 }
2767
2768 #[test]
2769 fn next_deadline_surfaces_the_handshake_bound_while_establishing() {
2770 const DEADLINE: Timestamp = Timestamp::from_nanos(9_000);
2771 let mut dialer = HandshakeDialer;
2772 let mut driver = run_dial(
2773 &mut dialer,
2774 trunk_config(),
2775 HandshakePolicy::establish_by(DEADLINE),
2776 max_programs(),
2777 )
2778 .unwrap();
2779
2780 // The session itself has no deadline of its own, so a caller driving
2781 // purely off next_deadline() would never fire the timeout check
2782 // unless the driver contributes the handshake bound here.
2783 assert_eq!(
2784 driver.next_deadline(),
2785 Some(DEADLINE),
2786 "while Establishing, next_deadline must surface the handshake bound"
2787 );
2788
2789 // Once established it drops out again (the session's own None wins).
2790 for _ in 0..HANDSHAKE_REQUESTS.len() {
2791 let req = driver.poll_transmit().expect("handshake request");
2792 let reply = format!("200 {}", String::from_utf8_lossy(&req));
2793 driver.feed(reply.as_bytes(), Timestamp::ZERO);
2794 }
2795 assert!(matches!(driver.health(), HealthState::Live));
2796 assert_eq!(
2797 driver.next_deadline(),
2798 None,
2799 "the handshake bound must not linger after establishment"
2800 );
2801 }
2802}