use serde::Serialize; use std::collections::VecDeque; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; /// A single RTT sample. #[derive(Debug, Clone)] struct RttSample { _rtt: Duration, _timestamp: Instant, was_timeout: bool, } /// Snapshot of connection quality metrics. #[derive(Debug, Clone, Serialize, Default)] #[serde(rename_all = "camelCase")] pub struct ConnectionQuality { /// Smoothed RTT in milliseconds (EMA, RFC 6298 style). pub srtt_ms: f64, /// Jitter in milliseconds (mean deviation of RTT). pub jitter_ms: f64, /// Minimum RTT observed in the sample window. pub min_rtt_ms: f64, /// Maximum RTT observed in the sample window. pub max_rtt_ms: f64, /// Packet loss ratio over the sample window (0.0 - 1.0). pub loss_ratio: f64, /// Number of consecutive keepalive timeouts (0 if last succeeded). pub consecutive_timeouts: u32, /// Total keepalives sent. pub keepalives_sent: u64, /// Total keepalive ACKs received. pub keepalives_acked: u64, } /// Tracks connection quality from keepalive round-trips. pub struct RttTracker { /// Maximum number of samples to keep in the window. max_samples: usize, /// Recent RTT samples (including timeout markers). samples: VecDeque, /// When the last keepalive was sent (for computing RTT on ACK). pending_ping_sent_at: Option, /// Number of consecutive keepalive timeouts. pub consecutive_timeouts: u32, /// Smoothed RTT (EMA). srtt: Option, /// Jitter (mean deviation). jitter: f64, /// Minimum RTT observed. min_rtt: f64, /// Maximum RTT observed. max_rtt: f64, /// Total keepalives sent. keepalives_sent: u64, /// Total keepalive ACKs received. keepalives_acked: u64, /// Previous RTT sample for jitter calculation. last_rtt_ms: Option, } impl RttTracker { /// Create a new tracker with the given window size. pub fn new(max_samples: usize) -> Self { Self { max_samples, samples: VecDeque::with_capacity(max_samples), pending_ping_sent_at: None, consecutive_timeouts: 0, srtt: None, jitter: 0.0, min_rtt: f64::MAX, max_rtt: 0.0, keepalives_sent: 0, keepalives_acked: 0, last_rtt_ms: None, } } /// Record that a keepalive was sent. /// Returns a millisecond timestamp (since UNIX epoch) to embed in the keepalive payload. pub fn mark_ping_sent(&mut self) -> u64 { self.pending_ping_sent_at = Some(Instant::now()); self.keepalives_sent += 1; SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64 } /// Record that a keepalive ACK was received with the echoed timestamp. /// Returns the computed RTT if a pending ping was recorded. pub fn record_ack(&mut self, _echoed_timestamp_ms: u64) -> Option { let sent_at = self.pending_ping_sent_at.take()?; let rtt = sent_at.elapsed(); let rtt_ms = rtt.as_secs_f64() * 1000.0; self.keepalives_acked += 1; self.consecutive_timeouts = 0; // Update SRTT (RFC 6298: alpha = 1/8) match self.srtt { None => { self.srtt = Some(rtt_ms); self.jitter = rtt_ms / 2.0; } Some(prev_srtt) => { // RTTVAR = (1 - beta) * RTTVAR + beta * |SRTT - R| (beta = 1/4) self.jitter = 0.75 * self.jitter + 0.25 * (prev_srtt - rtt_ms).abs(); // SRTT = (1 - alpha) * SRTT + alpha * R (alpha = 1/8) self.srtt = Some(0.875 * prev_srtt + 0.125 * rtt_ms); } } // Update min/max if rtt_ms < self.min_rtt { self.min_rtt = rtt_ms; } if rtt_ms > self.max_rtt { self.max_rtt = rtt_ms; } self.last_rtt_ms = Some(rtt_ms); // Push sample into window if self.samples.len() >= self.max_samples { self.samples.pop_front(); } self.samples.push_back(RttSample { _rtt: rtt, _timestamp: Instant::now(), was_timeout: false, }); Some(rtt) } /// Record that a keepalive timed out (no ACK received). pub fn record_timeout(&mut self) { self.consecutive_timeouts += 1; self.pending_ping_sent_at = None; if self.samples.len() >= self.max_samples { self.samples.pop_front(); } self.samples.push_back(RttSample { _rtt: Duration::ZERO, _timestamp: Instant::now(), was_timeout: true, }); } /// Get a snapshot of the current connection quality. pub fn snapshot(&self) -> ConnectionQuality { let loss_ratio = if self.samples.is_empty() { 0.0 } else { let timeouts = self.samples.iter().filter(|s| s.was_timeout).count(); timeouts as f64 / self.samples.len() as f64 }; ConnectionQuality { srtt_ms: self.srtt.unwrap_or(0.0), jitter_ms: self.jitter, min_rtt_ms: if self.min_rtt == f64::MAX { 0.0 } else { self.min_rtt }, max_rtt_ms: self.max_rtt, loss_ratio, consecutive_timeouts: self.consecutive_timeouts, keepalives_sent: self.keepalives_sent, keepalives_acked: self.keepalives_acked, } } } #[cfg(test)] mod tests { use super::*; #[test] fn new_tracker_has_zero_quality() { let tracker = RttTracker::new(30); let q = tracker.snapshot(); assert_eq!(q.srtt_ms, 0.0); assert_eq!(q.jitter_ms, 0.0); assert_eq!(q.loss_ratio, 0.0); assert_eq!(q.consecutive_timeouts, 0); assert_eq!(q.keepalives_sent, 0); assert_eq!(q.keepalives_acked, 0); } #[test] fn mark_ping_returns_timestamp() { let mut tracker = RttTracker::new(30); let ts = tracker.mark_ping_sent(); // Should be a reasonable epoch-ms value (after 2020) assert!(ts > 1_577_836_800_000); assert_eq!(tracker.keepalives_sent, 1); } #[test] fn record_ack_computes_rtt() { let mut tracker = RttTracker::new(30); let ts = tracker.mark_ping_sent(); std::thread::sleep(Duration::from_millis(5)); let rtt = tracker.record_ack(ts); assert!(rtt.is_some()); let rtt = rtt.unwrap(); assert!(rtt.as_millis() >= 4); // at least ~5ms minus scheduling jitter assert_eq!(tracker.keepalives_acked, 1); assert_eq!(tracker.consecutive_timeouts, 0); } #[test] fn record_ack_without_pending_returns_none() { let mut tracker = RttTracker::new(30); assert!(tracker.record_ack(12345).is_none()); } #[test] fn srtt_converges() { let mut tracker = RttTracker::new(30); // Simulate several ping/ack cycles with ~10ms RTT for _ in 0..10 { let ts = tracker.mark_ping_sent(); std::thread::sleep(Duration::from_millis(10)); tracker.record_ack(ts); } let q = tracker.snapshot(); // SRTT should be roughly 10ms (allowing for scheduling variance) assert!(q.srtt_ms > 5.0, "SRTT too low: {}", q.srtt_ms); assert!(q.srtt_ms < 50.0, "SRTT too high: {}", q.srtt_ms); } #[test] fn timeout_increments_counter_and_loss() { let mut tracker = RttTracker::new(30); tracker.mark_ping_sent(); tracker.record_timeout(); assert_eq!(tracker.consecutive_timeouts, 1); tracker.mark_ping_sent(); tracker.record_timeout(); assert_eq!(tracker.consecutive_timeouts, 2); let q = tracker.snapshot(); assert_eq!(q.loss_ratio, 1.0); // 2 timeouts out of 2 samples } #[test] fn ack_resets_consecutive_timeouts() { let mut tracker = RttTracker::new(30); tracker.mark_ping_sent(); tracker.record_timeout(); assert_eq!(tracker.consecutive_timeouts, 1); let ts = tracker.mark_ping_sent(); tracker.record_ack(ts); assert_eq!(tracker.consecutive_timeouts, 0); } #[test] fn loss_ratio_over_mixed_window() { let mut tracker = RttTracker::new(30); // 3 successful, 1 timeout, 1 successful = 1/5 = 0.2 loss for _ in 0..3 { let ts = tracker.mark_ping_sent(); tracker.record_ack(ts); } tracker.mark_ping_sent(); tracker.record_timeout(); let ts = tracker.mark_ping_sent(); tracker.record_ack(ts); let q = tracker.snapshot(); assert!((q.loss_ratio - 0.2).abs() < 0.01); } #[test] fn window_evicts_old_samples() { let mut tracker = RttTracker::new(5); // Fill window with 5 timeouts for _ in 0..5 { tracker.mark_ping_sent(); tracker.record_timeout(); } assert_eq!(tracker.snapshot().loss_ratio, 1.0); // Add 5 successes — should evict all timeouts for _ in 0..5 { let ts = tracker.mark_ping_sent(); tracker.record_ack(ts); } assert_eq!(tracker.snapshot().loss_ratio, 0.0); } #[test] fn min_max_rtt_tracked() { let mut tracker = RttTracker::new(30); let ts = tracker.mark_ping_sent(); std::thread::sleep(Duration::from_millis(5)); tracker.record_ack(ts); let ts = tracker.mark_ping_sent(); std::thread::sleep(Duration::from_millis(15)); tracker.record_ack(ts); let q = tracker.snapshot(); assert!(q.min_rtt_ms < q.max_rtt_ms); assert!(q.min_rtt_ms > 0.0); } }