Skip to main content

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}