Skip to main content

media_plane/
byte_tap.rs

1//! [`ByteTap`] — a positional, non-blocking observer of bytes in flight.
2//!
3//! # What a tap is for
4//!
5//! `dvb-conformance`'s `ConformanceMonitor` implements TR 101 290's 19
6//! indicators, but a demuxed IR only ever preserves 2 of them
7//! (`transmux::StreamingTsDemux` reads `pid`/`pusi` off `TsHeader` and
8//! discards `tei`, `continuity_counter`, `scrambling` — see
9//! `docs/superpowers/specs/2026-07-26-media-plane-architecture.md` §1.1).
10//! That is correct layering for a demuxer, whose job is framing — but it
11//! means the *only* place TR 101 290 conformance (and #737's T-STD buffer
12//! model, which needs packet arrival timing) can be measured is on the bytes
13//! themselves, before anything interprets or discards them. `ByteTap` is
14//! that place: a positional observer that yields bytes **exactly as
15//! received, with their arrival time, including bytes a demuxer will reject**
16//! (bad sync byte, TEI set, bad CRC, unaligned framing). `ByteTap` performs
17//! no validation and no filtering — it does not know what a "valid" packet
18//! looks like, deliberately, because deciding that is exactly the analysis
19//! its consumers are for.
20//!
21//! `dvb_conformance::ConformanceMonitor::feed(&mut self, ts_packet: &[u8], t:
22//! Duration) -> &[ConformanceEvent]` (`dvb-conformance/src/lib.rs:649`) is
23//! the primary intended consumer: already a per-packet streaming API over
24//! exactly `(bytes, arrival time)`, which is what [`ByteTap::poll`] hands
25//! back (`Timestamp` in place of `Duration` — see
26//! [`broadcast_common::stage::Timestamp`]).
27//!
28//! # The non-blocking trade, stated honestly
29//!
30//! **A tap must never block or back-pressure the producer.** Stalling live
31//! ingest so that analysis can keep up is a worse failure than gapped
32//! analysis — a broadcast head-end does not get to pause the incoming
33//! transport stream because a conformance monitor is slow. So [`ByteTap`]'s
34//! ring is bounded, and when a consumer falls behind, **the consumer loses
35//! data, not the stream**: [`ByteTap::record`] (the producer side) evicts the
36//! oldest buffered item and keeps going; it never waits, never grows without
37//! bound, and never fails.
38//!
39//! That trade has a consequence most people miss, so it is stated plainly
40//! here rather than left to be rediscovered from a wrong conformance report:
41//! **a lagged tap silently invalidates counter-based analysis over the gap.**
42//! TR 101 290's continuity-count indicator (1.4) and anything else that
43//! depends on having seen every packet in sequence (CC state, PCR
44//! interval/discontinuity tracking, #737's T-STD buffer occupancy) cannot be
45//! trusted across a period where packets were dropped before the consumer
46//! ever saw them — a gap looks exactly like a real continuity error unless
47//! the consumer knows a gap happened. That is why loss is surfaced as data
48//! ([`TapItem::Lagged`]) rather than a side channel a consumer could ignore:
49//! a consumer **must** treat `Lagged` as "reset or flag counter-based state
50//! for this stretch", not as "carry on as if nothing happened".
51//!
52//! # Not a `Stage`
53//!
54//! [`ByteTap`] is deliberately **not** a [`crate::ByteStage`]. It consumes
55//! nothing (it is fed, not driven with `feed`/`poll` as one contract — the
56//! producer and consumer sides are different callers entirely) and
57//! transforms nothing; it is a pure observer. Forcing it into the `Stage`
58//! shape would invent a `finish()`/`demand()` story neither side needs.
59//!
60//! # `TapPoint` is metadata, not behaviour
61//!
62//! [`TapPoint`] records *where* a tap sits — raw wire bytes, or bytes after
63//! some [`crate::ByteStage`] transform (CAM descramble, `ts-fix`, T2-MI inner
64//! recovery) — purely so a consumer can label what it is looking at (e.g. "is
65//! this TEI-set packet a real transmission error, or did the CAM produce it
66//! while descrambling?"). It does not change [`ByteTap`]'s ring, eviction, or
67//! `Lagged` accounting at all; both variants behave identically.
68
69use alloc::collections::VecDeque;
70use broadcast_common::stage::Timestamp;
71use bytes::Bytes;
72
73/// Where in the byte pipeline a [`ByteTap`] is observing.
74///
75/// Purely descriptive — see the [module docs](self) for why this carries no
76/// behaviour of its own.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
78#[non_exhaustive]
79pub enum TapPoint {
80    /// Bytes exactly as they arrived off the wire, before any
81    /// [`crate::ByteStage`] transform — including bytes a downstream demuxer
82    /// will reject. This is the only tap point that can see wire-level
83    /// TR 101 290 indicators (sync loss, TEI, bad CRC) at all, because a
84    /// demuxed IR has already discarded them.
85    Wire,
86    /// Bytes after some byte-layer transform (CAM descramble, `ts-fix`
87    /// continuity/PCR repair, T2-MI/BBFrame inner-TS recovery).
88    PostTransform,
89}
90
91/// What [`ByteTap::poll`] hands back to the consumer.
92///
93/// A consumer cannot poll past a [`TapItem::Lagged`] without seeing it: it is
94/// returned in-band, in the same `Option<TapItem>` as real data, ordered
95/// ahead of the data that follows the gap it reports — see the
96/// [module docs](self) on why loss must never be a side channel a consumer
97/// could fail to check.
98///
99/// `#[non_exhaustive]` for the same reason [`crate::trunk::SampleCursorItem`]
100/// is: this is the same in-band shape (data interleaved with loss reports),
101/// and a later loss class — the escalated `Degraded` that ring already
102/// distinguishes from ordinary `Lagged`, say — must be addable without a
103/// major version. Adding a variant to a published exhaustive enum is
104/// breaking, and this crate has not shipped yet, so the cost of getting it
105/// right is zero now and non-zero forever after.
106#[derive(Debug, Clone, PartialEq, Eq)]
107#[non_exhaustive]
108pub enum TapItem {
109    /// One observed byte unit with its arrival time, verbatim — never
110    /// validated, never filtered.
111    Data(Bytes, Timestamp),
112    /// The consumer fell behind the producer: `skipped` items were evicted
113    /// from the ring (oldest first) since the last successful poll, to keep
114    /// [`ByteTap::record`] non-blocking. See the [module docs](self) for the
115    /// consequence for counter-based analysis (TR 101 290 continuity
116    /// counting, T-STD buffer state, …) across this gap.
117    Lagged {
118        /// Count of items dropped since the last poll returned data (or
119        /// since construction, for the first poll).
120        skipped: u64,
121    },
122}
123
124/// A positional, non-blocking observer of bytes passing a point in the byte
125/// layer. See the [module docs](self).
126pub struct ByteTap {
127    point: TapPoint,
128    capacity: usize,
129    ring: VecDeque<(Bytes, Timestamp)>,
130    skipped_since_last_poll: u64,
131}
132
133impl ByteTap {
134    /// Create a tap at `point` with a ring bounded to `capacity` items.
135    ///
136    /// `capacity` is a hard cap independent of how fast [`ByteTap::record`]
137    /// is called or how slowly [`ByteTap::poll`] drains it — see the
138    /// [module docs](self)'s non-blocking trade. Panics if `capacity == 0`,
139    /// which would make every recorded item lost immediately and is almost
140    /// certainly a construction mistake rather than an intended tap.
141    pub fn new(point: TapPoint, capacity: usize) -> Self {
142        assert!(capacity > 0, "ByteTap capacity must be > 0");
143        ByteTap {
144            point,
145            capacity,
146            ring: VecDeque::with_capacity(capacity),
147            skipped_since_last_poll: 0,
148        }
149    }
150
151    /// Where this tap sits in the byte pipeline.
152    pub fn point(&self) -> TapPoint {
153        self.point
154    }
155
156    /// The fixed ring bound this tap was constructed with.
157    pub fn capacity(&self) -> usize {
158        self.capacity
159    }
160
161    /// Items currently buffered, awaiting [`ByteTap::poll`]. Never exceeds
162    /// [`ByteTap::capacity`] — useful for a caller that wants to watch a
163    /// tap's backlog before it starts lagging.
164    pub fn len(&self) -> usize {
165        self.ring.len()
166    }
167
168    /// `true` if no items are currently buffered.
169    pub fn is_empty(&self) -> bool {
170        self.ring.is_empty()
171    }
172
173    /// Producer side: record one observed byte unit with its arrival time.
174    ///
175    /// **Never blocks, never errors, never grows the ring past
176    /// [`ByteTap::capacity`].** When the ring is already full, the oldest
177    /// buffered item is evicted to make room and a skip is recorded for the
178    /// next [`ByteTap::poll`] to report — the producer always completes in
179    /// O(1) regardless of whether anything has ever called `poll`. See the
180    /// [module docs](self) for why this is the correct trade and what it
181    /// costs a slow consumer.
182    pub fn record(&mut self, bytes: Bytes, at: Timestamp) {
183        if self.ring.len() >= self.capacity {
184            self.ring.pop_front();
185            self.skipped_since_last_poll = self.skipped_since_last_poll.saturating_add(1);
186        }
187        self.ring.push_back((bytes, at));
188    }
189
190    /// Consumer side: pull the next observed item.
191    ///
192    /// Returns [`TapItem::Lagged`] first if any items were evicted since the
193    /// last poll — a consumer cannot skip past it to reach the data that
194    /// follows a gap. Returns `None` when the ring is empty and no loss is
195    /// pending.
196    pub fn poll(&mut self) -> Option<TapItem> {
197        if self.skipped_since_last_poll > 0 {
198            let skipped = self.skipped_since_last_poll;
199            self.skipped_since_last_poll = 0;
200            return Some(TapItem::Lagged { skipped });
201        }
202        self.ring.pop_front().map(|(b, t)| TapItem::Data(b, t))
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use alloc::vec::Vec;
210
211    /// A tap must yield bytes a demuxer would reject — bad sync byte, TEI
212    /// set, unaligned length — unaltered, with the timestamp it was recorded
213    /// with. `ByteTap` performs no validation, so this is really testing
214    /// that `record`/`poll` are a plain pass-through, not a filter.
215    #[test]
216    fn tap_yields_bytes_a_demuxer_would_reject() {
217        let mut tap = ByteTap::new(TapPoint::Wire, 4);
218        // Sync byte 0x00 (not 0x47), TEI bit set (bit 7 of byte 1), and only
219        // 5 bytes long (not the expected 188) — three separate reasons a
220        // real TS demuxer / dvb-conformance would flag or drop this packet.
221        let malformed = Bytes::from_static(&[0x00, 0x80, 0xFF, 0xFF, 0xFF]);
222        tap.record(malformed.clone(), Timestamp::from_nanos(42));
223
224        assert_eq!(
225            tap.poll(),
226            Some(TapItem::Data(malformed, Timestamp::from_nanos(42)))
227        );
228        assert_eq!(tap.poll(), None);
229    }
230
231    /// A slow consumer that never polls must not stop the producer, and must
232    /// receive an accurate `Lagged { skipped }` the first time it does poll.
233    #[test]
234    fn slow_consumer_gets_accurate_lagged_and_producer_is_never_blocked() {
235        let capacity = 4;
236        let mut tap = ByteTap::new(TapPoint::Wire, capacity);
237
238        // The producer records far more than capacity while the consumer
239        // never once calls poll(). This must simply complete — there is no
240        // blocking call in `record` for a test to hang on, but the
241        // assertions below prove the *state* is what a non-blocking producer
242        // would leave behind, not merely that the loop returned.
243        let total_records: u64 = 1_000;
244        for i in 0..total_records {
245            tap.record(
246                Bytes::copy_from_slice(&i.to_be_bytes()),
247                Timestamp::from_nanos(i),
248            );
249        }
250        // The producer completed regardless of consumer progress: exactly
251        // `capacity` items are resident, never more.
252        assert_eq!(tap.len(), capacity);
253
254        // First poll after falling behind must report the loss, with an
255        // exact count: total records minus the `capacity` that fit.
256        let expected_skipped = total_records - capacity as u64;
257        assert_eq!(
258            tap.poll(),
259            Some(TapItem::Lagged {
260                skipped: expected_skipped
261            })
262        );
263
264        // After the Lagged report, the remaining ring drains as the last
265        // `capacity` items recorded, oldest-first, with no further loss
266        // reports mixed in.
267        let mut drained = Vec::new();
268        while let Some(item) = tap.poll() {
269            drained.push(item);
270        }
271        assert_eq!(drained.len(), capacity);
272        for (offset, item) in drained.into_iter().enumerate() {
273            let expected_index = total_records - capacity as u64 + offset as u64;
274            assert_eq!(
275                item,
276                TapItem::Data(
277                    Bytes::copy_from_slice(&expected_index.to_be_bytes()),
278                    Timestamp::from_nanos(expected_index)
279                )
280            );
281        }
282    }
283
284    /// Flooding the ring at scale cannot grow memory without limit: `len()`
285    /// must never exceed the configured `capacity`, no matter how many
286    /// `record` calls are made.
287    #[test]
288    fn tap_ring_is_bounded_under_flood() {
289        let capacity = 8;
290        let mut tap = ByteTap::new(TapPoint::PostTransform, capacity);
291        for i in 0..200_000u64 {
292            tap.record(Bytes::from_static(b"x"), Timestamp::from_nanos(i));
293            assert!(tap.len() <= capacity, "ring exceeded capacity mid-flood");
294        }
295        assert_eq!(tap.len(), capacity);
296        assert_eq!(tap.capacity(), capacity);
297    }
298
299    #[test]
300    fn empty_tap_polls_none() {
301        let mut tap = ByteTap::new(TapPoint::Wire, 2);
302        assert_eq!(tap.poll(), None);
303        assert!(tap.is_empty());
304    }
305
306    #[test]
307    #[should_panic(expected = "capacity must be > 0")]
308    fn zero_capacity_panics() {
309        let _ = ByteTap::new(TapPoint::Wire, 0);
310    }
311}