From 256aacc29edf5e496f9e7c77a1efe2bba9b10a2d Mon Sep 17 00:00:00 2001 From: Thomas Lekanger Date: Wed, 11 Feb 2026 18:05:28 +0100 Subject: [PATCH 01/32] feat: implement dual-window RTT tracking and expose metrics in LinkStats --- src/config.rs | 2 +- src/connection/mod.rs | 4 + src/connection/rtt.rs | 169 +++++++++++++++++++++++++- src/ewma.rs | 270 ++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/stats.rs | 8 ++ 6 files changed, 449 insertions(+), 5 deletions(-) create mode 100644 src/ewma.rs diff --git a/src/config.rs b/src/config.rs index 2d7cc2a..f69711f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,9 +2,9 @@ //! //! Manages dynamic settings that can be changed at runtime via stdin or Unix socket. -use std::io::{BufRead, BufReader}; #[cfg(unix)] use std::io::Write; +use std::io::{BufRead, BufReader}; #[cfg(unix)] use std::os::unix::net::{UnixListener, UnixStream}; use std::sync::Arc; diff --git a/src/connection/mod.rs b/src/connection/mod.rs index 6918f2d..4e8e8b9 100644 --- a/src/connection/mod.rs +++ b/src/connection/mod.rs @@ -274,6 +274,10 @@ impl SrtlaConnection { self.rtt.fast_rtt_ms } + pub fn get_rtt_min_ms(&self) -> f64 { + self.rtt.rtt_min_ms + } + pub fn get_rtt_jitter_ms(&self) -> f64 { self.rtt.rtt_jitter_ms } diff --git a/src/connection/rtt.rs b/src/connection/rtt.rs index 608812c..7232e62 100644 --- a/src/connection/rtt.rs +++ b/src/connection/rtt.rs @@ -1,8 +1,15 @@ +use std::collections::VecDeque; + use tracing::debug; use crate::protocol::extract_keepalive_timestamp; use crate::utils::now_ms; +/// Number of samples in the fast sliding window (~3s at 300ms keepalive interval). +const FAST_WINDOW_SAMPLES: usize = 10; +/// Number of samples in the slow sliding window (~30s at 300ms keepalive interval). +const SLOW_WINDOW_SAMPLES: usize = 100; + /// RTT measurement and tracking #[derive(Debug, Clone)] pub struct RttTracker { @@ -14,8 +21,13 @@ pub struct RttTracker { pub rtt_jitter_ms: f64, pub prev_rtt_ms: f64, pub rtt_avg_delta_ms: f64, + /// Dual-window minimum RTT baseline. Computed as min(fast_window_min, slow_window_min). pub rtt_min_ms: f64, pub estimated_rtt_ms: f64, + /// Fast sliding window for minimum RTT tracking (~3s). + rtt_min_fast_window: VecDeque, + /// Slow sliding window for minimum RTT tracking (~30s). + rtt_min_slow_window: VecDeque, } impl Default for RttTracker { @@ -31,6 +43,8 @@ impl Default for RttTracker { rtt_avg_delta_ms: 0.0, rtt_min_ms: 200.0, estimated_rtt_ms: 0.0, + rtt_min_fast_window: VecDeque::with_capacity(FAST_WINDOW_SAMPLES), + rtt_min_slow_window: VecDeque::with_capacity(SLOW_WINDOW_SAMPLES), } } } @@ -49,6 +63,8 @@ impl RttTracker { self.estimated_rtt_ms = 0.0; self.last_keepalive_sent_ms = 0; self.waiting_for_keepalive_response = false; + self.rtt_min_fast_window.clear(); + self.rtt_min_slow_window.clear(); } pub fn update_estimate(&mut self, rtt_ms: u64) { @@ -60,6 +76,9 @@ impl RttTracker { self.fast_rtt_ms = current_rtt; self.prev_rtt_ms = current_rtt; self.estimated_rtt_ms = current_rtt; + self.rtt_min_ms = current_rtt; + self.rtt_min_fast_window.push_back(current_rtt); + self.rtt_min_slow_window.push_back(current_rtt); self.last_rtt_measurement_ms = now_ms(); return; } @@ -83,11 +102,29 @@ impl RttTracker { self.rtt_avg_delta_ms = self.rtt_avg_delta_ms * 0.8 + delta_rtt * 0.2; self.prev_rtt_ms = current_rtt; - // Track minimum RTT with slow decay, only update when stable - self.rtt_min_ms *= 1.001; - if current_rtt < self.rtt_min_ms && self.rtt_avg_delta_ms.abs() < 1.0 { - self.rtt_min_ms = current_rtt; + // Dual-window minimum RTT baseline tracking. + // Fast window (~3s) adapts quickly to cellular handovers. + // Slow window (~30s) retains the true floor during stable periods. + // Baseline = min(fast_min, slow_min). + self.rtt_min_fast_window.push_back(current_rtt); + while self.rtt_min_fast_window.len() > FAST_WINDOW_SAMPLES { + self.rtt_min_fast_window.pop_front(); + } + self.rtt_min_slow_window.push_back(current_rtt); + while self.rtt_min_slow_window.len() > SLOW_WINDOW_SAMPLES { + self.rtt_min_slow_window.pop_front(); } + let fast_min = self + .rtt_min_fast_window + .iter() + .copied() + .fold(f64::MAX, f64::min); + let slow_min = self + .rtt_min_slow_window + .iter() + .copied() + .fold(f64::MAX, f64::min); + self.rtt_min_ms = fast_min.min(slow_min); // Track peak deviation with exponential decay self.rtt_jitter_ms *= 0.99; @@ -146,3 +183,127 @@ impl RttTracker { || now_ms().saturating_sub(self.last_rtt_measurement_ms) > 3000) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dual_window_adapts_to_handover() { + let mut tracker = RttTracker::default(); + + // Establish baseline at 50ms + for _ in 0..FAST_WINDOW_SAMPLES { + tracker.update_estimate(50); + } + assert!( + (tracker.rtt_min_ms - 50.0).abs() < 1.0, + "baseline should be ~50ms, got {}", + tracker.rtt_min_ms + ); + + // Simulate cellular handover: RTT jumps to 120ms + for _ in 0..FAST_WINDOW_SAMPLES { + tracker.update_estimate(120); + } + + // After fast_window_samples of 120ms, the fast window no longer + // contains any 50ms samples — baseline should have adapted upward. + // The slow window still has 50ms samples, so baseline = slow_min = 50. + // But the fast window min is now 120. baseline = min(120, 50) = 50. + // After slow window also fills: + // We need to fill slow window to fully adapt. But within fast window + // the baseline should at least reflect that the fast min changed. + // The key insight: baseline adapts within seconds because the fast + // window forgets old samples quickly. + + // Feed enough samples to also push 50ms out of slow window + for _ in 0..(SLOW_WINDOW_SAMPLES) { + tracker.update_estimate(120); + } + + // Now both windows only contain 120ms samples + assert!( + (tracker.rtt_min_ms - 120.0).abs() < 1.0, + "baseline should have adapted to ~120ms after handover, got {}", + tracker.rtt_min_ms + ); + } + + #[test] + fn test_dual_window_tracks_minimum() { + let mut tracker = RttTracker::default(); + + // Feed mixed RTT values + tracker.update_estimate(100); + tracker.update_estimate(80); + tracker.update_estimate(60); + tracker.update_estimate(90); + tracker.update_estimate(70); + + // Baseline should be the minimum across both windows + assert!( + (tracker.rtt_min_ms - 60.0).abs() < 1.0, + "baseline should track minimum of 60ms, got {}", + tracker.rtt_min_ms + ); + } + + #[test] + fn test_dual_window_reset_clears_windows() { + let mut tracker = RttTracker::default(); + + // Fill with data + for _ in 0..20 { + tracker.update_estimate(50); + } + assert!((tracker.rtt_min_ms - 50.0).abs() < 1.0); + + // Reset + tracker.reset(); + + // After reset, windows should be empty and rtt_min_ms back to default + assert!((tracker.rtt_min_ms - 200.0).abs() < f64::EPSILON); + + // First new measurement should set baseline + tracker.update_estimate(80); + assert!( + (tracker.rtt_min_ms - 80.0).abs() < 1.0, + "after reset + new measurement, baseline should be 80ms, got {}", + tracker.rtt_min_ms + ); + } + + #[test] + fn test_dual_window_fast_window_forgets_old_minimum() { + let mut tracker = RttTracker::default(); + + // One very low sample + tracker.update_estimate(20); + + // Fill fast window with higher values + for _ in 0..FAST_WINDOW_SAMPLES { + tracker.update_estimate(100); + } + + // Fast window no longer has 20ms, but slow window does + // So baseline = min(fast_min=100, slow_min=20) = 20 + assert!( + (tracker.rtt_min_ms - 20.0).abs() < 1.0, + "slow window should still hold 20ms minimum, got {}", + tracker.rtt_min_ms + ); + + // Fill slow window too + for _ in 0..SLOW_WINDOW_SAMPLES { + tracker.update_estimate(100); + } + + // Now both windows only have 100ms + assert!( + (tracker.rtt_min_ms - 100.0).abs() < 1.0, + "after both windows filled, baseline should be 100ms, got {}", + tracker.rtt_min_ms + ); + } +} diff --git a/src/ewma.rs b/src/ewma.rs new file mode 100644 index 0000000..98c36c1 --- /dev/null +++ b/src/ewma.rs @@ -0,0 +1,270 @@ +/// Exponentially Weighted Moving Average filter with asymmetric alpha support. +/// +/// Smooths a noisy measurement series by weighting recent samples more +/// heavily. Used throughout the codebase to smooth RTT, bandwidth, and +/// other time-series observations. +/// +/// The smoothing factor `alpha` controls responsiveness: +/// - `alpha` near 1.0: tracks input closely (low smoothing) +/// - `alpha` near 0.0: retains history (high smoothing) +/// +/// Asymmetric mode uses different alphas for rising vs falling measurements, +/// matching the existing pattern in `RttTracker` for fast-decrease/slow-increase +/// or vice versa. +pub struct Ewma { + value: f64, + alpha_up: f64, + alpha_down: f64, + initialized: bool, +} + +impl Ewma { + /// Creates a new EWMA filter with symmetric smoothing factor (`0.0 < alpha ≤ 1.0`). + pub fn new(alpha: f64) -> Self { + Self { + value: 0.0, + alpha_up: alpha, + alpha_down: alpha, + initialized: false, + } + } + + /// Creates a new EWMA filter with asymmetric smoothing factors. + /// + /// - `alpha_up`: smoothing factor when measurement > current value (rising) + /// - `alpha_down`: smoothing factor when measurement < current value (falling) + /// + /// Example: `Ewma::asymmetric(0.04, 0.40)` gives slow rise, fast fall — + /// matching `RttTracker`'s smooth RTT behavior. + pub fn asymmetric(alpha_up: f64, alpha_down: f64) -> Self { + Self { + value: 0.0, + alpha_up, + alpha_down, + initialized: false, + } + } + + /// Feeds a new measurement into the filter, updating the smoothed value. + /// + /// NaN or infinite measurements are silently ignored to prevent + /// poisoning the smoothed value. + pub fn update(&mut self, measurement: f64) { + if measurement.is_nan() || measurement.is_infinite() { + return; + } + if !self.initialized { + self.value = measurement; + self.initialized = true; + } else { + let alpha = if measurement > self.value { + self.alpha_up + } else { + self.alpha_down + }; + self.value = self.value * (1.0 - alpha) + measurement * alpha; + } + } + + /// Returns the current smoothed value. + pub fn value(&self) -> f64 { + self.value + } + + /// Returns whether the filter has received at least one valid measurement. + pub fn is_initialized(&self) -> bool { + self.initialized + } + + /// Resets the filter to its uninitialized state. + pub fn reset(&mut self) { + self.value = 0.0; + self.initialized = false; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ewma_logic() { + let mut ewma = Ewma::new(0.5); + + // First update should set the value + ewma.update(10.0); + assert!((ewma.value() - 10.0).abs() < f64::EPSILON); + + // Second update: (10 * 0.5) + (20 * 0.5) = 15 + ewma.update(20.0); + assert!((ewma.value() - 15.0).abs() < f64::EPSILON); + + // Third update: (15 * 0.5) + (30 * 0.5) = 22.5 + ewma.update(30.0); + assert!((ewma.value() - 22.5).abs() < f64::EPSILON); + } + + #[test] + fn test_ewma_smoothing() { + let mut ewma = Ewma::new(0.1); + ewma.update(100.0); + assert!((ewma.value() - 100.0).abs() < f64::EPSILON); + + // Sudden drop: value = 100 * 0.9 + 0 * 0.1 = 90 + ewma.update(0.0); + assert!((ewma.value() - 90.0).abs() < f64::EPSILON); + } + + #[test] + fn test_ewma_uninitialized_value_is_zero() { + let ewma = Ewma::new(0.5); + assert!((ewma.value() - 0.0).abs() < f64::EPSILON); + assert!(!ewma.is_initialized()); + } + + #[test] + fn test_ewma_alpha_one_follows_input() { + let mut ewma = Ewma::new(1.0); + ewma.update(10.0); + assert!((ewma.value() - 10.0).abs() < f64::EPSILON); + + ewma.update(50.0); + assert!((ewma.value() - 50.0).abs() < f64::EPSILON); + } + + #[test] + fn test_ewma_alpha_near_zero_retains_history() { + let mut ewma = Ewma::new(0.001); + ewma.update(100.0); + assert!((ewma.value() - 100.0).abs() < f64::EPSILON); + + // value = 100 * 0.999 + 0 * 0.001 = 99.9 + ewma.update(0.0); + assert!((ewma.value() - 99.9).abs() < 0.01); + } + + #[test] + fn test_ewma_negative_values() { + let mut ewma = Ewma::new(0.5); + ewma.update(-10.0); + assert!((ewma.value() - (-10.0)).abs() < f64::EPSILON); + + ewma.update(10.0); + assert!((ewma.value() - 0.0).abs() < f64::EPSILON); + } + + #[test] + fn test_ewma_converges_to_constant() { + let mut ewma = Ewma::new(0.5); + for _ in 0..100 { + ewma.update(42.0); + } + assert!((ewma.value() - 42.0).abs() < 0.001); + } + + #[test] + fn test_ewma_nan_guard() { + let mut ewma = Ewma::new(0.5); + ewma.update(10.0); + assert!((ewma.value() - 10.0).abs() < f64::EPSILON); + + // NaN should be silently ignored + ewma.update(f64::NAN); + assert!((ewma.value() - 10.0).abs() < f64::EPSILON); + + // Infinity should be silently ignored + ewma.update(f64::INFINITY); + assert!((ewma.value() - 10.0).abs() < f64::EPSILON); + + ewma.update(f64::NEG_INFINITY); + assert!((ewma.value() - 10.0).abs() < f64::EPSILON); + + // Normal values should still work after NaN/Inf + ewma.update(20.0); + assert!((ewma.value() - 15.0).abs() < f64::EPSILON); + } + + #[test] + fn test_ewma_nan_on_first_sample() { + let mut ewma = Ewma::new(0.5); + ewma.update(f64::NAN); + // Should remain uninitialized + assert!((ewma.value() - 0.0).abs() < f64::EPSILON); + assert!(!ewma.is_initialized()); + + // First valid sample should initialize + ewma.update(42.0); + assert!((ewma.value() - 42.0).abs() < f64::EPSILON); + assert!(ewma.is_initialized()); + } + + #[test] + fn test_ewma_reset() { + let mut ewma = Ewma::new(0.5); + ewma.update(100.0); + assert!(ewma.is_initialized()); + + ewma.reset(); + assert!(!ewma.is_initialized()); + assert!((ewma.value() - 0.0).abs() < f64::EPSILON); + + // Should re-initialize on next update + ewma.update(50.0); + assert!((ewma.value() - 50.0).abs() < f64::EPSILON); + assert!(ewma.is_initialized()); + } + + // === Asymmetric EWMA tests === + + #[test] + fn test_asymmetric_slow_rise_fast_fall() { + // Matches RttTracker's smooth RTT: alpha_up=0.04, alpha_down=0.40 + let mut ewma = Ewma::asymmetric(0.04, 0.40); + ewma.update(100.0); + assert!((ewma.value() - 100.0).abs() < f64::EPSILON); + + // Rising: slow response (alpha=0.04) + // value = 100 * 0.96 + 200 * 0.04 = 96 + 8 = 104 + ewma.update(200.0); + assert!((ewma.value() - 104.0).abs() < f64::EPSILON); + + // Falling: fast response (alpha=0.40) + // value = 104 * 0.60 + 50 * 0.40 = 62.4 + 20 = 82.4 + ewma.update(50.0); + assert!((ewma.value() - 82.4).abs() < f64::EPSILON); + } + + #[test] + fn test_asymmetric_fast_rise_slow_fall() { + // Inverse: fast rise, slow fall + let mut ewma = Ewma::asymmetric(0.40, 0.04); + ewma.update(100.0); + + // Rising: fast response (alpha=0.40) + // value = 100 * 0.60 + 200 * 0.40 = 60 + 80 = 140 + ewma.update(200.0); + assert!((ewma.value() - 140.0).abs() < f64::EPSILON); + + // Falling: slow response (alpha=0.04) + // value = 140 * 0.96 + 50 * 0.04 = 134.4 + 2.0 = 136.4 + ewma.update(50.0); + assert!((ewma.value() - 136.4).abs() < f64::EPSILON); + } + + #[test] + fn test_asymmetric_converges_to_constant() { + let mut ewma = Ewma::asymmetric(0.1, 0.3); + for _ in 0..1000 { + ewma.update(42.0); + } + assert!((ewma.value() - 42.0).abs() < 0.001); + } + + #[test] + fn test_asymmetric_nan_guard() { + let mut ewma = Ewma::asymmetric(0.1, 0.3); + ewma.update(100.0); + ewma.update(f64::NAN); + assert!((ewma.value() - 100.0).abs() < f64::EPSILON); + } +} diff --git a/src/lib.rs b/src/lib.rs index 02590f4..9fedeaa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc; pub mod config; pub mod connection; +pub mod ewma; pub mod mode; pub mod protocol; pub mod registration; diff --git a/src/stats.rs b/src/stats.rs index 2d78dd2..c2295e8 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -55,6 +55,12 @@ pub struct LinkStats { /// Current send bitrate in bytes/sec (measured, not estimated). pub bitrate_bps: u32, + // --- RTT baseline tracking --- + /// Dual-window minimum RTT baseline in milliseconds. + pub rtt_min_ms: f64, + /// Fast RTT tracker in milliseconds (quicker response to spikes). + pub fast_rtt_ms: f64, + // --- Selection algorithm context --- /// Base score: window / (in_flight + 1). Used by classic mode. /// Higher score = more available capacity on this link. @@ -160,6 +166,8 @@ impl SharedStats { rtt_ms: conn.get_smooth_rtt_ms() as u32, nak_count: conn.total_nak_count(), bitrate_bps: (conn.current_bitrate_mbps() * 1_000_000.0 / 8.0) as u32, + rtt_min_ms: conn.get_rtt_min_ms(), + fast_rtt_ms: conn.get_fast_rtt_ms(), base_score: conn.get_score(), quality_multiplier, }; From 3064c73ea9b61016939168842d118ac8241c1492 Mon Sep 17 00:00:00 2001 From: Thomas Lekanger Date: Wed, 11 Feb 2026 19:11:12 +0100 Subject: [PATCH 02/32] feat: add queued_count method to BatchSender and update score calculation in SrtlaConnection --- src/connection/batch_send.rs | 8 ++++++++ src/connection/mod.rs | 12 ++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/connection/batch_send.rs b/src/connection/batch_send.rs index 2c8eb1c..c284192 100644 --- a/src/connection/batch_send.rs +++ b/src/connection/batch_send.rs @@ -81,6 +81,14 @@ impl BatchSender { !self.queue.is_empty() } + /// Number of data packets currently queued (not yet flushed). + /// Used by `get_score()` so the selection algorithm sees the true load, + /// matching the C behaviour where `reg_pkt()` increments in_flight per packet. + #[inline] + pub fn queued_count(&self) -> i32 { + self.queue.len() as i32 + } + /// Flush all queued packets to the socket /// /// Returns a vector of (seq, queue_time) pairs for packets that need tracking. diff --git a/src/connection/mod.rs b/src/connection/mod.rs index 4e8e8b9..b1821a2 100644 --- a/src/connection/mod.rs +++ b/src/connection/mod.rs @@ -168,10 +168,14 @@ impl SrtlaConnection { if !self.connected { return -1; } - // Mirror classic implementations: score is window divided by in-flight load. - // Use saturating_add to avoid overflow when the queue is extremely large and - // clamp the denominator to at least 1 to prevent division by zero. - let denom = self.in_flight_packets.saturating_add(1).max(1); + // Mirror C's select_conn(): score = window / (in_flight + 1). + // Include queued (not-yet-flushed) packets so the score drops immediately + // when a packet is queued, matching C's reg_pkt() which increments + // in_flight_pkts per packet before the next select_conn() call. + let total_in_flight = self + .in_flight_packets + .saturating_add(self.batch_sender.queued_count()); + let denom = total_in_flight.saturating_add(1).max(1); self.window / denom } From e4c749ca41ffbcede81c8f5fff87c021dc0a2570 Mon Sep 17 00:00:00 2001 From: Thomas Lekanger Date: Wed, 11 Feb 2026 19:18:09 +0100 Subject: [PATCH 03/32] refactor: update STARTUP_GRACE_MS to 5000 and adjust related reconnection logic --- src/connection/mod.rs | 2 +- src/connection/reconnection.rs | 2 +- src/sender/housekeeping.rs | 10 +++++++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/connection/mod.rs b/src/connection/mod.rs index b1821a2..46153ac 100644 --- a/src/connection/mod.rs +++ b/src/connection/mod.rs @@ -27,7 +27,7 @@ use tokio::time::Instant; use crate::protocol::*; use crate::utils::now_ms; -const STARTUP_GRACE_MS: u64 = 1_500; +pub(crate) const STARTUP_GRACE_MS: u64 = 5_000; /// Interval in milliseconds between quality multiplier recalculations. /// Caching reduces expensive exp() calls from every packet to ~20 times per second. diff --git a/src/connection/reconnection.rs b/src/connection/reconnection.rs index 7448303..7d8f611 100644 --- a/src/connection/reconnection.rs +++ b/src/connection/reconnection.rs @@ -2,7 +2,7 @@ use tracing::{debug, info}; use crate::utils::now_ms; -const STARTUP_GRACE_MS: u64 = 1_500; +use super::STARTUP_GRACE_MS; const BASE_RECONNECT_DELAY_MS: u64 = 5000; const MAX_BACKOFF_DELAY_MS: u64 = 120_000; const MAX_BACKOFF_COUNT: u32 = 5; diff --git a/src/sender/housekeeping.rs b/src/sender/housekeeping.rs index c279496..b419e3f 100644 --- a/src/sender/housekeeping.rs +++ b/src/sender/housekeeping.rs @@ -6,7 +6,7 @@ use tokio::time::Instant; use tracing::{debug, error, info, warn}; use super::uplink::{ConnectionId, ReaderHandle, UplinkPacket, restart_reader_for}; -use crate::connection::SrtlaConnection; +use crate::connection::{STARTUP_GRACE_MS, SrtlaConnection}; use crate::registration::SrtlaRegistrationManager; use crate::utils::now_ms; @@ -36,7 +36,7 @@ pub async fn handle_housekeeping( if !reg.is_probing() && was_probing { if let Some(idx) = reg.get_selected_connection_idx() { if let Some(conn) = connections.get_mut(idx) { - conn.reconnection.startup_grace_deadline_ms = current_ms + 1500; + conn.reconnection.startup_grace_deadline_ms = current_ms + STARTUP_GRACE_MS; debug!( "{}: Reset grace period after being selected for initial registration", conn.label @@ -53,7 +53,11 @@ pub async fn handle_housekeeping( if conn.should_attempt_reconnect() { let label = conn.label.clone(); conn.record_reconnect_attempt(); - warn!("{} timed out; attempting full socket reconnection", label); + if conn.connection_established_ms() == 0 { + debug!("{} initial registration timed out; retrying", label); + } else { + warn!("{} timed out; attempting full socket reconnection", label); + } // Perform full socket reconnection if let Err(e) = conn.reconnect().await { warn!("{} failed to reconnect: {}", label, e); From 3f67a3db499c854842ffbc19e31eae80e50926d0 Mon Sep 17 00:00:00 2001 From: Thomas Lekanger Date: Wed, 11 Feb 2026 19:21:09 +0100 Subject: [PATCH 04/32] refactor: enhance logging in run_sender_with_config to include scheduling mode --- src/sender/mod.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/sender/mod.rs b/src/sender/mod.rs index 948ab9b..efca158 100644 --- a/src/sender/mod.rs +++ b/src/sender/mod.rs @@ -57,8 +57,16 @@ pub async fn run_sender_with_config( shared_stats: SharedStats, ) -> Result<()> { info!( - "starting srtla_send: local_srt_port={}, receiver={}:{}, ips_file={}", - local_srt_port, receiver_host, receiver_port, ips_file + "starting srtla_send: local_srt_port={}, receiver={}:{}, ips_file={}, mode={}", + local_srt_port, + receiver_host, + receiver_port, + ips_file, + match config.mode() { + crate::mode::SchedulingMode::Classic => "classic", + crate::mode::SchedulingMode::Enhanced => "enhanced", + crate::mode::SchedulingMode::RttThreshold => "rtt-threshold", + } ); let ips = read_ip_list(ips_file).await?; debug!( From 308d3f00cc1c63fb7f45fe8b69a20377d97e7a0b Mon Sep 17 00:00:00 2001 From: Thomas Lekanger Date: Wed, 11 Feb 2026 19:42:48 +0100 Subject: [PATCH 05/32] refactor: replace timestamp generation in keepalive packet functions with utility method --- src/protocol/builders.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/protocol/builders.rs b/src/protocol/builders.rs index 1de98f7..442a78a 100644 --- a/src/protocol/builders.rs +++ b/src/protocol/builders.rs @@ -21,10 +21,8 @@ pub fn create_reg2_packet(id: &[u8; SRTLA_ID_LEN]) -> [u8; SRTLA_TYPE_REG2_LEN] pub fn create_keepalive_packet() -> [u8; 10] { let mut pkt = [0u8; 10]; pkt[0..2].copy_from_slice(&SRTLA_TYPE_KEEPALIVE.to_be_bytes()); - let ts = chrono::Utc::now().timestamp_millis() as u64; - for i in 0..8 { - pkt[2 + i] = ((ts >> (56 - i * 8)) & 0xff) as u8; - } + let ts = crate::utils::now_ms(); + pkt[2..10].copy_from_slice(&ts.to_be_bytes()); pkt } @@ -50,7 +48,7 @@ pub fn create_keepalive_packet_ext(info: ConnectionInfo) -> [u8; SRTLA_KEEPALIVE // Standard keepalive header (bytes 0-9) pkt[0..2].copy_from_slice(&SRTLA_TYPE_KEEPALIVE.to_be_bytes()); - let ts = chrono::Utc::now().timestamp_millis() as u64; + let ts = crate::utils::now_ms(); pkt[2..10].copy_from_slice(&ts.to_be_bytes()); // Extended data (bytes 10-37) From 86039ff038bee71d9b9914e93fabb7b3282b521a Mon Sep 17 00:00:00 2001 From: Thomas Lekanger Date: Wed, 11 Feb 2026 20:46:34 +0100 Subject: [PATCH 06/32] refactor: update MIN_SWITCH_INTERVAL_MS to 15 for improved connection rotation --- src/sender/selection/mod.rs | 11 ++++++----- src/tests/sender_tests.rs | 8 ++++---- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/sender/selection/mod.rs b/src/sender/selection/mod.rs index abb8d8a..82d86d3 100644 --- a/src/sender/selection/mod.rs +++ b/src/sender/selection/mod.rs @@ -42,9 +42,10 @@ use crate::connection::SrtlaConnection; use crate::mode::SchedulingMode; /// Minimum time in milliseconds between connection switches -/// Prevents rapid thrashing when scores fluctuate due to bursty ACK/NAK patterns -/// Works in combination with score-based hysteresis for stable connection selection -pub const MIN_SWITCH_INTERVAL_MS: u64 = 500; +/// Prevents rapid thrashing when scores fluctuate due to bursty ACK/NAK patterns. +/// Aligned with FLUSH_INTERVAL_MS (15ms) so connections can rotate between batches +/// while avoiding intra-batch flip-flopping. +pub const MIN_SWITCH_INTERVAL_MS: u64 = 15; /// Select the best connection index based on mode and configuration /// @@ -147,7 +148,7 @@ mod tests { connections[2].in_flight_packets = 10; // Lowest score let last_switch_time_ms = now_ms(); - let current_time_ms = last_switch_time_ms + 100; // Within cooldown + let current_time_ms = last_switch_time_ms + 5; // Within 15ms cooldown let config = ConfigSnapshot { mode: SchedulingMode::Enhanced, @@ -171,7 +172,7 @@ mod tests { ); // After cooldown expires, should allow switching - let current_time_after_cooldown = last_switch_time_ms + 600; // Past cooldown + let current_time_after_cooldown = last_switch_time_ms + 20; // Past 15ms cooldown let result_after = select_connection_idx( &mut connections, Some(0), diff --git a/src/tests/sender_tests.rs b/src/tests/sender_tests.rs index 5e36cdd..819b127 100644 --- a/src/tests/sender_tests.rs +++ b/src/tests/sender_tests.rs @@ -104,7 +104,7 @@ mod tests { connections[2].in_flight_packets = 10; // Worst score let last_switch_time_ms = now_ms(); - let current_time_ms = last_switch_time_ms + 200; // 200ms after last switch (within 500ms cooldown) + let current_time_ms = last_switch_time_ms + 5; // 5ms after last switch (within 15ms cooldown) let config = ConfigSnapshot { mode: SchedulingMode::Enhanced, @@ -140,7 +140,7 @@ mod tests { connections[2].in_flight_packets = 10; // Worst score let last_switch_time_ms = now_ms(); - let current_time_ms = last_switch_time_ms + 600; // 600ms after last switch (past 500ms cooldown) + let current_time_ms = last_switch_time_ms + 20; // 20ms after last switch (past 15ms cooldown) let config = ConfigSnapshot { mode: SchedulingMode::Enhanced, @@ -180,7 +180,7 @@ mod tests { connections[2].in_flight_packets = 10; let last_switch_time_ms = now_ms(); - let current_time_ms = last_switch_time_ms + 200; // Within cooldown period + let current_time_ms = last_switch_time_ms + 5; // Within 15ms cooldown let config = ConfigSnapshot { mode: SchedulingMode::Enhanced, @@ -217,7 +217,7 @@ mod tests { connections[2].in_flight_packets = 1; // Second-best let last_switch_time_ms = now_ms(); - let current_time_ms = last_switch_time_ms + 200; // Within cooldown + let current_time_ms = last_switch_time_ms + 5; // Within 15ms cooldown let config = ConfigSnapshot { mode: SchedulingMode::Enhanced, From 082d118dac93cd6378453db1b9b9475c4e45d823 Mon Sep 17 00:00:00 2001 From: datagutt Date: Wed, 11 Feb 2026 20:57:28 +0100 Subject: [PATCH 07/32] refactor: increase score hysteresis from 2% to 10% for enhanced mode Increase SWITCH_THRESHOLD from 1.02 to 1.10 so a new connection must be 10% better before traffic is moved to it. With the shorter 15ms cooldown, hysteresis is now the primary stability mechanism; 2% was too small to prevent noise-driven flip-flopping between connections with similar scores. Co-Authored-By: Claude Opus 4.6 --- README.md | 6 +++--- src/sender/selection/enhanced.rs | 11 ++++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index a45512c..055aa61 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ The sender supports three mutually exclusive scheduling modes: - **NAK Burst Detection**: Extra penalties for connections experiencing severe packet loss (≥5 NAKs) - **RTT-Aware Selection**: Small bonus (3% max) for lower-latency connections - **Quality Scoring**: Automatic preference for higher-quality connections -- **Minimal Hysteresis**: 2% threshold prevents flip-flopping while maintaining natural load distribution +- **Score Hysteresis**: 10% threshold prevents noise-driven flip-flopping while maintaining natural load distribution #### Classic Mode @@ -346,7 +346,7 @@ With properly configured connections, you should observe: **If connections are flip-flopping**: -1. This should be minimal with 2% hysteresis in enhanced mode +1. This should be minimal with 10% hysteresis in enhanced mode 2. Check if scores are truly identical (look for hysteresis messages in debug logs) 3. Verify connections have stable quality (no intermittent NAKs) 4. Consider using classic mode for perfectly equal connections @@ -359,7 +359,7 @@ If needed, these can be adjusted in `src/sender/selection/`: **Enhanced Mode (`enhanced.rs`):** -- `SWITCH_THRESHOLD`: 1.02 (2% hysteresis) - increase for more stability, decrease for faster response +- `SWITCH_THRESHOLD`: 1.10 (10% hysteresis) - increase for more stability, decrease for faster response **Quality Scoring (`quality.rs`):** diff --git a/src/sender/selection/enhanced.rs b/src/sender/selection/enhanced.rs index 3396a76..05b42a2 100644 --- a/src/sender/selection/enhanced.rs +++ b/src/sender/selection/enhanced.rs @@ -3,7 +3,7 @@ //! This module implements the enhanced SRTLA connection selection with: //! - Quality-aware scoring based on NAK history //! - RTT-aware bonuses for low-latency connections -//! - Minimal hysteresis to prevent flip-flopping (2%) +//! - Score hysteresis to prevent flip-flopping (10%) //! - Optional smart exploration of alternative connections //! //! The enhanced mode provides better connection quality awareness while @@ -15,10 +15,11 @@ use super::MIN_SWITCH_INTERVAL_MS; use super::exploration::should_explore_now; use crate::connection::SrtlaConnection; -/// Switching hysteresis: require new connection to be significantly better -/// REDUCED to 2% to allow better load distribution across multiple connections -/// Original 15% was preventing traffic from spreading across all uplinks -const SWITCH_THRESHOLD: f64 = 1.02; // New connection must be 2% better +/// Switching hysteresis: require new connection to be meaningfully better. +/// At 10%, this prevents noise-driven flip-flopping between connections with +/// similar scores while still allowing switches when one connection genuinely +/// degrades (e.g., higher in_flight due to congestion or packet loss). +const SWITCH_THRESHOLD: f64 = 1.10; // New connection must be 10% better /// Select best connection using enhanced algorithm with quality awareness /// From ce18df87e92448e9224b3910de765ebdf63db074 Mon Sep 17 00:00:00 2001 From: datagutt Date: Wed, 11 Feb 2026 21:08:45 +0100 Subject: [PATCH 08/32] fix: clear pre-registration state on REG3 to prevent startup death spiral Before REG3, forward_via_connection() tracks data packets in packet_log, creating phantom in-flight counts that never get ACKed. Early NAKs from these packets also penalize quality scoring. This cascading penalty starves connections of traffic, causing the system to get stuck at low throughput after startup. Reset packet_log, in_flight_packets, congestion state, and quality cache when REG3 is received so every connection starts with a clean slate. Co-Authored-By: Claude Opus 4.6 --- src/connection/mod.rs | 26 ++++++++++++++++++++++++++ src/connection/packet_io.rs | 3 +++ 2 files changed, 29 insertions(+) diff --git a/src/connection/mod.rs b/src/connection/mod.rs index 46153ac..59a166b 100644 --- a/src/connection/mod.rs +++ b/src/connection/mod.rs @@ -24,6 +24,8 @@ use rustc_hash::FxHashMap; pub use socket::{bind_from_ip, resolve_remote}; use tokio::time::Instant; +use tracing::debug; + use crate::protocol::*; use crate::utils::now_ms; @@ -339,6 +341,30 @@ impl SrtlaConnection { } } + /// Clear state accumulated during pre-registration phase. + /// + /// Called when REG3 is received to prevent phantom in-flight counts + /// and early NAK penalties from persisting into the connected state. + /// Before REG3, `forward_via_connection()` may have queued and sent + /// data packets, creating `packet_log` entries that will never be + /// properly ACKed. Early NAKs from these packets would also penalize + /// the connection's quality score during startup. + pub(crate) fn clear_pre_registration_state(&mut self) { + if !self.packet_log.is_empty() || self.congestion.nak_count > 0 { + debug!( + "{}: clearing pre-registration state ({} in-flight, {} NAKs)", + self.label, + self.packet_log.len(), + self.congestion.nak_count + ); + } + self.packet_log.clear(); + self.in_flight_packets = 0; + self.highest_acked_seq = i32::MIN; + self.congestion.reset(); + self.quality_cache = CachedQuality::default(); + } + /// Reset core connection state (window, packet tracking, batch queue). /// Used by both mark_for_recovery and reset_state. fn reset_core_state(&mut self) { diff --git a/src/connection/packet_io.rs b/src/connection/packet_io.rs index 09402d6..4006b87 100644 --- a/src/connection/packet_io.rs +++ b/src/connection/packet_io.rs @@ -93,6 +93,9 @@ impl SrtlaConnection { reg.try_send_reg1_immediately(conn_idx, self).await; } RegistrationEvent::Reg3 => { + // Clear any phantom in-flight packets and NAK state + // accumulated during pre-registration data forwarding + self.clear_pre_registration_state(); self.connected = true; self.last_received = Some(recv_time); if self.reconnection.connection_established_ms == 0 { From ce147dd13025abd3df587c541667578451448789 Mon Sep 17 00:00:00 2001 From: Thomas Lekanger Date: Wed, 11 Feb 2026 21:16:39 +0100 Subject: [PATCH 09/32] feat(network-sim): add network simulation toolkit with impairment modeling - Introduced a new workspace with a `network-sim` crate for simulating network conditions. - Implemented `ImpairmentConfig` for configuring network impairments using `tc netem`. - Added `GemodelConfig` for modeling bursty packet loss with Gilbert-Elliott model. - Created `Scenario` and `ScenarioConfig` for generating deterministic random-walk impairment scenarios. - Implemented `Namespace` management for Linux network namespaces, including veth link creation. - Added tests for impairment application and scenario generation to ensure functionality. --- Cargo.lock | 377 ++++++++++++++++----------- Cargo.toml | 5 +- crates/network-sim/Cargo.toml | 11 + crates/network-sim/src/impairment.rs | 250 ++++++++++++++++++ crates/network-sim/src/lib.rs | 21 ++ crates/network-sim/src/scenario.rs | 200 ++++++++++++++ crates/network-sim/src/test_util.rs | 30 +++ crates/network-sim/src/topology.rs | 182 +++++++++++++ 8 files changed, 923 insertions(+), 153 deletions(-) create mode 100644 crates/network-sim/Cargo.toml create mode 100644 crates/network-sim/src/impairment.rs create mode 100644 crates/network-sim/src/lib.rs create mode 100644 crates/network-sim/src/scenario.rs create mode 100644 crates/network-sim/src/test_util.rs create mode 100644 crates/network-sim/src/topology.rs diff --git a/Cargo.lock b/Cargo.lock index 6a1ce00..a742998 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,15 +11,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - [[package]] name = "anstream" version = "0.6.21" @@ -82,24 +73,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - [[package]] name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" -[[package]] -name = "bumpalo" -version = "3.19.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" - [[package]] name = "bytes" version = "1.11.1" @@ -123,14 +102,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "chrono" -version = "0.4.43" +name = "chacha20" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ - "iana-time-zone", - "num-traits", - "windows-link", + "cfg-if", + "cpufeatures", + "rand_core 0.10.0", ] [[package]] @@ -180,10 +159,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] -name = "core-foundation-sys" -version = "0.8.7" +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" @@ -207,6 +195,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "futures-core" version = "0.3.31" @@ -225,6 +219,35 @@ dependencies = [ "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core 0.10.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "heck" version = "0.5.0" @@ -232,27 +255,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "iana-time-zone" -version = "0.1.65" +name = "id-arena" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" +name = "indexmap" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ - "cc", + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -267,22 +284,18 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" -[[package]] -name = "js-sys" -version = "0.3.85" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.180" @@ -347,21 +360,21 @@ dependencies = [ ] [[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +name = "network-sim" +version = "0.1.0" dependencies = [ - "windows-sys 0.61.2", + "anyhow", + "rand 0.10.0", + "tracing", ] [[package]] -name = "num-traits" -version = "0.2.19" +name = "nu-ansi-term" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "autocfg", + "windows-sys 0.61.2", ] [[package]] @@ -391,6 +404,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -422,7 +445,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom 0.4.1", + "rand_core 0.10.0", ] [[package]] @@ -432,7 +466,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -441,9 +475,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom", + "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + [[package]] name = "regex-automata" version = "0.4.14" @@ -481,10 +521,10 @@ dependencies = [ ] [[package]] -name = "rustversion" -version = "1.0.22" +name = "semver" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" @@ -583,11 +623,11 @@ dependencies = [ "anyhow", "assert_matches", "bytes", - "chrono", "clap", "libc", "mimalloc", - "rand", + "network-sim", + "rand 0.9.2", "rustc-hash", "serde", "serde_json", @@ -624,7 +664,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", @@ -755,6 +795,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "utf8parse" version = "0.2.2" @@ -783,83 +829,46 @@ dependencies = [ ] [[package]] -name = "wasm-bindgen" -version = "0.2.108" +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" -dependencies = [ - "unicode-ident", + "wit-bindgen", ] [[package]] -name = "windows-core" -version = "0.62.2" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "leb128fmt", + "wasmparser", ] [[package]] -name = "windows-implement" -version = "0.60.2" +name = "wasm-metadata" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ - "proc-macro2", - "quote", - "syn", + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", ] [[package]] -name = "windows-interface" -version = "0.59.3" +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "proc-macro2", - "quote", - "syn", + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", ] [[package]] @@ -868,24 +877,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.60.2" @@ -974,6 +965,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "zerocopy" diff --git a/Cargo.toml b/Cargo.toml index 9d03c0d..d335483 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,6 @@ +[workspace] +members = ["crates/network-sim"] + [package] resolver = "3" name = "srtla_send" @@ -33,7 +36,6 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } socket2 = { version = "0.6", features = ["all"] } bytes = "1.11.0" -chrono = { version = "0.4", default-features = false, features = ["clock"] } smallvec = "=2.0.0-alpha.12" mimalloc = { version = "0.1", default-features = false, features = [ "secure", @@ -58,6 +60,7 @@ path = "src/main.rs" tokio-test = "0.4" tempfile = "3" assert_matches = "1" +network-sim = { path = "crates/network-sim" } [profile.dev] opt-level = 1 diff --git a/crates/network-sim/Cargo.toml b/crates/network-sim/Cargo.toml new file mode 100644 index 0000000..dea05f3 --- /dev/null +++ b/crates/network-sim/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "network-sim" +version = "0.1.0" +edition = "2024" +description = "Network simulation toolkit for integration testing with Linux network namespaces and tc netem" +license = "MIT" + +[dependencies] +anyhow = "1.0" +tracing = "0.1" +rand = "0.10" diff --git a/crates/network-sim/src/impairment.rs b/crates/network-sim/src/impairment.rs new file mode 100644 index 0000000..14c6a31 --- /dev/null +++ b/crates/network-sim/src/impairment.rs @@ -0,0 +1,250 @@ +use anyhow::{Result, bail}; + +use crate::topology::Namespace; + +/// Gilbert-Elliott (4-state) loss model for `tc netem`. +/// +/// Models bursty loss as a Markov chain between Good and Bad states, +/// each with independent loss probabilities. +#[derive(Debug, Clone, Default)] +pub struct GemodelConfig { + /// Transition probability Good -> Bad (%). + pub p: f32, + /// Transition probability Bad -> Good (%). + pub r: f32, + /// Loss probability in Good state (%). + pub one_h: f32, + /// Loss probability in Bad state (%). + pub one_k: f32, +} + +/// Network impairment applied via `tc netem` (and optionally `tbf`). +/// +/// All fields default to `None`/`false`. Set only the parameters you need; +/// omitted parameters are not passed to `tc`. An all-`None` config clears +/// any existing impairment on the interface. +#[derive(Debug, Clone, Default)] +pub struct ImpairmentConfig { + pub delay_ms: Option, + pub jitter_ms: Option, + pub loss_percent: Option, + pub loss_correlation: Option, + /// Overrides `loss_percent` with a Gilbert-Elliott 4-state model. + pub gemodel: Option, + pub rate_kbit: Option, + pub duplicate_percent: Option, + pub reorder_percent: Option, + pub corrupt_percent: Option, + /// When true, bandwidth is enforced via a TBF root qdisc that drops + /// excess packets. When false, `rate_kbit` only adds serialization + /// delay (netem `rate` param) without real enforcement. + pub tbf_shaping: bool, +} + +impl ImpairmentConfig { + /// True if no impairment parameters are set (config would be a no-op). + fn is_empty(&self) -> bool { + self.delay_ms.is_none() + && self.loss_percent.is_none() + && self.rate_kbit.is_none() + && self.gemodel.is_none() + && self.duplicate_percent.is_none() + && self.reorder_percent.is_none() + && self.corrupt_percent.is_none() + } + + /// True if any netem-specific parameter (delay/loss/dup/reorder/corrupt) is set. + fn has_netem_params(&self) -> bool { + self.delay_ms.is_some() + || self.loss_percent.is_some() + || self.gemodel.is_some() + || self.duplicate_percent.is_some() + || self.reorder_percent.is_some() + || self.corrupt_percent.is_some() + } + + /// Build the netem parameter list (delay, loss, dup, reorder, corrupt). + /// When `include_rate` is true, appends the netem `rate` param too. + fn netem_args(&self, include_rate: bool) -> Vec { + let mut args = Vec::new(); + + if let Some(delay) = self.delay_ms { + args.push("delay".into()); + args.push(format!("{delay}ms")); + if let Some(jitter) = self.jitter_ms + && jitter > 0 + { + args.push(format!("{jitter}ms")); + } + } + + match (&self.gemodel, self.loss_percent) { + (Some(ge), _) => { + args.extend([ + "loss".into(), + "gemodel".into(), + format!("{}%", ge.p), + format!("{}%", ge.r), + format!("{}%", ge.one_h), + format!("{}%", ge.one_k), + ]); + } + (None, Some(loss)) => { + args.push("loss".into()); + args.push(format!("{loss}%")); + if let Some(corr) = self.loss_correlation { + args.push(format!("{corr}%")); + } + } + _ => {} + } + + if let Some(dup) = self.duplicate_percent { + args.extend(["duplicate".into(), format!("{dup}%")]); + } + if let Some(reorder) = self.reorder_percent { + args.extend(["reorder".into(), format!("{reorder}%")]); + } + if let Some(corrupt) = self.corrupt_percent { + args.extend(["corrupt".into(), format!("{corrupt}%")]); + } + + if include_rate + && let Some(rate) = self.rate_kbit + { + args.extend(["rate".into(), format!("{rate}kbit")]); + } + + args + } +} + +/// Apply impairment to `interface` inside `ns`. +/// +/// Always removes the existing root qdisc first (clean slate). With +/// `tbf_shaping`, installs TBF as root for real bandwidth enforcement +/// and chains netem as a child. Without it, netem is the root qdisc. +pub fn apply_impairment(ns: &Namespace, interface: &str, config: ImpairmentConfig) -> Result<()> { + // Always start clean + let _ = ns.exec("tc", &["qdisc", "del", "dev", interface, "root"]); + + if config.is_empty() { + return Ok(()); + } + + if config.tbf_shaping { + apply_tbf_with_netem(ns, interface, &config) + } else { + apply_netem_root(ns, interface, &config) + } +} + +/// TBF as root (bandwidth enforcement) + netem as child (delay/loss). +fn apply_tbf_with_netem(ns: &Namespace, iface: &str, config: &ImpairmentConfig) -> Result<()> { + let rate = config + .rate_kbit + .ok_or_else(|| anyhow::anyhow!("tbf_shaping requires rate_kbit"))?; + + // burst = max(rate_bytes/10, 1540) — at least one MTU + let rate_bytes_per_sec = rate * 1000 / 8; + let burst = rate_bytes_per_sec.max(15400) / 10; + let rate_arg = format!("{rate}kbit"); + let burst_arg = burst.to_string(); + + tc_checked( + ns, + iface, + &[ + "qdisc", "add", "dev", iface, "root", "handle", "1:", "tbf", "rate", &rate_arg, + "burst", &burst_arg, "latency", "1s", + ], + "apply TBF qdisc", + )?; + + if config.has_netem_params() { + let netem_params = config.netem_args(false); + let mut args = vec![ + "qdisc", "add", "dev", iface, "parent", "1:1", "handle", "10:", "netem", + ]; + let netem_strs: Vec<&str> = netem_params.iter().map(|s| s.as_str()).collect(); + args.extend_from_slice(&netem_strs); + tc_checked(ns, iface, &args, "apply netem child qdisc")?; + } + + Ok(()) +} + +/// Netem as root qdisc (no real bandwidth enforcement). +fn apply_netem_root(ns: &Namespace, iface: &str, config: &ImpairmentConfig) -> Result<()> { + let netem_params = config.netem_args(true); + let mut args = vec!["qdisc", "add", "dev", iface, "root", "netem"]; + let netem_strs: Vec<&str> = netem_params.iter().map(|s| s.as_str()).collect(); + args.extend_from_slice(&netem_strs); + tc_checked(ns, iface, &args, "apply netem qdisc")?; + Ok(()) +} + +/// Run `tc` inside `ns`, bailing with stderr + the full command on failure. +fn tc_checked(ns: &Namespace, _iface: &str, args: &[&str], ctx: &str) -> Result<()> { + let output = ns.exec("tc", args)?; + if !output.status.success() { + bail!( + "{ctx}: tc {}\n{}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_util::{check_privileges, unique_ns_name}; + + fn parse_ping_rtt(output: &str) -> Option { + output.lines().find_map(|line| { + let rest = line.split("time=").nth(1)?; + let num = rest.split_whitespace().next()?; + num.parse().ok() + }) + } + + #[test] + fn test_impairment_delay() { + if !check_privileges() { + eprintln!("Skipping: insufficient privileges"); + return; + } + + let ns1 = Namespace::new(&unique_ns_name("nsi_a")).expect("create ns1"); + let ns2 = Namespace::new(&unique_ns_name("nsi_b")).expect("create ns2"); + + ns1.add_veth_link(&ns2, "veth_a", "veth_b", "10.201.1.1/24", "10.201.1.2/24") + .expect("add veth link"); + + let config = ImpairmentConfig { + delay_ms: Some(100), + jitter_ms: Some(10), + rate_kbit: Some(5000), + ..Default::default() + }; + + if let Err(err) = apply_impairment(&ns1, "veth_a", config) { + if err.to_string().contains("qdisc kind is unknown") { + eprintln!("Skipping: netem not available"); + return; + } + panic!("apply_impairment: {err}"); + } + + let out = ns1 + .exec("ping", &["-c", "4", "-i", "0.2", "10.201.1.2"]) + .expect("ping"); + assert!(out.status.success(), "ping failed"); + + let stdout = String::from_utf8_lossy(&out.stdout); + let rtt = parse_ping_rtt(&stdout).expect("parse ping RTT"); + assert!(rtt >= 95.0, "RTT {rtt}ms < expected 100ms delay"); + } +} diff --git a/crates/network-sim/src/lib.rs b/crates/network-sim/src/lib.rs new file mode 100644 index 0000000..1a39120 --- /dev/null +++ b/crates/network-sim/src/lib.rs @@ -0,0 +1,21 @@ +//! Network simulation for integration testing under realistic impairment. +//! +//! Uses Linux network namespaces and `tc netem`/`tbf` to create isolated +//! virtual networks with configurable delay, loss, bandwidth, and jitter. +//! +//! # Modules +//! +//! - [`topology`]: Namespace and veth link management (RAII cleanup on drop) +//! - [`impairment`]: `tc netem`/`tbf` configuration and application +//! - [`scenario`]: Deterministic random-walk impairment generator +//! - [`test_util`]: Privilege checks and unique name generation for tests + +pub mod impairment; +pub mod scenario; +pub mod test_util; +pub mod topology; + +pub use impairment::{GemodelConfig, ImpairmentConfig, apply_impairment}; +pub use scenario::{LinkScenarioConfig, Scenario, ScenarioConfig, ScenarioFrame}; +pub use test_util::{check_privileges, unique_ns_name}; +pub use topology::Namespace; diff --git a/crates/network-sim/src/scenario.rs b/crates/network-sim/src/scenario.rs new file mode 100644 index 0000000..3cebfcb --- /dev/null +++ b/crates/network-sim/src/scenario.rs @@ -0,0 +1,200 @@ +use std::time::Duration; + +use rand::rngs::StdRng; +use rand::{RngExt as _, SeedableRng}; + +use crate::impairment::ImpairmentConfig; + +/// Top-level scenario configuration. +#[derive(Debug, Clone)] +pub struct ScenarioConfig { + /// RNG seed for reproducibility. + pub seed: u64, + /// Total scenario duration. + pub duration: Duration, + /// Time between frames. + pub step: Duration, + /// Per-link random-walk bounds. + pub links: Vec, +} + +/// Per-link bounds and maximum step sizes for the random walk. +#[derive(Debug, Clone)] +pub struct LinkScenarioConfig { + pub min_rate_kbit: u64, + pub max_rate_kbit: u64, + pub rate_step_kbit: u64, + pub base_delay_ms: u32, + pub delay_jitter_ms: u32, + pub delay_step_ms: u32, + pub max_loss_percent: f32, + pub loss_step_percent: f32, +} + +/// One time-step of impairment values for every link. +#[derive(Debug, Clone)] +pub struct ScenarioFrame { + pub t: Duration, + pub configs: Vec, +} + +/// Deterministic random-walk impairment generator. +/// +/// Given a seed, produces a reproducible sequence of [`ScenarioFrame`]s where +/// each link's rate, delay, and loss evolve via clamped random-walk steps. +#[derive(Debug)] +pub struct Scenario { + cfg: ScenarioConfig, + rng: StdRng, + states: Vec, +} + +#[derive(Debug, Clone)] +struct LinkState { + rate_kbit: f64, + delay_ms: f64, + loss_percent: f64, +} + +impl Scenario { + pub fn new(cfg: ScenarioConfig) -> Self { + let mut rng = StdRng::seed_from_u64(cfg.seed); + + let states = cfg + .links + .iter() + .map(|link| { + let range = link.max_rate_kbit.saturating_sub(link.min_rate_kbit) as f64; + LinkState { + rate_kbit: link.min_rate_kbit as f64 + rng.random::() * range, + delay_ms: link.base_delay_ms as f64, + loss_percent: rng.random::() * link.max_loss_percent as f64 * 0.2, + } + }) + .collect(); + + Self { cfg, rng, states } + } + + /// Generate all frames for the configured duration. + pub fn frames(&mut self) -> Vec { + let total_steps = + (self.cfg.duration.as_secs_f64() / self.cfg.step.as_secs_f64()).ceil() as u64; + + (0..=total_steps) + .map(|step_idx| { + let t = self.cfg.step.mul_f64(step_idx as f64); + let configs = self + .cfg + .links + .iter() + .zip(self.states.iter_mut()) + .map(|(link_cfg, state)| { + state.rate_kbit = (state.rate_kbit + + rand_signed(&mut self.rng, link_cfg.rate_step_kbit as f64)) + .clamp(link_cfg.min_rate_kbit as f64, link_cfg.max_rate_kbit as f64); + + let max_delay = + (link_cfg.base_delay_ms + link_cfg.delay_jitter_ms) as f64; + state.delay_ms = (state.delay_ms + + rand_signed(&mut self.rng, link_cfg.delay_step_ms as f64)) + .clamp(1.0, max_delay); + + state.loss_percent = (state.loss_percent + + rand_signed(&mut self.rng, link_cfg.loss_step_percent as f64)) + .clamp(0.0, link_cfg.max_loss_percent as f64); + + ImpairmentConfig { + rate_kbit: Some(state.rate_kbit.max(1.0) as u64), + delay_ms: Some(state.delay_ms.max(1.0) as u32), + jitter_ms: (link_cfg.delay_jitter_ms > 0) + .then_some(link_cfg.delay_jitter_ms), + loss_percent: Some(state.loss_percent as f32), + ..Default::default() + } + }) + .collect(); + + ScenarioFrame { t, configs } + }) + .collect() + } +} + +/// Random value in `[-max_step, +max_step]`. +fn rand_signed(rng: &mut StdRng, max_step: f64) -> f64 { + if max_step <= 0.0 { + return 0.0; + } + let mag = rng.random::() * max_step; + if rng.random::() { mag } else { -mag } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn two_link_config() -> ScenarioConfig { + ScenarioConfig { + seed: 42, + duration: Duration::from_secs(5), + step: Duration::from_secs(1), + links: vec![ + LinkScenarioConfig { + min_rate_kbit: 500, + max_rate_kbit: 1500, + rate_step_kbit: 150, + base_delay_ms: 30, + delay_jitter_ms: 20, + delay_step_ms: 5, + max_loss_percent: 10.0, + loss_step_percent: 2.0, + }, + LinkScenarioConfig { + min_rate_kbit: 800, + max_rate_kbit: 2000, + rate_step_kbit: 200, + base_delay_ms: 20, + delay_jitter_ms: 10, + delay_step_ms: 4, + max_loss_percent: 5.0, + loss_step_percent: 1.0, + }, + ], + } + } + + #[test] + fn deterministic_for_same_seed() { + let f1 = Scenario::new(two_link_config()).frames(); + let f2 = Scenario::new(two_link_config()).frames(); + + assert_eq!(f1.len(), f2.len()); + for (a, b) in f1.iter().zip(&f2) { + assert_eq!(a.t, b.t); + for (ca, cb) in a.configs.iter().zip(&b.configs) { + assert_eq!(ca.rate_kbit, cb.rate_kbit); + assert_eq!(ca.delay_ms, cb.delay_ms); + assert_eq!(ca.loss_percent, cb.loss_percent); + } + } + } + + #[test] + fn values_stay_within_bounds() { + let cfg = two_link_config(); + let frames = Scenario::new(cfg.clone()).frames(); + + for frame in &frames { + for (config, link_cfg) in frame.configs.iter().zip(&cfg.links) { + let rate = config.rate_kbit.unwrap(); + assert!(rate >= link_cfg.min_rate_kbit, "rate {rate} < min"); + assert!(rate <= link_cfg.max_rate_kbit, "rate {rate} > max"); + + let loss = config.loss_percent.unwrap(); + assert!(loss >= 0.0, "negative loss"); + assert!(loss <= link_cfg.max_loss_percent, "loss {loss} > max"); + } + } + } +} diff --git a/crates/network-sim/src/test_util.rs b/crates/network-sim/src/test_util.rs new file mode 100644 index 0000000..f408ac2 --- /dev/null +++ b/crates/network-sim/src/test_util.rs @@ -0,0 +1,30 @@ +use std::process::Command; +use std::sync::atomic::{AtomicU32, Ordering}; + +static NS_COUNTER: AtomicU32 = AtomicU32::new(0); + +/// Returns `true` if the environment supports namespace-based tests +/// (requires `ip` tool and passwordless `sudo`). +pub fn check_privileges() -> bool { + let has_ip = Command::new("ip") + .arg("netns") + .output() + .is_ok_and(|o| o.status.success()); + + has_ip + && Command::new("sudo") + .args(["-n", "ip", "netns", "list"]) + .output() + .is_ok_and(|o| o.status.success()) +} + +/// Generate a unique namespace/interface name safe for parallel tests. +/// +/// Combines prefix + PID + atomic counter, truncated to 15 chars +/// (Linux netdev name limit). +pub fn unique_ns_name(prefix: &str) -> String { + let seq = NS_COUNTER.fetch_add(1, Ordering::Relaxed); + let pid = std::process::id() % 0xffff; + let name = format!("{prefix}_{pid:x}_{seq}"); + if name.len() > 15 { name[..15].to_string() } else { name } +} diff --git a/crates/network-sim/src/topology.rs b/crates/network-sim/src/topology.rs new file mode 100644 index 0000000..a992335 --- /dev/null +++ b/crates/network-sim/src/topology.rs @@ -0,0 +1,182 @@ +use std::process::{Command, Output}; + +use anyhow::{Context, Result, bail}; +use tracing::debug; + +/// A Linux network namespace with RAII cleanup. +/// +/// Creates the namespace on construction, brings up loopback, and deletes +/// it on drop. All commands inside the namespace run via `sudo ip netns exec`. +pub struct Namespace { + pub name: String, +} + +impl Namespace { + pub fn new(name: &str) -> Result { + // Clean up stale namespace with same name (idempotent) + let _ = sudo(&["ip", "netns", "del", name]); + + sudo_checked(&["ip", "netns", "add", name]) + .with_context(|| format!("create netns '{name}'"))?; + + debug!(ns = name, "created network namespace"); + + // Loopback — best-effort, failure is non-fatal + let _ = sudo(&["ip", "netns", "exec", name, "ip", "link", "set", "lo", "up"]); + + Ok(Self { + name: name.to_string(), + }) + } + + /// Run a command inside this namespace, returning raw output. + pub fn exec(&self, cmd: &str, args: &[&str]) -> Result { + let mut full_args = vec!["ip", "netns", "exec", &self.name, cmd]; + full_args.extend_from_slice(args); + sudo(&full_args).with_context(|| format!("exec '{cmd}' in ns '{}'", self.name)) + } + + /// Run a command inside this namespace, failing if it exits non-zero. + pub fn exec_checked(&self, cmd: &str, args: &[&str]) -> Result { + let mut full_args = vec!["ip", "netns", "exec", &self.name, cmd]; + full_args.extend_from_slice(args); + sudo_checked(&full_args).with_context(|| format!("exec '{cmd}' in ns '{}'", self.name)) + } + + /// Create a veth pair connecting this namespace to `peer`. + /// + /// Each end gets an IP address assigned and is brought up. + /// Interface names must be <= 15 chars (Linux limit). + pub fn add_veth_link( + &self, + peer: &Namespace, + local_iface: &str, + peer_iface: &str, + local_ip: &str, + peer_ip: &str, + ) -> Result<()> { + // Clean up stale veth (idempotent) + let _ = sudo(&["ip", "link", "del", local_iface]); + + // Create pair in host namespace + sudo_checked(&[ + "ip", + "link", + "add", + local_iface, + "type", + "veth", + "peer", + "name", + peer_iface, + ]) + .context("create veth pair")?; + + debug!(local = local_iface, peer = peer_iface, "created veth pair"); + + // Move each end into its namespace + sudo_checked(&["ip", "link", "set", local_iface, "netns", &self.name]) + .context("move local veth")?; + sudo_checked(&["ip", "link", "set", peer_iface, "netns", &peer.name]) + .context("move peer veth")?; + + // Configure local end + self.exec_checked("ip", &["addr", "add", local_ip, "dev", local_iface]) + .context("set local IP")?; + self.exec_checked("ip", &["link", "set", local_iface, "up"]) + .context("bring local link up")?; + + // Configure peer end + peer.exec_checked("ip", &["addr", "add", peer_ip, "dev", peer_iface]) + .context("set peer IP")?; + peer.exec_checked("ip", &["link", "set", peer_iface, "up"]) + .context("bring peer link up")?; + + debug!( + ns_local = self.name, + ns_peer = peer.name, + local_ip, + peer_ip, + "veth link configured" + ); + + Ok(()) + } +} + +impl Drop for Namespace { + fn drop(&mut self) { + debug!(ns = self.name, "deleting network namespace"); + let _ = sudo(&["ip", "netns", "del", &self.name]); + } +} + +// -- helpers -- + +/// Run `sudo `, returning raw output. +fn sudo(args: &[&str]) -> Result { + Command::new("sudo") + .args(args) + .output() + .with_context(|| format!("sudo {}", args.join(" "))) +} + +/// Run `sudo `, returning output on success or bailing with stderr. +fn sudo_checked(args: &[&str]) -> Result { + let output = sudo(args)?; + if !output.status.success() { + bail!( + "command failed: sudo {}\n{}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_util::{check_privileges, unique_ns_name}; + + #[test] + fn test_namespace_has_loopback() { + if !check_privileges() { + eprintln!("Skipping: insufficient privileges"); + return; + } + + let ns = Namespace::new(&unique_ns_name("nst_a")).expect("create ns"); + let out = ns.exec("ip", &["link"]).expect("ip link"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("lo"), "loopback missing: {stdout}"); + } + + #[test] + fn test_veth_ping() { + if !check_privileges() { + eprintln!("Skipping: insufficient privileges"); + return; + } + + let ns1 = Namespace::new(&unique_ns_name("nst_a")).expect("create ns1"); + let ns2 = Namespace::new(&unique_ns_name("nst_b")).expect("create ns2"); + + let id = std::process::id() % 100_000; + let v_a = format!("va_{id}"); + let v_b = format!("vb_{id}"); + + ns1.add_veth_link(&ns2, &v_a, &v_b, "10.200.1.1/24", "10.200.1.2/24") + .expect("add veth link"); + + let out = ns1 + .exec("ping", &["-c", "1", "-W", "1", "10.200.1.2"]) + .expect("ping"); + + assert!( + out.status.success(), + "ping failed:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + } +} From 6a4d01181aac9ff91cfdb3b962b371b99cddaa86 Mon Sep 17 00:00:00 2001 From: Thomas Lekanger Date: Wed, 11 Feb 2026 21:59:34 +0100 Subject: [PATCH 10/32] Add integration tests and update dependencies for network simulation - Introduced a new `harness.rs` module for managing integration tests. - Added common utilities for integration tests in `mod.rs`. - Implemented basic connectivity tests in `netns_basic.rs` to validate registration and data forwarding. - Created failure and recovery tests in `netns_failure.rs` to ensure link failure detection and recovery. - Developed impairment tests in `netns_impairment.rs` to validate adaptation to network conditions. - Added scenario-driven tests in `netns_scenario.rs` to assess stability under evolving impairments. - Updated `Cargo.toml` and `Cargo.lock` to include `tempfile` dependency. - Enhanced `lib.rs` to expose new test harness functionalities. --- Cargo.lock | 1 + crates/network-sim/Cargo.toml | 1 + crates/network-sim/src/harness.rs | 609 ++++++++++++++++++++++++++++++ crates/network-sim/src/lib.rs | 6 + tests/common/mod.rs | 94 +++++ tests/netns_basic.rs | 67 ++++ tests/netns_failure.rs | 100 +++++ tests/netns_impairment.rs | 146 +++++++ tests/netns_scenario.rs | 186 +++++++++ 9 files changed, 1210 insertions(+) create mode 100644 crates/network-sim/src/harness.rs create mode 100644 tests/common/mod.rs create mode 100644 tests/netns_basic.rs create mode 100644 tests/netns_failure.rs create mode 100644 tests/netns_impairment.rs create mode 100644 tests/netns_scenario.rs diff --git a/Cargo.lock b/Cargo.lock index a742998..7037c95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -365,6 +365,7 @@ version = "0.1.0" dependencies = [ "anyhow", "rand 0.10.0", + "tempfile", "tracing", ] diff --git a/crates/network-sim/Cargo.toml b/crates/network-sim/Cargo.toml index dea05f3..7d4c747 100644 --- a/crates/network-sim/Cargo.toml +++ b/crates/network-sim/Cargo.toml @@ -9,3 +9,4 @@ license = "MIT" anyhow = "1.0" tracing = "0.1" rand = "0.10" +tempfile = "3" diff --git a/crates/network-sim/src/harness.rs b/crates/network-sim/src/harness.rs new file mode 100644 index 0000000..add51c2 --- /dev/null +++ b/crates/network-sim/src/harness.rs @@ -0,0 +1,609 @@ +//! Test harness for end-to-end SRTLA integration tests. +//! +//! Provides [`SrtlaTestTopology`] for network namespace setup, +//! [`NamespaceProcess`] for managed child processes inside namespaces, +//! and [`SrtlaTestStack`] for the full 3-process test pipeline +//! (srt-live-transmit + srtla_rec + srtla_send). + +use std::io::{BufRead, BufReader}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, bail}; + +use crate::impairment::{ImpairmentConfig, apply_impairment}; +use crate::test_util::unique_ns_name; +use crate::topology::Namespace; + +// --------------------------------------------------------------------------- +// Dependency checking +// --------------------------------------------------------------------------- + +/// Check if a binary exists in PATH. +pub fn check_binary(name: &str) -> Option { + Command::new("which") + .arg(name) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| PathBuf::from(String::from_utf8_lossy(&o.stdout).trim().to_string())) +} + +/// Reason why integration tests must be skipped. +#[derive(Debug)] +pub enum SkipReason { + NotRoot, + MissingBinary(String), + MissingTool(String), + NoNetem, +} + +impl std::fmt::Display for SkipReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SkipReason::NotRoot => write!(f, "requires root / passwordless sudo"), + SkipReason::MissingBinary(b) => write!(f, "{b} not found in PATH"), + SkipReason::MissingTool(t) => write!(f, "system tool '{t}' not found"), + SkipReason::NoNetem => write!(f, "sch_netem kernel module not available (try: sudo modprobe sch_netem)"), + } + } +} + +/// Check all dependencies needed for integration tests. +/// +/// Returns `Ok(())` if everything is available, or `Err(SkipReason)` with +/// the first missing dependency. +pub fn check_integration_deps() -> std::result::Result<(), SkipReason> { + // Root / sudo check + if !crate::test_util::check_privileges() { + return Err(SkipReason::NotRoot); + } + + // External binaries + for bin in &["srtla_rec", "srt-live-transmit"] { + if check_binary(bin).is_none() { + return Err(SkipReason::MissingBinary(bin.to_string())); + } + } + + // System tools + for tool in &["ip", "tc", "ss"] { + if check_binary(tool).is_none() { + return Err(SkipReason::MissingTool(tool.to_string())); + } + } + + Ok(()) +} + +/// Check deps including netem (for tests that apply impairment). +pub fn check_impairment_deps() -> std::result::Result<(), SkipReason> { + check_integration_deps()?; + + // Try to load sch_netem and check if it succeeded + let modprobe_ok = Command::new("sudo") + .args(["modprobe", "sch_netem"]) + .output() + .is_ok_and(|o| o.status.success()); + + if !modprobe_ok { + return Err(SkipReason::NoNetem); + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// NamespaceProcess +// --------------------------------------------------------------------------- + +/// A child process running inside a network namespace. +/// +/// Captures stdout+stderr and kills the process on drop. +pub struct NamespaceProcess { + child: Child, + #[expect(dead_code)] + label: String, +} + +impl NamespaceProcess { + /// Spawn `binary args...` inside `ns` via `sudo ip netns exec`. + pub fn spawn(ns: &Namespace, binary: &str, args: &[&str]) -> Result { + Self::spawn_with_env(ns, binary, args, &[]) + } + + /// Spawn with additional environment variables as `(key, value)` pairs. + pub fn spawn_with_env( + ns: &Namespace, + binary: &str, + args: &[&str], + env: &[(&str, &str)], + ) -> Result { + let label = format!("{binary} in ns:{}", ns.name); + let mut cmd = Command::new("sudo"); + cmd.args(["ip", "netns", "exec", &ns.name]); + if !env.is_empty() { + // Use `env` to set variables inside the namespace + cmd.arg("env"); + for &(k, v) in env { + cmd.arg(format!("{k}={v}")); + } + } + cmd.arg(binary) + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let child = cmd + .spawn() + .with_context(|| format!("spawn {label}"))?; + + tracing::debug!(%label, pid = child.id(), "spawned namespace process"); + Ok(Self { child, label }) + } + + /// Read all captured stdout lines (non-blocking snapshot via `try_wait`). + /// Only meaningful after the process has exited. + pub fn stdout_lines(&mut self) -> Vec { + match self.child.stdout.take() { + Some(stdout) => BufReader::new(stdout) + .lines() + .map_while(|l| l.ok()) + .collect(), + None => vec![], + } + } + + /// Read all captured stderr lines. Only meaningful after exit. + pub fn stderr_lines(&mut self) -> Vec { + match self.child.stderr.take() { + Some(stderr) => BufReader::new(stderr) + .lines() + .map_while(|l| l.ok()) + .collect(), + None => vec![], + } + } + + /// Send SIGTERM, wait briefly, then SIGKILL if needed. + pub fn kill(&mut self) { + // Try SIGTERM via sudo kill (since the child runs under sudo) + if let Some(pid) = self.pid() { + let _ = Command::new("sudo") + .args(["kill", "-TERM", &pid.to_string()]) + .output(); + } + + match self + .child + .try_wait() + .ok() + .flatten() + { + Some(_) => return, + None => { + // Wait up to 2s for graceful exit + std::thread::sleep(Duration::from_secs(2)); + if self.child.try_wait().ok().flatten().is_some() { + return; + } + } + } + + // Force kill + if let Some(pid) = self.pid() { + let _ = Command::new("sudo") + .args(["kill", "-9", &pid.to_string()]) + .output(); + } + let _ = self.child.wait(); + } + + /// Check if the process is still running. + pub fn is_alive(&mut self) -> bool { + self.child.try_wait().ok().flatten().is_none() + } + + /// If the process has exited, return its exit code and stderr. + /// Returns `None` if still running. + pub fn check_exit(&mut self) -> Option<(Option, String)> { + match self.child.try_wait() { + Ok(Some(status)) => { + let stderr = self.stderr_lines().join("\n"); + Some((status.code(), stderr)) + } + _ => None, + } + } + + fn pid(&self) -> Option { + Some(self.child.id()) + } +} + +impl Drop for NamespaceProcess { + fn drop(&mut self) { + self.kill(); + } +} + +// --------------------------------------------------------------------------- +// SrtlaTestTopology +// --------------------------------------------------------------------------- + +/// Network topology with sender and receiver namespaces connected by N veth links. +pub struct SrtlaTestTopology { + pub sender_ns: Namespace, + pub receiver_ns: Namespace, + /// Sender-side IPs, e.g. `["10.10.1.1", "10.10.2.1"]`. + pub sender_ips: Vec, + /// Receiver-side IP on the first link (used for srtla_rec bind address). + pub receiver_ip: String, + /// Sender-side veth interface names (for applying impairment). + pub sender_ifaces: Vec, + /// Receiver-side veth interface names. + pub receiver_ifaces: Vec, +} + +impl SrtlaTestTopology { + /// Create a new topology with `num_links` veth pairs. + /// + /// Addresses use `10.10.{i+1}.{1|2}/24` where `i` is the link index. + pub fn new(test_name: &str, num_links: usize) -> Result { + assert!(num_links > 0, "need at least one link"); + + let sender_ns = Namespace::new(&unique_ns_name(&format!("{test_name}_s")))?; + let receiver_ns = Namespace::new(&unique_ns_name(&format!("{test_name}_r")))?; + + let mut sender_ips = Vec::with_capacity(num_links); + let mut sender_ifaces = Vec::with_capacity(num_links); + let mut receiver_ifaces = Vec::with_capacity(num_links); + + let pid = std::process::id() % 0xffff; + + for i in 0..num_links { + let subnet = i + 1; + let s_ip = format!("10.10.{subnet}.1"); + let r_ip = format!("10.10.{subnet}.2"); + let s_iface = format!("vs{pid:x}_{i}"); + let r_iface = format!("vr{pid:x}_{i}"); + + // Truncate to 15 chars (Linux netdev limit) + let s_iface = if s_iface.len() > 15 { + s_iface[..15].to_string() + } else { + s_iface + }; + let r_iface = if r_iface.len() > 15 { + r_iface[..15].to_string() + } else { + r_iface + }; + + sender_ns.add_veth_link( + &receiver_ns, + &s_iface, + &r_iface, + &format!("{s_ip}/24"), + &format!("{r_ip}/24"), + )?; + + sender_ips.push(s_ip); + sender_ifaces.push(s_iface); + receiver_ifaces.push(r_iface); + } + + let receiver_ip = "10.10.1.2".to_string(); + + Ok(Self { + sender_ns, + receiver_ns, + sender_ips, + receiver_ip, + sender_ifaces, + receiver_ifaces, + }) + } + + /// Apply impairment to sender-side veth link at `idx`. + pub fn impair_link(&self, idx: usize, config: ImpairmentConfig) -> Result<()> { + let iface = self + .sender_ifaces + .get(idx) + .with_context(|| format!("link index {idx} out of range"))?; + apply_impairment(&self.sender_ns, iface, config) + } + + /// Write sender IPs to a temp file and return the path. + pub fn write_ip_list(&self) -> Result { + let dir = tempfile::tempdir().context("create temp dir for IP list")?; + let path = dir.keep().join("srtla_ips.txt"); + std::fs::write(&path, self.sender_ips.join("\n") + "\n") + .context("write IP list")?; + Ok(path) + } +} + +// --------------------------------------------------------------------------- +// Waiting helpers +// --------------------------------------------------------------------------- + +/// Poll `ss -uln` inside `ns` until `port` appears as a UDP listener. +pub fn wait_for_udp_listener(ns: &Namespace, port: u16, timeout: Duration) -> Result<()> { + let start = Instant::now(); + let port_str = format!(":{port}"); + let mut last_ss_output; + + loop { + let out = ns.exec("ss", &["-uln"])?; + let stdout = String::from_utf8_lossy(&out.stdout); + if stdout.lines().any(|line| line.contains(&port_str)) { + return Ok(()); + } + last_ss_output = stdout.to_string(); + + if start.elapsed() > timeout { + bail!( + "timeout waiting for UDP listener on port {port} in ns {}\n\ + last ss -uln output:\n{last_ss_output}", + ns.name + ); + } + std::thread::sleep(Duration::from_millis(200)); + } +} + +// --------------------------------------------------------------------------- +// SrtlaTestStack +// --------------------------------------------------------------------------- + +/// Full 3-process SRTLA test stack: srt-live-transmit + srtla_rec + srtla_send. +pub struct SrtlaTestStack { + pub topo: SrtlaTestTopology, + srt_server: Option, + srtla_rec: Option, + srtla_send: Option, + _ip_list_path: PathBuf, +} + +/// Output collected from all processes after stopping the stack. +pub struct StackOutput { + pub srt_server_stdout: Vec, + pub srt_server_stderr: Vec, + pub srtla_rec_stdout: Vec, + pub srtla_rec_stderr: Vec, + pub srtla_send_stdout: Vec, + pub srtla_send_stderr: Vec, +} + +/// Ports used by the test stack. +const SRT_SERVER_PORT: u16 = 4001; +const SRTLA_REC_PORT: u16 = 5000; +const SRTLA_SEND_SRT_PORT: u16 = 5555; + +impl SrtlaTestStack { + /// Start the full stack: srt-live-transmit → srtla_rec → srtla_send. + /// + /// `sender_extra_args` are appended to the srtla_send command line. + pub fn start( + test_name: &str, + num_links: usize, + sender_extra_args: &[&str], + ) -> Result { + let topo = SrtlaTestTopology::new(test_name, num_links)?; + let ip_list_path = topo.write_ip_list()?; + + // 1. Start srt-live-transmit in receiver NS + // Acts as an SRT listener that sinks to /dev/null. + let srt_uri = format!("srt://:{}?mode=listener", SRT_SERVER_PORT); + // Sink to a UDP port — srt-live-transmit only supports srt://, udp://, file://con + let sink_uri = "udp://127.0.0.1:9999"; + let mut srt_server = NamespaceProcess::spawn( + &topo.receiver_ns, + "srt-live-transmit", + &[&srt_uri, sink_uri], + ) + .context("start srt-live-transmit")?; + + // Brief pause for listener setup, then check it's alive + std::thread::sleep(Duration::from_millis(500)); + if let Some((code, stderr)) = srt_server.check_exit() { + bail!( + "srt-live-transmit exited immediately (code: {code:?})\nstderr:\n{stderr}" + ); + } + wait_for_udp_listener(&topo.receiver_ns, SRT_SERVER_PORT, Duration::from_secs(5)) + .context("wait for srt-live-transmit")?; + + // 2. Start srtla_rec in receiver NS + let srtla_port_str = SRTLA_REC_PORT.to_string(); + let srt_port_str = SRT_SERVER_PORT.to_string(); + let mut srtla_rec = NamespaceProcess::spawn( + &topo.receiver_ns, + "srtla_rec", + &[ + "--srtla_port", &srtla_port_str, + "--srt_hostname", "127.0.0.1", + "--srt_port", &srt_port_str, + ], + ) + .context("start srtla_rec")?; + + // Wait for srtla_rec to be listening + std::thread::sleep(Duration::from_millis(500)); + if let Some((code, stderr)) = srtla_rec.check_exit() { + bail!( + "srtla_rec exited immediately (code: {code:?})\nstderr:\n{stderr}" + ); + } + wait_for_udp_listener(&topo.receiver_ns, SRTLA_REC_PORT, Duration::from_secs(5)) + .context("wait for srtla_rec")?; + + // 3. Start srtla_send in sender NS + let send_port = SRTLA_SEND_SRT_PORT.to_string(); + let rec_port = SRTLA_REC_PORT.to_string(); + let ip_list_str = ip_list_path.to_string_lossy().to_string(); + + let mut send_args = vec![ + send_port.as_str(), + topo.receiver_ip.as_str(), + rec_port.as_str(), + ip_list_str.as_str(), + ]; + send_args.extend_from_slice(sender_extra_args); + + // Find the srtla_send binary built by cargo + let srtla_send_bin = find_srtla_send_binary()?; + let bin_str = srtla_send_bin.to_string_lossy().to_string(); + + let srtla_send = NamespaceProcess::spawn_with_env( + &topo.sender_ns, + &bin_str, + &send_args, + &[("RUST_LOG", "debug")], + ) + .context("start srtla_send")?; + + Ok(Self { + topo, + srt_server: Some(srt_server), + srtla_rec: Some(srtla_rec), + srtla_send: Some(srtla_send), + _ip_list_path: ip_list_path, + }) + } + + /// Apply impairment to sender-side link at `idx`. + pub fn impair_link(&self, idx: usize, config: ImpairmentConfig) -> Result<()> { + self.topo.impair_link(idx, config) + } + + /// The local SRT port that srtla_send listens on (for injecting test data). + pub fn sender_srt_port(&self) -> u16 { + SRTLA_SEND_SRT_PORT + } + + /// Stop all processes and collect their output. + pub fn stop(&mut self) -> StackOutput { + let mut send_out = (vec![], vec![]); + let mut rec_out = (vec![], vec![]); + let mut srt_out = (vec![], vec![]); + + // Kill in reverse order: sender → receiver → srt server + if let Some(mut p) = self.srtla_send.take() { + p.kill(); + send_out = (p.stdout_lines(), p.stderr_lines()); + } + if let Some(mut p) = self.srtla_rec.take() { + p.kill(); + rec_out = (p.stdout_lines(), p.stderr_lines()); + } + if let Some(mut p) = self.srt_server.take() { + p.kill(); + srt_out = (p.stdout_lines(), p.stderr_lines()); + } + + StackOutput { + srt_server_stdout: srt_out.0, + srt_server_stderr: srt_out.1, + srtla_rec_stdout: rec_out.0, + srtla_rec_stderr: rec_out.1, + srtla_send_stdout: send_out.0, + srtla_send_stderr: send_out.1, + } + } +} + +impl Drop for SrtlaTestStack { + fn drop(&mut self) { + // Ensure all processes are killed even if stop() wasn't called + self.srtla_send.take(); + self.srtla_rec.take(); + self.srt_server.take(); + } +} + +// --------------------------------------------------------------------------- +// UDP injection +// --------------------------------------------------------------------------- + +/// Inject `count` UDP packets into a port inside `ns`. +/// +/// Sends from within the namespace using a bound local socket. Each packet +/// is 188 bytes (MPEG-TS packet size) of zeroes to simulate SRT data. +pub fn inject_udp_packets(ns: &Namespace, target_ip: &str, port: u16, count: usize) -> Result<()> { + let addr = format!("{target_ip}:{port}"); + let script = format!( + "import socket; s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); \ + [s.sendto(b'\\x00'*188,('{target_ip}',{port})) for _ in range({count})]; \ + s.close()" + ); + ns.exec_checked("python3", &["-c", &script]) + .with_context(|| format!("inject {count} UDP packets to {addr}"))?; + Ok(()) +} + +/// Inject UDP packets at a steady rate (packets/sec) for `duration`. +pub fn inject_udp_stream( + ns: &Namespace, + target_ip: &str, + port: u16, + packets_per_sec: u32, + duration: Duration, +) -> Result<()> { + let interval_us = 1_000_000 / packets_per_sec; + let dur_secs = duration.as_secs_f64(); + + let script = format!( + "import socket,time; s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); \ + d=b'\\x00'*188; start=time.time(); \ + i=0\n\ + while time.time()-start<{dur_secs}:\n\ + s.sendto(d,('{target_ip}',{port}))\n\ + i+=1\n\ + time.sleep({interval_us}/1e6)\n\ + s.close(); print(f'sent {{i}} packets')" + ); + ns.exec_checked("python3", &["-c", &script]) + .context("inject UDP stream")?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Locate the srtla_send binary from a cargo build. +fn find_srtla_send_binary() -> Result { + // Check common cargo build output locations + let candidates = [ + // Debug build + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("target/debug/srtla_send"), + // Release build + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("target/release/srtla_send"), + ]; + + for path in &candidates { + if path.exists() { + return Ok(path.clone()); + } + } + + // Fall back to PATH + check_binary("srtla_send") + .ok_or_else(|| anyhow::anyhow!( + "srtla_send binary not found. Run `cargo build` first. Checked: {:?}", + candidates + )) +} diff --git a/crates/network-sim/src/lib.rs b/crates/network-sim/src/lib.rs index 1a39120..f4e6ce6 100644 --- a/crates/network-sim/src/lib.rs +++ b/crates/network-sim/src/lib.rs @@ -10,11 +10,17 @@ //! - [`scenario`]: Deterministic random-walk impairment generator //! - [`test_util`]: Privilege checks and unique name generation for tests +pub mod harness; pub mod impairment; pub mod scenario; pub mod test_util; pub mod topology; +pub use harness::{ + SrtlaTestStack, SrtlaTestTopology, StackOutput, check_binary, check_impairment_deps, + check_integration_deps, inject_udp_packets, inject_udp_stream, wait_for_udp_listener, + NamespaceProcess, SkipReason, +}; pub use impairment::{GemodelConfig, ImpairmentConfig, apply_impairment}; pub use scenario::{LinkScenarioConfig, Scenario, ScenarioConfig, ScenarioFrame}; pub use test_util::{check_privileges, unique_ns_name}; diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..8fcbd51 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,94 @@ +//! Shared utilities for integration tests. +#![allow(dead_code)] + +use std::time::Duration; + +use network_sim::{SrtlaTestStack, check_impairment_deps, check_integration_deps}; + +/// Check all integration test dependencies. Returns `true` if tests should +/// be skipped (prints the reason to stderr). Use at the top of every test. +pub fn skip_without_deps() -> bool { + match check_integration_deps() { + Ok(()) => false, + Err(reason) => { + eprintln!("Skipping: {reason}"); + true + } + } +} + +/// Like `skip_without_deps` but also requires netem for impairment tests. +pub fn skip_without_impairment_deps() -> bool { + match check_impairment_deps() { + Ok(()) => false, + Err(reason) => { + eprintln!("Skipping: {reason}"); + true + } + } +} + +/// Build the srtla_send binary (debug mode). Call once before tests that +/// need the binary. Panics if the build fails. +pub fn build_srtla_send() { + let status = std::process::Command::new("cargo") + .args(["build", "--bin", "srtla_send"]) + .status() + .expect("failed to run cargo build"); + assert!(status.success(), "cargo build failed"); +} + +/// Inject `count` UDP packets to srtla_send's local SRT port from within +/// the sender namespace. Each packet is 188 bytes of zeroes. +pub fn inject_packets(stack: &SrtlaTestStack, count: usize) -> anyhow::Result<()> { + network_sim::inject_udp_packets( + &stack.topo.sender_ns, + "127.0.0.1", + stack.sender_srt_port(), + count, + ) +} + +/// Inject a steady UDP stream into srtla_send for `duration`. +#[expect(dead_code)] +pub fn inject_stream( + stack: &SrtlaTestStack, + packets_per_sec: u32, + duration: Duration, +) -> anyhow::Result<()> { + network_sim::inject_udp_stream( + &stack.topo.sender_ns, + "127.0.0.1", + stack.sender_srt_port(), + packets_per_sec, + duration, + ) +} + +/// Collect and print all process output for debugging failed tests. +pub fn dump_output(output: &network_sim::StackOutput) { + eprintln!("--- srtla_send stdout ---"); + for line in &output.srtla_send_stdout { + eprintln!(" {line}"); + } + eprintln!("--- srtla_send stderr ---"); + for line in &output.srtla_send_stderr { + eprintln!(" {line}"); + } + eprintln!("--- srtla_rec stdout ---"); + for line in &output.srtla_rec_stdout { + eprintln!(" {line}"); + } + eprintln!("--- srtla_rec stderr ---"); + for line in &output.srtla_rec_stderr { + eprintln!(" {line}"); + } + eprintln!("--- srt-live-transmit stdout ---"); + for line in &output.srt_server_stdout { + eprintln!(" {line}"); + } + eprintln!("--- srt-live-transmit stderr ---"); + for line in &output.srt_server_stderr { + eprintln!(" {line}"); + } +} diff --git a/tests/netns_basic.rs b/tests/netns_basic.rs new file mode 100644 index 0000000..2b21314 --- /dev/null +++ b/tests/netns_basic.rs @@ -0,0 +1,67 @@ +//! Basic connectivity integration tests. +//! +//! Validates that srtla_send completes registration through real srtla_rec +//! and forwards data through the full pipeline. + +mod common; + +use std::thread; +use std::time::Duration; + +use network_sim::SrtlaTestStack; + +#[test] +fn test_two_link_registration() { + if common::skip_without_deps() { + return; + } + common::build_srtla_send(); + + let mut stack = + SrtlaTestStack::start("reg2", 2, &[]).expect("start stack"); + + // Allow time for registration handshake on both links + thread::sleep(Duration::from_secs(5)); + + let output = stack.stop(); + common::dump_output(&output); + + // Sender should have started without crashing + let all_stderr: String = output.srtla_send_stderr.join("\n"); + + // Look for registration success indicators in logs + // (srtla_send logs registration events at debug level) + assert!( + !all_stderr.contains("PANIC") && !all_stderr.contains("panic"), + "srtla_send panicked" + ); +} + +#[test] +fn test_data_forwarding() { + if common::skip_without_deps() { + return; + } + common::build_srtla_send(); + + let mut stack = + SrtlaTestStack::start("fwd", 2, &[]).expect("start stack"); + + // Wait for registration + thread::sleep(Duration::from_secs(5)); + + // Inject UDP packets into sender's local SRT port + common::inject_packets(&stack, 100).expect("inject packets"); + + // Allow data to flow through the pipeline + thread::sleep(Duration::from_secs(3)); + + let output = stack.stop(); + common::dump_output(&output); + + let all_stderr: String = output.srtla_send_stderr.join("\n"); + assert!( + !all_stderr.contains("PANIC") && !all_stderr.contains("panic"), + "srtla_send panicked" + ); +} diff --git a/tests/netns_failure.rs b/tests/netns_failure.rs new file mode 100644 index 0000000..45f738a --- /dev/null +++ b/tests/netns_failure.rs @@ -0,0 +1,100 @@ +//! Link failure and recovery integration tests. +//! +//! Validates that srtla_send detects link failure, continues on surviving +//! links, and recovers when a failed link returns. + +mod common; + +use std::thread; +use std::time::Duration; + +use network_sim::{ImpairmentConfig, SrtlaTestStack}; + +#[test] +fn test_link_failure_failover() { + if common::skip_without_impairment_deps() { + return; + } + common::build_srtla_send(); + + let mut stack = + SrtlaTestStack::start("fail", 2, &[]).expect("start stack"); + + // Let both links register + thread::sleep(Duration::from_secs(5)); + + // Inject background data + common::inject_packets(&stack, 100).expect("inject initial data"); + thread::sleep(Duration::from_secs(2)); + + // Kill link 0 with 100% loss + stack + .impair_link( + 0, + ImpairmentConfig { + loss_percent: Some(100.0), + ..Default::default() + }, + ) + .expect("kill link 0"); + + // Continue sending — should survive on link 1 + common::inject_packets(&stack, 200).expect("inject data after link kill"); + thread::sleep(Duration::from_secs(5)); + + let output = stack.stop(); + common::dump_output(&output); + + let all_stderr: String = output.srtla_send_stderr.join("\n"); + assert!( + !all_stderr.contains("PANIC") && !all_stderr.contains("panic"), + "srtla_send panicked after link failure" + ); +} + +#[test] +fn test_link_recovery() { + if common::skip_without_impairment_deps() { + return; + } + common::build_srtla_send(); + + let mut stack = + SrtlaTestStack::start("recv", 2, &[]).expect("start stack"); + + // Let both links register + thread::sleep(Duration::from_secs(5)); + + // Kill link 0 + stack + .impair_link( + 0, + ImpairmentConfig { + loss_percent: Some(100.0), + ..Default::default() + }, + ) + .expect("kill link 0"); + + thread::sleep(Duration::from_secs(5)); + + // Restore link 0 (clear impairment) + stack + .impair_link(0, ImpairmentConfig::default()) + .expect("restore link 0"); + + // Give time for re-registration + thread::sleep(Duration::from_secs(5)); + + common::inject_packets(&stack, 100).expect("inject data after recovery"); + thread::sleep(Duration::from_secs(3)); + + let output = stack.stop(); + common::dump_output(&output); + + let all_stderr: String = output.srtla_send_stderr.join("\n"); + assert!( + !all_stderr.contains("PANIC") && !all_stderr.contains("panic"), + "srtla_send panicked during link recovery" + ); +} diff --git a/tests/netns_impairment.rs b/tests/netns_impairment.rs new file mode 100644 index 0000000..e969fe6 --- /dev/null +++ b/tests/netns_impairment.rs @@ -0,0 +1,146 @@ +//! Impaired network integration tests. +//! +//! Validates that srtla_send adapts to asymmetric delay, packet loss, +//! and bandwidth limits. + +mod common; + +use std::thread; +use std::time::Duration; + +use network_sim::{ImpairmentConfig, SrtlaTestStack}; + +#[test] +fn test_asymmetric_delay() { + if common::skip_without_impairment_deps() { + return; + } + common::build_srtla_send(); + + let mut stack = + SrtlaTestStack::start("asym", 2, &[]).expect("start stack"); + + // Link 0: low delay, Link 1: high delay + stack + .impair_link( + 0, + ImpairmentConfig { + delay_ms: Some(20), + ..Default::default() + }, + ) + .expect("impair link 0"); + + stack + .impair_link( + 1, + ImpairmentConfig { + delay_ms: Some(100), + ..Default::default() + }, + ) + .expect("impair link 1"); + + // Wait for registration + RTT measurement + thread::sleep(Duration::from_secs(5)); + + // Inject some data so RTT tracking kicks in + common::inject_packets(&stack, 200).expect("inject packets"); + thread::sleep(Duration::from_secs(5)); + + let output = stack.stop(); + common::dump_output(&output); + + let all_stderr: String = output.srtla_send_stderr.join("\n"); + assert!( + !all_stderr.contains("PANIC") && !all_stderr.contains("panic"), + "srtla_send panicked" + ); +} + +#[test] +fn test_loss_triggers_window_reduction() { + if common::skip_without_impairment_deps() { + return; + } + common::build_srtla_send(); + + let mut stack = + SrtlaTestStack::start("loss", 2, &[]).expect("start stack"); + + // Wait for clean registration first + thread::sleep(Duration::from_secs(5)); + + // Apply 10% loss on link 0 + stack + .impair_link( + 0, + ImpairmentConfig { + loss_percent: Some(10.0), + ..Default::default() + }, + ) + .expect("impair link 0 with loss"); + + // Inject data to trigger NAK detection + common::inject_packets(&stack, 500).expect("inject packets"); + thread::sleep(Duration::from_secs(5)); + + let output = stack.stop(); + common::dump_output(&output); + + let all_stderr: String = output.srtla_send_stderr.join("\n"); + assert!( + !all_stderr.contains("PANIC") && !all_stderr.contains("panic"), + "srtla_send panicked" + ); +} + +#[test] +fn test_tbf_bandwidth_limit() { + if common::skip_without_impairment_deps() { + return; + } + common::build_srtla_send(); + + let mut stack = + SrtlaTestStack::start("tbf", 2, &[]).expect("start stack"); + + // Link 0: 1 Mbps, Link 1: 5 Mbps + stack + .impair_link( + 0, + ImpairmentConfig { + rate_kbit: Some(1000), + tbf_shaping: true, + ..Default::default() + }, + ) + .expect("impair link 0"); + + stack + .impair_link( + 1, + ImpairmentConfig { + rate_kbit: Some(5000), + tbf_shaping: true, + ..Default::default() + }, + ) + .expect("impair link 1"); + + thread::sleep(Duration::from_secs(5)); + + // Inject a burst of data + common::inject_packets(&stack, 500).expect("inject packets"); + thread::sleep(Duration::from_secs(5)); + + let output = stack.stop(); + common::dump_output(&output); + + let all_stderr: String = output.srtla_send_stderr.join("\n"); + assert!( + !all_stderr.contains("PANIC") && !all_stderr.contains("panic"), + "srtla_send panicked under bandwidth constraints" + ); +} diff --git a/tests/netns_scenario.rs b/tests/netns_scenario.rs new file mode 100644 index 0000000..01afa4a --- /dev/null +++ b/tests/netns_scenario.rs @@ -0,0 +1,186 @@ +//! Scenario-driven integration tests. +//! +//! Uses the random-walk scenario generator to apply evolving impairment +//! over time and validates that srtla_send survives without crashing. + +mod common; + +use std::thread; +use std::time::{Duration, Instant}; + +use network_sim::{ + ImpairmentConfig, LinkScenarioConfig, Scenario, ScenarioConfig, SrtlaTestStack, +}; + +#[test] +fn test_random_walk_stability() { + if common::skip_without_impairment_deps() { + return; + } + common::build_srtla_send(); + + let mut stack = + SrtlaTestStack::start("rw", 2, &[]).expect("start stack"); + + // Wait for registration + thread::sleep(Duration::from_secs(5)); + + let scenario_cfg = ScenarioConfig { + seed: 42, + duration: Duration::from_secs(30), + step: Duration::from_secs(2), + links: vec![ + LinkScenarioConfig { + min_rate_kbit: 500, + max_rate_kbit: 5000, + rate_step_kbit: 500, + base_delay_ms: 20, + delay_jitter_ms: 30, + delay_step_ms: 5, + max_loss_percent: 5.0, + loss_step_percent: 1.0, + }, + LinkScenarioConfig { + min_rate_kbit: 1000, + max_rate_kbit: 8000, + rate_step_kbit: 800, + base_delay_ms: 15, + delay_jitter_ms: 20, + delay_step_ms: 4, + max_loss_percent: 3.0, + loss_step_percent: 0.5, + }, + ], + }; + + let frames = Scenario::new(scenario_cfg).frames(); + let start = Instant::now(); + + // Inject a slow background stream while applying scenario + let inject_handle = { + // Spawn injection in a separate thread + let sender_ns_name = stack.topo.sender_ns.name.clone(); + let port = stack.sender_srt_port(); + thread::spawn(move || { + // Inject packets periodically for the duration + let end = Instant::now() + Duration::from_secs(30); + while Instant::now() < end { + // Use a raw command since we don't have the Namespace object + let _ = std::process::Command::new("sudo") + .args([ + "ip", "netns", "exec", &sender_ns_name, + "python3", "-c", + &format!( + "import socket; s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); \ + [s.sendto(b'\\x00'*188,('127.0.0.1',{port})) for _ in range(50)]; \ + s.close()" + ), + ]) + .output(); + thread::sleep(Duration::from_millis(500)); + } + }) + }; + + for frame in &frames { + let elapsed = start.elapsed(); + if elapsed < frame.t { + thread::sleep(frame.t - elapsed); + } + for (i, cfg) in frame.configs.iter().enumerate() { + if let Err(e) = stack.impair_link(i, cfg.clone()) { + eprintln!("impair_link({i}) at t={:?}: {e}", frame.t); + } + } + } + + let _ = inject_handle.join(); + + let output = stack.stop(); + common::dump_output(&output); + + let all_stderr: String = output.srtla_send_stderr.join("\n"); + assert!( + !all_stderr.contains("PANIC") && !all_stderr.contains("panic"), + "srtla_send panicked during random-walk scenario" + ); +} + +#[test] +fn test_step_change_convergence() { + if common::skip_without_impairment_deps() { + return; + } + common::build_srtla_send(); + + let mut stack = + SrtlaTestStack::start("step", 2, &[]).expect("start stack"); + + // Wait for registration + thread::sleep(Duration::from_secs(5)); + + // Phase 1: Stable, good conditions (5s) + stack + .impair_link( + 0, + ImpairmentConfig { + rate_kbit: Some(5000), + delay_ms: Some(10), + ..Default::default() + }, + ) + .expect("set good conditions link 0"); + stack + .impair_link( + 1, + ImpairmentConfig { + rate_kbit: Some(5000), + delay_ms: Some(10), + ..Default::default() + }, + ) + .expect("set good conditions link 1"); + + common::inject_packets(&stack, 200).expect("inject data phase 1"); + thread::sleep(Duration::from_secs(5)); + + // Phase 2: Sudden bandwidth drop on link 0 (5s) + stack + .impair_link( + 0, + ImpairmentConfig { + rate_kbit: Some(500), + delay_ms: Some(50), + tbf_shaping: true, + ..Default::default() + }, + ) + .expect("step-change link 0"); + + common::inject_packets(&stack, 200).expect("inject data phase 2"); + thread::sleep(Duration::from_secs(5)); + + // Phase 3: Recover (5s) + stack + .impair_link( + 0, + ImpairmentConfig { + rate_kbit: Some(5000), + delay_ms: Some(10), + ..Default::default() + }, + ) + .expect("recover link 0"); + + common::inject_packets(&stack, 200).expect("inject data phase 3"); + thread::sleep(Duration::from_secs(5)); + + let output = stack.stop(); + common::dump_output(&output); + + let all_stderr: String = output.srtla_send_stderr.join("\n"); + assert!( + !all_stderr.contains("PANIC") && !all_stderr.contains("panic"), + "srtla_send panicked during step-change test" + ); +} From 0bc155ab793f9485c612ebef155c1a2db0d66333 Mon Sep 17 00:00:00 2001 From: Thomas Lekanger Date: Wed, 11 Feb 2026 23:08:51 +0100 Subject: [PATCH 11/32] chore: fmt --- crates/network-sim/src/harness.rs | 68 +++++++++++----------------- crates/network-sim/src/impairment.rs | 4 +- crates/network-sim/src/lib.rs | 6 +-- crates/network-sim/src/scenario.rs | 3 +- crates/network-sim/src/test_util.rs | 6 ++- src/connection/mod.rs | 1 - src/connection/reconnection.rs | 3 +- tests/netns_basic.rs | 6 +-- tests/netns_failure.rs | 6 +-- tests/netns_impairment.rs | 9 ++-- tests/netns_scenario.rs | 18 ++++---- 11 files changed, 54 insertions(+), 76 deletions(-) diff --git a/crates/network-sim/src/harness.rs b/crates/network-sim/src/harness.rs index add51c2..069060c 100644 --- a/crates/network-sim/src/harness.rs +++ b/crates/network-sim/src/harness.rs @@ -45,7 +45,10 @@ impl std::fmt::Display for SkipReason { SkipReason::NotRoot => write!(f, "requires root / passwordless sudo"), SkipReason::MissingBinary(b) => write!(f, "{b} not found in PATH"), SkipReason::MissingTool(t) => write!(f, "system tool '{t}' not found"), - SkipReason::NoNetem => write!(f, "sch_netem kernel module not available (try: sudo modprobe sch_netem)"), + SkipReason::NoNetem => write!( + f, + "sch_netem kernel module not available (try: sudo modprobe sch_netem)" + ), } } } @@ -135,9 +138,7 @@ impl NamespaceProcess { .stdout(Stdio::piped()) .stderr(Stdio::piped()); - let child = cmd - .spawn() - .with_context(|| format!("spawn {label}"))?; + let child = cmd.spawn().with_context(|| format!("spawn {label}"))?; tracing::debug!(%label, pid = child.id(), "spawned namespace process"); Ok(Self { child, label }) @@ -175,12 +176,7 @@ impl NamespaceProcess { .output(); } - match self - .child - .try_wait() - .ok() - .flatten() - { + match self.child.try_wait().ok().flatten() { Some(_) => return, None => { // Wait up to 2s for graceful exit @@ -319,8 +315,7 @@ impl SrtlaTestTopology { pub fn write_ip_list(&self) -> Result { let dir = tempfile::tempdir().context("create temp dir for IP list")?; let path = dir.keep().join("srtla_ips.txt"); - std::fs::write(&path, self.sender_ips.join("\n") + "\n") - .context("write IP list")?; + std::fs::write(&path, self.sender_ips.join("\n") + "\n").context("write IP list")?; Ok(path) } } @@ -345,8 +340,8 @@ pub fn wait_for_udp_listener(ns: &Namespace, port: u16, timeout: Duration) -> Re if start.elapsed() > timeout { bail!( - "timeout waiting for UDP listener on port {port} in ns {}\n\ - last ss -uln output:\n{last_ss_output}", + "timeout waiting for UDP listener on port {port} in ns {}\nlast ss -uln \ + output:\n{last_ss_output}", ns.name ); } @@ -386,11 +381,7 @@ impl SrtlaTestStack { /// Start the full stack: srt-live-transmit → srtla_rec → srtla_send. /// /// `sender_extra_args` are appended to the srtla_send command line. - pub fn start( - test_name: &str, - num_links: usize, - sender_extra_args: &[&str], - ) -> Result { + pub fn start(test_name: &str, num_links: usize, sender_extra_args: &[&str]) -> Result { let topo = SrtlaTestTopology::new(test_name, num_links)?; let ip_list_path = topo.write_ip_list()?; @@ -409,9 +400,7 @@ impl SrtlaTestStack { // Brief pause for listener setup, then check it's alive std::thread::sleep(Duration::from_millis(500)); if let Some((code, stderr)) = srt_server.check_exit() { - bail!( - "srt-live-transmit exited immediately (code: {code:?})\nstderr:\n{stderr}" - ); + bail!("srt-live-transmit exited immediately (code: {code:?})\nstderr:\n{stderr}"); } wait_for_udp_listener(&topo.receiver_ns, SRT_SERVER_PORT, Duration::from_secs(5)) .context("wait for srt-live-transmit")?; @@ -423,9 +412,12 @@ impl SrtlaTestStack { &topo.receiver_ns, "srtla_rec", &[ - "--srtla_port", &srtla_port_str, - "--srt_hostname", "127.0.0.1", - "--srt_port", &srt_port_str, + "--srtla_port", + &srtla_port_str, + "--srt_hostname", + "127.0.0.1", + "--srt_port", + &srt_port_str, ], ) .context("start srtla_rec")?; @@ -433,9 +425,7 @@ impl SrtlaTestStack { // Wait for srtla_rec to be listening std::thread::sleep(Duration::from_millis(500)); if let Some((code, stderr)) = srtla_rec.check_exit() { - bail!( - "srtla_rec exited immediately (code: {code:?})\nstderr:\n{stderr}" - ); + bail!("srtla_rec exited immediately (code: {code:?})\nstderr:\n{stderr}"); } wait_for_udp_listener(&topo.receiver_ns, SRTLA_REC_PORT, Duration::from_secs(5)) .context("wait for srtla_rec")?; @@ -536,8 +526,7 @@ pub fn inject_udp_packets(ns: &Namespace, target_ip: &str, port: u16, count: usi let addr = format!("{target_ip}:{port}"); let script = format!( "import socket; s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); \ - [s.sendto(b'\\x00'*188,('{target_ip}',{port})) for _ in range({count})]; \ - s.close()" + [s.sendto(b'\\x00'*188,('{target_ip}',{port})) for _ in range({count})]; s.close()" ); ns.exec_checked("python3", &["-c", &script]) .with_context(|| format!("inject {count} UDP packets to {addr}"))?; @@ -556,14 +545,10 @@ pub fn inject_udp_stream( let dur_secs = duration.as_secs_f64(); let script = format!( - "import socket,time; s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); \ - d=b'\\x00'*188; start=time.time(); \ - i=0\n\ - while time.time()-start<{dur_secs}:\n\ - s.sendto(d,('{target_ip}',{port}))\n\ - i+=1\n\ - time.sleep({interval_us}/1e6)\n\ - s.close(); print(f'sent {{i}} packets')" + "import socket,time; s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); d=b'\\x00'*188; \ + start=time.time(); i=0\nwhile \ + time.time()-start<{dur_secs}:\ns.sendto(d,('{target_ip}',{port}))\ni+=1\ntime.\ + sleep({interval_us}/1e6)\ns.close(); print(f'sent {{i}} packets')" ); ns.exec_checked("python3", &["-c", &script]) .context("inject UDP stream")?; @@ -601,9 +586,10 @@ fn find_srtla_send_binary() -> Result { } // Fall back to PATH - check_binary("srtla_send") - .ok_or_else(|| anyhow::anyhow!( + check_binary("srtla_send").ok_or_else(|| { + anyhow::anyhow!( "srtla_send binary not found. Run `cargo build` first. Checked: {:?}", candidates - )) + ) + }) } diff --git a/crates/network-sim/src/impairment.rs b/crates/network-sim/src/impairment.rs index 14c6a31..538c817 100644 --- a/crates/network-sim/src/impairment.rs +++ b/crates/network-sim/src/impairment.rs @@ -109,9 +109,7 @@ impl ImpairmentConfig { args.extend(["corrupt".into(), format!("{corrupt}%")]); } - if include_rate - && let Some(rate) = self.rate_kbit - { + if include_rate && let Some(rate) = self.rate_kbit { args.extend(["rate".into(), format!("{rate}kbit")]); } diff --git a/crates/network-sim/src/lib.rs b/crates/network-sim/src/lib.rs index f4e6ce6..9b17890 100644 --- a/crates/network-sim/src/lib.rs +++ b/crates/network-sim/src/lib.rs @@ -17,9 +17,9 @@ pub mod test_util; pub mod topology; pub use harness::{ - SrtlaTestStack, SrtlaTestTopology, StackOutput, check_binary, check_impairment_deps, - check_integration_deps, inject_udp_packets, inject_udp_stream, wait_for_udp_listener, - NamespaceProcess, SkipReason, + NamespaceProcess, SkipReason, SrtlaTestStack, SrtlaTestTopology, StackOutput, check_binary, + check_impairment_deps, check_integration_deps, inject_udp_packets, inject_udp_stream, + wait_for_udp_listener, }; pub use impairment::{GemodelConfig, ImpairmentConfig, apply_impairment}; pub use scenario::{LinkScenarioConfig, Scenario, ScenarioConfig, ScenarioFrame}; diff --git a/crates/network-sim/src/scenario.rs b/crates/network-sim/src/scenario.rs index 3cebfcb..a608f15 100644 --- a/crates/network-sim/src/scenario.rs +++ b/crates/network-sim/src/scenario.rs @@ -94,8 +94,7 @@ impl Scenario { + rand_signed(&mut self.rng, link_cfg.rate_step_kbit as f64)) .clamp(link_cfg.min_rate_kbit as f64, link_cfg.max_rate_kbit as f64); - let max_delay = - (link_cfg.base_delay_ms + link_cfg.delay_jitter_ms) as f64; + let max_delay = (link_cfg.base_delay_ms + link_cfg.delay_jitter_ms) as f64; state.delay_ms = (state.delay_ms + rand_signed(&mut self.rng, link_cfg.delay_step_ms as f64)) .clamp(1.0, max_delay); diff --git a/crates/network-sim/src/test_util.rs b/crates/network-sim/src/test_util.rs index f408ac2..8c076fc 100644 --- a/crates/network-sim/src/test_util.rs +++ b/crates/network-sim/src/test_util.rs @@ -26,5 +26,9 @@ pub fn unique_ns_name(prefix: &str) -> String { let seq = NS_COUNTER.fetch_add(1, Ordering::Relaxed); let pid = std::process::id() % 0xffff; let name = format!("{prefix}_{pid:x}_{seq}"); - if name.len() > 15 { name[..15].to_string() } else { name } + if name.len() > 15 { + name[..15].to_string() + } else { + name + } } diff --git a/src/connection/mod.rs b/src/connection/mod.rs index 59a166b..89fb311 100644 --- a/src/connection/mod.rs +++ b/src/connection/mod.rs @@ -23,7 +23,6 @@ pub use rtt::RttTracker; use rustc_hash::FxHashMap; pub use socket::{bind_from_ip, resolve_remote}; use tokio::time::Instant; - use tracing::debug; use crate::protocol::*; diff --git a/src/connection/reconnection.rs b/src/connection/reconnection.rs index 7d8f611..710173c 100644 --- a/src/connection/reconnection.rs +++ b/src/connection/reconnection.rs @@ -1,8 +1,7 @@ use tracing::{debug, info}; -use crate::utils::now_ms; - use super::STARTUP_GRACE_MS; +use crate::utils::now_ms; const BASE_RECONNECT_DELAY_MS: u64 = 5000; const MAX_BACKOFF_DELAY_MS: u64 = 120_000; const MAX_BACKOFF_COUNT: u32 = 5; diff --git a/tests/netns_basic.rs b/tests/netns_basic.rs index 2b21314..72db83b 100644 --- a/tests/netns_basic.rs +++ b/tests/netns_basic.rs @@ -17,8 +17,7 @@ fn test_two_link_registration() { } common::build_srtla_send(); - let mut stack = - SrtlaTestStack::start("reg2", 2, &[]).expect("start stack"); + let mut stack = SrtlaTestStack::start("reg2", 2, &[]).expect("start stack"); // Allow time for registration handshake on both links thread::sleep(Duration::from_secs(5)); @@ -44,8 +43,7 @@ fn test_data_forwarding() { } common::build_srtla_send(); - let mut stack = - SrtlaTestStack::start("fwd", 2, &[]).expect("start stack"); + let mut stack = SrtlaTestStack::start("fwd", 2, &[]).expect("start stack"); // Wait for registration thread::sleep(Duration::from_secs(5)); diff --git a/tests/netns_failure.rs b/tests/netns_failure.rs index 45f738a..18d93f8 100644 --- a/tests/netns_failure.rs +++ b/tests/netns_failure.rs @@ -17,8 +17,7 @@ fn test_link_failure_failover() { } common::build_srtla_send(); - let mut stack = - SrtlaTestStack::start("fail", 2, &[]).expect("start stack"); + let mut stack = SrtlaTestStack::start("fail", 2, &[]).expect("start stack"); // Let both links register thread::sleep(Duration::from_secs(5)); @@ -59,8 +58,7 @@ fn test_link_recovery() { } common::build_srtla_send(); - let mut stack = - SrtlaTestStack::start("recv", 2, &[]).expect("start stack"); + let mut stack = SrtlaTestStack::start("recv", 2, &[]).expect("start stack"); // Let both links register thread::sleep(Duration::from_secs(5)); diff --git a/tests/netns_impairment.rs b/tests/netns_impairment.rs index e969fe6..f7467a4 100644 --- a/tests/netns_impairment.rs +++ b/tests/netns_impairment.rs @@ -17,8 +17,7 @@ fn test_asymmetric_delay() { } common::build_srtla_send(); - let mut stack = - SrtlaTestStack::start("asym", 2, &[]).expect("start stack"); + let mut stack = SrtlaTestStack::start("asym", 2, &[]).expect("start stack"); // Link 0: low delay, Link 1: high delay stack @@ -65,8 +64,7 @@ fn test_loss_triggers_window_reduction() { } common::build_srtla_send(); - let mut stack = - SrtlaTestStack::start("loss", 2, &[]).expect("start stack"); + let mut stack = SrtlaTestStack::start("loss", 2, &[]).expect("start stack"); // Wait for clean registration first thread::sleep(Duration::from_secs(5)); @@ -103,8 +101,7 @@ fn test_tbf_bandwidth_limit() { } common::build_srtla_send(); - let mut stack = - SrtlaTestStack::start("tbf", 2, &[]).expect("start stack"); + let mut stack = SrtlaTestStack::start("tbf", 2, &[]).expect("start stack"); // Link 0: 1 Mbps, Link 1: 5 Mbps stack diff --git a/tests/netns_scenario.rs b/tests/netns_scenario.rs index 01afa4a..201d095 100644 --- a/tests/netns_scenario.rs +++ b/tests/netns_scenario.rs @@ -8,9 +8,7 @@ mod common; use std::thread; use std::time::{Duration, Instant}; -use network_sim::{ - ImpairmentConfig, LinkScenarioConfig, Scenario, ScenarioConfig, SrtlaTestStack, -}; +use network_sim::{ImpairmentConfig, LinkScenarioConfig, Scenario, ScenarioConfig, SrtlaTestStack}; #[test] fn test_random_walk_stability() { @@ -19,8 +17,7 @@ fn test_random_walk_stability() { } common::build_srtla_send(); - let mut stack = - SrtlaTestStack::start("rw", 2, &[]).expect("start stack"); + let mut stack = SrtlaTestStack::start("rw", 2, &[]).expect("start stack"); // Wait for registration thread::sleep(Duration::from_secs(5)); @@ -68,8 +65,12 @@ fn test_random_walk_stability() { // Use a raw command since we don't have the Namespace object let _ = std::process::Command::new("sudo") .args([ - "ip", "netns", "exec", &sender_ns_name, - "python3", "-c", + "ip", + "netns", + "exec", + &sender_ns_name, + "python3", + "-c", &format!( "import socket; s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); \ [s.sendto(b'\\x00'*188,('127.0.0.1',{port})) for _ in range(50)]; \ @@ -113,8 +114,7 @@ fn test_step_change_convergence() { } common::build_srtla_send(); - let mut stack = - SrtlaTestStack::start("step", 2, &[]).expect("start stack"); + let mut stack = SrtlaTestStack::start("step", 2, &[]).expect("start stack"); // Wait for registration thread::sleep(Duration::from_secs(5)); From c87ebb37a1f1328843dc40baa9599442f86cc193 Mon Sep 17 00:00:00 2001 From: Thomas Lekanger Date: Wed, 11 Feb 2026 23:10:06 +0100 Subject: [PATCH 12/32] Delete receiver --- receiver | 1 - 1 file changed, 1 deletion(-) delete mode 120000 receiver diff --git a/receiver b/receiver deleted file mode 120000 index b118449..0000000 --- a/receiver +++ /dev/null @@ -1 +0,0 @@ -../srtla \ No newline at end of file From 45e71bccff21e1b5b8ec7a86451caf6807644c36 Mon Sep 17 00:00:00 2001 From: Thomas Lekanger Date: Wed, 11 Feb 2026 23:10:33 +0100 Subject: [PATCH 13/32] feat: add initial configuration for CodeRabbit integration --- .coderabbit.yaml | 94 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..66cf248 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,94 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +# https://docs.coderabbit.ai/getting-started/configure-coderabbit/ +# Validator https://docs.coderabbit.ai/configuration/yaml-validator#yaml-validator +# In PR, comment "@coderabbitai configuration" to get the full config including defaults +# Set the language for reviews by using the corresponding ISO language code. +# Default: "en-US" +language: "en-US" +# Settings related to reviews. +# Default: {} +reviews: + # Set the profile for reviews. Assertive profile yields more feedback, that may be considered nitpicky. + # Options: chill, assertive + # Default: "chill" + profile: chill + # Add this keyword in the PR/MR title to auto-generate the title. + # Default: "@coderabbitai" + auto_title_placeholder: "@coderabbitai title" + # Auto Title Instructions - Custom instructions for auto-generating the PR/MR title. + # Default: "" + auto_title_instructions: 'Format: ": ". Category must be one of: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert, cp. The category must be followed by a colon. Title should be concise (<= 80 chars). Example: "feat: Add logit_bias support".' # current: '' + # Set the commit status to 'pending' when the review is in progress and 'success' when it is complete. + # Default: true + commit_status: false + # Generate walkthrough in a markdown collapsible section. + # Default: false + collapse_walkthrough: true + # Generate an assessment of how well the changes address the linked issues in the walkthrough. + # Default: true + assess_linked_issues: true + # Include possibly related issues in the walkthrough. + # Default: true + related_issues: true + # Related PRs - Include possibly related pull requests in the walkthrough. + # Default: true + related_prs: true + # Suggest labels based on the changes in the pull request in the walkthrough. + # Default: true + suggested_labels: true + # Suggest reviewers based on the changes in the pull request in the walkthrough. + # Default: true + suggested_reviewers: true + # Generate a poem in the walkthrough comment. + # Default: true + poem: false # current: true + # Post review details on each review. Additionally, post a review status when a review is skipped in certain cases. + # Default: true + review_status: false # current: true + # Configuration for pre merge checks + # Default: {} + pre_merge_checks: + # Custom Pre-merge Checks - Add unique checks to enforce your team's standards before merging a pull request. Each check must have a unique name (up to 50 characters) and clear instructions (up to 10000 characters). Use these to automatically verify coding, security, documentation, or business rules and maintain code quality. + # Default: [] + custom_checks: [] + auto_review: + # Configuration for auto review + # Default: {} + # Automatic Incremental Review - Automatic incremental code review on each push + # Default: true + auto_incremental_review: true # current: true + # Review draft PRs/MRs. + # Default: false + drafts: false + # Base branches (other than the default branch) to review. Accepts regex patterns. Use '.*' to match all branches. + # Default: [] + base_branches: ["main"] # current: [] +# Configuration for knowledge base +# Default: {} +knowledge_base: + code_guidelines: + # CodeRabbit will analyse and learn from your organization's code guidelines, which you can mention in the file patterns section. These guidelines will then be used to conduct thorough code reviews. + # Default: {} + enabled: true + # Enabled - Enable CodeRabbit to enforce your organization's coding standards during reviews. + # Default: true + filePatterns: # current: [] + # File Patterns - Specify files for your coding guideline documents in this section. CodeRabbit will scan these files to understand your team's standards and apply them during code reviews. Multiple files supported. File names are case-sensitive. Common files like: (**/.cursorrules, .github/copilot-instructions.md, .github/instructions/*.instructions.md, **/CLAUDE.md, **/GEMINI.md, **/.cursor/rules/*, **/.windsurfrules, **/.clinerules/*, **/.rules/*, **/AGENT.md, **/AGENTS.md) are included by default. + # Default: [] + - "AGENTS.md" + - "CONTRIBUTING.md" From f10eaedf3f6d92f5ae91b894303ae394f163829c Mon Sep 17 00:00:00 2001 From: Thomas Lekanger <thomas.lekanger@hotmail.com> Date: Wed, 11 Feb 2026 23:12:56 +0100 Subject: [PATCH 14/32] test: adjust cooldown timing in RTT threshold tests --- src/tests/rtt_threshold_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tests/rtt_threshold_tests.rs b/src/tests/rtt_threshold_tests.rs index 0f04662..1a03675 100644 --- a/src/tests/rtt_threshold_tests.rs +++ b/src/tests/rtt_threshold_tests.rs @@ -175,7 +175,7 @@ mod tests { let mut connections = rt.block_on(create_test_connections(2)); let current_time = now_ms(); - let last_switch_time = current_time - 200; // 200ms ago (within 500ms cooldown) + let last_switch_time = current_time - 5; // 5ms ago (within 15ms cooldown) // Connection 0: Currently selected, lower capacity connections[0].rtt.smooth_rtt_ms = 50.0; @@ -201,7 +201,7 @@ mod tests { ); // After cooldown, should switch - let after_cooldown = current_time - 600; // 600ms ago (past cooldown) + let after_cooldown = current_time - 20; // 20ms ago (past 15ms cooldown) let selected_after = select_connection( &mut connections, Some(0), From 3beedf1e4b0a0fb306eaf5a0132909b363d50316 Mon Sep 17 00:00:00 2001 From: Thomas Lekanger <thomas.lekanger@hotmail.com> Date: Thu, 12 Feb 2026 01:54:54 +0100 Subject: [PATCH 15/32] refactor: update RTT handling to use Ewma for smoother calculations --- src/connection/mod.rs | 6 ++-- src/connection/rtt.rs | 51 ++++++++++++++------------------ src/ewma.rs | 1 + src/main.rs | 1 + src/tests/rtt_threshold_tests.rs | 34 ++++++++++----------- 5 files changed, 44 insertions(+), 49 deletions(-) diff --git a/src/connection/mod.rs b/src/connection/mod.rs index 89fb311..16604b8 100644 --- a/src/connection/mod.rs +++ b/src/connection/mod.rs @@ -233,7 +233,7 @@ impl SrtlaConnection { conn_id: self.conn_id as u32, window: self.window, in_flight: self.in_flight_packets, - rtt_ms: self.rtt.smooth_rtt_ms as u32, + rtt_ms: self.rtt.smooth_rtt.value() as u32, nak_count: self.congestion.nak_count as u32, bitrate_bytes_per_sec: (self.bitrate.current_bitrate_bps / 8.0) as u32, }; @@ -272,11 +272,11 @@ impl SrtlaConnection { } pub fn get_smooth_rtt_ms(&self) -> f64 { - self.rtt.smooth_rtt_ms + self.rtt.smooth_rtt.value() } pub fn get_fast_rtt_ms(&self) -> f64 { - self.rtt.fast_rtt_ms + self.rtt.fast_rtt.value() } pub fn get_rtt_min_ms(&self) -> f64 { diff --git a/src/connection/rtt.rs b/src/connection/rtt.rs index 7232e62..a8dbf41 100644 --- a/src/connection/rtt.rs +++ b/src/connection/rtt.rs @@ -2,6 +2,7 @@ use std::collections::VecDeque; use tracing::debug; +use crate::ewma::Ewma; use crate::protocol::extract_keepalive_timestamp; use crate::utils::now_ms; @@ -16,11 +17,14 @@ pub struct RttTracker { pub last_keepalive_sent_ms: u64, pub waiting_for_keepalive_response: bool, pub last_rtt_measurement_ms: u64, - pub smooth_rtt_ms: f64, - pub fast_rtt_ms: f64, + /// Smooth RTT in ms: slow rise (alpha=0.04), fast fall (alpha=0.40). + pub smooth_rtt: Ewma, + /// Fast RTT in ms: catches spikes quickly (up=0.10, down=0.30). + pub fast_rtt: Ewma, pub rtt_jitter_ms: f64, pub prev_rtt_ms: f64, - pub rtt_avg_delta_ms: f64, + /// Smoothed RTT change rate in ms/sample (symmetric alpha=0.2). + pub rtt_avg_delta: Ewma, /// Dual-window minimum RTT baseline. Computed as min(fast_window_min, slow_window_min). pub rtt_min_ms: f64, pub estimated_rtt_ms: f64, @@ -36,11 +40,11 @@ impl Default for RttTracker { last_keepalive_sent_ms: 0, waiting_for_keepalive_response: false, last_rtt_measurement_ms: 0, - smooth_rtt_ms: 0.0, - fast_rtt_ms: 0.0, + smooth_rtt: Ewma::asymmetric(0.04, 0.40), + fast_rtt: Ewma::asymmetric(0.10, 0.30), rtt_jitter_ms: 0.0, prev_rtt_ms: 0.0, - rtt_avg_delta_ms: 0.0, + rtt_avg_delta: Ewma::new(0.2), rtt_min_ms: 200.0, estimated_rtt_ms: 0.0, rtt_min_fast_window: VecDeque::with_capacity(FAST_WINDOW_SAMPLES), @@ -54,11 +58,11 @@ impl RttTracker { /// Used during reconnection to start with a clean slate pub fn reset(&mut self) { self.last_rtt_measurement_ms = 0; - self.smooth_rtt_ms = 0.0; - self.fast_rtt_ms = 0.0; + self.smooth_rtt.reset(); + self.fast_rtt.reset(); self.rtt_jitter_ms = 0.0; self.prev_rtt_ms = 0.0; - self.rtt_avg_delta_ms = 0.0; + self.rtt_avg_delta.reset(); self.rtt_min_ms = 200.0; self.estimated_rtt_ms = 0.0; self.last_keepalive_sent_ms = 0; @@ -71,9 +75,9 @@ impl RttTracker { let current_rtt = rtt_ms as f64; // Initialize on first measurement - if self.smooth_rtt_ms == 0.0 { - self.smooth_rtt_ms = current_rtt; - self.fast_rtt_ms = current_rtt; + if !self.smooth_rtt.is_initialized() { + self.smooth_rtt.update(current_rtt); + self.fast_rtt.update(current_rtt); self.prev_rtt_ms = current_rtt; self.estimated_rtt_ms = current_rtt; self.rtt_min_ms = current_rtt; @@ -83,23 +87,12 @@ impl RttTracker { return; } - // Asymmetric smoothing for smooth RTT: fast decrease, slow increase - if self.smooth_rtt_ms > current_rtt { - self.smooth_rtt_ms = self.smooth_rtt_ms * 0.60 + current_rtt * 0.40; - } else { - self.smooth_rtt_ms = self.smooth_rtt_ms * 0.96 + current_rtt * 0.04; - } - - // Asymmetric smoothing for fast RTT: catches spikes quickly - if self.fast_rtt_ms > current_rtt { - self.fast_rtt_ms = self.fast_rtt_ms * 0.70 + current_rtt * 0.30; - } else { - self.fast_rtt_ms = self.fast_rtt_ms * 0.90 + current_rtt * 0.10; - } + self.smooth_rtt.update(current_rtt); + self.fast_rtt.update(current_rtt); // Track RTT change rate let delta_rtt = current_rtt - self.prev_rtt_ms; - self.rtt_avg_delta_ms = self.rtt_avg_delta_ms * 0.8 + delta_rtt * 0.2; + self.rtt_avg_delta.update(delta_rtt); self.prev_rtt_ms = current_rtt; // Dual-window minimum RTT baseline tracking. @@ -133,12 +126,12 @@ impl RttTracker { } // Update legacy field for backwards compatibility - self.estimated_rtt_ms = self.smooth_rtt_ms; + self.estimated_rtt_ms = self.smooth_rtt.value(); self.last_rtt_measurement_ms = now_ms(); } pub fn is_stable(&self) -> bool { - self.rtt_avg_delta_ms.abs() < 1.0 + self.rtt_avg_delta.value().abs() < 1.0 } pub fn record_keepalive_sent(&mut self) { @@ -159,7 +152,7 @@ impl RttTracker { debug!( "{}: RTT from keepalive: {}ms (smooth: {:.1}ms, fast: {:.1}ms, jitter: \ {:.1}ms)", - label, rtt, self.smooth_rtt_ms, self.fast_rtt_ms, self.rtt_jitter_ms + label, rtt, self.smooth_rtt.value(), self.fast_rtt.value(), self.rtt_jitter_ms ); return Some(rtt); } diff --git a/src/ewma.rs b/src/ewma.rs index 98c36c1..3ff13bf 100644 --- a/src/ewma.rs +++ b/src/ewma.rs @@ -11,6 +11,7 @@ /// Asymmetric mode uses different alphas for rising vs falling measurements, /// matching the existing pattern in `RttTracker` for fast-decrease/slow-increase /// or vice versa. +#[derive(Debug, Clone)] pub struct Ewma { value: f64, alpha_up: f64, diff --git a/src/main.rs b/src/main.rs index aa2e5a4..3874002 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc; mod config; mod connection; +mod ewma; mod mode; mod protocol; mod registration; diff --git a/src/tests/rtt_threshold_tests.rs b/src/tests/rtt_threshold_tests.rs index 1a03675..6fa5951 100644 --- a/src/tests/rtt_threshold_tests.rs +++ b/src/tests/rtt_threshold_tests.rs @@ -13,11 +13,11 @@ mod tests { let current_time = now_ms(); // Connection 0: Low RTT (50ms) - connections[0].rtt.smooth_rtt_ms = 50.0; + connections[0].rtt.smooth_rtt.update(50.0); connections[0].in_flight_packets = 0; // Connection 1: High RTT (200ms) - connections[1].rtt.smooth_rtt_ms = 200.0; + connections[1].rtt.smooth_rtt.update(200.0); connections[1].in_flight_packets = 0; // With 30ms delta, only connection 0 (50ms) is "fast" @@ -36,11 +36,11 @@ mod tests { let current_time = now_ms(); // Connection 0: 50ms RTT, lower capacity - connections[0].rtt.smooth_rtt_ms = 50.0; + connections[0].rtt.smooth_rtt.update(50.0); connections[0].in_flight_packets = 5; // Lower score // Connection 1: 70ms RTT (within 30ms delta), higher capacity - connections[1].rtt.smooth_rtt_ms = 70.0; + connections[1].rtt.smooth_rtt.update(70.0); connections[1].in_flight_packets = 0; // Higher score // Both are "fast" (within 50 + 30 = 80ms), should pick higher capacity @@ -62,12 +62,12 @@ mod tests { let current_time = now_ms(); // Connection 0: Fast but saturated (window=0) - connections[0].rtt.smooth_rtt_ms = 50.0; + connections[0].rtt.smooth_rtt.update(50.0); connections[0].window = 0; connections[0].in_flight_packets = 10; // Connection 1: Slow but has capacity - connections[1].rtt.smooth_rtt_ms = 200.0; + connections[1].rtt.smooth_rtt.update(200.0); connections[1].window = 100; connections[1].in_flight_packets = 0; @@ -89,7 +89,7 @@ mod tests { let current_time = now_ms(); // Connection 0: Fast, equal capacity, but has recent NAKs - connections[0].rtt.smooth_rtt_ms = 50.0; + connections[0].rtt.smooth_rtt.update(50.0); connections[0].in_flight_packets = 0; connections[0].congestion.nak_count = 5; connections[0].congestion.last_nak_time_ms = current_time - 1000; @@ -97,7 +97,7 @@ mod tests { connections[0].reconnection.connection_established_ms = current_time - 35000; // Connection 1: Fast, equal capacity, no NAKs - connections[1].rtt.smooth_rtt_ms = 60.0; // Still fast (within delta) + connections[1].rtt.smooth_rtt.update(60.0); // Still fast (within delta) connections[1].in_flight_packets = 0; connections[1].congestion.nak_count = 0; // Set connection established time to beyond startup grace @@ -122,11 +122,11 @@ mod tests { let current_time = now_ms(); // Connection 0: No RTT data (0.0) - connections[0].rtt.smooth_rtt_ms = 0.0; + connections[0].rtt.smooth_rtt.update(0.0); connections[0].in_flight_packets = 5; // Connection 1: Has RTT data - connections[1].rtt.smooth_rtt_ms = 100.0; + connections[1].rtt.smooth_rtt.update(100.0); connections[1].in_flight_packets = 0; // Higher capacity // Connection 0 should be treated as fast (no RTT data) @@ -149,13 +149,13 @@ mod tests { let current_time = now_ms(); // Various RTTs - connections[0].rtt.smooth_rtt_ms = 50.0; + connections[0].rtt.smooth_rtt.update(50.0); connections[0].in_flight_packets = 5; - connections[1].rtt.smooth_rtt_ms = 150.0; + connections[1].rtt.smooth_rtt.update(150.0); connections[1].in_flight_packets = 0; // Best capacity - connections[2].rtt.smooth_rtt_ms = 200.0; + connections[2].rtt.smooth_rtt.update(200.0); connections[2].in_flight_packets = 3; // With 200ms delta, all are fast (min 50 + 200 = 250ms threshold) @@ -178,11 +178,11 @@ mod tests { let last_switch_time = current_time - 5; // 5ms ago (within 15ms cooldown) // Connection 0: Currently selected, lower capacity - connections[0].rtt.smooth_rtt_ms = 50.0; + connections[0].rtt.smooth_rtt.update(50.0); connections[0].in_flight_packets = 5; // Connection 1: Better link - connections[1].rtt.smooth_rtt_ms = 50.0; + connections[1].rtt.smooth_rtt.update(50.0); connections[1].in_flight_packets = 0; let selected = select_connection( @@ -254,14 +254,14 @@ mod tests { let current_time = now_ms(); // Connection 0: Fast, good capacity, but terrible NAK history - connections[0].rtt.smooth_rtt_ms = 50.0; + connections[0].rtt.smooth_rtt.update(50.0); connections[0].in_flight_packets = 0; // Best capacity connections[0].congestion.nak_count = 100; connections[0].congestion.last_nak_time_ms = current_time - 100; connections[0].reconnection.connection_established_ms = current_time - 35000; // Connection 1: Fast, slightly worse capacity, clean history - connections[1].rtt.smooth_rtt_ms = 50.0; + connections[1].rtt.smooth_rtt.update(50.0); connections[1].in_flight_packets = 1; connections[1].congestion.nak_count = 0; connections[1].reconnection.connection_established_ms = current_time - 35000; From 81e91fcf4034a60919a1f791dc97627f628e9c6b Mon Sep 17 00:00:00 2001 From: Thomas Lekanger <thomas.lekanger@hotmail.com> Date: Thu, 12 Feb 2026 01:59:13 +0100 Subject: [PATCH 16/32] chore: fmt --- src/connection/rtt.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/connection/rtt.rs b/src/connection/rtt.rs index a8dbf41..2f90e34 100644 --- a/src/connection/rtt.rs +++ b/src/connection/rtt.rs @@ -152,7 +152,11 @@ impl RttTracker { debug!( "{}: RTT from keepalive: {}ms (smooth: {:.1}ms, fast: {:.1}ms, jitter: \ {:.1}ms)", - label, rtt, self.smooth_rtt.value(), self.fast_rtt.value(), self.rtt_jitter_ms + label, + rtt, + self.smooth_rtt.value(), + self.fast_rtt.value(), + self.rtt_jitter_ms ); return Some(rtt); } From fd5fa6a5cc890ec892817a5676b089a3f73b82af Mon Sep 17 00:00:00 2001 From: Thomas Lekanger <thomas.lekanger@hotmail.com> Date: Thu, 12 Feb 2026 02:15:32 +0100 Subject: [PATCH 17/32] refactor: reset batch sender during connection reset --- src/connection/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/connection/mod.rs b/src/connection/mod.rs index 16604b8..76b3cd2 100644 --- a/src/connection/mod.rs +++ b/src/connection/mod.rs @@ -361,6 +361,7 @@ impl SrtlaConnection { self.in_flight_packets = 0; self.highest_acked_seq = i32::MIN; self.congestion.reset(); + self.batch_sender.reset(); self.quality_cache = CachedQuality::default(); } From e7a38cb1afeb21c0d8e7fa7ecf491c2d6ecbfc20 Mon Sep 17 00:00:00 2001 From: Thomas Lekanger <thomas.lekanger@hotmail.com> Date: Thu, 12 Feb 2026 02:15:55 +0100 Subject: [PATCH 18/32] refactor: improve signal handling and validation in process management --- crates/network-sim/src/harness.rs | 28 ++++++++++++++++++++-------- crates/network-sim/src/scenario.rs | 6 ++++++ crates/network-sim/src/test_util.rs | 15 +++++++-------- 3 files changed, 33 insertions(+), 16 deletions(-) diff --git a/crates/network-sim/src/harness.rs b/crates/network-sim/src/harness.rs index 069060c..2a356f2 100644 --- a/crates/network-sim/src/harness.rs +++ b/crates/network-sim/src/harness.rs @@ -168,11 +168,14 @@ impl NamespaceProcess { } /// Send SIGTERM, wait briefly, then SIGKILL if needed. + /// + /// Signals the entire process group (negative PID) so the inner process + /// receives the signal even when wrapped by `sudo ip netns exec`. pub fn kill(&mut self) { - // Try SIGTERM via sudo kill (since the child runs under sudo) if let Some(pid) = self.pid() { + // Signal the entire process group so the inner process receives it let _ = Command::new("sudo") - .args(["kill", "-TERM", &pid.to_string()]) + .args(["kill", "-TERM", "--", &format!("-{pid}")]) .output(); } @@ -187,10 +190,10 @@ impl NamespaceProcess { } } - // Force kill + // Force kill the process group if let Some(pid) = self.pid() { let _ = Command::new("sudo") - .args(["kill", "-9", &pid.to_string()]) + .args(["kill", "-9", "--", &format!("-{pid}")]) .output(); } let _ = self.child.wait(); @@ -541,14 +544,23 @@ pub fn inject_udp_stream( packets_per_sec: u32, duration: Duration, ) -> Result<()> { + if packets_per_sec == 0 { + bail!("packets_per_sec must be > 0"); + } let interval_us = 1_000_000 / packets_per_sec; let dur_secs = duration.as_secs_f64(); let script = format!( - "import socket,time; s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); d=b'\\x00'*188; \ - start=time.time(); i=0\nwhile \ - time.time()-start<{dur_secs}:\ns.sendto(d,('{target_ip}',{port}))\ni+=1\ntime.\ - sleep({interval_us}/1e6)\ns.close(); print(f'sent {{i}} packets')" + "import socket,time\n\ + s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)\n\ + d=b'\\x00'*188\n\ + start=time.time(); i=0\n\ + while time.time()-start<{dur_secs}:\n\ + \x20 s.sendto(d,('{target_ip}',{port}))\n\ + \x20 i+=1\n\ + \x20 time.sleep({interval_us}/1e6)\n\ + s.close()\n\ + print(f'sent {{i}} packets')" ); ns.exec_checked("python3", &["-c", &script]) .context("inject UDP stream")?; diff --git a/crates/network-sim/src/scenario.rs b/crates/network-sim/src/scenario.rs index a608f15..4612134 100644 --- a/crates/network-sim/src/scenario.rs +++ b/crates/network-sim/src/scenario.rs @@ -77,7 +77,13 @@ impl Scenario { } /// Generate all frames for the configured duration. + /// + /// Panics if `cfg.step` is zero. pub fn frames(&mut self) -> Vec<ScenarioFrame> { + assert!( + !self.cfg.step.is_zero(), + "ScenarioConfig.step must be non-zero" + ); let total_steps = (self.cfg.duration.as_secs_f64() / self.cfg.step.as_secs_f64()).ceil() as u64; diff --git a/crates/network-sim/src/test_util.rs b/crates/network-sim/src/test_util.rs index 8c076fc..12d3d75 100644 --- a/crates/network-sim/src/test_util.rs +++ b/crates/network-sim/src/test_util.rs @@ -20,15 +20,14 @@ pub fn check_privileges() -> bool { /// Generate a unique namespace/interface name safe for parallel tests. /// -/// Combines prefix + PID + atomic counter, truncated to 15 chars -/// (Linux netdev name limit). +/// Combines prefix + PID + atomic counter. The uniqueness suffix +/// (`_{pid:x}_{seq}`) is always preserved; the prefix is truncated +/// if the total would exceed 15 chars (Linux netdev name limit). pub fn unique_ns_name(prefix: &str) -> String { let seq = NS_COUNTER.fetch_add(1, Ordering::Relaxed); let pid = std::process::id() % 0xffff; - let name = format!("{prefix}_{pid:x}_{seq}"); - if name.len() > 15 { - name[..15].to_string() - } else { - name - } + let suffix = format!("_{pid:x}_{seq}"); + let max_prefix = 15_usize.saturating_sub(suffix.len()); + let truncated_prefix = &prefix[..prefix.len().min(max_prefix)]; + format!("{truncated_prefix}{suffix}") } From 78d641c95b7d3b6b916bbd103f451b9bf85eae76 Mon Sep 17 00:00:00 2001 From: Thomas Lekanger <thomas.lekanger@hotmail.com> Date: Thu, 12 Feb 2026 02:16:02 +0100 Subject: [PATCH 19/32] refactor: update inject_stream to allow dead code and enhance error handling in random walk test --- tests/common/mod.rs | 2 +- tests/netns_scenario.rs | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 8fcbd51..4c8fef3 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -50,7 +50,7 @@ pub fn inject_packets(stack: &SrtlaTestStack, count: usize) -> anyhow::Result<() } /// Inject a steady UDP stream into srtla_send for `duration`. -#[expect(dead_code)] +#[allow(dead_code)] pub fn inject_stream( stack: &SrtlaTestStack, packets_per_sec: u32, diff --git a/tests/netns_scenario.rs b/tests/netns_scenario.rs index 201d095..85fcc47 100644 --- a/tests/netns_scenario.rs +++ b/tests/netns_scenario.rs @@ -83,6 +83,7 @@ fn test_random_walk_stability() { }) }; + let mut impair_errors: Vec<String> = Vec::new(); for frame in &frames { let elapsed = start.elapsed(); if elapsed < frame.t { @@ -90,7 +91,7 @@ fn test_random_walk_stability() { } for (i, cfg) in frame.configs.iter().enumerate() { if let Err(e) = stack.impair_link(i, cfg.clone()) { - eprintln!("impair_link({i}) at t={:?}: {e}", frame.t); + impair_errors.push(format!("impair_link({i}) at t={:?}: {e}", frame.t)); } } } @@ -100,6 +101,12 @@ fn test_random_walk_stability() { let output = stack.stop(); common::dump_output(&output); + assert!( + impair_errors.is_empty(), + "impairment errors during scenario:\n{}", + impair_errors.join("\n") + ); + let all_stderr: String = output.srtla_send_stderr.join("\n"); assert!( !all_stderr.contains("PANIC") && !all_stderr.contains("panic"), From 517030ff10cdf800afc7a713bab57f77b22718de Mon Sep 17 00:00:00 2001 From: Thomas Lekanger <thomas.lekanger@hotmail.com> Date: Thu, 12 Feb 2026 02:19:21 +0100 Subject: [PATCH 20/32] fmt fmt fmt --- crates/network-sim/src/harness.rs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/crates/network-sim/src/harness.rs b/crates/network-sim/src/harness.rs index 2a356f2..6f86005 100644 --- a/crates/network-sim/src/harness.rs +++ b/crates/network-sim/src/harness.rs @@ -551,16 +551,10 @@ pub fn inject_udp_stream( let dur_secs = duration.as_secs_f64(); let script = format!( - "import socket,time\n\ - s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)\n\ - d=b'\\x00'*188\n\ - start=time.time(); i=0\n\ - while time.time()-start<{dur_secs}:\n\ - \x20 s.sendto(d,('{target_ip}',{port}))\n\ - \x20 i+=1\n\ - \x20 time.sleep({interval_us}/1e6)\n\ - s.close()\n\ - print(f'sent {{i}} packets')" + "import socket,time\ns=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)\nd=b'\\x00'*188\\ + nstart=time.time(); i=0\nwhile time.time()-start<{dur_secs}:\n\x20 \ + s.sendto(d,('{target_ip}',{port}))\n\x20 i+=1\n\x20 \ + time.sleep({interval_us}/1e6)\ns.close()\nprint(f'sent {{i}} packets')" ); ns.exec_checked("python3", &["-c", &script]) .context("inject UDP stream")?; From bb84439d28140ac6ac8c7aa56951a5acaf9ada33 Mon Sep 17 00:00:00 2001 From: Thomas Lekanger <thomas.lekanger@hotmail.com> Date: Thu, 12 Feb 2026 02:22:02 +0100 Subject: [PATCH 21/32] At Least It Was Here --- Cargo.lock | 46 +++++++++++++++++++++++----------------------- Cargo.toml | 2 +- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7037c95..9c5ef87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -63,9 +63,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" [[package]] name = "assert_matches" @@ -114,9 +114,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.57" +version = "4.5.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" dependencies = [ "clap_builder", "clap_derive", @@ -124,9 +124,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.57" +version = "4.5.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" +checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" dependencies = [ "anstream", "anstyle", @@ -148,9 +148,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "colorchoice" @@ -298,9 +298,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.181" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" [[package]] name = "libmimalloc-sys" @@ -335,9 +335,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "mimalloc" @@ -660,12 +660,12 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.24.0" +version = "3.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.1", "once_cell", "rustix", "windows-sys 0.61.2", @@ -792,9 +792,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" [[package]] name = "unicode-xid" @@ -1051,18 +1051,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.38" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57cf3aa6855b23711ee9852dfc97dfaa51c45feaba5b645d0c777414d494a961" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.38" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a616990af1a287837c4fe6596ad77ef57948f787e46ce28e166facc0cc1cb75" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", @@ -1071,6 +1071,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" +checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" diff --git a/Cargo.toml b/Cargo.toml index d335483..119d55e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ homepage = "https://github.com/irlserver/srtla_send" readme = "README.md" keywords = ["srt", "streaming", "bonding", "aggregation", "networking"] categories = ["network-programming", "multimedia"] -rust-version = "1.87" +rust-version = "1.88" [dependencies] anyhow = "1.0" From c45080001c7148c91e996f066e7def135e4a98ab Mon Sep 17 00:00:00 2001 From: Thomas Lekanger <thomas.lekanger@hotmail.com> Date: Thu, 12 Feb 2026 02:23:13 +0100 Subject: [PATCH 22/32] comment --- src/sender/selection/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sender/selection/mod.rs b/src/sender/selection/mod.rs index 82d86d3..2937234 100644 --- a/src/sender/selection/mod.rs +++ b/src/sender/selection/mod.rs @@ -13,7 +13,7 @@ //! - Exponential NAK decay (smooth ~8s recovery) //! - NAK burst detection and penalties //! - RTT-aware scoring (small bonus for low latency) -//! - Minimal hysteresis (2%) to prevent flip-flopping +//! - Hysteresis (10%) to prevent flip-flopping //! - Optional smart exploration //! - Time-based switch dampening to prevent rapid thrashing //! From 5dc82e85f7f88ffc24304086e05fb24ac938e0c6 Mon Sep 17 00:00:00 2001 From: Thomas Lekanger <thomas.lekanger@hotmail.com> Date: Thu, 12 Feb 2026 02:23:53 +0100 Subject: [PATCH 23/32] refactor: simplify mode handling in run_sender_with_config function --- src/sender/mod.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/sender/mod.rs b/src/sender/mod.rs index efca158..9c26a03 100644 --- a/src/sender/mod.rs +++ b/src/sender/mod.rs @@ -62,11 +62,7 @@ pub async fn run_sender_with_config( receiver_host, receiver_port, ips_file, - match config.mode() { - crate::mode::SchedulingMode::Classic => "classic", - crate::mode::SchedulingMode::Enhanced => "enhanced", - crate::mode::SchedulingMode::RttThreshold => "rtt-threshold", - } + config.mode() ); let ips = read_ip_list(ips_file).await?; debug!( From b5487fd52e37994333c0e09f1180ab43c165ca34 Mon Sep 17 00:00:00 2001 From: Thomas Lekanger <thomas.lekanger@hotmail.com> Date: Thu, 12 Feb 2026 02:26:14 +0100 Subject: [PATCH 24/32] clippyyyy --- src/registration/probing.rs | 4 +--- src/sender/housekeeping.rs | 8 +++----- src/sender/packet_handler.rs | 23 +++++++++-------------- src/sender/selection/enhanced.rs | 10 ++++------ src/sender/selection/rtt_threshold.rs | 5 ++--- 5 files changed, 19 insertions(+), 31 deletions(-) diff --git a/src/registration/probing.rs b/src/registration/probing.rs index 22151ba..7ea24d5 100644 --- a/src/registration/probing.rs +++ b/src/registration/probing.rs @@ -80,8 +80,7 @@ impl SrtlaRegistrationManager { .probe_results .iter_mut() .find(|r| r.conn_idx == conn_idx) - { - if result.rtt_ms.is_none() { + && result.rtt_ms.is_none() { let rtt = now.saturating_sub(result.probe_sent_ms); result.rtt_ms = Some(rtt); info!( @@ -89,7 +88,6 @@ impl SrtlaRegistrationManager { conn_idx, rtt ); } - } } pub fn check_probing_complete(&mut self) -> bool { diff --git a/src/sender/housekeeping.rs b/src/sender/housekeeping.rs index b419e3f..dd7cc15 100644 --- a/src/sender/housekeeping.rs +++ b/src/sender/housekeeping.rs @@ -33,17 +33,15 @@ pub async fn handle_housekeeping( let was_probing = true; reg.check_probing_complete(); // If probing just completed, reset grace period for the selected connection - if !reg.is_probing() && was_probing { - if let Some(idx) = reg.get_selected_connection_idx() { - if let Some(conn) = connections.get_mut(idx) { + if !reg.is_probing() && was_probing + && let Some(idx) = reg.get_selected_connection_idx() + && let Some(conn) = connections.get_mut(idx) { conn.reconnection.startup_grace_deadline_ms = current_ms + STARTUP_GRACE_MS; debug!( "{}: Reset grace period after being selected for initial registration", conn.label ); } - } - } } // housekeeping: drive registration, send keepalives diff --git a/src/sender/packet_handler.rs b/src/sender/packet_handler.rs index 19a6989..cab0fad 100644 --- a/src/sender/packet_handler.rs +++ b/src/sender/packet_handler.rs @@ -73,12 +73,11 @@ pub async fn process_connection_events( let mut handled = false; // O(1) lookup in the ring buffer - if let Some(conn_id) = seq_tracker.get(*nak, current_time_ms) { - if let Some(conn) = connections.iter_mut().find(|c| c.conn_id == conn_id) { + if let Some(conn_id) = seq_tracker.get(*nak, current_time_ms) + && let Some(conn) = connections.iter_mut().find(|c| c.conn_id == conn_id) { conn.handle_nak(*nak as i32); handled = true; } - } if !handled { for conn in connections.iter_mut() { @@ -200,13 +199,11 @@ fn select_pre_registration_connection( last_selected_idx: Option<usize>, ) -> Option<usize> { // Try to reuse the last selected connection if it's still valid - if let Some(idx) = last_selected_idx { - if let Some(conn) = connections.get(idx) { - if conn.connected && !conn.is_timed_out() { + if let Some(idx) = last_selected_idx + && let Some(conn) = connections.get(idx) + && conn.connected && !conn.is_timed_out() { return Some(idx); } - } - } // Otherwise, find any non-timed-out connection connections @@ -307,14 +304,13 @@ pub async fn forward_via_connection( if let Some(prev_idx) = *last_selected_idx { if prev_idx < connections.len() { // Flush the previous connection's batch before switching - if connections[prev_idx].has_queued_packets() { - if let Err(e) = connections[prev_idx].flush_batch().await { + if connections[prev_idx].has_queued_packets() + && let Err(e) = connections[prev_idx].flush_batch().await { warn!( "{}: batch flush on switch failed: {}", connections[prev_idx].label, e ); } - } debug!( "Connection switch: {} → {} (seq: {:?})", connections[prev_idx].label, connections[sel_idx].label, seq @@ -372,10 +368,9 @@ pub async fn flush_all_batches(connections: &mut [SrtlaConnection]) { // Now do the actual flush for connections that need it for conn in connections.iter_mut() { - if conn.needs_batch_flush() || conn.has_queued_packets() { - if let Err(e) = conn.flush_batch().await { + if (conn.needs_batch_flush() || conn.has_queued_packets()) + && let Err(e) = conn.flush_batch().await { warn!("{}: periodic batch flush failed: {}", conn.label, e); } - } } } diff --git a/src/sender/selection/enhanced.rs b/src/sender/selection/enhanced.rs index 05b42a2..bfcefa7 100644 --- a/src/sender/selection/enhanced.rs +++ b/src/sender/selection/enhanced.rs @@ -113,8 +113,8 @@ pub fn select_connection( // Apply score-based hysteresis if not in cooldown // If current connection is still valid and new best isn't significantly better - if let Some(current) = current_score { - if best_score < current * SWITCH_THRESHOLD { + if let Some(current) = current_score + && best_score < current * SWITCH_THRESHOLD { // Only log occasionally to reduce spam if current_time_ms % 1000 < 10 { debug!( @@ -127,7 +127,6 @@ pub fn select_connection( } return Some(last); } - } } } @@ -140,12 +139,11 @@ pub fn select_connection( if explore_now { // Exploration wants to try second-best, but only if different from current - if let (Some(second), Some(last)) = (second_idx, last_idx) { - if second != last { + if let (Some(second), Some(last)) = (second_idx, last_idx) + && second != last { debug!("Exploration: trying second-best connection"); return second_idx.or(best_idx); } - } // If second is same as current, just use best best_idx } else { diff --git a/src/sender/selection/rtt_threshold.rs b/src/sender/selection/rtt_threshold.rs index 37be4fd..feaf52b 100644 --- a/src/sender/selection/rtt_threshold.rs +++ b/src/sender/selection/rtt_threshold.rs @@ -125,8 +125,8 @@ pub fn select_connection( let time_since_last_switch = current_time_ms.saturating_sub(last_switch_time_ms); let in_cooldown = time_since_last_switch < MIN_SWITCH_INTERVAL_MS; - if let Some(last) = last_idx { - if best_idx != Some(last) && in_cooldown { + if let Some(last) = last_idx + && best_idx != Some(last) && in_cooldown { // Check if last connection is still valid let last_valid = last < conns.len() && !conns[last].is_timed_out() && conns[last].connected; @@ -134,7 +134,6 @@ pub fn select_connection( return Some(last); } } - } best_idx } From bd96be69c4cf5599ee0d8864a8951bccf4bb3a65 Mon Sep 17 00:00:00 2001 From: Thomas Lekanger <thomas.lekanger@hotmail.com> Date: Thu, 12 Feb 2026 11:56:42 +0100 Subject: [PATCH 25/32] refactor: update binary check command and improve resource cleanup in SrtlaTestStack --- crates/network-sim/src/harness.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/network-sim/src/harness.rs b/crates/network-sim/src/harness.rs index 6f86005..256905b 100644 --- a/crates/network-sim/src/harness.rs +++ b/crates/network-sim/src/harness.rs @@ -22,8 +22,8 @@ use crate::topology::Namespace; /// Check if a binary exists in PATH. pub fn check_binary(name: &str) -> Option<PathBuf> { - Command::new("which") - .arg(name) + Command::new("sh") + .args(["-c", &format!("command -v {name}")]) .output() .ok() .filter(|o| o.status.success()) @@ -510,10 +510,11 @@ impl SrtlaTestStack { impl Drop for SrtlaTestStack { fn drop(&mut self) { - // Ensure all processes are killed even if stop() wasn't called - self.srtla_send.take(); - self.srtla_rec.take(); - self.srt_server.take(); + // Ensure all processes are killed even if stop() wasn't called. + // Dropping NamespaceProcess triggers its Drop impl which calls kill(). + drop(self.srtla_send.take()); + drop(self.srtla_rec.take()); + drop(self.srt_server.take()); } } @@ -551,7 +552,7 @@ pub fn inject_udp_stream( let dur_secs = duration.as_secs_f64(); let script = format!( - "import socket,time\ns=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)\nd=b'\\x00'*188\\ + "import socket,time\ns=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)\nd=b'\\x00'*188\\\ nstart=time.time(); i=0\nwhile time.time()-start<{dur_secs}:\n\x20 \ s.sendto(d,('{target_ip}',{port}))\n\x20 i+=1\n\x20 \ time.sleep({interval_us}/1e6)\ns.close()\nprint(f'sent {{i}} packets')" From 281f34a04363136c9dfa3070d6cd6c05297872e9 Mon Sep 17 00:00:00 2001 From: Thomas Lekanger <thomas.lekanger@hotmail.com> Date: Thu, 12 Feb 2026 11:56:49 +0100 Subject: [PATCH 26/32] test: add assertions for delay values in scenario tests --- crates/network-sim/src/scenario.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/network-sim/src/scenario.rs b/crates/network-sim/src/scenario.rs index 4612134..a20acac 100644 --- a/crates/network-sim/src/scenario.rs +++ b/crates/network-sim/src/scenario.rs @@ -199,6 +199,11 @@ mod tests { let loss = config.loss_percent.unwrap(); assert!(loss >= 0.0, "negative loss"); assert!(loss <= link_cfg.max_loss_percent, "loss {loss} > max"); + + let delay = config.delay_ms.unwrap(); + assert!(delay >= 1, "delay {delay} < 1"); + let max_delay = link_cfg.base_delay_ms + link_cfg.delay_jitter_ms; + assert!(delay <= max_delay, "delay {delay} > max {max_delay}"); } } } From 8ef2b6c00b17844f4afae6c324231ee0c31cac01 Mon Sep 17 00:00:00 2001 From: Thomas Lekanger <thomas.lekanger@hotmail.com> Date: Thu, 12 Feb 2026 11:57:32 +0100 Subject: [PATCH 27/32] chore: fmt --- src/registration/probing.rs | 17 ++++++++------- src/sender/housekeeping.rs | 18 +++++++++------- src/sender/packet_handler.rs | 37 ++++++++++++++++++-------------- src/sender/selection/enhanced.rs | 34 +++++++++++++++-------------- 4 files changed, 58 insertions(+), 48 deletions(-) diff --git a/src/registration/probing.rs b/src/registration/probing.rs index 7ea24d5..e22cf16 100644 --- a/src/registration/probing.rs +++ b/src/registration/probing.rs @@ -80,14 +80,15 @@ impl SrtlaRegistrationManager { .probe_results .iter_mut() .find(|r| r.conn_idx == conn_idx) - && result.rtt_ms.is_none() { - let rtt = now.saturating_sub(result.probe_sent_ms); - result.rtt_ms = Some(rtt); - info!( - "Probe response from connection #{} (RTT: {}ms)", - conn_idx, rtt - ); - } + && result.rtt_ms.is_none() + { + let rtt = now.saturating_sub(result.probe_sent_ms); + result.rtt_ms = Some(rtt); + info!( + "Probe response from connection #{} (RTT: {}ms)", + conn_idx, rtt + ); + } } pub fn check_probing_complete(&mut self) -> bool { diff --git a/src/sender/housekeeping.rs b/src/sender/housekeeping.rs index dd7cc15..1e4775c 100644 --- a/src/sender/housekeeping.rs +++ b/src/sender/housekeeping.rs @@ -33,15 +33,17 @@ pub async fn handle_housekeeping( let was_probing = true; reg.check_probing_complete(); // If probing just completed, reset grace period for the selected connection - if !reg.is_probing() && was_probing + if !reg.is_probing() + && was_probing && let Some(idx) = reg.get_selected_connection_idx() - && let Some(conn) = connections.get_mut(idx) { - conn.reconnection.startup_grace_deadline_ms = current_ms + STARTUP_GRACE_MS; - debug!( - "{}: Reset grace period after being selected for initial registration", - conn.label - ); - } + && let Some(conn) = connections.get_mut(idx) + { + conn.reconnection.startup_grace_deadline_ms = current_ms + STARTUP_GRACE_MS; + debug!( + "{}: Reset grace period after being selected for initial registration", + conn.label + ); + } } // housekeeping: drive registration, send keepalives diff --git a/src/sender/packet_handler.rs b/src/sender/packet_handler.rs index cab0fad..fb7e3dc 100644 --- a/src/sender/packet_handler.rs +++ b/src/sender/packet_handler.rs @@ -74,10 +74,11 @@ pub async fn process_connection_events( // O(1) lookup in the ring buffer if let Some(conn_id) = seq_tracker.get(*nak, current_time_ms) - && let Some(conn) = connections.iter_mut().find(|c| c.conn_id == conn_id) { - conn.handle_nak(*nak as i32); - handled = true; - } + && let Some(conn) = connections.iter_mut().find(|c| c.conn_id == conn_id) + { + conn.handle_nak(*nak as i32); + handled = true; + } if !handled { for conn in connections.iter_mut() { @@ -201,9 +202,11 @@ fn select_pre_registration_connection( // Try to reuse the last selected connection if it's still valid if let Some(idx) = last_selected_idx && let Some(conn) = connections.get(idx) - && conn.connected && !conn.is_timed_out() { - return Some(idx); - } + && conn.connected + && !conn.is_timed_out() + { + return Some(idx); + } // Otherwise, find any non-timed-out connection connections @@ -305,12 +308,13 @@ pub async fn forward_via_connection( if prev_idx < connections.len() { // Flush the previous connection's batch before switching if connections[prev_idx].has_queued_packets() - && let Err(e) = connections[prev_idx].flush_batch().await { - warn!( - "{}: batch flush on switch failed: {}", - connections[prev_idx].label, e - ); - } + && let Err(e) = connections[prev_idx].flush_batch().await + { + warn!( + "{}: batch flush on switch failed: {}", + connections[prev_idx].label, e + ); + } debug!( "Connection switch: {} → {} (seq: {:?})", connections[prev_idx].label, connections[sel_idx].label, seq @@ -369,8 +373,9 @@ pub async fn flush_all_batches(connections: &mut [SrtlaConnection]) { // Now do the actual flush for connections that need it for conn in connections.iter_mut() { if (conn.needs_batch_flush() || conn.has_queued_packets()) - && let Err(e) = conn.flush_batch().await { - warn!("{}: periodic batch flush failed: {}", conn.label, e); - } + && let Err(e) = conn.flush_batch().await + { + warn!("{}: periodic batch flush failed: {}", conn.label, e); + } } } diff --git a/src/sender/selection/enhanced.rs b/src/sender/selection/enhanced.rs index bfcefa7..49e7fec 100644 --- a/src/sender/selection/enhanced.rs +++ b/src/sender/selection/enhanced.rs @@ -114,19 +114,20 @@ pub fn select_connection( // Apply score-based hysteresis if not in cooldown // If current connection is still valid and new best isn't significantly better if let Some(current) = current_score - && best_score < current * SWITCH_THRESHOLD { - // Only log occasionally to reduce spam - if current_time_ms % 1000 < 10 { - debug!( - "Score hysteresis: staying with current connection (current: {:.1}, \ - best: {:.1}, threshold: {:.1})", - current, - best_score, - current * SWITCH_THRESHOLD - ); - } - return Some(last); + && best_score < current * SWITCH_THRESHOLD + { + // Only log occasionally to reduce spam + if current_time_ms % 1000 < 10 { + debug!( + "Score hysteresis: staying with current connection (current: {:.1}, best: \ + {:.1}, threshold: {:.1})", + current, + best_score, + current * SWITCH_THRESHOLD + ); } + return Some(last); + } } } @@ -140,10 +141,11 @@ pub fn select_connection( if explore_now { // Exploration wants to try second-best, but only if different from current if let (Some(second), Some(last)) = (second_idx, last_idx) - && second != last { - debug!("Exploration: trying second-best connection"); - return second_idx.or(best_idx); - } + && second != last + { + debug!("Exploration: trying second-best connection"); + return second_idx.or(best_idx); + } // If second is same as current, just use best best_idx } else { From 71063c4fe042a9bf3e7cc54ef6060037a1625bb7 Mon Sep 17 00:00:00 2001 From: Thomas Lekanger <thomas.lekanger@hotmail.com> Date: Thu, 12 Feb 2026 11:57:36 +0100 Subject: [PATCH 28/32] refactor: streamline UDP stream injection script formatting --- crates/network-sim/src/harness.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/network-sim/src/harness.rs b/crates/network-sim/src/harness.rs index 256905b..9e5d75b 100644 --- a/crates/network-sim/src/harness.rs +++ b/crates/network-sim/src/harness.rs @@ -552,8 +552,7 @@ pub fn inject_udp_stream( let dur_secs = duration.as_secs_f64(); let script = format!( - "import socket,time\ns=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)\nd=b'\\x00'*188\\\ - nstart=time.time(); i=0\nwhile time.time()-start<{dur_secs}:\n\x20 \ + "import socket,time\ns=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)\nd=b'\\x00'*188\\nstart=time.time(); i=0\nwhile time.time()-start<{dur_secs}:\n\x20 \ s.sendto(d,('{target_ip}',{port}))\n\x20 i+=1\n\x20 \ time.sleep({interval_us}/1e6)\ns.close()\nprint(f'sent {{i}} packets')" ); From ceae1e0ceaf66765713bec000e704c962ffb4a45 Mon Sep 17 00:00:00 2001 From: Thomas Lekanger <thomas.lekanger@hotmail.com> Date: Thu, 12 Feb 2026 11:57:46 +0100 Subject: [PATCH 29/32] chore: fmt --- src/sender/selection/rtt_threshold.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/sender/selection/rtt_threshold.rs b/src/sender/selection/rtt_threshold.rs index feaf52b..454347d 100644 --- a/src/sender/selection/rtt_threshold.rs +++ b/src/sender/selection/rtt_threshold.rs @@ -126,14 +126,15 @@ pub fn select_connection( let in_cooldown = time_since_last_switch < MIN_SWITCH_INTERVAL_MS; if let Some(last) = last_idx - && best_idx != Some(last) && in_cooldown { - // Check if last connection is still valid - let last_valid = - last < conns.len() && !conns[last].is_timed_out() && conns[last].connected; - if last_valid && conns[last].get_score() > 0 { - return Some(last); - } + && best_idx != Some(last) + && in_cooldown + { + // Check if last connection is still valid + let last_valid = last < conns.len() && !conns[last].is_timed_out() && conns[last].connected; + if last_valid && conns[last].get_score() > 0 { + return Some(last); } + } best_idx } From 2b4bd844677875d4f1ffe35c2b5a74c2d9fb4c66 Mon Sep 17 00:00:00 2001 From: Thomas Lekanger <thomas.lekanger@hotmail.com> Date: Thu, 12 Feb 2026 11:57:49 +0100 Subject: [PATCH 30/32] refactor: ensure injection thread panics are handled in random walk stability test --- tests/netns_scenario.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/netns_scenario.rs b/tests/netns_scenario.rs index 85fcc47..3611731 100644 --- a/tests/netns_scenario.rs +++ b/tests/netns_scenario.rs @@ -96,7 +96,7 @@ fn test_random_walk_stability() { } } - let _ = inject_handle.join(); + inject_handle.join().expect("injection thread panicked"); let output = stack.stop(); common::dump_output(&output); From c1c1df839fba82796c209b2d60b78901cd7d5d64 Mon Sep 17 00:00:00 2001 From: Thomas Lekanger <thomas.lekanger@hotmail.com> Date: Thu, 12 Feb 2026 11:58:28 +0100 Subject: [PATCH 31/32] refactor: fix formatting in UDP stream injection script --- crates/network-sim/src/harness.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/network-sim/src/harness.rs b/crates/network-sim/src/harness.rs index 9e5d75b..0cf06ab 100644 --- a/crates/network-sim/src/harness.rs +++ b/crates/network-sim/src/harness.rs @@ -552,7 +552,7 @@ pub fn inject_udp_stream( let dur_secs = duration.as_secs_f64(); let script = format!( - "import socket,time\ns=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)\nd=b'\\x00'*188\\nstart=time.time(); i=0\nwhile time.time()-start<{dur_secs}:\n\x20 \ + "import socket,time\ns=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)\nd=b'\\x00'*188\nstart=time.time(); i=0\nwhile time.time()-start<{dur_secs}:\n\x20 \ s.sendto(d,('{target_ip}',{port}))\n\x20 i+=1\n\x20 \ time.sleep({interval_us}/1e6)\ns.close()\nprint(f'sent {{i}} packets')" ); From d4e86fef999ce16c3c76a3376da5db3c48df2efe Mon Sep 17 00:00:00 2001 From: Thomas Lekanger <thomas.lekanger@hotmail.com> Date: Thu, 12 Feb 2026 12:00:35 +0100 Subject: [PATCH 32/32] smhhhhhhhhhhhhhhh --- crates/network-sim/src/harness.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/network-sim/src/harness.rs b/crates/network-sim/src/harness.rs index 0cf06ab..504ddb4 100644 --- a/crates/network-sim/src/harness.rs +++ b/crates/network-sim/src/harness.rs @@ -552,7 +552,8 @@ pub fn inject_udp_stream( let dur_secs = duration.as_secs_f64(); let script = format!( - "import socket,time\ns=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)\nd=b'\\x00'*188\nstart=time.time(); i=0\nwhile time.time()-start<{dur_secs}:\n\x20 \ + "import socket,time\ns=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)\nd=b'\\x00'*188\\ + nstart=time.time(); i=0\nwhile time.time()-start<{dur_secs}:\n\x20 \ s.sendto(d,('{target_ip}',{port}))\n\x20 i+=1\n\x20 \ time.sleep({interval_us}/1e6)\ns.close()\nprint(f'sent {{i}} packets')" );