From b25a1e03e702d413648a8b37457ec31461bbee05 Mon Sep 17 00:00:00 2001 From: Frando Date: Thu, 9 Jul 2026 23:37:44 +0200 Subject: [PATCH 1/5] feat(qdisc): add congestion-buffer and bursty-loss link knobs Add two LinkLimits fields for more realistic, more repeatable link emulation: - buffer_ms: sizes the tbf rate-limiter buffer (0 keeps the historical 400ms). Sizing it near the path RTT makes packet loss emerge from buffer overflow (congestion) as on a real bottleneck, instead of only from the netem loss knob. - loss_burst_pkts: switches loss_pct from independent per-packet (Bernoulli) loss to bursty Gilbert-Elliott loss (netem gemodel) of the given mean burst length, at the same long-run rate. Real links lose in bursts (fades, handovers), and clustered loss stresses congestion control differently than spread-out loss. Document both on the fields and point at them from the LinkCondition doc. --- patchbay/src/lab.rs | 13 ++++++++++-- patchbay/src/qdisc.rs | 48 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/patchbay/src/lab.rs b/patchbay/src/lab.rs index 3a2a4f5..e134215 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 { 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(()) From 73093e1c6b988e814c21dc8ec37db0f2b9e55343 Mon Sep 17 00:00:00 2001 From: Frando Date: Thu, 9 Jul 2026 23:41:29 +0200 Subject: [PATCH 2/5] feat(lab): make LinkCondition presets real-world-realistic Rework the last-mile presets to match measured 2024-2025 real-world figures, and use the new congestion-buffer and bursty-loss knobs so the emulated links behave like real ones (bounded bandwidth, an RTT-sized buffer that produces congestion loss, and bursty radio loss) rather than uncapped links with independent per-packet loss. - Wifi (good 5 GHz): 200 Mbit, 4 ms, 0.1 % bursty loss, 15 ms buffer. - WifiBad (congested 2.4 GHz): 25 Mbit, 25 ms, 15 ms jitter, 1.5 %. - Mobile4G: 40 Mbit, 25 ms one-way (~50 ms RTT), 0.3 %, 100 ms buffer. Was uncapped; LTE medians are ~30-100 Mbit at ~50 ms RTT. - Mobile3G: 3 Mbit, 80 ms, 0.5 %, 250 ms buffer (3G bufferbloats). - Satellite (LEO/Starlink): 100 Mbit, 22 ms one-way (~45 ms RTT), 0.5 %. Was 40 ms/1 %/uncapped; Starlink medians are now ~105 Mbit at ~45 ms. - SatelliteGeo: 25 Mbit, 300 ms one-way (~600 ms RTT), 0.3 %, 600 ms. - Lan stays unimpaired (gigabit, same-rack). Every rate cap used here (<= 200 Mbit) is reachable with the existing tbf burst. Downstream throughput assertions tuned to the old uncapped presets may need updating. --- patchbay/src/lab.rs | 58 +++++++++++++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 20 deletions(-) diff --git a/patchbay/src/lab.rs b/patchbay/src/lab.rs index e134215..ff1e2ca 100644 --- a/patchbay/src/lab.rs +++ b/patchbay/src/lab.rs @@ -105,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), @@ -167,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, From 99531a1df7d1467b34a41e85ad62142d47662f5c Mon Sep 17 00:00:00 2001 From: Frando Date: Fri, 10 Jul 2026 13:20:54 +0200 Subject: [PATCH 3/5] test: add the new LinkLimits fields to the exhaustive round-trip literal The `to_limits` round-trip test builds a `LinkLimits` with every field listed explicitly, so it does not compile once `buffer_ms` and `loss_burst_pkts` are added. Set both to non-default values so the test covers them too. --- patchbay/src/tests/link_condition.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/patchbay/src/tests/link_condition.rs b/patchbay/src/tests/link_condition.rs index 72fdf3b..d656601 100644 --- a/patchbay/src/tests/link_condition.rs +++ b/patchbay/src/tests/link_condition.rs @@ -952,6 +952,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); } From f3367e48c8469f3ece539274685a8b92f1920fdd Mon Sep 17 00:00:00 2001 From: Frando Date: Fri, 10 Jul 2026 13:20:54 +0200 Subject: [PATCH 4/5] chore: bump anyhow and crossbeam-epoch for RUSTSEC advisories cargo-deny flags two advisories against the locked dependencies, neither related to this change: RUSTSEC-2026-0190 (unsoundness in anyhow's `Error::downcast_mut`, fixed in 1.0.103) and RUSTSEC-2026-0204 (invalid pointer dereference in crossbeam-epoch's `fmt::Display`, fixed in 0.9.20). Bump both to the patched releases. --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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", ] From 3902a8258200e4a437f05f708de75e5d58a4f0d7 Mon Sep 17 00:00:00 2001 From: Frando Date: Fri, 10 Jul 2026 13:32:13 +0200 Subject: [PATCH 5/5] test: fix presets_rtt_and_loss for the realistic presets The preset one-way latencies moved with the realism rework, and the presets now use bursty Gilbert-Elliott loss, which concentrates drops into rare bursts. The loss smoke-test sent only 1000 packets, so at a few percent bursty loss it could see zero drops and fail spuriously (Mobile3G and Satellite did). Update the expected latency floors, restrict the loss check to the two presets whose rate fires reliably, and raise the sample to 5000 packets so the probability of seeing no loss is well under 0.1%. --- patchbay/src/tests/link_condition.rs | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/patchbay/src/tests/link_condition.rs b/patchbay/src/tests/link_condition.rs index d656601..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}" ); } }