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}