Graceful Shutdown of Iroh Endpoints and Routers: A Complete Guide
To gracefully shut down an iroh endpoint, call endpoint.close().await to initiate QUIC connection termination and router shutdown, then optionally await endpoint.closed() to block until the underlying QUIC stack finishes, allowing the final drop to close UDP sockets.
The iroh networking stack from n0-computer/iroh provides robust QUIC-based communication through the Endpoint struct, which manages UDP sockets, QUIC connections, and background routing tasks. When building production applications, implementing a graceful shutdown of iroh endpoints and routers prevents packet loss and ensures clean resource cleanup. This guide examines the actual implementation in iroh/src/endpoint.rs and iroh/src/socket.rs to show you exactly how to terminate endpoints correctly.
Understanding the Iroh Endpoint Architecture
The Endpoint and Its Router
The Endpoint is a high-level handle that owns a QUIC endpoint instance, UDP sockets, and background actors including the router and relay managers. According to the source code in iroh/src/socket.rs, the router is spawned as a background task during EndpointInner::bind and manages protocol frame multiplexing.
Shutdown Targets
When shutting down, the system must accomplish four critical tasks:
- Close the QUIC endpoint by sending
ConnectionCloseframes with error code 0, draining pending streams, and waiting for retransmissions to complete. - Signal the router to stop processing via an internal shutdown channel (
ControlMessage::Shutdown). - Preserve UDP sockets until all
Endpointclones are dropped to prevent panics in background tasks that may still reference the socket. - Detach watchers automatically when the last
Endpointreference disappears, as their lifetimes are tied to the endpoint inner.
Step-by-Step Graceful Shutdown Sequence
Step 1: Initiate Shutdown with close()
Call Endpoint::close (defined in iroh/src/endpoint.rs at line 61) to begin the process. This method sends QUIC ConnectionClose frames to all peers, drains pending streams, and dispatches a shutdown command to the router task via the internal control channel.
Step 2: Wait for QUIC Stack Completion
Use Endpoint::closed (line 170 of iroh/src/endpoint.rs) to obtain an EndpointClosed future. Awaiting this future blocks until the underlying QUIC implementation finishes retransmissions and signals cancellation, ensuring no packets are lost during termination.
Step 3: Drop Final References
Once the last clone of the Endpoint is dropped, EndpointInner::is_closed (line 236 of iroh/src/socket.rs) allows the UDP sockets to close. This reference-counted approach ensures background tasks never access invalid socket handles.
Code Examples
Basic Graceful Shutdown
This example demonstrates the minimal sequence for clean termination:
use iroh::{Endpoint, endpoint::presets};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Build and bind a minimal endpoint.
let ep = Endpoint::builder(presets::Minimal)
.alpns(vec![b"demo-alpn".to_vec()])
.bind()
.await?;
// … use the endpoint for connections …
// Start a graceful shutdown.
ep.close().await; // send QUIC close and shutdown router
ep.closed().await; // wait for the QUIC stack to finish
// All clones are dropped here, UDP sockets are finally closed.
Ok(())
}
Coordinating Background Tasks
Use EndpointClosed::run_until to spawn work that automatically stops when the endpoint shuts down:
use iroh::{Endpoint, endpoint::presets};
use n0_future::FutureExt; // for `.run_until`
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let ep = Endpoint::builder(presets::Minimal).bind().await?;
let endpoint_closed = ep.closed();
// Spawn a background task that does work until the endpoint closes.
tokio::spawn(endpoint_closed.run_until(async {
loop {
// ... periodic work ...
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}));
// When you are done with the endpoint:
ep.close().await; // triggers the above task to stop
Ok(())
}
Key lines: EndpointClosed::run_until (line 224 of iroh/src/endpoint.rs).
Advanced: Direct Router Control
For library authors who need explicit confirmation that the router task has exited, you can use the internal control channel directly:
use iroh::socket::router::ControlMessage;
use futures::channel::mpsc::UnboundedSender;
fn shutdown_router(ctrl_tx: UnboundedSender<ControlMessage>) {
// Send a shutdown request; the router will exit after processing any
// pending packets.
let _ = ctrl_tx.unbounded_send(ControlMessage::Shutdown);
}
Key lines: Router receives ControlMessage::Shutdown in socket::router (see iroh/src/socket.rs).
Key Source Files and Implementation Details
| File | Purpose |
|---|---|
iroh/src/endpoint.rs |
Public API for building, using, and shutting down an Endpoint. Contains close(), closed(), and the internal EndpointClosed future. |
iroh/src/socket.rs |
Sets up the low-level QUIC endpoint, creates the router task in EndpointInner::bind, and forwards the shutdown command via EndpointInner::close. |
iroh/src/socket/router.rs |
Implements the router that demultiplexes protocol frames; reacts to ControlMessage::Shutdown. |
Summary
- Call
endpoint.close().awaitto initiate QUIC shutdown, notify the router, and start the graceful-close handshake. - Await
endpoint.closed().awaitto block until the QUIC stack reports that the close is complete and all connections are terminated. - Drop all
Endpointclones to trigger final UDP socket cleanup viais_closedguards in the inner implementation. - Router lifecycle is automatic—the background task receives shutdown signals through internal channels when
close()is called, requiring no manual intervention.
Frequently Asked Questions
Do I need to manually stop the router?
No. The router does not expose a public API for shutdown; its lifecycle is tied to the Endpoint. When EndpointInner::close runs, it automatically sends a ControlMessage::Shutdown to the router's control channel, causing the task to exit after processing remaining packets.
What happens if I drop the endpoint without calling close()?
Dropping the endpoint without calling close() will eventually clean up resources, but it may not send graceful QUIC ConnectionClose frames to peers. This can leave connections in a half-open state on the remote side. Always call close().await for production applications to ensure clean protocol termination.
How do I ensure all connections are fully terminated before my program exits?
First, await endpoint.close().await to initiate the close handshake. Then, await endpoint.closed().await to block until the underlying QUIC endpoint signals that all connections have finished retransmissions and closed properly. This guarantees that no pending data is lost.
Can I restart an endpoint after closing it?
No, an Endpoint cannot be reused after close() has been called. You must create a new Endpoint instance using Endpoint::builder() and bind to new sockets. The original UDP sockets are permanently closed once the last reference is dropped.
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 →