Skip to content

Commit a26cf72

Browse files
authored
feat!: bidirectional per-device link impairment (#12)
`set_link_condition` previously applied netem only on the device-side veth — egress from the device. Download traffic flowed through the unimpaired bridge-side veth in the router namespace. For protocols that rely on bidirectional timing (QUIC holepunching, congestion control), this produced misleading results: measured RTT was half the configured latency. We add a `LinkDirection` enum as a third parameter to `Device::set_link_condition`. The default is `Both`, which applies the same netem qdisc to both the device-side veth (outgoing) and the bridge-side veth (incoming). `Egress` and `Ingress` are available for asymmetric scenarios like modeling a bad uplink on a good downlink. The runner's `set-link-condition` step accepts an optional `direction` field defaulting to `"both"`. `DeviceBuilder::iface()` no longer accepts an impairment argument. Impairment is set exclusively through `set_link_condition` after build, which makes the API consistent: one method, one place to configure direction. The config/TOML path uses an internal `iface_impaired()` method to preserve build-time impairment for sim files. The `direction_permutations` test verifies all three modes with 500ms configured latency: egress-only and ingress-only each produce ~500ms RTT, while `Both` produces ~1000ms RTT. Assertion ranges are non-overlapping (single-direction upper bound < both-direction lower bound) to prove additivity without flaking on slow CI. ## Breaking changes * **Changed** `Device::set_link_condition(ifname, impair)` now requires a third argument: `Device::set_link_condition(ifname, impair, direction)`. Use `LinkDirection::Both` for the new default (bidirectional) or `LinkDirection::Egress` to preserve old behavior. * **Changed** `Lab::set_link_condition(a, b, impair)` now requires a fourth argument: `Lab::set_link_condition(a, b, impair, direction)`. * **Changed** `DeviceBuilder::iface(ifname, router, impair)` is now `DeviceBuilder::iface(ifname, router)`. Use `device.set_link_condition(ifname, impair, direction)` after `.build().await?` instead. * **Added** `LinkDirection` enum (`Both`, `Egress`, `Ingress`) — exported from `patchbay`. * **Added** `direction` field to `LabEventKind::LinkConditionChanged`. * **Removed** impairment parameter from `DeviceBuilder::iface()` and `Device::add_iface()`.
1 parent dff4873 commit a26cf72

File tree

31 files changed

+537
-428
lines changed

31 files changed

+537
-428
lines changed

README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,15 @@ let home = lab
3636
// A device behind the home router, with a lossy WiFi link.
3737
let dev = lab
3838
.add_device("laptop")
39-
.iface("eth0", home.id(), Some(LinkCondition::Wifi))
39+
.iface("eth0", home.id())
4040
.build()
4141
.await?;
42+
dev.set_link_condition("eth0", Some(LinkCondition::Wifi), LinkDirection::Both).await?;
4243

4344
// A server in the datacenter.
4445
let server = lab
4546
.add_device("server")
46-
.iface("eth0", dc.id(), None)
47+
.iface("eth0", dc.id())
4748
.build()
4849
.await?;
4950

@@ -224,10 +225,11 @@ let home = lab.add_router("home")
224225

225226
// Devices
226227
let dev = lab.add_device("phone")
227-
.iface("wlan0", home.id(), Some(LinkCondition::Wifi))
228-
.iface("eth0", dc.id(), None)
228+
.iface("wlan0", home.id())
229+
.iface("eth0", dc.id())
229230
.default_via("wlan0")
230231
.build().await?;
232+
dev.set_link_condition("wlan0", Some(LinkCondition::Wifi), LinkDirection::Both).await?;
231233
```
232234

233235
### Running code in namespaces
@@ -285,7 +287,7 @@ dev.set_link_condition("wlan0", Some(LinkCondition::Manual(LinkLimits {
285287
loss_pct: 5.0,
286288
latency_ms: 100,
287289
..Default::default()
288-
}))).await?;
290+
})), LinkDirection::Both).await?;
289291

290292
// Change NAT mode at runtime
291293
router.set_nat_mode(Nat::Corporate).await?;

docs/guide/getting-started.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ front of the router's downstream, assigning devices private addresses and
112112
masquerading their traffic, like a typical home WiFi router.
113113

114114
```rust
115-
use patchbay::{Nat, LinkCondition};
115+
use patchbay::{Nat, LinkCondition, LinkDirection};
116116

117117
// A datacenter router whose devices get public IPs.
118118
let dc = lab.add_router("dc").build().await?;
@@ -130,16 +130,17 @@ real-world impairment like packet loss, latency, and jitter.
130130
// A server in the datacenter, with a clean link.
131131
let server = lab
132132
.add_device("server")
133-
.iface("eth0", dc.id(), None)
133+
.iface("eth0", dc.id())
134134
.build()
135135
.await?;
136136

137137
// A laptop behind the home router, over a lossy WiFi link.
138138
let laptop = lab
139139
.add_device("laptop")
140-
.iface("eth0", home.id(), Some(LinkCondition::Wifi))
140+
.iface("eth0", home.id())
141141
.build()
142142
.await?;
143+
laptop.set_link_condition("eth0", Some(LinkCondition::Wifi), LinkDirection::Both).await?;
143144
```
144145

145146
At this point you have five network namespaces — the IX root, two

docs/guide/running-code.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -169,21 +169,21 @@ Modify link impairment on the fly to simulate degrading or improving
169169
network quality:
170170

171171
```rust
172-
use patchbay::{LinkCondition, LinkLimits};
172+
use patchbay::{LinkCondition, LinkDirection, LinkLimits};
173173

174174
// Switch to a 3G-like link.
175-
dev.set_link_condition("wlan0", Some(LinkCondition::Mobile3G)).await?;
175+
dev.set_link_condition("wlan0", Some(LinkCondition::Mobile3G), LinkDirection::Both).await?;
176176

177177
// Apply custom impairment.
178178
dev.set_link_condition("wlan0", Some(LinkCondition::Manual(LinkLimits {
179179
rate_kbit: 500,
180180
loss_pct: 15.0,
181181
latency_ms: 200,
182182
..Default::default()
183-
}))).await?;
183+
})), LinkDirection::Both).await?;
184184

185185
// Remove all impairment and return to a clean link.
186-
dev.set_link_condition("wlan0", None).await?;
186+
dev.set_link_condition("wlan0", None, LinkDirection::Both).await?;
187187
```
188188

189189
### Changing NAT at runtime

docs/guide/testing.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,12 @@ async fn tcp_through_nat() -> Result<()> {
5757
// Server in the datacenter, client behind NAT.
5858
let server = lab
5959
.add_device("server")
60-
.iface("eth0", dc.id(), None)
60+
.iface("eth0", dc.id())
6161
.build()
6262
.await?;
6363
let client = lab
6464
.add_device("client")
65-
.iface("eth0", home.id(), None)
65+
.iface("eth0", home.id())
6666
.build()
6767
.await?;
6868

docs/guide/topology.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ router. IP addresses are assigned automatically from the router's pool.
109109
```rust
110110
let server = lab
111111
.add_device("server")
112-
.iface("eth0", dc.id(), None)
112+
.iface("eth0", dc.id())
113113
.build()
114114
.await?;
115115
```
@@ -136,11 +136,13 @@ the physical link:
136136
```rust
137137
let phone = lab
138138
.add_device("phone")
139-
.iface("wlan0", home.id(), Some(LinkCondition::Wifi))
140-
.iface("cell0", carrier.id(), Some(LinkCondition::Mobile4G))
139+
.iface("wlan0", home.id())
140+
.iface("cell0", carrier.id())
141141
.default_via("wlan0")
142142
.build()
143143
.await?;
144+
phone.set_link_condition("wlan0", Some(LinkCondition::Wifi), LinkDirection::Both).await?;
145+
phone.set_link_condition("cell0", Some(LinkCondition::Mobile4G), LinkDirection::Both).await?;
144146
```
145147

146148
The `.default_via("wlan0")` call sets which interface carries the default
@@ -169,12 +171,13 @@ The built-in presets model common access technologies:
169171
| `Mobile3G` | 3% | 100 ms | 30 ms | 2 Mbit/s |
170172
| `Satellite` | 0.5% | 600 ms | 50 ms | 10 Mbit/s |
171173

172-
Apply a preset when building the interface:
174+
Apply a preset after building the device:
173175

174176
```rust
175177
let dev = lab.add_device("laptop")
176-
.iface("eth0", home.id(), Some(LinkCondition::Wifi))
178+
.iface("eth0", home.id())
177179
.build().await?;
180+
dev.set_link_condition("eth0", Some(LinkCondition::Wifi), LinkDirection::Both).await?;
178181
```
179182

180183
### Custom parameters
@@ -194,8 +197,9 @@ let degraded = LinkCondition::Manual(LinkLimits {
194197
});
195198

196199
let dev = lab.add_device("laptop")
197-
.iface("eth0", home.id(), Some(degraded))
200+
.iface("eth0", home.id())
198201
.build().await?;
202+
dev.set_link_condition("eth0", Some(degraded), LinkDirection::Both).await?;
199203
```
200204

201205
### Runtime changes
@@ -206,10 +210,10 @@ for example switching from WiFi to a congested 3G link and verifying that
206210
your application adapts:
207211

208212
```rust
209-
dev.set_link_condition("eth0", Some(LinkCondition::Mobile3G)).await?;
213+
dev.set_link_condition("eth0", Some(LinkCondition::Mobile3G), LinkDirection::Both).await?;
210214

211215
// Later, restore a clean link.
212-
dev.set_link_condition("eth0", None).await?;
216+
dev.set_link_condition("eth0", None, LinkDirection::Both).await?;
213217
```
214218

215219
## Regions

docs/reference/patterns.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@ interfaces on different routers:
5959

6060
```rust
6161
let device = lab.add_device("client")
62-
.iface("eth0", home.id(), None) // physical: internet traffic
63-
.iface("wg0", vpn_exit.id(), None) // tunnel: corporate traffic
62+
.iface("eth0", home.id()) // physical: internet traffic
63+
.iface("wg0", vpn_exit.id()) // tunnel: corporate traffic
6464
.default_via("eth0") // default route on physical
6565
.build().await?;
6666

@@ -187,14 +187,15 @@ let wifi_router = lab.add_router("wifi").nat(Nat::Home).build().await?;
187187
let cell_router = lab.add_router("cell").nat(Nat::Cgnat).build().await?;
188188

189189
let device = lab.add_device("phone")
190-
.iface("eth0", wifi_router.id(), Some(LinkCondition::Wifi))
190+
.iface("eth0", wifi_router.id())
191191
.build().await?;
192+
device.set_link_condition("eth0", Some(LinkCondition::Wifi), LinkDirection::Both).await?;
192193

193194
// Simulate handoff with connectivity gap
194195
device.link_down("eth0").await?;
195196
tokio::time::sleep(Duration::from_millis(500)).await;
196197
device.replug_iface("eth0", cell_router.id()).await?;
197-
device.set_link_condition("eth0", Some(LinkCondition::Mobile4G)).await?;
198+
device.set_link_condition("eth0", Some(LinkCondition::Mobile4G), LinkDirection::Both).await?;
198199
device.link_up("eth0").await?;
199200

200201
// Assert: application reconnects within X seconds
@@ -245,7 +246,7 @@ let device = lab.add_device("client").uplink(router.id()).build().await?;
245246
device.set_link_condition("eth0", Some(LinkCondition::Manual(LinkLimits {
246247
rate_kbit: 2_000,
247248
..Default::default()
248-
})))?;
249+
})), LinkDirection::Both)?;
249250
```
250251

251252
---
@@ -336,11 +337,11 @@ Network conditions worsen over time (moving away from WiFi AP, entering tunnel
336337
on cellular, weather affecting satellite).
337338

338339
```rust
339-
device.set_link_condition("eth0", Some(LinkCondition::Wifi)).await?;
340+
device.set_link_condition("eth0", Some(LinkCondition::Wifi), LinkDirection::Both).await?;
340341
tokio::time::sleep(Duration::from_secs(5)).await;
341-
device.set_link_condition("eth0", Some(LinkCondition::WifiBad)).await?;
342+
device.set_link_condition("eth0", Some(LinkCondition::WifiBad), LinkDirection::Both).await?;
342343
tokio::time::sleep(Duration::from_secs(5)).await;
343-
device.set_link_condition("eth0", None).await?; // remove impairment
344+
device.set_link_condition("eth0", None, LinkDirection::Both).await?; // remove impairment
344345
```
345346

346347
### Intermittent connectivity

patchbay-cli/tests/fixtures/counter/tests/counter.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,12 @@ async fn udp_counter() -> anyhow::Result<()> {
2323
let dc = lab.add_router("dc").build().await?;
2424
let sender = lab
2525
.add_device("sender")
26-
.iface("eth0", dc.id(), None)
26+
.iface("eth0", dc.id())
2727
.build()
2828
.await?;
2929
let receiver = lab
3030
.add_device("receiver")
31-
.iface("eth0", dc.id(), None)
31+
.iface("eth0", dc.id())
3232
.build()
3333
.await?;
3434

patchbay-runner/src/sim/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@ pub enum Step {
271271
interface: Option<String>,
272272
#[serde(alias = "impair")]
273273
condition: Option<toml::Value>,
274+
direction: Option<patchbay::LinkDirection>,
274275
},
275276
SetDefaultRoute {
276277
device: String,

patchbay-runner/src/sim/steps.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,8 +407,10 @@ pub(crate) async fn execute_step(state: &mut SimState, step: &Step) -> Result<()
407407
device,
408408
interface,
409409
condition,
410+
direction,
410411
} => {
411412
let condition = parse_link_condition(condition)?;
413+
let direction = direction.unwrap_or_default();
412414
let dev = state
413415
.lab
414416
.device_by_name(device)
@@ -421,7 +423,8 @@ pub(crate) async fn execute_step(state: &mut SimState, step: &Step) -> Result<()
421423
.name()
422424
.to_string(),
423425
};
424-
dev.set_link_condition(&ifname, condition).await?;
426+
dev.set_link_condition(&ifname, condition, direction)
427+
.await?;
425428
}
426429

427430
// ── set-default-route ──────────────────────────────────────────────

patchbay/examples/simple.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use anyhow::Result;
2-
use patchbay::{Lab, LinkCondition, RouterPreset};
2+
use patchbay::{Lab, RouterPreset};
33
use tokio::io::{AsyncReadExt, AsyncWriteExt};
44

55
fn main() -> Result<()> {
@@ -30,14 +30,14 @@ async fn async_main() -> Result<()> {
3030
// A device behind the home router, with a lossy WiFi link.
3131
let dev = lab
3232
.add_device("laptop")
33-
.iface("eth0", home.id(), Some(LinkCondition::Wifi))
33+
.iface("eth0", home.id())
3434
.build()
3535
.await?;
3636

3737
// A server in the datacenter.
3838
let server = lab
3939
.add_device("server")
40-
.iface("eth0", dc.id(), None)
40+
.iface("eth0", dc.id())
4141
.build()
4242
.await?;
4343

0 commit comments

Comments
 (0)