Answer "am I actually P2P right now?" per peer. A 1 Hz session task snapshots the selected QUIC path of every live audio connection (IrohTransport::connection_stats), core::connstats::derive turns consecutive snapshots into RTT/loss/bitrate (path switches and counter resets invalidate the rate window), and the peer card shows a Direct/Relay badge with a hover tooltip for address, loss, and up/down bitrate. No new dependencies, no wire change. Loopback-integration-tested against real iroh endpoints; not yet field-verified on a 2-machine call (FEATURES.md row marked 🧪). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
188 lines
7.0 KiB
Rust
188 lines
7.0 KiB
Rust
//! 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<f32>,
|
|
/// Outbound bitrate over the window, kilobits per second.
|
|
pub up_kbps: Option<f32>,
|
|
/// Inbound bitrate over the window, kilobits per second.
|
|
pub down_kbps: Option<f32>,
|
|
}
|
|
|
|
/// 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<f32>,
|
|
up_kbps: f32,
|
|
down_kbps: f32,
|
|
}
|
|
|
|
fn window_rates(prev: &PathSnapshot, cur: &PathSnapshot, elapsed: Duration) -> Option<WindowRates> {
|
|
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);
|
|
}
|
|
}
|