mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
proto/mmp: sans-IO metrics-reporting state machine
This commit is contained in:
+2
-1
@@ -7,7 +7,8 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use super::IdentityConfig;
|
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
|
// Node Configuration Subsections
|
||||||
|
|||||||
@@ -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<Instant>,
|
|
||||||
/// 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<f64> {
|
|
||||||
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<f64> {
|
|
||||||
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<f64> {
|
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+21
-479
@@ -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,
|
//! The MMP protocol core (estimators, sender/receiver/metrics/path-MTU state
|
||||||
//! throughput, one-way delay trend, and ETX. Operates on the per-frame
|
//! machines, report codecs and the reporting decisions) lives in the sans-IO
|
||||||
//! hooks (counter, timestamp, flags) introduced by the FMP wire format
|
//! [`crate::proto::mmp`] module. What remains here is the shell-only surface:
|
||||||
//! revision.
|
//! the serde node configuration ([`MmpConfig`]) and the monotonic millisecond
|
||||||
//!
|
//! clock ([`mono_ms`]) the shell reads at the edge and injects into the owned
|
||||||
//! Three operating modes trade measurement fidelity for overhead:
|
//! `u64`-ms state.
|
||||||
//! - **Full**: sender + receiver reports at RTT-adaptive intervals
|
|
||||||
//! - **Lightweight**: receiver reports only (infer loss from counters)
|
use std::sync::LazyLock;
|
||||||
//! - **Minimal**: spin bit + CE echo only, no reports
|
use std::time::Instant;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fmt::{self, Debug};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
// Sub-modules
|
use crate::proto::mmp::{DEFAULT_LOG_INTERVAL_SECS, DEFAULT_OWD_WINDOW_SIZE, MmpMode};
|
||||||
pub mod algorithms;
|
|
||||||
pub mod metrics;
|
|
||||||
pub mod receiver;
|
|
||||||
pub mod report;
|
|
||||||
pub mod sender;
|
|
||||||
|
|
||||||
// Re-exports
|
/// Monotonic millisecond clock for the MMP time seam.
|
||||||
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).
|
|
||||||
///
|
///
|
||||||
/// Raised from 100ms to 1000ms: parent re-evaluation runs every 60s,
|
/// The owned `proto::mmp` state takes injected `u64` milliseconds rather than
|
||||||
/// so 60 samples/cycle is more than sufficient for EWMA convergence (~10).
|
/// reading a clock. The shell reads this process-monotonic clock at each edge
|
||||||
/// The cold-start phase uses `DEFAULT_COLD_START_INTERVAL_MS` (200ms) for
|
/// and passes the value in; only deltas between two `mono_ms()` reads are ever
|
||||||
/// fast initial SRTT convergence before transitioning to this floor.
|
/// compared, so the (process-relative) epoch is immaterial.
|
||||||
pub const MIN_REPORT_INTERVAL_MS: u64 = 1_000;
|
pub(crate) fn mono_ms() -> u64 {
|
||||||
|
static START: LazyLock<Instant> = LazyLock::new(Instant::now);
|
||||||
/// Maximum report interval (SRTT clamp ceiling).
|
START.elapsed().as_millis() as u64
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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.*`).
|
/// MMP configuration (`node.mmp.*`).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct MmpConfig {
|
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<Instant>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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<Instant>,
|
|
||||||
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<Instant>,
|
|
||||||
/// 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<Instant>,
|
|
||||||
/// 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<u16> {
|
|
||||||
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::MmpConfig;
|
||||||
|
use crate::proto::mmp::{DEFAULT_LOG_INTERVAL_SECS, DEFAULT_OWD_WINDOW_SIZE, 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_config_default() {
|
fn test_config_default() {
|
||||||
|
|||||||
@@ -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<u64>,
|
|
||||||
/// 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<Instant>,
|
|
||||||
|
|
||||||
// --- 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<Instant>,
|
|
||||||
|
|
||||||
// --- Report timing ---
|
|
||||||
last_report_time: Option<Instant>,
|
|
||||||
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<ReceiverReport> {
|
|
||||||
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<Instant> {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<Instant>,
|
|
||||||
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<SenderReport> {
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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::node::wire::{EncryptedHeader, FLAG_CE, FLAG_KEY_EPOCH, FLAG_SP, strip_inner_header};
|
||||||
use crate::noise::NoiseError;
|
use crate::noise::NoiseError;
|
||||||
use crate::transport::ReceivedPacket;
|
use crate::transport::ReceivedPacket;
|
||||||
use std::time::Instant;
|
|
||||||
use tracing::{debug, trace, warn};
|
use tracing::{debug, trace, warn};
|
||||||
|
|
||||||
/// Force-remove a peer after this many consecutive decryption failures.
|
/// Force-remove a peer after this many consecutive decryption failures.
|
||||||
@@ -259,7 +258,7 @@ impl Node {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// MMP per-frame processing and statistics
|
// 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 ce_flag = header.flags & FLAG_CE != 0;
|
||||||
let sp_flag = header.flags & FLAG_SP != 0;
|
let sp_flag = header.flags & FLAG_SP != 0;
|
||||||
|
|
||||||
@@ -270,9 +269,9 @@ impl Node {
|
|||||||
timestamp,
|
timestamp,
|
||||||
packet.data.len(),
|
packet.data.len(),
|
||||||
ce_flag,
|
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.set_current_addr(packet.transport_id, packet.remote_addr.clone());
|
||||||
peer.link_stats_mut()
|
peer.link_stats_mut()
|
||||||
@@ -355,7 +354,7 @@ impl Node {
|
|||||||
} else {
|
} else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let now = Instant::now();
|
let now_ms = crate::mmp::mono_ms();
|
||||||
let mut address_changed = false;
|
let mut address_changed = false;
|
||||||
if let Some(peer) = self.peers.get_mut(node_addr) {
|
if let Some(peer) = self.peers.get_mut(node_addr) {
|
||||||
peer.reset_decrypt_failures();
|
peer.reset_decrypt_failures();
|
||||||
@@ -365,8 +364,8 @@ impl Node {
|
|||||||
peer.touch(packet_timestamp_ms);
|
peer.touch(packet_timestamp_ms);
|
||||||
if let Some(mmp) = peer.mmp_mut() {
|
if let Some(mmp) = peer.mmp_mut() {
|
||||||
mmp.receiver
|
mmp.receiver
|
||||||
.record_recv(fmp_counter, inner_ts, packet_len, ce_flag, 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);
|
let _spin_rtt = mmp.spin_bit.rx_observe(sp_flag, fmp_counter, now_ms);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Address rotation invalidates the per-peer connect()-ed UDP
|
// Address rotation invalidates the per-peer connect()-ed UDP
|
||||||
|
|||||||
+346
-241
@@ -5,18 +5,57 @@
|
|||||||
//! and teardown metric logs.
|
//! and teardown metric logs.
|
||||||
|
|
||||||
use crate::NodeAddr;
|
use crate::NodeAddr;
|
||||||
use crate::mmp::MmpMode;
|
|
||||||
use crate::mmp::MmpSessionState;
|
|
||||||
use crate::mmp::report::{ReceiverReport, SenderReport};
|
|
||||||
use crate::node::Node;
|
use crate::node::Node;
|
||||||
use crate::node::reject::{MmpReject, RejectReason, TreeReject};
|
use crate::node::reject::{MmpReject, RejectReason, TreeReject};
|
||||||
use crate::protocol::{
|
use crate::proto::mmp::{
|
||||||
LinkMessageType, PathMtuNotification, SessionMessageType, SessionReceiverReport,
|
BackoffUpdate, LinkReportKind, LinkReportSnapshot, MmpAction, MmpSessionState,
|
||||||
SessionSenderReport,
|
PathMtuNotification, PeerLivenessSnapshot, ReceiverReport, RrLog, SendResult, SenderReport,
|
||||||
|
SessionReceiverReport, SessionReportKind, SessionReportSnapshot, SessionSenderReport,
|
||||||
};
|
};
|
||||||
|
use crate::protocol::{LinkMessageType, SessionMessageType};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tracing::{debug, info, trace, warn};
|
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.
|
/// Format bytes/sec as human-readable throughput.
|
||||||
fn format_throughput(bps: f64) -> String {
|
fn format_throughput(bps: f64) -> String {
|
||||||
if bps == 0.0 {
|
if bps == 0.0 {
|
||||||
@@ -113,10 +152,12 @@ impl Node {
|
|||||||
|
|
||||||
// Process the report: computes RTT from timestamp echo, updates
|
// Process the report: computes RTT from timestamp echo, updates
|
||||||
// loss rate, goodput rate, jitter trend, and ETX.
|
// loss rate, goodput rate, jitter trend, and ETX.
|
||||||
let now = Instant::now();
|
let now_ms = crate::mmp::mono_ms();
|
||||||
let first_rtt = mmp
|
let (first_rtt, rr_log) =
|
||||||
.metrics
|
mmp.metrics
|
||||||
.process_receiver_report(&rr, our_timestamp_ms, now);
|
.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
|
// Feed SRTT back to sender/receiver report interval tuning
|
||||||
if let Some(srtt_ms) = mmp.metrics.srtt_ms() {
|
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.
|
/// Called from the tick handler. Also emits periodic operator logs.
|
||||||
pub(in crate::node) async fn check_mmp_reports(&mut self) {
|
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)
|
// Build one report-gating snapshot per peer, resolving every timing read
|
||||||
let mut sender_reports: Vec<(NodeAddr, Vec<u8>)> = Vec::new();
|
// shell-side into a `bool`. `send_sr`/`send_rr` are `true` on the master
|
||||||
let mut receiver_reports: Vec<(NodeAddr, Vec<u8>)> = Vec::new();
|
// (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<LinkReportSnapshot> = 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() {
|
let actions = self.mmp.plan_link_reports(&snapshots);
|
||||||
// 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 Some(mmp) = peer.mmp_mut() else {
|
// Drive the planned actions in their phase-grouped order (all logs, then
|
||||||
continue;
|
// 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
|
||||||
let mode = mmp.mode();
|
// pre-refactor handler logged during its collect pass, before any send.
|
||||||
|
// `build_report` (which advances the interval state) is called only on a
|
||||||
// Sender reports: Full mode only
|
// SendLinkReport action, exactly as the pre-refactor gate did.
|
||||||
if mode == MmpMode::Full
|
for action in actions {
|
||||||
&& mmp.sender.should_send_report(now)
|
match action {
|
||||||
&& let Some(sr) = mmp.sender.build_report(now)
|
MmpAction::SendLinkReport { peer, kind } => {
|
||||||
{
|
let encoded = self
|
||||||
sender_reports.push((*node_addr, sr.encode()));
|
.peers
|
||||||
}
|
.get_mut(&peer)
|
||||||
|
.and_then(|p| p.mmp_mut())
|
||||||
// Receiver reports: Full and Lightweight modes
|
.and_then(|mmp| match kind {
|
||||||
if mode != MmpMode::Minimal
|
LinkReportKind::Sender => {
|
||||||
&& mmp.receiver.should_send_report(now)
|
mmp.sender.build_report(now_ms).map(|sr| sr.encode())
|
||||||
&& let Some(rr) = mmp.receiver.build_report(now)
|
}
|
||||||
{
|
LinkReportKind::Receiver => {
|
||||||
receiver_reports.push((*node_addr, rr.encode()));
|
mmp.receiver.build_report(now_ms).map(|rr| rr.encode())
|
||||||
}
|
}
|
||||||
|
});
|
||||||
// Periodic operator logging
|
if let Some(encoded) = encoded
|
||||||
if mmp.should_log(now) {
|
&& let Err(e) = self.send_encrypted_link_message(&peer, &encoded).await
|
||||||
Self::log_mmp_metrics(&peer_name, mmp);
|
{
|
||||||
mmp.mark_logged(now);
|
let label = match kind {
|
||||||
}
|
LinkReportKind::Sender => "Failed to send SenderReport",
|
||||||
}
|
LinkReportKind::Receiver => "Failed to send ReceiverReport",
|
||||||
|
};
|
||||||
// Send collected reports
|
debug!(peer = %self.peer_display_name(&peer), error = %e, "{}", label);
|
||||||
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");
|
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.
|
||||||
for (node_addr, encoded) in receiver_reports {
|
let peer_name = self.peer_aliases.get(&peer).cloned().unwrap_or_else(|| {
|
||||||
if let Err(e) = self.send_encrypted_link_message(&node_addr, &encoded).await {
|
self.peers
|
||||||
debug!(peer = %self.peer_display_name(&node_addr), error = %e, "Failed to send ReceiverReport");
|
.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.
|
/// 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 m = &mmp.metrics;
|
||||||
|
|
||||||
let rtt_str = if m.rtt_trend.initialized() {
|
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.
|
/// 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 m = &mmp.metrics;
|
||||||
let jitter_ms = mmp.receiver.jitter_us() as f64 / 1000.0;
|
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.
|
/// Called from the tick handler. Also emits periodic session MMP logs.
|
||||||
/// Uses the collect-then-send pattern to avoid borrowing conflicts.
|
/// Uses the collect-then-send pattern to avoid borrowing conflicts.
|
||||||
pub(in crate::node) async fn check_session_mmp_reports(&mut self) {
|
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)
|
// Build one report-gating snapshot per session, resolving every timing
|
||||||
let mut reports: Vec<(NodeAddr, u8, Vec<u8>)> = Vec::new();
|
// 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<SessionReportSnapshot> = 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() {
|
let actions = self.mmp.plan_session_reports(&snapshots);
|
||||||
// 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 Some(mmp) = entry.mmp_mut() else {
|
// Drive the planned actions in phase-grouped order (all logs, then the
|
||||||
continue;
|
// 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
|
||||||
let mode = mmp.mode();
|
// handler logged during its collect pass, before any send. Each build
|
||||||
|
// (`build_report`/`build_notification`, which advance interval/
|
||||||
// Sender reports: Full mode only
|
// notification state) runs only on its SendSessionReport action, exactly
|
||||||
if mode == MmpMode::Full
|
// as the pre-refactor collect pass did. Per-destination success/failure
|
||||||
&& mmp.sender.should_send_report(now)
|
// is collected for the backoff dedup + failure-log suppression.
|
||||||
&& let Some(sr) = mmp.sender.build_report(now)
|
let mut send_results: Vec<SendResult> = Vec::new();
|
||||||
{
|
for action in actions {
|
||||||
let session_sr: SessionSenderReport = SessionSenderReport::from(&sr);
|
match action {
|
||||||
reports.push((
|
MmpAction::LogSession { dest } => {
|
||||||
*dest_addr,
|
// Resolve the display name exactly as the pre-refactor loop
|
||||||
SessionMessageType::SenderReport.to_byte(),
|
// did (alias, else short_npub from the session's remote key).
|
||||||
session_sr.encode(),
|
let session_name = self.peer_aliases.get(&dest).cloned().unwrap_or_else(|| {
|
||||||
));
|
self.sessions
|
||||||
}
|
.get(&dest)
|
||||||
|
.map(|entry| {
|
||||||
// Receiver reports: Full and Lightweight modes
|
let (xonly, _) = entry.remote_pubkey().x_only_public_key();
|
||||||
if mode != MmpMode::Minimal
|
crate::PeerIdentity::from_pubkey(xonly).short_npub()
|
||||||
&& mmp.receiver.should_send_report(now)
|
})
|
||||||
&& let Some(rr) = mmp.receiver.build_report(now)
|
.unwrap_or_default()
|
||||||
{
|
});
|
||||||
let session_rr: SessionReceiverReport = SessionReceiverReport::from(&rr);
|
if let Some(mmp) = self.sessions.get_mut(&dest).and_then(|e| e.mmp_mut()) {
|
||||||
reports.push((
|
Self::log_session_mmp_metrics(&session_name, mmp);
|
||||||
*dest_addr,
|
mmp.mark_logged(now_ms);
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
MmpAction::SendSessionReport { dest, kind } => {
|
||||||
// Peek at current failure count for log suppression
|
let built = self
|
||||||
let failures = self
|
|
||||||
.sessions
|
.sessions
|
||||||
.get(&dest_addr)
|
.get_mut(&dest)
|
||||||
.and_then(|entry| entry.mmp())
|
.and_then(|entry| entry.mmp_mut())
|
||||||
.map(|mmp| mmp.sender.consecutive_send_failures())
|
.and_then(|mmp| match kind {
|
||||||
.unwrap_or(0);
|
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 {
|
let Some((msg_type, body)) = built else {
|
||||||
debug!(
|
continue;
|
||||||
dest = %self.peer_display_name(&dest_addr),
|
};
|
||||||
msg_type,
|
|
||||||
error = %e,
|
match self.send_session_msg(&dest, msg_type, &body).await {
|
||||||
"Failed to send session MMP report"
|
Ok(()) => send_results.push(SendResult { dest, ok: true }),
|
||||||
);
|
Err(e) => {
|
||||||
} else if failures == 3 {
|
// Peek at current failure count for log suppression
|
||||||
debug!(
|
// (unchanged by the backoff apply, which runs later).
|
||||||
dest = %self.peer_display_name(&dest_addr),
|
let failures = self
|
||||||
"Suppressing further session MMP send failure logs"
|
.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 send results per destination (any-ok -> success, all-fail
|
||||||
// Deduplicate: a destination counts as success if ANY report succeeded,
|
// -> failure) and apply the backoff state transition for each dest.
|
||||||
// failure only if ALL reports for that destination failed.
|
for update in self.mmp.plan_backoff(&send_results) {
|
||||||
let mut dest_success: std::collections::HashMap<NodeAddr, bool> =
|
match update {
|
||||||
std::collections::HashMap::new();
|
BackoffUpdate::Success { dest } => {
|
||||||
for (dest, ok) in &send_results {
|
if let Some(mmp) = self.sessions.get_mut(&dest).and_then(|e| e.mmp_mut()) {
|
||||||
let entry = dest_success.entry(*dest).or_insert(false);
|
let prev = mmp.sender.record_send_success();
|
||||||
if *ok {
|
if prev > 3 {
|
||||||
*entry = true;
|
debug!(
|
||||||
}
|
dest = %self.peer_display_name(&dest),
|
||||||
}
|
consecutive_failures = prev,
|
||||||
for (dest_addr, success) in dest_success {
|
"Resumed session MMP reporting"
|
||||||
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();
|
BackoffUpdate::Failure { dest } => {
|
||||||
if prev > 3 {
|
if let Some(mmp) = self.sessions.get_mut(&dest).and_then(|e| e.mmp_mut()) {
|
||||||
debug!(
|
mmp.sender.record_send_failure();
|
||||||
dest = %self.peer_display_name(&dest_addr),
|
|
||||||
consecutive_failures = prev,
|
|
||||||
"Resumed session MMP reporting"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
mmp.sender.record_send_failure();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -540,82 +628,99 @@ impl Node {
|
|||||||
/// hasn't sent us a frame within the link dead timeout.
|
/// hasn't sent us a frame within the link dead timeout.
|
||||||
pub(in crate::node) async fn check_link_heartbeats(&mut self) {
|
pub(in crate::node) async fn check_link_heartbeats(&mut self) {
|
||||||
let now = Instant::now();
|
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 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 = 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 max_resends = self.config().node.rate_limit.handshake_max_resends;
|
||||||
let heartbeat_msg = [LinkMessageType::Heartbeat.to_byte()];
|
let heartbeat_msg = [LinkMessageType::Heartbeat.to_byte()];
|
||||||
|
|
||||||
// Collect heartbeats to send and dead peers to remove
|
// Build one liveness snapshot per peer, resolving every clock read and
|
||||||
let mut heartbeats: Vec<NodeAddr> = Vec::new();
|
// the rekey-suppression predicate shell-side. The snapshots own only
|
||||||
let mut dead_peers: Vec<NodeAddr> = Vec::new();
|
// `NodeAddr`/`bool`, so the peer-iteration borrow is released before the
|
||||||
|
// pure decision runs and the driving loop mutates the registry.
|
||||||
|
let snapshots: Vec<PeerLivenessSnapshot> = 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() {
|
// Suppress teardown while an FMP rekey is genuinely in flight
|
||||||
// Check liveness via MMP receiver last_recv_time.
|
// with budget left: a rekey-handshake link is not silent. The
|
||||||
// Fall back to session_start for peers that never sent data.
|
// msg1 resend cap guarantees this terminates (abandon on
|
||||||
let time_dead = if let Some(mmp) = peer.mmp() {
|
// exhaustion or cutover on completion clears
|
||||||
let reference_time = mmp
|
// `rekey_in_progress`), so a truly dead link is reaped on the
|
||||||
.receiver
|
// next cycle.
|
||||||
.last_recv_time()
|
let rekey_active = peer.rekey_in_progress()
|
||||||
.unwrap_or(peer.session_start());
|
&& peer.rekey_msg1_resend_count() < max_resends
|
||||||
now.duration_since(reference_time) >= dead_timeout
|
&& peer.rekey_msg1().is_some();
|
||||||
} else {
|
|
||||||
false
|
|
||||||
};
|
|
||||||
|
|
||||||
// Suppress teardown while an FMP rekey is genuinely in flight with
|
// Check if heartbeat is due.
|
||||||
// budget left: a rekey-handshake link is not silent. The msg1
|
let heartbeat_due = match peer.last_heartbeat_sent() {
|
||||||
// resend cap guarantees this terminates (abandon on exhaustion or
|
None => true,
|
||||||
// cutover on completion clears `rekey_in_progress`), so a truly
|
Some(last) => now.duration_since(last) >= heartbeat_interval,
|
||||||
// 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();
|
|
||||||
|
|
||||||
let is_dead = time_dead && !rekey_active;
|
PeerLivenessSnapshot {
|
||||||
if is_dead {
|
peer: *node_addr,
|
||||||
dead_peers.push(*node_addr);
|
time_dead,
|
||||||
continue;
|
rekey_active,
|
||||||
}
|
heartbeat_due,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Check if heartbeat is due
|
let actions = self.mmp.plan_heartbeats(&snapshots);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove dead peers and schedule auto-reconnect
|
// Wall-clock basis for reconnect scheduling, sourced once (as before).
|
||||||
let now_ms = std::time::SystemTime::now()
|
let now_ms = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.map(|d| d.as_millis() as u64)
|
.map(|d| d.as_millis() as u64)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
for addr in &dead_peers {
|
// Drive the planned actions: all reaps first (each removed +
|
||||||
debug!(
|
// reconnect-scheduled), then all heartbeats (a just-reaped peer is never
|
||||||
peer = %self.peer_display_name(addr),
|
// heartbeated — the core never emits both for the same peer).
|
||||||
timeout_secs = self.config().node.link_dead_timeout_secs,
|
for action in actions {
|
||||||
"Removing peer: link dead timeout"
|
match action {
|
||||||
);
|
MmpAction::ReapPeer { peer } => {
|
||||||
self.remove_active_peer(addr);
|
debug!(
|
||||||
self.schedule_reconnect(*addr, now_ms);
|
peer = %self.peer_display_name(&peer),
|
||||||
}
|
timeout_secs = self.config().node.link_dead_timeout_secs,
|
||||||
|
"Removing peer: link dead timeout"
|
||||||
// Send heartbeats (skip peers we just removed)
|
);
|
||||||
for addr in heartbeats {
|
self.remove_active_peer(&peer);
|
||||||
if dead_peers.contains(&addr) {
|
self.schedule_reconnect(peer, now_ms);
|
||||||
continue;
|
}
|
||||||
}
|
MmpAction::Heartbeat { peer } => {
|
||||||
if let Some(peer) = self.peers.get_mut(&addr) {
|
if let Some(p) = self.peers.get_mut(&peer) {
|
||||||
peer.mark_heartbeat_sent(now);
|
p.mark_heartbeat_sent(now);
|
||||||
}
|
}
|
||||||
if let Err(e) = self
|
if let Err(e) = self
|
||||||
.send_encrypted_link_message(&addr, &heartbeat_msg)
|
.send_encrypted_link_message(&peer, &heartbeat_msg)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
trace!(peer = %self.peer_display_name(&addr), error = %e, "Failed to send heartbeat");
|
trace!(peer = %self.peer_display_name(&peer), error = %e, "Failed to send heartbeat");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MmpAction::SendLinkReport { .. }
|
||||||
|
| MmpAction::LogLink { .. }
|
||||||
|
| MmpAction::SendSessionReport { .. }
|
||||||
|
| MmpAction::LogSession { .. } => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,6 @@
|
|||||||
//! encrypted data, and error signals (CoordsRequired, PathBroken).
|
//! encrypted data, and error signals (CoordsRequired, PathBroken).
|
||||||
|
|
||||||
use crate::NodeAddr;
|
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::reject::{RejectReason, SessionReject};
|
||||||
use crate::node::session::{EndToEndState, EpochSlot, SessionEntry};
|
use crate::node::session::{EndToEndState, EpochSlot, SessionEntry};
|
||||||
use crate::node::session_wire::{
|
use crate::node::session_wire::{
|
||||||
@@ -24,14 +22,17 @@ use crate::node::{Node, NodeError};
|
|||||||
use crate::noise::{
|
use crate::noise::{
|
||||||
HandshakeState, XK_HANDSHAKE_MSG1_SIZE, XK_HANDSHAKE_MSG2_SIZE, XK_HANDSHAKE_MSG3_SIZE,
|
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};
|
use crate::proto::routing::{CoordsRequired, MtuExceeded, PathBroken};
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use crate::protocol::LinkMessageType;
|
use crate::protocol::LinkMessageType;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use crate::protocol::SESSION_DATAGRAM_HEADER_SIZE;
|
use crate::protocol::SESSION_DATAGRAM_HEADER_SIZE;
|
||||||
use crate::protocol::{
|
use crate::protocol::{
|
||||||
FspInnerFlags, PathMtuNotification, SessionAck, SessionDatagram, SessionMessageType,
|
FspInnerFlags, SessionAck, SessionDatagram, SessionMessageType, SessionMsg3, SessionSetup,
|
||||||
SessionMsg3, SessionReceiverReport, SessionSenderReport, SessionSetup,
|
|
||||||
};
|
};
|
||||||
use crate::protocol::{coords_wire_size, encode_coords};
|
use crate::protocol::{coords_wire_size, encode_coords};
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -305,16 +306,16 @@ impl Node {
|
|||||||
if let Some(entry) = self.sessions.get_mut(src_addr)
|
if let Some(entry) = self.sessions.get_mut(src_addr)
|
||||||
&& let Some(mmp) = entry.mmp_mut()
|
&& let Some(mmp) = entry.mmp_mut()
|
||||||
{
|
{
|
||||||
let now = std::time::Instant::now();
|
let now_ms = crate::mmp::mono_ms();
|
||||||
mmp.receiver
|
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.
|
// Spin bit: advance state machine for correct TX reflection.
|
||||||
// RTT samples not fed into SRTT — timestamp-echo provides
|
// RTT samples not fed into SRTT — timestamp-echo provides
|
||||||
// accurate RTT; spin bit includes variable inter-frame delays.
|
// accurate RTT; spin bit includes variable inter-frame delays.
|
||||||
let inner_flags = FspInnerFlags::from_byte(inner_flags_byte);
|
let inner_flags = FspInnerFlags::from_byte(inner_flags_byte);
|
||||||
let _spin_rtt = mmp
|
let _spin_rtt = mmp
|
||||||
.spin_bit
|
.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.
|
// Feed path_mtu from datagram envelope to MMP path MTU tracking.
|
||||||
@@ -974,9 +975,11 @@ impl Node {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let now = std::time::Instant::now();
|
let (_first_rtt, rr_log) =
|
||||||
mmp.metrics
|
mmp.metrics
|
||||||
.process_receiver_report(&rr, our_timestamp_ms, now);
|
.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)
|
// Feed SRTT back to sender/receiver report interval tuning (session-layer bounds)
|
||||||
if let Some(srtt_ms) = mmp.metrics.srtt_ms() {
|
if let Some(srtt_ms) = mmp.metrics.srtt_ms() {
|
||||||
@@ -1042,8 +1045,9 @@ impl Node {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let old_mtu = mmp.path_mtu.current_mtu();
|
let old_mtu = mmp.path_mtu.current_mtu();
|
||||||
let now = std::time::Instant::now();
|
let changed = mmp
|
||||||
let changed = mmp.path_mtu.apply_notification(notif.path_mtu, now);
|
.path_mtu
|
||||||
|
.apply_notification(notif.path_mtu, crate::mmp::mono_ms());
|
||||||
let new_mtu = mmp.path_mtu.current_mtu();
|
let new_mtu = mmp.path_mtu.current_mtu();
|
||||||
|
|
||||||
if !changed {
|
if !changed {
|
||||||
@@ -1256,8 +1260,10 @@ impl Node {
|
|||||||
&& let Some(mmp) = entry.mmp_mut()
|
&& let Some(mmp) = entry.mmp_mut()
|
||||||
{
|
{
|
||||||
let old_mtu = mmp.path_mtu.current_mtu();
|
let old_mtu = mmp.path_mtu.current_mtu();
|
||||||
let now = std::time::Instant::now();
|
if mmp
|
||||||
if mmp.path_mtu.apply_notification(msg.mtu, now) {
|
.path_mtu
|
||||||
|
.apply_notification(msg.mtu, crate::mmp::mono_ms())
|
||||||
|
{
|
||||||
let new_mtu = mmp.path_mtu.current_mtu();
|
let new_mtu = mmp.path_mtu.current_mtu();
|
||||||
info!(
|
info!(
|
||||||
dest = %peer_name,
|
dest = %peer_name,
|
||||||
|
|||||||
+9
-3
@@ -46,6 +46,7 @@ use crate::node::session::SessionEntry;
|
|||||||
use crate::peer::{ActivePeer, PeerConnection};
|
use crate::peer::{ActivePeer, PeerConnection};
|
||||||
use crate::proto::discovery::{Discovery, DiscoveryBackoff, DiscoveryForwardRateLimiter};
|
use crate::proto::discovery::{Discovery, DiscoveryBackoff, DiscoveryForwardRateLimiter};
|
||||||
use crate::proto::fmp::Fmp;
|
use crate::proto::fmp::Fmp;
|
||||||
|
use crate::proto::mmp::Mmp;
|
||||||
use crate::proto::routing::{self, Router, RoutingErrorRateLimiter};
|
use crate::proto::routing::{self, Router, RoutingErrorRateLimiter};
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use crate::transport::ethernet::EthernetTransport;
|
use crate::transport::ethernet::EthernetTransport;
|
||||||
@@ -420,6 +421,9 @@ pub struct Node {
|
|||||||
/// FMP connection-lifecycle decision anchor (stateless; drives the
|
/// FMP connection-lifecycle decision anchor (stateless; drives the
|
||||||
/// tick-poll maintain/teardown decisions).
|
/// tick-poll maintain/teardown decisions).
|
||||||
fmp: Fmp,
|
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.
|
/// Rate limiter for source-side CoordsRequired/PathBroken responses.
|
||||||
coords_response_rate_limiter: RoutingErrorRateLimiter,
|
coords_response_rate_limiter: RoutingErrorRateLimiter,
|
||||||
|
|
||||||
@@ -663,6 +667,7 @@ impl Node {
|
|||||||
icmp_rate_limiter: IcmpRateLimiter::new(),
|
icmp_rate_limiter: IcmpRateLimiter::new(),
|
||||||
routing: Router::new(),
|
routing: Router::new(),
|
||||||
fmp: Fmp::new(),
|
fmp: Fmp::new(),
|
||||||
|
mmp: Mmp::new(),
|
||||||
coords_response_rate_limiter: RoutingErrorRateLimiter::with_interval_ms(
|
coords_response_rate_limiter: RoutingErrorRateLimiter::with_interval_ms(
|
||||||
coords_response_interval_ms,
|
coords_response_interval_ms,
|
||||||
),
|
),
|
||||||
@@ -823,6 +828,7 @@ impl Node {
|
|||||||
icmp_rate_limiter: IcmpRateLimiter::new(),
|
icmp_rate_limiter: IcmpRateLimiter::new(),
|
||||||
routing: Router::new(),
|
routing: Router::new(),
|
||||||
fmp: Fmp::new(),
|
fmp: Fmp::new(),
|
||||||
|
mmp: Mmp::new(),
|
||||||
coords_response_rate_limiter: RoutingErrorRateLimiter::with_interval_ms(
|
coords_response_rate_limiter: RoutingErrorRateLimiter::with_interval_ms(
|
||||||
coords_response_interval_ms,
|
coords_response_interval_ms,
|
||||||
),
|
),
|
||||||
@@ -2039,7 +2045,7 @@ impl Node {
|
|||||||
(Some(srtt), Some(setx)) => Some(setx * (1.0 + srtt / 100.0)),
|
(Some(srtt), Some(setx)) => Some(setx * (1.0 + srtt / 100.0)),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
let trend = |dual: &crate::mmp::algorithms::DualEwma| {
|
let trend = |dual: &crate::proto::mmp::DualEwma| {
|
||||||
dual.initialized()
|
dual.initialized()
|
||||||
.then(|| crate::control::queries::trend_label(dual.short(), dual.long()))
|
.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)),
|
(Some(srtt), Some(setx)) => Some(setx * (1.0 + srtt / 100.0)),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
let trend = |dual: &crate::mmp::algorithms::DualEwma| {
|
let trend = |dual: &crate::proto::mmp::DualEwma| {
|
||||||
dual.initialized()
|
dual.initialized()
|
||||||
.then(|| crate::control::queries::trend_label(dual.short(), dual.long()))
|
.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
|
/// is precomputed here exactly as the on-loop queries do, so the render is a
|
||||||
/// plain field emit.
|
/// plain field emit.
|
||||||
fn project_entity_mmp(
|
fn project_entity_mmp(
|
||||||
metrics: &crate::mmp::metrics::MmpMetrics,
|
metrics: &crate::proto::mmp::MmpMetrics,
|
||||||
mode: String,
|
mode: String,
|
||||||
path_mtu: Option<u16>,
|
path_mtu: Option<u16>,
|
||||||
) -> crate::control::snapshot::EntityMmp {
|
) -> crate::control::snapshot::EntityMmp {
|
||||||
|
|||||||
+9
-6
@@ -5,13 +5,11 @@
|
|||||||
//! (SessionSetup/SessionAck/SessionMsg3) carried inside SessionDatagram
|
//! (SessionSetup/SessionAck/SessionMsg3) carried inside SessionDatagram
|
||||||
//! envelopes through the mesh.
|
//! envelopes through the mesh.
|
||||||
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use crate::NodeAddr;
|
use crate::NodeAddr;
|
||||||
use crate::config::SessionMmpConfig;
|
use crate::config::SessionMmpConfig;
|
||||||
use crate::mmp::MmpSessionState;
|
|
||||||
use crate::node::REKEY_JITTER_SECS;
|
use crate::node::REKEY_JITTER_SECS;
|
||||||
use crate::noise::{HandshakeState, NoiseSession};
|
use crate::noise::{HandshakeState, NoiseSession};
|
||||||
|
use crate::proto::mmp::MmpSessionState;
|
||||||
use rand::RngExt;
|
use rand::RngExt;
|
||||||
use secp256k1::PublicKey;
|
use secp256k1::PublicKey;
|
||||||
|
|
||||||
@@ -338,7 +336,12 @@ impl SessionEntry {
|
|||||||
|
|
||||||
/// Initialize session-layer MMP state (called on Established transition).
|
/// Initialize session-layer MMP state (called on Established transition).
|
||||||
pub(crate) fn init_mmp(&mut self, config: &SessionMmpConfig) {
|
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 ===
|
// === Traffic Counters ===
|
||||||
@@ -665,9 +668,9 @@ impl SessionEntry {
|
|||||||
self.rekey_jitter_secs = draw_rekey_jitter();
|
self.rekey_jitter_secs = draw_rekey_jitter();
|
||||||
|
|
||||||
// Reset MMP counters to avoid metric discontinuity
|
// 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 {
|
if let Some(mmp) = &mut self.mmp {
|
||||||
mmp.reset_for_rekey(now);
|
mmp.reset_for_rekey(now_ms);
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<u8> {
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ mod ethernet;
|
|||||||
mod forwarding;
|
mod forwarding;
|
||||||
mod handshake;
|
mod handshake;
|
||||||
mod heartbeat;
|
mod heartbeat;
|
||||||
|
mod mmp_chartests;
|
||||||
mod routing;
|
mod routing;
|
||||||
mod session;
|
mod session;
|
||||||
mod spanning_tree;
|
mod spanning_tree;
|
||||||
|
|||||||
@@ -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 entry = nodes[0].node.get_session_mut(&node1_addr).unwrap();
|
||||||
let mmp = entry.mmp_mut().unwrap();
|
let mmp = entry.mmp_mut().unwrap();
|
||||||
mmp.path_mtu
|
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);
|
assert_eq!(mmp.path_mtu.current_mtu(), reduced_mtu);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+44
-6
@@ -4,9 +4,10 @@
|
|||||||
//! ActivePeer holds tree state, Bloom filter, and routing information.
|
//! ActivePeer holds tree state, Bloom filter, and routing information.
|
||||||
|
|
||||||
use crate::bloom::BloomFilter;
|
use crate::bloom::BloomFilter;
|
||||||
use crate::mmp::{MmpConfig, MmpPeerState};
|
use crate::mmp::MmpConfig;
|
||||||
use crate::node::REKEY_JITTER_SECS;
|
use crate::node::REKEY_JITTER_SECS;
|
||||||
use crate::noise::{HandshakeState as NoiseHandshakeState, NoiseError, NoiseSession};
|
use crate::noise::{HandshakeState as NoiseHandshakeState, NoiseError, NoiseSession};
|
||||||
|
use crate::proto::mmp::MmpPeerState;
|
||||||
use crate::transport::{LinkId, LinkStats, TransportAddr, TransportId};
|
use crate::transport::{LinkId, LinkStats, TransportAddr, TransportId};
|
||||||
use crate::tree::{ParentDeclaration, TreeCoordinate};
|
use crate::tree::{ParentDeclaration, TreeCoordinate};
|
||||||
use crate::utils::index::SessionIndex;
|
use crate::utils::index::SessionIndex;
|
||||||
@@ -332,7 +333,12 @@ impl ActivePeer {
|
|||||||
authenticated_at,
|
authenticated_at,
|
||||||
last_seen: authenticated_at,
|
last_seen: authenticated_at,
|
||||||
remote_epoch,
|
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,
|
last_heartbeat_sent: None,
|
||||||
handshake_msg2: None,
|
handshake_msg2: None,
|
||||||
replay_suppressed_count: 0,
|
replay_suppressed_count: 0,
|
||||||
@@ -892,6 +898,38 @@ impl ActivePeer {
|
|||||||
.unwrap_or_else(Instant::now);
|
.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).
|
/// Per-session symmetric rekey-timer jitter offset (seconds).
|
||||||
///
|
///
|
||||||
/// Drawn at session construction and at each rekey cutover; uniform
|
/// Drawn at session construction and at each rekey cutover; uniform
|
||||||
@@ -1018,9 +1056,9 @@ impl ActivePeer {
|
|||||||
self.reset_replay_suppressed();
|
self.reset_replay_suppressed();
|
||||||
|
|
||||||
// Reset MMP counters to avoid metric discontinuity
|
// 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 {
|
if let Some(mmp) = &mut self.mmp {
|
||||||
mmp.reset_for_rekey(now);
|
mmp.reset_for_rekey(now_ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.previous_our_index
|
self.previous_our_index
|
||||||
@@ -1055,9 +1093,9 @@ impl ActivePeer {
|
|||||||
self.reset_replay_suppressed();
|
self.reset_replay_suppressed();
|
||||||
|
|
||||||
// Reset MMP counters to avoid metric discontinuity
|
// 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 {
|
if let Some(mmp) = &mut self.mmp {
|
||||||
mmp.reset_for_rekey(now);
|
mmp.reset_for_rekey(now_ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.previous_our_index
|
self.previous_our_index
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
//! MMP algorithmic building blocks.
|
//! MMP algorithmic building blocks.
|
||||||
//!
|
//!
|
||||||
//! Pure computational types with no dependency on peer or node state.
|
//! 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 alloc::collections::VecDeque;
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use crate::mmp::{EWMA_LONG_ALPHA, EWMA_SHORT_ALPHA};
|
use super::{EWMA_LONG_ALPHA, EWMA_SHORT_ALPHA};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Jitter Estimator (RFC 3550 §6.4.1)
|
// Jitter Estimator (RFC 3550 §6.4.1)
|
||||||
@@ -235,7 +236,10 @@ impl OwdTrendDetector {
|
|||||||
den += dx * dx;
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,14 +250,6 @@ impl OwdTrendDetector {
|
|||||||
let slope_per_packet = num / den;
|
let slope_per_packet = num / den;
|
||||||
(slope_per_packet * 1000.0) as i32
|
(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,
|
current_value: bool,
|
||||||
/// Highest counter observed with a spin edge (responder guard).
|
/// Highest counter observed with a spin edge (responder guard).
|
||||||
highest_counter_for_spin: u64,
|
highest_counter_for_spin: u64,
|
||||||
/// Time of last spin edge (initiator only, for RTT measurement).
|
/// Time of last spin edge in injected `u64` milliseconds (initiator only,
|
||||||
last_edge_time: Option<Instant>,
|
/// for RTT measurement).
|
||||||
|
last_edge_ms: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SpinBitState {
|
impl SpinBitState {
|
||||||
@@ -300,7 +297,7 @@ impl SpinBitState {
|
|||||||
is_initiator,
|
is_initiator,
|
||||||
current_value: false,
|
current_value: false,
|
||||||
highest_counter_for_spin: 0,
|
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.
|
/// Process a received frame's spin bit.
|
||||||
///
|
///
|
||||||
/// Returns an RTT sample duration if an edge was detected (initiator only).
|
/// `now_ms` is the injected monotonic time in milliseconds. Returns an RTT
|
||||||
pub fn rx_observe(
|
/// sample in milliseconds if an edge was detected (initiator only).
|
||||||
&mut self,
|
pub fn rx_observe(&mut self, received_bit: bool, counter: u64, now_ms: u64) -> Option<u64> {
|
||||||
received_bit: bool,
|
|
||||||
counter: u64,
|
|
||||||
now: Instant,
|
|
||||||
) -> Option<std::time::Duration> {
|
|
||||||
if self.is_initiator {
|
if self.is_initiator {
|
||||||
// Initiator: when the reflected bit matches what we sent,
|
// Initiator: when the reflected bit matches what we sent,
|
||||||
// that completes a round trip. Record the edge time, then
|
// that completes a round trip. Record the edge time, then
|
||||||
// flip for the next cycle.
|
// flip for the next cycle.
|
||||||
if received_bit == self.current_value {
|
if received_bit == self.current_value {
|
||||||
let rtt = self.last_edge_time.map(|t| now.duration_since(t));
|
let rtt = self.last_edge_ms.map(|t| now_ms.saturating_sub(t));
|
||||||
self.last_edge_time = Some(now);
|
self.last_edge_ms = Some(now_ms);
|
||||||
self.current_value = !self.current_value;
|
self.current_value = !self.current_value;
|
||||||
rtt
|
rtt
|
||||||
} else {
|
} 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<MmpAction> {
|
||||||
|
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<MmpAction> {
|
||||||
|
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<MmpAction> {
|
||||||
|
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<NodeAddr, bool>` (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<BackoffUpdate> {
|
||||||
|
let mut dest_success: BTreeMap<NodeAddr, bool> = 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
//! MMP reporting subsystem unit tests.
|
||||||
|
|
||||||
|
mod algorithms;
|
||||||
|
mod core;
|
||||||
|
mod state;
|
||||||
|
mod wire;
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
//! Serialization and deserialization for the report types exchanged between
|
||||||
//! between link-layer peers. Wire format follows the MMP design doc.
|
//! 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;
|
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<u8> {
|
||||||
|
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<Self, ProtocolError> {
|
||||||
|
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<u8> {
|
||||||
|
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<Self, ProtocolError> {
|
||||||
|
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<u8> {
|
||||||
|
self.path_mtu.to_le_bytes().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode from body (after FSP inner header has been stripped).
|
||||||
|
pub fn decode(body: &[u8]) -> Result<Self, ProtocolError> {
|
||||||
|
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 {
|
impl From<&SenderReport> for SessionSenderReport {
|
||||||
fn from(r: &SenderReport) -> Self {
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,4 +5,5 @@
|
|||||||
|
|
||||||
pub(crate) mod discovery;
|
pub(crate) mod discovery;
|
||||||
pub(crate) mod fmp;
|
pub(crate) mod fmp;
|
||||||
|
pub(crate) mod mmp;
|
||||||
pub(crate) mod routing;
|
pub(crate) mod routing;
|
||||||
|
|||||||
+2
-3
@@ -33,9 +33,8 @@ pub use link::{
|
|||||||
LinkMessageType, SESSION_DATAGRAM_HEADER_SIZE, SessionDatagram, SessionDatagramRef,
|
LinkMessageType, SESSION_DATAGRAM_HEADER_SIZE, SessionDatagram, SessionDatagramRef,
|
||||||
};
|
};
|
||||||
pub use session::{
|
pub use session::{
|
||||||
FspFlags, FspInnerFlags, PATH_MTU_NOTIFICATION_SIZE, PathMtuNotification,
|
FspFlags, FspInnerFlags, SessionAck, SessionFlags, SessionMessageType, SessionMsg3,
|
||||||
SESSION_RECEIVER_REPORT_SIZE, SESSION_SENDER_REPORT_SIZE, SessionAck, SessionFlags,
|
SessionSetup,
|
||||||
SessionMessageType, SessionMsg3, SessionReceiverReport, SessionSenderReport, SessionSetup,
|
|
||||||
};
|
};
|
||||||
pub(crate) use session::{coords_wire_size, decode_optional_coords, encode_coords};
|
pub(crate) use session::{coords_wire_size, decode_optional_coords, encode_coords};
|
||||||
pub use tree::TreeAnnounce;
|
pub use tree::TreeAnnounce;
|
||||||
|
|||||||
@@ -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<u8> {
|
|
||||||
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<Self, ProtocolError> {
|
|
||||||
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<u8> {
|
|
||||||
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<Self, ProtocolError> {
|
|
||||||
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<u8> {
|
|
||||||
self.path_mtu.to_le_bytes().to_vec()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode from body (after FSP inner header has been stripped).
|
|
||||||
pub fn decode(body: &[u8]) -> Result<Self, ProtocolError> {
|
|
||||||
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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 =====
|
// ===== MtuExceeded Tests =====
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user