Skip to main content

naia_shared/connection/
loss_monitor.rs

1const WINDOW: usize = 64;
2
3/// Rolling packet-loss estimator over the last 64 resolved packets.
4/// Tracks outcomes (acked vs lost) for `PacketType::Data` packets only;
5/// heartbeats and other non-data packets are excluded by the caller.
6///
7/// O(1) record and O(1) query. Memory cost: 64 bytes + 3 words.
8pub struct LossMonitor {
9    outcomes: [bool; WINDOW], // true = acked, false = lost
10    write_pos: usize,
11    total: usize,      // number of valid entries, capped at WINDOW
12    acked_count: usize, // number of acked entries in the valid window
13}
14
15impl Default for LossMonitor {
16    fn default() -> Self {
17        Self::new()
18    }
19}
20
21impl LossMonitor {
22    /// Creates a new `LossMonitor` with a zeroed sliding window.
23    pub fn new() -> Self {
24        Self {
25            outcomes: [false; WINDOW],
26            write_pos: 0,
27            total: 0,
28            acked_count: 0,
29        }
30    }
31
32    /// Records that a packet was acknowledged (not lost).
33    pub fn record_acked(&mut self) {
34        self.record(true);
35    }
36
37    /// Records that a packet was lost (not acknowledged).
38    pub fn record_lost(&mut self) {
39        self.record(false);
40    }
41
42    fn record(&mut self, acked: bool) {
43        if self.total == WINDOW {
44            // Evict oldest outcome from running tally before overwriting.
45            if self.outcomes[self.write_pos] {
46                self.acked_count -= 1;
47            }
48        } else {
49            self.total += 1;
50        }
51        self.outcomes[self.write_pos] = acked;
52        if acked {
53            self.acked_count += 1;
54        }
55        self.write_pos = (self.write_pos + 1) % WINDOW;
56    }
57
58    /// Fraction of tracked packets that were lost (0.0–1.0).
59    /// Returns 0.0 if no packets have been tracked yet.
60    pub fn packet_loss_pct(&self) -> f32 {
61        if self.total == 0 {
62            return 0.0;
63        }
64        1.0 - (self.acked_count as f32 / self.total as f32)
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::LossMonitor;
71
72    #[test]
73    fn empty_returns_zero() {
74        let m = LossMonitor::new();
75        assert_eq!(m.packet_loss_pct(), 0.0);
76    }
77
78    #[test]
79    fn all_acked_returns_zero_loss() {
80        let mut m = LossMonitor::new();
81        for _ in 0..32 {
82            m.record_acked();
83        }
84        assert_eq!(m.packet_loss_pct(), 0.0);
85    }
86
87    #[test]
88    fn all_lost_returns_one() {
89        let mut m = LossMonitor::new();
90        for _ in 0..64 {
91            m.record_lost();
92        }
93        assert_eq!(m.packet_loss_pct(), 1.0);
94    }
95
96    #[test]
97    fn fifty_percent_loss() {
98        let mut m = LossMonitor::new();
99        for i in 0..64 {
100            if i % 2 == 0 { m.record_acked(); } else { m.record_lost(); }
101        }
102        assert!((m.packet_loss_pct() - 0.5).abs() < 1e-6);
103    }
104
105    #[test]
106    fn window_evicts_oldest_entries() {
107        let mut m = LossMonitor::new();
108        // Fill with 64 losses, then add 64 acks — loss should drop to 0.
109        for _ in 0..64 { m.record_lost(); }
110        assert_eq!(m.packet_loss_pct(), 1.0);
111        for _ in 0..64 { m.record_acked(); }
112        assert_eq!(m.packet_loss_pct(), 0.0);
113    }
114}