rtc_interceptor/cc/estimator.rs
1//! The seam a congestion control algorithm plugs into.
2
3use crate::rtpfb::acknowledgement::PacketReport;
4use std::time::Instant;
5
6/// A congestion control algorithm: acknowledged packets in, a target bitrate out.
7///
8/// # Why this is not an `Interceptor`
9///
10/// An estimator does not transform packets, it observes them. Making it an interceptor would give
11/// it a position in the chain ordering it does not need, four bind methods it has no use for, and —
12/// fatally — bury it inside a `Box<dyn Interceptor>` the application cannot reach.
13/// `CongestionControlInterceptor` is the interceptor; this is the algorithm it drives.
14///
15/// # Why the seam is here rather than at the RTCP packet
16///
17/// Upstream's equivalent interface has six methods, of which one returns a writer, one takes raw
18/// RTCP, and one takes a callback. A custom estimator there must parse feedback, own a history of
19/// sent packets, own a pacer, and correctly wrap a writer — four responsibilities that have nothing
20/// to do with the algorithm, reimplemented per algorithm.
21///
22/// Here [`PacketReport`] is already resolved by [`History`](crate::History): departure joined with
23/// arrival, per packet, in send order. What is left is a function from acknowledgements to a number,
24/// which is what a congestion control algorithm actually is.
25///
26/// # Clocks
27///
28/// There are none. Every instant arrives as a parameter, so a test can pin an exact bitrate
29/// trajectory for a given feedback sequence rather than asserting that something eventually
30/// happens.
31pub trait BandwidthEstimator: Send + Sync {
32 /// Packets whose fate the remote has now reported, in send order.
33 ///
34 /// Each report carries the instant it *left* this endpoint — the pacer's release instant, not
35 /// the instant the application enqueued it — the instant it arrived on the receiver's clock,
36 /// its size, and whether it arrived at all. That is everything a delay-based or loss-based
37 /// estimator needs.
38 ///
39 /// May be called with an empty slice; an implementation should treat that as "no news".
40 fn on_reports(&mut self, now: Instant, reports: &[PacketReport]);
41
42 /// The current estimate, in bits per second.
43 ///
44 /// Read after every [`on_reports`](Self::on_reports) and after every
45 /// [`handle_timeout`](Self::handle_timeout); a change is what reaches the pacer.
46 fn target_bitrate(&self) -> f64;
47
48 /// Periodic work, for an estimator that has any. Most do not.
49 fn handle_timeout(&mut self, _now: Instant) {}
50
51 /// When this estimator next wants waking, or `None` if it does not.
52 ///
53 /// `None` when idle, and the instant must advance — a deadline at or before the `now` just
54 /// handed to [`handle_timeout`](Self::handle_timeout) is a busy-loop that wakes the whole chain.
55 fn poll_timeout(&self) -> Option<Instant> {
56 None
57 }
58
59 /// Whatever the algorithm wants to expose. Never load-bearing.
60 fn stats(&self) -> EstimatorStats {
61 EstimatorStats::default()
62 }
63}
64
65/// What an estimator is willing to say about itself.
66///
67/// Deliberately a struct rather than upstream's `map[string]any`: a stringly-typed bag cannot be
68/// read without knowing what the implementation happened to put in it. Fields are optional because
69/// an estimator that does not compute one should say so rather than report a zero that reads like a
70/// measurement.
71#[derive(Debug, Clone, Copy, Default, PartialEq)]
72#[non_exhaustive]
73pub struct EstimatorStats {
74 /// The delay-based half of the estimate, in bits per second.
75 pub delay_based_bitrate: Option<f64>,
76 /// The loss-based half of the estimate, in bits per second.
77 pub loss_based_bitrate: Option<f64>,
78 /// Fraction of packets the remote reported lost, over whatever window the estimator uses.
79 pub packet_loss: Option<f64>,
80 /// Round trip time implied by the most recent feedback.
81 pub round_trip_time: Option<std::time::Duration>,
82}
83
84/// An estimator that always says the same number.
85///
86/// Not a placeholder. It is the proof that [`BandwidthEstimator`] is usable with two methods, and
87/// it is what the interceptor's own tests drive so that the interceptor's behaviour — recording
88/// departures, resolving feedback, attaching the attribute — is separable from any algorithm's.
89///
90/// It is also a legitimate configuration: a fixed rate with a pacer in front of it is what an
91/// application wants when the path is known and it would rather not have an algorithm second-guess
92/// it.
93#[derive(Debug, Clone, Copy)]
94pub struct ConstantBitrate {
95 bits_per_second: f64,
96}
97
98impl ConstantBitrate {
99 /// An estimator fixed at `bits_per_second`.
100 pub fn new(bits_per_second: f64) -> Self {
101 Self { bits_per_second }
102 }
103}
104
105impl BandwidthEstimator for ConstantBitrate {
106 /// Nothing to learn from: the answer does not depend on the question.
107 fn on_reports(&mut self, _now: Instant, _reports: &[PacketReport]) {}
108
109 fn target_bitrate(&self) -> f64 {
110 self.bits_per_second
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use rtcp::transport_feedbacks::cc_feedback_report::Ecn;
118 use std::time::Duration;
119
120 fn report(id: u64, arrived: bool, departure: Instant) -> PacketReport {
121 PacketReport {
122 ssrc: 1,
123 id,
124 rtp_sequence_number: id as u16,
125 is_twcc: true,
126 twcc_sequence_number: id as u16,
127 size: 1200,
128 arrived,
129 departure,
130 arrival: arrived.then(|| Duration::from_millis(10)),
131 ecn: Ecn::default(),
132 }
133 }
134
135 /// The seam is two methods. If this ever needs a third to compile, the defaults have stopped
136 /// carrying their weight and the trait has grown.
137 #[test]
138 fn an_estimator_needs_only_two_methods() {
139 struct Minimal(f64);
140 impl BandwidthEstimator for Minimal {
141 fn on_reports(&mut self, _now: Instant, reports: &[PacketReport]) {
142 // Something an algorithm plausibly does, so this is not vacuously minimal.
143 let arrived = reports.iter().filter(|report| report.arrived).count();
144 self.0 = 100_000.0 * arrived as f64;
145 }
146 fn target_bitrate(&self) -> f64 {
147 self.0
148 }
149 }
150
151 let epoch = Instant::now();
152 let mut estimator = Minimal(0.0);
153 estimator.on_reports(epoch, &[report(1, true, epoch), report(2, false, epoch)]);
154
155 assert_eq!(100_000.0, estimator.target_bitrate());
156 assert_eq!(None, estimator.poll_timeout(), "the default is idle");
157 assert_eq!(EstimatorStats::default(), estimator.stats());
158 estimator.handle_timeout(epoch + Duration::from_secs(1));
159 }
160
161 #[test]
162 fn a_constant_estimator_ignores_what_it_is_told() {
163 let epoch = Instant::now();
164 let mut estimator = ConstantBitrate::new(750_000.0);
165
166 assert_eq!(750_000.0, estimator.target_bitrate());
167 estimator.on_reports(epoch, &[report(1, false, epoch)]);
168 estimator.handle_timeout(epoch + Duration::from_secs(10));
169 assert_eq!(
170 750_000.0,
171 estimator.target_bitrate(),
172 "loss and time must not move a rate the application fixed"
173 );
174 }
175
176 /// `Send + Sync` is a supertrait because the chain is, and a `Box<dyn BandwidthEstimator>`
177 /// has to be storable in one.
178 #[test]
179 fn an_estimator_is_send_and_sync() {
180 fn assert_send_sync<T: Send + Sync>() {}
181 assert_send_sync::<ConstantBitrate>();
182 assert_send_sync::<Box<dyn BandwidthEstimator>>();
183 }
184}