diff --git a/Cargo.lock b/Cargo.lock index 85b834c..e090afc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -87,9 +87,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "asn1-rs" @@ -568,9 +568,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] diff --git a/patchbay/src/lab.rs b/patchbay/src/lab.rs index 3a2a4f5..ff1e2ca 100644 --- a/patchbay/src/lab.rs +++ b/patchbay/src/lab.rs @@ -83,10 +83,19 @@ pub enum LinkDirection { Ingress, } -/// Link-layer impairment profile applied via `tc netem`. +/// Link-layer impairment profile applied via `tc netem` (and `tc tbf` for a rate +/// cap). /// /// Named presets model common last-mile conditions. Use [`LinkCondition::Manual`] -/// with [`LinkLimits`] for full control over all `tc netem` parameters. +/// with [`LinkLimits`] for full control over all parameters. +/// +/// The presets model loss as independent per-packet (Bernoulli) loss on an +/// uncapped link (except the rate-limited ones). For a more faithful and more +/// repeatable link, [`LinkLimits`] additionally exposes +/// [`buffer_ms`](LinkLimits::buffer_ms) -- an RTT-sized rate-limiter buffer, so +/// loss emerges from congestion (buffer overflow) as on a real bottleneck -- and +/// [`loss_burst_pkts`](LinkLimits::loss_burst_pkts) -- bursty Gilbert-Elliott +/// loss instead of Bernoulli, matching how real links (fades, handovers) drop. #[derive(Clone, Copy, Debug, PartialEq, serde::Serialize)] #[serde(rename_all = "snake_case")] pub enum LinkCondition { @@ -96,27 +105,30 @@ pub enum LinkCondition { Lan, /// Good WiFi — 5 GHz band, close to AP, low contention. /// - /// 5 ms one-way delay, 2 ms jitter, 0.1 % loss. + /// 200 Mbit, 4 ms one-way delay, 2 ms jitter, 0.1 % bursty loss, 15 ms buffer. Wifi, /// Congested WiFi — 2.4 GHz, far from AP, interference. /// - /// 40 ms one-way delay, 15 ms jitter, 2 % loss, 20 Mbit. + /// 25 Mbit, 25 ms one-way delay, 15 ms jitter, 1.5 % bursty loss, 60 ms buffer. WifiBad, /// 4G/LTE good signal. /// - /// 25 ms one-way delay, 8 ms jitter, 0.5 % loss. + /// 40 Mbit, 25 ms one-way delay (~50 ms RTT), 10 ms jitter, 0.3 % bursty loss, + /// 100 ms buffer. Mobile4G, /// 3G or degraded 4G. /// - /// 100 ms one-way delay, 30 ms jitter, 2 % loss, 2 Mbit. + /// 3 Mbit, 80 ms one-way delay, 25 ms jitter, 0.5 % bursty loss, 250 ms buffer. Mobile3G, /// LEO satellite (Starlink-class). /// - /// 40 ms one-way delay, 7 ms jitter, 1 % loss. + /// 100 Mbit, 22 ms one-way delay (~45 ms RTT), 10 ms jitter, 0.5 % bursty + /// loss, 50 ms buffer. Satellite, /// GEO satellite (HughesNet/Viasat). /// - /// 300 ms one-way delay, 20 ms jitter, 0.5 % loss, 25 Mbit. + /// 25 Mbit, 300 ms one-way delay (~600 ms RTT), 20 ms jitter, 0.3 % bursty + /// loss, 600 ms buffer. SatelliteGeo, /// Fully custom impairment parameters. Manual(LinkLimits), @@ -158,42 +170,57 @@ impl LinkCondition { match self { LinkCondition::Lan => LinkLimits::default(), LinkCondition::Wifi => LinkLimits { - latency_ms: 5, + latency_ms: 4, jitter_ms: 2, loss_pct: 0.1, + loss_burst_pkts: 5, + rate_kbit: 200_000, + buffer_ms: 15, ..Default::default() }, LinkCondition::WifiBad => LinkLimits { - latency_ms: 40, + latency_ms: 25, jitter_ms: 15, - loss_pct: 2.0, - rate_kbit: 20_000, + loss_pct: 1.5, + loss_burst_pkts: 8, + rate_kbit: 25_000, + buffer_ms: 60, ..Default::default() }, LinkCondition::Mobile4G => LinkLimits { latency_ms: 25, - jitter_ms: 8, - loss_pct: 0.5, + jitter_ms: 10, + loss_pct: 0.3, + loss_burst_pkts: 6, + rate_kbit: 40_000, + buffer_ms: 100, ..Default::default() }, LinkCondition::Mobile3G => LinkLimits { - latency_ms: 100, - jitter_ms: 30, - loss_pct: 2.0, - rate_kbit: 2_000, + latency_ms: 80, + jitter_ms: 25, + loss_pct: 0.5, + loss_burst_pkts: 6, + rate_kbit: 3_000, + buffer_ms: 250, ..Default::default() }, LinkCondition::Satellite => LinkLimits { - latency_ms: 40, - jitter_ms: 7, - loss_pct: 1.0, + latency_ms: 22, + jitter_ms: 10, + loss_pct: 0.5, + loss_burst_pkts: 4, + rate_kbit: 100_000, + buffer_ms: 50, ..Default::default() }, LinkCondition::SatelliteGeo => LinkLimits { latency_ms: 300, jitter_ms: 20, - loss_pct: 0.5, + loss_pct: 0.3, + loss_burst_pkts: 4, rate_kbit: 25_000, + buffer_ms: 600, ..Default::default() }, LinkCondition::Manual(limits) => limits, diff --git a/patchbay/src/qdisc.rs b/patchbay/src/qdisc.rs index c9542e6..bfc35f0 100644 --- a/patchbay/src/qdisc.rs +++ b/patchbay/src/qdisc.rs @@ -34,6 +34,26 @@ pub struct LinkLimits { /// Bit-error corruption percentage (0.0–100.0). #[serde(deserialize_with = "coerce::f32_or_string")] pub corrupt_pct: f32, + /// tbf queue depth in milliseconds -- how long a packet may wait in the rate + /// limiter's buffer before it is dropped. Only applies when `rate_kbit > 0`. + /// `0` (the default) keeps the historical 400ms buffer. Sizing this near the + /// link's RTT makes packet loss emerge from buffer overflow (congestion), + /// as on a real bottleneck, rather than only from `loss_pct`. + #[serde(default, deserialize_with = "coerce::u32_or_string")] + pub buffer_ms: u32, + /// Mean loss-burst length, in packets, for the `loss_pct` random loss. + /// + /// `0` or `1` (the default) uses `tc netem`'s independent per-packet + /// (Bernoulli) loss -- `loss %`. A value `>= 2` switches to the + /// Gilbert-Elliott model (`loss gemodel`), a two-state (good/bad) Markov + /// chain that drops packets in bursts of this mean length while holding the + /// long-run loss rate at `loss_pct`. Real links (wifi fades, cellular + /// handovers) lose in bursts, and the same loss rate concentrated into + /// bursts hurts congestion control more than when it is spread out, so this + /// is the more faithful model; it is also more variable run to run, so keep + /// bursts short when repeatability matters. + #[serde(default, deserialize_with = "coerce::u32_or_string")] + pub loss_burst_pkts: u32, } /// Serde helpers that accept both native types and string representations. @@ -75,7 +95,7 @@ pub(crate) async fn apply_impair(ifname: &str, limits: LinkLimits) -> Result<()> let qdisc = Qdisc::new(ifname); qdisc.add_netem_root(limits).await?; if limits.rate_kbit > 0 { - qdisc.add_tbf(limits.rate_kbit).await?; + qdisc.add_tbf(limits.rate_kbit, limits.buffer_ms).await?; } Ok(()) } @@ -121,7 +141,23 @@ impl<'a> Qdisc<'a> { } if limits.loss_pct > 0.0 { args.push("loss".into()); - args.push(format!("{:.3}%", limits.loss_pct)); + if limits.loss_burst_pkts >= 2 { + // Gilbert-Elliott: a good state (no loss) and a bad state + // (always lose), so losses arrive in bursts. With `p` = good->bad + // and `r` = bad->good transition probabilities, the mean bad run + // (burst length) is 1/r and the long-run loss rate is p/(p+r). + // Invert for a target loss L and mean burst length B: + // r = 1/B, p = L / (B * (1 - L)). + let l = (limits.loss_pct as f64 / 100.0).clamp(0.0, 0.999); + let b = limits.loss_burst_pkts as f64; + let r = 100.0 / b; + let p = 100.0 * l / (b * (1.0 - l)); + args.push("gemodel".into()); + args.push(format!("{p:.4}%")); + args.push(format!("{r:.4}%")); + } else { + args.push(format!("{:.3}%", limits.loss_pct)); + } } if limits.reorder_pct > 0.0 { args.push("reorder".into()); @@ -142,7 +178,11 @@ impl<'a> Qdisc<'a> { Ok(()) } - async fn add_tbf(&self, rate_kbit: u32) -> Result<()> { + async fn add_tbf(&self, rate_kbit: u32, buffer_ms: u32) -> Result<()> { + // `buffer_ms = 0` keeps the historical 400ms buffer; a smaller value + // makes the rate limiter drop on overflow near one RTT, so loss emerges + // from congestion as on a real bottleneck link. + let latency = if buffer_ms == 0 { 400 } else { buffer_ms }; let mut cmd = Command::new("tc"); cmd.args([ "qdisc", @@ -159,7 +199,7 @@ impl<'a> Qdisc<'a> { "burst", "32kbit", "latency", - "400ms", + &format!("{}ms", latency), ]); ensure_success(cmd, "tc qdisc tbf add").await?; Ok(()) diff --git a/patchbay/src/tests/link_condition.rs b/patchbay/src/tests/link_condition.rs index 72fdf3b..b79a49d 100644 --- a/patchbay/src/tests/link_condition.rs +++ b/patchbay/src/tests/link_condition.rs @@ -858,13 +858,18 @@ async fn latency_dynamic_add_remove() -> Result<()> { #[tokio::test(flavor = "current_thread")] #[traced_test] async fn presets_rtt_and_loss() -> Result<()> { + // (preset, min one-way latency ms, loss % to smoke-test). The presets now + // use bursty Gilbert-Elliott loss, which concentrates drops into rare bursts, + // so a small sample can see zero loss even at a few percent. We only + // smoke-test loss on the two presets whose rate is high enough to fire + // reliably over the sample below, and use a large sample for those. let cases: Vec<(LinkCondition, u64, f32)> = vec![ (LinkCondition::Lan, 0, 0.0), - (LinkCondition::Wifi, 5, 0.0), - (LinkCondition::WifiBad, 40, 2.0), + (LinkCondition::Wifi, 4, 0.0), + (LinkCondition::WifiBad, 25, 1.5), (LinkCondition::Mobile4G, 25, 0.0), - (LinkCondition::Mobile3G, 100, 2.0), - (LinkCondition::Satellite, 40, 1.0), + (LinkCondition::Mobile3G, 80, 2.0), + (LinkCondition::Satellite, 22, 0.0), (LinkCondition::SatelliteGeo, 300, 0.0), ]; let mut port_base = 19_100u16; @@ -889,14 +894,17 @@ async fn presets_rtt_and_loss() -> Result<()> { bail!("preset {preset:?}: expected RTT ≥ {min_latency_ms}ms, got {rtt:?}"); } if loss_pct > 0.0 { + // Large enough that bursty loss at ~1.5% or more reliably drops at + // least one packet (P(zero loss) is well under 0.1%). + let total = 5000usize; let (_, received) = dev .spawn(move |_| async move { - test_utils::udp_send_recv_count(r, 1000, 64, Duration::from_secs(5)).await + test_utils::udp_send_recv_count(r, total, 64, Duration::from_secs(8)).await })? .await??; - if received == 1000 { + if received == total { bail!( - "preset {preset:?}: expected some loss at {loss_pct}%, got {received}/1000" + "preset {preset:?}: expected some loss at {loss_pct}%, got {received}/{total}" ); } } @@ -952,6 +960,8 @@ fn presets_to_limits_roundtrip() { reorder_pct: 1.0, duplicate_pct: 0.5, corrupt_pct: 0.1, + buffer_ms: 200, + loss_burst_pkts: 4, }; assert_eq!(LinkCondition::Manual(custom).to_limits(), custom); }