//! Per-peer connection-transparency derivation. //! //! The transport hands us cumulative counters for each peer's selected QUIC //! path ([`PathSnapshot`]); this module turns two consecutive snapshots into //! the human-facing [`PeerConnInfo`] the UI renders (badge + tooltip): path //! type, RTT, and loss/bitrate over the poll window. Pure functions only — //! the polling task in `core::mod` owns the clock and the previous-snapshot //! map. use crate::network::PathSnapshot; use std::time::Duration; /// How often the core polls the transport for path snapshots. pub const POLL_INTERVAL: Duration = Duration::from_secs(1); /// Derived, display-ready connection info for one peer, sent to the UI via /// `UiEvent::ConnectionStats`. Window-relative fields are `None` when they /// can't be derived yet (first poll, path switch, or an idle window). #[derive(Debug, Clone, PartialEq)] pub struct PeerConnInfo { /// True = relayed path, false = direct IP path. pub relay: bool, /// `ip:port` for a direct path, the relay URL for a relayed one. pub remote_addr: String, /// Path round-trip time, rounded to whole milliseconds. pub rtt_ms: u32, /// Percentage of packets sent in the window that were detected lost. pub loss_pct: Option, /// Outbound bitrate over the window, kilobits per second. pub up_kbps: Option, /// Inbound bitrate over the window, kilobits per second. pub down_kbps: Option, } /// Derive display info from the current snapshot and (when comparable) the /// previous one. `prev` is comparable only if it's the same path — a relay→ /// direct migration or a reconnect resets the counters, so those windows /// yield `None` rates rather than garbage (negative deltas show up as /// `cur < prev` and are treated the same way). pub fn derive(prev: Option<&PathSnapshot>, cur: &PathSnapshot, elapsed: Duration) -> PeerConnInfo { let rates = prev .filter(|p| comparable(p, cur)) .and_then(|p| window_rates(p, cur, elapsed)); PeerConnInfo { relay: cur.is_relay, remote_addr: cur.remote_addr.clone(), rtt_ms: cur.rtt.as_millis().min(u128::from(u32::MAX)) as u32, loss_pct: rates.and_then(|r| r.loss_pct), up_kbps: rates.map(|r| r.up_kbps), down_kbps: rates.map(|r| r.down_kbps), } } /// True when `cur`'s counters continue `prev`'s: same path (address) and /// monotonically non-decreasing counters (a reconnect on the same address /// restarts them from zero). fn comparable(prev: &PathSnapshot, cur: &PathSnapshot) -> bool { prev.remote_addr == cur.remote_addr && cur.tx_bytes >= prev.tx_bytes && cur.rx_bytes >= prev.rx_bytes && cur.tx_datagrams >= prev.tx_datagrams && cur.lost_packets >= prev.lost_packets } #[derive(Debug, Clone, Copy)] struct WindowRates { loss_pct: Option, up_kbps: f32, down_kbps: f32, } fn window_rates(prev: &PathSnapshot, cur: &PathSnapshot, elapsed: Duration) -> Option { let secs = elapsed.as_secs_f64(); if secs <= 0.0 { return None; } let sent = cur.tx_datagrams - prev.tx_datagrams; let lost = cur.lost_packets - prev.lost_packets; // Loss detection lags sending (it needs ACK timeouts), so a window can see // more losses than sends; clamp to 100% rather than exceeding it. An idle // window (nothing sent or lost) has no loss story to tell. let loss_pct = if sent == 0 && lost == 0 { None } else { Some(((lost as f64 / (sent.max(lost)) as f64) * 100.0) as f32) }; let kbps = |bytes: u64| ((bytes as f64 * 8.0 / 1000.0) / secs) as f32; Some(WindowRates { loss_pct, up_kbps: kbps(cur.tx_bytes - prev.tx_bytes), down_kbps: kbps(cur.rx_bytes - prev.rx_bytes), }) } #[cfg(test)] mod tests { use super::*; fn snap(addr: &str, tx_b: u64, rx_b: u64, tx_d: u64, lost: u64) -> PathSnapshot { PathSnapshot { is_relay: false, remote_addr: addr.to_string(), rtt: Duration::from_millis(12), tx_bytes: tx_b, rx_bytes: rx_b, tx_datagrams: tx_d, lost_packets: lost, } } #[test] fn first_poll_has_type_and_rtt_but_no_rates() { let cur = snap("1.2.3.4:5", 1000, 2000, 50, 0); let info = derive(None, &cur, POLL_INTERVAL); assert_eq!(info.rtt_ms, 12); assert!(!info.relay); assert_eq!(info.remote_addr, "1.2.3.4:5"); assert_eq!(info.loss_pct, None); assert_eq!(info.up_kbps, None); assert_eq!(info.down_kbps, None); } #[test] fn steady_window_yields_rates_and_loss() { let prev = snap("1.2.3.4:5", 0, 0, 0, 0); // 1s window: 4000 bytes up (32 kbps), 2000 down (16 kbps), 2 of 100 lost. let cur = snap("1.2.3.4:5", 4000, 2000, 100, 2); let info = derive(Some(&prev), &cur, Duration::from_secs(1)); assert_eq!(info.up_kbps, Some(32.0)); assert_eq!(info.down_kbps, Some(16.0)); assert_eq!(info.loss_pct, Some(2.0)); } #[test] fn idle_window_has_no_loss_story() { let prev = snap("1.2.3.4:5", 4000, 2000, 100, 2); let cur = prev.clone(); let info = derive(Some(&prev), &cur, Duration::from_secs(1)); assert_eq!(info.loss_pct, None); assert_eq!(info.up_kbps, Some(0.0)); } #[test] fn loss_detected_in_an_idle_window_clamps_to_full() { // Losses can be *detected* after sending stops (ACK timeouts fire late). let prev = snap("1.2.3.4:5", 4000, 2000, 100, 0); let cur = snap("1.2.3.4:5", 4000, 2000, 100, 3); let info = derive(Some(&prev), &cur, Duration::from_secs(1)); assert_eq!(info.loss_pct, Some(100.0)); } #[test] fn path_switch_resets_the_window() { let prev = snap("relay.example:443", 9000, 9000, 900, 5); let cur = snap("1.2.3.4:5", 100, 100, 10, 0); let info = derive(Some(&prev), &cur, Duration::from_secs(1)); assert_eq!(info.up_kbps, None); assert_eq!(info.loss_pct, None); } #[test] fn counter_reset_on_same_address_resets_the_window() { // Same address but the connection was rebuilt → counters restarted. let prev = snap("1.2.3.4:5", 9000, 9000, 900, 5); let cur = snap("1.2.3.4:5", 100, 100, 10, 0); let info = derive(Some(&prev), &cur, Duration::from_secs(1)); assert_eq!(info.up_kbps, None); assert_eq!(info.loss_pct, None); } #[test] fn zero_elapsed_yields_no_rates() { let prev = snap("1.2.3.4:5", 0, 0, 0, 0); let cur = snap("1.2.3.4:5", 4000, 2000, 100, 2); let info = derive(Some(&prev), &cur, Duration::ZERO); assert_eq!(info.up_kbps, None); assert_eq!(info.loss_pct, None); } #[test] fn oversized_rtt_saturates_instead_of_wrapping() { let mut cur = snap("1.2.3.4:5", 0, 0, 0, 0); cur.rtt = Duration::from_secs(u64::MAX); let info = derive(None, &cur, POLL_INTERVAL); assert_eq!(info.rtt_ms, u32::MAX); } }