use std::{collections::HashMap, time::Instant};
use super::socket::InetSocket;
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub struct SocketRate {
pub cookie: u64,
pub tx_goodput_bps: u64,
pub rx_goodput_bps: u64,
pub retrans_ratio: f64,
}
#[derive(Debug, Clone, Copy)]
struct Counters {
bytes_acked: u64,
bytes_received: u64,
bytes_sent: u64,
bytes_retrans: u64,
}
#[derive(Debug, Clone, Copy)]
struct Entry {
counters: Counters,
at: Instant,
last_seen: u64,
}
#[derive(Debug)]
pub struct SocketRateTracker {
entries: HashMap<u64, Entry>,
generation: u64,
evict_after: u64,
}
impl Default for SocketRateTracker {
fn default() -> Self {
Self::new()
}
}
impl SocketRateTracker {
pub fn new() -> Self {
Self {
entries: HashMap::new(),
generation: 0,
evict_after: 3,
}
}
pub fn with_evict_after(mut self, n: u64) -> Self {
self.evict_after = n.max(1);
self
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn ingest<'a>(
&mut self,
sockets: impl IntoIterator<Item = &'a InetSocket>,
at: Instant,
) -> Vec<SocketRate> {
self.generation += 1;
let mut rates = Vec::new();
for sock in sockets {
let Some(info) = sock.tcp_info.as_ref() else {
continue;
};
let counters = Counters {
bytes_acked: info.bytes_acked,
bytes_received: info.bytes_received,
bytes_sent: info.bytes_sent,
bytes_retrans: info.bytes_retrans,
};
let entry = Entry {
counters,
at,
last_seen: self.generation,
};
let Some(prev) = self.entries.insert(sock.cookie, entry) else {
continue; };
let dt = at.saturating_duration_since(prev.at).as_secs_f64();
if dt <= 0.0 {
continue;
}
let p = prev.counters;
if counters.bytes_acked < p.bytes_acked
|| counters.bytes_received < p.bytes_received
|| counters.bytes_sent < p.bytes_sent
|| counters.bytes_retrans < p.bytes_retrans
{
continue;
}
let d_sent = counters.bytes_sent - p.bytes_sent;
let d_retrans = counters.bytes_retrans - p.bytes_retrans;
rates.push(SocketRate {
cookie: sock.cookie,
tx_goodput_bps: (((counters.bytes_acked - p.bytes_acked) as f64) / dt) as u64,
rx_goodput_bps: (((counters.bytes_received - p.bytes_received) as f64) / dt)
as u64,
retrans_ratio: if d_sent == 0 {
0.0
} else {
d_retrans as f64 / d_sent as f64
},
});
}
let horizon = self.generation.saturating_sub(self.evict_after);
self.entries.retain(|_, e| e.last_seen > horizon);
rates
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
use crate::sockdiag::types::TcpInfo;
fn sock(cookie: u64, acked: u64, received: u64, sent: u64, retrans: u64) -> InetSocket {
InetSocket {
cookie,
tcp_info: Some(TcpInfo {
bytes_acked: acked,
bytes_received: received,
bytes_sent: sent,
bytes_retrans: retrans,
..TcpInfo::default()
}),
..InetSocket::default()
}
}
#[test]
fn two_snapshots_yield_goodput_and_retrans_ratio() {
let mut t = SocketRateTracker::new();
let t0 = Instant::now();
let s0 = [sock(7, 1_000, 2_000, 1_100, 0)];
assert!(t.ingest(&s0, t0).is_empty());
let s1 = [sock(7, 2_001_000, 4_002_000, 3_201_100, 100_000)];
let rates = t.ingest(&s1, t0 + Duration::from_secs(2));
assert_eq!(rates.len(), 1);
let r = &rates[0];
assert_eq!(r.cookie, 7);
assert_eq!(r.tx_goodput_bps, 1_000_000);
assert_eq!(r.rx_goodput_bps, 2_000_000);
assert!((r.retrans_ratio - 100_000.0 / 3_200_000.0).abs() < 1e-9);
}
#[test]
fn new_cookie_needs_a_baseline_first() {
let mut t = SocketRateTracker::new();
let t0 = Instant::now();
t.ingest(&[sock(1, 100, 100, 100, 0)], t0);
let rates = t.ingest(
&[sock(1, 200, 200, 200, 0), sock(2, 999, 999, 999, 0)],
t0 + Duration::from_secs(1),
);
assert_eq!(rates.len(), 1);
assert_eq!(rates[0].cookie, 1);
assert_eq!(t.len(), 2, "cookie 2 is baselined for next round");
}
#[test]
fn counter_regression_skips_sample_and_rebaselines() {
let mut t = SocketRateTracker::new();
let t0 = Instant::now();
t.ingest(&[sock(1, 10_000, 0, 10_000, 0)], t0);
let rates = t.ingest(&[sock(1, 100, 0, 100, 0)], t0 + Duration::from_secs(1));
assert!(rates.is_empty());
let rates = t.ingest(&[sock(1, 1_100, 0, 1_100, 0)], t0 + Duration::from_secs(2));
assert_eq!(rates.len(), 1);
assert_eq!(rates[0].tx_goodput_bps, 1_000);
}
#[test]
fn zero_dt_yields_no_rate() {
let mut t = SocketRateTracker::new();
let t0 = Instant::now();
t.ingest(&[sock(1, 100, 100, 100, 0)], t0);
assert!(t.ingest(&[sock(1, 200, 200, 200, 0)], t0).is_empty());
}
#[test]
fn sockets_without_tcp_info_are_skipped() {
let mut t = SocketRateTracker::new();
let bare = InetSocket {
cookie: 5,
..InetSocket::default()
};
assert!(t.ingest(&[bare], Instant::now()).is_empty());
assert_eq!(t.len(), 0);
}
#[test]
fn stale_cookies_evict_after_horizon() {
let mut t = SocketRateTracker::new().with_evict_after(2);
let t0 = Instant::now();
t.ingest(&[sock(1, 100, 100, 100, 0)], t0);
assert_eq!(t.len(), 1);
t.ingest(&[sock(2, 1, 1, 1, 0)], t0 + Duration::from_secs(1));
assert_eq!(t.len(), 2);
t.ingest(&[sock(2, 2, 2, 2, 0)], t0 + Duration::from_secs(2));
t.ingest(&[sock(2, 3, 3, 3, 0)], t0 + Duration::from_secs(3));
assert_eq!(t.len(), 1, "cookie 1 evicted, cookie 2 retained");
}
#[test]
fn zero_sent_interval_has_zero_retrans_ratio() {
let mut t = SocketRateTracker::new();
let t0 = Instant::now();
t.ingest(&[sock(1, 0, 0, 0, 0)], t0);
let rates = t.ingest(&[sock(1, 0, 500, 0, 0)], t0 + Duration::from_secs(1));
assert_eq!(rates[0].retrans_ratio, 0.0);
assert_eq!(rates[0].rx_goodput_bps, 500);
}
}