Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Running Code in Namespaces

Every node in a patchbay topology, whether it is a device, a router, or the IX itself, has its own Linux network namespace. Each namespace comes with two workers: an async worker backed by a single-threaded tokio runtime, and a sync worker backed by a dedicated OS thread. You never interact with setns directly; the workers enter the correct namespace before executing your code.

This chapter describes all the execution methods available on node handles, when to use each one, and how to modify the topology at runtime.

Async tasks

The spawn method is the primary way to run networking code inside a namespace. It takes an async closure, dispatches it to the namespace’s tokio runtime, and returns a join handle that resolves when the task completes:

#![allow(unused)]
fn main() {
let handle = dev.spawn(async move |_dev| {
    let stream = tokio::net::TcpStream::connect("203.0.113.10:80").await?;
    let mut buf = vec![0u8; 1024];
    let n = stream.read(&mut buf).await?;
    anyhow::Ok(n)
})?;

let bytes_read = handle.await??;
}

The closure receives a clone of the device handle, which you can use to query addresses or spawn further tasks. All tokio networking primitives work inside spawn: TcpStream, TcpListener, UdpSocket, timeouts, intervals, and anything built on top of them. Because the runtime is single-threaded and pinned to the namespace, all socket operations happen against the namespace’s isolated network stack.

You should use spawn for any work that involves network I/O. The alternative, blocking I/O in a sync context, will stall the worker thread and can cause kernel-level timeouts for TCP (SYN retransmit takes roughly 127 seconds to exhaust). Always prefer async networking via spawn.

Sync closures

The run_sync method dispatches a closure to the namespace’s sync worker thread and blocks until it returns. It is intended for quick, non-I/O operations: reading a sysctl value, creating a socket to inspect its local address, or spawning an OS process.

#![allow(unused)]
fn main() {
let local_addr = dev.run_sync(|| {
    let sock = std::net::UdpSocket::bind("0.0.0.0:0")?;
    Ok(sock.local_addr()?)
})?;
}

Because run_sync blocks both the calling thread and the sync worker, avoid doing anything slow inside it. TCP connects, HTTP requests, and other blocking network I/O belong in spawn, not in run_sync.

OS commands

spawn_command runs an OS process inside the namespace and registers the child with the namespace’s tokio reactor, so .wait() and .wait_with_output() work as non-blocking futures. It takes a tokio::process::Command and returns a tokio::process::Child:

#![allow(unused)]
fn main() {
let mut child = dev.spawn_command({
    let mut cmd = tokio::process::Command::new("curl");
    cmd.arg("http://203.0.113.10");
    cmd
})?;

let status = child.wait().await?;
assert!(status.success());
}

When you need a synchronous std::process::Child instead (for example to pass to spawn_blocking or manage outside of an async context), use spawn_command_sync:

#![allow(unused)]
fn main() {
let mut child = dev.spawn_command_sync({
    let mut cmd = std::process::Command::new("curl");
    cmd.arg("http://203.0.113.10");
    cmd
})?;

let output = tokio::task::spawn_blocking(move || {
    child.wait_with_output()
}).await??;
assert!(output.status.success());
}

Dedicated threads

When you have long-running blocking work that would starve the sync worker, spawn_thread creates a dedicated OS thread inside the namespace. Unlike run_sync, this thread does not compete with other sync operations on the same namespace:

#![allow(unused)]
fn main() {
let handle = dev.spawn_thread(|| {
    // This thread runs in the device's namespace.
    // It can do blocking work for an extended period
    // without affecting run_sync calls.
    Ok(())
})?;
}

UDP reflectors

spawn_reflector starts a UDP echo server in the namespace. It is a convenience method for connectivity tests: send a datagram to the reflector and measure the round-trip time to verify that the path works.

#![allow(unused)]
fn main() {
let bind_addr = SocketAddr::new(IpAddr::V4(server_ip), 9000);
server.spawn_reflector(bind_addr)?;
}

The reflector runs on the namespace’s async worker and echoes every received datagram back to its sender.

Dynamic topology operations

A patchbay topology is not static. After building the initial layout, you can modify interfaces, routes, link conditions, and NAT configuration at runtime. These operations are useful for simulating network events during a test: a WiFi handoff, a link failure, or a NAT policy change.

Replugging interfaces

Move a device’s interface from one router to another. The interface receives a new IP address from the new router’s pool, and routes are updated automatically:

#![allow(unused)]
fn main() {
dev.replug_iface("wlan0", other_router.id()).await?;
}

This models scenarios like roaming between WiFi access points or switching between ISPs.

Switching the default route

For multi-homed devices, change which interface carries the default route. This simulates a WiFi-to-cellular handoff or a VPN tunnel activation:

#![allow(unused)]
fn main() {
dev.set_default_route("cell0").await?;
}

Bringing interfaces down and up

Simulate link failures by administratively disabling an interface. While the interface is down, packets sent to or from it are dropped:

#![allow(unused)]
fn main() {
dev.link_down("wlan0").await?;
// All traffic over wlan0 is now dropped.

dev.link_up("wlan0").await?;
// The interface is back and traffic flows again.
}

Modify link impairment on the fly to simulate degrading or improving network quality:

#![allow(unused)]
fn main() {
use patchbay::{LinkCondition, LinkLimits};

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

// Apply custom impairment.
dev.set_link_condition("wlan0", Some(LinkCondition::Manual(LinkLimits {
    rate_kbit: 500,
    loss_pct: 15.0,
    latency_ms: 200,
    ..Default::default()
}))).await?;

// Remove all impairment and return to a clean link.
dev.set_link_condition("wlan0", None).await?;
}

Changing NAT at runtime

Switch a router’s NAT mode and flush stale connection tracking state. This is covered in more detail in the NAT and Firewalls chapter:

#![allow(unused)]
fn main() {
router.set_nat_mode(Nat::Corporate).await?;
router.flush_nat_state().await?;
}

Handles

Device, Router, and Ix are lightweight, cloneable handles. All three types support the same set of execution methods described above: spawn, run_sync, spawn_thread, spawn_command, spawn_command_sync, and spawn_reflector. Cloning a handle is cheap; it does not duplicate the underlying namespace or its workers.

Handle methods return Result or Option when the underlying node has been removed from the lab. If you hold a handle to a device that no longer exists, calls will return an error rather than panicking.

Cleanup

When the Lab is dropped, it shuts down all async and sync workers, then closes the namespace file descriptors. The kernel removes veth pairs, routes, and nftables rules when the last reference to a namespace disappears. No explicit cleanup is needed, and no state leaks onto the host between test runs.