Skip to main content

rtc_interceptor/rtpfb/
history.rs

1//! What was sent, joined with what the receiver said about it.
2
3use super::acknowledgement::{Acknowledgement, PacketReport};
4use rtcp::transport_feedbacks::cc_feedback_report::Ecn;
5use std::collections::HashMap;
6use std::time::{Duration, Instant};
7
8/// Records outgoing packets and matches incoming feedback against them.
9///
10/// The two feedback formats identify a packet differently — TWCC by its own transport-wide
11/// sequence number, RFC 8888 by the stream's SSRC and RTP sequence number — so both indexes are
12/// kept, pointing at one record.
13#[derive(Debug, Default)]
14pub struct History {
15    /// Monotonic id, so reports come out in send order however sequence numbers wrap.
16    next_id: u64,
17    packets: HashMap<u64, PacketReport>,
18    twcc_index: HashMap<u16, u64>,
19    ssrc_sequence_index: HashMap<(u32, u16), u64>,
20    /// Highest id acknowledged so far.
21    highest_acknowledged: Option<u64>,
22    /// Lowest id not yet reported to the consumer.
23    next_to_report: u64,
24}
25
26impl History {
27    /// An empty history.
28    pub fn new() -> Self {
29        Self::default()
30    }
31
32    /// How many packets are being tracked.
33    pub fn len(&self) -> usize {
34        self.packets.len()
35    }
36
37    /// Whether nothing is being tracked.
38    pub fn is_empty(&self) -> bool {
39        self.packets.is_empty()
40    }
41
42    /// Record a packet as it leaves.
43    ///
44    /// `departure` must be the instant the packet was **released** to the network, not the
45    /// instant the application handed it over. A pacer can hold a packet for tens of
46    /// milliseconds, and counting that as network delay is exactly the error that makes a
47    /// bandwidth estimate collapse (chain contract rule 3).
48    #[allow(clippy::too_many_arguments)]
49    pub fn add_outgoing(
50        &mut self,
51        ssrc: u32,
52        rtp_sequence_number: u16,
53        is_twcc: bool,
54        twcc_sequence_number: u16,
55        size: usize,
56        departure: Instant,
57    ) -> u64 {
58        let id = self.next_id;
59        self.next_id += 1;
60
61        if is_twcc {
62            self.twcc_index.insert(twcc_sequence_number, id);
63        }
64        self.ssrc_sequence_index
65            .insert((ssrc, rtp_sequence_number), id);
66
67        self.packets.insert(
68            id,
69            PacketReport {
70                ssrc,
71                id,
72                rtp_sequence_number,
73                is_twcc,
74                twcc_sequence_number,
75                size,
76                arrived: false,
77                departure,
78                arrival: None,
79                ecn: Ecn::NotEct,
80            },
81        );
82
83        id
84    }
85
86    /// Apply TWCC feedback, returning the round trip time it implies.
87    ///
88    /// `None` when the feedback names a packet this endpoint has no record of — which happens
89    /// routinely at startup and after the history has been pruned, and is not an error.
90    pub fn on_twcc_feedback(
91        &mut self,
92        received_at: Instant,
93        acknowledgement: Acknowledgement,
94    ) -> Option<Duration> {
95        let id = *self.twcc_index.get(&acknowledgement.sequence_number)?;
96        self.apply(received_at, id, acknowledgement)
97    }
98
99    /// Apply RFC 8888 feedback for one stream, returning the round trip time it implies.
100    pub fn on_ccfb_feedback(
101        &mut self,
102        received_at: Instant,
103        ssrc: u32,
104        acknowledgement: Acknowledgement,
105    ) -> Option<Duration> {
106        let id = *self
107            .ssrc_sequence_index
108            .get(&(ssrc, acknowledgement.sequence_number))?;
109        self.apply(received_at, id, acknowledgement)
110    }
111
112    fn apply(
113        &mut self,
114        received_at: Instant,
115        id: u64,
116        acknowledgement: Acknowledgement,
117    ) -> Option<Duration> {
118        let packet = self.packets.get_mut(&id)?;
119
120        packet.arrived = acknowledgement.arrived;
121        packet.arrival = acknowledgement.arrival;
122        packet.ecn = acknowledgement.ecn;
123
124        if packet.arrived {
125            self.highest_acknowledged = Some(match self.highest_acknowledged {
126                Some(highest) => highest.max(id),
127                None => id,
128            });
129        }
130
131        // Round trip: this feedback arrived now, and the packet left then. Both instants are on
132        // this endpoint's clock, so unlike the arrival times this is a real duration.
133        Some(received_at.saturating_duration_since(packet.departure))
134    }
135
136    /// Take everything up to the highest *arrived* packet since the last call, in send order.
137    ///
138    /// Packets older than that which are still unreported are treated as lost and then dropped.
139    /// Loss feedback alone does not advance the reporting window; losses are emitted when a later
140    /// packet is reported as arrived.
141    pub fn take_reports(&mut self) -> Vec<PacketReport> {
142        let Some(highest) = self.highest_acknowledged else {
143            return Vec::new();
144        };
145        if self.next_to_report > highest {
146            return Vec::new();
147        }
148
149        let mut reports = Vec::new();
150        for id in self.next_to_report..=highest {
151            if let Some(packet) = self.packets.remove(&id) {
152                if packet.is_twcc {
153                    self.twcc_index.remove(&packet.twcc_sequence_number);
154                }
155                self.ssrc_sequence_index
156                    .remove(&(packet.ssrc, packet.rtp_sequence_number));
157                reports.push(packet);
158            }
159        }
160        self.next_to_report = highest + 1;
161
162        reports
163    }
164
165    /// Drop records of packets sent before `cutoff` that were never acknowledged.
166    ///
167    /// Without this the history grows without bound on a lossy path: an unacknowledged packet is
168    /// never reported and never removed, so nothing else would ever release it.
169    pub fn prune_before(&mut self, cutoff: Instant) {
170        let stale: Vec<u64> = self
171            .packets
172            .iter()
173            .filter(|(_, packet)| packet.departure < cutoff && !packet.arrived)
174            .map(|(&id, _)| id)
175            .collect();
176
177        for id in stale {
178            if let Some(packet) = self.packets.remove(&id) {
179                // Only a TWCC packet has an entry under its transport-wide sequence number. A
180                // non-TWCC packet carries whatever the caller passed — typically 0 — and removing
181                // that key would evict whichever real TWCC packet holds it.
182                if packet.is_twcc {
183                    self.twcc_index.remove(&packet.twcc_sequence_number);
184                }
185                self.ssrc_sequence_index
186                    .remove(&(packet.ssrc, packet.rtp_sequence_number));
187            }
188        }
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    fn history_with_three_packets(now: Instant) -> History {
197        let mut history = History::new();
198        for sequence_number in 0..3u16 {
199            history.add_outgoing(1, 100 + sequence_number, true, sequence_number, 1200, now);
200        }
201        history
202    }
203
204    #[test]
205    fn feedback_for_an_unknown_packet_is_ignored() {
206        let now = Instant::now();
207        let mut history = History::new();
208
209        assert_eq!(
210            None,
211            history.on_twcc_feedback(now, Acknowledgement::received(5, None, Ecn::NotEct)),
212            "nothing was sent with that sequence number"
213        );
214        assert!(history.take_reports().is_empty());
215    }
216
217    #[test]
218    fn twcc_feedback_matches_by_transport_wide_sequence_number() {
219        let now = Instant::now();
220        let mut history = history_with_three_packets(now);
221
222        let rtt = history.on_twcc_feedback(
223            now + Duration::from_millis(80),
224            Acknowledgement::received(1, Some(Duration::from_millis(5)), Ecn::NotEct),
225        );
226        assert_eq!(Some(Duration::from_millis(80)), rtt);
227
228        let reports = history.take_reports();
229        assert_eq!(2, reports.len(), "everything up to the acknowledgement");
230        assert_eq!(101, reports[1].rtp_sequence_number);
231        assert!(reports[1].arrived);
232        assert!(!reports[0].arrived, "never acknowledged, so a loss");
233    }
234
235    /// RFC 8888 names packets by the stream's own sequence number, so two streams can use the
236    /// same one and must not be confused.
237    #[test]
238    fn ccfb_feedback_matches_by_ssrc_and_sequence_number() {
239        let now = Instant::now();
240        let mut history = History::new();
241        history.add_outgoing(1, 500, false, 0, 1000, now);
242        history.add_outgoing(2, 500, false, 0, 1000, now);
243
244        history.on_ccfb_feedback(
245            now + Duration::from_millis(50),
246            2,
247            Acknowledgement::received(500, Some(Duration::from_millis(1)), Ecn::Ce),
248        );
249
250        let reports = history.take_reports();
251        assert_eq!(2, reports.len());
252        assert!(
253            !reports[0].arrived,
254            "stream 1's packet was not acknowledged"
255        );
256        assert!(reports[1].arrived, "stream 2's was");
257        assert_eq!(Ecn::Ce, reports[1].ecn);
258    }
259
260    /// Feedback naming a stream that never sent that sequence number must not match another
261    /// stream that did. RFC 8888 numbers packets per stream, so the same sequence number is in
262    /// use on every stream at once — matching on it alone would attribute one stream's arrival to
263    /// another and corrupt both their delay estimates.
264    ///
265    /// Deterministic by construction: only stream 1 ever sent 500, so a lookup that ignores the
266    /// SSRC can only match stream 1's packet, and a correct one can only fail.
267    #[test]
268    fn ccfb_feedback_for_a_sequence_number_another_stream_sent_does_not_match() {
269        let now = Instant::now();
270        let mut history = History::new();
271        history.add_outgoing(1, 500, false, 0, 1000, now);
272
273        assert_eq!(
274            None,
275            history.on_ccfb_feedback(
276                now + Duration::from_millis(50),
277                2,
278                Acknowledgement::received(500, Some(Duration::from_millis(1)), Ecn::NotEct),
279            ),
280            "stream 2 never sent sequence 500"
281        );
282        assert!(
283            history.take_reports().is_empty(),
284            "and nothing was acknowledged, so stream 1's packet is still outstanding"
285        );
286    }
287
288    #[test]
289    fn nothing_is_reported_until_something_is_acknowledged() {
290        let now = Instant::now();
291        let mut history = history_with_three_packets(now);
292
293        assert!(
294            history.take_reports().is_empty(),
295            "sent but not yet acknowledged"
296        );
297        assert_eq!(3, history.len(), "and still tracked");
298    }
299
300    /// A packet reported once must not be reported again: congestion control would count it
301    /// twice, and a loss re-reported later looks like a reordered arrival.
302    #[test]
303    fn packets_are_reported_once() {
304        let now = Instant::now();
305        let mut history = history_with_three_packets(now);
306
307        history.on_twcc_feedback(now, Acknowledgement::received(2, None, Ecn::NotEct));
308        assert_eq!(3, history.take_reports().len());
309        assert!(history.take_reports().is_empty());
310        assert!(history.is_empty(), "and released");
311    }
312
313    #[test]
314    fn later_feedback_reports_only_what_follows() {
315        let now = Instant::now();
316        let mut history = History::new();
317        history.add_outgoing(1, 100, true, 0, 1200, now);
318        history.add_outgoing(1, 101, true, 1, 1200, now);
319
320        history.on_twcc_feedback(now, Acknowledgement::received(0, None, Ecn::NotEct));
321        assert_eq!(1, history.take_reports().len());
322
323        history.on_twcc_feedback(now, Acknowledgement::received(1, None, Ecn::NotEct));
324        let reports = history.take_reports();
325        assert_eq!(1, reports.len());
326        assert_eq!(101, reports[0].rtp_sequence_number);
327    }
328
329    /// A packet reported as lost does not advance the reporting window on its own — otherwise a
330    /// long run of losses would flush packets that have not been heard about yet.
331    #[test]
332    fn a_loss_alone_does_not_advance_the_window() {
333        let now = Instant::now();
334        let mut history = history_with_three_packets(now);
335
336        history.on_twcc_feedback(now, Acknowledgement::lost(1));
337        assert!(
338            history.take_reports().is_empty(),
339            "nothing has been acknowledged as arrived"
340        );
341        assert_eq!(3, history.len());
342    }
343
344    /// The round trip is measured between two instants on *this* endpoint's clock, unlike the
345    /// arrival times, which are on the receiver's.
346    #[test]
347    fn the_round_trip_is_measured_locally() {
348        let now = Instant::now();
349        let mut history = History::new();
350        history.add_outgoing(1, 100, true, 0, 1200, now);
351
352        assert_eq!(
353            Some(Duration::from_millis(120)),
354            history.on_twcc_feedback(
355                now + Duration::from_millis(120),
356                Acknowledgement::received(0, Some(Duration::from_secs(9999)), Ecn::NotEct),
357            ),
358            "the receiver's arrival clock does not affect it"
359        );
360    }
361
362    /// Without pruning, an unacknowledged packet is never reported and never removed, so a lossy
363    /// path grows the history without bound.
364    /// A non-TWCC packet carries a meaningless `twcc_sequence_number` (whatever the caller
365    /// passed, typically 0) and was never entered into the TWCC index. Removing that key when it
366    /// is released evicts whichever *real* TWCC packet happens to hold it, and that packet's
367    /// feedback can then never match.
368    #[test]
369    fn releasing_a_non_twcc_packet_does_not_evict_a_twcc_packets_index_entry() {
370        let now = Instant::now();
371        let mut history = History::new();
372
373        // The non-TWCC packet is sent *first*, so reporting it does not sweep up the TWCC one:
374        // the reporting window runs to the highest arrived id, and anything below it is dropped
375        // as a loss regardless. Ordering this way isolates the index eviction from that.
376        history.add_outgoing(2, 200, false, 0, 1200, now);
377        // A real TWCC packet holding transport-wide sequence 0, sent after.
378        history.add_outgoing(1, 100, true, 0, 1200, now);
379
380        // Release the non-TWCC packet by reporting it.
381        history.on_ccfb_feedback(now, 2, Acknowledgement::received(200, None, Ecn::NotEct));
382        history.take_reports();
383
384        assert!(
385            history
386                .on_twcc_feedback(now, Acknowledgement::received(0, None, Ecn::NotEct))
387                .is_some(),
388            "the TWCC packet is still matchable by its transport-wide sequence number"
389        );
390    }
391
392    /// The same hazard on the pruning path.
393    #[test]
394    fn pruning_a_non_twcc_packet_does_not_evict_a_twcc_packets_index_entry() {
395        let now = Instant::now();
396        let mut history = History::new();
397
398        // Old, non-TWCC, carrying the default 0 it never registered.
399        history.add_outgoing(2, 200, false, 0, 1200, now);
400        // Recent, a real TWCC packet holding transport-wide sequence 0.
401        history.add_outgoing(1, 100, true, 0, 1200, now + Duration::from_secs(5));
402
403        history.prune_before(now + Duration::from_secs(1));
404
405        assert!(
406            history
407                .on_twcc_feedback(
408                    now + Duration::from_secs(5),
409                    Acknowledgement::received(0, None, Ecn::NotEct)
410                )
411                .is_some(),
412            "pruning the unrelated packet must not take the TWCC index entry with it"
413        );
414    }
415
416    /// An acknowledged packet that has not been reported yet must survive pruning, or the arrival
417    /// is lost and congestion control never hears about it.
418    #[test]
419    fn an_acknowledged_packet_is_not_pruned_before_it_is_reported() {
420        let now = Instant::now();
421        let mut history = History::new();
422        history.add_outgoing(1, 100, true, 0, 1200, now);
423
424        history.on_twcc_feedback(
425            now + Duration::from_millis(50),
426            Acknowledgement::received(0, None, Ecn::NotEct),
427        );
428
429        // Well past the cutoff, but it has been acknowledged and not yet collected.
430        history.prune_before(now + Duration::from_secs(30));
431
432        let reports = history.take_reports();
433        assert_eq!(1, reports.len(), "the arrival survived to be reported");
434        assert!(reports[0].arrived);
435    }
436
437    #[test]
438    fn old_unacknowledged_packets_are_pruned() {
439        let now = Instant::now();
440        let mut history = History::new();
441        history.add_outgoing(1, 100, true, 0, 1200, now);
442        history.add_outgoing(1, 101, true, 1, 1200, now + Duration::from_secs(5));
443
444        history.prune_before(now + Duration::from_secs(1));
445
446        assert_eq!(1, history.len(), "only the recent one survives");
447        assert_eq!(
448            None,
449            history.on_twcc_feedback(now, Acknowledgement::received(0, None, Ecn::NotEct)),
450            "and the pruned one is no longer matchable"
451        );
452    }
453}