From 50a595a0ed50a39f9784250ff7189cdbb052adb3 Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Tue, 7 Jul 2026 09:24:17 +0000 Subject: [PATCH] proto/mmp: sans-IO metrics-reporting state machine --- src/config/node.rs | 3 +- src/mmp/metrics.rs | 556 --------- src/mmp/mod.rs | 500 +------- src/mmp/receiver.rs | 673 ----------- src/mmp/sender.rs | 418 ------- src/node/handlers/encrypted.rs | 13 +- src/node/handlers/mmp.rs | 587 ++++++---- src/node/handlers/session.rs | 34 +- src/node/mod.rs | 12 +- src/node/session.rs | 15 +- src/node/tests/mmp_chartests.rs | 439 +++++++ src/node/tests/mod.rs | 1 + src/node/tests/session.rs | 2 +- src/peer/active.rs | 50 +- src/{ => proto}/mmp/algorithms.rs | 221 +--- src/proto/mmp/core.rs | 374 ++++++ src/proto/mmp/mod.rs | 121 ++ src/proto/mmp/state.rs | 1339 ++++++++++++++++++++++ src/proto/mmp/tests/algorithms.rs | 175 +++ src/proto/mmp/tests/core.rs | 506 ++++++++ src/proto/mmp/tests/mod.rs | 6 + src/proto/mmp/tests/state.rs | 697 +++++++++++ src/proto/mmp/tests/wire.rs | 250 ++++ src/{mmp/report.rs => proto/mmp/wire.rs} | 364 +++--- src/proto/mod.rs | 1 + src/protocol/mod.rs | 5 +- src/protocol/session.rs | 336 ------ 27 files changed, 4610 insertions(+), 3088 deletions(-) delete mode 100644 src/mmp/metrics.rs delete mode 100644 src/mmp/receiver.rs delete mode 100644 src/mmp/sender.rs create mode 100644 src/node/tests/mmp_chartests.rs rename src/{ => proto}/mmp/algorithms.rs (62%) create mode 100644 src/proto/mmp/core.rs create mode 100644 src/proto/mmp/mod.rs create mode 100644 src/proto/mmp/state.rs create mode 100644 src/proto/mmp/tests/algorithms.rs create mode 100644 src/proto/mmp/tests/core.rs create mode 100644 src/proto/mmp/tests/mod.rs create mode 100644 src/proto/mmp/tests/state.rs create mode 100644 src/proto/mmp/tests/wire.rs rename src/{mmp/report.rs => proto/mmp/wire.rs} (53%) diff --git a/src/config/node.rs b/src/config/node.rs index 3b1a8bd..ae50541 100644 --- a/src/config/node.rs +++ b/src/config/node.rs @@ -7,7 +7,8 @@ use serde::{Deserialize, Serialize}; use super::IdentityConfig; -use crate::mmp::{DEFAULT_LOG_INTERVAL_SECS, DEFAULT_OWD_WINDOW_SIZE, MmpConfig, MmpMode}; +use crate::mmp::MmpConfig; +use crate::proto::mmp::{DEFAULT_LOG_INTERVAL_SECS, DEFAULT_OWD_WINDOW_SIZE, MmpMode}; // ============================================================================ // Node Configuration Subsections diff --git a/src/mmp/metrics.rs b/src/mmp/metrics.rs deleted file mode 100644 index a04d664..0000000 --- a/src/mmp/metrics.rs +++ /dev/null @@ -1,556 +0,0 @@ -//! MMP derived metrics. -//! -//! `MmpMetrics` processes incoming ReceiverReports (from our peer) and -//! maintains derived metrics: SRTT, loss rate, goodput, ETX, and dual -//! EWMA trend indicators. Updated by the sender side when it receives -//! a ReceiverReport about its own traffic. - -use crate::mmp::algorithms::{DualEwma, SrttEstimator, compute_etx}; -use crate::mmp::report::ReceiverReport; -use std::time::Instant; -use tracing::trace; - -/// Derived MMP metrics, updated from incoming ReceiverReports. -/// -/// This lives on the sender side: when we receive a ReceiverReport from -/// our peer describing what they observed about our traffic, we process -/// it here to compute RTT, loss, goodput, and trend indicators. -pub struct MmpMetrics { - /// Smoothed RTT from timestamp echo. - pub srtt: SrttEstimator, - - /// Dual EWMA trend detectors. - pub rtt_trend: DualEwma, - pub loss_trend: DualEwma, - pub goodput_trend: DualEwma, - pub jitter_trend: DualEwma, - pub etx_trend: DualEwma, - - /// Forward delivery ratio (what fraction of our frames the peer received). - pub delivery_ratio_forward: f64, - /// Reverse delivery ratio (set when we compute from our own receiver state). - pub delivery_ratio_reverse: f64, - /// ETX computed from bidirectional delivery ratios. - pub etx: f64, - - /// Smoothed goodput in bytes/sec (forward direction: what the peer received from us). - pub goodput_bps: f64, - - // --- State for delta computation --- - /// Previous ReceiverReport's cumulative counters (for computing interval deltas). - prev_rr_cum_packets: u64, - prev_rr_cum_bytes: u64, - prev_rr_highest_counter: u64, - prev_rr_ecn_ce: u32, - prev_rr_reorder: u32, - /// Time of previous ReceiverReport (for goodput rate computation). - prev_rr_time: Option, - /// Whether we have a previous ReceiverReport for delta computation. - has_prev_rr: bool, - - // --- State for reverse delivery ratio delta computation --- - /// Previous reverse-side cumulative packets received (our receiver state). - prev_reverse_packets: u64, - /// Previous reverse-side highest counter (our receiver state). - prev_reverse_highest: u64, - /// Whether we have a previous reverse-side snapshot for delta computation. - has_prev_reverse: bool, -} - -impl MmpMetrics { - /// Reset state derived from ReceiverReport counters for rekey cutover. - /// - /// The new session starts with counter 0, so the prev_rr deltas must - /// be reset to avoid computing bogus loss/goodput from the counter - /// discontinuity. RTT (SRTT) is preserved since it remains valid. - pub fn reset_for_rekey(&mut self) { - self.prev_rr_cum_packets = 0; - self.prev_rr_cum_bytes = 0; - self.prev_rr_highest_counter = 0; - self.prev_rr_ecn_ce = 0; - self.prev_rr_reorder = 0; - self.prev_rr_time = None; - self.has_prev_rr = false; - self.delivery_ratio_forward = 1.0; - self.prev_reverse_packets = 0; - self.prev_reverse_highest = 0; - self.has_prev_reverse = false; - // Keep srtt, etx, trends, goodput_bps — they'll refresh from data - } - - pub fn new() -> Self { - Self { - srtt: SrttEstimator::new(), - rtt_trend: DualEwma::new(), - loss_trend: DualEwma::new(), - goodput_trend: DualEwma::new(), - jitter_trend: DualEwma::new(), - etx_trend: DualEwma::new(), - delivery_ratio_forward: 1.0, - delivery_ratio_reverse: 1.0, - etx: 1.0, - goodput_bps: 0.0, - prev_rr_cum_packets: 0, - prev_rr_cum_bytes: 0, - prev_rr_highest_counter: 0, - prev_rr_ecn_ce: 0, - prev_rr_reorder: 0, - prev_rr_time: None, - has_prev_rr: false, - prev_reverse_packets: 0, - prev_reverse_highest: 0, - has_prev_reverse: false, - } - } - - /// Process an incoming ReceiverReport (from the peer about our traffic). - /// - /// `our_timestamp_ms` is the current session-relative time in ms (for RTT). - /// `now` is the current monotonic time (for goodput rate computation). - /// - /// Returns `true` if this report produced the first SRTT measurement - /// (transition from uninitialized to initialized). - pub fn process_receiver_report( - &mut self, - rr: &ReceiverReport, - our_timestamp_ms: u32, - now: Instant, - ) -> bool { - let had_srtt = self.srtt.initialized(); - - if self.has_prev_rr { - let counters_regressed = rr.highest_counter < self.prev_rr_highest_counter - || rr.cumulative_packets_recv < self.prev_rr_cum_packets - || rr.cumulative_bytes_recv < self.prev_rr_cum_bytes - || rr.ecn_ce_count < self.prev_rr_ecn_ce - || rr.cumulative_reorder_count < self.prev_rr_reorder; - let duplicate_counters = rr.highest_counter == self.prev_rr_highest_counter - && rr.cumulative_packets_recv == self.prev_rr_cum_packets - && rr.cumulative_bytes_recv == self.prev_rr_cum_bytes - && rr.ecn_ce_count == self.prev_rr_ecn_ce - && rr.cumulative_reorder_count == self.prev_rr_reorder; - // Safe to drop: reports are only built after interval data, so - // a fresh report always advances at least one cumulative counter. - if counters_regressed || duplicate_counters { - trace!( - highest_counter = rr.highest_counter, - prev_highest_counter = self.prev_rr_highest_counter, - cumulative_packets_recv = rr.cumulative_packets_recv, - prev_cumulative_packets_recv = self.prev_rr_cum_packets, - cumulative_bytes_recv = rr.cumulative_bytes_recv, - prev_cumulative_bytes_recv = self.prev_rr_cum_bytes, - "Ignoring stale MMP ReceiverReport" - ); - return false; - } - } - - // --- RTT from timestamp echo --- - // RTT = now - echoed_timestamp - dwell_time - if rr.timestamp_echo > 0 { - let echo_ms = rr.timestamp_echo; - let dwell_ms = u32::from(rr.dwell_time); - let rtt_sample_ms = echo_ms - .checked_add(dwell_ms) - .and_then(|send_done_ms| our_timestamp_ms.checked_sub(send_done_ms)); - - match rtt_sample_ms { - Some(rtt_ms) if rtt_ms > 0 => { - let rtt_us = (rtt_ms as i64) * 1000; - trace!( - our_ts = our_timestamp_ms, - echo = echo_ms, - dwell = dwell_ms, - rtt_ms = rtt_ms, - srtt_ms = self.srtt.srtt_us() as f64 / 1000.0, - "RTT sample from timestamp echo" - ); - self.srtt.update(rtt_us); - self.rtt_trend.update(rtt_us as f64); - } - _ => { - trace!( - our_ts = our_timestamp_ms, - echo = echo_ms, - dwell = dwell_ms, - "Ignoring invalid MMP RTT sample" - ); - } - } - } - - // --- Loss rate from cumulative counters --- - // Delta: frames the peer should have received vs. actually received - if self.has_prev_rr { - let counter_span = rr - .highest_counter - .saturating_sub(self.prev_rr_highest_counter); - let packets_delta = rr - .cumulative_packets_recv - .saturating_sub(self.prev_rr_cum_packets); - - if counter_span > 0 { - let delivery = (packets_delta as f64) / (counter_span as f64); - self.delivery_ratio_forward = delivery.clamp(0.0, 1.0); - let loss_rate = 1.0 - self.delivery_ratio_forward; - self.loss_trend.update(loss_rate); - self.etx = compute_etx(self.delivery_ratio_forward, self.delivery_ratio_reverse); - self.etx_trend.update(self.etx); - } - } - - // --- Goodput from cumulative bytes + time delta --- - if self.has_prev_rr { - let bytes_delta = rr - .cumulative_bytes_recv - .saturating_sub(self.prev_rr_cum_bytes); - self.goodput_trend.update(bytes_delta as f64); - - // Compute bytes/sec if we have a time reference - if let Some(prev_time) = self.prev_rr_time { - let elapsed = now.duration_since(prev_time); - let secs = elapsed.as_secs_f64(); - if secs > 0.0 { - let bps = bytes_delta as f64 / secs; - // EWMA smoothing: α = 1/4 - if self.goodput_bps == 0.0 { - self.goodput_bps = bps; - } else { - self.goodput_bps += (bps - self.goodput_bps) * 0.25; - } - } - } - } - - // --- Jitter trend --- - self.jitter_trend.update(rr.jitter as f64); - - // --- Save for next delta --- - self.prev_rr_cum_packets = rr.cumulative_packets_recv; - self.prev_rr_cum_bytes = rr.cumulative_bytes_recv; - self.prev_rr_highest_counter = rr.highest_counter; - self.prev_rr_ecn_ce = rr.ecn_ce_count; - self.prev_rr_reorder = rr.cumulative_reorder_count; - self.prev_rr_time = Some(now); - self.has_prev_rr = true; - - !had_srtt && self.srtt.initialized() - } - - /// Update the reverse delivery ratio from our own receiver state. - /// - /// Computes a per-interval delta (same as forward ratio) rather than - /// a lifetime cumulative ratio, so ETX responds to recent conditions. - pub fn update_reverse_delivery(&mut self, our_recv_packets: u64, peer_highest: u64) { - if self.has_prev_reverse { - let counter_span = peer_highest.saturating_sub(self.prev_reverse_highest); - let packets_delta = our_recv_packets.saturating_sub(self.prev_reverse_packets); - - if counter_span > 0 { - let delivery = (packets_delta as f64) / (counter_span as f64); - self.delivery_ratio_reverse = delivery.clamp(0.0, 1.0); - self.etx = compute_etx(self.delivery_ratio_forward, self.delivery_ratio_reverse); - self.etx_trend.update(self.etx); - } - } - - self.prev_reverse_packets = our_recv_packets; - self.prev_reverse_highest = peer_highest; - self.has_prev_reverse = true; - } - - /// Current smoothed RTT in milliseconds, or `None` if not yet measured. - pub fn srtt_ms(&self) -> Option { - if self.srtt.initialized() { - Some(self.srtt.srtt_us() as f64 / 1000.0) - } else { - None - } - } - - /// Current loss rate (0.0 = no loss, 1.0 = total loss). - pub fn loss_rate(&self) -> f64 { - 1.0 - self.delivery_ratio_forward - } - - /// Smoothed loss rate (long-term EWMA), or `None` if not yet initialized. - pub fn smoothed_loss(&self) -> Option { - if self.loss_trend.initialized() { - Some(self.loss_trend.long()) - } else { - None - } - } - - /// Smoothed ETX (long-term EWMA), or `None` if not yet initialized. - pub fn smoothed_etx(&self) -> Option { - if self.etx_trend.initialized() { - Some(self.etx_trend.long()) - } else { - None - } - } - - /// Current smoothed goodput in bytes/sec, or 0 if not yet measured. - pub fn goodput_bps(&self) -> f64 { - self.goodput_bps - } - - /// Cumulative ECN CE count from the most recent ReceiverReport. - pub fn last_ecn_ce_count(&self) -> u32 { - self.prev_rr_ecn_ce - } -} - -impl Default for MmpMetrics { - fn default() -> Self { - Self::new() - } -} - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - use std::time::Duration; - - fn make_rr( - highest_counter: u64, - cum_packets: u64, - cum_bytes: u64, - timestamp_echo: u32, - dwell: u16, - jitter: u32, - ) -> ReceiverReport { - ReceiverReport { - highest_counter, - cumulative_packets_recv: cum_packets, - cumulative_bytes_recv: cum_bytes, - timestamp_echo, - dwell_time: dwell, - max_burst_loss: 0, - mean_burst_loss: 0, - jitter, - ecn_ce_count: 0, - owd_trend: 0, - burst_loss_count: 0, - cumulative_reorder_count: 0, - interval_packets_recv: 0, - interval_bytes_recv: 0, - } - } - - #[test] - fn test_rtt_from_echo() { - let mut m = MmpMetrics::new(); - let now = Instant::now(); - // Peer echoes timestamp 1000ms, dwell=5ms, our current time=1050ms - let rr = make_rr(10, 10, 5000, 1000, 5, 0); - m.process_receiver_report(&rr, 1050, now); - - assert!(m.srtt.initialized()); - // RTT = 1050 - 1000 - 5 = 45ms - let srtt_ms = m.srtt_ms().unwrap(); - assert!((srtt_ms - 45.0).abs() < 1.0, "srtt={srtt_ms}, expected ~45"); - } - - #[test] - fn test_ignores_duplicate_receiver_report_after_valid_sample() { - let mut m = MmpMetrics::new(); - let t0 = Instant::now(); - - let rr1 = make_rr(10, 10, 5_000, 1_000, 5, 0); - m.process_receiver_report(&rr1, 1_050, t0); - - let rr2 = make_rr(20, 18, 14_000, 1_100, 5, 0); - m.process_receiver_report(&rr2, 1_150, t0 + Duration::from_secs(1)); - let baseline_srtt_ms = m.srtt_ms().unwrap(); - let baseline_loss = m.loss_rate(); - let baseline_goodput = m.goodput_bps(); - - assert!(baseline_loss > 0.0); - assert!(baseline_goodput > 0.0); - - // A duplicate of the same counters arriving later would be a 4.895s - // RTT sample if accepted. It is stale and must not move metrics. - m.process_receiver_report(&rr2, 6_000, t0 + Duration::from_secs(5)); - - assert_eq!(m.srtt_ms().unwrap(), baseline_srtt_ms); - assert_eq!(m.loss_rate(), baseline_loss); - assert_eq!(m.goodput_bps(), baseline_goodput); - } - - #[test] - fn test_ignores_out_of_order_receiver_report_after_valid_sample() { - let mut m = MmpMetrics::new(); - let now = Instant::now(); - - let valid_rr = make_rr(20, 20, 10000, 1000, 5, 0); - m.process_receiver_report(&valid_rr, 1050, now); - let baseline_srtt_ms = m.srtt_ms().unwrap(); - - let old_rr = make_rr(10, 10, 5000, 1000, 0, 0); - m.process_receiver_report(&old_rr, 6000, now + Duration::from_secs(5)); - - let srtt_ms = m.srtt_ms().unwrap(); - assert_eq!(srtt_ms, baseline_srtt_ms); - } - - #[test] - fn test_ignores_wrapped_rtt_sample() { - let mut m = MmpMetrics::new(); - let now = Instant::now(); - - let wrapped_rr = make_rr(10, 10, 5000, u32::MAX - 10, 20, 0); - m.process_receiver_report(&wrapped_rr, 15, now); - - assert!(m.srtt_ms().is_none()); - } - - #[test] - fn test_ignores_future_rtt_sample() { - let mut m = MmpMetrics::new(); - let now = Instant::now(); - - let future_rr = make_rr(10, 10, 5_000, 2_000, 5, 0); - m.process_receiver_report(&future_rr, 1_000, now); - - assert!(m.srtt_ms().is_none()); - } - - #[test] - fn test_loss_rate_computation() { - let mut m = MmpMetrics::new(); - let t0 = Instant::now(); - - // First report: baseline - let rr1 = make_rr(100, 100, 50000, 0, 0, 0); - m.process_receiver_report(&rr1, 0, t0); - - // Second report: 200 counters sent, 190 received (5% loss) - let rr2 = make_rr(300, 290, 145000, 0, 0, 0); - m.process_receiver_report(&rr2, 0, t0 + Duration::from_secs(1)); - - let loss = m.loss_rate(); - assert!((loss - 0.05).abs() < 0.01, "loss={loss}, expected ~0.05"); - } - - #[test] - fn test_etx_updates() { - let mut m = MmpMetrics::new(); - assert_eq!(m.etx, 1.0); // initial: perfect - - // Simulate some loss via forward ratio - m.delivery_ratio_forward = 0.9; - - // First call establishes the baseline (no ETX update yet) - m.update_reverse_delivery(100, 100); - assert_eq!(m.etx, 1.0); // still perfect — baseline only - - // Second call: 190 of 200 frames received (5% loss) - m.update_reverse_delivery(290, 300); - assert!(m.etx > 1.0); - assert!(m.etx < 2.0); - } - - #[test] - fn test_no_rtt_without_echo() { - let mut m = MmpMetrics::new(); - let now = Instant::now(); - let rr = make_rr(10, 10, 5000, 0, 0, 0); - m.process_receiver_report(&rr, 1000, now); - assert!(m.srtt_ms().is_none()); - } - - #[test] - fn test_jitter_trend() { - let mut m = MmpMetrics::new(); - let t0 = Instant::now(); - let rr1 = make_rr(10, 10, 5000, 0, 0, 100); - m.process_receiver_report(&rr1, 0, t0); - - let rr2 = make_rr(20, 20, 10000, 0, 0, 500); - m.process_receiver_report(&rr2, 0, t0 + Duration::from_secs(1)); - - assert!(m.jitter_trend.initialized()); - // Short-term should be closer to 500 than long-term - assert!(m.jitter_trend.short() > m.jitter_trend.long()); - } - - #[test] - fn test_goodput_bps() { - let mut m = MmpMetrics::new(); - let t0 = Instant::now(); - - // First report: baseline (50KB received) - let rr1 = make_rr(100, 100, 50_000, 0, 0, 0); - m.process_receiver_report(&rr1, 0, t0); - assert_eq!(m.goodput_bps(), 0.0); // no rate yet (first report) - - // Second report 1s later: 150KB total (100KB delta in 1s = 100KB/s) - let rr2 = make_rr(300, 290, 150_000, 0, 0, 0); - m.process_receiver_report(&rr2, 0, t0 + Duration::from_secs(1)); - assert!( - m.goodput_bps() > 90_000.0, - "goodput={}, expected ~100000", - m.goodput_bps() - ); - assert!( - m.goodput_bps() < 110_000.0, - "goodput={}, expected ~100000", - m.goodput_bps() - ); - } - - #[test] - fn test_reverse_delivery_delta() { - let mut m = MmpMetrics::new(); - - // First call: baseline only, no ratio update - m.update_reverse_delivery(100, 100); - assert_eq!(m.delivery_ratio_reverse, 1.0); // unchanged from default - - // Second call: perfect delivery (200 new frames, all received) - m.update_reverse_delivery(300, 300); - assert!((m.delivery_ratio_reverse - 1.0).abs() < 0.001); - - // Third call: 50% loss (100 frames sent, 50 received) - m.update_reverse_delivery(350, 400); - assert!( - (m.delivery_ratio_reverse - 0.5).abs() < 0.001, - "reverse={}, expected 0.5", - m.delivery_ratio_reverse - ); - } - - #[test] - fn test_reverse_delivery_rekey_reset() { - let mut m = MmpMetrics::new(); - - // Establish baseline and one measurement - m.update_reverse_delivery(100, 100); - m.update_reverse_delivery(300, 300); - assert!((m.delivery_ratio_reverse - 1.0).abs() < 0.001); - - // Rekey resets reverse state - m.reset_for_rekey(); - - // First call after rekey: baseline only - m.update_reverse_delivery(50, 50); - // delivery_ratio_reverse was reset to 1.0 by reset_for_rekey's - // clearing of delivery_ratio_forward; reverse is not explicitly - // reset — but the delta state is, so next call computes fresh. - assert_eq!(m.delivery_ratio_reverse, 1.0); - - // Second call after rekey: 80% delivery - m.update_reverse_delivery(90, 100); - assert!( - (m.delivery_ratio_reverse - 0.8).abs() < 0.001, - "reverse={}, expected 0.8", - m.delivery_ratio_reverse - ); - } -} diff --git a/src/mmp/mod.rs b/src/mmp/mod.rs index b894062..5a729bb 100644 --- a/src/mmp/mod.rs +++ b/src/mmp/mod.rs @@ -1,144 +1,30 @@ -//! Metrics Measurement Protocol (MMP) — link-layer instantiation. +//! Metrics Measurement Protocol (MMP) — node-side shell. //! -//! Measures link quality between adjacent peers: RTT, loss, jitter, -//! throughput, one-way delay trend, and ETX. Operates on the per-frame -//! hooks (counter, timestamp, flags) introduced by the FMP wire format -//! revision. -//! -//! Three operating modes trade measurement fidelity for overhead: -//! - **Full**: sender + receiver reports at RTT-adaptive intervals -//! - **Lightweight**: receiver reports only (infer loss from counters) -//! - **Minimal**: spin bit + CE echo only, no reports +//! The MMP protocol core (estimators, sender/receiver/metrics/path-MTU state +//! machines, report codecs and the reporting decisions) lives in the sans-IO +//! [`crate::proto::mmp`] module. What remains here is the shell-only surface: +//! the serde node configuration ([`MmpConfig`]) and the monotonic millisecond +//! clock ([`mono_ms`]) the shell reads at the edge and injects into the owned +//! `u64`-ms state. + +use std::sync::LazyLock; +use std::time::Instant; use serde::{Deserialize, Serialize}; -use std::fmt::{self, Debug}; -use std::time::{Duration, Instant}; -// Sub-modules -pub mod algorithms; -pub mod metrics; -pub mod receiver; -pub mod report; -pub mod sender; +use crate::proto::mmp::{DEFAULT_LOG_INTERVAL_SECS, DEFAULT_OWD_WINDOW_SIZE, MmpMode}; -// Re-exports -pub use algorithms::{ - DualEwma, JitterEstimator, OwdTrendDetector, SpinBitState, SrttEstimator, compute_etx, -}; -pub use metrics::MmpMetrics; -pub use receiver::ReceiverState; -pub use report::{ReceiverReport, SenderReport}; -pub use sender::SenderState; - -// Session-layer re-exports -// MmpSessionState and PathMtuState are defined in this file - -// ============================================================================ -// Constants -// ============================================================================ - -/// SenderReport body size (after msg_type byte): 3 reserved + 44 payload = 47. -pub const SENDER_REPORT_BODY_SIZE: usize = 47; - -/// ReceiverReport body size (after msg_type byte): 3 reserved + 64 payload = 67. -pub const RECEIVER_REPORT_BODY_SIZE: usize = 67; - -/// SenderReport total wire size including inner header: 5 + 47 = 52. -pub const SENDER_REPORT_WIRE_SIZE: usize = 52; - -/// ReceiverReport total wire size including inner header: 5 + 67 = 72. -pub const RECEIVER_REPORT_WIRE_SIZE: usize = 72; - -// --- EWMA parameters (as shift amounts for integer arithmetic) --- - -/// Jitter EWMA: α = 1/16 (RFC 3550 §6.4.1). -pub const JITTER_ALPHA_SHIFT: u32 = 4; - -/// SRTT: α = 1/8 (Jacobson, RFC 6298). -pub const SRTT_ALPHA_SHIFT: u32 = 3; - -/// RTTVAR: β = 1/4 (Jacobson, RFC 6298). -pub const RTTVAR_BETA_SHIFT: u32 = 2; - -/// Dual EWMA short-term: α = 1/4. -pub const EWMA_SHORT_ALPHA: f64 = 0.25; - -/// Dual EWMA long-term: α = 1/32. -pub const EWMA_LONG_ALPHA: f64 = 1.0 / 32.0; - -// --- Timing defaults (milliseconds) --- - -/// Default report interval before SRTT is available (cold start). -pub const DEFAULT_COLD_START_INTERVAL_MS: u64 = 200; - -/// Minimum report interval (SRTT clamp floor). +/// Monotonic millisecond clock for the MMP time seam. /// -/// Raised from 100ms to 1000ms: parent re-evaluation runs every 60s, -/// so 60 samples/cycle is more than sufficient for EWMA convergence (~10). -/// The cold-start phase uses `DEFAULT_COLD_START_INTERVAL_MS` (200ms) for -/// fast initial SRTT convergence before transitioning to this floor. -pub const MIN_REPORT_INTERVAL_MS: u64 = 1_000; - -/// Maximum report interval (SRTT clamp ceiling). -pub const MAX_REPORT_INTERVAL_MS: u64 = 5_000; - -/// Number of SRTT samples before transitioning from cold-start to normal floor. -/// -/// During cold-start, report intervals use `DEFAULT_COLD_START_INTERVAL_MS` as -/// the floor to gather SRTT samples quickly. After this many updates, the floor -/// switches to `MIN_REPORT_INTERVAL_MS`. -pub const COLD_START_SAMPLES: u32 = 5; - -/// Default OWD ring buffer capacity. -pub const DEFAULT_OWD_WINDOW_SIZE: usize = 32; - -/// Default operator log interval in seconds. -pub const DEFAULT_LOG_INTERVAL_SECS: u64 = 30; - -// --- Session-layer timing defaults --- -// Session reports are routed end-to-end (bandwidth cost on every transit link), -// so intervals are higher than link-layer. - -/// Session-layer minimum report interval. -pub const MIN_SESSION_REPORT_INTERVAL_MS: u64 = 500; - -/// Session-layer maximum report interval. -pub const MAX_SESSION_REPORT_INTERVAL_MS: u64 = 10_000; - -/// Session-layer cold-start report interval (before SRTT is available). -pub const SESSION_COLD_START_INTERVAL_MS: u64 = 1_000; - -// ============================================================================ -// Operating Mode -// ============================================================================ - -/// MMP operating mode. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum MmpMode { - /// Sender + receiver reports at RTT-adaptive intervals. Maximum fidelity. - #[default] - Full, - /// Receiver reports only. Loss inferred from counter gaps. - Lightweight, - /// Spin bit + CE echo only. No reports exchanged. - Minimal, +/// The owned `proto::mmp` state takes injected `u64` milliseconds rather than +/// reading a clock. The shell reads this process-monotonic clock at each edge +/// and passes the value in; only deltas between two `mono_ms()` reads are ever +/// compared, so the (process-relative) epoch is immaterial. +pub(crate) fn mono_ms() -> u64 { + static START: LazyLock = LazyLock::new(Instant::now); + START.elapsed().as_millis() as u64 } -impl fmt::Display for MmpMode { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - MmpMode::Full => write!(f, "full"), - MmpMode::Lightweight => write!(f, "lightweight"), - MmpMode::Minimal => write!(f, "minimal"), - } - } -} - -// ============================================================================ -// Configuration -// ============================================================================ - /// MMP configuration (`node.mmp.*`). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MmpConfig { @@ -174,354 +60,10 @@ impl MmpConfig { } } -// ============================================================================ -// Per-Peer MMP State -// ============================================================================ - -/// Combined MMP state for a single peer link. -/// -/// Wraps sender, receiver, metrics, and spin bit state. One instance -/// per `ActivePeer`. -pub struct MmpPeerState { - pub sender: SenderState, - pub receiver: ReceiverState, - pub metrics: MmpMetrics, - pub spin_bit: SpinBitState, - mode: MmpMode, - log_interval: Duration, - last_log_time: Option, -} - -impl MmpPeerState { - /// Create MMP state for a new peer link. - /// - /// `is_initiator`: true if this node initiated the Noise handshake - /// (determines spin bit role). - pub fn new(config: &MmpConfig, is_initiator: bool) -> Self { - Self { - sender: SenderState::new(), - receiver: ReceiverState::new(config.owd_window_size), - metrics: MmpMetrics::new(), - spin_bit: SpinBitState::new(is_initiator), - mode: config.mode, - log_interval: Duration::from_secs(config.log_interval_secs), - last_log_time: None, - } - } - - /// Reset counter-dependent state for rekey cutover. - pub fn reset_for_rekey(&mut self, now: Instant) { - self.receiver.reset_for_rekey(now); - self.metrics.reset_for_rekey(); - } - - /// Current operating mode. - pub fn mode(&self) -> MmpMode { - self.mode - } - - /// Check if it's time to emit a periodic metrics log. - pub fn should_log(&self, now: Instant) -> bool { - match self.last_log_time { - None => true, - Some(last) => now.duration_since(last) >= self.log_interval, - } - } - - /// Mark that a periodic log was emitted. - pub fn mark_logged(&mut self, now: Instant) { - self.last_log_time = Some(now); - } -} - -// ============================================================================ -// Per-Session MMP State (session-layer instantiation) -// ============================================================================ - -/// Combined MMP state for a single end-to-end session. -/// -/// Wraps sender, receiver, metrics, spin bit, and path MTU state. -/// One instance per established `SessionEntry`. -pub struct MmpSessionState { - pub sender: SenderState, - pub receiver: ReceiverState, - pub metrics: MmpMetrics, - pub spin_bit: SpinBitState, - mode: MmpMode, - log_interval: Duration, - last_log_time: Option, - pub path_mtu: PathMtuState, -} - -impl MmpSessionState { - /// Create MMP state for a new session. - /// - /// `is_initiator`: true if this node initiated the Noise handshake - /// (determines spin bit role). - pub fn new(config: &crate::config::SessionMmpConfig, is_initiator: bool) -> Self { - Self { - sender: SenderState::new_with_cold_start(SESSION_COLD_START_INTERVAL_MS), - receiver: ReceiverState::new_with_cold_start( - config.owd_window_size, - SESSION_COLD_START_INTERVAL_MS, - ), - metrics: MmpMetrics::new(), - spin_bit: SpinBitState::new(is_initiator), - mode: config.mode, - log_interval: Duration::from_secs(config.log_interval_secs), - last_log_time: None, - path_mtu: PathMtuState::new(), - } - } - - /// Reset counter-dependent state for rekey cutover. - pub fn reset_for_rekey(&mut self, now: Instant) { - self.receiver.reset_for_rekey(now); - self.metrics.reset_for_rekey(); - } - - /// Current operating mode. - pub fn mode(&self) -> MmpMode { - self.mode - } - - /// Check if it's time to emit a periodic metrics log. - pub fn should_log(&self, now: Instant) -> bool { - match self.last_log_time { - None => true, - Some(last) => now.duration_since(last) >= self.log_interval, - } - } - - /// Mark that a periodic log was emitted. - pub fn mark_logged(&mut self, now: Instant) { - self.last_log_time = Some(now); - } -} - -impl Debug for MmpSessionState { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("MmpSessionState") - .field("mode", &self.mode) - .field("path_mtu", &self.path_mtu.current_mtu()) - .finish_non_exhaustive() - } -} - -// ============================================================================ -// Path MTU State (session-layer only) -// ============================================================================ - -/// Path MTU tracking for a single session. -/// -/// Destination side: observes `path_mtu` from incoming SessionDatagram envelopes -/// and generates PathMtuNotification messages back to the source. -/// -/// Source side: applies received PathMtuNotification to limit outbound datagram -/// size. Decrease is immediate; increase requires 3 consecutive notifications. -pub struct PathMtuState { - /// Current effective path MTU (what we use for sending). - current_mtu: u16, - /// Last observed path MTU from incoming datagrams (destination-side). - last_observed_mtu: u16, - /// Whether the observed MTU has changed since the last notification. - observed_changed: bool, - /// Last time a PathMtuNotification was sent. - last_notification_time: Option, - /// Notification interval: max(10s, 5 * SRTT). Default 10s. - notification_interval: Duration, - /// For source-side increase tracking: consecutive higher-value notifications. - consecutive_increase_count: u8, - /// Time of the first notification in the current increase sequence. - first_increase_time: Option, - /// The MTU value being proposed for increase. - pending_increase_mtu: u16, -} - -impl PathMtuState { - /// Create path MTU state with no initial measurement. - pub fn new() -> Self { - Self { - current_mtu: u16::MAX, - last_observed_mtu: u16::MAX, - observed_changed: false, - last_notification_time: None, - notification_interval: Duration::from_secs(10), - consecutive_increase_count: 0, - first_increase_time: None, - pending_increase_mtu: 0, - } - } - - /// Current effective path MTU (source-side, for sending). - pub fn current_mtu(&self) -> u16 { - self.current_mtu - } - - /// Last observed incoming path MTU (destination-side). - pub fn last_observed_mtu(&self) -> u16 { - self.last_observed_mtu - } - - /// Update notification interval from SRTT: max(10s, 5 * SRTT). - pub fn update_interval_from_srtt(&mut self, srtt_ms: f64) { - let five_srtt = Duration::from_millis((srtt_ms * 5.0) as u64); - self.notification_interval = five_srtt.max(Duration::from_secs(10)); - } - - /// Seed source-side current_mtu from outbound transport MTU. - /// - /// Called on each send. Only decreases (never increases) the current_mtu - /// so the destination's PathMtuNotification can still raise it later. - /// Ensures current_mtu doesn't stay at u16::MAX before any notification - /// arrives from the destination. - pub fn seed_source_mtu(&mut self, outbound_mtu: u16) { - if outbound_mtu < self.current_mtu { - self.current_mtu = outbound_mtu; - } - } - - // --- Destination side --- - - /// Observe the path_mtu from an incoming SessionDatagram envelope. - /// - /// Called on the destination (receiver) side for every session message. - pub fn observe_incoming_mtu(&mut self, path_mtu: u16) { - if path_mtu != self.last_observed_mtu { - self.observed_changed = true; - self.last_observed_mtu = path_mtu; - } - } - - /// Check if a PathMtuNotification should be sent. - /// - /// Send on first measurement, on decrease (immediate), or periodic - /// confirmation at the notification interval. - pub fn should_send_notification(&self, now: Instant) -> bool { - if self.last_observed_mtu == u16::MAX { - return false; // No measurement yet - } - match self.last_notification_time { - None => true, // First measurement - Some(last) => { - // Immediate on decrease - if self.observed_changed && self.last_observed_mtu < self.current_mtu { - return true; - } - // Periodic confirmation - now.duration_since(last) >= self.notification_interval - } - } - } - - /// Build a PathMtuNotification from current state. - /// - /// Returns the path_mtu value to send. Caller handles encoding. - pub fn build_notification(&mut self, now: Instant) -> Option { - if self.last_observed_mtu == u16::MAX { - return None; - } - self.last_notification_time = Some(now); - self.observed_changed = false; - Some(self.last_observed_mtu) - } - - // --- Source side --- - - /// Apply a received PathMtuNotification. - /// - /// - Decrease: immediate (take the lower value). - /// - Increase: require 3 consecutive notifications with the same higher - /// value, spanning at least 2 * notification_interval. - /// - /// Returns `true` if the effective MTU changed. - pub fn apply_notification(&mut self, reported_mtu: u16, now: Instant) -> bool { - if reported_mtu < self.current_mtu { - // Decrease: immediate - self.current_mtu = reported_mtu; - self.consecutive_increase_count = 0; - self.first_increase_time = None; - return true; - } - - if reported_mtu > self.current_mtu { - // Increase: track consecutive notifications - if reported_mtu == self.pending_increase_mtu { - self.consecutive_increase_count += 1; - } else { - // Different value: reset sequence - self.pending_increase_mtu = reported_mtu; - self.consecutive_increase_count = 1; - self.first_increase_time = Some(now); - } - - // Accept increase after 3 consecutive spanning 2 * interval - if self.consecutive_increase_count >= 3 - && let Some(first_time) = self.first_increase_time - { - let required = self.notification_interval * 2; - if now.duration_since(first_time) >= required { - self.current_mtu = reported_mtu; - self.consecutive_increase_count = 0; - self.first_increase_time = None; - return true; - } - } - } - - // No change (equal or increase not yet confirmed) - false - } -} - -impl Default for PathMtuState { - fn default() -> Self { - Self::new() - } -} - -impl Debug for MmpPeerState { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("MmpPeerState") - .field("mode", &self.mode) - .finish_non_exhaustive() - } -} - -// ============================================================================ -// Tests -// ============================================================================ - #[cfg(test)] mod tests { - use super::*; - - #[test] - fn test_mode_default() { - assert_eq!(MmpMode::default(), MmpMode::Full); - } - - #[test] - fn test_mode_display() { - assert_eq!(MmpMode::Full.to_string(), "full"); - assert_eq!(MmpMode::Lightweight.to_string(), "lightweight"); - assert_eq!(MmpMode::Minimal.to_string(), "minimal"); - } - - #[test] - fn test_mode_serde_roundtrip() { - let yaml = "full"; - let mode: MmpMode = serde_yaml::from_str(yaml).unwrap(); - assert_eq!(mode, MmpMode::Full); - - let yaml = "lightweight"; - let mode: MmpMode = serde_yaml::from_str(yaml).unwrap(); - assert_eq!(mode, MmpMode::Lightweight); - - let yaml = "minimal"; - let mode: MmpMode = serde_yaml::from_str(yaml).unwrap(); - assert_eq!(mode, MmpMode::Minimal); - } + use super::MmpConfig; + use crate::proto::mmp::{DEFAULT_LOG_INTERVAL_SECS, DEFAULT_OWD_WINDOW_SIZE, MmpMode}; #[test] fn test_config_default() { diff --git a/src/mmp/receiver.rs b/src/mmp/receiver.rs deleted file mode 100644 index 3c3b5e0..0000000 --- a/src/mmp/receiver.rs +++ /dev/null @@ -1,673 +0,0 @@ -//! MMP receiver state machine. -//! -//! Tracks what this node has received from a specific peer and produces -//! ReceiverReport messages on demand. One `ReceiverState` per active peer. - -use std::time::{Duration, Instant}; - -use crate::mmp::algorithms::{JitterEstimator, OwdTrendDetector}; -use crate::mmp::report::ReceiverReport; -use crate::mmp::{ - COLD_START_SAMPLES, DEFAULT_COLD_START_INTERVAL_MS, DEFAULT_OWD_WINDOW_SIZE, - MAX_REPORT_INTERVAL_MS, MIN_REPORT_INTERVAL_MS, -}; - -/// Grace period after rekey before resuming jitter calculation. -/// -/// During rekey cutover, frames from the old session may still arrive via the -/// drain window (DRAIN_WINDOW_SECS = 10s). These carry large sender timestamps -/// from the old session, producing enormous transit deltas that spike the EWMA -/// jitter estimator. We suppress jitter updates for drain window + 5s margin. -const REKEY_JITTER_GRACE_SECS: u64 = 15; - -// ============================================================================ -// Gap Tracker (burst loss detection) -// ============================================================================ - -/// Tracks counter gaps to detect loss bursts. -/// -/// Each gap in the counter sequence is a burst of lost frames. -/// Maintains per-interval statistics that are reset when a report is built. -struct GapTracker { - /// Next expected counter value. - expected_next: Option, - /// Whether we are currently in a burst (gap). - in_burst: bool, - /// Length of the current burst. - current_burst_len: u16, - - // --- Per-interval stats (reset on report) --- - /// Number of distinct burst events this interval. - burst_count: u32, - /// Longest burst in this interval. - max_burst_len: u16, - /// Sum of all burst lengths (for mean computation). - total_burst_len: u64, -} - -impl GapTracker { - fn new() -> Self { - Self { - expected_next: None, - in_burst: false, - current_burst_len: 0, - burst_count: 0, - max_burst_len: 0, - total_burst_len: 0, - } - } - - /// Process a received counter value. Returns the number of lost frames - /// detected (0 if in order or first frame). - fn observe(&mut self, counter: u64) -> u64 { - let Some(expected) = self.expected_next else { - // First frame: initialize - self.expected_next = Some(counter + 1); - return 0; - }; - - let lost = if counter > expected { - // Gap detected - let gap = counter - expected; - if self.in_burst { - // Extend current burst - self.current_burst_len = self.current_burst_len.saturating_add(gap as u16); - } else { - // New burst - self.in_burst = true; - self.current_burst_len = gap as u16; - self.burst_count += 1; - } - gap - } else { - // In-order or duplicate (counter <= expected) - if self.in_burst { - // End current burst - self.finish_burst(); - } - 0 - }; - - // Update expected (always advance to counter+1 or keep expected if - // this was a late/reordered frame) - if counter >= expected { - self.expected_next = Some(counter + 1); - } - - lost - } - - /// Finish the current burst and record its stats. - fn finish_burst(&mut self) { - if self.in_burst { - self.max_burst_len = self.max_burst_len.max(self.current_burst_len); - self.total_burst_len += self.current_burst_len as u64; - self.in_burst = false; - self.current_burst_len = 0; - } - } - - /// Get interval stats and reset for next interval. - fn take_interval_stats(&mut self) -> (u32, u16, u16) { - // Finish any in-progress burst - self.finish_burst(); - - let count = self.burst_count; - let max_len = self.max_burst_len; - let mean_len = if count > 0 { - // u8.8 fixed-point: (total / count) * 256 - let mean_f = (self.total_burst_len as f64) / (count as f64); - (mean_f * 256.0) as u16 - } else { - 0 - }; - - // Reset interval - self.burst_count = 0; - self.max_burst_len = 0; - self.total_burst_len = 0; - - (count, max_len, mean_len) - } -} - -// ============================================================================ -// ReceiverState -// ============================================================================ - -/// Per-peer receiver-side MMP state. -/// -/// Accumulates per-frame observations and produces `ReceiverReport` snapshots. -pub struct ReceiverState { - // --- Cumulative (lifetime) --- - cumulative_packets_recv: u64, - cumulative_bytes_recv: u64, - cumulative_reorder_count: u64, - - /// Highest counter value ever received. - highest_counter: u64, - - // --- Current interval --- - interval_packets_recv: u32, - interval_bytes_recv: u32, - - // --- Jitter --- - jitter: JitterEstimator, - - // --- OWD trend --- - owd_trend: OwdTrendDetector, - /// Monotonic sequence counter for OWD samples. - owd_seq: u32, - - // --- Loss tracking --- - gap_tracker: GapTracker, - - // --- ECN --- - ecn_ce_count: u32, - - // --- Timestamp echo --- - /// Sender timestamp from the most recent frame (for echo). - last_sender_timestamp: u32, - /// Local time when the most recent frame was received (for dwell computation). - last_recv_time: Option, - - // --- Rekey grace --- - /// When set, jitter updates are suppressed until this instant passes. - /// Prevents drain-window frames from spiking the jitter estimator. - rekey_jitter_grace_until: Option, - - // --- Report timing --- - last_report_time: Option, - report_interval: Duration, - /// Whether any frames have been received since the last report. - interval_has_data: bool, - - // --- Cold-start tracking --- - /// Number of SRTT-based interval updates received. - srtt_sample_count: u32, -} - -impl ReceiverState { - pub fn new(owd_window_size: usize) -> Self { - Self::new_with_cold_start(owd_window_size, DEFAULT_COLD_START_INTERVAL_MS) - } - - /// Create with a custom cold-start interval (ms). - /// - /// Used by session-layer MMP which needs a longer initial interval - /// since reports consume bandwidth on every transit link. - pub fn new_with_cold_start(owd_window_size: usize, cold_start_ms: u64) -> Self { - Self { - cumulative_packets_recv: 0, - cumulative_bytes_recv: 0, - cumulative_reorder_count: 0, - highest_counter: 0, - interval_packets_recv: 0, - interval_bytes_recv: 0, - jitter: JitterEstimator::new(), - owd_trend: OwdTrendDetector::new(owd_window_size), - owd_seq: 0, - gap_tracker: GapTracker::new(), - ecn_ce_count: 0, - last_sender_timestamp: 0, - last_recv_time: None, - rekey_jitter_grace_until: None, - last_report_time: None, - report_interval: Duration::from_millis(cold_start_ms), - interval_has_data: false, - srtt_sample_count: 0, - } - } - - /// Reset counter-dependent state for rekey cutover. - /// - /// After cutover, the new session starts with counter 0 and reset - /// timestamps. Without resetting, the old `highest_counter` and - /// `GapTracker.expected_next` cause false reorder/loss detection. - pub fn reset_for_rekey(&mut self, now: Instant) { - self.highest_counter = 0; - self.cumulative_reorder_count = 0; - self.gap_tracker = GapTracker::new(); - self.interval_packets_recv = 0; - self.interval_bytes_recv = 0; - self.jitter = JitterEstimator::new(); - self.owd_trend.clear(); - self.owd_seq = 0; - self.last_sender_timestamp = 0; - self.last_recv_time = None; - self.rekey_jitter_grace_until = Some(now + Duration::from_secs(REKEY_JITTER_GRACE_SECS)); - self.ecn_ce_count = 0; - self.interval_has_data = false; - // Keep cumulative_packets_recv, cumulative_bytes_recv (lifetime stats) - // Keep last_report_time, report_interval (report scheduling) - } - - /// Record a received frame from this peer. - /// - /// Called on the RX path after AEAD decryption, before message dispatch. - /// - /// - `counter`: AEAD counter from outer header - /// - `sender_timestamp_ms`: session-relative timestamp from inner header (ms) - /// - `bytes`: wire payload size - /// - `ce_flag`: CE bit from flags byte - /// - `now`: current local time - pub fn record_recv( - &mut self, - counter: u64, - sender_timestamp_ms: u32, - bytes: usize, - ce_flag: bool, - now: Instant, - ) { - self.interval_has_data = true; - self.cumulative_packets_recv += 1; - self.cumulative_bytes_recv += bytes as u64; - self.interval_packets_recv = self.interval_packets_recv.saturating_add(1); - self.interval_bytes_recv = self.interval_bytes_recv.saturating_add(bytes as u32); - - // Reordering detection: counter < highest means out-of-order - if counter < self.highest_counter { - self.cumulative_reorder_count += 1; - } else { - self.highest_counter = counter; - } - - // Loss/burst detection - let _lost = self.gap_tracker.observe(counter); - - // ECN - if ce_flag { - self.ecn_ce_count = self.ecn_ce_count.saturating_add(1); - } - - // Jitter: compute transit time delta - // Transit = recv_local - sender_timestamp (in µs for precision) - // We use a monotonic local reference derived from Instant offsets. - let sender_us = (sender_timestamp_ms as i64) * 1000; - // We can't get absolute µs from Instant, but we can compute the delta - // between consecutive transits using relative Instant differences. - // Skip during post-rekey grace period to avoid drain-window spikes. - let in_grace = self - .rekey_jitter_grace_until - .is_some_and(|deadline| now < deadline); - if !in_grace { - self.rekey_jitter_grace_until = None; // clear expired grace - if let Some(prev_recv) = self.last_recv_time { - let recv_delta_us = now.duration_since(prev_recv).as_micros() as i64; - let send_delta_us = sender_us - (self.last_sender_timestamp as i64 * 1000); - let transit_delta = (recv_delta_us - send_delta_us) as i32; - self.jitter.update(transit_delta); - } - } - - // OWD trend: use sender timestamp as a proxy for send time - // and Instant delta from a fixed reference as receive time. - // Since we only need the *trend* (slope), absolute offsets cancel out. - if let Some(first_recv) = self.last_recv_time.or(Some(now)) { - let recv_offset_us = now.duration_since(first_recv).as_micros() as i64; - let owd_us = recv_offset_us - sender_us; - self.owd_seq = self.owd_seq.wrapping_add(1); - self.owd_trend.push(self.owd_seq, owd_us); - } - - // Timestamp echo state - self.last_sender_timestamp = sender_timestamp_ms; - self.last_recv_time = Some(now); - } - - /// Build a ReceiverReport from current state and reset the interval. - /// - /// Returns `None` if no frames have been received since the last report. - pub fn build_report(&mut self, now: Instant) -> Option { - if !self.interval_has_data { - return None; - } - - // Dwell time: ms between last frame reception and report generation. - // If it no longer fits on the wire, the timestamp echo cannot produce - // a valid RTT sample. Preserve the counters but suppress the echo. - let (timestamp_echo, dwell_time) = self - .last_recv_time - .map(|t| { - let dwell_ms = now.duration_since(t).as_millis(); - if dwell_ms > u128::from(u16::MAX) { - (0, u16::MAX) - } else { - (self.last_sender_timestamp, dwell_ms as u16) - } - }) - .unwrap_or((0, 0)); - - let (burst_count, max_burst, mean_burst) = self.gap_tracker.take_interval_stats(); - - let report = ReceiverReport { - highest_counter: self.highest_counter, - cumulative_packets_recv: self.cumulative_packets_recv, - cumulative_bytes_recv: self.cumulative_bytes_recv, - timestamp_echo, - dwell_time, - max_burst_loss: max_burst, - mean_burst_loss: mean_burst, - jitter: self.jitter.jitter_us(), - ecn_ce_count: self.ecn_ce_count, - owd_trend: self.owd_trend.trend_us_per_sec(), - burst_loss_count: burst_count, - cumulative_reorder_count: self.cumulative_reorder_count as u32, - interval_packets_recv: self.interval_packets_recv, - interval_bytes_recv: self.interval_bytes_recv, - }; - - // Reset interval - self.interval_packets_recv = 0; - self.interval_bytes_recv = 0; - self.interval_has_data = false; - self.last_report_time = Some(now); - - Some(report) - } - - /// Check if it's time to send a report. - pub fn should_send_report(&self, now: Instant) -> bool { - if !self.interval_has_data { - return false; - } - match self.last_report_time { - None => true, - Some(last) => now.duration_since(last) >= self.report_interval, - } - } - - /// Update the report interval based on SRTT (link-layer defaults). - /// - /// Receiver reports at 1× SRTT clamped to [floor, MAX]. During cold-start - /// (first `COLD_START_SAMPLES` updates), the floor is the cold-start - /// interval (200ms) for fast SRTT convergence. After that, it rises to - /// `MIN_REPORT_INTERVAL_MS` (1000ms) for steady-state efficiency. - pub fn update_report_interval_from_srtt(&mut self, srtt_us: i64) { - self.srtt_sample_count = self.srtt_sample_count.saturating_add(1); - let floor = if self.srtt_sample_count <= COLD_START_SAMPLES { - DEFAULT_COLD_START_INTERVAL_MS - } else { - MIN_REPORT_INTERVAL_MS - }; - self.update_report_interval_with_bounds(srtt_us, floor, MAX_REPORT_INTERVAL_MS); - } - - /// Update the report interval based on SRTT with custom bounds. - /// - /// Used by session-layer MMP which needs higher clamp values since - /// each report consumes bandwidth on every transit link. - pub fn update_report_interval_with_bounds(&mut self, srtt_us: i64, min_ms: u64, max_ms: u64) { - if srtt_us <= 0 { - return; - } - let interval_ms = ((srtt_us as u64) / 1000).clamp(min_ms, max_ms); - self.report_interval = Duration::from_millis(interval_ms); - } - - // --- Accessors --- - - pub fn cumulative_packets_recv(&self) -> u64 { - self.cumulative_packets_recv - } - - pub fn cumulative_bytes_recv(&self) -> u64 { - self.cumulative_bytes_recv - } - - pub fn highest_counter(&self) -> u64 { - self.highest_counter - } - - pub fn jitter_us(&self) -> u32 { - self.jitter.jitter_us() - } - - pub fn report_interval(&self) -> Duration { - self.report_interval - } - - pub fn last_recv_time(&self) -> Option { - self.last_recv_time - } - - pub fn ecn_ce_count(&self) -> u32 { - self.ecn_ce_count - } -} - -impl Default for ReceiverState { - fn default() -> Self { - Self::new(DEFAULT_OWD_WINDOW_SIZE) - } -} - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_new_receiver_state() { - let r = ReceiverState::new(32); - assert_eq!(r.cumulative_packets_recv(), 0); - assert_eq!(r.cumulative_bytes_recv(), 0); - assert_eq!(r.highest_counter(), 0); - } - - #[test] - fn test_record_recv_basic() { - let mut r = ReceiverState::new(32); - let now = Instant::now(); - r.record_recv(1, 100, 500, false, now); - r.record_recv(2, 200, 600, false, now + Duration::from_millis(100)); - - assert_eq!(r.cumulative_packets_recv(), 2); - assert_eq!(r.cumulative_bytes_recv(), 1100); - assert_eq!(r.highest_counter(), 2); - } - - #[test] - fn test_reorder_detection() { - let mut r = ReceiverState::new(32); - let now = Instant::now(); - r.record_recv(5, 500, 100, false, now); - r.record_recv(3, 300, 100, false, now + Duration::from_millis(10)); - - assert_eq!(r.cumulative_reorder_count, 1); - assert_eq!(r.highest_counter(), 5); // not changed by out-of-order - } - - #[test] - fn test_ecn_counting() { - let mut r = ReceiverState::new(32); - let now = Instant::now(); - r.record_recv(1, 100, 100, true, now); - r.record_recv(2, 200, 100, false, now); - r.record_recv(3, 300, 100, true, now); - - assert_eq!(r.ecn_ce_count, 2); - } - - #[test] - fn test_build_report_empty() { - let mut r = ReceiverState::new(32); - assert!(r.build_report(Instant::now()).is_none()); - } - - #[test] - fn test_build_report() { - let mut r = ReceiverState::new(32); - let t0 = Instant::now(); - r.record_recv(1, 100, 500, false, t0); - r.record_recv(2, 200, 600, false, t0 + Duration::from_millis(100)); - - let report = r.build_report(t0 + Duration::from_millis(150)).unwrap(); - assert_eq!(report.highest_counter, 2); - assert_eq!(report.cumulative_packets_recv, 2); - assert_eq!(report.cumulative_bytes_recv, 1100); - assert_eq!(report.timestamp_echo, 200); // last sender timestamp - assert_eq!(report.interval_packets_recv, 2); - assert_eq!(report.interval_bytes_recv, 1100); - } - - #[test] - fn test_build_report_suppresses_rtt_echo_when_dwell_overflows() { - let mut r = ReceiverState::new(32); - let t0 = Instant::now(); - r.record_recv(1, 100, 500, false, t0); - - let report = r - .build_report(t0 + Duration::from_millis(u64::from(u16::MAX) + 1)) - .unwrap(); - - assert_eq!(report.timestamp_echo, 0); - assert_eq!(report.dwell_time, u16::MAX); - assert_eq!(report.cumulative_packets_recv, 1); - } - - #[test] - fn test_build_report_resets_interval() { - let mut r = ReceiverState::new(32); - let t0 = Instant::now(); - r.record_recv(1, 100, 500, false, t0); - let _ = r.build_report(t0); - - // No new data - assert!(r.build_report(t0).is_none()); - - // New data - r.record_recv(2, 200, 300, false, t0 + Duration::from_millis(100)); - let report = r.build_report(t0 + Duration::from_millis(150)).unwrap(); - assert_eq!(report.interval_packets_recv, 1); - assert_eq!(report.interval_bytes_recv, 300); - // Cumulative continues - assert_eq!(report.cumulative_packets_recv, 2); - } - - #[test] - fn test_gap_tracker_no_loss() { - let mut g = GapTracker::new(); - g.observe(1); - g.observe(2); - g.observe(3); - let (count, max, mean) = g.take_interval_stats(); - assert_eq!(count, 0); - assert_eq!(max, 0); - assert_eq!(mean, 0); - } - - #[test] - fn test_gap_tracker_single_burst() { - let mut g = GapTracker::new(); - g.observe(1); - // frames 2, 3 lost - g.observe(4); - g.observe(5); - let (count, max, _mean) = g.take_interval_stats(); - assert_eq!(count, 1); - assert_eq!(max, 2); - } - - #[test] - fn test_gap_tracker_multiple_bursts() { - let mut g = GapTracker::new(); - g.observe(1); - g.observe(4); // burst of 2 (frames 2,3 lost) - g.observe(5); - g.observe(8); // burst of 2 (frames 6,7 lost) - g.observe(9); - let (count, max, mean) = g.take_interval_stats(); - assert_eq!(count, 2); - assert_eq!(max, 2); - // mean = 2.0 in u8.8 = 512 - assert_eq!(mean, 512); - } - - #[test] - fn test_should_send_report_timing() { - let mut r = ReceiverState::new(32); - let t0 = Instant::now(); - - assert!(!r.should_send_report(t0)); // no data - - r.record_recv(1, 100, 500, false, t0); - assert!(r.should_send_report(t0)); // first time, has data - - let _ = r.build_report(t0); - r.record_recv(2, 200, 500, false, t0); - assert!(!r.should_send_report(t0)); // just reported - - let t1 = t0 + r.report_interval() + Duration::from_millis(1); - assert!(r.should_send_report(t1)); - } - - #[test] - fn test_update_report_interval_cold_start() { - let mut r = ReceiverState::new(32); - // During cold-start, floor is 200ms (DEFAULT_COLD_START_INTERVAL_MS) - // 50ms SRTT → 50ms receiver interval (1× SRTT), clamped to cold-start floor 200ms - r.update_report_interval_from_srtt(50_000); - assert_eq!(r.report_interval(), Duration::from_millis(200)); - - // 500ms SRTT → 500ms (above cold-start floor) - r.update_report_interval_from_srtt(500_000); - assert_eq!(r.report_interval(), Duration::from_millis(500)); - } - - #[test] - fn test_update_report_interval_after_cold_start() { - let mut r = ReceiverState::new(32); - // Burn through cold-start samples - for _ in 0..COLD_START_SAMPLES { - r.update_report_interval_from_srtt(500_000); - } - - // 6th sample: steady state, floor is MIN_REPORT_INTERVAL_MS (1000ms) - // 50ms SRTT → 50ms receiver interval (1× SRTT), clamped to 1000ms - r.update_report_interval_from_srtt(50_000); - assert_eq!( - r.report_interval(), - Duration::from_millis(MIN_REPORT_INTERVAL_MS) - ); - - // 3s SRTT → 3000ms, within [1000, 5000] - r.update_report_interval_from_srtt(3_000_000); - assert_eq!(r.report_interval(), Duration::from_millis(3000)); - } - - #[test] - fn test_rekey_jitter_grace_suppresses_spikes() { - let mut r = ReceiverState::new(32); - let t0 = Instant::now(); - - // Establish baseline with two frames so jitter starts updating - r.record_recv(1, 1000, 100, false, t0); - r.record_recv(2, 2000, 100, false, t0 + Duration::from_secs(1)); - assert_eq!(r.jitter_us(), 0); // perfect 1s spacing → 0 jitter - - // Simulate rekey: reset, then send a frame with a large old-session - // timestamp followed by a new-session timestamp near zero. - // Without grace, this would produce a huge jitter spike. - r.reset_for_rekey(t0 + Duration::from_secs(2)); - - // Frame arrives during grace period with old-session timestamp - r.record_recv(0, 120_000, 100, false, t0 + Duration::from_secs(3)); - // Next frame with new-session timestamp near zero - r.record_recv(1, 100, 100, false, t0 + Duration::from_secs(4)); - // Jitter should still be zero — updates suppressed during grace - assert_eq!(r.jitter_us(), 0); - - // After grace expires, jitter updates resume - let after_grace = - t0 + Duration::from_secs(2) + Duration::from_secs(REKEY_JITTER_GRACE_SECS + 1); - r.record_recv(2, 200, 100, false, after_grace); - r.record_recv(3, 300, 100, false, after_grace + Duration::from_millis(100)); - // Now jitter should be updating (non-zero or zero depending on timing) - // The key assertion is that it's not a multi-second spike - assert!(r.jitter_us() < 1_000_000); // less than 1 second - } -} diff --git a/src/mmp/sender.rs b/src/mmp/sender.rs deleted file mode 100644 index ac7e0a6..0000000 --- a/src/mmp/sender.rs +++ /dev/null @@ -1,418 +0,0 @@ -//! MMP sender state machine. -//! -//! Tracks what this node has sent to a specific peer and produces -//! SenderReport messages on demand. One `SenderState` per active peer. - -use std::time::{Duration, Instant}; - -use crate::mmp::report::SenderReport; -use crate::mmp::{ - COLD_START_SAMPLES, DEFAULT_COLD_START_INTERVAL_MS, MAX_REPORT_INTERVAL_MS, - MIN_REPORT_INTERVAL_MS, -}; - -/// Per-peer sender-side MMP state. -/// -/// Records cumulative and interval counters for every frame transmitted -/// to this peer. Produces `SenderReport` snapshots on demand. -pub struct SenderState { - // --- Cumulative (lifetime) --- - cumulative_packets_sent: u64, - cumulative_bytes_sent: u64, - - // --- Current interval --- - interval_start_counter: u64, - interval_start_timestamp: u32, - interval_bytes_sent: u32, - /// Counter of the most recently sent frame. - last_counter: u64, - /// Timestamp of the most recently sent frame. - last_timestamp: u32, - /// Whether any frames have been sent in the current interval. - interval_has_data: bool, - - // --- Report timing --- - last_report_time: Option, - report_interval: Duration, - - // --- Send failure backoff --- - /// Consecutive send failure count for backoff calculation. - consecutive_send_failures: u32, - - // --- Cold-start tracking --- - /// Number of SRTT-based interval updates received. - srtt_sample_count: u32, -} - -impl SenderState { - pub fn new() -> Self { - Self::new_with_cold_start(DEFAULT_COLD_START_INTERVAL_MS) - } - - /// Create with a custom cold-start interval (ms). - /// - /// Used by session-layer MMP which needs a longer initial interval - /// since reports consume bandwidth on every transit link. - pub fn new_with_cold_start(cold_start_ms: u64) -> Self { - Self { - cumulative_packets_sent: 0, - cumulative_bytes_sent: 0, - interval_start_counter: 0, - interval_start_timestamp: 0, - interval_bytes_sent: 0, - last_counter: 0, - last_timestamp: 0, - interval_has_data: false, - last_report_time: None, - report_interval: Duration::from_millis(cold_start_ms), - consecutive_send_failures: 0, - srtt_sample_count: 0, - } - } - - /// Record a frame sent to this peer. - /// - /// Called on the TX path for every encrypted link message. - /// `counter` is the AEAD nonce/counter, `timestamp` is the inner header - /// session-relative timestamp (ms), `bytes` is the wire payload size. - pub fn record_sent(&mut self, counter: u64, timestamp: u32, bytes: usize) { - if !self.interval_has_data { - self.interval_start_counter = counter; - self.interval_start_timestamp = timestamp; - self.interval_has_data = true; - } - self.last_counter = counter; - self.last_timestamp = timestamp; - self.interval_bytes_sent = self.interval_bytes_sent.saturating_add(bytes as u32); - self.cumulative_packets_sent += 1; - self.cumulative_bytes_sent += bytes as u64; - } - - /// Build a SenderReport from current state and reset the interval. - /// - /// Returns `None` if no frames have been sent since the last report. - pub fn build_report(&mut self, now: Instant) -> Option { - if !self.interval_has_data { - return None; - } - - let report = SenderReport { - interval_start_counter: self.interval_start_counter, - interval_end_counter: self.last_counter, - interval_start_timestamp: self.interval_start_timestamp, - interval_end_timestamp: self.last_timestamp, - interval_bytes_sent: self.interval_bytes_sent, - cumulative_packets_sent: self.cumulative_packets_sent, - cumulative_bytes_sent: self.cumulative_bytes_sent, - }; - - // Reset interval - self.interval_has_data = false; - self.interval_bytes_sent = 0; - self.last_report_time = Some(now); - - Some(report) - } - - /// Check if it's time to send a report. - /// - /// When consecutive send failures have occurred, the effective interval - /// is multiplied by an exponential backoff factor (2^failures, capped at 32×). - pub fn should_send_report(&self, now: Instant) -> bool { - if !self.interval_has_data { - return false; - } - match self.last_report_time { - None => true, // Never sent a report — send immediately - Some(last) => { - let effective = self - .report_interval - .mul_f64(self.send_failure_backoff_multiplier()); - now.duration_since(last) >= effective - } - } - } - - /// Record a send failure. Returns the new consecutive failure count. - pub fn record_send_failure(&mut self) -> u32 { - self.consecutive_send_failures += 1; - self.consecutive_send_failures - } - - /// Record a successful send. Returns the previous failure count (for summary logging). - pub fn record_send_success(&mut self) -> u32 { - let prev = self.consecutive_send_failures; - self.consecutive_send_failures = 0; - prev - } - - /// Get the backoff multiplier based on consecutive failures. - /// - /// Returns 1.0 for no failures, 2.0 for 1 failure, 4.0 for 2, ... - /// capped at 32.0 (5 failures). - pub fn send_failure_backoff_multiplier(&self) -> f64 { - if self.consecutive_send_failures == 0 { - 1.0 - } else { - 2.0_f64.powi(self.consecutive_send_failures.min(5) as i32) - } - } - - /// Update the report interval based on SRTT (link-layer defaults). - /// - /// Sender reports at 2× SRTT clamped to [floor, MAX]. During cold-start - /// (first `COLD_START_SAMPLES` updates), the floor is the cold-start - /// interval (200ms) for fast SRTT convergence. After that, it rises to - /// `MIN_REPORT_INTERVAL_MS` (1000ms) for steady-state efficiency. - pub fn update_report_interval_from_srtt(&mut self, srtt_us: i64) { - self.srtt_sample_count = self.srtt_sample_count.saturating_add(1); - let floor = if self.srtt_sample_count <= COLD_START_SAMPLES { - DEFAULT_COLD_START_INTERVAL_MS - } else { - MIN_REPORT_INTERVAL_MS - }; - self.update_report_interval_with_bounds(srtt_us, floor, MAX_REPORT_INTERVAL_MS); - } - - /// Update the report interval based on SRTT with custom bounds. - /// - /// Used by session-layer MMP which needs higher clamp values since - /// each report consumes bandwidth on every transit link. - pub fn update_report_interval_with_bounds(&mut self, srtt_us: i64, min_ms: u64, max_ms: u64) { - if srtt_us <= 0 { - return; - } - let interval_us = (srtt_us * 2) as u64; - let interval_ms = (interval_us / 1000).clamp(min_ms, max_ms); - self.report_interval = Duration::from_millis(interval_ms); - } - - // --- Accessors --- - - pub fn cumulative_packets_sent(&self) -> u64 { - self.cumulative_packets_sent - } - - pub fn cumulative_bytes_sent(&self) -> u64 { - self.cumulative_bytes_sent - } - - pub fn report_interval(&self) -> Duration { - self.report_interval - } - - pub fn consecutive_send_failures(&self) -> u32 { - self.consecutive_send_failures - } -} - -impl Default for SenderState { - fn default() -> Self { - Self::new() - } -} - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_new_sender_state() { - let s = SenderState::new(); - assert_eq!(s.cumulative_packets_sent(), 0); - assert_eq!(s.cumulative_bytes_sent(), 0); - } - - #[test] - fn test_record_sent() { - let mut s = SenderState::new(); - s.record_sent(1, 100, 500); - s.record_sent(2, 200, 600); - assert_eq!(s.cumulative_packets_sent(), 2); - assert_eq!(s.cumulative_bytes_sent(), 1100); - } - - #[test] - fn test_build_report_empty() { - let mut s = SenderState::new(); - assert!(s.build_report(Instant::now()).is_none()); - } - - #[test] - fn test_build_report() { - let mut s = SenderState::new(); - s.record_sent(10, 1000, 500); - s.record_sent(11, 1100, 600); - s.record_sent(12, 1200, 400); - - let report = s.build_report(Instant::now()).unwrap(); - assert_eq!(report.interval_start_counter, 10); - assert_eq!(report.interval_end_counter, 12); - assert_eq!(report.interval_start_timestamp, 1000); - assert_eq!(report.interval_end_timestamp, 1200); - assert_eq!(report.interval_bytes_sent, 1500); - assert_eq!(report.cumulative_packets_sent, 3); - assert_eq!(report.cumulative_bytes_sent, 1500); - } - - #[test] - fn test_build_report_resets_interval() { - let mut s = SenderState::new(); - s.record_sent(1, 100, 500); - let _ = s.build_report(Instant::now()); - - // Second report with no new data returns None - assert!(s.build_report(Instant::now()).is_none()); - - // New data starts a fresh interval - s.record_sent(2, 200, 300); - let report = s.build_report(Instant::now()).unwrap(); - assert_eq!(report.interval_start_counter, 2); - assert_eq!(report.interval_bytes_sent, 300); - // Cumulative continues - assert_eq!(report.cumulative_packets_sent, 2); - assert_eq!(report.cumulative_bytes_sent, 800); - } - - #[test] - fn test_should_send_report_no_data() { - let s = SenderState::new(); - assert!(!s.should_send_report(Instant::now())); - } - - #[test] - fn test_should_send_report_first_time() { - let mut s = SenderState::new(); - s.record_sent(1, 100, 500); - assert!(s.should_send_report(Instant::now())); - } - - #[test] - fn test_should_send_report_respects_interval() { - let mut s = SenderState::new(); - let t0 = Instant::now(); - s.record_sent(1, 100, 500); - let _ = s.build_report(t0); - - s.record_sent(2, 200, 500); - // Immediately after report — should not send - assert!(!s.should_send_report(t0)); - - // After interval elapses - let t1 = t0 + s.report_interval() + Duration::from_millis(1); - assert!(s.should_send_report(t1)); - } - - #[test] - fn test_update_report_interval_cold_start() { - let mut s = SenderState::new(); - // During cold-start, floor is 200ms (DEFAULT_COLD_START_INTERVAL_MS) - // 50ms RTT → 100ms sender interval (2× SRTT), clamped to cold-start floor 200ms - s.update_report_interval_from_srtt(50_000); - assert_eq!(s.report_interval(), Duration::from_millis(200)); - - // 500ms RTT → 1000ms sender interval (above cold-start floor) - s.update_report_interval_from_srtt(500_000); - assert_eq!(s.report_interval(), Duration::from_millis(1000)); - } - - #[test] - fn test_update_report_interval_after_cold_start() { - let mut s = SenderState::new(); - // Burn through cold-start samples (COLD_START_SAMPLES = 5) - for _ in 0..COLD_START_SAMPLES { - s.update_report_interval_from_srtt(500_000); - } - - // 6th sample: now in steady state, floor is MIN_REPORT_INTERVAL_MS (1000ms) - // 50ms RTT → 100ms sender interval (2× SRTT), clamped to 1000ms - s.update_report_interval_from_srtt(50_000); - assert_eq!( - s.report_interval(), - Duration::from_millis(MIN_REPORT_INTERVAL_MS) - ); - - // 3s RTT → 6s, clamped to max 5s - s.update_report_interval_from_srtt(3_000_000); - assert_eq!( - s.report_interval(), - Duration::from_millis(MAX_REPORT_INTERVAL_MS) - ); - } - - #[test] - fn test_backoff_multiplier_progression() { - let mut s = SenderState::new(); - - // No failures → multiplier 1.0 - assert_eq!(s.send_failure_backoff_multiplier(), 1.0); - assert_eq!(s.consecutive_send_failures(), 0); - - // Progressive failures: 2^1, 2^2, 2^3, 2^4, 2^5 - let expected = [2.0, 4.0, 8.0, 16.0, 32.0]; - for (i, &exp) in expected.iter().enumerate() { - let count = s.record_send_failure(); - assert_eq!(count, (i + 1) as u32); - assert_eq!(s.send_failure_backoff_multiplier(), exp); - } - - // Beyond 5 failures: stays capped at 32.0 - s.record_send_failure(); // 6th - assert_eq!(s.send_failure_backoff_multiplier(), 32.0); - s.record_send_failure(); // 7th - assert_eq!(s.send_failure_backoff_multiplier(), 32.0); - } - - #[test] - fn test_backoff_reset_on_success() { - let mut s = SenderState::new(); - - // Accumulate failures - s.record_send_failure(); - s.record_send_failure(); - s.record_send_failure(); - assert_eq!(s.consecutive_send_failures(), 3); - assert_eq!(s.send_failure_backoff_multiplier(), 8.0); - - // Success resets and returns previous count - let prev = s.record_send_success(); - assert_eq!(prev, 3); - assert_eq!(s.consecutive_send_failures(), 0); - assert_eq!(s.send_failure_backoff_multiplier(), 1.0); - } - - #[test] - fn test_backoff_success_with_no_prior_failures() { - let mut s = SenderState::new(); - - // Success with no failures returns 0 - let prev = s.record_send_success(); - assert_eq!(prev, 0); - assert_eq!(s.consecutive_send_failures(), 0); - } - - #[test] - fn test_should_send_report_respects_backoff() { - let mut s = SenderState::new(); - let t0 = Instant::now(); - s.record_sent(1, 100, 500); - let _ = s.build_report(t0); - - // Record a failure: multiplier becomes 2.0 - s.record_send_failure(); - - s.record_sent(2, 200, 500); - - // At 1× interval: should NOT send (backoff requires 2×) - let t1 = t0 + s.report_interval() + Duration::from_millis(1); - assert!(!s.should_send_report(t1)); - - // At 2× interval: should send - let t2 = t0 + s.report_interval() * 2 + Duration::from_millis(1); - assert!(s.should_send_report(t2)); - } -} diff --git a/src/node/handlers/encrypted.rs b/src/node/handlers/encrypted.rs index 765f964..3df8a43 100644 --- a/src/node/handlers/encrypted.rs +++ b/src/node/handlers/encrypted.rs @@ -4,7 +4,6 @@ use crate::node::Node; use crate::node::wire::{EncryptedHeader, FLAG_CE, FLAG_KEY_EPOCH, FLAG_SP, strip_inner_header}; use crate::noise::NoiseError; use crate::transport::ReceivedPacket; -use std::time::Instant; use tracing::{debug, trace, warn}; /// Force-remove a peer after this many consecutive decryption failures. @@ -259,7 +258,7 @@ impl Node { }; // MMP per-frame processing and statistics - let now = Instant::now(); + let now_ms = crate::mmp::mono_ms(); let ce_flag = header.flags & FLAG_CE != 0; let sp_flag = header.flags & FLAG_SP != 0; @@ -270,9 +269,9 @@ impl Node { timestamp, packet.data.len(), ce_flag, - now, + now_ms, ); - let _spin_rtt = mmp.spin_bit.rx_observe(sp_flag, header.counter, now); + let _spin_rtt = mmp.spin_bit.rx_observe(sp_flag, header.counter, now_ms); } peer.set_current_addr(packet.transport_id, packet.remote_addr.clone()); peer.link_stats_mut() @@ -355,7 +354,7 @@ impl Node { } else { return; }; - let now = Instant::now(); + let now_ms = crate::mmp::mono_ms(); let mut address_changed = false; if let Some(peer) = self.peers.get_mut(node_addr) { peer.reset_decrypt_failures(); @@ -365,8 +364,8 @@ impl Node { peer.touch(packet_timestamp_ms); if let Some(mmp) = peer.mmp_mut() { mmp.receiver - .record_recv(fmp_counter, inner_ts, packet_len, ce_flag, now); - let _spin_rtt = mmp.spin_bit.rx_observe(sp_flag, fmp_counter, now); + .record_recv(fmp_counter, inner_ts, packet_len, ce_flag, now_ms); + let _spin_rtt = mmp.spin_bit.rx_observe(sp_flag, fmp_counter, now_ms); } } // Address rotation invalidates the per-peer connect()-ed UDP diff --git a/src/node/handlers/mmp.rs b/src/node/handlers/mmp.rs index 74b89f2..378e21d 100644 --- a/src/node/handlers/mmp.rs +++ b/src/node/handlers/mmp.rs @@ -5,18 +5,57 @@ //! and teardown metric logs. use crate::NodeAddr; -use crate::mmp::MmpMode; -use crate::mmp::MmpSessionState; -use crate::mmp::report::{ReceiverReport, SenderReport}; use crate::node::Node; use crate::node::reject::{MmpReject, RejectReason, TreeReject}; -use crate::protocol::{ - LinkMessageType, PathMtuNotification, SessionMessageType, SessionReceiverReport, - SessionSenderReport, +use crate::proto::mmp::{ + BackoffUpdate, LinkReportKind, LinkReportSnapshot, MmpAction, MmpSessionState, + PathMtuNotification, PeerLivenessSnapshot, ReceiverReport, RrLog, SendResult, SenderReport, + SessionReceiverReport, SessionReportKind, SessionReportSnapshot, SessionSenderReport, }; +use crate::protocol::{LinkMessageType, SessionMessageType}; use std::time::{Duration, Instant}; use tracing::{debug, info, trace, warn}; +/// Emit the operator `trace!` point for a processed ReceiverReport outcome. +/// +/// These log points used to live inside `MmpMetrics::process_receiver_report`; +/// the sans-IO migration returns the outcome as an [`RrLog`] and re-emits it +/// here, shell-side, preserving the original field set, content, and (relative +/// to the surrounding handler logs) ordering. The original traces carried no +/// peer identifier, so none is added here. +pub(super) fn log_rr_outcome(rr: &ReceiverReport, our_timestamp_ms: u32, log: RrLog) { + match log { + RrLog::Stale { + prev_highest, + prev_packets, + prev_bytes, + } => trace!( + highest_counter = rr.highest_counter, + prev_highest_counter = prev_highest, + cumulative_packets_recv = rr.cumulative_packets_recv, + prev_cumulative_packets_recv = prev_packets, + cumulative_bytes_recv = rr.cumulative_bytes_recv, + prev_cumulative_bytes_recv = prev_bytes, + "Ignoring stale MMP ReceiverReport" + ), + RrLog::RttSample { rtt_ms, srtt_ms } => trace!( + our_ts = our_timestamp_ms, + echo = rr.timestamp_echo, + dwell = u32::from(rr.dwell_time), + rtt_ms = rtt_ms, + srtt_ms = srtt_ms, + "RTT sample from timestamp echo" + ), + RrLog::InvalidRtt => trace!( + our_ts = our_timestamp_ms, + echo = rr.timestamp_echo, + dwell = u32::from(rr.dwell_time), + "Ignoring invalid MMP RTT sample" + ), + RrLog::None => {} + } +} + /// Format bytes/sec as human-readable throughput. fn format_throughput(bps: f64) -> String { if bps == 0.0 { @@ -113,10 +152,12 @@ impl Node { // Process the report: computes RTT from timestamp echo, updates // loss rate, goodput rate, jitter trend, and ETX. - let now = Instant::now(); - let first_rtt = mmp - .metrics - .process_receiver_report(&rr, our_timestamp_ms, now); + let now_ms = crate::mmp::mono_ms(); + let (first_rtt, rr_log) = + mmp.metrics + .process_receiver_report(&rr, our_timestamp_ms, now_ms); + // Re-emit the operator trace the core used to log mid-decision. + log_rr_outcome(&rr, our_timestamp_ms, rr_log); // Feed SRTT back to sender/receiver report interval tuning if let Some(srtt_ms) = mmp.metrics.srtt_ms() { @@ -223,65 +264,91 @@ impl Node { /// /// Called from the tick handler. Also emits periodic operator logs. pub(in crate::node) async fn check_mmp_reports(&mut self) { - let now = Instant::now(); + let now_ms = crate::mmp::mono_ms(); - // Collect peers that need reports (can't borrow self mutably while iterating) - let mut sender_reports: Vec<(NodeAddr, Vec)> = Vec::new(); - let mut receiver_reports: Vec<(NodeAddr, Vec)> = Vec::new(); + // Build one report-gating snapshot per peer, resolving every timing read + // shell-side into a `bool`. `send_sr`/`send_rr` are `true` on the master + // (IK) line — there is no profile negotiation here; the forward-merge to + // `-next` wires them to `peer.send_sr()`/`peer.send_rr()` (plan spot c). + // The snapshots own only `NodeAddr`/`MmpMode`/`bool`, so the + // peer-iteration borrow is released before the pure decision runs and the + // driving loop mutates the reporting state. + let snapshots: Vec = self + .peers + .iter() + .filter_map(|(node_addr, peer)| { + let mmp = peer.mmp()?; + Some(LinkReportSnapshot { + peer: *node_addr, + mode: mmp.mode(), + send_sr: true, + send_rr: true, + sr_due: mmp.sender.should_send_report(now_ms), + rr_due: mmp.receiver.should_send_report(now_ms), + log_due: mmp.should_log(now_ms), + }) + }) + .collect(); - for (node_addr, peer) in self.peers.iter_mut() { - // Compute display name before taking mutable MMP borrow - let peer_name = self - .peer_aliases - .get(node_addr) - .cloned() - .unwrap_or_else(|| peer.identity().short_npub()); + let actions = self.mmp.plan_link_reports(&snapshots); - let Some(mmp) = peer.mmp_mut() else { - continue; - }; - - let mode = mmp.mode(); - - // Sender reports: Full mode only - if mode == MmpMode::Full - && mmp.sender.should_send_report(now) - && let Some(sr) = mmp.sender.build_report(now) - { - sender_reports.push((*node_addr, sr.encode())); - } - - // Receiver reports: Full and Lightweight modes - if mode != MmpMode::Minimal - && mmp.receiver.should_send_report(now) - && let Some(rr) = mmp.receiver.build_report(now) - { - receiver_reports.push((*node_addr, rr.encode())); - } - - // Periodic operator logging - if mmp.should_log(now) { - Self::log_mmp_metrics(&peer_name, mmp); - mmp.mark_logged(now); - } - } - - // Send collected reports - for (node_addr, encoded) in sender_reports { - if let Err(e) = self.send_encrypted_link_message(&node_addr, &encoded).await { - debug!(peer = %self.peer_display_name(&node_addr), error = %e, "Failed to send SenderReport"); - } - } - - for (node_addr, encoded) in receiver_reports { - if let Err(e) = self.send_encrypted_link_message(&node_addr, &encoded).await { - debug!(peer = %self.peer_display_name(&node_addr), error = %e, "Failed to send ReceiverReport"); + // Drive the planned actions in their phase-grouped order (all logs, then + // all SenderReports, then all ReceiverReports). Logs run first because the + // operator log reads cumulative_packets_sent, which each report send + // advances (send_encrypted_link_message -> sender.record_sent); the + // pre-refactor handler logged during its collect pass, before any send. + // `build_report` (which advances the interval state) is called only on a + // SendLinkReport action, exactly as the pre-refactor gate did. + for action in actions { + match action { + MmpAction::SendLinkReport { peer, kind } => { + let encoded = self + .peers + .get_mut(&peer) + .and_then(|p| p.mmp_mut()) + .and_then(|mmp| match kind { + LinkReportKind::Sender => { + mmp.sender.build_report(now_ms).map(|sr| sr.encode()) + } + LinkReportKind::Receiver => { + mmp.receiver.build_report(now_ms).map(|rr| rr.encode()) + } + }); + if let Some(encoded) = encoded + && let Err(e) = self.send_encrypted_link_message(&peer, &encoded).await + { + let label = match kind { + LinkReportKind::Sender => "Failed to send SenderReport", + LinkReportKind::Receiver => "Failed to send ReceiverReport", + }; + debug!(peer = %self.peer_display_name(&peer), error = %e, "{}", label); + } + } + MmpAction::LogLink { peer } => { + // Resolve the display name exactly as the pre-refactor loop + // did (alias, else short_npub) — not `peer_display_name`, + // which also consults the host map. + let peer_name = self.peer_aliases.get(&peer).cloned().unwrap_or_else(|| { + self.peers + .get(&peer) + .map(|p| p.identity().short_npub()) + .unwrap_or_default() + }); + if let Some(mmp) = self.peers.get_mut(&peer).and_then(|p| p.mmp_mut()) { + Self::log_mmp_metrics(&peer_name, mmp); + mmp.mark_logged(now_ms); + } + } + MmpAction::ReapPeer { .. } + | MmpAction::Heartbeat { .. } + | MmpAction::SendSessionReport { .. } + | MmpAction::LogSession { .. } => {} } } } /// Emit periodic MMP metrics for a peer. - fn log_mmp_metrics(peer_name: &str, mmp: &crate::mmp::MmpPeerState) { + fn log_mmp_metrics(peer_name: &str, mmp: &crate::proto::mmp::MmpPeerState) { let m = &mmp.metrics; let rtt_str = if m.rtt_trend.initialized() { @@ -309,7 +376,10 @@ impl Node { } /// Emit a teardown log summarizing lifetime MMP metrics for a removed peer. - pub(in crate::node) fn log_mmp_teardown(peer_name: &str, mmp: &crate::mmp::MmpPeerState) { + pub(in crate::node) fn log_mmp_teardown( + peer_name: &str, + mmp: &crate::proto::mmp::MmpPeerState, + ) { let m = &mmp.metrics; let jitter_ms = mmp.receiver.jitter_us() as f64 / 1000.0; @@ -341,136 +411,154 @@ impl Node { /// Called from the tick handler. Also emits periodic session MMP logs. /// Uses the collect-then-send pattern to avoid borrowing conflicts. pub(in crate::node) async fn check_session_mmp_reports(&mut self) { - let now = Instant::now(); + let now_ms = crate::mmp::mono_ms(); - // Collect reports to send: (dest_addr, msg_type, encoded_body) - let mut reports: Vec<(NodeAddr, u8, Vec)> = Vec::new(); + // Build one report-gating snapshot per session, resolving every timing + // read shell-side into a `bool`. The snapshots own only + // `NodeAddr`/`MmpMode`/`bool`, so the session-iteration borrow is released + // before the pure decision runs and the driving loop mutates the + // reporting state / performs the sends. + let snapshots: Vec = self + .sessions + .iter() + .filter_map(|(dest_addr, entry)| { + let mmp = entry.mmp()?; + Some(SessionReportSnapshot { + dest: *dest_addr, + mode: mmp.mode(), + sr_due: mmp.sender.should_send_report(now_ms), + rr_due: mmp.receiver.should_send_report(now_ms), + mtu_due: mmp.path_mtu.should_send_notification(now_ms), + log_due: mmp.should_log(now_ms), + }) + }) + .collect(); - for (dest_addr, entry) in self.sessions.iter_mut() { - // Compute display name before taking mutable MMP borrow - let session_name = self - .peer_aliases - .get(dest_addr) - .cloned() - .unwrap_or_else(|| { - let (xonly, _) = entry.remote_pubkey().x_only_public_key(); - crate::PeerIdentity::from_pubkey(xonly).short_npub() - }); + let actions = self.mmp.plan_session_reports(&snapshots); - let Some(mmp) = entry.mmp_mut() else { - continue; - }; - - let mode = mmp.mode(); - - // Sender reports: Full mode only - if mode == MmpMode::Full - && mmp.sender.should_send_report(now) - && let Some(sr) = mmp.sender.build_report(now) - { - let session_sr: SessionSenderReport = SessionSenderReport::from(&sr); - reports.push(( - *dest_addr, - SessionMessageType::SenderReport.to_byte(), - session_sr.encode(), - )); - } - - // Receiver reports: Full and Lightweight modes - if mode != MmpMode::Minimal - && mmp.receiver.should_send_report(now) - && let Some(rr) = mmp.receiver.build_report(now) - { - let session_rr: SessionReceiverReport = SessionReceiverReport::from(&rr); - reports.push(( - *dest_addr, - SessionMessageType::ReceiverReport.to_byte(), - session_rr.encode(), - )); - } - - // PathMtu notifications (all modes) - if mmp.path_mtu.should_send_notification(now) - && let Some(mtu_value) = mmp.path_mtu.build_notification(now) - { - let notif = PathMtuNotification::new(mtu_value); - reports.push(( - *dest_addr, - SessionMessageType::PathMtuNotification.to_byte(), - notif.encode(), - )); - } - - // Periodic operator logging - if mmp.should_log(now) { - Self::log_session_mmp_metrics(&session_name, mmp); - mmp.mark_logged(now); - } - } - - // Send collected reports via session-layer encryption. - // Track per-destination success/failure for backoff and log suppression. - let mut send_results: Vec<(NodeAddr, bool)> = Vec::new(); - for (dest_addr, msg_type, body) in reports { - match self.send_session_msg(&dest_addr, msg_type, &body).await { - Ok(()) => { - send_results.push((dest_addr, true)); + // Drive the planned actions in phase-grouped order (all logs, then the + // sends in per-session SR/RR/MTU order). Logs run first because the + // session operator log reads cumulative_packets_sent, which each send + // advances (send_session_msg -> sender.record_sent); the pre-refactor + // handler logged during its collect pass, before any send. Each build + // (`build_report`/`build_notification`, which advance interval/ + // notification state) runs only on its SendSessionReport action, exactly + // as the pre-refactor collect pass did. Per-destination success/failure + // is collected for the backoff dedup + failure-log suppression. + let mut send_results: Vec = Vec::new(); + for action in actions { + match action { + MmpAction::LogSession { dest } => { + // Resolve the display name exactly as the pre-refactor loop + // did (alias, else short_npub from the session's remote key). + let session_name = self.peer_aliases.get(&dest).cloned().unwrap_or_else(|| { + self.sessions + .get(&dest) + .map(|entry| { + let (xonly, _) = entry.remote_pubkey().x_only_public_key(); + crate::PeerIdentity::from_pubkey(xonly).short_npub() + }) + .unwrap_or_default() + }); + if let Some(mmp) = self.sessions.get_mut(&dest).and_then(|e| e.mmp_mut()) { + Self::log_session_mmp_metrics(&session_name, mmp); + mmp.mark_logged(now_ms); + } } - Err(e) => { - // Peek at current failure count for log suppression - let failures = self + MmpAction::SendSessionReport { dest, kind } => { + let built = self .sessions - .get(&dest_addr) - .and_then(|entry| entry.mmp()) - .map(|mmp| mmp.sender.consecutive_send_failures()) - .unwrap_or(0); + .get_mut(&dest) + .and_then(|entry| entry.mmp_mut()) + .and_then(|mmp| match kind { + SessionReportKind::Sender => { + mmp.sender.build_report(now_ms).map(|sr| { + ( + SessionMessageType::SenderReport.to_byte(), + SessionSenderReport::from(&sr).encode(), + ) + }) + } + SessionReportKind::Receiver => { + mmp.receiver.build_report(now_ms).map(|rr| { + ( + SessionMessageType::ReceiverReport.to_byte(), + SessionReceiverReport::from(&rr).encode(), + ) + }) + } + SessionReportKind::PathMtu => { + mmp.path_mtu.build_notification(now_ms).map(|mtu_value| { + ( + SessionMessageType::PathMtuNotification.to_byte(), + PathMtuNotification::new(mtu_value).encode(), + ) + }) + } + }); - if failures < 3 { - debug!( - dest = %self.peer_display_name(&dest_addr), - msg_type, - error = %e, - "Failed to send session MMP report" - ); - } else if failures == 3 { - debug!( - dest = %self.peer_display_name(&dest_addr), - "Suppressing further session MMP send failure logs" - ); + let Some((msg_type, body)) = built else { + continue; + }; + + match self.send_session_msg(&dest, msg_type, &body).await { + Ok(()) => send_results.push(SendResult { dest, ok: true }), + Err(e) => { + // Peek at current failure count for log suppression + // (unchanged by the backoff apply, which runs later). + let failures = self + .sessions + .get(&dest) + .and_then(|entry| entry.mmp()) + .map(|mmp| mmp.sender.consecutive_send_failures()) + .unwrap_or(0); + + if failures < 3 { + debug!( + dest = %self.peer_display_name(&dest), + msg_type, + error = %e, + "Failed to send session MMP report" + ); + } else if failures == 3 { + debug!( + dest = %self.peer_display_name(&dest), + "Suppressing further session MMP send failure logs" + ); + } + // failures > 3: silently suppressed + + send_results.push(SendResult { dest, ok: false }); + } } - // failures > 3: silently suppressed - - send_results.push((dest_addr, false)); } + MmpAction::ReapPeer { .. } + | MmpAction::Heartbeat { .. } + | MmpAction::SendLinkReport { .. } + | MmpAction::LogLink { .. } => {} } } - // Update backoff state from send results. - // Deduplicate: a destination counts as success if ANY report succeeded, - // failure only if ALL reports for that destination failed. - let mut dest_success: std::collections::HashMap = - std::collections::HashMap::new(); - for (dest, ok) in &send_results { - let entry = dest_success.entry(*dest).or_insert(false); - if *ok { - *entry = true; - } - } - for (dest_addr, success) in dest_success { - if let Some(entry) = self.sessions.get_mut(&dest_addr) - && let Some(mmp) = entry.mmp_mut() - { - if success { - let prev = mmp.sender.record_send_success(); - if prev > 3 { - debug!( - dest = %self.peer_display_name(&dest_addr), - consecutive_failures = prev, - "Resumed session MMP reporting" - ); + // Deduplicate send results per destination (any-ok -> success, all-fail + // -> failure) and apply the backoff state transition for each dest. + for update in self.mmp.plan_backoff(&send_results) { + match update { + BackoffUpdate::Success { dest } => { + if let Some(mmp) = self.sessions.get_mut(&dest).and_then(|e| e.mmp_mut()) { + let prev = mmp.sender.record_send_success(); + if prev > 3 { + debug!( + dest = %self.peer_display_name(&dest), + consecutive_failures = prev, + "Resumed session MMP reporting" + ); + } + } + } + BackoffUpdate::Failure { dest } => { + if let Some(mmp) = self.sessions.get_mut(&dest).and_then(|e| e.mmp_mut()) { + mmp.sender.record_send_failure(); } - } else { - mmp.sender.record_send_failure(); } } } @@ -540,82 +628,99 @@ impl Node { /// hasn't sent us a frame within the link dead timeout. pub(in crate::node) async fn check_link_heartbeats(&mut self) { let now = Instant::now(); + // Monotonic ms for the MMP receiver's injected-`u64` liveness clock; the + // Instant `now` is still used for the shell-owned heartbeat timing and + // the session-start fallback (both `ActivePeer` Instants). + let now_ms = crate::mmp::mono_ms(); let heartbeat_interval = Duration::from_secs(self.config().node.heartbeat_interval_secs); let dead_timeout = Duration::from_secs(self.config().node.link_dead_timeout_secs); + let dead_timeout_ms = dead_timeout.as_millis() as u64; let max_resends = self.config().node.rate_limit.handshake_max_resends; let heartbeat_msg = [LinkMessageType::Heartbeat.to_byte()]; - // Collect heartbeats to send and dead peers to remove - let mut heartbeats: Vec = Vec::new(); - let mut dead_peers: Vec = Vec::new(); + // Build one liveness snapshot per peer, resolving every clock read and + // the rekey-suppression predicate shell-side. The snapshots own only + // `NodeAddr`/`bool`, so the peer-iteration borrow is released before the + // pure decision runs and the driving loop mutates the registry. + let snapshots: Vec = self + .peers + .iter() + .map(|(node_addr, peer)| { + // Check liveness via the MMP receiver's last-received monotonic + // ms. Fall back to session_start (an `ActivePeer` Instant) for + // peers that never sent data, keeping that branch in Instant + // space so no monotonic-ms epoch conversion is needed. + let time_dead = if let Some(mmp) = peer.mmp() { + match mmp.receiver.last_recv_ms() { + Some(last_ms) => now_ms.saturating_sub(last_ms) >= dead_timeout_ms, + None => now.duration_since(peer.session_start()) >= dead_timeout, + } + } else { + false + }; - for (node_addr, peer) in self.peers.iter() { - // Check liveness via MMP receiver last_recv_time. - // Fall back to session_start for peers that never sent data. - let time_dead = if let Some(mmp) = peer.mmp() { - let reference_time = mmp - .receiver - .last_recv_time() - .unwrap_or(peer.session_start()); - now.duration_since(reference_time) >= dead_timeout - } else { - false - }; + // Suppress teardown while an FMP rekey is genuinely in flight + // with budget left: a rekey-handshake link is not silent. The + // msg1 resend cap guarantees this terminates (abandon on + // exhaustion or cutover on completion clears + // `rekey_in_progress`), so a truly dead link is reaped on the + // next cycle. + let rekey_active = peer.rekey_in_progress() + && peer.rekey_msg1_resend_count() < max_resends + && peer.rekey_msg1().is_some(); - // Suppress teardown while an FMP rekey is genuinely in flight with - // budget left: a rekey-handshake link is not silent. The msg1 - // resend cap guarantees this terminates (abandon on exhaustion or - // cutover on completion clears `rekey_in_progress`), so a truly - // dead link is reaped on the next cycle. - let rekey_active = peer.rekey_in_progress() - && peer.rekey_msg1_resend_count() < max_resends - && peer.rekey_msg1().is_some(); + // Check if heartbeat is due. + let heartbeat_due = match peer.last_heartbeat_sent() { + None => true, + Some(last) => now.duration_since(last) >= heartbeat_interval, + }; - let is_dead = time_dead && !rekey_active; - if is_dead { - dead_peers.push(*node_addr); - continue; - } + PeerLivenessSnapshot { + peer: *node_addr, + time_dead, + rekey_active, + heartbeat_due, + } + }) + .collect(); - // Check if heartbeat is due - let needs_heartbeat = match peer.last_heartbeat_sent() { - None => true, - Some(last) => now.duration_since(last) >= heartbeat_interval, - }; - if needs_heartbeat { - heartbeats.push(*node_addr); - } - } + let actions = self.mmp.plan_heartbeats(&snapshots); - // Remove dead peers and schedule auto-reconnect + // Wall-clock basis for reconnect scheduling, sourced once (as before). let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); - for addr in &dead_peers { - debug!( - peer = %self.peer_display_name(addr), - timeout_secs = self.config().node.link_dead_timeout_secs, - "Removing peer: link dead timeout" - ); - self.remove_active_peer(addr); - self.schedule_reconnect(*addr, now_ms); - } - - // Send heartbeats (skip peers we just removed) - for addr in heartbeats { - if dead_peers.contains(&addr) { - continue; - } - if let Some(peer) = self.peers.get_mut(&addr) { - peer.mark_heartbeat_sent(now); - } - if let Err(e) = self - .send_encrypted_link_message(&addr, &heartbeat_msg) - .await - { - trace!(peer = %self.peer_display_name(&addr), error = %e, "Failed to send heartbeat"); + // Drive the planned actions: all reaps first (each removed + + // reconnect-scheduled), then all heartbeats (a just-reaped peer is never + // heartbeated — the core never emits both for the same peer). + for action in actions { + match action { + MmpAction::ReapPeer { peer } => { + debug!( + peer = %self.peer_display_name(&peer), + timeout_secs = self.config().node.link_dead_timeout_secs, + "Removing peer: link dead timeout" + ); + self.remove_active_peer(&peer); + self.schedule_reconnect(peer, now_ms); + } + MmpAction::Heartbeat { peer } => { + if let Some(p) = self.peers.get_mut(&peer) { + p.mark_heartbeat_sent(now); + } + if let Err(e) = self + .send_encrypted_link_message(&peer, &heartbeat_msg) + .await + { + trace!(peer = %self.peer_display_name(&peer), error = %e, "Failed to send heartbeat"); + } + } + MmpAction::SendLinkReport { .. } + | MmpAction::LogLink { .. } + | MmpAction::SendSessionReport { .. } + | MmpAction::LogSession { .. } => {} } } } diff --git a/src/node/handlers/session.rs b/src/node/handlers/session.rs index d198369..140c156 100644 --- a/src/node/handlers/session.rs +++ b/src/node/handlers/session.rs @@ -6,8 +6,6 @@ //! encrypted data, and error signals (CoordsRequired, PathBroken). use crate::NodeAddr; -use crate::mmp::report::ReceiverReport; -use crate::mmp::{MAX_SESSION_REPORT_INTERVAL_MS, MIN_SESSION_REPORT_INTERVAL_MS}; use crate::node::reject::{RejectReason, SessionReject}; use crate::node::session::{EndToEndState, EpochSlot, SessionEntry}; use crate::node::session_wire::{ @@ -24,14 +22,17 @@ use crate::node::{Node, NodeError}; use crate::noise::{ HandshakeState, XK_HANDSHAKE_MSG1_SIZE, XK_HANDSHAKE_MSG2_SIZE, XK_HANDSHAKE_MSG3_SIZE, }; +use crate::proto::mmp::{MAX_SESSION_REPORT_INTERVAL_MS, MIN_SESSION_REPORT_INTERVAL_MS}; +use crate::proto::mmp::{ + PathMtuNotification, ReceiverReport, SessionReceiverReport, SessionSenderReport, +}; use crate::proto::routing::{CoordsRequired, MtuExceeded, PathBroken}; #[cfg(unix)] use crate::protocol::LinkMessageType; #[cfg(unix)] use crate::protocol::SESSION_DATAGRAM_HEADER_SIZE; use crate::protocol::{ - FspInnerFlags, PathMtuNotification, SessionAck, SessionDatagram, SessionMessageType, - SessionMsg3, SessionReceiverReport, SessionSenderReport, SessionSetup, + FspInnerFlags, SessionAck, SessionDatagram, SessionMessageType, SessionMsg3, SessionSetup, }; use crate::protocol::{coords_wire_size, encode_coords}; #[cfg(unix)] @@ -305,16 +306,16 @@ impl Node { if let Some(entry) = self.sessions.get_mut(src_addr) && let Some(mmp) = entry.mmp_mut() { - let now = std::time::Instant::now(); + let now_ms = crate::mmp::mono_ms(); mmp.receiver - .record_recv(header.counter, timestamp, plaintext.len(), ce_flag, now); + .record_recv(header.counter, timestamp, plaintext.len(), ce_flag, now_ms); // Spin bit: advance state machine for correct TX reflection. // RTT samples not fed into SRTT — timestamp-echo provides // accurate RTT; spin bit includes variable inter-frame delays. let inner_flags = FspInnerFlags::from_byte(inner_flags_byte); let _spin_rtt = mmp .spin_bit - .rx_observe(inner_flags.spin_bit, header.counter, now); + .rx_observe(inner_flags.spin_bit, header.counter, now_ms); } // Feed path_mtu from datagram envelope to MMP path MTU tracking. @@ -974,9 +975,11 @@ impl Node { return; }; - let now = std::time::Instant::now(); - mmp.metrics - .process_receiver_report(&rr, our_timestamp_ms, now); + let (_first_rtt, rr_log) = + mmp.metrics + .process_receiver_report(&rr, our_timestamp_ms, crate::mmp::mono_ms()); + // Re-emit the operator trace the core used to log mid-decision. + super::mmp::log_rr_outcome(&rr, our_timestamp_ms, rr_log); // Feed SRTT back to sender/receiver report interval tuning (session-layer bounds) if let Some(srtt_ms) = mmp.metrics.srtt_ms() { @@ -1042,8 +1045,9 @@ impl Node { }; let old_mtu = mmp.path_mtu.current_mtu(); - let now = std::time::Instant::now(); - let changed = mmp.path_mtu.apply_notification(notif.path_mtu, now); + let changed = mmp + .path_mtu + .apply_notification(notif.path_mtu, crate::mmp::mono_ms()); let new_mtu = mmp.path_mtu.current_mtu(); if !changed { @@ -1256,8 +1260,10 @@ impl Node { && let Some(mmp) = entry.mmp_mut() { let old_mtu = mmp.path_mtu.current_mtu(); - let now = std::time::Instant::now(); - if mmp.path_mtu.apply_notification(msg.mtu, now) { + if mmp + .path_mtu + .apply_notification(msg.mtu, crate::mmp::mono_ms()) + { let new_mtu = mmp.path_mtu.current_mtu(); info!( dest = %peer_name, diff --git a/src/node/mod.rs b/src/node/mod.rs index b00d0be..dbfe934 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -46,6 +46,7 @@ use crate::node::session::SessionEntry; use crate::peer::{ActivePeer, PeerConnection}; use crate::proto::discovery::{Discovery, DiscoveryBackoff, DiscoveryForwardRateLimiter}; use crate::proto::fmp::Fmp; +use crate::proto::mmp::Mmp; use crate::proto::routing::{self, Router, RoutingErrorRateLimiter}; #[cfg(unix)] use crate::transport::ethernet::EthernetTransport; @@ -420,6 +421,9 @@ pub struct Node { /// FMP connection-lifecycle decision anchor (stateless; drives the /// tick-poll maintain/teardown decisions). fmp: Fmp, + /// MMP reporting decision anchor (stateless; drives the report-fan-out / + /// liveness / heartbeat decisions). + mmp: Mmp, /// Rate limiter for source-side CoordsRequired/PathBroken responses. coords_response_rate_limiter: RoutingErrorRateLimiter, @@ -663,6 +667,7 @@ impl Node { icmp_rate_limiter: IcmpRateLimiter::new(), routing: Router::new(), fmp: Fmp::new(), + mmp: Mmp::new(), coords_response_rate_limiter: RoutingErrorRateLimiter::with_interval_ms( coords_response_interval_ms, ), @@ -823,6 +828,7 @@ impl Node { icmp_rate_limiter: IcmpRateLimiter::new(), routing: Router::new(), fmp: Fmp::new(), + mmp: Mmp::new(), coords_response_rate_limiter: RoutingErrorRateLimiter::with_interval_ms( coords_response_interval_ms, ), @@ -2039,7 +2045,7 @@ impl Node { (Some(srtt), Some(setx)) => Some(setx * (1.0 + srtt / 100.0)), _ => None, }; - let trend = |dual: &crate::mmp::algorithms::DualEwma| { + let trend = |dual: &crate::proto::mmp::DualEwma| { dual.initialized() .then(|| crate::control::queries::trend_label(dual.short(), dual.long())) }; @@ -2080,7 +2086,7 @@ impl Node { (Some(srtt), Some(setx)) => Some(setx * (1.0 + srtt / 100.0)), _ => None, }; - let trend = |dual: &crate::mmp::algorithms::DualEwma| { + let trend = |dual: &crate::proto::mmp::DualEwma| { dual.initialized() .then(|| crate::control::queries::trend_label(dual.short(), dual.long())) }; @@ -2973,7 +2979,7 @@ impl routing::RoutingView for NodeRoutingView<'_> { /// is precomputed here exactly as the on-loop queries do, so the render is a /// plain field emit. fn project_entity_mmp( - metrics: &crate::mmp::metrics::MmpMetrics, + metrics: &crate::proto::mmp::MmpMetrics, mode: String, path_mtu: Option, ) -> crate::control::snapshot::EntityMmp { diff --git a/src/node/session.rs b/src/node/session.rs index cd56633..b098e4f 100644 --- a/src/node/session.rs +++ b/src/node/session.rs @@ -5,13 +5,11 @@ //! (SessionSetup/SessionAck/SessionMsg3) carried inside SessionDatagram //! envelopes through the mesh. -use std::time::Instant; - use crate::NodeAddr; use crate::config::SessionMmpConfig; -use crate::mmp::MmpSessionState; use crate::node::REKEY_JITTER_SECS; use crate::noise::{HandshakeState, NoiseSession}; +use crate::proto::mmp::MmpSessionState; use rand::RngExt; use secp256k1::PublicKey; @@ -338,7 +336,12 @@ impl SessionEntry { /// Initialize session-layer MMP state (called on Established transition). pub(crate) fn init_mmp(&mut self, config: &SessionMmpConfig) { - self.mmp = Some(MmpSessionState::new(config, self.is_initiator)); + self.mmp = Some(MmpSessionState::new( + config.mode, + config.log_interval_secs, + config.owd_window_size, + self.is_initiator, + )); } // === Traffic Counters === @@ -665,9 +668,9 @@ impl SessionEntry { self.rekey_jitter_secs = draw_rekey_jitter(); // Reset MMP counters to avoid metric discontinuity - let now = Instant::now(); + let now_ms = crate::mmp::mono_ms(); if let Some(mmp) = &mut self.mmp { - mmp.reset_for_rekey(now); + mmp.reset_for_rekey(now_ms); } true } diff --git a/src/node/tests/mmp_chartests.rs b/src/node/tests/mmp_chartests.rs new file mode 100644 index 0000000..27e15f2 --- /dev/null +++ b/src/node/tests/mmp_chartests.rs @@ -0,0 +1,439 @@ +//! Characterization tests for the three under-tested MMP tick handlers. +//! +//! These lock in the *current* observable behavior of the MMP fan-out and +//! first-RTT paths so a later behavior-neutral sans-IO extraction has an +//! equality oracle. The `check_link_heartbeats` handler already has a good +//! oracle (`heartbeat.rs` + `tcp.rs`) and is not re-covered here; this file +//! targets the three paths with no direct handler tests: +//! +//! * `check_mmp_reports` — link-layer mode/flag fan-out gating +//! * `check_session_mmp_reports` — session mode + PathMtu gating + backoff dedup +//! * `handle_receiver_report` — the first-RTT tree re-evaluation branch +//! +//! Assertions capture what the code does today, surprising or not. +//! +//! Report-generation is probed through the reused `src/mmp/` primitives +//! (`should_send_report` / `should_send_notification`): after a handler tick, +//! a *consumed* interval reads as "not due" (the report was built) while an +//! *ungated* interval still reads as "due" (the report was suppressed by the +//! mode/flag gate). This survives the later refactor because those primitives +//! stay in `src/mmp/` unchanged. +//! +//! Two `#[cfg(test)]` production seams are used, both on `ActivePeer`: +//! * `test_init_mmp(mode)` — attach link MMP with a chosen mode to a +//! bare (sessionless) peer, so mode gating is exercisable. +//! * `test_backdate_session_start` — age `session_elapsed_ms()` so a crafted +//! ReceiverReport yields a positive RTT sample (first-RTT trigger). +//! +//! Neither changes any decision logic or threshold. + +use super::*; +use crate::config::SessionMmpConfig; +use crate::node::session::{EndToEndState, SessionEntry}; +use crate::noise::HandshakeState; +use crate::peer::ActivePeer; +use crate::proto::mmp::{MmpMode, ReceiverReport}; +use crate::tree::{ParentDeclaration, TreeCoordinate}; + +// =========================================================================== +// Helpers +// =========================================================================== + +/// Insert a bare (sessionless) peer carrying link-layer MMP state in `mode`. +/// Returns the peer's NodeAddr. +fn insert_link_peer(node: &mut Node, mode: MmpMode) -> NodeAddr { + let identity = make_peer_identity(); + let addr = *identity.node_addr(); + let mut peer = ActivePeer::new(identity, LinkId::new(1), 0); + peer.test_init_mmp(mode); + node.peers.insert(addr, peer); + addr +} + +/// Arm both sender and receiver link-MMP intervals so a report would be built. +fn arm_link_mmp(node: &mut Node, addr: &NodeAddr) { + let mmp = node.get_peer_mut(addr).unwrap().mmp_mut().unwrap(); + mmp.sender.record_sent(1, 100, 500); + mmp.receiver + .record_recv(1, 100, 500, false, crate::mmp::mono_ms()); +} + +/// Complete an in-memory Noise IK handshake, returning the initiator session. +fn make_noise_session( + our_identity: &crate::Identity, + remote_identity: &crate::Identity, +) -> crate::noise::NoiseSession { + let mut initiator = + HandshakeState::new_initiator(our_identity.keypair(), remote_identity.pubkey_full()); + let mut responder = HandshakeState::new_responder(remote_identity.keypair()); + + let mut init_epoch = [0u8; 8]; + rand::Rng::fill_bytes(&mut rand::rng(), &mut init_epoch); + initiator.set_local_epoch(init_epoch); + let mut resp_epoch = [0u8; 8]; + rand::Rng::fill_bytes(&mut rand::rng(), &mut resp_epoch); + responder.set_local_epoch(resp_epoch); + + let msg1 = initiator.write_message_1().unwrap(); + responder.read_message_1(&msg1).unwrap(); + let msg2 = responder.write_message_2().unwrap(); + initiator.read_message_2(&msg2).unwrap(); + + initiator.into_session().unwrap() +} + +/// Insert an Established session carrying session-layer MMP state in `mode`. +/// Returns the destination NodeAddr. +fn insert_session(node: &mut Node, mode: MmpMode) -> NodeAddr { + let remote = crate::Identity::generate(); + let remote_addr = *remote.node_addr(); + let session = make_noise_session(node.identity(), &remote); + let mut entry = SessionEntry::new( + remote_addr, + remote.pubkey_full(), + EndToEndState::Established(session), + 1000, + true, + ); + let cfg = SessionMmpConfig { + mode, + ..SessionMmpConfig::default() + }; + entry.init_mmp(&cfg); + node.sessions.insert(remote_addr, entry); + remote_addr +} + +/// Arm both sender and receiver session-MMP intervals. +fn arm_session_mmp(node: &mut Node, addr: &NodeAddr) { + let mmp = node.sessions.get_mut(addr).unwrap().mmp_mut().unwrap(); + mmp.sender.record_sent(1, 100, 500); + mmp.receiver + .record_recv(1, 100, 500, false, crate::mmp::mono_ms()); +} + +// =========================================================================== +// check_mmp_reports — link-layer mode fan-out +// =========================================================================== + +/// Full mode: both a SenderReport and a ReceiverReport are generated (both +/// intervals consumed). +#[tokio::test] +async fn mmp_full_mode_builds_sender_and_receiver_reports() { + let mut node = make_node(); + let addr = insert_link_peer(&mut node, MmpMode::Full); + arm_link_mmp(&mut node, &addr); + + node.check_mmp_reports().await; + + let mmp = node.get_peer(&addr).unwrap().mmp().unwrap(); + let now = crate::mmp::mono_ms(); + assert!( + !mmp.sender.should_send_report(now), + "Full mode consumes the sender interval (SenderReport built)" + ); + assert!( + !mmp.receiver.should_send_report(now), + "Full mode consumes the receiver interval (ReceiverReport built)" + ); +} + +/// Lightweight mode: only a ReceiverReport is generated; the sender interval +/// is left intact (no SenderReport in Lightweight). +#[tokio::test] +async fn mmp_lightweight_mode_builds_receiver_report_only() { + let mut node = make_node(); + let addr = insert_link_peer(&mut node, MmpMode::Lightweight); + arm_link_mmp(&mut node, &addr); + + node.check_mmp_reports().await; + + let mmp = node.get_peer(&addr).unwrap().mmp().unwrap(); + let now = crate::mmp::mono_ms(); + assert!( + mmp.sender.should_send_report(now), + "Lightweight mode suppresses the SenderReport (sender interval intact)" + ); + assert!( + !mmp.receiver.should_send_report(now), + "Lightweight mode still builds the ReceiverReport (receiver interval consumed)" + ); +} + +/// Minimal mode: neither report is generated; both intervals stay intact. +#[tokio::test] +async fn mmp_minimal_mode_builds_nothing() { + let mut node = make_node(); + let addr = insert_link_peer(&mut node, MmpMode::Minimal); + arm_link_mmp(&mut node, &addr); + + node.check_mmp_reports().await; + + let mmp = node.get_peer(&addr).unwrap().mmp().unwrap(); + let now = crate::mmp::mono_ms(); + assert!( + mmp.sender.should_send_report(now), + "Minimal mode suppresses the SenderReport" + ); + assert!( + mmp.receiver.should_send_report(now), + "Minimal mode suppresses the ReceiverReport" + ); +} + +/// Periodic operator logging fires once per interval: a fresh peer is due for +/// a log, and after one tick the log is marked (not due again within the +/// interval). +#[tokio::test] +async fn mmp_should_log_marks_logged_once_per_interval() { + let mut node = make_node(); + let addr = insert_link_peer(&mut node, MmpMode::Full); + + assert!( + node.get_peer(&addr) + .unwrap() + .mmp() + .unwrap() + .should_log(crate::mmp::mono_ms()), + "a freshly created peer is due for its first operator log" + ); + + node.check_mmp_reports().await; + + assert!( + !node + .get_peer(&addr) + .unwrap() + .mmp() + .unwrap() + .should_log(crate::mmp::mono_ms()), + "after one tick the log is marked and not due again within the interval" + ); +} + +// =========================================================================== +// check_session_mmp_reports — session mode + PathMtu gating + backoff dedup +// =========================================================================== + +/// Full mode session: both SenderReport and ReceiverReport are generated +/// (both intervals consumed) even though the send has no route and fails. +#[tokio::test] +async fn session_full_mode_builds_sender_and_receiver_reports() { + let mut node = make_node(); + let addr = insert_session(&mut node, MmpMode::Full); + arm_session_mmp(&mut node, &addr); + + node.check_session_mmp_reports().await; + + let mmp = node.get_session(&addr).unwrap().mmp().unwrap(); + let now = crate::mmp::mono_ms(); + assert!( + !mmp.sender.should_send_report(now), + "Full session consumes the sender interval" + ); + assert!( + !mmp.receiver.should_send_report(now), + "Full session consumes the receiver interval" + ); +} + +/// PathMtu notifications gate on all modes: in Minimal mode neither report is +/// built, yet a PathMtuNotification is still generated when an MTU has been +/// observed. +#[tokio::test] +async fn session_minimal_mode_still_sends_path_mtu() { + let mut node = make_node(); + let addr = insert_session(&mut node, MmpMode::Minimal); + arm_session_mmp(&mut node, &addr); + // Observe an MTU so a notification becomes due (all modes). + node.sessions + .get_mut(&addr) + .unwrap() + .mmp_mut() + .unwrap() + .path_mtu + .observe_incoming_mtu(1200); + + let now_before = crate::mmp::mono_ms(); + assert!( + node.get_session(&addr) + .unwrap() + .mmp() + .unwrap() + .path_mtu + .should_send_notification(now_before), + "precondition: a PathMtuNotification is due after observing an MTU" + ); + + node.check_session_mmp_reports().await; + + let mmp = node.get_session(&addr).unwrap().mmp().unwrap(); + let now = crate::mmp::mono_ms(); + assert!( + mmp.sender.should_send_report(now), + "Minimal mode suppresses the session SenderReport" + ); + assert!( + mmp.receiver.should_send_report(now), + "Minimal mode suppresses the session ReceiverReport" + ); + assert!( + !mmp.path_mtu.should_send_notification(now), + "PathMtuNotification is generated in Minimal mode (gate is mode-independent)" + ); +} + +/// Backoff dedup, all-fail side: a Full-mode session generates two reports +/// (SR + RR) to one destination; with no route both sends fail. The +/// per-destination dedup collapses the two failures into exactly ONE +/// `record_send_failure` (consecutive count advances by 1, not 2). +#[tokio::test] +async fn session_backoff_all_reports_fail_records_single_failure() { + let mut node = make_node(); + let addr = insert_session(&mut node, MmpMode::Full); + arm_session_mmp(&mut node, &addr); + + assert_eq!( + node.get_session(&addr) + .unwrap() + .mmp() + .unwrap() + .sender + .consecutive_send_failures(), + 0, + "precondition: no prior send failures" + ); + + node.check_session_mmp_reports().await; + + assert_eq!( + node.get_session(&addr) + .unwrap() + .mmp() + .unwrap() + .sender + .consecutive_send_failures(), + 1, + "two failed reports to one dest dedup to a single record_send_failure" + ); +} + +// =========================================================================== +// handle_receiver_report — first-RTT tree re-evaluation branch +// =========================================================================== + +/// Build a peer (NodeAddr strictly smaller than the node's own) that carries +/// link MMP but no RTT yet, and register it in the tree as a self-root with +/// that smaller address. This makes it a mandatory parent-switch target once +/// it becomes eligible. Returns the peer's NodeAddr. +fn setup_smaller_root_peer(node: &mut Node) -> NodeAddr { + let my_addr = *node.node_addr(); + let (identity, addr) = loop { + let id = make_peer_identity(); + let a = *id.node_addr(); + if a < my_addr { + break (id, a); + } + }; + let mut peer = ActivePeer::new(identity, LinkId::new(1), 0); + peer.test_init_mmp(MmpMode::Full); + // Age the session so a crafted ReceiverReport yields a positive RTT. + peer.test_backdate_session_start(std::time::Duration::from_secs(10)); + node.peers.insert(addr, peer); + + // Register the peer as a self-root in the tree at its (smaller) address. + node.tree_state_mut().update_peer( + ParentDeclaration::self_root(addr, 1, 0), + TreeCoordinate::root(addr), + ); + addr +} + +/// Craft a ReceiverReport whose timestamp echo yields a valid first RTT +/// sample. `highest`/`pkts`/`bytes` advance the cumulative counters so a +/// second report is not dropped as stale/duplicate. +fn craft_rr_payload(highest: u64, pkts: u64, bytes: u64) -> Vec { + let rr = ReceiverReport { + highest_counter: highest, + cumulative_packets_recv: pkts, + cumulative_bytes_recv: bytes, + timestamp_echo: 1000, + dwell_time: 0, + max_burst_loss: 0, + mean_burst_loss: 0, + jitter: 0, + ecn_ce_count: 0, + owd_trend: 0, + burst_loss_count: 0, + cumulative_reorder_count: 0, + interval_packets_recv: pkts as u32, + interval_bytes_recv: bytes as u32, + }; + // handle_receiver_report receives the body with the msg_type byte stripped. + rr.encode()[1..].to_vec() +} + +/// A first RTT sample flips the peer eligible for parent selection AND fires +/// the shell-resident tree branch: the node (initially self-root) adopts the +/// smaller-addressed peer as its new root. +#[tokio::test] +async fn first_rtt_flips_peer_eligible_and_triggers_tree_reeval() { + let mut node = make_node(); + let addr = setup_smaller_root_peer(&mut node); + + assert!( + node.tree_state().is_root(), + "precondition: node starts as its own root" + ); + assert!( + !node.get_peer(&addr).unwrap().has_srtt(), + "precondition: peer has no RTT measurement yet" + ); + let switches_before = node.metrics().tree.parent_switches.get(); + + node.handle_receiver_report(&addr, &craft_rr_payload(10, 5, 500)) + .await; + + assert!( + node.get_peer(&addr).unwrap().has_srtt(), + "first RTT sample makes the peer eligible for parent selection" + ); + assert!( + !node.tree_state().is_root(), + "the first-RTT tree branch fired: node adopted a parent" + ); + assert_eq!( + node.tree_state().root(), + &addr, + "node switched its root to the smaller-addressed peer" + ); + assert!( + node.metrics().tree.parent_switches.get() > switches_before, + "the parent-switch was recorded in the tree metrics" + ); +} + +/// Regression guard: a *second* ReceiverReport (RTT already initialized, so +/// `first_rtt` is false) does NOT re-enter the tree branch — no further parent +/// switch is recorded. +#[tokio::test] +async fn non_first_receiver_report_does_not_retrigger_tree() { + let mut node = make_node(); + let addr = setup_smaller_root_peer(&mut node); + + // First report: fires the branch (established by the test above). + node.handle_receiver_report(&addr, &craft_rr_payload(10, 5, 500)) + .await; + let switches_after_first = node.metrics().tree.parent_switches.get(); + assert!(node.get_peer(&addr).unwrap().has_srtt()); + + // Second report with advanced counters: first_rtt is now false. + node.handle_receiver_report(&addr, &craft_rr_payload(20, 10, 1000)) + .await; + + assert_eq!( + node.metrics().tree.parent_switches.get(), + switches_after_first, + "a non-first ReceiverReport does not re-enter the first-RTT tree branch" + ); +} diff --git a/src/node/tests/mod.rs b/src/node/tests/mod.rs index 54f7f98..645aea4 100644 --- a/src/node/tests/mod.rs +++ b/src/node/tests/mod.rs @@ -19,6 +19,7 @@ mod ethernet; mod forwarding; mod handshake; mod heartbeat; +mod mmp_chartests; mod routing; mod session; mod spanning_tree; diff --git a/src/node/tests/session.rs b/src/node/tests/session.rs index 25d12c5..05557fe 100644 --- a/src/node/tests/session.rs +++ b/src/node/tests/session.rs @@ -1911,7 +1911,7 @@ async fn test_tun_outbound_path_mtu_generates_ptb() { let entry = nodes[0].node.get_session_mut(&node1_addr).unwrap(); let mmp = entry.mmp_mut().unwrap(); mmp.path_mtu - .apply_notification(reduced_mtu, std::time::Instant::now()); + .apply_notification(reduced_mtu, crate::mmp::mono_ms()); assert_eq!(mmp.path_mtu.current_mtu(), reduced_mtu); } diff --git a/src/peer/active.rs b/src/peer/active.rs index 3c08b25..bfdaed2 100644 --- a/src/peer/active.rs +++ b/src/peer/active.rs @@ -4,9 +4,10 @@ //! ActivePeer holds tree state, Bloom filter, and routing information. use crate::bloom::BloomFilter; -use crate::mmp::{MmpConfig, MmpPeerState}; +use crate::mmp::MmpConfig; use crate::node::REKEY_JITTER_SECS; use crate::noise::{HandshakeState as NoiseHandshakeState, NoiseError, NoiseSession}; +use crate::proto::mmp::MmpPeerState; use crate::transport::{LinkId, LinkStats, TransportAddr, TransportId}; use crate::tree::{ParentDeclaration, TreeCoordinate}; use crate::utils::index::SessionIndex; @@ -332,7 +333,12 @@ impl ActivePeer { authenticated_at, last_seen: authenticated_at, remote_epoch, - mmp: Some(MmpPeerState::new(mmp_config, is_initiator)), + mmp: Some(MmpPeerState::new( + mmp_config.mode, + mmp_config.log_interval_secs, + mmp_config.owd_window_size, + is_initiator, + )), last_heartbeat_sent: None, handshake_msg2: None, replay_suppressed_count: 0, @@ -892,6 +898,38 @@ impl ActivePeer { .unwrap_or_else(Instant::now); } + /// Test-only seam: install link-layer MMP state with a chosen operating + /// mode on a peer that was constructed without a Noise session (the bare + /// `new` constructor leaves `mmp` as `None`). This only attaches the same + /// `MmpPeerState::new` the session path installs; it changes no decision + /// logic and no threshold, and is compiled out of release builds. + #[cfg(test)] + pub(crate) fn test_init_mmp(&mut self, mode: crate::proto::mmp::MmpMode) { + let config = MmpConfig { + mode, + ..MmpConfig::default() + }; + self.mmp = Some(MmpPeerState::new( + config.mode, + config.log_interval_secs, + config.owd_window_size, + true, + )); + } + + /// Test-only seam: backdate the session-start instant so a test can make + /// `session_elapsed_ms()` read as `age`-old (needed to synthesize a + /// positive RTT sample from a crafted ReceiverReport). This only shifts the + /// private timestamp field; it changes no decision logic, no threshold, and + /// is compiled out of release builds. + #[cfg(test)] + pub(crate) fn test_backdate_session_start(&mut self, age: std::time::Duration) { + self.session_start = self + .session_start + .checked_sub(age) + .unwrap_or_else(Instant::now); + } + /// Per-session symmetric rekey-timer jitter offset (seconds). /// /// Drawn at session construction and at each rekey cutover; uniform @@ -1018,9 +1056,9 @@ impl ActivePeer { self.reset_replay_suppressed(); // Reset MMP counters to avoid metric discontinuity - let now = Instant::now(); + let now_ms = crate::mmp::mono_ms(); if let Some(mmp) = &mut self.mmp { - mmp.reset_for_rekey(now); + mmp.reset_for_rekey(now_ms); } self.previous_our_index @@ -1055,9 +1093,9 @@ impl ActivePeer { self.reset_replay_suppressed(); // Reset MMP counters to avoid metric discontinuity - let now = Instant::now(); + let now_ms = crate::mmp::mono_ms(); if let Some(mmp) = &mut self.mmp { - mmp.reset_for_rekey(now); + mmp.reset_for_rekey(now_ms); } self.previous_our_index diff --git a/src/mmp/algorithms.rs b/src/proto/mmp/algorithms.rs similarity index 62% rename from src/mmp/algorithms.rs rename to src/proto/mmp/algorithms.rs index 080af10..27e837b 100644 --- a/src/mmp/algorithms.rs +++ b/src/proto/mmp/algorithms.rs @@ -1,12 +1,13 @@ //! MMP algorithmic building blocks. //! //! Pure computational types with no dependency on peer or node state. -//! Each is independently testable. +//! Each is independently testable. `no_std`+`alloc`-clean: the ring buffer +//! comes from `alloc`, all arithmetic is `core`, and the spin-bit RTT clock is +//! an injected `u64` millisecond value (never a `std::time` read). -use std::collections::VecDeque; -use std::time::Instant; +use alloc::collections::VecDeque; -use crate::mmp::{EWMA_LONG_ALPHA, EWMA_SHORT_ALPHA}; +use super::{EWMA_LONG_ALPHA, EWMA_SHORT_ALPHA}; // ============================================================================ // Jitter Estimator (RFC 3550 §6.4.1) @@ -235,7 +236,10 @@ impl OwdTrendDetector { den += dx * dx; } - if den.abs() < f64::EPSILON { + // `den` is a sum of squares, so it is always non-negative; comparing it + // directly against EPSILON is equivalent to the original `den.abs()` + // guard while staying `core`-only (no `libm`). + if den < f64::EPSILON { return 0; } @@ -246,14 +250,6 @@ impl OwdTrendDetector { let slope_per_packet = num / den; (slope_per_packet * 1000.0) as i32 } - - pub fn len(&self) -> usize { - self.samples.len() - } - - pub fn is_empty(&self) -> bool { - self.samples.is_empty() - } } // ============================================================================ @@ -290,8 +286,9 @@ pub struct SpinBitState { current_value: bool, /// Highest counter observed with a spin edge (responder guard). highest_counter_for_spin: u64, - /// Time of last spin edge (initiator only, for RTT measurement). - last_edge_time: Option, + /// Time of last spin edge in injected `u64` milliseconds (initiator only, + /// for RTT measurement). + last_edge_ms: Option, } impl SpinBitState { @@ -300,7 +297,7 @@ impl SpinBitState { is_initiator, current_value: false, highest_counter_for_spin: 0, - last_edge_time: None, + last_edge_ms: None, } } @@ -316,20 +313,16 @@ impl SpinBitState { /// Process a received frame's spin bit. /// - /// Returns an RTT sample duration if an edge was detected (initiator only). - pub fn rx_observe( - &mut self, - received_bit: bool, - counter: u64, - now: Instant, - ) -> Option { + /// `now_ms` is the injected monotonic time in milliseconds. Returns an RTT + /// sample in milliseconds if an edge was detected (initiator only). + pub fn rx_observe(&mut self, received_bit: bool, counter: u64, now_ms: u64) -> Option { if self.is_initiator { // Initiator: when the reflected bit matches what we sent, // that completes a round trip. Record the edge time, then // flip for the next cycle. if received_bit == self.current_value { - let rtt = self.last_edge_time.map(|t| now.duration_since(t)); - self.last_edge_time = Some(now); + let rtt = self.last_edge_ms.map(|t| now_ms.saturating_sub(t)); + self.last_edge_ms = Some(now_ms); self.current_value = !self.current_value; rtt } else { @@ -346,181 +339,3 @@ impl SpinBitState { } } } - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_jitter_zero_input() { - let mut j = JitterEstimator::new(); - j.update(0); - assert_eq!(j.jitter_us(), 0); - } - - #[test] - fn test_jitter_convergence() { - let mut j = JitterEstimator::new(); - // Feed constant transit delta of 1000µs - for _ in 0..200 { - j.update(1000); - } - // Should converge near 1000µs - let jitter = j.jitter_us(); - assert!( - jitter > 900 && jitter < 1100, - "jitter={jitter}, expected ~1000" - ); - } - - #[test] - fn test_srtt_first_sample() { - let mut s = SrttEstimator::new(); - s.update(10_000); // 10ms - assert_eq!(s.srtt_us(), 10_000); - assert_eq!(s.rttvar_us(), 5_000); - assert!(s.initialized()); - } - - #[test] - fn test_srtt_convergence() { - let mut s = SrttEstimator::new(); - // Feed constant 50ms RTT - for _ in 0..100 { - s.update(50_000); - } - let srtt = s.srtt_us(); - assert!((srtt - 50_000).abs() < 1000, "srtt={srtt}, expected ~50000"); - } - - #[test] - fn test_dual_ewma_initialization() { - let mut e = DualEwma::new(); - assert!(!e.initialized()); - e.update(100.0); - assert!(e.initialized()); - assert_eq!(e.short(), 100.0); - assert_eq!(e.long(), 100.0); - } - - #[test] - fn test_dual_ewma_short_tracks_faster() { - let mut e = DualEwma::new(); - // Initialize at 0 - e.update(0.0); - // Jump to 100 - for _ in 0..20 { - e.update(100.0); - } - // Short should be closer to 100 than long - assert!( - e.short() > e.long(), - "short={} long={}", - e.short(), - e.long() - ); - } - - #[test] - fn test_owd_trend_flat() { - let mut d = OwdTrendDetector::new(32); - for i in 0..20 { - d.push(i, 5000); // constant OWD - } - let trend = d.trend_us_per_sec(); - assert_eq!(trend, 0, "flat OWD should have zero trend"); - } - - #[test] - fn test_owd_trend_increasing() { - let mut d = OwdTrendDetector::new(32); - for i in 0..20 { - d.push(i, 5000 + (i as i64) * 100); // increasing by 100µs per packet - } - let trend = d.trend_us_per_sec(); - assert!( - trend > 0, - "increasing OWD should have positive trend, got {trend}" - ); - } - - #[test] - fn test_owd_trend_insufficient_samples() { - let mut d = OwdTrendDetector::new(32); - d.push(0, 5000); - assert_eq!(d.trend_us_per_sec(), 0); - } - - #[test] - fn test_etx_perfect_link() { - assert!((compute_etx(1.0, 1.0) - 1.0).abs() < f64::EPSILON); - } - - #[test] - fn test_etx_lossy_link() { - // 10% forward loss, 5% reverse loss - let etx = compute_etx(0.9, 0.95); - assert!(etx > 1.0 && etx < 2.0, "etx={etx}"); - } - - #[test] - fn test_etx_zero_delivery() { - assert_eq!(compute_etx(0.0, 1.0), 100.0); - assert_eq!(compute_etx(1.0, 0.0), 100.0); - } - - #[test] - fn test_spin_bit_initiator_rtt() { - let mut initiator = SpinBitState::new(true); - let mut responder = SpinBitState::new(false); - - let t0 = Instant::now(); - let t1 = t0 + std::time::Duration::from_millis(10); - let t2 = t0 + std::time::Duration::from_millis(20); - - // Initiator sends with spin=false (initial) - let bit_to_send = initiator.tx_bit(); - assert!(!bit_to_send); - - // Responder receives, copies bit - responder.rx_observe(bit_to_send, 1, t0); - assert!(!responder.tx_bit()); - - // Responder sends back, initiator receives - let resp_bit = responder.tx_bit(); - let rtt1 = initiator.rx_observe(resp_bit, 2, t1); - // First edge: no previous edge to compare - assert!(rtt1.is_none()); - - // Now initiator's spin flipped to true - let bit2 = initiator.tx_bit(); - assert!(bit2); - - // Responder receives new bit - responder.rx_observe(bit2, 3, t1); - assert!(responder.tx_bit()); - - // Responder sends back, initiator receives - let resp_bit2 = responder.tx_bit(); - let rtt2 = initiator.rx_observe(resp_bit2, 4, t2); - // Second edge: should produce an RTT sample - assert!(rtt2.is_some()); - } - - #[test] - fn test_spin_bit_responder_counter_guard() { - let mut responder = SpinBitState::new(false); - - // Receive counter=5 with spin=true - responder.rx_observe(true, 5, Instant::now()); - assert!(responder.tx_bit()); - - // Reordered packet with counter=3 and spin=false should be ignored - responder.rx_observe(false, 3, Instant::now()); - assert!(responder.tx_bit()); // unchanged - } -} diff --git a/src/proto/mmp/core.rs b/src/proto/mmp/core.rs new file mode 100644 index 0000000..d38398a --- /dev/null +++ b/src/proto/mmp/core.rs @@ -0,0 +1,374 @@ +//! Sans-IO MMP (metrics protocol) reporting decision core. +//! +//! Pure, runtime-agnostic report-fan-out / liveness / heartbeat decisions, +//! migrated out of the async node shell. The async I/O adapters in +//! `node::handlers::mmp` build the per-entity snapshots over live node state +//! (pre-computing every clock read into plain `bool` snapshot fields, and +//! resolving the rekey-suppression predicate shell-side), call the `plan_*` +//! decisions, and drive the returned [`MmpAction`]s — the actual sends, +//! registry mutations, `src/mmp/` primitive calls, metrics, and logging. No +//! I/O, no clock, no metrics, no logging here. +//! +//! Unlike routing/discovery (whose cores do live cross-table reads through a +//! `RoutingView`), MMP's decisions are per-entity over data the shell's collect +//! loop already snapshots, so the read-seam is the **snapshot vector** (the +//! FMP-style injected-snapshot form), not a live trait. + +use alloc::collections::BTreeMap; + +use super::MmpMode; +use super::state::Mmp; +use crate::NodeAddr; + +/// A snapshot of one active peer's liveness/heartbeat-relevant state, taken by +/// the shell so the core decides without touching live `Node` state or reading +/// a clock. +/// +/// Every clock read is resolved shell-side into a plain `bool` before the +/// snapshot reaches the core: `time_dead` is the pre-evaluated dead-timeout +/// predicate (monotonic `Instant` delta since last receive or session start), +/// `heartbeat_due` is the pre-evaluated heartbeat-interval predicate, and +/// `rekey_active` is the pre-evaluated FMP rekey-suppression predicate. The +/// core applies only the reap-vs-heartbeat precedence with **no** clock read. +pub(crate) struct PeerLivenessSnapshot { + /// The peer's node address (reap / heartbeat target). + pub peer: NodeAddr, + /// The peer has not sent a frame within the link dead timeout + /// (`now - (last_recv | session_start) >= dead_timeout`). + pub time_dead: bool, + /// An FMP rekey handshake is genuinely in flight with retransmission budget + /// left; suppresses teardown of an otherwise-silent rekey link. + pub rekey_active: bool, + /// A heartbeat is due (`last_heartbeat_sent` is none, or elapsed since it is + /// >= the heartbeat interval). + pub heartbeat_due: bool, +} + +/// A snapshot of one active peer's link-layer report-gating state, taken by the +/// shell so the core decides the report fan-out without touching live `Node` +/// state or reading a clock. +/// +/// Every timing read is resolved shell-side into a plain `bool`: `sr_due` / +/// `rr_due` are the pre-evaluated `should_send_report` gates on the peer's +/// `src/mmp/` sender/receiver primitives, and `log_due` is the pre-evaluated +/// `should_log` gate. `send_sr`/`send_rr` are the profile provide/want flags: +/// on the master (IK) line there is no profile negotiation, so the shell sets +/// them to the behavior-neutral constant `true` (ANDing a runtime `true` is a +/// no-op); the forward-merge to `-next` wires them to `peer.send_sr()`/ +/// `peer.send_rr()` (plan §7.3 spot c). +pub(crate) struct LinkReportSnapshot { + /// The peer's node address (report target). + pub peer: NodeAddr, + /// The peer's MMP reporting mode (gates which reports are eligible). + pub mode: MmpMode, + /// The peer's profile provides/wants a SenderReport. Constant `true` on the + /// master line (no profile negotiation); the forward-merge wires this to the + /// profile predicate. + pub send_sr: bool, + /// The peer's profile provides/wants a ReceiverReport. See `send_sr`. + pub send_rr: bool, + /// A SenderReport is due (`mmp.sender.should_send_report(now)`). + pub sr_due: bool, + /// A ReceiverReport is due (`mmp.receiver.should_send_report(now)`). + pub rr_due: bool, + /// A periodic operator log is due (`mmp.should_log(now)`). + pub log_due: bool, +} + +/// A snapshot of one active session's report-gating state, taken by the shell +/// so the core decides the session fan-out without touching live `Node` state or +/// reading a clock. +/// +/// Every timing read is resolved shell-side into a plain `bool`: `sr_due` / +/// `rr_due` are the pre-evaluated `should_send_report` gates on the session's +/// `src/mmp/` sender/receiver primitives, `mtu_due` is the pre-evaluated +/// `path_mtu.should_send_notification` gate, and `log_due` is the pre-evaluated +/// `should_log` gate. Unlike the link path there is **no** `send_sr`/`send_rr` +/// profile flag — the session handler has never gated on a profile, so no +/// forward-merge fold applies here. +pub(crate) struct SessionReportSnapshot { + /// The session peer's node address (report target). + pub dest: NodeAddr, + /// The session's MMP reporting mode (gates which reports are eligible). + pub mode: MmpMode, + /// A SenderReport is due (`mmp.sender.should_send_report(now)`). + pub sr_due: bool, + /// A ReceiverReport is due (`mmp.receiver.should_send_report(now)`). + pub rr_due: bool, + /// A PathMtuNotification is due + /// (`mmp.path_mtu.should_send_notification(now)`). Gated in **all** modes. + pub mtu_due: bool, + /// A periodic operator log is due (`mmp.should_log(now)`). + pub log_due: bool, +} + +/// Which link-layer report the shell should build/encode/send for a +/// [`SendLinkReport`](MmpAction::SendLinkReport) action. +pub(crate) enum LinkReportKind { + /// A SenderReport (Full mode only): `mmp.sender.build_report(now)`. + Sender, + /// A ReceiverReport (Full and Lightweight modes): + /// `mmp.receiver.build_report(now)`. + Receiver, +} + +/// Which session-layer report the shell should build/encode/send for a +/// [`SendSessionReport`](MmpAction::SendSessionReport) action. +pub(crate) enum SessionReportKind { + /// A SessionSenderReport (Full mode only): `mmp.sender.build_report(now)`. + Sender, + /// A SessionReceiverReport (Full and Lightweight modes): + /// `mmp.receiver.build_report(now)`. + Receiver, + /// A PathMtuNotification (all modes): `mmp.path_mtu.build_notification(now)`. + PathMtu, +} + +/// The outcome of one session-report send, collected by the shell while driving +/// the [`SendSessionReport`](MmpAction::SendSessionReport) actions, and fed back +/// into [`Mmp::plan_backoff`] for the per-destination backoff dedup. +pub(crate) struct SendResult { + /// The destination the report was sent to. + pub dest: NodeAddr, + /// Whether `send_session_msg` succeeded. + pub ok: bool, +} + +/// The per-destination backoff decision produced by [`Mmp::plan_backoff`] from a +/// tick's [`SendResult`]s, deduplicating multiple reports to the same +/// destination into a single success/failure verdict. +/// +/// The core carries no failure count: the pre-refactor "Resumed session MMP +/// reporting" log fires on the `prev` value **returned by** `record_send_success` +/// (the previous consecutive-failure count), which is shell-owned mutation state. +/// So `Success` carries no field — the shell calls `record_send_success`, reads +/// its returned `prev`, and emits the resume log iff `prev > 3`, exactly as the +/// original did. +pub(crate) enum BackoffUpdate { + /// The destination had at least one successful report this cycle → the shell + /// runs `record_send_success` (and the `prev > 3` resume log). + Success { dest: NodeAddr }, + /// Every report to the destination failed this cycle → the shell runs + /// `record_send_failure`. + Failure { dest: NodeAddr }, +} + +/// A registry/transport effect the async shell performs on the core's behalf. +/// +/// The heartbeat/liveness decision emits [`ReapPeer`]/[`Heartbeat`]; the +/// link-report fan-out emits [`SendLinkReport`]/[`LogLink`]; the session-report +/// fan-out emits [`SendSessionReport`]/[`LogSession`]. +/// +/// [`ReapPeer`]: MmpAction::ReapPeer +/// [`Heartbeat`]: MmpAction::Heartbeat +/// [`SendLinkReport`]: MmpAction::SendLinkReport +/// [`LogLink`]: MmpAction::LogLink +/// [`SendSessionReport`]: MmpAction::SendSessionReport +/// [`LogSession`]: MmpAction::LogSession +pub(crate) enum MmpAction { + /// Reap a dead peer: the shell runs `remove_active_peer` + + /// `schedule_reconnect` (with its wall-clock `now_ms`). + ReapPeer { peer: NodeAddr }, + /// Send a heartbeat to `peer`: the shell runs `mark_heartbeat_sent` and the + /// encrypted link send. + Heartbeat { peer: NodeAddr }, + /// Build (shell: `src/mmp/` `build_report` + `encode`) and send the given + /// link report over the encrypted link. The interval-advancing + /// `build_report` mutation happens **only** while driving this action, so an + /// ungated report never advances its interval. + SendLinkReport { + peer: NodeAddr, + kind: LinkReportKind, + }, + /// Emit the periodic link operator log for `peer` (shell owns the `tracing` + /// call and runs `mark_logged`). + LogLink { peer: NodeAddr }, + /// Build (shell: `src/mmp/` `build_report`/`build_notification` + the + /// `Session*`/`PathMtuNotification` codec) and send the given session report + /// over the encrypted session. The interval/notification-advancing build + /// mutation happens **only** while driving this action. + SendSessionReport { + dest: NodeAddr, + kind: SessionReportKind, + }, + /// Emit the periodic session operator log for `dest` (shell owns the + /// `tracing` call and runs `mark_logged`). + LogSession { dest: NodeAddr }, +} + +impl Mmp { + /// Decide the per-tick reap/heartbeat choreography for the peers the shell + /// snapshotted. Reproduces the pre-refactor `check_link_heartbeats` logic + /// exactly: + /// + /// - A peer past the dead timeout with no rekey in flight + /// (`time_dead && !rekey_active`) is reaped and considered for nothing + /// else — it is **not** also sent a heartbeat (the pre-refactor + /// `continue`, plus the "skip just-reaped peers" guard). + /// - Otherwise, a peer whose heartbeat is due gets a heartbeat. + /// + /// Actions are returned phase-grouped (all reaps, then all heartbeats) to + /// preserve the pre-refactor two-loop global execution order (every dead + /// peer removed + reconnect-scheduled before any heartbeat is sent). Pure + /// over the snapshots. + pub(crate) fn plan_heartbeats(&self, peers: &[PeerLivenessSnapshot]) -> Vec { + let mut reaps = Vec::new(); + let mut heartbeats = Vec::new(); + for snap in peers { + if snap.time_dead && !snap.rekey_active { + reaps.push(MmpAction::ReapPeer { peer: snap.peer }); + continue; + } + if snap.heartbeat_due { + heartbeats.push(MmpAction::Heartbeat { peer: snap.peer }); + } + } + reaps.extend(heartbeats); + reaps + } + + /// Decide the per-tick link-layer report fan-out for the peers the shell + /// snapshotted. Reproduces the pre-refactor `check_mmp_reports` gating + /// exactly: + /// + /// - A SenderReport is emitted for a `Full`-mode peer whose profile provides + /// it and whose sender interval is due (`mode == Full && send_sr && + /// sr_due`). + /// - A ReceiverReport is emitted for any non-`Minimal`-mode peer whose + /// profile provides it and whose receiver interval is due (`mode != + /// Minimal && send_rr && rr_due`). + /// - A `LogLink` marker is emitted for any peer whose operator-log interval + /// is due (`log_due`). + /// + /// Actions are returned phase-grouped — all SenderReport sends (in peer + /// order), then all ReceiverReport sends (in peer order), then all log + /// markers — to preserve the pre-refactor collect-then-send order (every + /// SenderReport sent before any ReceiverReport). Logs come last: the shell + /// runs `build_report` while driving the send actions, so a peer's log + /// (which reads that peer's post-build cumulative counters, unaffected by any + /// other peer's build) reads the same state it did when the original emitted + /// it inline. Pure over the snapshots; the interval/log mutations happen + /// shell-side while driving. + pub(crate) fn plan_link_reports(&self, peers: &[LinkReportSnapshot]) -> Vec { + let mut actions = Vec::new(); + // Logs first: the operator log reads `cumulative_packets_sent`, which the + // report sends advance (send_encrypted_link_message -> sender.record_sent). + // The pre-refactor handler logged during its collect pass, before any + // send, so the logged tx_pkts excludes this tick's reports. Emitting the + // log markers ahead of the sends preserves that (build_report touches no + // log-read field, so ordering the logs before the builds is neutral). + for snap in peers { + if snap.log_due { + actions.push(MmpAction::LogLink { peer: snap.peer }); + } + } + for snap in peers { + if snap.mode == MmpMode::Full && snap.send_sr && snap.sr_due { + actions.push(MmpAction::SendLinkReport { + peer: snap.peer, + kind: LinkReportKind::Sender, + }); + } + } + for snap in peers { + if snap.mode != MmpMode::Minimal && snap.send_rr && snap.rr_due { + actions.push(MmpAction::SendLinkReport { + peer: snap.peer, + kind: LinkReportKind::Receiver, + }); + } + } + actions + } + + /// Decide the per-tick session-layer report fan-out for the sessions the + /// shell snapshotted. Reproduces the pre-refactor `check_session_mmp_reports` + /// collect-pass gating exactly: + /// + /// - A SessionSenderReport is emitted for a `Full`-mode session whose sender + /// interval is due (`mode == Full && sr_due`). + /// - A SessionReceiverReport is emitted for any non-`Minimal`-mode session + /// whose receiver interval is due (`mode != Minimal && rr_due`). + /// - A PathMtuNotification is emitted for any session whose notification is + /// due, in **all** modes (`mtu_due`) — the MTU gate is mode-independent. + /// - A `LogSession` marker is emitted for any session whose operator-log + /// interval is due (`log_due`). + /// + /// Ordering: all `LogSession` markers (in session order) come **first**, then + /// the sends. The pre-refactor handler logged inline during its collect pass, + /// before any `send_session_msg`; the session operator log reads + /// `cumulative_packets_sent`, which each send advances (`send_session_msg` -> + /// `sender.record_sent`), while `build_report`/`build_notification` touch no + /// log-read field — so emitting the logs ahead of the sends reproduces the + /// original logged counters byte-identically. The sends preserve the + /// pre-refactor per-session emission order (Sender, then Receiver, then + /// PathMtu, interleaved by session), matching the order reports were pushed + /// into the collect vector. Pure over the snapshots; every build/log mutation + /// happens shell-side while driving. + pub(crate) fn plan_session_reports( + &self, + sessions: &[SessionReportSnapshot], + ) -> Vec { + let mut actions = Vec::new(); + // Logs first (see the ordering note above): they read the pre-send + // cumulative_packets_sent the original captured during its collect pass. + for snap in sessions { + if snap.log_due { + actions.push(MmpAction::LogSession { dest: snap.dest }); + } + } + // Sends in the pre-refactor per-session push order: SR, RR, MTU. + for snap in sessions { + if snap.mode == MmpMode::Full && snap.sr_due { + actions.push(MmpAction::SendSessionReport { + dest: snap.dest, + kind: SessionReportKind::Sender, + }); + } + if snap.mode != MmpMode::Minimal && snap.rr_due { + actions.push(MmpAction::SendSessionReport { + dest: snap.dest, + kind: SessionReportKind::Receiver, + }); + } + if snap.mtu_due { + actions.push(MmpAction::SendSessionReport { + dest: snap.dest, + kind: SessionReportKind::PathMtu, + }); + } + } + actions + } + + /// Deduplicate a tick's session-report [`SendResult`]s into one + /// [`BackoffUpdate`] per distinct destination. Reproduces the pre-refactor + /// `check_session_mmp_reports` backoff reduction exactly: + /// + /// - A destination counts as **success if ANY** of its reports succeeded. + /// - A destination counts as **failure only if ALL** of its reports failed. + /// + /// One `BackoffUpdate::Success`/`Failure` is emitted per distinct dest. The + /// original accumulated into a `HashMap` (success OR-fold, + /// initialized `false`) with nondeterministic iteration order; a `BTreeMap` + /// is used here for a deterministic (address-sorted) emission order — no test + /// depends on the old order (the backoff chartest exercises a single dest), + /// and stage 6's no_std pass requires the ordered map regardless. + pub(crate) fn plan_backoff(&self, results: &[SendResult]) -> Vec { + let mut dest_success: BTreeMap = BTreeMap::new(); + for res in results { + let entry = dest_success.entry(res.dest).or_insert(false); + *entry |= res.ok; + } + dest_success + .into_iter() + .map(|(dest, success)| { + if success { + BackoffUpdate::Success { dest } + } else { + BackoffUpdate::Failure { dest } + } + }) + .collect() + } +} diff --git a/src/proto/mmp/mod.rs b/src/proto/mmp/mod.rs new file mode 100644 index 0000000..37db794 --- /dev/null +++ b/src/proto/mmp/mod.rs @@ -0,0 +1,121 @@ +//! Sans-IO MMP (metrics protocol) reporting subsystem. +//! +//! Pure, runtime-agnostic report-fan-out / liveness / heartbeat decisions plus +//! the owned per-peer/per-session protocol state, migrated out of the async node +//! shell. The async I/O adapters remain in `node::handlers::mmp`. +//! +//! - `core.rs` — the snapshot read-seams, the [`MmpAction`] effect vocabulary, +//! and the pure `plan_*` decisions (line-invariant across the master/next +//! report shapes). +//! - `state.rs` — [`Mmp`] (the reporting anchor) plus the owned sender/receiver/ +//! metrics/path-MTU state machines (`u64`-ms time seam, `no_std`+`alloc`). +//! - `algorithms.rs` — the pure estimators (jitter/SRTT/dual-EWMA/OWD-trend/ETX) +//! and the spin-bit state (`no_std`+`alloc`). +//! - `wire.rs` — the link-layer and session-layer report codecs. +//! +//! `MmpConfig` (serde node config) stays shell-side in `crate::mmp`; only the +//! plain values it carries reach the owned state constructors. + +// Leading `::` disambiguates the extern `core` crate from the child `mod core`. +use ::core::fmt; + +use serde::{Deserialize, Serialize}; + +mod algorithms; +mod core; +mod state; +mod wire; + +#[cfg(test)] +mod tests; + +pub(crate) use algorithms::DualEwma; +pub(crate) use core::{ + BackoffUpdate, LinkReportKind, LinkReportSnapshot, MmpAction, PeerLivenessSnapshot, SendResult, + SessionReportKind, SessionReportSnapshot, +}; +pub(crate) use state::{Mmp, MmpMetrics, MmpPeerState, MmpSessionState, RrLog}; +pub use wire::{ + PathMtuNotification, ReceiverReport, SenderReport, SessionReceiverReport, SessionSenderReport, +}; + +// ============================================================================ +// Operating Mode +// ============================================================================ + +/// MMP operating mode. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MmpMode { + /// Sender + receiver reports at RTT-adaptive intervals. Maximum fidelity. + #[default] + Full, + /// Receiver reports only. Loss inferred from counter gaps. + Lightweight, + /// Spin bit + CE echo only. No reports exchanged. + Minimal, +} + +impl fmt::Display for MmpMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + MmpMode::Full => write!(f, "full"), + MmpMode::Lightweight => write!(f, "lightweight"), + MmpMode::Minimal => write!(f, "minimal"), + } + } +} + +// ============================================================================ +// Constants +// ============================================================================ + +// --- EWMA parameters --- + +/// Dual EWMA short-term: α = 1/4. +pub const EWMA_SHORT_ALPHA: f64 = 0.25; + +/// Dual EWMA long-term: α = 1/32. +pub const EWMA_LONG_ALPHA: f64 = 1.0 / 32.0; + +// --- Timing defaults (milliseconds) --- + +/// Default report interval before SRTT is available (cold start). +pub const DEFAULT_COLD_START_INTERVAL_MS: u64 = 200; + +/// Minimum report interval (SRTT clamp floor). +/// +/// Raised from 100ms to 1000ms: parent re-evaluation runs every 60s, +/// so 60 samples/cycle is more than sufficient for EWMA convergence (~10). +/// The cold-start phase uses `DEFAULT_COLD_START_INTERVAL_MS` (200ms) for +/// fast initial SRTT convergence before transitioning to this floor. +pub const MIN_REPORT_INTERVAL_MS: u64 = 1_000; + +/// Maximum report interval (SRTT clamp ceiling). +pub const MAX_REPORT_INTERVAL_MS: u64 = 5_000; + +/// Number of SRTT samples before transitioning from cold-start to normal floor. +/// +/// During cold-start, report intervals use `DEFAULT_COLD_START_INTERVAL_MS` as +/// the floor to gather SRTT samples quickly. After this many updates, the floor +/// switches to `MIN_REPORT_INTERVAL_MS`. +pub const COLD_START_SAMPLES: u32 = 5; + +/// Default OWD ring buffer capacity. +pub const DEFAULT_OWD_WINDOW_SIZE: usize = 32; + +/// Default operator log interval in seconds. +pub const DEFAULT_LOG_INTERVAL_SECS: u64 = 30; + +// --- Session-layer timing defaults --- +// Session reports are routed end-to-end (bandwidth cost on every transit link), +// so intervals are higher than link-layer. + +/// Session-layer minimum report interval. +pub const MIN_SESSION_REPORT_INTERVAL_MS: u64 = 500; + +/// Session-layer maximum report interval. +pub const MAX_SESSION_REPORT_INTERVAL_MS: u64 = 10_000; + +/// Session-layer cold-start report interval (before SRTT is available). +pub const SESSION_COLD_START_INTERVAL_MS: u64 = 1_000; diff --git a/src/proto/mmp/state.rs b/src/proto/mmp/state.rs new file mode 100644 index 0000000..c4184a3 --- /dev/null +++ b/src/proto/mmp/state.rs @@ -0,0 +1,1339 @@ +//! Owned MMP protocol state (sans-IO). +//! +//! The per-peer/per-session sender, receiver, derived-metrics, spin-bit and +//! path-MTU state machines migrated out of the async node shell. All time +//! inputs are injected `u64` milliseconds (the shell reads its monotonic clock +//! at the edge and passes the value in); nothing here reads a clock, logs, or +//! bumps a counter. `no_std`+`alloc`-clean: `core` arithmetic only, the ring +//! buffer comes from `super::algorithms` (which uses `alloc`), and observability +//! is returned to the shell as an [`RrLog`] outcome rather than emitted here. +//! +//! [`Mmp`] itself remains the stateless reporting anchor owned by `Node` (the +//! live per-entity state lives on the peers'/sessions' shell structs); it exists +//! so the reporting decisions can hang off a `Node` field in the same shape the +//! other migrated subsystems use. + +use core::fmt::{self, Debug}; + +use super::algorithms::{ + DualEwma, JitterEstimator, OwdTrendDetector, SpinBitState, SrttEstimator, compute_etx, +}; +use super::wire::{ReceiverReport, SenderReport}; +use super::{ + COLD_START_SAMPLES, DEFAULT_COLD_START_INTERVAL_MS, DEFAULT_OWD_WINDOW_SIZE, + MAX_REPORT_INTERVAL_MS, MIN_REPORT_INTERVAL_MS, MmpMode, SESSION_COLD_START_INTERVAL_MS, +}; + +/// Grace period after rekey before resuming jitter calculation. +/// +/// During rekey cutover, frames from the old session may still arrive via the +/// drain window (DRAIN_WINDOW_SECS = 10s). These carry large sender timestamps +/// from the old session, producing enormous transit deltas that spike the EWMA +/// jitter estimator. We suppress jitter updates for drain window + 5s margin. +const REKEY_JITTER_GRACE_SECS: u64 = 15; + +/// MMP reporting subsystem anchor owned by [`Node`](crate::node::Node). +/// +/// Like [`Fmp`](crate::proto::fmp::Fmp), the MMP reporting core owns **no** +/// mutable state: the per-peer/per-session timing and backoff state lives on +/// the peers'/sessions' [`MmpPeerState`]/[`MmpSessionState`] structs (defined in +/// this module, held shell-side), and every registry mutation and send stays +/// shell-side, driven by the [`MmpAction`](super::MmpAction)s the pure `plan_*` +/// decisions emit. `Mmp` is therefore an empty namespace anchor: it exists so +/// the reporting decisions can hang off a `Node` field (`self.mmp`) in the same +/// shape the other migrated subsystems use, not to hold data. +pub(crate) struct Mmp; + +impl Mmp { + /// Create the (stateless) MMP reporting anchor. + pub(crate) fn new() -> Self { + Self + } +} + +// ============================================================================ +// ReceiverReport processing outcome (observability hoisted to the shell) +// ============================================================================ + +/// The observability outcome of [`MmpMetrics::process_receiver_report`], returned +/// so the async shell can emit the operator `trace!` points that used to live +/// mid-decision (per the sans-IO rule that migrated code carries no `tracing`). +/// +/// Exactly one variant applies per call; the shell has the incoming report and +/// our timestamp, so this only carries the values the shell cannot otherwise +/// reconstruct (the previous cumulative snapshot / the derived RTT). +pub enum RrLog { + /// No RTT log point fires (the report carried no timestamp echo). + None, + /// The report was ignored as stale/duplicate. Carries the previous + /// cumulative snapshot for the operator trace. + Stale { + prev_highest: u64, + prev_packets: u64, + prev_bytes: u64, + }, + /// A valid RTT sample was taken; carries the derived RTT and the SRTT as it + /// stood **before** this sample was folded in (matching the original log, + /// which read `srtt` before `update`). + RttSample { rtt_ms: u32, srtt_ms: f64 }, + /// The report carried an echo but the derived RTT was invalid (wrapped / + /// non-positive). + InvalidRtt, +} + +// ============================================================================ +// SenderState +// ============================================================================ + +/// Per-peer sender-side MMP state. +/// +/// Records cumulative and interval counters for every frame transmitted +/// to this peer. Produces `SenderReport` snapshots on demand. +pub struct SenderState { + // --- Cumulative (lifetime) --- + cumulative_packets_sent: u64, + cumulative_bytes_sent: u64, + + // --- Current interval --- + interval_start_counter: u64, + interval_start_timestamp: u32, + interval_bytes_sent: u32, + /// Counter of the most recently sent frame. + last_counter: u64, + /// Timestamp of the most recently sent frame. + last_timestamp: u32, + /// Whether any frames have been sent in the current interval. + interval_has_data: bool, + + // --- Report timing (injected `u64` ms) --- + last_report_ms: Option, + report_interval_ms: u64, + + // --- Send failure backoff --- + /// Consecutive send failure count for backoff calculation. + consecutive_send_failures: u32, + + // --- Cold-start tracking --- + /// Number of SRTT-based interval updates received. + srtt_sample_count: u32, +} + +impl SenderState { + pub fn new() -> Self { + Self::new_with_cold_start(DEFAULT_COLD_START_INTERVAL_MS) + } + + /// Create with a custom cold-start interval (ms). + /// + /// Used by session-layer MMP which needs a longer initial interval + /// since reports consume bandwidth on every transit link. + pub fn new_with_cold_start(cold_start_ms: u64) -> Self { + Self { + cumulative_packets_sent: 0, + cumulative_bytes_sent: 0, + interval_start_counter: 0, + interval_start_timestamp: 0, + interval_bytes_sent: 0, + last_counter: 0, + last_timestamp: 0, + interval_has_data: false, + last_report_ms: None, + report_interval_ms: cold_start_ms, + consecutive_send_failures: 0, + srtt_sample_count: 0, + } + } + + /// Record a frame sent to this peer. + /// + /// Called on the TX path for every encrypted link message. + /// `counter` is the AEAD nonce/counter, `timestamp` is the inner header + /// session-relative timestamp (ms), `bytes` is the wire payload size. + pub fn record_sent(&mut self, counter: u64, timestamp: u32, bytes: usize) { + if !self.interval_has_data { + self.interval_start_counter = counter; + self.interval_start_timestamp = timestamp; + self.interval_has_data = true; + } + self.last_counter = counter; + self.last_timestamp = timestamp; + self.interval_bytes_sent = self.interval_bytes_sent.saturating_add(bytes as u32); + self.cumulative_packets_sent += 1; + self.cumulative_bytes_sent += bytes as u64; + } + + /// Build a SenderReport from current state and reset the interval. + /// + /// Returns `None` if no frames have been sent since the last report. + /// `now_ms` is the injected monotonic time in milliseconds. + pub fn build_report(&mut self, now_ms: u64) -> Option { + if !self.interval_has_data { + return None; + } + + let report = SenderReport { + interval_start_counter: self.interval_start_counter, + interval_end_counter: self.last_counter, + interval_start_timestamp: self.interval_start_timestamp, + interval_end_timestamp: self.last_timestamp, + interval_bytes_sent: self.interval_bytes_sent, + cumulative_packets_sent: self.cumulative_packets_sent, + cumulative_bytes_sent: self.cumulative_bytes_sent, + }; + + // Reset interval + self.interval_has_data = false; + self.interval_bytes_sent = 0; + self.last_report_ms = Some(now_ms); + + Some(report) + } + + /// Check if it's time to send a report. + /// + /// When consecutive send failures have occurred, the effective interval + /// is multiplied by an exponential backoff factor (2^failures, capped at 32×). + pub fn should_send_report(&self, now_ms: u64) -> bool { + if !self.interval_has_data { + return false; + } + match self.last_report_ms { + None => true, // Never sent a report — send immediately + Some(last) => { + let effective_ms = (self.report_interval_ms as f64 + * self.send_failure_backoff_multiplier()) + as u64; + now_ms.saturating_sub(last) >= effective_ms + } + } + } + + /// Record a send failure. Returns the new consecutive failure count. + pub fn record_send_failure(&mut self) -> u32 { + self.consecutive_send_failures += 1; + self.consecutive_send_failures + } + + /// Record a successful send. Returns the previous failure count (for summary logging). + pub fn record_send_success(&mut self) -> u32 { + let prev = self.consecutive_send_failures; + self.consecutive_send_failures = 0; + prev + } + + /// Get the backoff multiplier based on consecutive failures. + /// + /// Returns 1.0 for no failures, 2.0 for 1 failure, 4.0 for 2, ... + /// capped at 32.0 (5 failures). Computed as an exact power-of-two bit shift + /// (`2^k == 1 << k`), keeping the module `core`-only (no `libm` for `powi`). + pub fn send_failure_backoff_multiplier(&self) -> f64 { + if self.consecutive_send_failures == 0 { + 1.0 + } else { + (1u64 << self.consecutive_send_failures.min(5)) as f64 + } + } + + /// Update the report interval based on SRTT (link-layer defaults). + /// + /// Sender reports at 2× SRTT clamped to [floor, MAX]. During cold-start + /// (first `COLD_START_SAMPLES` updates), the floor is the cold-start + /// interval (200ms) for fast SRTT convergence. After that, it rises to + /// `MIN_REPORT_INTERVAL_MS` (1000ms) for steady-state efficiency. + pub fn update_report_interval_from_srtt(&mut self, srtt_us: i64) { + self.srtt_sample_count = self.srtt_sample_count.saturating_add(1); + let floor = if self.srtt_sample_count <= COLD_START_SAMPLES { + DEFAULT_COLD_START_INTERVAL_MS + } else { + MIN_REPORT_INTERVAL_MS + }; + self.update_report_interval_with_bounds(srtt_us, floor, MAX_REPORT_INTERVAL_MS); + } + + /// Update the report interval based on SRTT with custom bounds. + /// + /// Used by session-layer MMP which needs higher clamp values since + /// each report consumes bandwidth on every transit link. + pub fn update_report_interval_with_bounds(&mut self, srtt_us: i64, min_ms: u64, max_ms: u64) { + if srtt_us <= 0 { + return; + } + let interval_us = (srtt_us * 2) as u64; + let interval_ms = (interval_us / 1000).clamp(min_ms, max_ms); + self.report_interval_ms = interval_ms; + } + + // --- Accessors --- + + pub fn cumulative_packets_sent(&self) -> u64 { + self.cumulative_packets_sent + } + + pub fn cumulative_bytes_sent(&self) -> u64 { + self.cumulative_bytes_sent + } + + pub fn report_interval_ms(&self) -> u64 { + self.report_interval_ms + } + + pub fn consecutive_send_failures(&self) -> u32 { + self.consecutive_send_failures + } +} + +impl Default for SenderState { + fn default() -> Self { + Self::new() + } +} + +// ============================================================================ +// Gap Tracker (burst loss detection) +// ============================================================================ + +/// Tracks counter gaps to detect loss bursts. +/// +/// Each gap in the counter sequence is a burst of lost frames. +/// Maintains per-interval statistics that are reset when a report is built. +struct GapTracker { + /// Next expected counter value. + expected_next: Option, + /// Whether we are currently in a burst (gap). + in_burst: bool, + /// Length of the current burst. + current_burst_len: u16, + + // --- Per-interval stats (reset on report) --- + /// Number of distinct burst events this interval. + burst_count: u32, + /// Longest burst in this interval. + max_burst_len: u16, + /// Sum of all burst lengths (for mean computation). + total_burst_len: u64, +} + +impl GapTracker { + fn new() -> Self { + Self { + expected_next: None, + in_burst: false, + current_burst_len: 0, + burst_count: 0, + max_burst_len: 0, + total_burst_len: 0, + } + } + + /// Process a received counter value. Returns the number of lost frames + /// detected (0 if in order or first frame). + fn observe(&mut self, counter: u64) -> u64 { + let Some(expected) = self.expected_next else { + // First frame: initialize + self.expected_next = Some(counter + 1); + return 0; + }; + + let lost = if counter > expected { + // Gap detected + let gap = counter - expected; + if self.in_burst { + // Extend current burst + self.current_burst_len = self.current_burst_len.saturating_add(gap as u16); + } else { + // New burst + self.in_burst = true; + self.current_burst_len = gap as u16; + self.burst_count += 1; + } + gap + } else { + // In-order or duplicate (counter <= expected) + if self.in_burst { + // End current burst + self.finish_burst(); + } + 0 + }; + + // Update expected (always advance to counter+1 or keep expected if + // this was a late/reordered frame) + if counter >= expected { + self.expected_next = Some(counter + 1); + } + + lost + } + + /// Finish the current burst and record its stats. + fn finish_burst(&mut self) { + if self.in_burst { + self.max_burst_len = self.max_burst_len.max(self.current_burst_len); + self.total_burst_len += self.current_burst_len as u64; + self.in_burst = false; + self.current_burst_len = 0; + } + } + + /// Get interval stats and reset for next interval. + fn take_interval_stats(&mut self) -> (u32, u16, u16) { + // Finish any in-progress burst + self.finish_burst(); + + let count = self.burst_count; + let max_len = self.max_burst_len; + let mean_len = if count > 0 { + // u8.8 fixed-point: (total / count) * 256 + let mean_f = (self.total_burst_len as f64) / (count as f64); + (mean_f * 256.0) as u16 + } else { + 0 + }; + + // Reset interval + self.burst_count = 0; + self.max_burst_len = 0; + self.total_burst_len = 0; + + (count, max_len, mean_len) + } +} + +// ============================================================================ +// ReceiverState +// ============================================================================ + +/// Per-peer receiver-side MMP state. +/// +/// Accumulates per-frame observations and produces `ReceiverReport` snapshots. +pub struct ReceiverState { + // --- Cumulative (lifetime) --- + cumulative_packets_recv: u64, + cumulative_bytes_recv: u64, + cumulative_reorder_count: u64, + + /// Highest counter value ever received. + highest_counter: u64, + + // --- Current interval --- + interval_packets_recv: u32, + interval_bytes_recv: u32, + + // --- Jitter --- + jitter: JitterEstimator, + + // --- OWD trend --- + owd_trend: OwdTrendDetector, + /// Monotonic sequence counter for OWD samples. + owd_seq: u32, + + // --- Loss tracking --- + gap_tracker: GapTracker, + + // --- ECN --- + ecn_ce_count: u32, + + // --- Timestamp echo --- + /// Sender timestamp from the most recent frame (for echo). + last_sender_timestamp: u32, + /// Local time (injected `u64` ms) when the most recent frame was received + /// (for dwell / jitter computation). + last_recv_ms: Option, + + // --- Rekey grace --- + /// When set, jitter updates are suppressed until this injected-ms instant + /// passes. Prevents drain-window frames from spiking the jitter estimator. + rekey_jitter_grace_until_ms: Option, + + // --- Report timing (injected `u64` ms) --- + last_report_ms: Option, + report_interval_ms: u64, + /// Whether any frames have been received since the last report. + interval_has_data: bool, + + // --- Cold-start tracking --- + /// Number of SRTT-based interval updates received. + srtt_sample_count: u32, +} + +impl ReceiverState { + pub fn new(owd_window_size: usize) -> Self { + Self::new_with_cold_start(owd_window_size, DEFAULT_COLD_START_INTERVAL_MS) + } + + /// Create with a custom cold-start interval (ms). + /// + /// Used by session-layer MMP which needs a longer initial interval + /// since reports consume bandwidth on every transit link. + pub fn new_with_cold_start(owd_window_size: usize, cold_start_ms: u64) -> Self { + Self { + cumulative_packets_recv: 0, + cumulative_bytes_recv: 0, + cumulative_reorder_count: 0, + highest_counter: 0, + interval_packets_recv: 0, + interval_bytes_recv: 0, + jitter: JitterEstimator::new(), + owd_trend: OwdTrendDetector::new(owd_window_size), + owd_seq: 0, + gap_tracker: GapTracker::new(), + ecn_ce_count: 0, + last_sender_timestamp: 0, + last_recv_ms: None, + rekey_jitter_grace_until_ms: None, + last_report_ms: None, + report_interval_ms: cold_start_ms, + interval_has_data: false, + srtt_sample_count: 0, + } + } + + /// Reset counter-dependent state for rekey cutover. + /// + /// After cutover, the new session starts with counter 0 and reset + /// timestamps. Without resetting, the old `highest_counter` and + /// `GapTracker.expected_next` cause false reorder/loss detection. + /// `now_ms` is the injected monotonic time in milliseconds. + pub fn reset_for_rekey(&mut self, now_ms: u64) { + self.highest_counter = 0; + self.cumulative_reorder_count = 0; + self.gap_tracker = GapTracker::new(); + self.interval_packets_recv = 0; + self.interval_bytes_recv = 0; + self.jitter = JitterEstimator::new(); + self.owd_trend.clear(); + self.owd_seq = 0; + self.last_sender_timestamp = 0; + self.last_recv_ms = None; + self.rekey_jitter_grace_until_ms = Some(now_ms + REKEY_JITTER_GRACE_SECS * 1000); + self.ecn_ce_count = 0; + self.interval_has_data = false; + // Keep cumulative_packets_recv, cumulative_bytes_recv (lifetime stats) + // Keep last_report_ms, report_interval_ms (report scheduling) + } + + /// Record a received frame from this peer. + /// + /// Called on the RX path after AEAD decryption, before message dispatch. + /// + /// - `counter`: AEAD counter from outer header + /// - `sender_timestamp_ms`: session-relative timestamp from inner header (ms) + /// - `bytes`: wire payload size + /// - `ce_flag`: CE bit from flags byte + /// - `now_ms`: injected monotonic local time in milliseconds + pub fn record_recv( + &mut self, + counter: u64, + sender_timestamp_ms: u32, + bytes: usize, + ce_flag: bool, + now_ms: u64, + ) { + self.interval_has_data = true; + self.cumulative_packets_recv += 1; + self.cumulative_bytes_recv += bytes as u64; + self.interval_packets_recv = self.interval_packets_recv.saturating_add(1); + self.interval_bytes_recv = self.interval_bytes_recv.saturating_add(bytes as u32); + + // Reordering detection: counter < highest means out-of-order + if counter < self.highest_counter { + self.cumulative_reorder_count += 1; + } else { + self.highest_counter = counter; + } + + // Loss/burst detection + let _lost = self.gap_tracker.observe(counter); + + // ECN + if ce_flag { + self.ecn_ce_count = self.ecn_ce_count.saturating_add(1); + } + + // Jitter: compute transit time delta + // Transit = recv_local - sender_timestamp (in µs for precision) + // We use the injected monotonic ms clock for the local reference. + let sender_us = (sender_timestamp_ms as i64) * 1000; + // We compute the delta between consecutive transits using relative + // millisecond differences (scaled to µs to match the estimator input). + // Skip during post-rekey grace period to avoid drain-window spikes. + let in_grace = self + .rekey_jitter_grace_until_ms + .is_some_and(|deadline| now_ms < deadline); + if !in_grace { + self.rekey_jitter_grace_until_ms = None; // clear expired grace + if let Some(prev_recv) = self.last_recv_ms { + let recv_delta_us = (now_ms.saturating_sub(prev_recv) as i64) * 1000; + let send_delta_us = sender_us - (self.last_sender_timestamp as i64 * 1000); + let transit_delta = (recv_delta_us - send_delta_us) as i32; + self.jitter.update(transit_delta); + } + } + + // OWD trend: use sender timestamp as a proxy for send time + // and the injected ms delta from a fixed reference as receive time. + // Since we only need the *trend* (slope), absolute offsets cancel out. + if let Some(first_recv) = self.last_recv_ms.or(Some(now_ms)) { + let recv_offset_us = (now_ms.saturating_sub(first_recv) as i64) * 1000; + let owd_us = recv_offset_us - sender_us; + self.owd_seq = self.owd_seq.wrapping_add(1); + self.owd_trend.push(self.owd_seq, owd_us); + } + + // Timestamp echo state + self.last_sender_timestamp = sender_timestamp_ms; + self.last_recv_ms = Some(now_ms); + } + + /// Build a ReceiverReport from current state and reset the interval. + /// + /// Returns `None` if no frames have been received since the last report. + /// `now_ms` is the injected monotonic time in milliseconds. + pub fn build_report(&mut self, now_ms: u64) -> Option { + if !self.interval_has_data { + return None; + } + + // Dwell time: ms between last frame reception and report generation. + // If it no longer fits on the wire, the timestamp echo cannot produce + // a valid RTT sample. Preserve the counters but suppress the echo. + let (timestamp_echo, dwell_time) = self + .last_recv_ms + .map(|t| { + let dwell_ms = now_ms.saturating_sub(t); + if dwell_ms > u64::from(u16::MAX) { + (0, u16::MAX) + } else { + (self.last_sender_timestamp, dwell_ms as u16) + } + }) + .unwrap_or((0, 0)); + + let (burst_count, max_burst, mean_burst) = self.gap_tracker.take_interval_stats(); + + let report = ReceiverReport { + highest_counter: self.highest_counter, + cumulative_packets_recv: self.cumulative_packets_recv, + cumulative_bytes_recv: self.cumulative_bytes_recv, + timestamp_echo, + dwell_time, + max_burst_loss: max_burst, + mean_burst_loss: mean_burst, + jitter: self.jitter.jitter_us(), + ecn_ce_count: self.ecn_ce_count, + owd_trend: self.owd_trend.trend_us_per_sec(), + burst_loss_count: burst_count, + cumulative_reorder_count: self.cumulative_reorder_count as u32, + interval_packets_recv: self.interval_packets_recv, + interval_bytes_recv: self.interval_bytes_recv, + }; + + // Reset interval + self.interval_packets_recv = 0; + self.interval_bytes_recv = 0; + self.interval_has_data = false; + self.last_report_ms = Some(now_ms); + + Some(report) + } + + /// Check if it's time to send a report. + pub fn should_send_report(&self, now_ms: u64) -> bool { + if !self.interval_has_data { + return false; + } + match self.last_report_ms { + None => true, + Some(last) => now_ms.saturating_sub(last) >= self.report_interval_ms, + } + } + + /// Update the report interval based on SRTT (link-layer defaults). + /// + /// Receiver reports at 1× SRTT clamped to [floor, MAX]. During cold-start + /// (first `COLD_START_SAMPLES` updates), the floor is the cold-start + /// interval (200ms) for fast SRTT convergence. After that, it rises to + /// `MIN_REPORT_INTERVAL_MS` (1000ms) for steady-state efficiency. + pub fn update_report_interval_from_srtt(&mut self, srtt_us: i64) { + self.srtt_sample_count = self.srtt_sample_count.saturating_add(1); + let floor = if self.srtt_sample_count <= COLD_START_SAMPLES { + DEFAULT_COLD_START_INTERVAL_MS + } else { + MIN_REPORT_INTERVAL_MS + }; + self.update_report_interval_with_bounds(srtt_us, floor, MAX_REPORT_INTERVAL_MS); + } + + /// Update the report interval based on SRTT with custom bounds. + /// + /// Used by session-layer MMP which needs higher clamp values since + /// each report consumes bandwidth on every transit link. + pub fn update_report_interval_with_bounds(&mut self, srtt_us: i64, min_ms: u64, max_ms: u64) { + if srtt_us <= 0 { + return; + } + let interval_ms = ((srtt_us as u64) / 1000).clamp(min_ms, max_ms); + self.report_interval_ms = interval_ms; + } + + // --- Accessors --- + + pub fn cumulative_packets_recv(&self) -> u64 { + self.cumulative_packets_recv + } + + pub fn cumulative_bytes_recv(&self) -> u64 { + self.cumulative_bytes_recv + } + + pub fn highest_counter(&self) -> u64 { + self.highest_counter + } + + pub fn jitter_us(&self) -> u32 { + self.jitter.jitter_us() + } + + pub fn report_interval_ms(&self) -> u64 { + self.report_interval_ms + } + + /// Local monotonic time (injected `u64` ms) of the most recent received + /// frame, or `None` if none has been received. + pub fn last_recv_ms(&self) -> Option { + self.last_recv_ms + } + + pub fn ecn_ce_count(&self) -> u32 { + self.ecn_ce_count + } +} + +impl Default for ReceiverState { + fn default() -> Self { + Self::new(DEFAULT_OWD_WINDOW_SIZE) + } +} + +// ============================================================================ +// MmpMetrics (derived metrics) +// ============================================================================ + +/// Derived MMP metrics, updated from incoming ReceiverReports. +/// +/// This lives on the sender side: when we receive a ReceiverReport from +/// our peer describing what they observed about our traffic, we process +/// it here to compute RTT, loss, goodput, and trend indicators. +pub struct MmpMetrics { + /// Smoothed RTT from timestamp echo. + pub srtt: SrttEstimator, + + /// Dual EWMA trend detectors. + pub rtt_trend: DualEwma, + pub loss_trend: DualEwma, + pub goodput_trend: DualEwma, + pub jitter_trend: DualEwma, + pub etx_trend: DualEwma, + + /// Forward delivery ratio (what fraction of our frames the peer received). + pub delivery_ratio_forward: f64, + /// Reverse delivery ratio (set when we compute from our own receiver state). + pub delivery_ratio_reverse: f64, + /// ETX computed from bidirectional delivery ratios. + pub etx: f64, + + /// Smoothed goodput in bytes/sec (forward direction: what the peer received from us). + pub goodput_bps: f64, + + // --- State for delta computation --- + /// Previous ReceiverReport's cumulative counters (for computing interval deltas). + prev_rr_cum_packets: u64, + prev_rr_cum_bytes: u64, + prev_rr_highest_counter: u64, + prev_rr_ecn_ce: u32, + prev_rr_reorder: u32, + /// Time (injected `u64` ms) of previous ReceiverReport (for goodput rate). + prev_rr_ms: Option, + /// Whether we have a previous ReceiverReport for delta computation. + has_prev_rr: bool, + + // --- State for reverse delivery ratio delta computation --- + /// Previous reverse-side cumulative packets received (our receiver state). + prev_reverse_packets: u64, + /// Previous reverse-side highest counter (our receiver state). + prev_reverse_highest: u64, + /// Whether we have a previous reverse-side snapshot for delta computation. + has_prev_reverse: bool, +} + +impl MmpMetrics { + /// Reset state derived from ReceiverReport counters for rekey cutover. + /// + /// The new session starts with counter 0, so the prev_rr deltas must + /// be reset to avoid computing bogus loss/goodput from the counter + /// discontinuity. RTT (SRTT) is preserved since it remains valid. + pub fn reset_for_rekey(&mut self) { + self.prev_rr_cum_packets = 0; + self.prev_rr_cum_bytes = 0; + self.prev_rr_highest_counter = 0; + self.prev_rr_ecn_ce = 0; + self.prev_rr_reorder = 0; + self.prev_rr_ms = None; + self.has_prev_rr = false; + self.delivery_ratio_forward = 1.0; + self.prev_reverse_packets = 0; + self.prev_reverse_highest = 0; + self.has_prev_reverse = false; + // Keep srtt, etx, trends, goodput_bps — they'll refresh from data + } + + pub fn new() -> Self { + Self { + srtt: SrttEstimator::new(), + rtt_trend: DualEwma::new(), + loss_trend: DualEwma::new(), + goodput_trend: DualEwma::new(), + jitter_trend: DualEwma::new(), + etx_trend: DualEwma::new(), + delivery_ratio_forward: 1.0, + delivery_ratio_reverse: 1.0, + etx: 1.0, + goodput_bps: 0.0, + prev_rr_cum_packets: 0, + prev_rr_cum_bytes: 0, + prev_rr_highest_counter: 0, + prev_rr_ecn_ce: 0, + prev_rr_reorder: 0, + prev_rr_ms: None, + has_prev_rr: false, + prev_reverse_packets: 0, + prev_reverse_highest: 0, + has_prev_reverse: false, + } + } + + /// Process an incoming ReceiverReport (from the peer about our traffic). + /// + /// `our_timestamp_ms` is the current session-relative time in ms (for RTT). + /// `now_ms` is the injected monotonic time in ms (for goodput rate). + /// + /// Returns `(first_srtt, log)`: `first_srtt` is `true` if this report + /// produced the first SRTT measurement (transition from uninitialized to + /// initialized); `log` is the [`RrLog`] observability outcome the shell + /// emits (the `tracing` that used to fire here now lives shell-side). + pub fn process_receiver_report( + &mut self, + rr: &ReceiverReport, + our_timestamp_ms: u32, + now_ms: u64, + ) -> (bool, RrLog) { + let had_srtt = self.srtt.initialized(); + + if self.has_prev_rr { + let counters_regressed = rr.highest_counter < self.prev_rr_highest_counter + || rr.cumulative_packets_recv < self.prev_rr_cum_packets + || rr.cumulative_bytes_recv < self.prev_rr_cum_bytes + || rr.ecn_ce_count < self.prev_rr_ecn_ce + || rr.cumulative_reorder_count < self.prev_rr_reorder; + let duplicate_counters = rr.highest_counter == self.prev_rr_highest_counter + && rr.cumulative_packets_recv == self.prev_rr_cum_packets + && rr.cumulative_bytes_recv == self.prev_rr_cum_bytes + && rr.ecn_ce_count == self.prev_rr_ecn_ce + && rr.cumulative_reorder_count == self.prev_rr_reorder; + // Safe to drop: reports are only built after interval data, so + // a fresh report always advances at least one cumulative counter. + if counters_regressed || duplicate_counters { + return ( + false, + RrLog::Stale { + prev_highest: self.prev_rr_highest_counter, + prev_packets: self.prev_rr_cum_packets, + prev_bytes: self.prev_rr_cum_bytes, + }, + ); + } + } + + let mut log = RrLog::None; + + // --- RTT from timestamp echo --- + // RTT = now - echoed_timestamp - dwell_time + if rr.timestamp_echo > 0 { + let echo_ms = rr.timestamp_echo; + let dwell_ms = u32::from(rr.dwell_time); + let rtt_sample_ms = echo_ms + .checked_add(dwell_ms) + .and_then(|send_done_ms| our_timestamp_ms.checked_sub(send_done_ms)); + + match rtt_sample_ms { + Some(rtt_ms) if rtt_ms > 0 => { + let rtt_us = (rtt_ms as i64) * 1000; + // Capture SRTT before folding in this sample (the original + // trace read `srtt` before `update`). + let srtt_ms = self.srtt.srtt_us() as f64 / 1000.0; + log = RrLog::RttSample { rtt_ms, srtt_ms }; + self.srtt.update(rtt_us); + self.rtt_trend.update(rtt_us as f64); + } + _ => { + log = RrLog::InvalidRtt; + } + } + } + + // --- Loss rate from cumulative counters --- + // Delta: frames the peer should have received vs. actually received + if self.has_prev_rr { + let counter_span = rr + .highest_counter + .saturating_sub(self.prev_rr_highest_counter); + let packets_delta = rr + .cumulative_packets_recv + .saturating_sub(self.prev_rr_cum_packets); + + if counter_span > 0 { + let delivery = (packets_delta as f64) / (counter_span as f64); + self.delivery_ratio_forward = delivery.clamp(0.0, 1.0); + let loss_rate = 1.0 - self.delivery_ratio_forward; + self.loss_trend.update(loss_rate); + self.etx = compute_etx(self.delivery_ratio_forward, self.delivery_ratio_reverse); + self.etx_trend.update(self.etx); + } + } + + // --- Goodput from cumulative bytes + time delta --- + if self.has_prev_rr { + let bytes_delta = rr + .cumulative_bytes_recv + .saturating_sub(self.prev_rr_cum_bytes); + self.goodput_trend.update(bytes_delta as f64); + + // Compute bytes/sec if we have a time reference + if let Some(prev_ms) = self.prev_rr_ms { + let secs = (now_ms.saturating_sub(prev_ms) as f64) / 1000.0; + if secs > 0.0 { + let bps = bytes_delta as f64 / secs; + // EWMA smoothing: α = 1/4 + if self.goodput_bps == 0.0 { + self.goodput_bps = bps; + } else { + self.goodput_bps += (bps - self.goodput_bps) * 0.25; + } + } + } + } + + // --- Jitter trend --- + self.jitter_trend.update(rr.jitter as f64); + + // --- Save for next delta --- + self.prev_rr_cum_packets = rr.cumulative_packets_recv; + self.prev_rr_cum_bytes = rr.cumulative_bytes_recv; + self.prev_rr_highest_counter = rr.highest_counter; + self.prev_rr_ecn_ce = rr.ecn_ce_count; + self.prev_rr_reorder = rr.cumulative_reorder_count; + self.prev_rr_ms = Some(now_ms); + self.has_prev_rr = true; + + (!had_srtt && self.srtt.initialized(), log) + } + + /// Update the reverse delivery ratio from our own receiver state. + /// + /// Computes a per-interval delta (same as forward ratio) rather than + /// a lifetime cumulative ratio, so ETX responds to recent conditions. + pub fn update_reverse_delivery(&mut self, our_recv_packets: u64, peer_highest: u64) { + if self.has_prev_reverse { + let counter_span = peer_highest.saturating_sub(self.prev_reverse_highest); + let packets_delta = our_recv_packets.saturating_sub(self.prev_reverse_packets); + + if counter_span > 0 { + let delivery = (packets_delta as f64) / (counter_span as f64); + self.delivery_ratio_reverse = delivery.clamp(0.0, 1.0); + self.etx = compute_etx(self.delivery_ratio_forward, self.delivery_ratio_reverse); + self.etx_trend.update(self.etx); + } + } + + self.prev_reverse_packets = our_recv_packets; + self.prev_reverse_highest = peer_highest; + self.has_prev_reverse = true; + } + + /// Current smoothed RTT in milliseconds, or `None` if not yet measured. + pub fn srtt_ms(&self) -> Option { + if self.srtt.initialized() { + Some(self.srtt.srtt_us() as f64 / 1000.0) + } else { + None + } + } + + /// Current loss rate (0.0 = no loss, 1.0 = total loss). + pub fn loss_rate(&self) -> f64 { + 1.0 - self.delivery_ratio_forward + } + + /// Smoothed loss rate (long-term EWMA), or `None` if not yet initialized. + pub fn smoothed_loss(&self) -> Option { + if self.loss_trend.initialized() { + Some(self.loss_trend.long()) + } else { + None + } + } + + /// Smoothed ETX (long-term EWMA), or `None` if not yet initialized. + pub fn smoothed_etx(&self) -> Option { + if self.etx_trend.initialized() { + Some(self.etx_trend.long()) + } else { + None + } + } + + /// Current smoothed goodput in bytes/sec, or 0 if not yet measured. + pub fn goodput_bps(&self) -> f64 { + self.goodput_bps + } + + /// Cumulative ECN CE count from the most recent ReceiverReport. + pub fn last_ecn_ce_count(&self) -> u32 { + self.prev_rr_ecn_ce + } +} + +impl Default for MmpMetrics { + fn default() -> Self { + Self::new() + } +} + +// ============================================================================ +// Per-Peer MMP State +// ============================================================================ + +/// Combined MMP state for a single peer link. +/// +/// Wraps sender, receiver, metrics, and spin bit state. One instance +/// per `ActivePeer`. +pub struct MmpPeerState { + pub sender: SenderState, + pub receiver: ReceiverState, + pub metrics: MmpMetrics, + pub spin_bit: SpinBitState, + mode: MmpMode, + log_interval_ms: u64, + last_log_ms: Option, +} + +impl MmpPeerState { + /// Create MMP state for a new peer link. + /// + /// `is_initiator`: true if this node initiated the Noise handshake + /// (determines spin bit role). `mode`/`log_interval_secs`/`owd_window_size` + /// are the shell's config values, passed as plain data so this owned state + /// carries no dependency on the shell config struct. + pub fn new( + mode: MmpMode, + log_interval_secs: u64, + owd_window_size: usize, + is_initiator: bool, + ) -> Self { + Self { + sender: SenderState::new(), + receiver: ReceiverState::new(owd_window_size), + metrics: MmpMetrics::new(), + spin_bit: SpinBitState::new(is_initiator), + mode, + log_interval_ms: log_interval_secs * 1000, + last_log_ms: None, + } + } + + /// Reset counter-dependent state for rekey cutover. + pub fn reset_for_rekey(&mut self, now_ms: u64) { + self.receiver.reset_for_rekey(now_ms); + self.metrics.reset_for_rekey(); + } + + /// Current operating mode. + pub fn mode(&self) -> MmpMode { + self.mode + } + + /// Check if it's time to emit a periodic metrics log. + pub fn should_log(&self, now_ms: u64) -> bool { + match self.last_log_ms { + None => true, + Some(last) => now_ms.saturating_sub(last) >= self.log_interval_ms, + } + } + + /// Mark that a periodic log was emitted. + pub fn mark_logged(&mut self, now_ms: u64) { + self.last_log_ms = Some(now_ms); + } +} + +impl Debug for MmpPeerState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MmpPeerState") + .field("mode", &self.mode) + .finish_non_exhaustive() + } +} + +// ============================================================================ +// Per-Session MMP State (session-layer instantiation) +// ============================================================================ + +/// Combined MMP state for a single end-to-end session. +/// +/// Wraps sender, receiver, metrics, spin bit, and path MTU state. +/// One instance per established `SessionEntry`. +pub struct MmpSessionState { + pub sender: SenderState, + pub receiver: ReceiverState, + pub metrics: MmpMetrics, + pub spin_bit: SpinBitState, + mode: MmpMode, + log_interval_ms: u64, + last_log_ms: Option, + pub path_mtu: PathMtuState, +} + +impl MmpSessionState { + /// Create MMP state for a new session. + /// + /// `is_initiator`: true if this node initiated the Noise handshake + /// (determines spin bit role). `mode`/`log_interval_secs`/`owd_window_size` + /// are the shell's config values, passed as plain data. + pub fn new( + mode: MmpMode, + log_interval_secs: u64, + owd_window_size: usize, + is_initiator: bool, + ) -> Self { + Self { + sender: SenderState::new_with_cold_start(SESSION_COLD_START_INTERVAL_MS), + receiver: ReceiverState::new_with_cold_start( + owd_window_size, + SESSION_COLD_START_INTERVAL_MS, + ), + metrics: MmpMetrics::new(), + spin_bit: SpinBitState::new(is_initiator), + mode, + log_interval_ms: log_interval_secs * 1000, + last_log_ms: None, + path_mtu: PathMtuState::new(), + } + } + + /// Reset counter-dependent state for rekey cutover. + pub fn reset_for_rekey(&mut self, now_ms: u64) { + self.receiver.reset_for_rekey(now_ms); + self.metrics.reset_for_rekey(); + } + + /// Current operating mode. + pub fn mode(&self) -> MmpMode { + self.mode + } + + /// Check if it's time to emit a periodic metrics log. + pub fn should_log(&self, now_ms: u64) -> bool { + match self.last_log_ms { + None => true, + Some(last) => now_ms.saturating_sub(last) >= self.log_interval_ms, + } + } + + /// Mark that a periodic log was emitted. + pub fn mark_logged(&mut self, now_ms: u64) { + self.last_log_ms = Some(now_ms); + } +} + +impl Debug for MmpSessionState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MmpSessionState") + .field("mode", &self.mode) + .field("path_mtu", &self.path_mtu.current_mtu()) + .finish_non_exhaustive() + } +} + +// ============================================================================ +// Path MTU State (session-layer only) +// ============================================================================ + +/// Path MTU tracking for a single session. +/// +/// Destination side: observes `path_mtu` from incoming SessionDatagram envelopes +/// and generates PathMtuNotification messages back to the source. +/// +/// Source side: applies received PathMtuNotification to limit outbound datagram +/// size. Decrease is immediate; increase requires 3 consecutive notifications. +pub struct PathMtuState { + /// Current effective path MTU (what we use for sending). + current_mtu: u16, + /// Last observed path MTU from incoming datagrams (destination-side). + last_observed_mtu: u16, + /// Whether the observed MTU has changed since the last notification. + observed_changed: bool, + /// Last time (injected `u64` ms) a PathMtuNotification was sent. + last_notification_ms: Option, + /// Notification interval in ms: max(10s, 5 * SRTT). Default 10s. + notification_interval_ms: u64, + /// For source-side increase tracking: consecutive higher-value notifications. + consecutive_increase_count: u8, + /// Time (injected `u64` ms) of the first notification in the current + /// increase sequence. + first_increase_ms: Option, + /// The MTU value being proposed for increase. + pending_increase_mtu: u16, +} + +impl PathMtuState { + /// Create path MTU state with no initial measurement. + pub fn new() -> Self { + Self { + current_mtu: u16::MAX, + last_observed_mtu: u16::MAX, + observed_changed: false, + last_notification_ms: None, + notification_interval_ms: 10_000, + consecutive_increase_count: 0, + first_increase_ms: None, + pending_increase_mtu: 0, + } + } + + /// Current effective path MTU (source-side, for sending). + pub fn current_mtu(&self) -> u16 { + self.current_mtu + } + + /// Last observed incoming path MTU (destination-side). + pub fn last_observed_mtu(&self) -> u16 { + self.last_observed_mtu + } + + /// Update notification interval from SRTT: max(10s, 5 * SRTT). + pub fn update_interval_from_srtt(&mut self, srtt_ms: f64) { + self.notification_interval_ms = ((srtt_ms * 5.0) as u64).max(10_000); + } + + /// Seed source-side current_mtu from outbound transport MTU. + /// + /// Called on each send. Only decreases (never increases) the current_mtu + /// so the destination's PathMtuNotification can still raise it later. + /// Ensures current_mtu doesn't stay at u16::MAX before any notification + /// arrives from the destination. + pub fn seed_source_mtu(&mut self, outbound_mtu: u16) { + if outbound_mtu < self.current_mtu { + self.current_mtu = outbound_mtu; + } + } + + // --- Destination side --- + + /// Observe the path_mtu from an incoming SessionDatagram envelope. + /// + /// Called on the destination (receiver) side for every session message. + pub fn observe_incoming_mtu(&mut self, path_mtu: u16) { + if path_mtu != self.last_observed_mtu { + self.observed_changed = true; + self.last_observed_mtu = path_mtu; + } + } + + /// Check if a PathMtuNotification should be sent. + /// + /// Send on first measurement, on decrease (immediate), or periodic + /// confirmation at the notification interval. `now_ms` is the injected + /// monotonic time in milliseconds. + pub fn should_send_notification(&self, now_ms: u64) -> bool { + if self.last_observed_mtu == u16::MAX { + return false; // No measurement yet + } + match self.last_notification_ms { + None => true, // First measurement + Some(last) => { + // Immediate on decrease + if self.observed_changed && self.last_observed_mtu < self.current_mtu { + return true; + } + // Periodic confirmation + now_ms.saturating_sub(last) >= self.notification_interval_ms + } + } + } + + /// Build a PathMtuNotification from current state. + /// + /// Returns the path_mtu value to send. Caller handles encoding. + pub fn build_notification(&mut self, now_ms: u64) -> Option { + if self.last_observed_mtu == u16::MAX { + return None; + } + self.last_notification_ms = Some(now_ms); + self.observed_changed = false; + Some(self.last_observed_mtu) + } + + // --- Source side --- + + /// Apply a received PathMtuNotification. + /// + /// - Decrease: immediate (take the lower value). + /// - Increase: require 3 consecutive notifications with the same higher + /// value, spanning at least 2 * notification_interval. + /// + /// `now_ms` is the injected monotonic time in milliseconds. Returns `true` + /// if the effective MTU changed. + pub fn apply_notification(&mut self, reported_mtu: u16, now_ms: u64) -> bool { + if reported_mtu < self.current_mtu { + // Decrease: immediate + self.current_mtu = reported_mtu; + self.consecutive_increase_count = 0; + self.first_increase_ms = None; + return true; + } + + if reported_mtu > self.current_mtu { + // Increase: track consecutive notifications + if reported_mtu == self.pending_increase_mtu { + self.consecutive_increase_count += 1; + } else { + // Different value: reset sequence + self.pending_increase_mtu = reported_mtu; + self.consecutive_increase_count = 1; + self.first_increase_ms = Some(now_ms); + } + + // Accept increase after 3 consecutive spanning 2 * interval + if self.consecutive_increase_count >= 3 + && let Some(first_ms) = self.first_increase_ms + { + let required_ms = self.notification_interval_ms * 2; + if now_ms.saturating_sub(first_ms) >= required_ms { + self.current_mtu = reported_mtu; + self.consecutive_increase_count = 0; + self.first_increase_ms = None; + return true; + } + } + } + + // No change (equal or increase not yet confirmed) + false + } +} + +impl Default for PathMtuState { + fn default() -> Self { + Self::new() + } +} diff --git a/src/proto/mmp/tests/algorithms.rs b/src/proto/mmp/tests/algorithms.rs new file mode 100644 index 0000000..898b319 --- /dev/null +++ b/src/proto/mmp/tests/algorithms.rs @@ -0,0 +1,175 @@ +//! Tests for the MMP algorithmic building blocks. + +use crate::proto::mmp::algorithms::{ + DualEwma, JitterEstimator, OwdTrendDetector, SpinBitState, SrttEstimator, compute_etx, +}; + +#[test] +fn test_jitter_zero_input() { + let mut j = JitterEstimator::new(); + j.update(0); + assert_eq!(j.jitter_us(), 0); +} + +#[test] +fn test_jitter_convergence() { + let mut j = JitterEstimator::new(); + // Feed constant transit delta of 1000µs + for _ in 0..200 { + j.update(1000); + } + // Should converge near 1000µs + let jitter = j.jitter_us(); + assert!( + jitter > 900 && jitter < 1100, + "jitter={jitter}, expected ~1000" + ); +} + +#[test] +fn test_srtt_first_sample() { + let mut s = SrttEstimator::new(); + s.update(10_000); // 10ms + assert_eq!(s.srtt_us(), 10_000); + assert_eq!(s.rttvar_us(), 5_000); + assert!(s.initialized()); +} + +#[test] +fn test_srtt_convergence() { + let mut s = SrttEstimator::new(); + // Feed constant 50ms RTT + for _ in 0..100 { + s.update(50_000); + } + let srtt = s.srtt_us(); + assert!((srtt - 50_000).abs() < 1000, "srtt={srtt}, expected ~50000"); +} + +#[test] +fn test_dual_ewma_initialization() { + let mut e = DualEwma::new(); + assert!(!e.initialized()); + e.update(100.0); + assert!(e.initialized()); + assert_eq!(e.short(), 100.0); + assert_eq!(e.long(), 100.0); +} + +#[test] +fn test_dual_ewma_short_tracks_faster() { + let mut e = DualEwma::new(); + // Initialize at 0 + e.update(0.0); + // Jump to 100 + for _ in 0..20 { + e.update(100.0); + } + // Short should be closer to 100 than long + assert!( + e.short() > e.long(), + "short={} long={}", + e.short(), + e.long() + ); +} + +#[test] +fn test_owd_trend_flat() { + let mut d = OwdTrendDetector::new(32); + for i in 0..20 { + d.push(i, 5000); // constant OWD + } + let trend = d.trend_us_per_sec(); + assert_eq!(trend, 0, "flat OWD should have zero trend"); +} + +#[test] +fn test_owd_trend_increasing() { + let mut d = OwdTrendDetector::new(32); + for i in 0..20 { + d.push(i, 5000 + (i as i64) * 100); // increasing by 100µs per packet + } + let trend = d.trend_us_per_sec(); + assert!( + trend > 0, + "increasing OWD should have positive trend, got {trend}" + ); +} + +#[test] +fn test_owd_trend_insufficient_samples() { + let mut d = OwdTrendDetector::new(32); + d.push(0, 5000); + assert_eq!(d.trend_us_per_sec(), 0); +} + +#[test] +fn test_etx_perfect_link() { + assert!((compute_etx(1.0, 1.0) - 1.0).abs() < f64::EPSILON); +} + +#[test] +fn test_etx_lossy_link() { + // 10% forward loss, 5% reverse loss + let etx = compute_etx(0.9, 0.95); + assert!(etx > 1.0 && etx < 2.0, "etx={etx}"); +} + +#[test] +fn test_etx_zero_delivery() { + assert_eq!(compute_etx(0.0, 1.0), 100.0); + assert_eq!(compute_etx(1.0, 0.0), 100.0); +} + +#[test] +fn test_spin_bit_initiator_rtt() { + let mut initiator = SpinBitState::new(true); + let mut responder = SpinBitState::new(false); + + // Injected monotonic milliseconds: t0=0, t1=10ms, t2=20ms. + let t0 = 0u64; + let t1 = 10u64; + let t2 = 20u64; + + // Initiator sends with spin=false (initial) + let bit_to_send = initiator.tx_bit(); + assert!(!bit_to_send); + + // Responder receives, copies bit + responder.rx_observe(bit_to_send, 1, t0); + assert!(!responder.tx_bit()); + + // Responder sends back, initiator receives + let resp_bit = responder.tx_bit(); + let rtt1 = initiator.rx_observe(resp_bit, 2, t1); + // First edge: no previous edge to compare + assert!(rtt1.is_none()); + + // Now initiator's spin flipped to true + let bit2 = initiator.tx_bit(); + assert!(bit2); + + // Responder receives new bit + responder.rx_observe(bit2, 3, t1); + assert!(responder.tx_bit()); + + // Responder sends back, initiator receives + let resp_bit2 = responder.tx_bit(); + let rtt2 = initiator.rx_observe(resp_bit2, 4, t2); + // Second edge: should produce an RTT sample + assert!(rtt2.is_some()); +} + +#[test] +fn test_spin_bit_responder_counter_guard() { + let mut responder = SpinBitState::new(false); + + // Receive counter=5 with spin=true + responder.rx_observe(true, 5, 0); + assert!(responder.tx_bit()); + + // Reordered packet with counter=3 and spin=false should be ignored + responder.rx_observe(false, 3, 0); + assert!(responder.tx_bit()); // unchanged +} diff --git a/src/proto/mmp/tests/core.rs b/src/proto/mmp/tests/core.rs new file mode 100644 index 0000000..7fbccf7 --- /dev/null +++ b/src/proto/mmp/tests/core.rs @@ -0,0 +1,506 @@ +//! Tests for the sans-IO MMP reporting decision core. + +use crate::proto::mmp::{ + BackoffUpdate, LinkReportKind, LinkReportSnapshot, Mmp, MmpAction, MmpMode, + PeerLivenessSnapshot, SendResult, SessionReportKind, SessionReportSnapshot, +}; +use crate::testutil::make_node_addr; + +/// Build a liveness snapshot for `peer` with the three pre-evaluated predicates. +fn snap( + peer: u8, + time_dead: bool, + rekey_active: bool, + heartbeat_due: bool, +) -> PeerLivenessSnapshot { + PeerLivenessSnapshot { + peer: make_node_addr(peer), + time_dead, + rekey_active, + heartbeat_due, + } +} + +#[test] +fn empty_snapshot_set_yields_no_actions() { + let mmp = Mmp::new(); + assert!(mmp.plan_heartbeats(&[]).is_empty()); +} + +#[test] +fn dead_peer_is_reaped_and_not_heartbeated() { + let mmp = Mmp::new(); + // time_dead, no rekey, heartbeat also nominally due: reap wins, no heartbeat. + let actions = mmp.plan_heartbeats(&[snap(0x11, true, false, true)]); + assert_eq!(actions.len(), 1); + assert!(matches!(actions[0], MmpAction::ReapPeer { peer } if peer == make_node_addr(0x11))); +} + +#[test] +fn rekey_active_suppresses_reap_even_when_dead() { + let mmp = Mmp::new(); + // time_dead but rekey in flight → not reaped; heartbeat still gated on due. + let actions = mmp.plan_heartbeats(&[snap(0x22, true, true, false)]); + assert!(actions.is_empty()); +} + +#[test] +fn rekey_active_and_dead_and_due_sends_heartbeat_not_reap() { + let mmp = Mmp::new(); + // Rekey suppresses the reap; the peer is alive for scheduling and due. + let actions = mmp.plan_heartbeats(&[snap(0x23, true, true, true)]); + assert_eq!(actions.len(), 1); + assert!(matches!(actions[0], MmpAction::Heartbeat { peer } if peer == make_node_addr(0x23))); +} + +#[test] +fn alive_due_peer_gets_heartbeat() { + let mmp = Mmp::new(); + let actions = mmp.plan_heartbeats(&[snap(0x33, false, false, true)]); + assert_eq!(actions.len(), 1); + assert!(matches!(actions[0], MmpAction::Heartbeat { peer } if peer == make_node_addr(0x33))); +} + +#[test] +fn alive_peer_not_due_yields_no_action() { + let mmp = Mmp::new(); + assert!( + mmp.plan_heartbeats(&[snap(0x44, false, false, false)]) + .is_empty() + ); +} + +#[test] +fn reaps_precede_heartbeats_in_returned_order() { + let mmp = Mmp::new(); + // Mixed set in peer order: alive-due, dead, alive-due, dead. The returned + // Vec must group all reaps first, then all heartbeats (the pre-refactor + // two-loop order). + let actions = mmp.plan_heartbeats(&[ + snap(0x01, false, false, true), + snap(0x02, true, false, false), + snap(0x03, false, false, true), + snap(0x04, true, false, false), + ]); + assert_eq!(actions.len(), 4); + assert!(matches!(actions[0], MmpAction::ReapPeer { peer } if peer == make_node_addr(0x02))); + assert!(matches!(actions[1], MmpAction::ReapPeer { peer } if peer == make_node_addr(0x04))); + assert!(matches!(actions[2], MmpAction::Heartbeat { peer } if peer == make_node_addr(0x01))); + assert!(matches!(actions[3], MmpAction::Heartbeat { peer } if peer == make_node_addr(0x03))); +} + +// =========================================================================== +// plan_link_reports — link-layer report fan-out +// =========================================================================== + +/// Build a link-report snapshot for `peer` with the mode, profile flags, and +/// pre-evaluated timing gates. +#[allow(clippy::too_many_arguments)] +fn link_snap( + peer: u8, + mode: MmpMode, + send_sr: bool, + send_rr: bool, + sr_due: bool, + rr_due: bool, + log_due: bool, +) -> LinkReportSnapshot { + LinkReportSnapshot { + peer: make_node_addr(peer), + mode, + send_sr, + send_rr, + sr_due, + rr_due, + log_due, + } +} + +#[test] +fn link_empty_snapshot_set_yields_no_actions() { + let mmp = Mmp::new(); + assert!(mmp.plan_link_reports(&[]).is_empty()); +} + +#[test] +fn link_full_mode_emits_sender_then_receiver() { + let mmp = Mmp::new(); + let actions = mmp.plan_link_reports(&[link_snap( + 0x11, + MmpMode::Full, + true, + true, + true, + true, + false, + )]); + assert_eq!(actions.len(), 2); + assert!(matches!( + actions[0], + MmpAction::SendLinkReport { peer, kind: LinkReportKind::Sender } if peer == make_node_addr(0x11) + )); + assert!(matches!( + actions[1], + MmpAction::SendLinkReport { peer, kind: LinkReportKind::Receiver } if peer == make_node_addr(0x11) + )); +} + +#[test] +fn link_lightweight_mode_emits_receiver_only() { + let mmp = Mmp::new(); + let actions = mmp.plan_link_reports(&[link_snap( + 0x22, + MmpMode::Lightweight, + true, + true, + true, + true, + false, + )]); + assert_eq!(actions.len(), 1); + assert!(matches!( + actions[0], + MmpAction::SendLinkReport { peer, kind: LinkReportKind::Receiver } if peer == make_node_addr(0x22) + )); +} + +#[test] +fn link_minimal_mode_emits_no_reports() { + let mmp = Mmp::new(); + let actions = mmp.plan_link_reports(&[link_snap( + 0x33, + MmpMode::Minimal, + true, + true, + true, + true, + false, + )]); + assert!(actions.is_empty()); +} + +#[test] +fn link_send_sr_false_suppresses_sender_report() { + let mmp = Mmp::new(); + // Full mode, sender due, but the profile does not provide the SenderReport. + let actions = mmp.plan_link_reports(&[link_snap( + 0x44, + MmpMode::Full, + false, + true, + true, + true, + false, + )]); + assert_eq!(actions.len(), 1); + assert!(matches!( + actions[0], + MmpAction::SendLinkReport { peer, kind: LinkReportKind::Receiver } if peer == make_node_addr(0x44) + )); +} + +#[test] +fn link_send_rr_false_suppresses_receiver_report() { + let mmp = Mmp::new(); + // Full mode, receiver due, but the profile does not provide the ReceiverReport. + let actions = mmp.plan_link_reports(&[link_snap( + 0x55, + MmpMode::Full, + true, + false, + true, + true, + false, + )]); + assert_eq!(actions.len(), 1); + assert!(matches!( + actions[0], + MmpAction::SendLinkReport { peer, kind: LinkReportKind::Sender } if peer == make_node_addr(0x55) + )); +} + +#[test] +fn link_not_due_yields_no_reports() { + let mmp = Mmp::new(); + // Full mode, profiles provide both, but neither interval is due. + let actions = mmp.plan_link_reports(&[link_snap( + 0x66, + MmpMode::Full, + true, + true, + false, + false, + false, + )]); + assert!(actions.is_empty()); +} + +#[test] +fn link_log_due_emits_log_marker() { + let mmp = Mmp::new(); + // Minimal mode (no reports), but the operator-log interval is due. + let actions = mmp.plan_link_reports(&[link_snap( + 0x77, + MmpMode::Minimal, + true, + true, + false, + false, + true, + )]); + assert_eq!(actions.len(), 1); + assert!(matches!(actions[0], MmpAction::LogLink { peer } if peer == make_node_addr(0x77))); +} + +#[test] +fn link_actions_are_phase_grouped_logs_senders_receivers() { + let mmp = Mmp::new(); + // Two Full peers, both due for SR+RR+log. The returned Vec must group all + // log markers (peer order) FIRST — they read cumulative_packets_sent, which + // the report sends advance, so the pre-refactor handler logged before any + // send — then all SenderReports (peer order), then all ReceiverReports. + let actions = mmp.plan_link_reports(&[ + link_snap(0x01, MmpMode::Full, true, true, true, true, true), + link_snap(0x02, MmpMode::Full, true, true, true, true, true), + ]); + assert_eq!(actions.len(), 6); + assert!(matches!(actions[0], MmpAction::LogLink { peer } if peer == make_node_addr(0x01))); + assert!(matches!(actions[1], MmpAction::LogLink { peer } if peer == make_node_addr(0x02))); + assert!(matches!( + actions[2], + MmpAction::SendLinkReport { peer, kind: LinkReportKind::Sender } if peer == make_node_addr(0x01) + )); + assert!(matches!( + actions[3], + MmpAction::SendLinkReport { peer, kind: LinkReportKind::Sender } if peer == make_node_addr(0x02) + )); + assert!(matches!( + actions[4], + MmpAction::SendLinkReport { peer, kind: LinkReportKind::Receiver } if peer == make_node_addr(0x01) + )); + assert!(matches!( + actions[5], + MmpAction::SendLinkReport { peer, kind: LinkReportKind::Receiver } if peer == make_node_addr(0x02) + )); +} + +// =========================================================================== +// plan_session_reports — session-layer report fan-out +// =========================================================================== + +/// Build a session-report snapshot for `dest` with the mode and pre-evaluated +/// timing gates. +fn sess_snap( + dest: u8, + mode: MmpMode, + sr_due: bool, + rr_due: bool, + mtu_due: bool, + log_due: bool, +) -> SessionReportSnapshot { + SessionReportSnapshot { + dest: make_node_addr(dest), + mode, + sr_due, + rr_due, + mtu_due, + log_due, + } +} + +#[test] +fn session_empty_snapshot_set_yields_no_actions() { + let mmp = Mmp::new(); + assert!(mmp.plan_session_reports(&[]).is_empty()); +} + +#[test] +fn session_full_mode_emits_sender_receiver_mtu() { + let mmp = Mmp::new(); + let actions = + mmp.plan_session_reports(&[sess_snap(0x11, MmpMode::Full, true, true, true, false)]); + assert_eq!(actions.len(), 3); + assert!(matches!( + actions[0], + MmpAction::SendSessionReport { dest, kind: SessionReportKind::Sender } if dest == make_node_addr(0x11) + )); + assert!(matches!( + actions[1], + MmpAction::SendSessionReport { dest, kind: SessionReportKind::Receiver } if dest == make_node_addr(0x11) + )); + assert!(matches!( + actions[2], + MmpAction::SendSessionReport { dest, kind: SessionReportKind::PathMtu } if dest == make_node_addr(0x11) + )); +} + +#[test] +fn session_lightweight_mode_emits_receiver_and_mtu_no_sender() { + let mmp = Mmp::new(); + let actions = mmp.plan_session_reports(&[sess_snap( + 0x22, + MmpMode::Lightweight, + true, + true, + true, + false, + )]); + assert_eq!(actions.len(), 2); + assert!(matches!( + actions[0], + MmpAction::SendSessionReport { dest, kind: SessionReportKind::Receiver } if dest == make_node_addr(0x22) + )); + assert!(matches!( + actions[1], + MmpAction::SendSessionReport { dest, kind: SessionReportKind::PathMtu } if dest == make_node_addr(0x22) + )); +} + +#[test] +fn session_minimal_mode_emits_only_path_mtu() { + let mmp = Mmp::new(); + // Minimal suppresses both reports; the PathMtu gate is mode-independent. + let actions = + mmp.plan_session_reports(&[sess_snap(0x33, MmpMode::Minimal, true, true, true, false)]); + assert_eq!(actions.len(), 1); + assert!(matches!( + actions[0], + MmpAction::SendSessionReport { dest, kind: SessionReportKind::PathMtu } if dest == make_node_addr(0x33) + )); +} + +#[test] +fn session_mtu_gate_is_mode_independent_even_when_not_due_elsewhere() { + let mmp = Mmp::new(); + // Full mode, nothing due except the MTU notification. + let actions = + mmp.plan_session_reports(&[sess_snap(0x44, MmpMode::Full, false, false, true, false)]); + assert_eq!(actions.len(), 1); + assert!(matches!( + actions[0], + MmpAction::SendSessionReport { dest, kind: SessionReportKind::PathMtu } if dest == make_node_addr(0x44) + )); +} + +#[test] +fn session_not_due_yields_no_reports() { + let mmp = Mmp::new(); + let actions = + mmp.plan_session_reports(&[sess_snap(0x55, MmpMode::Full, false, false, false, false)]); + assert!(actions.is_empty()); +} + +#[test] +fn session_log_due_emits_log_marker_only() { + let mmp = Mmp::new(); + // Minimal mode, no MTU due, but the operator log is due. + let actions = + mmp.plan_session_reports(&[sess_snap(0x66, MmpMode::Minimal, true, true, false, true)]); + assert_eq!(actions.len(), 1); + assert!(matches!(actions[0], MmpAction::LogSession { dest } if dest == make_node_addr(0x66))); +} + +#[test] +fn session_logs_precede_sends_across_sessions() { + let mmp = Mmp::new(); + // Two Full sessions, both due for SR+RR+MTU+log. All LogSession markers (in + // session order) must come FIRST — they read cumulative_packets_sent, which + // the sends advance, so the pre-refactor handler logged before any send — + // then the sends in per-session SR/RR/MTU order. + let actions = mmp.plan_session_reports(&[ + sess_snap(0x01, MmpMode::Full, true, true, true, true), + sess_snap(0x02, MmpMode::Full, true, true, true, true), + ]); + assert_eq!(actions.len(), 8); + assert!(matches!(actions[0], MmpAction::LogSession { dest } if dest == make_node_addr(0x01))); + assert!(matches!(actions[1], MmpAction::LogSession { dest } if dest == make_node_addr(0x02))); + assert!(matches!( + actions[2], + MmpAction::SendSessionReport { dest, kind: SessionReportKind::Sender } if dest == make_node_addr(0x01) + )); + assert!(matches!( + actions[3], + MmpAction::SendSessionReport { dest, kind: SessionReportKind::Receiver } if dest == make_node_addr(0x01) + )); + assert!(matches!( + actions[4], + MmpAction::SendSessionReport { dest, kind: SessionReportKind::PathMtu } if dest == make_node_addr(0x01) + )); + assert!(matches!( + actions[5], + MmpAction::SendSessionReport { dest, kind: SessionReportKind::Sender } if dest == make_node_addr(0x02) + )); + assert!(matches!( + actions[6], + MmpAction::SendSessionReport { dest, kind: SessionReportKind::Receiver } if dest == make_node_addr(0x02) + )); + assert!(matches!( + actions[7], + MmpAction::SendSessionReport { dest, kind: SessionReportKind::PathMtu } if dest == make_node_addr(0x02) + )); +} + +// =========================================================================== +// plan_backoff — per-destination success/failure dedup reduction +// =========================================================================== + +fn result(dest: u8, ok: bool) -> SendResult { + SendResult { + dest: make_node_addr(dest), + ok, + } +} + +#[test] +fn backoff_empty_yields_no_updates() { + let mmp = Mmp::new(); + assert!(mmp.plan_backoff(&[]).is_empty()); +} + +#[test] +fn backoff_partial_success_counts_as_success() { + let mmp = Mmp::new(); + // One report to the dest succeeded, one failed → exactly ONE Success. This is + // the partial-success path the real handler oracle cannot reach (its sends + // all fail with no route). + let updates = mmp.plan_backoff(&[result(0x11, true), result(0x11, false)]); + assert_eq!(updates.len(), 1); + assert!(matches!(updates[0], BackoffUpdate::Success { dest } if dest == make_node_addr(0x11))); +} + +#[test] +fn backoff_all_success_counts_as_success() { + let mmp = Mmp::new(); + // The success-side path: all reports to the dest succeeded → one Success. + // Unreachable through the real handler (its sends all fail). + let updates = mmp.plan_backoff(&[result(0x22, true), result(0x22, true)]); + assert_eq!(updates.len(), 1); + assert!(matches!(updates[0], BackoffUpdate::Success { dest } if dest == make_node_addr(0x22))); +} + +#[test] +fn backoff_all_fail_counts_as_single_failure() { + let mmp = Mmp::new(); + // Two failed reports to one dest collapse to exactly ONE Failure. + let updates = mmp.plan_backoff(&[result(0x33, false), result(0x33, false)]); + assert_eq!(updates.len(), 1); + assert!(matches!(updates[0], BackoffUpdate::Failure { dest } if dest == make_node_addr(0x33))); +} + +#[test] +fn backoff_multiple_dests_mixed_dedup_independently() { + let mmp = Mmp::new(); + // dest 0x01: partial success → Success; dest 0x02: all fail → Failure; + // dest 0x03: all success → Success. Emission is address-sorted (BTreeMap). + let updates = mmp.plan_backoff(&[ + result(0x02, false), + result(0x01, true), + result(0x03, true), + result(0x01, false), + result(0x02, false), + ]); + assert_eq!(updates.len(), 3); + assert!(matches!(updates[0], BackoffUpdate::Success { dest } if dest == make_node_addr(0x01))); + assert!(matches!(updates[1], BackoffUpdate::Failure { dest } if dest == make_node_addr(0x02))); + assert!(matches!(updates[2], BackoffUpdate::Success { dest } if dest == make_node_addr(0x03))); +} diff --git a/src/proto/mmp/tests/mod.rs b/src/proto/mmp/tests/mod.rs new file mode 100644 index 0000000..58b62c6 --- /dev/null +++ b/src/proto/mmp/tests/mod.rs @@ -0,0 +1,6 @@ +//! MMP reporting subsystem unit tests. + +mod algorithms; +mod core; +mod state; +mod wire; diff --git a/src/proto/mmp/tests/state.rs b/src/proto/mmp/tests/state.rs new file mode 100644 index 0000000..790cf77 --- /dev/null +++ b/src/proto/mmp/tests/state.rs @@ -0,0 +1,697 @@ +//! Tests for the owned MMP protocol state machines. + +use crate::proto::mmp::state::{ReceiverState, SenderState}; +use crate::proto::mmp::{ + COLD_START_SAMPLES, DEFAULT_LOG_INTERVAL_SECS, DEFAULT_OWD_WINDOW_SIZE, MAX_REPORT_INTERVAL_MS, + MIN_REPORT_INTERVAL_MS, MmpMetrics, MmpMode, ReceiverReport, +}; + +// =========================================================================== +// MmpMode +// =========================================================================== + +#[test] +fn test_mode_default() { + assert_eq!(MmpMode::default(), MmpMode::Full); +} + +#[test] +fn test_mode_display() { + assert_eq!(MmpMode::Full.to_string(), "full"); + assert_eq!(MmpMode::Lightweight.to_string(), "lightweight"); + assert_eq!(MmpMode::Minimal.to_string(), "minimal"); +} + +#[test] +fn test_mode_serde_roundtrip() { + let yaml = "full"; + let mode: MmpMode = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(mode, MmpMode::Full); + + let yaml = "lightweight"; + let mode: MmpMode = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(mode, MmpMode::Lightweight); + + let yaml = "minimal"; + let mode: MmpMode = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(mode, MmpMode::Minimal); +} + +// Sanity: the config defaults that back `MmpConfig`/`SessionMmpConfig` are the +// values the state constructors expect. +#[test] +fn test_config_default_consts() { + assert_eq!(DEFAULT_LOG_INTERVAL_SECS, 30); + assert_eq!(DEFAULT_OWD_WINDOW_SIZE, 32); +} + +// =========================================================================== +// SenderState +// =========================================================================== + +#[test] +fn test_new_sender_state() { + let s = SenderState::new(); + assert_eq!(s.cumulative_packets_sent(), 0); + assert_eq!(s.cumulative_bytes_sent(), 0); +} + +#[test] +fn test_record_sent() { + let mut s = SenderState::new(); + s.record_sent(1, 100, 500); + s.record_sent(2, 200, 600); + assert_eq!(s.cumulative_packets_sent(), 2); + assert_eq!(s.cumulative_bytes_sent(), 1100); +} + +#[test] +fn test_build_report_empty() { + let mut s = SenderState::new(); + assert!(s.build_report(0).is_none()); +} + +#[test] +fn test_build_report() { + let mut s = SenderState::new(); + s.record_sent(10, 1000, 500); + s.record_sent(11, 1100, 600); + s.record_sent(12, 1200, 400); + + let report = s.build_report(0).unwrap(); + assert_eq!(report.interval_start_counter, 10); + assert_eq!(report.interval_end_counter, 12); + assert_eq!(report.interval_start_timestamp, 1000); + assert_eq!(report.interval_end_timestamp, 1200); + assert_eq!(report.interval_bytes_sent, 1500); + assert_eq!(report.cumulative_packets_sent, 3); + assert_eq!(report.cumulative_bytes_sent, 1500); +} + +#[test] +fn test_build_report_resets_interval() { + let mut s = SenderState::new(); + s.record_sent(1, 100, 500); + let _ = s.build_report(0); + + // Second report with no new data returns None + assert!(s.build_report(0).is_none()); + + // New data starts a fresh interval + s.record_sent(2, 200, 300); + let report = s.build_report(0).unwrap(); + assert_eq!(report.interval_start_counter, 2); + assert_eq!(report.interval_bytes_sent, 300); + // Cumulative continues + assert_eq!(report.cumulative_packets_sent, 2); + assert_eq!(report.cumulative_bytes_sent, 800); +} + +#[test] +fn test_should_send_report_no_data() { + let s = SenderState::new(); + assert!(!s.should_send_report(0)); +} + +#[test] +fn test_should_send_report_first_time() { + let mut s = SenderState::new(); + s.record_sent(1, 100, 500); + assert!(s.should_send_report(0)); +} + +#[test] +fn test_should_send_report_respects_interval() { + let mut s = SenderState::new(); + let t0 = 0u64; + s.record_sent(1, 100, 500); + let _ = s.build_report(t0); + + s.record_sent(2, 200, 500); + // Immediately after report — should not send + assert!(!s.should_send_report(t0)); + + // After interval elapses + let t1 = t0 + s.report_interval_ms() + 1; + assert!(s.should_send_report(t1)); +} + +#[test] +fn test_update_report_interval_cold_start() { + let mut s = SenderState::new(); + // During cold-start, floor is 200ms (DEFAULT_COLD_START_INTERVAL_MS) + // 50ms RTT → 100ms sender interval (2× SRTT), clamped to cold-start floor 200ms + s.update_report_interval_from_srtt(50_000); + assert_eq!(s.report_interval_ms(), 200); + + // 500ms RTT → 1000ms sender interval (above cold-start floor) + s.update_report_interval_from_srtt(500_000); + assert_eq!(s.report_interval_ms(), 1000); +} + +#[test] +fn test_update_report_interval_after_cold_start() { + let mut s = SenderState::new(); + // Burn through cold-start samples (COLD_START_SAMPLES = 5) + for _ in 0..COLD_START_SAMPLES { + s.update_report_interval_from_srtt(500_000); + } + + // 6th sample: now in steady state, floor is MIN_REPORT_INTERVAL_MS (1000ms) + // 50ms RTT → 100ms sender interval (2× SRTT), clamped to 1000ms + s.update_report_interval_from_srtt(50_000); + assert_eq!(s.report_interval_ms(), MIN_REPORT_INTERVAL_MS); + + // 3s RTT → 6s, clamped to max 5s + s.update_report_interval_from_srtt(3_000_000); + assert_eq!(s.report_interval_ms(), MAX_REPORT_INTERVAL_MS); +} + +#[test] +fn test_backoff_multiplier_progression() { + let mut s = SenderState::new(); + + // No failures → multiplier 1.0 + assert_eq!(s.send_failure_backoff_multiplier(), 1.0); + assert_eq!(s.consecutive_send_failures(), 0); + + // Progressive failures: 2^1, 2^2, 2^3, 2^4, 2^5 + let expected = [2.0, 4.0, 8.0, 16.0, 32.0]; + for (i, &exp) in expected.iter().enumerate() { + let count = s.record_send_failure(); + assert_eq!(count, (i + 1) as u32); + assert_eq!(s.send_failure_backoff_multiplier(), exp); + } + + // Beyond 5 failures: stays capped at 32.0 + s.record_send_failure(); // 6th + assert_eq!(s.send_failure_backoff_multiplier(), 32.0); + s.record_send_failure(); // 7th + assert_eq!(s.send_failure_backoff_multiplier(), 32.0); +} + +#[test] +fn test_backoff_reset_on_success() { + let mut s = SenderState::new(); + + // Accumulate failures + s.record_send_failure(); + s.record_send_failure(); + s.record_send_failure(); + assert_eq!(s.consecutive_send_failures(), 3); + assert_eq!(s.send_failure_backoff_multiplier(), 8.0); + + // Success resets and returns previous count + let prev = s.record_send_success(); + assert_eq!(prev, 3); + assert_eq!(s.consecutive_send_failures(), 0); + assert_eq!(s.send_failure_backoff_multiplier(), 1.0); +} + +#[test] +fn test_backoff_success_with_no_prior_failures() { + let mut s = SenderState::new(); + + // Success with no failures returns 0 + let prev = s.record_send_success(); + assert_eq!(prev, 0); + assert_eq!(s.consecutive_send_failures(), 0); +} + +#[test] +fn test_should_send_report_respects_backoff() { + let mut s = SenderState::new(); + let t0 = 0u64; + s.record_sent(1, 100, 500); + let _ = s.build_report(t0); + + // Record a failure: multiplier becomes 2.0 + s.record_send_failure(); + + s.record_sent(2, 200, 500); + + // At 1× interval: should NOT send (backoff requires 2×) + let t1 = t0 + s.report_interval_ms() + 1; + assert!(!s.should_send_report(t1)); + + // At 2× interval: should send + let t2 = t0 + s.report_interval_ms() * 2 + 1; + assert!(s.should_send_report(t2)); +} + +// =========================================================================== +// ReceiverState +// =========================================================================== + +#[test] +fn test_new_receiver_state() { + let r = ReceiverState::new(32); + assert_eq!(r.cumulative_packets_recv(), 0); + assert_eq!(r.cumulative_bytes_recv(), 0); + assert_eq!(r.highest_counter(), 0); +} + +#[test] +fn test_record_recv_basic() { + let mut r = ReceiverState::new(32); + r.record_recv(1, 100, 500, false, 0); + r.record_recv(2, 200, 600, false, 100); + + assert_eq!(r.cumulative_packets_recv(), 2); + assert_eq!(r.cumulative_bytes_recv(), 1100); + assert_eq!(r.highest_counter(), 2); +} + +#[test] +fn test_reorder_detection() { + let mut r = ReceiverState::new(32); + r.record_recv(5, 500, 100, false, 0); + r.record_recv(3, 300, 100, false, 10); + + // Reorder count is surfaced through the report (no direct field accessor). + let rr = r.build_report(20).unwrap(); + assert_eq!(rr.cumulative_reorder_count, 1); + assert_eq!(r.highest_counter(), 5); // not changed by out-of-order +} + +#[test] +fn test_ecn_counting() { + let mut r = ReceiverState::new(32); + r.record_recv(1, 100, 100, true, 0); + r.record_recv(2, 200, 100, false, 0); + r.record_recv(3, 300, 100, true, 0); + + assert_eq!(r.ecn_ce_count(), 2); +} + +#[test] +fn test_receiver_build_report_empty() { + let mut r = ReceiverState::new(32); + assert!(r.build_report(0).is_none()); +} + +#[test] +fn test_receiver_build_report() { + let mut r = ReceiverState::new(32); + let t0 = 0u64; + r.record_recv(1, 100, 500, false, t0); + r.record_recv(2, 200, 600, false, t0 + 100); + + let report = r.build_report(t0 + 150).unwrap(); + assert_eq!(report.highest_counter, 2); + assert_eq!(report.cumulative_packets_recv, 2); + assert_eq!(report.cumulative_bytes_recv, 1100); + assert_eq!(report.timestamp_echo, 200); // last sender timestamp + assert_eq!(report.interval_packets_recv, 2); + assert_eq!(report.interval_bytes_recv, 1100); +} + +#[test] +fn test_build_report_suppresses_rtt_echo_when_dwell_overflows() { + let mut r = ReceiverState::new(32); + let t0 = 0u64; + r.record_recv(1, 100, 500, false, t0); + + let report = r.build_report(t0 + u64::from(u16::MAX) + 1).unwrap(); + + assert_eq!(report.timestamp_echo, 0); + assert_eq!(report.dwell_time, u16::MAX); + assert_eq!(report.cumulative_packets_recv, 1); +} + +#[test] +fn test_receiver_build_report_resets_interval() { + let mut r = ReceiverState::new(32); + let t0 = 0u64; + r.record_recv(1, 100, 500, false, t0); + let _ = r.build_report(t0); + + // No new data + assert!(r.build_report(t0).is_none()); + + // New data + r.record_recv(2, 200, 300, false, t0 + 100); + let report = r.build_report(t0 + 150).unwrap(); + assert_eq!(report.interval_packets_recv, 1); + assert_eq!(report.interval_bytes_recv, 300); + // Cumulative continues + assert_eq!(report.cumulative_packets_recv, 2); +} + +// The private `GapTracker` burst detection is exercised through the public +// `ReceiverState` report surface (counter gaps -> burst-loss report fields). + +#[test] +fn test_gap_tracker_no_loss() { + let mut r = ReceiverState::new(32); + r.record_recv(1, 0, 100, false, 0); + r.record_recv(2, 0, 100, false, 0); + r.record_recv(3, 0, 100, false, 0); + let rr = r.build_report(0).unwrap(); + assert_eq!(rr.burst_loss_count, 0); + assert_eq!(rr.max_burst_loss, 0); + assert_eq!(rr.mean_burst_loss, 0); +} + +#[test] +fn test_gap_tracker_single_burst() { + let mut r = ReceiverState::new(32); + r.record_recv(1, 0, 100, false, 0); + // frames 2, 3 lost + r.record_recv(4, 0, 100, false, 0); + r.record_recv(5, 0, 100, false, 0); + let rr = r.build_report(0).unwrap(); + assert_eq!(rr.burst_loss_count, 1); + assert_eq!(rr.max_burst_loss, 2); +} + +#[test] +fn test_gap_tracker_multiple_bursts() { + let mut r = ReceiverState::new(32); + r.record_recv(1, 0, 100, false, 0); + r.record_recv(4, 0, 100, false, 0); // burst of 2 (frames 2,3 lost) + r.record_recv(5, 0, 100, false, 0); + r.record_recv(8, 0, 100, false, 0); // burst of 2 (frames 6,7 lost) + r.record_recv(9, 0, 100, false, 0); + let rr = r.build_report(0).unwrap(); + assert_eq!(rr.burst_loss_count, 2); + assert_eq!(rr.max_burst_loss, 2); + // mean = 2.0 in u8.8 = 512 + assert_eq!(rr.mean_burst_loss, 512); +} + +#[test] +fn test_should_send_report_timing() { + let mut r = ReceiverState::new(32); + let t0 = 0u64; + + assert!(!r.should_send_report(t0)); // no data + + r.record_recv(1, 100, 500, false, t0); + assert!(r.should_send_report(t0)); // first time, has data + + let _ = r.build_report(t0); + r.record_recv(2, 200, 500, false, t0); + assert!(!r.should_send_report(t0)); // just reported + + let t1 = t0 + r.report_interval_ms() + 1; + assert!(r.should_send_report(t1)); +} + +#[test] +fn test_receiver_update_report_interval_cold_start() { + let mut r = ReceiverState::new(32); + // During cold-start, floor is 200ms (DEFAULT_COLD_START_INTERVAL_MS) + // 50ms SRTT → 50ms receiver interval (1× SRTT), clamped to cold-start floor 200ms + r.update_report_interval_from_srtt(50_000); + assert_eq!(r.report_interval_ms(), 200); + + // 500ms SRTT → 500ms (above cold-start floor) + r.update_report_interval_from_srtt(500_000); + assert_eq!(r.report_interval_ms(), 500); +} + +#[test] +fn test_receiver_update_report_interval_after_cold_start() { + let mut r = ReceiverState::new(32); + // Burn through cold-start samples + for _ in 0..COLD_START_SAMPLES { + r.update_report_interval_from_srtt(500_000); + } + + // 6th sample: steady state, floor is MIN_REPORT_INTERVAL_MS (1000ms) + // 50ms SRTT → 50ms receiver interval (1× SRTT), clamped to 1000ms + r.update_report_interval_from_srtt(50_000); + assert_eq!(r.report_interval_ms(), MIN_REPORT_INTERVAL_MS); + + // 3s SRTT → 3000ms, within [1000, 5000] + r.update_report_interval_from_srtt(3_000_000); + assert_eq!(r.report_interval_ms(), 3000); +} + +#[test] +fn test_rekey_jitter_grace_suppresses_spikes() { + let mut r = ReceiverState::new(32); + let t0 = 0u64; + + // Establish baseline with two frames so jitter starts updating + r.record_recv(1, 1000, 100, false, t0); + r.record_recv(2, 2000, 100, false, t0 + 1000); + assert_eq!(r.jitter_us(), 0); // perfect 1s spacing → 0 jitter + + // Simulate rekey: reset, then send a frame with a large old-session + // timestamp followed by a new-session timestamp near zero. + // Without grace, this would produce a huge jitter spike. + r.reset_for_rekey(t0 + 2000); + + // Frame arrives during grace period with old-session timestamp + r.record_recv(0, 120_000, 100, false, t0 + 3000); + // Next frame with new-session timestamp near zero + r.record_recv(1, 100, 100, false, t0 + 4000); + // Jitter should still be zero — updates suppressed during grace + assert_eq!(r.jitter_us(), 0); + + // After grace expires, jitter updates resume. Grace = 15s from reset. + let after_grace = t0 + 2000 + (15 + 1) * 1000; + r.record_recv(2, 200, 100, false, after_grace); + r.record_recv(3, 300, 100, false, after_grace + 100); + // Now jitter should be updating (non-zero or zero depending on timing) + // The key assertion is that it's not a multi-second spike + assert!(r.jitter_us() < 1_000_000); // less than 1 second +} + +// =========================================================================== +// MmpMetrics +// =========================================================================== + +fn make_rr( + highest_counter: u64, + cum_packets: u64, + cum_bytes: u64, + timestamp_echo: u32, + dwell: u16, + jitter: u32, +) -> ReceiverReport { + ReceiverReport { + highest_counter, + cumulative_packets_recv: cum_packets, + cumulative_bytes_recv: cum_bytes, + timestamp_echo, + dwell_time: dwell, + max_burst_loss: 0, + mean_burst_loss: 0, + jitter, + ecn_ce_count: 0, + owd_trend: 0, + burst_loss_count: 0, + cumulative_reorder_count: 0, + interval_packets_recv: 0, + interval_bytes_recv: 0, + } +} + +#[test] +fn test_rtt_from_echo() { + let mut m = MmpMetrics::new(); + // Peer echoes timestamp 1000ms, dwell=5ms, our current time=1050ms + let rr = make_rr(10, 10, 5000, 1000, 5, 0); + m.process_receiver_report(&rr, 1050, 0); + + assert!(m.srtt.initialized()); + // RTT = 1050 - 1000 - 5 = 45ms + let srtt_ms = m.srtt_ms().unwrap(); + assert!((srtt_ms - 45.0).abs() < 1.0, "srtt={srtt_ms}, expected ~45"); +} + +#[test] +fn test_ignores_duplicate_receiver_report_after_valid_sample() { + let mut m = MmpMetrics::new(); + let t0 = 0u64; + + let rr1 = make_rr(10, 10, 5_000, 1_000, 5, 0); + m.process_receiver_report(&rr1, 1_050, t0); + + let rr2 = make_rr(20, 18, 14_000, 1_100, 5, 0); + m.process_receiver_report(&rr2, 1_150, t0 + 1000); + let baseline_srtt_ms = m.srtt_ms().unwrap(); + let baseline_loss = m.loss_rate(); + let baseline_goodput = m.goodput_bps(); + + assert!(baseline_loss > 0.0); + assert!(baseline_goodput > 0.0); + + // A duplicate of the same counters arriving later would be a 4.895s + // RTT sample if accepted. It is stale and must not move metrics. + m.process_receiver_report(&rr2, 6_000, t0 + 5000); + + assert_eq!(m.srtt_ms().unwrap(), baseline_srtt_ms); + assert_eq!(m.loss_rate(), baseline_loss); + assert_eq!(m.goodput_bps(), baseline_goodput); +} + +#[test] +fn test_ignores_out_of_order_receiver_report_after_valid_sample() { + let mut m = MmpMetrics::new(); + + let valid_rr = make_rr(20, 20, 10000, 1000, 5, 0); + m.process_receiver_report(&valid_rr, 1050, 0); + let baseline_srtt_ms = m.srtt_ms().unwrap(); + + let old_rr = make_rr(10, 10, 5000, 1000, 0, 0); + m.process_receiver_report(&old_rr, 6000, 5000); + + let srtt_ms = m.srtt_ms().unwrap(); + assert_eq!(srtt_ms, baseline_srtt_ms); +} + +#[test] +fn test_ignores_wrapped_rtt_sample() { + let mut m = MmpMetrics::new(); + + let wrapped_rr = make_rr(10, 10, 5000, u32::MAX - 10, 20, 0); + m.process_receiver_report(&wrapped_rr, 15, 0); + + assert!(m.srtt_ms().is_none()); +} + +#[test] +fn test_ignores_future_rtt_sample() { + let mut m = MmpMetrics::new(); + + let future_rr = make_rr(10, 10, 5_000, 2_000, 5, 0); + m.process_receiver_report(&future_rr, 1_000, 0); + + assert!(m.srtt_ms().is_none()); +} + +#[test] +fn test_loss_rate_computation() { + let mut m = MmpMetrics::new(); + let t0 = 0u64; + + // First report: baseline + let rr1 = make_rr(100, 100, 50000, 0, 0, 0); + m.process_receiver_report(&rr1, 0, t0); + + // Second report: 200 counters sent, 190 received (5% loss) + let rr2 = make_rr(300, 290, 145000, 0, 0, 0); + m.process_receiver_report(&rr2, 0, t0 + 1000); + + let loss = m.loss_rate(); + assert!((loss - 0.05).abs() < 0.01, "loss={loss}, expected ~0.05"); +} + +#[test] +fn test_etx_updates() { + let mut m = MmpMetrics::new(); + assert_eq!(m.etx, 1.0); // initial: perfect + + // Simulate some loss via forward ratio + m.delivery_ratio_forward = 0.9; + + // First call establishes the baseline (no ETX update yet) + m.update_reverse_delivery(100, 100); + assert_eq!(m.etx, 1.0); // still perfect — baseline only + + // Second call: 190 of 200 frames received (5% loss) + m.update_reverse_delivery(290, 300); + assert!(m.etx > 1.0); + assert!(m.etx < 2.0); +} + +#[test] +fn test_no_rtt_without_echo() { + let mut m = MmpMetrics::new(); + let rr = make_rr(10, 10, 5000, 0, 0, 0); + m.process_receiver_report(&rr, 1000, 0); + assert!(m.srtt_ms().is_none()); +} + +#[test] +fn test_jitter_trend() { + let mut m = MmpMetrics::new(); + let t0 = 0u64; + let rr1 = make_rr(10, 10, 5000, 0, 0, 100); + m.process_receiver_report(&rr1, 0, t0); + + let rr2 = make_rr(20, 20, 10000, 0, 0, 500); + m.process_receiver_report(&rr2, 0, t0 + 1000); + + assert!(m.jitter_trend.initialized()); + // Short-term should be closer to 500 than long-term + assert!(m.jitter_trend.short() > m.jitter_trend.long()); +} + +#[test] +fn test_goodput_bps() { + let mut m = MmpMetrics::new(); + let t0 = 0u64; + + // First report: baseline (50KB received) + let rr1 = make_rr(100, 100, 50_000, 0, 0, 0); + m.process_receiver_report(&rr1, 0, t0); + assert_eq!(m.goodput_bps(), 0.0); // no rate yet (first report) + + // Second report 1s later: 150KB total (100KB delta in 1s = 100KB/s) + let rr2 = make_rr(300, 290, 150_000, 0, 0, 0); + m.process_receiver_report(&rr2, 0, t0 + 1000); + assert!( + m.goodput_bps() > 90_000.0, + "goodput={}, expected ~100000", + m.goodput_bps() + ); + assert!( + m.goodput_bps() < 110_000.0, + "goodput={}, expected ~100000", + m.goodput_bps() + ); +} + +#[test] +fn test_reverse_delivery_delta() { + let mut m = MmpMetrics::new(); + + // First call: baseline only, no ratio update + m.update_reverse_delivery(100, 100); + assert_eq!(m.delivery_ratio_reverse, 1.0); // unchanged from default + + // Second call: perfect delivery (200 new frames, all received) + m.update_reverse_delivery(300, 300); + assert!((m.delivery_ratio_reverse - 1.0).abs() < 0.001); + + // Third call: 50% loss (100 frames sent, 50 received) + m.update_reverse_delivery(350, 400); + assert!( + (m.delivery_ratio_reverse - 0.5).abs() < 0.001, + "reverse={}, expected 0.5", + m.delivery_ratio_reverse + ); +} + +#[test] +fn test_reverse_delivery_rekey_reset() { + let mut m = MmpMetrics::new(); + + // Establish baseline and one measurement + m.update_reverse_delivery(100, 100); + m.update_reverse_delivery(300, 300); + assert!((m.delivery_ratio_reverse - 1.0).abs() < 0.001); + + // Rekey resets reverse state + m.reset_for_rekey(); + + // First call after rekey: baseline only + m.update_reverse_delivery(50, 50); + // delivery_ratio_reverse was reset to 1.0 by reset_for_rekey's + // clearing of delivery_ratio_forward; reverse is not explicitly + // reset — but the delta state is, so next call computes fresh. + assert_eq!(m.delivery_ratio_reverse, 1.0); + + // Second call after rekey: 80% delivery + m.update_reverse_delivery(90, 100); + assert!( + (m.delivery_ratio_reverse - 0.8).abs() < 0.001, + "reverse={}, expected 0.8", + m.delivery_ratio_reverse + ); +} diff --git a/src/proto/mmp/tests/wire.rs b/src/proto/mmp/tests/wire.rs new file mode 100644 index 0000000..5fce3ce --- /dev/null +++ b/src/proto/mmp/tests/wire.rs @@ -0,0 +1,250 @@ +//! Tests for the MMP report wire codecs (link-layer and session-layer). + +use crate::proto::mmp::wire::{ + PATH_MTU_NOTIFICATION_SIZE, PathMtuNotification, ReceiverReport, SESSION_RECEIVER_REPORT_SIZE, + SESSION_SENDER_REPORT_SIZE, SenderReport, SessionReceiverReport, SessionSenderReport, +}; + +// ===== Link-layer report tests (mmp/report.rs) ===== + +fn sample_sender_report() -> SenderReport { + SenderReport { + interval_start_counter: 100, + interval_end_counter: 200, + interval_start_timestamp: 5000, + interval_end_timestamp: 6000, + interval_bytes_sent: 50_000, + cumulative_packets_sent: 10_000, + cumulative_bytes_sent: 5_000_000, + } +} + +fn sample_receiver_report() -> ReceiverReport { + ReceiverReport { + highest_counter: 195, + cumulative_packets_recv: 9_500, + cumulative_bytes_recv: 4_750_000, + timestamp_echo: 5900, + dwell_time: 5, + max_burst_loss: 3, + mean_burst_loss: 384, // 1.5 in u8.8 + jitter: 1200, + ecn_ce_count: 0, + owd_trend: -50, + burst_loss_count: 2, + cumulative_reorder_count: 10, + interval_packets_recv: 95, + interval_bytes_recv: 47_500, + } +} + +#[test] +fn test_sender_report_encode_size() { + let sr = sample_sender_report(); + let encoded = sr.encode(); + assert_eq!(encoded.len(), 48); + assert_eq!(encoded[0], 0x01); // msg_type +} + +#[test] +fn test_sender_report_roundtrip() { + let sr = sample_sender_report(); + let encoded = sr.encode(); + // decode expects payload after msg_type + let decoded = SenderReport::decode(&encoded[1..]).unwrap(); + assert_eq!(sr, decoded); +} + +#[test] +fn test_sender_report_too_short() { + let result = SenderReport::decode(&[0u8; 10]); + assert!(result.is_err()); +} + +#[test] +fn test_receiver_report_encode_size() { + let rr = sample_receiver_report(); + let encoded = rr.encode(); + assert_eq!(encoded.len(), 68); + assert_eq!(encoded[0], 0x02); // msg_type +} + +#[test] +fn test_receiver_report_roundtrip() { + let rr = sample_receiver_report(); + let encoded = rr.encode(); + // decode expects payload after msg_type + let decoded = ReceiverReport::decode(&encoded[1..]).unwrap(); + assert_eq!(rr, decoded); +} + +#[test] +fn test_receiver_report_too_short() { + let result = ReceiverReport::decode(&[0u8; 10]); + assert!(result.is_err()); +} + +#[test] +fn test_sender_report_zero_values() { + let sr = SenderReport { + interval_start_counter: 0, + interval_end_counter: 0, + interval_start_timestamp: 0, + interval_end_timestamp: 0, + interval_bytes_sent: 0, + cumulative_packets_sent: 0, + cumulative_bytes_sent: 0, + }; + let encoded = sr.encode(); + let decoded = SenderReport::decode(&encoded[1..]).unwrap(); + assert_eq!(sr, decoded); +} + +#[test] +fn test_receiver_report_max_values() { + let rr = ReceiverReport { + highest_counter: u64::MAX, + cumulative_packets_recv: u64::MAX, + cumulative_bytes_recv: u64::MAX, + timestamp_echo: u32::MAX, + dwell_time: u16::MAX, + max_burst_loss: u16::MAX, + mean_burst_loss: u16::MAX, + jitter: u32::MAX, + ecn_ce_count: u32::MAX, + owd_trend: i32::MAX, + burst_loss_count: u32::MAX, + cumulative_reorder_count: u32::MAX, + interval_packets_recv: u32::MAX, + interval_bytes_recv: u32::MAX, + }; + let encoded = rr.encode(); + let decoded = ReceiverReport::decode(&encoded[1..]).unwrap(); + assert_eq!(rr, decoded); +} + +#[test] +fn test_receiver_report_negative_owd_trend() { + let rr = ReceiverReport { + owd_trend: -12345, + ..sample_receiver_report() + }; + let encoded = rr.encode(); + let decoded = ReceiverReport::decode(&encoded[1..]).unwrap(); + assert_eq!(decoded.owd_trend, -12345); +} + +// ===== Session-layer report tests (protocol/session.rs) ===== + +fn sample_session_sender_report() -> SessionSenderReport { + SessionSenderReport { + interval_start_counter: 100, + interval_end_counter: 200, + interval_start_timestamp: 5000, + interval_end_timestamp: 6000, + interval_bytes_sent: 50_000, + cumulative_packets_sent: 10_000, + cumulative_bytes_sent: 5_000_000, + } +} + +#[test] +fn test_session_sender_report_encode_size() { + let sr = sample_session_sender_report(); + let encoded = sr.encode(); + assert_eq!(encoded.len(), SESSION_SENDER_REPORT_SIZE); +} + +#[test] +fn test_session_sender_report_roundtrip() { + let sr = sample_session_sender_report(); + let encoded = sr.encode(); + let decoded = SessionSenderReport::decode(&encoded).unwrap(); + assert_eq!(sr, decoded); +} + +#[test] +fn test_session_sender_report_too_short() { + assert!(SessionSenderReport::decode(&[0u8; 10]).is_err()); +} + +fn sample_session_receiver_report() -> SessionReceiverReport { + SessionReceiverReport { + highest_counter: 195, + cumulative_packets_recv: 9_500, + cumulative_bytes_recv: 4_750_000, + timestamp_echo: 5900, + dwell_time: 5, + max_burst_loss: 3, + mean_burst_loss: 384, + jitter: 1200, + ecn_ce_count: 0, + owd_trend: -50, + burst_loss_count: 2, + cumulative_reorder_count: 10, + interval_packets_recv: 95, + interval_bytes_recv: 47_500, + } +} + +#[test] +fn test_session_receiver_report_encode_size() { + let rr = sample_session_receiver_report(); + let encoded = rr.encode(); + assert_eq!(encoded.len(), SESSION_RECEIVER_REPORT_SIZE); +} + +#[test] +fn test_session_receiver_report_roundtrip() { + let rr = sample_session_receiver_report(); + let encoded = rr.encode(); + let decoded = SessionReceiverReport::decode(&encoded).unwrap(); + assert_eq!(rr, decoded); +} + +#[test] +fn test_session_receiver_report_too_short() { + assert!(SessionReceiverReport::decode(&[0u8; 10]).is_err()); +} + +#[test] +fn test_session_receiver_report_negative_owd_trend() { + let rr = SessionReceiverReport { + owd_trend: -12345, + ..sample_session_receiver_report() + }; + let encoded = rr.encode(); + let decoded = SessionReceiverReport::decode(&encoded).unwrap(); + assert_eq!(decoded.owd_trend, -12345); +} + +#[test] +fn test_path_mtu_notification_encode_size() { + let n = PathMtuNotification::new(1400); + let encoded = n.encode(); + assert_eq!(encoded.len(), PATH_MTU_NOTIFICATION_SIZE); +} + +#[test] +fn test_path_mtu_notification_roundtrip() { + let n = PathMtuNotification::new(1400); + let encoded = n.encode(); + let decoded = PathMtuNotification::decode(&encoded).unwrap(); + assert_eq!(decoded.path_mtu, 1400); +} + +#[test] +fn test_path_mtu_notification_too_short() { + assert!(PathMtuNotification::decode(&[]).is_err()); + assert!(PathMtuNotification::decode(&[0x00]).is_err()); +} + +#[test] +fn test_path_mtu_notification_boundary_values() { + for mtu in [0u16, 1280, 1500, u16::MAX] { + let n = PathMtuNotification::new(mtu); + let encoded = n.encode(); + let decoded = PathMtuNotification::decode(&encoded).unwrap(); + assert_eq!(decoded.path_mtu, mtu); + } +} diff --git a/src/mmp/report.rs b/src/proto/mmp/wire.rs similarity index 53% rename from src/mmp/report.rs rename to src/proto/mmp/wire.rs index 8d0289f..8632ba8 100644 --- a/src/mmp/report.rs +++ b/src/proto/mmp/wire.rs @@ -1,7 +1,10 @@ -//! MMP report wire format: SenderReport and ReceiverReport. +//! MMP report wire format: link-layer and session-layer report codecs. //! -//! Serialization and deserialization for the two report types exchanged -//! between link-layer peers. Wire format follows the MMP design doc. +//! Serialization and deserialization for the report types exchanged between +//! MMP peers: the link-layer [`SenderReport`]/[`ReceiverReport`] and their +//! session-layer FSP counterparts ([`SessionSenderReport`]/ +//! [`SessionReceiverReport`]/[`PathMtuNotification`]), plus the conversions +//! between the two layers. Wire format follows the MMP design doc. use crate::protocol::ProtocolError; @@ -173,10 +176,225 @@ impl ReceiverReport { } // ============================================================================ -// Conversions between link-layer and session-layer report types +// Session-Layer MMP Reports // ============================================================================ -use crate::protocol::{SessionReceiverReport, SessionSenderReport}; +/// Session-layer sender report (msg_type 0x11). +/// +/// Mirrors the FMP `SenderReport` fields but carried as an FSP session +/// message inside the AEAD envelope. The msg_type is in the FSP inner +/// header, so the body starts with reserved bytes. +/// +/// ## Wire Format (46 bytes body, after inner header stripped) +/// +/// ```text +/// [0-1] reserved (zero) +/// [2-9] interval_start_counter: u64 LE +/// [10-17] interval_end_counter: u64 LE +/// [18-21] interval_start_timestamp: u32 LE +/// [22-25] interval_end_timestamp: u32 LE +/// [26-29] interval_bytes_sent: u32 LE +/// [30-37] cumulative_packets_sent: u64 LE +/// [38-45] cumulative_bytes_sent: u64 LE +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionSenderReport { + pub interval_start_counter: u64, + pub interval_end_counter: u64, + pub interval_start_timestamp: u32, + pub interval_end_timestamp: u32, + pub interval_bytes_sent: u32, + pub cumulative_packets_sent: u64, + pub cumulative_bytes_sent: u64, +} + +/// Body size for SessionSenderReport: 2 reserved + 44 fields. +pub const SESSION_SENDER_REPORT_SIZE: usize = 46; + +impl SessionSenderReport { + /// Encode to wire format (46 bytes body). + pub fn encode(&self) -> Vec { + let mut buf = Vec::with_capacity(SESSION_SENDER_REPORT_SIZE); + buf.extend_from_slice(&[0u8; 2]); // reserved + buf.extend_from_slice(&self.interval_start_counter.to_le_bytes()); + buf.extend_from_slice(&self.interval_end_counter.to_le_bytes()); + buf.extend_from_slice(&self.interval_start_timestamp.to_le_bytes()); + buf.extend_from_slice(&self.interval_end_timestamp.to_le_bytes()); + buf.extend_from_slice(&self.interval_bytes_sent.to_le_bytes()); + buf.extend_from_slice(&self.cumulative_packets_sent.to_le_bytes()); + buf.extend_from_slice(&self.cumulative_bytes_sent.to_le_bytes()); + buf + } + + /// Decode from body (after FSP inner header has been stripped). + pub fn decode(body: &[u8]) -> Result { + if body.len() < SESSION_SENDER_REPORT_SIZE { + return Err(ProtocolError::MessageTooShort { + expected: SESSION_SENDER_REPORT_SIZE, + got: body.len(), + }); + } + // Skip 2 reserved bytes + let p = &body[2..]; + Ok(Self { + interval_start_counter: u64::from_le_bytes(p[0..8].try_into().unwrap()), + interval_end_counter: u64::from_le_bytes(p[8..16].try_into().unwrap()), + interval_start_timestamp: u32::from_le_bytes(p[16..20].try_into().unwrap()), + interval_end_timestamp: u32::from_le_bytes(p[20..24].try_into().unwrap()), + interval_bytes_sent: u32::from_le_bytes(p[24..28].try_into().unwrap()), + cumulative_packets_sent: u64::from_le_bytes(p[28..36].try_into().unwrap()), + cumulative_bytes_sent: u64::from_le_bytes(p[36..44].try_into().unwrap()), + }) + } +} + +/// Session-layer receiver report (msg_type 0x12). +/// +/// Mirrors the FMP `ReceiverReport` fields but carried as an FSP session +/// message inside the AEAD envelope. +/// +/// ## Wire Format (66 bytes body, after inner header stripped) +/// +/// ```text +/// [0-1] reserved (zero) +/// [2-9] highest_counter: u64 LE +/// [10-17] cumulative_packets_recv: u64 LE +/// [18-25] cumulative_bytes_recv: u64 LE +/// [26-29] timestamp_echo: u32 LE +/// [30-31] dwell_time: u16 LE +/// [32-33] max_burst_loss: u16 LE +/// [34-35] mean_burst_loss: u16 LE (u8.8 fixed-point) +/// [36-37] reserved: u16 LE +/// [38-41] jitter: u32 LE (microseconds) +/// [42-45] ecn_ce_count: u32 LE +/// [46-49] owd_trend: i32 LE (µs/s) +/// [50-53] burst_loss_count: u32 LE +/// [54-57] cumulative_reorder_count: u32 LE +/// [58-61] interval_packets_recv: u32 LE +/// [62-65] interval_bytes_recv: u32 LE +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionReceiverReport { + pub highest_counter: u64, + pub cumulative_packets_recv: u64, + pub cumulative_bytes_recv: u64, + pub timestamp_echo: u32, + pub dwell_time: u16, + pub max_burst_loss: u16, + pub mean_burst_loss: u16, + pub jitter: u32, + pub ecn_ce_count: u32, + pub owd_trend: i32, + pub burst_loss_count: u32, + pub cumulative_reorder_count: u32, + pub interval_packets_recv: u32, + pub interval_bytes_recv: u32, +} + +/// Body size for SessionReceiverReport: 2 reserved + 64 fields. +pub const SESSION_RECEIVER_REPORT_SIZE: usize = 66; + +impl SessionReceiverReport { + /// Encode to wire format (66 bytes body). + pub fn encode(&self) -> Vec { + let mut buf = Vec::with_capacity(SESSION_RECEIVER_REPORT_SIZE); + buf.extend_from_slice(&[0u8; 2]); // reserved + buf.extend_from_slice(&self.highest_counter.to_le_bytes()); + buf.extend_from_slice(&self.cumulative_packets_recv.to_le_bytes()); + buf.extend_from_slice(&self.cumulative_bytes_recv.to_le_bytes()); + buf.extend_from_slice(&self.timestamp_echo.to_le_bytes()); + buf.extend_from_slice(&self.dwell_time.to_le_bytes()); + buf.extend_from_slice(&self.max_burst_loss.to_le_bytes()); + buf.extend_from_slice(&self.mean_burst_loss.to_le_bytes()); + buf.extend_from_slice(&[0u8; 2]); // reserved + buf.extend_from_slice(&self.jitter.to_le_bytes()); + buf.extend_from_slice(&self.ecn_ce_count.to_le_bytes()); + buf.extend_from_slice(&self.owd_trend.to_le_bytes()); + buf.extend_from_slice(&self.burst_loss_count.to_le_bytes()); + buf.extend_from_slice(&self.cumulative_reorder_count.to_le_bytes()); + buf.extend_from_slice(&self.interval_packets_recv.to_le_bytes()); + buf.extend_from_slice(&self.interval_bytes_recv.to_le_bytes()); + buf + } + + /// Decode from body (after FSP inner header has been stripped). + pub fn decode(body: &[u8]) -> Result { + if body.len() < SESSION_RECEIVER_REPORT_SIZE { + return Err(ProtocolError::MessageTooShort { + expected: SESSION_RECEIVER_REPORT_SIZE, + got: body.len(), + }); + } + // Skip 2 reserved bytes + let p = &body[2..]; + Ok(Self { + highest_counter: u64::from_le_bytes(p[0..8].try_into().unwrap()), + cumulative_packets_recv: u64::from_le_bytes(p[8..16].try_into().unwrap()), + cumulative_bytes_recv: u64::from_le_bytes(p[16..24].try_into().unwrap()), + timestamp_echo: u32::from_le_bytes(p[24..28].try_into().unwrap()), + dwell_time: u16::from_le_bytes(p[28..30].try_into().unwrap()), + max_burst_loss: u16::from_le_bytes(p[30..32].try_into().unwrap()), + mean_burst_loss: u16::from_le_bytes(p[32..34].try_into().unwrap()), + // skip 2 reserved bytes at p[34..36] + jitter: u32::from_le_bytes(p[36..40].try_into().unwrap()), + ecn_ce_count: u32::from_le_bytes(p[40..44].try_into().unwrap()), + owd_trend: i32::from_le_bytes(p[44..48].try_into().unwrap()), + burst_loss_count: u32::from_le_bytes(p[48..52].try_into().unwrap()), + cumulative_reorder_count: u32::from_le_bytes(p[52..56].try_into().unwrap()), + interval_packets_recv: u32::from_le_bytes(p[56..60].try_into().unwrap()), + interval_bytes_recv: u32::from_le_bytes(p[60..64].try_into().unwrap()), + }) + } +} + +/// Path MTU notification (msg_type 0x13). +/// +/// Sent by a node that discovers a path MTU value (from transit router +/// feedback or ICMP Packet Too Big). Allows the remote endpoint to +/// adjust its sending MTU. +/// +/// ## Wire Format (2 bytes body, after inner header stripped) +/// +/// ```text +/// [0-1] path_mtu: u16 LE +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PathMtuNotification { + /// Discovered path MTU in bytes. + pub path_mtu: u16, +} + +/// Body size for PathMtuNotification. +pub const PATH_MTU_NOTIFICATION_SIZE: usize = 2; + +impl PathMtuNotification { + /// Create a new path MTU notification. + pub fn new(path_mtu: u16) -> Self { + Self { path_mtu } + } + + /// Encode to wire format (2 bytes body). + pub fn encode(&self) -> Vec { + self.path_mtu.to_le_bytes().to_vec() + } + + /// Decode from body (after FSP inner header has been stripped). + pub fn decode(body: &[u8]) -> Result { + if body.len() < PATH_MTU_NOTIFICATION_SIZE { + return Err(ProtocolError::MessageTooShort { + expected: PATH_MTU_NOTIFICATION_SIZE, + got: body.len(), + }); + } + Ok(Self { + path_mtu: u16::from_le_bytes([body[0], body[1]]), + }) + } +} + +// ============================================================================ +// Conversions between link-layer and session-layer report types +// ============================================================================ impl From<&SenderReport> for SessionSenderReport { fn from(r: &SenderReport) -> Self { @@ -247,139 +465,3 @@ impl From<&SessionReceiverReport> for ReceiverReport { } } } - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - - fn sample_sender_report() -> SenderReport { - SenderReport { - interval_start_counter: 100, - interval_end_counter: 200, - interval_start_timestamp: 5000, - interval_end_timestamp: 6000, - interval_bytes_sent: 50_000, - cumulative_packets_sent: 10_000, - cumulative_bytes_sent: 5_000_000, - } - } - - fn sample_receiver_report() -> ReceiverReport { - ReceiverReport { - highest_counter: 195, - cumulative_packets_recv: 9_500, - cumulative_bytes_recv: 4_750_000, - timestamp_echo: 5900, - dwell_time: 5, - max_burst_loss: 3, - mean_burst_loss: 384, // 1.5 in u8.8 - jitter: 1200, - ecn_ce_count: 0, - owd_trend: -50, - burst_loss_count: 2, - cumulative_reorder_count: 10, - interval_packets_recv: 95, - interval_bytes_recv: 47_500, - } - } - - #[test] - fn test_sender_report_encode_size() { - let sr = sample_sender_report(); - let encoded = sr.encode(); - assert_eq!(encoded.len(), 48); - assert_eq!(encoded[0], 0x01); // msg_type - } - - #[test] - fn test_sender_report_roundtrip() { - let sr = sample_sender_report(); - let encoded = sr.encode(); - // decode expects payload after msg_type - let decoded = SenderReport::decode(&encoded[1..]).unwrap(); - assert_eq!(sr, decoded); - } - - #[test] - fn test_sender_report_too_short() { - let result = SenderReport::decode(&[0u8; 10]); - assert!(result.is_err()); - } - - #[test] - fn test_receiver_report_encode_size() { - let rr = sample_receiver_report(); - let encoded = rr.encode(); - assert_eq!(encoded.len(), 68); - assert_eq!(encoded[0], 0x02); // msg_type - } - - #[test] - fn test_receiver_report_roundtrip() { - let rr = sample_receiver_report(); - let encoded = rr.encode(); - // decode expects payload after msg_type - let decoded = ReceiverReport::decode(&encoded[1..]).unwrap(); - assert_eq!(rr, decoded); - } - - #[test] - fn test_receiver_report_too_short() { - let result = ReceiverReport::decode(&[0u8; 10]); - assert!(result.is_err()); - } - - #[test] - fn test_sender_report_zero_values() { - let sr = SenderReport { - interval_start_counter: 0, - interval_end_counter: 0, - interval_start_timestamp: 0, - interval_end_timestamp: 0, - interval_bytes_sent: 0, - cumulative_packets_sent: 0, - cumulative_bytes_sent: 0, - }; - let encoded = sr.encode(); - let decoded = SenderReport::decode(&encoded[1..]).unwrap(); - assert_eq!(sr, decoded); - } - - #[test] - fn test_receiver_report_max_values() { - let rr = ReceiverReport { - highest_counter: u64::MAX, - cumulative_packets_recv: u64::MAX, - cumulative_bytes_recv: u64::MAX, - timestamp_echo: u32::MAX, - dwell_time: u16::MAX, - max_burst_loss: u16::MAX, - mean_burst_loss: u16::MAX, - jitter: u32::MAX, - ecn_ce_count: u32::MAX, - owd_trend: i32::MAX, - burst_loss_count: u32::MAX, - cumulative_reorder_count: u32::MAX, - interval_packets_recv: u32::MAX, - interval_bytes_recv: u32::MAX, - }; - let encoded = rr.encode(); - let decoded = ReceiverReport::decode(&encoded[1..]).unwrap(); - assert_eq!(rr, decoded); - } - - #[test] - fn test_receiver_report_negative_owd_trend() { - let rr = ReceiverReport { - owd_trend: -12345, - ..sample_receiver_report() - }; - let encoded = rr.encode(); - let decoded = ReceiverReport::decode(&encoded[1..]).unwrap(); - assert_eq!(decoded.owd_trend, -12345); - } -} diff --git a/src/proto/mod.rs b/src/proto/mod.rs index ccadfce..0552f78 100644 --- a/src/proto/mod.rs +++ b/src/proto/mod.rs @@ -5,4 +5,5 @@ pub(crate) mod discovery; pub(crate) mod fmp; +pub(crate) mod mmp; pub(crate) mod routing; diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 9feabf1..75fdcba 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -33,9 +33,8 @@ pub use link::{ LinkMessageType, SESSION_DATAGRAM_HEADER_SIZE, SessionDatagram, SessionDatagramRef, }; pub use session::{ - FspFlags, FspInnerFlags, PATH_MTU_NOTIFICATION_SIZE, PathMtuNotification, - SESSION_RECEIVER_REPORT_SIZE, SESSION_SENDER_REPORT_SIZE, SessionAck, SessionFlags, - SessionMessageType, SessionMsg3, SessionReceiverReport, SessionSenderReport, SessionSetup, + FspFlags, FspInnerFlags, SessionAck, SessionFlags, SessionMessageType, SessionMsg3, + SessionSetup, }; pub(crate) use session::{coords_wire_size, decode_optional_coords, encode_coords}; pub use tree::TreeAnnounce; diff --git a/src/protocol/session.rs b/src/protocol/session.rs index 7e91eda..380074c 100644 --- a/src/protocol/session.rs +++ b/src/protocol/session.rs @@ -668,223 +668,6 @@ impl SessionMsg3 { } } -// ============================================================================ -// Session-Layer MMP Reports -// ============================================================================ - -/// Session-layer sender report (msg_type 0x11). -/// -/// Mirrors the FMP `SenderReport` fields but carried as an FSP session -/// message inside the AEAD envelope. The msg_type is in the FSP inner -/// header, so the body starts with reserved bytes. -/// -/// ## Wire Format (46 bytes body, after inner header stripped) -/// -/// ```text -/// [0-1] reserved (zero) -/// [2-9] interval_start_counter: u64 LE -/// [10-17] interval_end_counter: u64 LE -/// [18-21] interval_start_timestamp: u32 LE -/// [22-25] interval_end_timestamp: u32 LE -/// [26-29] interval_bytes_sent: u32 LE -/// [30-37] cumulative_packets_sent: u64 LE -/// [38-45] cumulative_bytes_sent: u64 LE -/// ``` -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SessionSenderReport { - pub interval_start_counter: u64, - pub interval_end_counter: u64, - pub interval_start_timestamp: u32, - pub interval_end_timestamp: u32, - pub interval_bytes_sent: u32, - pub cumulative_packets_sent: u64, - pub cumulative_bytes_sent: u64, -} - -/// Body size for SessionSenderReport: 2 reserved + 44 fields. -pub const SESSION_SENDER_REPORT_SIZE: usize = 46; - -impl SessionSenderReport { - /// Encode to wire format (46 bytes body). - pub fn encode(&self) -> Vec { - let mut buf = Vec::with_capacity(SESSION_SENDER_REPORT_SIZE); - buf.extend_from_slice(&[0u8; 2]); // reserved - buf.extend_from_slice(&self.interval_start_counter.to_le_bytes()); - buf.extend_from_slice(&self.interval_end_counter.to_le_bytes()); - buf.extend_from_slice(&self.interval_start_timestamp.to_le_bytes()); - buf.extend_from_slice(&self.interval_end_timestamp.to_le_bytes()); - buf.extend_from_slice(&self.interval_bytes_sent.to_le_bytes()); - buf.extend_from_slice(&self.cumulative_packets_sent.to_le_bytes()); - buf.extend_from_slice(&self.cumulative_bytes_sent.to_le_bytes()); - buf - } - - /// Decode from body (after FSP inner header has been stripped). - pub fn decode(body: &[u8]) -> Result { - if body.len() < SESSION_SENDER_REPORT_SIZE { - return Err(ProtocolError::MessageTooShort { - expected: SESSION_SENDER_REPORT_SIZE, - got: body.len(), - }); - } - // Skip 2 reserved bytes - let p = &body[2..]; - Ok(Self { - interval_start_counter: u64::from_le_bytes(p[0..8].try_into().unwrap()), - interval_end_counter: u64::from_le_bytes(p[8..16].try_into().unwrap()), - interval_start_timestamp: u32::from_le_bytes(p[16..20].try_into().unwrap()), - interval_end_timestamp: u32::from_le_bytes(p[20..24].try_into().unwrap()), - interval_bytes_sent: u32::from_le_bytes(p[24..28].try_into().unwrap()), - cumulative_packets_sent: u64::from_le_bytes(p[28..36].try_into().unwrap()), - cumulative_bytes_sent: u64::from_le_bytes(p[36..44].try_into().unwrap()), - }) - } -} - -/// Session-layer receiver report (msg_type 0x12). -/// -/// Mirrors the FMP `ReceiverReport` fields but carried as an FSP session -/// message inside the AEAD envelope. -/// -/// ## Wire Format (66 bytes body, after inner header stripped) -/// -/// ```text -/// [0-1] reserved (zero) -/// [2-9] highest_counter: u64 LE -/// [10-17] cumulative_packets_recv: u64 LE -/// [18-25] cumulative_bytes_recv: u64 LE -/// [26-29] timestamp_echo: u32 LE -/// [30-31] dwell_time: u16 LE -/// [32-33] max_burst_loss: u16 LE -/// [34-35] mean_burst_loss: u16 LE (u8.8 fixed-point) -/// [36-37] reserved: u16 LE -/// [38-41] jitter: u32 LE (microseconds) -/// [42-45] ecn_ce_count: u32 LE -/// [46-49] owd_trend: i32 LE (µs/s) -/// [50-53] burst_loss_count: u32 LE -/// [54-57] cumulative_reorder_count: u32 LE -/// [58-61] interval_packets_recv: u32 LE -/// [62-65] interval_bytes_recv: u32 LE -/// ``` -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SessionReceiverReport { - pub highest_counter: u64, - pub cumulative_packets_recv: u64, - pub cumulative_bytes_recv: u64, - pub timestamp_echo: u32, - pub dwell_time: u16, - pub max_burst_loss: u16, - pub mean_burst_loss: u16, - pub jitter: u32, - pub ecn_ce_count: u32, - pub owd_trend: i32, - pub burst_loss_count: u32, - pub cumulative_reorder_count: u32, - pub interval_packets_recv: u32, - pub interval_bytes_recv: u32, -} - -/// Body size for SessionReceiverReport: 2 reserved + 64 fields. -pub const SESSION_RECEIVER_REPORT_SIZE: usize = 66; - -impl SessionReceiverReport { - /// Encode to wire format (66 bytes body). - pub fn encode(&self) -> Vec { - let mut buf = Vec::with_capacity(SESSION_RECEIVER_REPORT_SIZE); - buf.extend_from_slice(&[0u8; 2]); // reserved - buf.extend_from_slice(&self.highest_counter.to_le_bytes()); - buf.extend_from_slice(&self.cumulative_packets_recv.to_le_bytes()); - buf.extend_from_slice(&self.cumulative_bytes_recv.to_le_bytes()); - buf.extend_from_slice(&self.timestamp_echo.to_le_bytes()); - buf.extend_from_slice(&self.dwell_time.to_le_bytes()); - buf.extend_from_slice(&self.max_burst_loss.to_le_bytes()); - buf.extend_from_slice(&self.mean_burst_loss.to_le_bytes()); - buf.extend_from_slice(&[0u8; 2]); // reserved - buf.extend_from_slice(&self.jitter.to_le_bytes()); - buf.extend_from_slice(&self.ecn_ce_count.to_le_bytes()); - buf.extend_from_slice(&self.owd_trend.to_le_bytes()); - buf.extend_from_slice(&self.burst_loss_count.to_le_bytes()); - buf.extend_from_slice(&self.cumulative_reorder_count.to_le_bytes()); - buf.extend_from_slice(&self.interval_packets_recv.to_le_bytes()); - buf.extend_from_slice(&self.interval_bytes_recv.to_le_bytes()); - buf - } - - /// Decode from body (after FSP inner header has been stripped). - pub fn decode(body: &[u8]) -> Result { - if body.len() < SESSION_RECEIVER_REPORT_SIZE { - return Err(ProtocolError::MessageTooShort { - expected: SESSION_RECEIVER_REPORT_SIZE, - got: body.len(), - }); - } - // Skip 2 reserved bytes - let p = &body[2..]; - Ok(Self { - highest_counter: u64::from_le_bytes(p[0..8].try_into().unwrap()), - cumulative_packets_recv: u64::from_le_bytes(p[8..16].try_into().unwrap()), - cumulative_bytes_recv: u64::from_le_bytes(p[16..24].try_into().unwrap()), - timestamp_echo: u32::from_le_bytes(p[24..28].try_into().unwrap()), - dwell_time: u16::from_le_bytes(p[28..30].try_into().unwrap()), - max_burst_loss: u16::from_le_bytes(p[30..32].try_into().unwrap()), - mean_burst_loss: u16::from_le_bytes(p[32..34].try_into().unwrap()), - // skip 2 reserved bytes at p[34..36] - jitter: u32::from_le_bytes(p[36..40].try_into().unwrap()), - ecn_ce_count: u32::from_le_bytes(p[40..44].try_into().unwrap()), - owd_trend: i32::from_le_bytes(p[44..48].try_into().unwrap()), - burst_loss_count: u32::from_le_bytes(p[48..52].try_into().unwrap()), - cumulative_reorder_count: u32::from_le_bytes(p[52..56].try_into().unwrap()), - interval_packets_recv: u32::from_le_bytes(p[56..60].try_into().unwrap()), - interval_bytes_recv: u32::from_le_bytes(p[60..64].try_into().unwrap()), - }) - } -} - -/// Path MTU notification (msg_type 0x13). -/// -/// Sent by a node that discovers a path MTU value (from transit router -/// feedback or ICMP Packet Too Big). Allows the remote endpoint to -/// adjust its sending MTU. -/// -/// ## Wire Format (2 bytes body, after inner header stripped) -/// -/// ```text -/// [0-1] path_mtu: u16 LE -/// ``` -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PathMtuNotification { - /// Discovered path MTU in bytes. - pub path_mtu: u16, -} - -/// Body size for PathMtuNotification. -pub const PATH_MTU_NOTIFICATION_SIZE: usize = 2; - -impl PathMtuNotification { - /// Create a new path MTU notification. - pub fn new(path_mtu: u16) -> Self { - Self { path_mtu } - } - - /// Encode to wire format (2 bytes body). - pub fn encode(&self) -> Vec { - self.path_mtu.to_le_bytes().to_vec() - } - - /// Decode from body (after FSP inner header has been stripped). - pub fn decode(body: &[u8]) -> Result { - if body.len() < PATH_MTU_NOTIFICATION_SIZE { - return Err(ProtocolError::MessageTooShort { - expected: PATH_MTU_NOTIFICATION_SIZE, - got: body.len(), - }); - } - Ok(Self { - path_mtu: u16::from_le_bytes([body[0], body[1]]), - }) - } -} - #[cfg(test)] mod tests { use super::*; @@ -1145,125 +928,6 @@ mod tests { ); } - // ===== SessionSenderReport Tests ===== - - fn sample_session_sender_report() -> SessionSenderReport { - SessionSenderReport { - interval_start_counter: 100, - interval_end_counter: 200, - interval_start_timestamp: 5000, - interval_end_timestamp: 6000, - interval_bytes_sent: 50_000, - cumulative_packets_sent: 10_000, - cumulative_bytes_sent: 5_000_000, - } - } - - #[test] - fn test_session_sender_report_encode_size() { - let sr = sample_session_sender_report(); - let encoded = sr.encode(); - assert_eq!(encoded.len(), SESSION_SENDER_REPORT_SIZE); - } - - #[test] - fn test_session_sender_report_roundtrip() { - let sr = sample_session_sender_report(); - let encoded = sr.encode(); - let decoded = SessionSenderReport::decode(&encoded).unwrap(); - assert_eq!(sr, decoded); - } - - #[test] - fn test_session_sender_report_too_short() { - assert!(SessionSenderReport::decode(&[0u8; 10]).is_err()); - } - - // ===== SessionReceiverReport Tests ===== - - fn sample_session_receiver_report() -> SessionReceiverReport { - SessionReceiverReport { - highest_counter: 195, - cumulative_packets_recv: 9_500, - cumulative_bytes_recv: 4_750_000, - timestamp_echo: 5900, - dwell_time: 5, - max_burst_loss: 3, - mean_burst_loss: 384, - jitter: 1200, - ecn_ce_count: 0, - owd_trend: -50, - burst_loss_count: 2, - cumulative_reorder_count: 10, - interval_packets_recv: 95, - interval_bytes_recv: 47_500, - } - } - - #[test] - fn test_session_receiver_report_encode_size() { - let rr = sample_session_receiver_report(); - let encoded = rr.encode(); - assert_eq!(encoded.len(), SESSION_RECEIVER_REPORT_SIZE); - } - - #[test] - fn test_session_receiver_report_roundtrip() { - let rr = sample_session_receiver_report(); - let encoded = rr.encode(); - let decoded = SessionReceiverReport::decode(&encoded).unwrap(); - assert_eq!(rr, decoded); - } - - #[test] - fn test_session_receiver_report_too_short() { - assert!(SessionReceiverReport::decode(&[0u8; 10]).is_err()); - } - - #[test] - fn test_session_receiver_report_negative_owd_trend() { - let rr = SessionReceiverReport { - owd_trend: -12345, - ..sample_session_receiver_report() - }; - let encoded = rr.encode(); - let decoded = SessionReceiverReport::decode(&encoded).unwrap(); - assert_eq!(decoded.owd_trend, -12345); - } - - // ===== PathMtuNotification Tests ===== - - #[test] - fn test_path_mtu_notification_encode_size() { - let n = PathMtuNotification::new(1400); - let encoded = n.encode(); - assert_eq!(encoded.len(), PATH_MTU_NOTIFICATION_SIZE); - } - - #[test] - fn test_path_mtu_notification_roundtrip() { - let n = PathMtuNotification::new(1400); - let encoded = n.encode(); - let decoded = PathMtuNotification::decode(&encoded).unwrap(); - assert_eq!(decoded.path_mtu, 1400); - } - - #[test] - fn test_path_mtu_notification_too_short() { - assert!(PathMtuNotification::decode(&[]).is_err()); - assert!(PathMtuNotification::decode(&[0x00]).is_err()); - } - - #[test] - fn test_path_mtu_notification_boundary_values() { - for mtu in [0u16, 1280, 1500, u16::MAX] { - let n = PathMtuNotification::new(mtu); - let encoded = n.encode(); - let decoded = PathMtuNotification::decode(&encoded).unwrap(); - assert_eq!(decoded.path_mtu, mtu); - } - } - // ===== MtuExceeded Tests ===== #[test]