Skip to main content

rtc_interceptor/gcc/
loss.rs

1//! The loss-based half: what to do when packets vanish without the queue growing.
2
3use std::time::{Duration, Instant};
4
5/// Below this loss fraction the path is considered healthy and the rate may climb.
6pub const DEFAULT_LOW_LOSS: f64 = 0.02;
7
8/// Above this the path is considered congested and the rate must fall.
9pub const DEFAULT_HIGH_LOSS: f64 = 0.10;
10
11/// How long between changes, so each is observed before the next.
12pub const DEFAULT_LOSS_INTERVAL: Duration = Duration::from_millis(200);
13
14/// Loss-based congestion control, per draft-ietf-rmcat-gcc-02 §5.5.
15///
16/// # Why delay is not enough
17///
18/// A path can drop packets without ever queueing them — a wireless link with interference, or a
19/// bottleneck whose buffer is so shallow that it overflows before the delay signal moves. The
20/// delay-based half sees nothing there, so without this a sender keeps pushing into a link that is
21/// discarding a tenth of what it sends.
22///
23/// Between the two thresholds nothing happens. That band is deliberate: a few per cent loss is
24/// normal on a wireless link and reacting to it would give up capacity permanently.
25///
26/// # Divergence from upstream (D4)
27///
28/// **This controller may move the target on its own.** Upstream's cannot: its `latestBitrate` is
29/// only written inside `onDelayUpdate` (`send_side_bwe.go:304`), so on a lossy link *without*
30/// queueing delay its loss estimate is computed and then never applied — exactly the case this
31/// exists for. That is a bug rather than a design choice, and it is not inherited.
32#[derive(Debug, Clone)]
33pub struct LossController {
34    /// Current loss-based target, in bits per second.
35    target: f64,
36    min: f64,
37    max: f64,
38    low: f64,
39    high: f64,
40    interval: Duration,
41    /// Exponentially-weighted average loss, so one bad report does not swing the target.
42    average_loss: Option<f64>,
43    last_change: Option<Instant>,
44}
45
46impl LossController {
47    /// A controller starting at `initial`, clamped to `min..=max`.
48    pub fn new(initial: f64, min: f64, max: f64) -> Self {
49        Self {
50            target: initial.clamp(min, max),
51            min,
52            max,
53            low: DEFAULT_LOW_LOSS,
54            high: DEFAULT_HIGH_LOSS,
55            interval: DEFAULT_LOSS_INTERVAL,
56            average_loss: None,
57            last_change: None,
58        }
59    }
60
61    /// The current loss-based target, in bits per second.
62    pub fn target(&self) -> f64 {
63        self.target
64    }
65
66    /// The smoothed loss fraction, if any feedback has arrived.
67    pub fn average_loss(&self) -> Option<f64> {
68        self.average_loss
69    }
70
71    /// Fold in one batch of feedback: `lost` of `total` packets did not arrive.
72    pub fn update(&mut self, now: Instant, lost: usize, total: usize) -> f64 {
73        if total == 0 {
74            return self.target;
75        }
76
77        let sample = lost as f64 / total as f64;
78        // Smoothed, but the raw sample still has a say below — a sudden collapse should not have to
79        // wait for the average to catch up.
80        let average = match self.average_loss {
81            Some(previous) => 0.8 * previous + 0.2 * sample,
82            None => sample,
83        };
84        self.average_loss = Some(average);
85
86        if let Some(last) = self.last_change
87            && now.saturating_duration_since(last) < self.interval
88        {
89            return self.target;
90        }
91
92        if average.max(sample) < self.low {
93            // Healthy: probe for more.
94            self.target = (self.target * 1.05).clamp(self.min, self.max);
95            self.last_change = Some(now);
96        } else if average.min(sample) > self.high {
97            // Losing badly: back off in proportion to how badly.
98            self.target = (self.target * (1.0 - 0.5 * average)).clamp(self.min, self.max);
99            self.last_change = Some(now);
100        }
101        // Between the thresholds: hold. A few per cent loss is normal, and reacting to it would
102        // give up capacity permanently.
103
104        self.target
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    const MIN: f64 = 100_000.0;
113    const MAX: f64 = 10_000_000.0;
114
115    fn controller() -> LossController {
116        LossController::new(1_000_000.0, MIN, MAX)
117    }
118
119    /// Heavy loss brings the target down — and does so on its own, with no delay signal anywhere.
120    /// This is D4: upstream computes this and then never applies it.
121    #[test]
122    fn heavy_loss_lowers_the_target_on_its_own() {
123        let epoch = Instant::now();
124        let mut controller = controller();
125        let before = controller.target();
126
127        let mut at = epoch;
128        for _ in 0..10 {
129            at += DEFAULT_LOSS_INTERVAL;
130            controller.update(at, 20, 100);
131        }
132
133        assert!(
134            controller.target() < before,
135            "20% loss must lower the target: {before} → {}",
136            controller.target()
137        );
138    }
139
140    /// A healthy path climbs.
141    #[test]
142    fn a_clean_path_raises_the_target() {
143        let epoch = Instant::now();
144        let mut controller = controller();
145        let before = controller.target();
146
147        let mut at = epoch;
148        for _ in 0..10 {
149            at += DEFAULT_LOSS_INTERVAL;
150            controller.update(at, 0, 100);
151        }
152
153        assert!(
154            controller.target() > before,
155            "a lossless path should be probed: {before} → {}",
156            controller.target()
157        );
158    }
159
160    /// The band between the thresholds is where nothing happens. A few per cent loss is normal on a
161    /// wireless link, and reacting to it would give up capacity for good.
162    #[test]
163    fn moderate_loss_changes_nothing() {
164        let epoch = Instant::now();
165        let mut controller = controller();
166        let before = controller.target();
167
168        let mut at = epoch;
169        for _ in 0..20 {
170            at += DEFAULT_LOSS_INTERVAL;
171            // 5%: above the 2% floor, below the 10% ceiling.
172            controller.update(at, 5, 100);
173        }
174
175        assert_eq!(
176            before,
177            controller.target(),
178            "loss inside the band must not move the target"
179        );
180    }
181
182    /// Worse loss backs off harder — the reaction is proportional, not a fixed step.
183    #[test]
184    fn the_backoff_is_proportional_to_the_loss() {
185        let epoch = Instant::now();
186        let mut mild = controller();
187        let mut severe = controller();
188
189        let mut at = epoch;
190        for _ in 0..5 {
191            at += DEFAULT_LOSS_INTERVAL;
192            mild.update(at, 12, 100);
193            severe.update(at, 50, 100);
194        }
195
196        assert!(
197            severe.target() < mild.target(),
198            "50% loss should back off further than 12%: {} vs {}",
199            severe.target(),
200            mild.target()
201        );
202    }
203
204    /// Empty feedback says nothing, and must not be read as a perfect path.
205    #[test]
206    fn empty_feedback_changes_nothing() {
207        let epoch = Instant::now();
208        let mut controller = controller();
209        let before = controller.target();
210
211        assert_eq!(before, controller.update(epoch, 0, 0));
212        assert_eq!(None, controller.average_loss());
213    }
214
215    /// One clamp, from configuration (D3). Upstream clamps this controller to a hard-coded
216    /// 100 kb/s–100 Mb/s that ignores the configured range entirely.
217    #[test]
218    fn the_target_stays_within_configured_bounds() {
219        let epoch = Instant::now();
220        let mut controller = LossController::new(MIN, MIN, 400_000.0);
221
222        let mut at = epoch;
223        for _ in 0..500 {
224            at += DEFAULT_LOSS_INTERVAL;
225            controller.update(at, 0, 100);
226        }
227        assert!(
228            controller.target() <= 400_000.0,
229            "ceiling breached: {}",
230            controller.target()
231        );
232
233        for _ in 0..500 {
234            at += DEFAULT_LOSS_INTERVAL;
235            controller.update(at, 90, 100);
236        }
237        assert!(
238            controller.target() >= MIN,
239            "floor breached: {}",
240            controller.target()
241        );
242    }
243}