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 3def4bd..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,124 +83,6 @@ pub enum LinkDirection { Ingress, } -/// Link-layer impairment profile applied via `tc netem`. -/// -/// Named presets model common last-mile conditions. Use [`LinkCondition::Manual`] -/// with [`LinkLimits`] for full control over all `tc netem` parameters. -#[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. - /// - /// 5 ms one-way delay, 2 ms jitter, 0.1 % loss. - Wifi, - /// Congested WiFi — 2.4 GHz, far from AP, interference. - /// - /// 40 ms one-way delay, 15 ms jitter, 2 % loss, 20 Mbit. - WifiBad, - /// 4G/LTE good signal. - /// - /// 25 ms one-way delay, 8 ms jitter, 0.5 % loss. - Mobile4G, - /// 3G or degraded 4G. - /// - /// 100 ms one-way delay, 30 ms jitter, 2 % loss, 2 Mbit. - Mobile3G, - /// LEO satellite (Starlink-class). - /// - /// 40 ms one-way delay, 7 ms jitter, 1 % loss. - Satellite, - /// GEO satellite (HughesNet/Viasat). - /// - /// 300 ms one-way delay, 20 ms jitter, 0.5 % loss, 25 Mbit. - 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: 5, - jitter_ms: 2, - loss_pct: 0.1, - ..Default::default() - }, - LinkCondition::WifiBad => LinkLimits { - latency_ms: 40, - jitter_ms: 15, - loss_pct: 2.0, - rate_kbit: 20_000, - ..Default::default() - }, - LinkCondition::Mobile4G => LinkLimits { - latency_ms: 25, - jitter_ms: 8, - loss_pct: 0.5, - ..Default::default() - }, - LinkCondition::Mobile3G => LinkLimits { - latency_ms: 100, - jitter_ms: 30, - loss_pct: 2.0, - rate_kbit: 2_000, - ..Default::default() - }, - LinkCondition::Satellite => LinkLimits { - latency_ms: 40, - jitter_ms: 7, - loss_pct: 1.0, - ..Default::default() - }, - LinkCondition::SatelliteGeo => LinkLimits { - latency_ms: 300, - jitter_ms: 20, - loss_pct: 0.5, - rate_kbit: 25_000, - ..Default::default() - }, - LinkCondition::Manual(limits) => limits, - } - } -} - // ───────────────────────────────────────────── // Region // ───────────────────────────────────────────── @@ -1450,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 c9542e6..267957c 100644 --- a/patchbay/src/qdisc.rs +++ b/patchbay/src/qdisc.rs @@ -1,39 +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, + /// Human-readable name, set by the preset constructors. + /// + /// 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. @@ -67,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).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(()) } @@ -100,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(), @@ -121,7 +522,24 @@ impl<'a> Qdisc<'a> { } if limits.loss_pct > 0.0 { args.push("loss".into()); - args.push(format!("{:.3}%", limits.loss_pct)); + 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)). + 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 { args.push("reorder".into()); @@ -142,7 +560,12 @@ impl<'a> Qdisc<'a> { Ok(()) } - async fn add_tbf(&self, rate_kbit: u32) -> Result<()> { + 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", @@ -159,7 +582,7 @@ impl<'a> Qdisc<'a> { "burst", "32kbit", "latency", - "400ms", + &format!("{}ms", latency), ]); ensure_success(cmd, "tc qdisc tbf add").await?; Ok(()) @@ -191,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 72fdf3b..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!( @@ -858,14 +717,19 @@ 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::Mobile4G, 25, 0.0), - (LinkCondition::Mobile3G, 100, 2.0), - (LinkCondition::Satellite, 40, 1.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(); @@ -889,14 +753,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}" ); } } @@ -914,46 +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, - }; - 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. @@ -964,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?; @@ -1005,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(()) } @@ -1043,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 @@ -1126,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 @@ -1226,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 {