How to Write Integration Tests for iroh Applications with Connection Monitoring
Integration tests for iroh applications use standard Rust #[tokio::test] functions that instantiate Endpoint objects with custom EndpointHooks implementations to capture QUIC handshake metadata and log final connection statistics when connections close.
Writing robust integration tests for the n0-computer/iroh networking stack requires more than simple request-response validation. By implementing the EndpointHooks trait, you can monitor connection lifecycles within your tests, capturing ALPN identifiers, remote endpoint IDs, and final transfer statistics. This pattern enables deterministic verification of connection health while providing deep visibility into QUIC protocol behavior.
The Connection Monitoring Architecture
The connection monitoring pattern in iroh relies on the EndpointHooks trait defined in iroh/src/endpoint.rs. This trait provides an interception point immediately after the QUIC handshake completes, allowing your test infrastructure to record connection metadata and spawn background tasks that observe connection lifecycle events.
Capturing Handshake Data with EndpointHooks
When you implement EndpointHooks, the after_handshake method receives a Connection reference right after the cryptographic handshake succeeds. This is the ideal location to capture immutable connection properties like the ALPN identifier and remote endpoint ID, as well as to obtain a WeakConnectionHandle for later observation.
According to the reference implementation in iroh/examples/monitor-connections.rs#L82-L100, the hook should:
- Extract the ALPN and remote endpoint ID from the connection
- Create a
WeakConnectionHandleto avoid blocking the connection's drop - Send this data through an async channel to a dedicated watcher task
Monitoring Connection Closure
The background watcher task awaits the WeakConnectionHandle::closed() future, which resolves to a Closed struct containing final statistics. As shown in iroh/examples/monitor-connections.rs#L130-L144, this struct provides access to udp_rx.bytes, udp_tx.bytes, and path RTT measurements, enabling your tests to verify that data actually flowed through the connection.
Implementing a Custom Connection Monitor
The monitor implementation requires three components: a struct holding the channel sender, a message type for handshake data, and the EndpointHooks trait implementation.
// Pattern based on iroh/examples/monitor-connections.rs#L27-59
use iroh::{
endpoint::{EndpointHooks, Connection, WeakConnectionHandle},
EndpointId,
};
use tokio::sync::mpsc::{UnboundedSender, UnboundedReceiver};
use tracing::info;
#[derive(Clone, Debug)]
pub struct Monitor {
tx: UnboundedSender<MonitoredConnection>,
_task: std::sync::Arc<tokio::task::JoinHandle<()>>,
}
#[derive(Debug)]
pub struct MonitoredConnection {
alpn: Vec<u8>,
remote_id: EndpointId,
handle: WeakConnectionHandle,
}
impl EndpointHooks for Monitor {
async fn after_handshake(&self, conn: &Connection) -> iroh::endpoint::AfterHandshakeOutcome {
let info = MonitoredConnection {
alpn: conn.alpn().to_vec(),
remote_id: conn.remote_id(),
handle: conn.weak_handle(),
};
// Ignore send errors – they only happen when the watcher has shut down
let _ = self.tx.send(info);
iroh::endpoint::AfterHandshakeOutcome::Accept
}
}
impl Monitor {
pub fn new() -> Self {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let task = tokio::spawn(Self::run(rx));
Self {
tx,
_task: std::sync::Arc::new(task),
}
}
async fn run(mut rx: UnboundedReceiver<MonitoredConnection>) {
let mut tasks = tokio::task::JoinSet::new();
while let Some(MonitoredConnection { alpn, remote_id, handle }) = rx.recv().await {
let alpn_str = String::from_utf8_lossy(&alpn).to_string();
let remote_short = remote_id.fmt_short();
info!(remote = %remote_short, alpn = %alpn_str, "new connection");
tasks.spawn(async move {
match handle.closed().await {
Some(closed) => {
let rtt = handle.upgrade()
.and_then(|c| c.paths().iter().map(|p| p.rtt()).min());
info!(
remote = %remote_short,
alpn = %alpn_str,
?rtt,
udp_rx = closed.stats.udp_rx.bytes,
udp_tx = closed.stats.udp_tx.bytes,
"connection closed"
);
}
None => {
info!(remote = %remote_short, alpn = %alpn_str,
"connection closed before stats captured");
}
}
});
}
// Drain remaining tasks
while let Some(res) = tasks.join_next().await {
let _ = res;
}
}
}
Writing the Integration Test
Integration tests follow the pattern established in iroh/tests/integration.rs. You instantiate endpoints using the builder API, attach your monitor via .hooks(), drive traffic between nodes, and optionally assert on the collected metrics.
Setting Up Monitored Endpoints
Build your server endpoint with the monitor attached, as demonstrated in iroh/examples/monitor-connections.rs#L27-L31. The client endpoint may also include a monitor if you need visibility into both sides of the connection.
// Based on iroh/tests/integration.rs#L35-80 and integration.rs#L22-30
#[tokio::test]
async fn integration_with_monitor() -> iroh::Result<()> {
// Initialize tracing to see monitor output in test logs
tracing_subscriber::fmt().with_env_filter("info").init();
// Build server with monitoring hooks
let monitor = Monitor::new();
let server = iroh::Endpoint::builder(iroh::endpoint::presets::Minimal)
.hooks(monitor.clone())
.alpns(vec![b"test".to_vec()])
.bind()
.await?;
// Build client (monitor optional here)
let client = iroh::Endpoint::builder(iroh::endpoint::presets::Minimal)
.bind()
.await?;
// Drive traffic: connect, open bidirectional stream, send data
let conn = client.connect(server.addr(), b"test").await?;
let (mut send, mut recv) = conn.open_bi().await?;
send.write_all(b"ping").await?;
send.finish().await?;
let resp = recv.read_to_end(1024).await?;
assert_eq!(resp, b"ping");
// Graceful shutdown
client.close().await;
server.close().await;
// Allow watcher task to process close events before dropping monitor
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
drop(monitor);
Ok(())
}
Validating Connection Statistics
For deterministic verification, extend the Monitor implementation to store Closed statistics in a Vec or use a tokio::sync::watch channel to make them available to the test thread. The iroh/src/socket.rs module contains additional network monitoring utilities compatible with this pattern if you require deeper packet-level inspection.
Running the Tests
Execute your integration tests using the standard cargo test runner. The monitor's tracing output appears in the test logs, providing visibility into connection lifecycles.
# Native execution
cargo test integration_with_monitor
# WASM target (requires wasm-bindgen-test)
cargo test --target wasm32-unknown-unknown --test integration_with_monitor
When the test runs, you will observe log output from the monitor showing the connection opening and closing with statistics:
INFO monitor_watcher: new connection remote=abcd… alpn="test"
INFO monitor_watcher: connection closed remote=abcd… alpn="test" udp_rx=4 udp_tx=4
Summary
- Implement
EndpointHooksto intercept connections immediately after the QUIC handshake iniroh/src/endpoint.rs - Capture
WeakConnectionHandleduringafter_handshaketo avoid blocking connection drop while enabling async monitoring - Spawn background tasks that await
handle.closed()to receive finalClosedstatistics including byte counts and RTT - Attach monitors via
.hooks()on theEndpointbuilder when constructing test fixtures - Reference
iroh/examples/monitor-connections.rsfor the canonical implementation andiroh/tests/integration.rsfor test structure patterns
Frequently Asked Questions
Can I monitor connections in WASM integration tests?
Yes. The same EndpointHooks implementation works for WASM targets when using #[wasm_bindgen_test] instead of #[tokio::test]. The iroh/tests/integration.rs file demonstrates this pattern, and the WeakConnectionHandle closure tracking operates identically across native and WASM builds.
How do I assert on connection statistics rather than just logging them?
Modify your Monitor struct to include a tokio::sync::mpsc::Sender or Arc<Mutex<Vec<Closed>>> that the background task populates when handle.closed() resolves. In your test, await a short duration after closing endpoints, then query the monitor's stored statistics to assert specific byte counts or RTT thresholds.
What is the difference between WeakConnectionHandle and holding the Connection directly?
WeakConnectionHandle does not prevent the connection from closing, whereas holding the Connection struct would keep the QUIC session alive. The weak handle allows you to monitor closure statistics without interfering with the natural connection lifecycle, which is critical for testing realistic shutdown scenarios.
Does iroh provide built-in network monitoring beyond EndpointHooks?
Yes. The iroh/src/socket.rs module (lines 448-1870) contains netmon::Monitor and related utilities for lower-level network interface monitoring. You can combine these socket-level monitors with EndpointHooks to capture both connection-level metrics and interface-level packet flows in the same test.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →