Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

71 changes: 49 additions & 22 deletions patchbay/src/lab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -96,27 +105,30 @@ pub enum LinkCondition {
Lan,
/// Good WiFi — 5 GHz band, close to AP, low contention.
///
/// 5 ms one-way delay, 2 ms jitter, 0.1 % loss.
/// 200 Mbit, 4 ms one-way delay, 2 ms jitter, 0.1 % bursty loss, 15 ms buffer.
Wifi,
/// Congested WiFi — 2.4 GHz, far from AP, interference.
///
/// 40 ms one-way delay, 15 ms jitter, 2 % loss, 20 Mbit.
/// 25 Mbit, 25 ms one-way delay, 15 ms jitter, 1.5 % bursty loss, 60 ms buffer.
WifiBad,
/// 4G/LTE good signal.
///
/// 25 ms one-way delay, 8 ms jitter, 0.5 % loss.
/// 40 Mbit, 25 ms one-way delay (~50 ms RTT), 10 ms jitter, 0.3 % bursty loss,
/// 100 ms buffer.
Mobile4G,
/// 3G or degraded 4G.
///
/// 100 ms one-way delay, 30 ms jitter, 2 % loss, 2 Mbit.
/// 3 Mbit, 80 ms one-way delay, 25 ms jitter, 0.5 % bursty loss, 250 ms buffer.
Mobile3G,
/// LEO satellite (Starlink-class).
///
/// 40 ms one-way delay, 7 ms jitter, 1 % loss.
/// 100 Mbit, 22 ms one-way delay (~45 ms RTT), 10 ms jitter, 0.5 % bursty
/// loss, 50 ms buffer.
Satellite,
/// GEO satellite (HughesNet/Viasat).
///
/// 300 ms one-way delay, 20 ms jitter, 0.5 % loss, 25 Mbit.
/// 25 Mbit, 300 ms one-way delay (~600 ms RTT), 20 ms jitter, 0.3 % bursty
/// loss, 600 ms buffer.
SatelliteGeo,
/// Fully custom impairment parameters.
Manual(LinkLimits),
Expand Down Expand Up @@ -158,42 +170,57 @@ impl LinkCondition {
match self {
LinkCondition::Lan => LinkLimits::default(),
LinkCondition::Wifi => LinkLimits {
latency_ms: 5,
latency_ms: 4,
jitter_ms: 2,
loss_pct: 0.1,
loss_burst_pkts: 5,
rate_kbit: 200_000,
buffer_ms: 15,
..Default::default()
},
LinkCondition::WifiBad => LinkLimits {
latency_ms: 40,
latency_ms: 25,
jitter_ms: 15,
loss_pct: 2.0,
rate_kbit: 20_000,
loss_pct: 1.5,
loss_burst_pkts: 8,
rate_kbit: 25_000,
buffer_ms: 60,
..Default::default()
},
LinkCondition::Mobile4G => LinkLimits {
latency_ms: 25,
jitter_ms: 8,
loss_pct: 0.5,
jitter_ms: 10,
loss_pct: 0.3,
loss_burst_pkts: 6,
rate_kbit: 40_000,
buffer_ms: 100,
..Default::default()
},
LinkCondition::Mobile3G => LinkLimits {
latency_ms: 100,
jitter_ms: 30,
loss_pct: 2.0,
rate_kbit: 2_000,
latency_ms: 80,
jitter_ms: 25,
loss_pct: 0.5,
loss_burst_pkts: 6,
rate_kbit: 3_000,
buffer_ms: 250,
..Default::default()
},
LinkCondition::Satellite => LinkLimits {
latency_ms: 40,
jitter_ms: 7,
loss_pct: 1.0,
latency_ms: 22,
jitter_ms: 10,
loss_pct: 0.5,
loss_burst_pkts: 4,
rate_kbit: 100_000,
buffer_ms: 50,
..Default::default()
},
LinkCondition::SatelliteGeo => LinkLimits {
latency_ms: 300,
jitter_ms: 20,
loss_pct: 0.5,
loss_pct: 0.3,
loss_burst_pkts: 4,
rate_kbit: 25_000,
buffer_ms: 600,
..Default::default()
},
LinkCondition::Manual(limits) => limits,
Expand Down
48 changes: 44 additions & 4 deletions patchbay/src/qdisc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pct>%`. 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.
Expand Down Expand Up @@ -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(())
}
Expand Down Expand Up @@ -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());
Expand All @@ -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",
Expand All @@ -159,7 +199,7 @@ impl<'a> Qdisc<'a> {
"burst",
"32kbit",
"latency",
"400ms",
&format!("{}ms", latency),
]);
ensure_success(cmd, "tc qdisc tbf add").await?;
Ok(())
Expand Down
24 changes: 17 additions & 7 deletions patchbay/src/tests/link_condition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}"
);
}
}
Expand Down Expand Up @@ -952,6 +960,8 @@ fn presets_to_limits_roundtrip() {
reorder_pct: 1.0,
duplicate_pct: 0.5,
corrupt_pct: 0.1,
buffer_ms: 200,
loss_burst_pkts: 4,
};
assert_eq!(LinkCondition::Manual(custom).to_limits(), custom);
}
Expand Down
Loading