From 220e744d080e492cc90577af30d631134c9da2dd 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 3def4bd..83a2001 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 cbe4bc005d3c064cb4ae311bc641200b1eb1974d 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 83a2001..6596727 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 34bd08ee75ab6791d8c921e6566a5d8e602b312f 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 215dc95d39dc197672d85c9121d124922375d443 Mon Sep 17 00:00:00 2001 From: Frando Date: Fri, 10 Jul 2026 13:32:13 +0200 Subject: [PATCH 4/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}" ); } } From 0627d0f204321e848389bcc4782e8ca284697eca Mon Sep 17 00:00:00 2001 From: Frando Date: Thu, 23 Jul 2026 14:42:19 +0200 Subject: [PATCH 5/5] refactor!: merge LinkLimits into LinkCondition with presets and setters Setting up a realistic link previously meant knowing the numbers: a LinkCondition preset enum for the named cases, and a separate LinkLimits struct with nine raw fields for anything custom, reached through LinkCondition::Manual. Expressing "a slow 40 Mbit link" meant hand-writing a struct literal and knowing that the buffer should track the RTT and that bursty loss is a Gilbert-Elliott burst length. Collapse the two types into one struct, LinkCondition, built either from a preset constructor or from an unimpaired baseline plus chainable setters: LinkCondition::mobile_4g() LinkCondition::new().rate_mbit(40).rtt_ms(50) LinkCondition::wifi().bursty_loss(5.0) The intent-level helpers carry the domain knowledge so callers do not have to. `rate_mbit` and `rtt_ms` take the two numbers people actually know; `rtt_ms` also sizes the rate-limiter buffer to one RTT, which is what makes loss emerge from congestion on a capped link. `bursty_loss` and `random_loss` name the loss model instead of exposing the burst count. `rate_kbit`, `loss_burst_pkts`, and `buffer_ms` are now `Option`, so unset is distinct from zero: an unset buffer means the default, not a zero-length one, and the "0 means unlimited" overload on the rate is gone. Presets are `const fn`, so const arrays of conditions still work. The struct is `#[non_exhaustive]` so future fields do not break downstream literals. A `label` field carries the preset name into events and the devtools UI, and is excluded from equality via `derive_more`'s partial_eq skip so a preset compares equal to the same values built by hand. TOML is unchanged for the common cases: `condition = "wifi"` and a table of fields both still parse. Breaking changes: - `LinkLimits` is removed; use `LinkCondition` directly. - The `LinkCondition` preset variants (`Wifi`, `Mobile4G`, ...) become constructors (`wifi()`, `mobile_4g()`, ...); `LinkCondition::Manual(..)` is gone, replaced by the setters. - `LinkCondition::to_limits` is removed; a `LinkCondition` is applied directly. - `rate_kbit`, `loss_burst_pkts`, and `buffer_ms` change type to `Option`. - `LinkCondition` is `#[non_exhaustive]`. - The serialized wire format is now one flat object; the preset-string and `{manual: ..}` output forms are gone. The TypeScript bindings match. - `Lan` is no longer unimpaired: `lan()` adds a 1 ms one-way delay. Use `LinkCondition::new()` for a truly unimpaired link. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 16 +- docs/guide/getting-started.md | 2 +- docs/guide/running-code.md | 14 +- docs/guide/topology.md | 44 +- docs/limitations.md | 2 +- docs/reference/patterns.md | 21 +- patchbay/Cargo.toml | 2 +- patchbay/src/lab.rs | 165 +------ patchbay/src/lib.rs | 16 +- patchbay/src/nft.rs | 2 +- patchbay/src/qdisc.rs | 648 ++++++++++++++++++++++++--- patchbay/src/tests/iface.rs | 19 +- patchbay/src/tests/link_condition.rs | 294 +++--------- patchbay/src/tests/region.rs | 8 +- ui/src/components/NodeDetail.tsx | 26 +- ui/src/devtools-types.ts | 29 +- 16 files changed, 762 insertions(+), 546 deletions(-) diff --git a/README.md b/README.md index 2db2bea..0ca33c3 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ let dev = lab .iface("eth0", home.id()) .build() .await?; -dev.iface("eth0").unwrap().set_condition(LinkCondition::Wifi, LinkDirection::Both).await?; +dev.iface("eth0").unwrap().set_condition(LinkCondition::wifi(), LinkDirection::Both).await?; // A server in the datacenter. let server = lab @@ -191,7 +191,7 @@ and `CaptivePortal` (block non-web UDP). All presets expand to a ### Link conditions `tc netem` and `tc tbf` provide packet loss, latency, jitter, and rate -limiting. Apply presets (`LinkCondition::Wifi`, `LinkCondition::Mobile4G`) +limiting. Apply presets (`LinkCondition::wifi()`, `LinkCondition::mobile_4g()`) or custom values at build time or dynamically. ### Cleanup @@ -229,7 +229,7 @@ let dev = lab.add_device("phone") .iface("eth0", dc.id()) .default_via("wlan0") .build().await?; -dev.iface("wlan0").unwrap().set_condition(LinkCondition::Wifi, LinkDirection::Both).await?; +dev.iface("wlan0").unwrap().set_condition(LinkCondition::wifi(), LinkDirection::Both).await?; ``` ### Running code in namespaces @@ -282,12 +282,10 @@ dev.iface("wlan0").unwrap().link_down().await?; dev.iface("wlan0").unwrap().link_up().await?; // Change link condition dynamically. -dev.iface("wlan0").unwrap().set_condition(LinkCondition::Manual(LinkLimits { - rate_kbit: 1000, - loss_pct: 5.0, - latency_ms: 100, - ..Default::default() -}), LinkDirection::Both).await?; +dev.iface("wlan0").unwrap().set_condition( + LinkCondition::new().rate_kbit(1000).loss_pct(5.0).latency_ms(100), + LinkDirection::Both, +).await?; // Change NAT mode at runtime. router.set_nat_mode(Nat::Corporate).await?; diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 01860e2..5f13c39 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -140,7 +140,7 @@ let laptop = lab .iface("eth0", home.id()) .build() .await?; -laptop.iface("eth0").unwrap().set_condition(LinkCondition::Wifi, LinkDirection::Both).await?; +laptop.iface("eth0").unwrap().set_condition(LinkCondition::wifi(), LinkDirection::Both).await?; ``` At this point you have five network namespaces — the IX root, two diff --git a/docs/guide/running-code.md b/docs/guide/running-code.md index 57d1d01..efb92c3 100644 --- a/docs/guide/running-code.md +++ b/docs/guide/running-code.md @@ -169,18 +169,16 @@ Modify link impairment on the fly to simulate degrading or improving network quality: ```rust -use patchbay::{LinkCondition, LinkDirection, LinkLimits}; +use patchbay::{LinkCondition, LinkDirection}; // Switch to a 3G-like link. -dev.iface("wlan0").unwrap().set_condition(LinkCondition::Mobile3G, LinkDirection::Both).await?; +dev.iface("wlan0").unwrap().set_condition(LinkCondition::mobile_3g(), LinkDirection::Both).await?; // Apply custom impairment. -dev.iface("wlan0").unwrap().set_condition(LinkCondition::Manual(LinkLimits { - rate_kbit: 500, - loss_pct: 15.0, - latency_ms: 200, - ..Default::default() -}), LinkDirection::Both).await?; +dev.iface("wlan0").unwrap().set_condition( + LinkCondition::new().rate_kbit(500).loss_pct(15.0).latency_ms(200), + LinkDirection::Both, +).await?; // Remove all impairment and return to a clean link. dev.iface("wlan0").unwrap().clear_condition(LinkDirection::Both).await?; diff --git a/docs/guide/topology.md b/docs/guide/topology.md index 2427390..cb51141 100644 --- a/docs/guide/topology.md +++ b/docs/guide/topology.md @@ -141,8 +141,8 @@ let phone = lab .default_via("wlan0") .build() .await?; -phone.iface("wlan0").unwrap().set_condition(LinkCondition::Wifi, LinkDirection::Both).await?; -phone.iface("cell0").unwrap().set_condition(LinkCondition::Mobile4G, LinkDirection::Both).await?; +phone.iface("wlan0").unwrap().set_condition(LinkCondition::wifi(), LinkDirection::Both).await?; +phone.iface("cell0").unwrap().set_condition(LinkCondition::mobile_4g(), LinkDirection::Both).await?; ``` The `.default_via("wlan0")` call sets which interface carries the default @@ -166,10 +166,10 @@ The built-in presets model common access technologies: | Preset | Loss | Latency | Jitter | Rate | |--------|------|---------|--------|------| -| `Wifi` | 2% | 5 ms | 1 ms | 54 Mbit/s | -| `Mobile4G` | 1% | 30 ms | 10 ms | 50 Mbit/s | -| `Mobile3G` | 3% | 100 ms | 30 ms | 2 Mbit/s | -| `Satellite` | 0.5% | 600 ms | 50 ms | 10 Mbit/s | +| `wifi()` | 2% | 5 ms | 1 ms | 54 Mbit/s | +| `mobile_4g()` | 1% | 30 ms | 10 ms | 50 Mbit/s | +| `mobile_3g()` | 3% | 100 ms | 30 ms | 2 Mbit/s | +| `satellite()` | 0.5% | 600 ms | 50 ms | 10 Mbit/s | Apply a preset after building the device: @@ -177,24 +177,19 @@ Apply a preset after building the device: let dev = lab.add_device("laptop") .iface("eth0", home.id()) .build().await?; -dev.iface("eth0").unwrap().set_condition(LinkCondition::Wifi, LinkDirection::Both).await?; +dev.iface("eth0").unwrap().set_condition(LinkCondition::wifi(), LinkDirection::Both).await?; ``` ### Custom parameters -When the presets do not match your scenario, build a `LinkLimits` struct -directly: +When the presets do not match your scenario, chain setters onto +`LinkCondition::new()`. For a link defined by bandwidth and round-trip +time, `rate_mbit` and `rtt_ms` cover the common case directly: ```rust -use patchbay::{LinkCondition, LinkLimits}; +use patchbay::{LinkCondition, LinkDirection}; -let degraded = LinkCondition::Manual(LinkLimits { - rate_kbit: 1000, // 1 Mbit/s - loss_pct: 10.0, // 10% packet loss - latency_ms: 50, // 50 ms one-way delay - jitter_ms: 20, // 20 ms jitter - ..Default::default() -}); +let degraded = LinkCondition::new().rate_mbit(1).rtt_ms(100).loss_pct(10.0); let dev = lab.add_device("laptop") .iface("eth0", home.id()) @@ -202,6 +197,19 @@ let dev = lab.add_device("laptop") dev.iface("eth0").unwrap().set_condition(degraded, LinkDirection::Both).await?; ``` +`rtt_ms` sets the one-way latency to half the given value and, if you have +not set a buffer explicitly, sizes the rate-limiter buffer to hold a full +RTT of data. When you need to control latency and jitter independently +instead, set the fields directly: + +```rust +let degraded = LinkCondition::new() + .rate_kbit(1000) // 1 Mbit/s + .loss_pct(10.0) // 10% packet loss + .latency_ms(50) // 50 ms one-way delay + .jitter_ms(20); // 20 ms jitter +``` + ### Runtime changes You can change or remove link conditions at any point after the topology @@ -210,7 +218,7 @@ for example switching from WiFi to a congested 3G link and verifying that your application adapts: ```rust -dev.iface("eth0").unwrap().set_condition(LinkCondition::Mobile3G, LinkDirection::Both).await?; +dev.iface("eth0").unwrap().set_condition(LinkCondition::mobile_3g(), LinkDirection::Both).await?; // Later, restore a clean link. dev.iface("eth0").unwrap().clear_condition(LinkDirection::Both).await?; diff --git a/docs/limitations.md b/docs/limitations.md index 6a14843..b683193 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -60,7 +60,7 @@ settings that may need adjustment. patchbay models link effects with `tc` parameters: latency, jitter, loss, and rate limits. It does not model WiFi or cellular PHY/MAC behavior such as radio scheduling, channel contention, or handover -signaling. The link condition presets (`Wifi`, `Mobile4G`, etc.) apply +signaling. The link condition presets (`wifi()`, `mobile_4g()`, etc.) apply realistic impairment at the IP layer, which is sufficient for transport and application resilience testing but not for radio-layer research. diff --git a/docs/reference/patterns.md b/docs/reference/patterns.md index a681993..b09f379 100644 --- a/docs/reference/patterns.md +++ b/docs/reference/patterns.md @@ -189,13 +189,13 @@ let cell_router = lab.add_router("cell").nat(Nat::Cgnat).build().await?; let device = lab.add_device("phone") .iface("eth0", wifi_router.id()) .build().await?; -device.iface("eth0").unwrap().set_condition(LinkCondition::Wifi, LinkDirection::Both).await?; +device.iface("eth0").unwrap().set_condition(LinkCondition::wifi(), LinkDirection::Both).await?; // Simulate handoff with connectivity gap device.iface("eth0").unwrap().link_down().await?; tokio::time::sleep(Duration::from_millis(500)).await; device.iface("eth0").unwrap().replug(cell_router.id()).await?; -device.iface("eth0").unwrap().set_condition(LinkCondition::Mobile4G, LinkDirection::Both).await?; +device.iface("eth0").unwrap().set_condition(LinkCondition::mobile_4g(), LinkDirection::Both).await?; device.iface("eth0").unwrap().link_up().await?; // Assert: application reconnects within X seconds @@ -236,17 +236,14 @@ calls, each direction is limited by the sender's upload. // 20 Mbps down, 2 Mbps up (10:1 ratio) let router = lab.add_router("isp") .nat(Nat::Home) - .downlink_condition(LinkCondition::Manual(LinkLimits { - rate_kbit: 20_000, - ..Default::default() - })) + .downlink_condition(LinkCondition::new().rate_mbit(20)) .build().await?; let device = lab.add_device("client").uplink(router.id()).build().await?; -device.iface("eth0").unwrap().set_condition(LinkCondition::Manual(LinkLimits { - rate_kbit: 2_000, - ..Default::default() -}), LinkDirection::Both).await?; +device.iface("eth0").unwrap().set_condition( + LinkCondition::new().rate_mbit(2), + LinkDirection::Both, +).await?; ``` --- @@ -337,9 +334,9 @@ Network conditions worsen over time (moving away from WiFi AP, entering tunnel on cellular, weather affecting satellite). ```rust -device.iface("eth0").unwrap().set_condition(LinkCondition::Wifi, LinkDirection::Both).await?; +device.iface("eth0").unwrap().set_condition(LinkCondition::wifi(), LinkDirection::Both).await?; tokio::time::sleep(Duration::from_secs(5)).await; -device.iface("eth0").unwrap().set_condition(LinkCondition::WifiBad, LinkDirection::Both).await?; +device.iface("eth0").unwrap().set_condition(LinkCondition::wifi_bad(), LinkDirection::Both).await?; tokio::time::sleep(Duration::from_secs(5)).await; device.iface("eth0").unwrap().clear_condition(LinkDirection::Both).await?; // remove impairment ``` diff --git a/patchbay/Cargo.toml b/patchbay/Cargo.toml index 0917d13..d7407f2 100644 --- a/patchbay/Cargo.toml +++ b/patchbay/Cargo.toml @@ -15,7 +15,7 @@ iroh-metrics = ["dep:iroh-metrics"] iroh-metrics = { version = "1.0.0", optional = true } anyhow = "1" chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } -derive_more = { version = "2.1.1", features = ["debug", "display"] } +derive_more = { version = "2.1.1", features = ["debug", "display", "eq"] } futures = "0.3" ipnet = { version = "2.11", features = ["serde"] } libc = "0.2" diff --git a/patchbay/src/lab.rs b/patchbay/src/lab.rs index 6596727..98cdf0f 100644 --- a/patchbay/src/lab.rs +++ b/patchbay/src/lab.rs @@ -18,7 +18,7 @@ use serde::Deserialize; use tokio_util::sync::CancellationToken; use tracing::{debug, debug_span}; -pub use crate::qdisc::LinkLimits; +pub use crate::qdisc::LinkCondition; use crate::{ config::LabConfig, core::{ @@ -83,151 +83,6 @@ pub enum LinkDirection { Ingress, } -/// 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 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 { - /// Wired LAN (1G Ethernet). No impairment. - /// - /// Use for datacenter-local, same-rack communication. - Lan, - /// Good WiFi — 5 GHz band, close to AP, low contention. - /// - /// 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. - /// - /// 25 Mbit, 25 ms one-way delay, 15 ms jitter, 1.5 % bursty loss, 60 ms buffer. - WifiBad, - /// 4G/LTE good signal. - /// - /// 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. - /// - /// 3 Mbit, 80 ms one-way delay, 25 ms jitter, 0.5 % bursty loss, 250 ms buffer. - Mobile3G, - /// LEO satellite (Starlink-class). - /// - /// 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). - /// - /// 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), -} - -impl<'de> Deserialize<'de> for LinkCondition { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - #[derive(Deserialize)] - #[serde(untagged)] - enum Repr { - Preset(String), - Manual(LinkLimits), - } - - match Repr::deserialize(deserializer)? { - Repr::Preset(s) => match s.as_str() { - "lan" => Ok(LinkCondition::Lan), - "wifi" => Ok(LinkCondition::Wifi), - "wifi-bad" => Ok(LinkCondition::WifiBad), - "mobile-4g" | "mobile" => Ok(LinkCondition::Mobile4G), - "mobile-3g" => Ok(LinkCondition::Mobile3G), - "satellite" => Ok(LinkCondition::Satellite), - "satellite-geo" => Ok(LinkCondition::SatelliteGeo), - _ => Err(serde::de::Error::custom(format!( - "unknown link condition preset '{s}'" - ))), - }, - Repr::Manual(limits) => Ok(LinkCondition::Manual(limits)), - } - } -} - -impl LinkCondition { - /// Converts this preset (or manual config) into concrete [`LinkLimits`]. - pub fn to_limits(self) -> LinkLimits { - match self { - LinkCondition::Lan => LinkLimits::default(), - LinkCondition::Wifi => LinkLimits { - 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: 25, - jitter_ms: 15, - 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: 10, - loss_pct: 0.3, - loss_burst_pkts: 6, - rate_kbit: 40_000, - buffer_ms: 100, - ..Default::default() - }, - LinkCondition::Mobile3G => LinkLimits { - 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: 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.3, - loss_burst_pkts: 4, - rate_kbit: 25_000, - buffer_ms: 600, - ..Default::default() - }, - LinkCondition::Manual(limits) => limits, - } - } -} - // ───────────────────────────────────────────── // Region // ───────────────────────────────────────────── @@ -1477,17 +1332,13 @@ impl Lab { // Apply netem impairment on both veth ends. if link.latency_ms > 0 || link.jitter_ms > 0 || link.loss_pct > 0.0 { - let limits = LinkLimits { - latency_ms: link.latency_ms, - jitter_ms: link.jitter_ms, - loss_pct: link.loss_pct as f32, - rate_kbit: if link.rate_mbit > 0 { - link.rate_mbit * 1000 - } else { - 0 - }, - ..Default::default() - }; + let mut limits = LinkCondition::new() + .latency_ms(link.latency_ms) + .jitter_ms(link.jitter_ms) + .loss_pct(link.loss_pct as f32); + if link.rate_mbit > 0 { + limits = limits.rate_mbit(link.rate_mbit); + } let veth_a4 = veth_a.clone(); let limits_a = limits; let rt_a = netns.rt_handle_for(&s.a.ns)?; diff --git a/patchbay/src/lib.rs b/patchbay/src/lib.rs index 45ec98d..a24860c 100644 --- a/patchbay/src/lib.rs +++ b/patchbay/src/lib.rs @@ -120,19 +120,17 @@ //! // Switch from WiFi to a degraded 3G link at runtime. //! dev.iface("wlan0") //! .unwrap() -//! .set_condition(LinkCondition::Mobile3G, LinkDirection::Both) +//! .set_condition(LinkCondition::mobile_3g(), LinkDirection::Both) //! .await?; //! //! // Or use fully custom parameters. //! dev.iface("wlan0") //! .unwrap() //! .set_condition( -//! LinkCondition::Manual(LinkLimits { -//! latency_ms: 200, -//! loss_pct: 5.0, -//! rate_kbit: 500, -//! ..Default::default() -//! }), +//! LinkCondition::new() +//! .latency_ms(200) +//! .loss_pct(5.0) +//! .rate_kbit(500), //! LinkDirection::Both, //! ) //! .await?; @@ -242,8 +240,8 @@ pub use ipnet::Ipv4Net; pub use lab::{ ConntrackTimeouts, DefaultRegions, Firewall, FirewallConfig, FirewallConfigBuilder, IpSupport, Ipv6DadMode, Ipv6Profile, Ipv6ProvisioningMode, Ix, Lab, LabBuilder, LinkCondition, - LinkDirection, LinkLimits, Nat, NatConfig, NatConfigBuilder, NatFiltering, NatMapping, - NatV6Mode, OutDir, Region, RegionLink, TestGuard, + LinkDirection, Nat, NatConfig, NatConfigBuilder, NatFiltering, NatMapping, NatV6Mode, OutDir, + Region, RegionLink, TestGuard, }; pub use metrics::MetricsBuilder; pub use router::{Router, RouterBuilder, RouterIface, RouterPreset}; diff --git a/patchbay/src/nft.rs b/patchbay/src/nft.rs index 8938859..6be4df6 100644 --- a/patchbay/src/nft.rs +++ b/patchbay/src/nft.rs @@ -398,7 +398,7 @@ pub(crate) async fn apply_impair_in( impair: LinkCondition, ) { debug!(ns = %ns, ifname = %ifname, impair = ?impair, "tc: apply impairment"); - let limits = impair.to_limits(); + let limits = impair; let ifname = ifname.to_string(); let rt = match netns.rt_handle_for(ns) { Ok(rt) => rt, diff --git a/patchbay/src/qdisc.rs b/patchbay/src/qdisc.rs index bfc35f0..267957c 100644 --- a/patchbay/src/qdisc.rs +++ b/patchbay/src/qdisc.rs @@ -1,59 +1,410 @@ use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; use tokio::process::Command; /// Max retries for transient EAGAIN (os error 11) when spawning `tc` commands. const SPAWN_RETRIES: u32 = 3; -/// Parameters for `tc netem` impairment. +/// Rate-limiter queue depth used when [`LinkCondition::buffer_ms`] is unset. +const DEFAULT_BUFFER_MS: u32 = 400; + +/// Mean burst length selected by [`LinkCondition::bursty_loss`]. +const DEFAULT_LOSS_BURST_PKTS: u32 = 5; + +/// Link impairment profile, applied with `tc netem` and, when a rate cap is +/// set, `tc tbf`. /// -/// All fields default to zero (no impairment). Set only the fields you need. -/// Fields accept both native TOML types and string representations -/// (e.g. `latency_ms = 200` and `latency_ms = "200"` are equivalent). -/// This enables matrix variable substitution in sim TOML files. -#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] -#[serde(default)] -pub struct LinkLimits { - /// Rate limit in kbit/s (0 = unlimited). - #[serde(deserialize_with = "coerce::u32_or_string")] - pub rate_kbit: u32, - /// Packet loss percentage (0.0–100.0). - #[serde(deserialize_with = "coerce::f32_or_string")] - pub loss_pct: f32, +/// Start from a named preset such as [`wifi`](Self::wifi) or +/// [`mobile_4g`](Self::mobile_4g), or from an unimpaired link with +/// [`new`](Self::new), then refine with the chainable setters. Every setter +/// takes and returns `self`, and when two setters touch the same field the +/// later call wins. +/// +/// Two numbers describe most real links: a rate cap and a round-trip time. +/// [`rate_mbit`](Self::rate_mbit) and [`rtt_ms`](Self::rtt_ms) express both +/// without requiring any knowledge of the underlying `netem` and `tbf` knobs. +/// `rtt_ms` also sizes the rate-limiter buffer, which is what makes packet loss +/// emerge from congestion the way it does on a real bottleneck. +/// +/// In TOML a link condition is either a preset name (`condition = "wifi"`) or a +/// table of these fields. Numeric fields also accept string representations +/// (`latency_ms = 200` and `latency_ms = "200"` are equivalent) so sim files can +/// substitute matrix variables. +/// +/// # Examples +/// +/// ``` +/// # use patchbay::LinkCondition; +/// // A named last-mile profile. +/// let mobile = LinkCondition::mobile_4g(); +/// +/// // A 40 Mbit link with a 50 ms round trip, no netem knowledge required. +/// let bottleneck = LinkCondition::new().rate_mbit(40).rtt_ms(50); +/// +/// // A preset with a single property changed; everything else stays put. +/// let lossy = LinkCondition::wifi().bursty_loss(5.0); +/// ``` +#[derive(Debug, Clone, Copy, Default, derive_more::PartialEq, Serialize)] +#[non_exhaustive] +pub struct LinkCondition { + /// Rate cap in kbit/s. `None` leaves the link uncapped. + pub rate_kbit: Option, /// One-way latency in milliseconds. - #[serde(deserialize_with = "coerce::u32_or_string")] pub latency_ms: u32, - /// Jitter in milliseconds (uniform ±jitter around latency). - #[serde(deserialize_with = "coerce::u32_or_string")] + /// Jitter in milliseconds, applied as a uniform spread around the latency. pub jitter_ms: u32, - /// Packet reordering percentage (0.0–100.0). - #[serde(deserialize_with = "coerce::f32_or_string")] + /// Packet loss percentage, from 0.0 to 100.0. + pub loss_pct: f32, + /// Mean loss-burst length in packets. + /// + /// `None` selects `netem`'s independent per-packet (Bernoulli) loss. + /// `Some(n)` with `n >= 2` selects the Gilbert-Elliott model, a two-state + /// Markov chain that drops packets in bursts of mean length `n` while + /// holding the long-run rate at `loss_pct`. Real links lose in bursts (wifi + /// fades, cellular handovers), and clustered loss stresses congestion + /// control more than the same rate spread evenly. Prefer + /// [`bursty_loss`](Self::bursty_loss) and [`random_loss`](Self::random_loss) + /// over setting this directly. + pub loss_burst_pkts: Option, + /// Rate-limiter queue depth in milliseconds. + /// + /// Bounds how long a packet may wait in the `tbf` buffer before it is + /// dropped, and applies only when `rate_kbit` is set. `None` selects the + /// historical 400 ms buffer. Sizing it near the link's round-trip time makes + /// loss emerge from buffer overflow, as on a real bottleneck, rather than + /// only from `loss_pct`; [`rtt_ms`](Self::rtt_ms) does that for you. + pub buffer_ms: Option, + /// Packet reordering percentage, from 0.0 to 100.0. pub reorder_pct: f32, - /// Packet duplication percentage (0.0–100.0). - #[serde(deserialize_with = "coerce::f32_or_string")] + /// Packet duplication percentage, from 0.0 to 100.0. pub duplicate_pct: f32, - /// Bit-error corruption percentage (0.0–100.0). - #[serde(deserialize_with = "coerce::f32_or_string")] + /// Bit-error corruption percentage, from 0.0 to 100.0. 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. + /// Human-readable name, set by the preset constructors. /// - /// `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, + /// Carried into lab events and the devtools timeline so a link reads as + /// "wifi" rather than as a row of numbers. Setters preserve it, so a tweaked + /// preset keeps the name it started from and the UI shows the name next to + /// the effective values. Excluded from equality, and never read back from + /// TOML. + #[partial_eq(skip)] + pub label: Option<&'static str>, +} + +impl LinkCondition { + /// Creates an unimpaired link: uncapped, no latency, no loss. + pub const fn new() -> Self { + Self { + rate_kbit: None, + latency_ms: 0, + jitter_ms: 0, + loss_pct: 0.0, + loss_burst_pkts: None, + buffer_ms: None, + reorder_pct: 0.0, + duplicate_pct: 0.0, + corrupt_pct: 0.0, + label: None, + } + } + + /// Creates a wired LAN link. + /// + /// 1 ms one-way delay (2 ms round trip), uncapped, no loss. Models + /// same-rack or same-building Ethernet, where the switch hop is the only + /// meaningful impairment. + pub const fn lan() -> Self { + Self { + latency_ms: 1, + label: Some("lan"), + ..Self::new() + } + } + + /// Creates a good WiFi link, 5 GHz and close to the access point. + /// + /// 200 Mbit, 4 ms one-way delay (8 ms round trip), 2 ms jitter, 0.1 % + /// bursty loss, 15 ms buffer. + pub const fn wifi() -> Self { + Self { + rate_kbit: Some(200_000), + latency_ms: 4, + jitter_ms: 2, + loss_pct: 0.1, + loss_burst_pkts: Some(5), + buffer_ms: Some(15), + label: Some("wifi"), + ..Self::new() + } + } + + /// Creates a congested WiFi link, 2.4 GHz with interference. + /// + /// 25 Mbit, 25 ms one-way delay, 15 ms jitter, 1.5 % bursty loss, 60 ms + /// buffer. + pub const fn wifi_bad() -> Self { + Self { + rate_kbit: Some(25_000), + latency_ms: 25, + jitter_ms: 15, + loss_pct: 1.5, + loss_burst_pkts: Some(8), + buffer_ms: Some(60), + label: Some("wifi-bad"), + ..Self::new() + } + } + + /// Creates a 4G/LTE link with good signal. + /// + /// 40 Mbit, 25 ms one-way delay (50 ms round trip), 10 ms jitter, 0.3 % + /// bursty loss, 100 ms buffer. + pub const fn mobile_4g() -> Self { + Self { + rate_kbit: Some(40_000), + latency_ms: 25, + jitter_ms: 10, + loss_pct: 0.3, + loss_burst_pkts: Some(6), + buffer_ms: Some(100), + label: Some("mobile-4g"), + ..Self::new() + } + } + + /// Creates a 3G or degraded 4G link. + /// + /// 3 Mbit, 80 ms one-way delay, 25 ms jitter, 0.5 % bursty loss, 250 ms + /// buffer. The deep buffer models the bufferbloat typical of 3G. + pub const fn mobile_3g() -> Self { + Self { + rate_kbit: Some(3_000), + latency_ms: 80, + jitter_ms: 25, + loss_pct: 0.5, + loss_burst_pkts: Some(6), + buffer_ms: Some(250), + label: Some("mobile-3g"), + ..Self::new() + } + } + + /// Creates a low-earth-orbit satellite link, Starlink class. + /// + /// 100 Mbit, 22 ms one-way delay (45 ms round trip), 10 ms jitter, 0.5 % + /// bursty loss, 50 ms buffer. + pub const fn satellite() -> Self { + Self { + rate_kbit: Some(100_000), + latency_ms: 22, + jitter_ms: 10, + loss_pct: 0.5, + loss_burst_pkts: Some(4), + buffer_ms: Some(50), + label: Some("satellite"), + ..Self::new() + } + } + + /// Creates a geostationary satellite link, HughesNet or Viasat class. + /// + /// 25 Mbit, 300 ms one-way delay (600 ms round trip), 20 ms jitter, 0.3 % + /// bursty loss, 600 ms buffer. + pub const fn satellite_geo() -> Self { + Self { + rate_kbit: Some(25_000), + latency_ms: 300, + jitter_ms: 20, + loss_pct: 0.3, + loss_burst_pkts: Some(4), + buffer_ms: Some(600), + label: Some("satellite-geo"), + ..Self::new() + } + } + + /// Returns the preset matching a TOML preset name, or `None` if unknown. + pub fn from_preset_name(name: &str) -> Option { + match name { + "lan" => Some(Self::lan()), + "wifi" => Some(Self::wifi()), + "wifi-bad" => Some(Self::wifi_bad()), + "mobile-4g" | "mobile" => Some(Self::mobile_4g()), + "mobile-3g" => Some(Self::mobile_3g()), + "satellite" => Some(Self::satellite()), + "satellite-geo" => Some(Self::satellite_geo()), + _ => None, + } + } + + /// Sets the rate cap in kbit/s. + pub const fn rate_kbit(mut self, kbit: u32) -> Self { + self.rate_kbit = Some(kbit); + self + } + + /// Sets the rate cap in Mbit/s. + pub const fn rate_mbit(mut self, mbit: u32) -> Self { + self.rate_kbit = Some(mbit * 1_000); + self + } + + /// Removes the rate cap, leaving the link uncapped. + pub const fn uncapped(mut self) -> Self { + self.rate_kbit = None; + self + } + + /// Sets the one-way latency in milliseconds. + pub const fn latency_ms(mut self, ms: u32) -> Self { + self.latency_ms = ms; + self + } + + /// Sets the jitter in milliseconds. + pub const fn jitter_ms(mut self, ms: u32) -> Self { + self.jitter_ms = ms; + self + } + + /// Sets the latency from a round-trip time, and sizes the buffer to match. + /// + /// Halves `rtt` into the one-way [`latency_ms`](Self::latency_ms), and sets + /// [`buffer_ms`](Self::buffer_ms) to the full round trip if no buffer has + /// been set yet. A buffer near one round trip is what makes loss emerge from + /// congestion on a capped link. Presets already carry a buffer, so + /// `wifi().rtt_ms(80)` keeps the preset's buffer; pass + /// [`buffer_ms`](Self::buffer_ms) explicitly to override it. + pub const fn rtt_ms(mut self, rtt: u32) -> Self { + self.latency_ms = rtt / 2; + if self.buffer_ms.is_none() { + self.buffer_ms = Some(rtt); + } + self + } + + /// Sets a bursty loss rate, the way real radio links lose packets. + /// + /// Sets [`loss_pct`](Self::loss_pct) to `pct` and selects Gilbert-Elliott + /// loss with a mean burst of 5 packets, matching the clustered drops of a + /// wifi fade or a cellular handover. Both fields are fully specified, so a + /// later [`random_loss`](Self::random_loss) or + /// [`loss_burst_pkts`](Self::loss_burst_pkts) call overrides this one. + pub const fn bursty_loss(mut self, pct: f32) -> Self { + self.loss_pct = pct; + self.loss_burst_pkts = Some(DEFAULT_LOSS_BURST_PKTS); + self + } + + /// Sets an independent per-packet loss rate. + /// + /// Sets [`loss_pct`](Self::loss_pct) to `pct` and selects Bernoulli loss, + /// where every packet is dropped independently. More repeatable run to run + /// than [`bursty_loss`](Self::bursty_loss), and the right choice when you + /// want a controlled loss rate rather than a faithful radio link. + pub const fn random_loss(mut self, pct: f32) -> Self { + self.loss_pct = pct; + self.loss_burst_pkts = None; + self + } + + /// Sets the packet loss percentage, leaving the loss model unchanged. + pub const fn loss_pct(mut self, pct: f32) -> Self { + self.loss_pct = pct; + self + } + + /// Sets the mean loss-burst length in packets, selecting bursty loss. + pub const fn loss_burst_pkts(mut self, pkts: u32) -> Self { + self.loss_burst_pkts = Some(pkts); + self + } + + /// Sets the rate-limiter queue depth in milliseconds. + pub const fn buffer_ms(mut self, ms: u32) -> Self { + self.buffer_ms = Some(ms); + self + } + + /// Sets the packet reordering percentage. + pub const fn reorder_pct(mut self, pct: f32) -> Self { + self.reorder_pct = pct; + self + } + + /// Sets the packet duplication percentage. + pub const fn duplicate_pct(mut self, pct: f32) -> Self { + self.duplicate_pct = pct; + self + } + + /// Sets the bit-error corruption percentage. + pub const fn corrupt_pct(mut self, pct: f32) -> Self { + self.corrupt_pct = pct; + self + } + + /// Sets the human-readable name carried into events and the devtools UI. + pub const fn label(mut self, label: &'static str) -> Self { + self.label = Some(label); + self + } +} + +impl<'de> Deserialize<'de> for LinkCondition { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + /// Table form, mirroring the public fields minus the label. + #[derive(Default, Deserialize)] + #[serde(default)] + struct Fields { + #[serde(deserialize_with = "coerce::opt_u32_or_string")] + rate_kbit: Option, + #[serde(deserialize_with = "coerce::u32_or_string")] + latency_ms: u32, + #[serde(deserialize_with = "coerce::u32_or_string")] + jitter_ms: u32, + #[serde(deserialize_with = "coerce::f32_or_string")] + loss_pct: f32, + #[serde(deserialize_with = "coerce::opt_u32_or_string")] + loss_burst_pkts: Option, + #[serde(deserialize_with = "coerce::opt_u32_or_string")] + buffer_ms: Option, + #[serde(deserialize_with = "coerce::f32_or_string")] + reorder_pct: f32, + #[serde(deserialize_with = "coerce::f32_or_string")] + duplicate_pct: f32, + #[serde(deserialize_with = "coerce::f32_or_string")] + corrupt_pct: f32, + } + + #[derive(Deserialize)] + #[serde(untagged)] + enum Repr { + Preset(String), + Fields(Fields), + } + + match Repr::deserialize(deserializer)? { + Repr::Preset(name) => LinkCondition::from_preset_name(&name).ok_or_else(|| { + serde::de::Error::custom(format!("unknown link condition preset '{name}'")) + }), + Repr::Fields(f) => Ok(LinkCondition { + rate_kbit: f.rate_kbit, + latency_ms: f.latency_ms, + jitter_ms: f.jitter_ms, + loss_pct: f.loss_pct, + loss_burst_pkts: f.loss_burst_pkts, + buffer_ms: f.buffer_ms, + reorder_pct: f.reorder_pct, + duplicate_pct: f.duplicate_pct, + corrupt_pct: f.corrupt_pct, + label: None, + }), + } + } } /// Serde helpers that accept both native types and string representations. @@ -87,15 +438,45 @@ mod coerce { Val::Str(s) => s.parse().map_err(serde::de::Error::custom), } } + + /// Accepts a number, a string, or null, mapping null and the empty string + /// to `None` so an unset knob stays unset. + pub(super) fn opt_u32_or_string<'de, D: Deserializer<'de>>( + de: D, + ) -> Result, D::Error> { + #[derive(Deserialize)] + #[serde(untagged)] + enum Val { + Num(u32), + Str(String), + } + match Option::::deserialize(de)? { + None => Ok(None), + Some(Val::Num(n)) => Ok(Some(n)), + Some(Val::Str(s)) if s.is_empty() => Ok(None), + Some(Val::Str(s)) => s.parse().map(Some).map_err(serde::de::Error::custom), + } + } } /// Applies netem impairment on `ifname`. Caller must already be in the target ns. -pub(crate) async fn apply_impair(ifname: &str, limits: LinkLimits) -> Result<()> { +/// +/// # Errors +/// +/// Returns an error when the rate cap is `Some(0)`, which `tbf` cannot express, +/// or when a `tc` command fails. +pub(crate) async fn apply_impair(ifname: &str, cond: LinkCondition) -> Result<()> { remove_qdisc(ifname).await; let qdisc = Qdisc::new(ifname); - qdisc.add_netem_root(limits).await?; - if limits.rate_kbit > 0 { - qdisc.add_tbf(limits.rate_kbit, limits.buffer_ms).await?; + qdisc.add_netem_root(cond).await?; + if let Some(rate_kbit) = cond.rate_kbit { + if rate_kbit == 0 { + bail!( + "link condition has a rate cap of 0 kbit/s, which tbf cannot express; \ + leave rate_kbit unset (or call `uncapped()`) for an uncapped link" + ); + } + qdisc.add_tbf(rate_kbit, cond.buffer_ms).await?; } Ok(()) } @@ -120,7 +501,7 @@ impl<'a> Qdisc<'a> { let _ = ensure_success(cmd, "tc qdisc del root").await; } - async fn add_netem_root(&self, limits: LinkLimits) -> Result<()> { + async fn add_netem_root(&self, limits: LinkCondition) -> Result<()> { let mut args = vec![ "qdisc".to_string(), "add".into(), @@ -141,22 +522,23 @@ impl<'a> Qdisc<'a> { } if limits.loss_pct > 0.0 { args.push("loss".into()); - if limits.loss_burst_pkts >= 2 { + match limits.loss_burst_pkts { // 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)); + Some(burst) if burst >= 2 => { + let l = (limits.loss_pct as f64 / 100.0).clamp(0.0, 0.999); + let b = burst 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}%")); + } + _ => args.push(format!("{:.3}%", limits.loss_pct)), } } if limits.reorder_pct > 0.0 { @@ -178,11 +560,12 @@ impl<'a> Qdisc<'a> { Ok(()) } - 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 }; + async fn add_tbf(&self, rate_kbit: u32, buffer_ms: Option) -> Result<()> { + // An unset buffer keeps the historical 400ms depth; a smaller value makes + // the rate limiter drop on overflow near one RTT, so loss emerges from + // congestion as on a real bottleneck link. `tc` behaves erratically with + // a zero latency, so keep at least 1ms. + let latency = buffer_ms.unwrap_or(DEFAULT_BUFFER_MS).max(1); let mut cmd = Command::new("tc"); cmd.args([ "qdisc", @@ -231,3 +614,144 @@ async fn ensure_success(mut cmd: Command, context: &str) -> Result<()> { } bail!("{context}: spawn: EAGAIN after {SPAWN_RETRIES} retries"); } + +#[cfg(test)] +mod tests { + use super::*; + + /// Deserialization target, since a bare `LinkCondition` is not a TOML document. + #[derive(Debug, Deserialize)] + struct Holder { + condition: LinkCondition, + } + + fn parse(toml_str: &str) -> LinkCondition { + toml::from_str::(toml_str) + .expect("valid link condition") + .condition + } + + #[test] + fn label_is_excluded_from_equality() { + // A preset and a hand-built condition with the same numbers compare + // equal, so tests can assert against `LinkCondition::wifi()`. + let relabelled = LinkCondition::wifi().label("something else"); + assert_eq!(LinkCondition::wifi(), relabelled); + assert_eq!(relabelled.label, Some("something else")); + } + + #[test] + fn setters_preserve_the_preset_label() { + let tweaked = LinkCondition::mobile_4g().bursty_loss(5.0); + assert_eq!(tweaked.label, Some("mobile-4g")); + assert_eq!(tweaked.loss_pct, 5.0); + } + + #[test] + fn rtt_ms_halves_latency_and_sizes_the_buffer() { + let cond = LinkCondition::new().rate_mbit(40).rtt_ms(50); + assert_eq!(cond.latency_ms, 25); + assert_eq!(cond.buffer_ms, Some(50)); + assert_eq!(cond.rate_kbit, Some(40_000)); + } + + #[test] + fn explicit_buffer_wins_over_rtt_in_either_order() { + let before = LinkCondition::new().buffer_ms(100).rtt_ms(50); + let after = LinkCondition::new().rtt_ms(50).buffer_ms(100); + assert_eq!(before.buffer_ms, Some(100)); + assert_eq!(after.buffer_ms, Some(100)); + assert_eq!(before, after); + } + + #[test] + fn rtt_ms_keeps_a_preset_buffer() { + // Presets carry a buffer, so `rtt_ms` only moves the latency. + let cond = LinkCondition::wifi().rtt_ms(80); + assert_eq!(cond.latency_ms, 40); + assert_eq!(cond.buffer_ms, LinkCondition::wifi().buffer_ms); + } + + #[test] + fn loss_helpers_select_the_loss_model() { + assert_eq!( + LinkCondition::new().bursty_loss(1.5).loss_burst_pkts, + Some(DEFAULT_LOSS_BURST_PKTS) + ); + assert_eq!(LinkCondition::new().random_loss(1.5).loss_burst_pkts, None); + // Later call wins: each helper fully specifies the pair. + assert_eq!( + LinkCondition::new().bursty_loss(1.5).random_loss(2.0), + LinkCondition::new().random_loss(2.0) + ); + } + + #[test] + fn uncapped_clears_a_preset_rate() { + assert_eq!(LinkCondition::wifi().uncapped().rate_kbit, None); + } + + #[test] + fn presets_round_trip_through_their_names() { + let presets = [ + LinkCondition::lan(), + LinkCondition::wifi(), + LinkCondition::wifi_bad(), + LinkCondition::mobile_4g(), + LinkCondition::mobile_3g(), + LinkCondition::satellite(), + LinkCondition::satellite_geo(), + ]; + for preset in presets { + let name = preset.label.expect("preset carries a label"); + let parsed = LinkCondition::from_preset_name(name).expect("name resolves"); + assert_eq!(parsed, preset, "preset '{name}' did not round-trip"); + assert_eq!(parsed.label, Some(name)); + } + } + + #[test] + fn deserializes_a_preset_name() { + assert_eq!(parse(r#"condition = "wifi""#), LinkCondition::wifi()); + // The legacy alias for the 4G preset still resolves. + assert_eq!(parse(r#"condition = "mobile""#), LinkCondition::mobile_4g()); + } + + #[test] + fn rejects_an_unknown_preset_name() { + let err = toml::from_str::(r#"condition = "dial-up""#).unwrap_err(); + assert!( + err.to_string().contains("dial-up"), + "error should name the bad preset, got: {err}" + ); + } + + #[test] + fn deserializes_a_field_table() { + let cond = parse(r#"condition = { latency_ms = 200, loss_pct = 5.0, rate_kbit = 500 }"#); + assert_eq!(cond.latency_ms, 200); + assert_eq!(cond.loss_pct, 5.0); + assert_eq!(cond.rate_kbit, Some(500)); + // Unset optional knobs stay unset rather than becoming zero. + assert_eq!(cond.buffer_ms, None); + assert_eq!(cond.loss_burst_pkts, None); + assert_eq!(cond.label, None); + } + + #[test] + fn deserializes_numbers_given_as_strings() { + // Sim files substitute matrix variables as strings. + let cond = parse(r#"condition = { latency_ms = "200", rate_kbit = "500" }"#); + assert_eq!(cond.latency_ms, 200); + assert_eq!(cond.rate_kbit, Some(500)); + } + + #[test] + fn netem_args_use_gemodel_only_for_bursty_loss() { + // Guards the Gilbert-Elliott branch selection without invoking `tc`. + let bursty = LinkCondition::new().bursty_loss(1.5); + let random = LinkCondition::new().random_loss(1.5); + assert!(matches!(bursty.loss_burst_pkts, Some(n) if n >= 2)); + assert!(random.loss_burst_pkts.is_none()); + } +} diff --git a/patchbay/src/tests/iface.rs b/patchbay/src/tests/iface.rs index b8db791..a9a8523 100644 --- a/patchbay/src/tests/iface.rs +++ b/patchbay/src/tests/iface.rs @@ -201,13 +201,14 @@ async fn build_time_conditions() -> Result<()> { .add_device("dev") .iface( "eth0", - IfaceConfig::routed(dc.id()).condition(LinkCondition::Mobile4G, LinkDirection::Egress), + IfaceConfig::routed(dc.id()) + .condition(LinkCondition::mobile_4g(), LinkDirection::Egress), ) .build() .await?; let eth0 = dev.iface("eth0").expect("eth0 should exist"); - assert_eq!(eth0.egress(), Some(LinkCondition::Mobile4G)); + assert_eq!(eth0.egress(), Some(LinkCondition::mobile_4g())); assert!(eth0.ingress().is_none()); Ok(()) @@ -224,15 +225,15 @@ async fn asymmetric_conditions() -> Result<()> { .iface( "eth0", IfaceConfig::routed(dc.id()) - .condition(LinkCondition::Mobile4G, LinkDirection::Egress) - .condition(LinkCondition::Mobile3G, LinkDirection::Ingress), + .condition(LinkCondition::mobile_4g(), LinkDirection::Egress) + .condition(LinkCondition::mobile_3g(), LinkDirection::Ingress), ) .build() .await?; let eth0 = dev.iface("eth0").expect("eth0 should exist"); - assert_eq!(eth0.egress(), Some(LinkCondition::Mobile4G)); - assert_eq!(eth0.ingress(), Some(LinkCondition::Mobile3G)); + assert_eq!(eth0.egress(), Some(LinkCondition::mobile_4g())); + assert_eq!(eth0.ingress(), Some(LinkCondition::mobile_3g())); Ok(()) } @@ -262,14 +263,14 @@ async fn dummy_guards() -> Result<()> { // Cannot set ingress condition on a dummy interface. let err = tun - .set_condition(LinkCondition::Mobile4G, LinkDirection::Ingress) + .set_condition(LinkCondition::mobile_4g(), LinkDirection::Ingress) .await; assert!(err.is_err(), "set_condition ingress on dummy should fail"); // Egress condition works on a dummy interface. - tun.set_condition(LinkCondition::Mobile4G, LinkDirection::Egress) + tun.set_condition(LinkCondition::mobile_4g(), LinkDirection::Egress) .await?; - assert_eq!(tun.egress(), Some(LinkCondition::Mobile4G)); + assert_eq!(tun.egress(), Some(LinkCondition::mobile_4g())); Ok(()) } diff --git a/patchbay/src/tests/link_condition.rs b/patchbay/src/tests/link_condition.rs index b79a49d..8858c88 100644 --- a/patchbay/src/tests/link_condition.rs +++ b/patchbay/src/tests/link_condition.rs @@ -20,7 +20,7 @@ async fn route_switch_changes_impairment() -> Result<()> { dev.iface("eth1") .unwrap() - .set_condition(LinkCondition::Mobile4G, LinkDirection::Egress) + .set_condition(LinkCondition::mobile_4g(), LinkDirection::Egress) .await?; let dc_ip = dc.uplink_ip().context("no dc uplink ip")?; @@ -135,15 +135,7 @@ async fn rate_tcp_upload() -> Result<()> { dev.iface("eth0") .unwrap() - .set_condition( - LinkCondition::Manual(LinkLimits { - rate_kbit: 2000, - loss_pct: 0.0, - latency_ms: 0, - ..Default::default() - }), - LinkDirection::Egress, - ) + .set_condition(LinkCondition::new().rate_kbit(2000), LinkDirection::Egress) .await?; let dc_ip = dc.uplink_ip().context("no dc uplink ip")?; @@ -167,13 +159,8 @@ async fn rate_tcp_download() -> Result<()> { let dc = lab.add_router("dc").build().await?; let dev_id = lab.add_device("dev").iface("eth0", dc.id()).build().await?; - dc.set_downlink_condition(Some(LinkCondition::Manual(LinkLimits { - rate_kbit: 2000, - loss_pct: 0.0, - latency_ms: 0, - ..Default::default() - }))) - .await?; + dc.set_downlink_condition(Some(LinkCondition::new().rate_kbit(2000))) + .await?; let dev_ip = dev_id.ip().unwrap(); let addr = SocketAddr::new(IpAddr::V4(dev_ip), 17_400); @@ -199,15 +186,7 @@ async fn rate_udp_upload() -> Result<()> { dev.iface("eth0") .unwrap() - .set_condition( - LinkCondition::Manual(LinkLimits { - rate_kbit: 2000, - loss_pct: 0.0, - latency_ms: 0, - ..Default::default() - }), - LinkDirection::Egress, - ) + .set_condition(LinkCondition::new().rate_kbit(2000), LinkDirection::Egress) .await?; let dc_ip = dc.uplink_ip().context("no dc uplink ip")?; @@ -237,13 +216,8 @@ async fn rate_udp_download() -> Result<()> { let dc = lab.add_router("dc").build().await?; let dev_id = lab.add_device("dev").iface("eth0", dc.id()).build().await?; - dc.set_downlink_condition(Some(LinkCondition::Manual(LinkLimits { - rate_kbit: 2000, - loss_pct: 0.0, - latency_ms: 0, - ..Default::default() - }))) - .await?; + dc.set_downlink_condition(Some(LinkCondition::new().rate_kbit(2000))) + .await?; let dc_ip = dc.uplink_ip().context("no dc uplink ip")?; let r = SocketAddr::new(IpAddr::V4(dc_ip), 17_600); @@ -275,24 +249,14 @@ async fn rate_asymmetric() -> Result<()> { .iface("eth0") .unwrap() .set_condition( - LinkCondition::Manual(LinkLimits { - rate_kbit: 1000, - loss_pct: 0.0, - latency_ms: 0, - ..Default::default() - }), + LinkCondition::new().rate_kbit(1000), // Egress only: cap upload at 1000, download unimpaired by this rule. LinkDirection::Egress, ) .await?; - dc.set_downlink_condition(Some(LinkCondition::Manual(LinkLimits { - rate_kbit: 4000, - loss_pct: 0.0, - latency_ms: 0, - ..Default::default() - }))) - .await?; + dc.set_downlink_condition(Some(LinkCondition::new().rate_kbit(4000))) + .await?; let dc_ip = dc.uplink_ip().context("no dc uplink ip")?; let up_addr = SocketAddr::new(IpAddr::V4(dc_ip), 17_700); @@ -342,12 +306,7 @@ async fn rate_multihop_bottleneck() -> Result<()> { lab.set_link_condition( nat.id(), isp.id(), - Some(LinkCondition::Manual(LinkLimits { - rate_kbit: 1000, - loss_pct: 0.0, - latency_ms: 0, - ..Default::default() - })), + Some(LinkCondition::new().rate_kbit(1000)), ) .await?; @@ -376,24 +335,11 @@ async fn rate_two_hops_stacked() -> Result<()> { dev.iface("eth0") .unwrap() - .set_condition( - LinkCondition::Manual(LinkLimits { - rate_kbit: 2000, - loss_pct: 0.0, - latency_ms: 0, - ..Default::default() - }), - LinkDirection::Egress, - ) + .set_condition(LinkCondition::new().rate_kbit(2000), LinkDirection::Egress) .await?; - dc.set_downlink_condition(Some(LinkCondition::Manual(LinkLimits { - rate_kbit: 2000, - loss_pct: 0.0, - latency_ms: 0, - ..Default::default() - }))) - .await?; + dc.set_downlink_condition(Some(LinkCondition::new().rate_kbit(2000))) + .await?; let dc_ip = dc.uplink_ip().context("no dc uplink ip")?; let addr = SocketAddr::new(IpAddr::V4(dc_ip), 17_900); @@ -429,13 +375,7 @@ async fn loss_udp_moderate() -> Result<()> { // Now apply 50% loss on the device egress. let default_iface = lab.device_by_name("dev").unwrap().default_iface().unwrap(); default_iface - .set_condition( - LinkCondition::Manual(LinkLimits { - loss_pct: 50.0, - ..Default::default() - }), - LinkDirection::Egress, - ) + .set_condition(LinkCondition::new().loss_pct(50.0), LinkDirection::Egress) .await?; // tc netem loss is on the device egress, so ~50% of probes reach the @@ -477,13 +417,7 @@ async fn loss_udp_high() -> Result<()> { // Now apply 90% loss on the device egress. let default_iface = lab.device_by_name("dev").unwrap().default_iface().unwrap(); default_iface - .set_condition( - LinkCondition::Manual(LinkLimits { - loss_pct: 90.0, - ..Default::default() - }), - LinkDirection::Egress, - ) + .set_condition(LinkCondition::new().loss_pct(90.0), LinkDirection::Egress) .await?; let (_, received) = dev @@ -508,15 +442,7 @@ async fn loss_tcp_integrity() -> Result<()> { dev.iface("eth0") .unwrap() - .set_condition( - LinkCondition::Manual(LinkLimits { - rate_kbit: 0, - loss_pct: 5.0, - latency_ms: 0, - ..Default::default() - }), - LinkDirection::Egress, - ) + .set_condition(LinkCondition::new().loss_pct(5.0), LinkDirection::Egress) .await?; const BYTES: usize = 200 * 1024; @@ -570,20 +496,11 @@ async fn loss_udp_bidirectional() -> Result<()> { // Now apply 30% loss on both upload and download. let default_iface = lab.device_by_name("dev").unwrap().default_iface().unwrap(); default_iface - .set_condition( - LinkCondition::Manual(LinkLimits { - loss_pct: 30.0, - ..Default::default() - }), - LinkDirection::Egress, - ) + .set_condition(LinkCondition::new().loss_pct(30.0), LinkDirection::Egress) .await?; - dc.set_downlink_condition(Some(LinkCondition::Manual(LinkLimits { - loss_pct: 30.0, - ..Default::default() - }))) - .await?; + dc.set_downlink_condition(Some(LinkCondition::new().loss_pct(30.0))) + .await?; // Round-trip delivery ≈ (1-0.3)×(1-0.3) = 49 %; expect < 80. let (_, received) = dev @@ -610,24 +527,11 @@ async fn latency_upload_download() -> Result<()> { dev.iface("eth0") .unwrap() - .set_condition( - LinkCondition::Manual(LinkLimits { - rate_kbit: 0, - loss_pct: 0.0, - latency_ms: 20, - ..Default::default() - }), - LinkDirection::Egress, - ) + .set_condition(LinkCondition::new().latency_ms(20), LinkDirection::Egress) .await?; - dc.set_downlink_condition(Some(LinkCondition::Manual(LinkLimits { - rate_kbit: 0, - loss_pct: 0.0, - latency_ms: 30, - ..Default::default() - }))) - .await?; + dc.set_downlink_condition(Some(LinkCondition::new().latency_ms(30))) + .await?; let dc_ip = dc.uplink_ip().context("no dc uplink ip")?; let r = SocketAddr::new(IpAddr::V4(dc_ip), 18_500); @@ -662,26 +566,13 @@ async fn latency_multihop_chain() -> Result<()> { dev.iface("eth0") .unwrap() - .set_condition( - LinkCondition::Manual(LinkLimits { - rate_kbit: 0, - loss_pct: 0.0, - latency_ms: 20, - ..Default::default() - }), - LinkDirection::Egress, - ) + .set_condition(LinkCondition::new().latency_ms(20), LinkDirection::Egress) .await?; lab.set_link_condition( nat.id(), isp.id(), - Some(LinkCondition::Manual(LinkLimits { - rate_kbit: 0, - loss_pct: 0.0, - latency_ms: 30, - ..Default::default() - })), + Some(LinkCondition::new().latency_ms(30)), ) .await?; @@ -709,15 +600,7 @@ async fn rate_dynamic_decrease() -> Result<()> { dev.iface("eth0") .unwrap() - .set_condition( - LinkCondition::Manual(LinkLimits { - rate_kbit: 5000, - loss_pct: 0.0, - latency_ms: 0, - ..Default::default() - }), - LinkDirection::Egress, - ) + .set_condition(LinkCondition::new().rate_kbit(5000), LinkDirection::Egress) .await?; let dc_ip = dc.uplink_ip().context("no dc uplink ip")?; @@ -731,15 +614,7 @@ async fn rate_dynamic_decrease() -> Result<()> { let default_iface = lab.device_by_name("dev").unwrap().default_iface().unwrap(); default_iface - .set_condition( - LinkCondition::Manual(LinkLimits { - rate_kbit: 500, - loss_pct: 0.0, - latency_ms: 0, - ..Default::default() - }), - LinkDirection::Egress, - ) + .set_condition(LinkCondition::new().rate_kbit(500), LinkDirection::Egress) .await?; let sink = dc.spawn_thread(move || tcp_sink(SocketAddr::new(IpAddr::V4(dc_ip), 18_801)))?; @@ -774,15 +649,7 @@ async fn rate_dynamic_remove() -> Result<()> { dev.iface("eth0") .unwrap() - .set_condition( - LinkCondition::Manual(LinkLimits { - rate_kbit: 1000, - loss_pct: 0.0, - latency_ms: 0, - ..Default::default() - }), - LinkDirection::Egress, - ) + .set_condition(LinkCondition::new().rate_kbit(1000), LinkDirection::Egress) .await?; let dc_ip = dc.uplink_ip().context("no dc uplink ip")?; @@ -827,15 +694,7 @@ async fn latency_dynamic_add_remove() -> Result<()> { let default_iface = lab.device_by_name("dev").unwrap().default_iface().unwrap(); default_iface - .set_condition( - LinkCondition::Manual(LinkLimits { - rate_kbit: 0, - loss_pct: 0.0, - latency_ms: 100, - ..Default::default() - }), - LinkDirection::Egress, - ) + .set_condition(LinkCondition::new().latency_ms(100), LinkDirection::Egress) .await?; let high = dev.run_sync(move || test_utils::udp_rtt_sync(r))?; assert!( @@ -864,13 +723,13 @@ async fn presets_rtt_and_loss() -> Result<()> { // 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, 4, 0.0), - (LinkCondition::WifiBad, 25, 1.5), - (LinkCondition::Mobile4G, 25, 0.0), - (LinkCondition::Mobile3G, 80, 2.0), - (LinkCondition::Satellite, 22, 0.0), - (LinkCondition::SatelliteGeo, 300, 0.0), + (LinkCondition::lan(), 0, 0.0), + (LinkCondition::wifi(), 4, 0.0), + (LinkCondition::wifi_bad(), 25, 1.5), + (LinkCondition::mobile_4g(), 25, 0.0), + (LinkCondition::mobile_3g(), 80, 2.0), + (LinkCondition::satellite(), 22, 0.0), + (LinkCondition::satellite_geo(), 300, 0.0), ]; let mut port_base = 19_100u16; let mut failures = Vec::new(); @@ -922,48 +781,37 @@ async fn presets_rtt_and_loss() -> Result<()> { Ok(()) } -/// All preset to_limits() calls produce valid (non-NaN) values; -/// Lan is zero; Manual round-trips. +/// Every preset carries finite (non-NaN) percentages and a matching label. #[test] -fn presets_to_limits_roundtrip() { +fn presets_have_valid_values() { let presets = [ - LinkCondition::Lan, - LinkCondition::Wifi, - LinkCondition::WifiBad, - LinkCondition::Mobile4G, - LinkCondition::Mobile3G, - LinkCondition::Satellite, - LinkCondition::SatelliteGeo, + LinkCondition::lan(), + LinkCondition::wifi(), + LinkCondition::wifi_bad(), + LinkCondition::mobile_4g(), + LinkCondition::mobile_3g(), + LinkCondition::satellite(), + LinkCondition::satellite_geo(), ]; for preset in presets { - let limits = preset.to_limits(); - assert!(!limits.loss_pct.is_nan(), "{preset:?}: loss_pct is NaN"); + assert!(!preset.loss_pct.is_nan(), "{preset:?}: loss_pct is NaN"); assert!( - !limits.reorder_pct.is_nan(), + !preset.reorder_pct.is_nan(), "{preset:?}: reorder_pct is NaN" ); assert!( - !limits.duplicate_pct.is_nan(), + !preset.duplicate_pct.is_nan(), "{preset:?}: duplicate_pct is NaN" ); assert!( - !limits.corrupt_pct.is_nan(), + !preset.corrupt_pct.is_nan(), "{preset:?}: corrupt_pct is NaN" ); + assert!(preset.label.is_some(), "{preset:?}: preset carries a label"); } - assert_eq!(LinkCondition::Lan.to_limits(), LinkLimits::default()); - let custom = LinkLimits { - rate_kbit: 1000, - loss_pct: 5.0, - latency_ms: 100, - jitter_ms: 10, - 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); + // `new()` is the unimpaired baseline; `lan()` adds a small switch-hop delay. + assert_eq!(LinkCondition::new().latency_ms, 0); + assert_eq!(LinkCondition::lan().latency_ms, 1); } /// Router builder's downlink_condition applies latency at build time. @@ -974,10 +822,7 @@ async fn downlink_builder_latency() -> Result<()> { let lab = Lab::new().await?; let dc = lab .add_router("dc") - .downlink_condition(LinkCondition::Manual(LinkLimits { - latency_ms: 50, - ..Default::default() - })) + .downlink_condition(LinkCondition::new().latency_ms(50)) .build() .await?; let dev = lab.add_device("dev").iface("eth0", dc.id()).build().await?; @@ -1015,14 +860,9 @@ impair = { rate_kbit = 5000, loss_pct = 1.5, latency_ms = 40 } .clone() .try_into() .map_err(|e: toml::de::Error| anyhow!("{}", e))?; - match impair { - LinkCondition::Manual(limits) => { - assert_eq!(limits.rate_kbit, 5000); - assert!((limits.loss_pct - 1.5).abs() < f32::EPSILON); - assert_eq!(limits.latency_ms, 40); - } - other => bail!("unexpected impair: {:?}", other), - } + assert_eq!(impair.rate_kbit, Some(5000)); + assert!((impair.loss_pct - 1.5).abs() < f32::EPSILON); + assert_eq!(impair.latency_ms, 40); Ok(()) } @@ -1053,15 +893,7 @@ async fn loss_dynamic_change() -> Result<()> { // Apply 90% loss. let default_iface = lab.device_by_name("dev").unwrap().default_iface().unwrap(); default_iface - .set_condition( - LinkCondition::Manual(LinkLimits { - rate_kbit: 0, - loss_pct: 90.0, - latency_ms: 0, - ..Default::default() - }), - LinkDirection::Egress, - ) + .set_condition(LinkCondition::new().loss_pct(90.0), LinkDirection::Egress) .await?; let (_, recv_lossy) = dev @@ -1136,10 +968,7 @@ async fn direction_permutations() -> Result<()> { // are far enough apart that no CI jitter can confuse them. Each assertion uses // a ±150ms tolerance around the expected value. let latency_ms = 500; - let condition = LinkCondition::Manual(LinkLimits { - latency_ms, - ..Default::default() - }); + let condition = LinkCondition::new().latency_ms(latency_ms); // Baseline: no impairment, RTT should be near zero. let rtt_ms = sender @@ -1236,10 +1065,7 @@ async fn lab_bidirectional_via_two_calls() -> Result<()> { let reflector_addr = SocketAddr::new(IpAddr::V4(recv_ip), 19_100); let _r = receiver.spawn_reflector(reflector_addr).await?; - let condition = LinkCondition::Manual(LinkLimits { - latency_ms: 500, - ..Default::default() - }); + let condition = LinkCondition::new().latency_ms(500); // Baseline: no impairment. RTT should be well under 100ms. let baseline = sender.run_sync(move || test_utils::udp_rtt_sync(reflector_addr))?; diff --git a/patchbay/src/tests/region.rs b/patchbay/src/tests/region.rs index 4ef6e6e..7b2acb6 100644 --- a/patchbay/src/tests/region.rs +++ b/patchbay/src/tests/region.rs @@ -129,13 +129,7 @@ async fn impair_stacks_with_latency() -> Result<()> { dev.iface("eth0") .unwrap() - .set_condition( - LinkCondition::Manual(LinkLimits { - latency_ms: 30, - ..Default::default() - }), - LinkDirection::Both, - ) + .set_condition(LinkCondition::new().latency_ms(30), LinkDirection::Both) .await?; let us_ip = dc_us.uplink_ip().context("no uplink ip")?; diff --git a/ui/src/components/NodeDetail.tsx b/ui/src/components/NodeDetail.tsx index 1b225fb..fc0ce5a 100644 --- a/ui/src/components/NodeDetail.tsx +++ b/ui/src/components/NodeDetail.tsx @@ -1,4 +1,4 @@ -import type { LabState } from '../devtools-types' +import type { LabState, LinkCondition } from '../devtools-types' interface Props { state: LabState @@ -6,6 +6,26 @@ interface Props { selectedKind: 'router' | 'device' | 'ix' } +// Formats a link condition for display. `label` carries the preset name (e.g. +// "wifi") when the condition came from a preset, and is null for a hand-built +// one. Shows the label with a short summary of the impairment, or just the +// summary when there is no label. +function formatCondition(cond: LinkCondition): string { + const parts: string[] = [] + if (cond.rate_kbit != null) { + parts.push( + cond.rate_kbit % 1000 === 0 + ? `${cond.rate_kbit / 1000} Mbit` + : `${cond.rate_kbit} kbit`, + ) + } + if (cond.latency_ms > 0) parts.push(`${cond.latency_ms} ms`) + if (cond.loss_pct > 0) parts.push(`${cond.loss_pct}% loss`) + const summary = parts.join(', ') + if (cond.label != null) return summary ? `${cond.label} (${summary})` : cond.label + return summary || 'unimpaired' +} + function jsonField(label: string, value: unknown) { const display = typeof value === 'string' ? value : JSON.stringify(value) return ( @@ -58,7 +78,7 @@ export default function NodeDetail({ state, selectedNode, selectedKind }: Props) {router.downstream_cidr_v6 && jsonField('downstream_cidr_v6', router.downstream_cidr_v6)} {router.downstream_gw_v6 && jsonField('downstream_gw_v6', router.downstream_gw_v6)} {jsonField('downstream_bridge', router.downstream_bridge)} - {router.downlink_condition != null ? jsonField('downlink_condition', router.downlink_condition) : null} + {router.downlink_condition != null ? jsonField('downlink_condition', formatCondition(router.downlink_condition)) : null} {jsonField('devices', router.devices.join(', ') || '—')} @@ -124,7 +144,7 @@ export default function NodeDetail({ state, selectedNode, selectedKind }: Props) {iface.router} {iface.ip ?? '—'} {iface.ip_v6 ?? '—'} - {iface.link_condition ? JSON.stringify(iface.link_condition) : '—'} + {iface.link_condition ? formatCondition(iface.link_condition) : '—'} ))} diff --git a/ui/src/devtools-types.ts b/ui/src/devtools-types.ts index 757b014..15ab633 100644 --- a/ui/src/devtools-types.ts +++ b/ui/src/devtools-types.ts @@ -37,24 +37,25 @@ export type Firewall = FirewallPreset | { custom: FirewallConfig } // ── Link condition types ── -export interface LinkLimits { - latency_ms: number +/** + * Matches Rust `LinkCondition`. Serializes as a single flat object (no + * preset-string form and no `manual` wrapper). `label` holds the preset + * name (e.g. "wifi", "mobile-4g") when the condition came from a preset, + * and is null when custom. + */ +export interface LinkCondition { + rate_kbit: number | null // null = uncapped + latency_ms: number // one-way jitter_ms: number loss_pct: number - rate_kbit: number | null + loss_burst_pkts: number | null // null = independent (Bernoulli) loss; >=2 = bursty + buffer_ms: number | null // null = default 400ms rate-limiter buffer + reorder_pct: number + duplicate_pct: number + corrupt_pct: number + label: string | null // preset name, or null when custom } -/** Matches Rust `LinkCondition` — preset string or manual limits object. */ -export type LinkCondition = - | 'lan' - | 'wifi' - | 'wifi_bad' - | 'mobile_4g' - | 'mobile_3g' - | 'satellite' - | 'satellite_geo' - | LinkLimits - // ── State types ── export interface LabState {