How to Properly Shut Down an Iroh Endpoint and Clean Up Resources

Call Endpoint::close().await to gracefully shut down an iroh endpoint, then await Endpoint::closed() to ensure all background tasks and UDP sockets are fully released before dropping the endpoint.

The iroh library provides a robust QUIC-based peer-to-peer networking stack, but improper shutdown can leave connections hanging and cause abrupt "connection lost" errors on remote peers. Understanding the iroh endpoint shutdown and resource cleanup lifecycle ensures your application terminates cleanly without leaking sockets or orphaning background tasks.

Why Graceful Shutdown Matters in Iroh

An iroh Endpoint represents your local node in the network. It manages UDP sockets, relay connections, NAT traversal helpers, address discovery services, and active QUIC connections. When you drop an endpoint without closing it properly, open connections abort after a timeout, appearing as failures to remote peers.

The source code in iroh/src/endpoint.rs documents this explicitly: the close method comment (lines 1681-1689) warns that dropping without awaiting close causes connections to terminate uncleanly.

Step 1: Initiate Shutdown with Endpoint::close()

The Endpoint::close() method is your entry point for graceful shutdown. Located at lines 1703-1705 in iroh/src/endpoint.rs, this async method forwards to EndpointInner::close and waits for the QUIC endpoint to finish draining.

// Gracefully close the endpoint
ep.close().await;

What happens internally:

  • QUIC connection draining: The method waits for ACKs that confirm remote peers received close notifications. The default timeout is approximately 3 seconds on poor networks.
  • Cancellation token trigger: EndpointInner owns a ShutdownState containing a cancellation token. Calling close triggers this token, signaling all background tasks to stop.

Step 2: Monitor Completion with Endpoint::closed()

After calling close(), use Endpoint::closed() to receive an EndpointClosed future that resolves when shutdown is fully complete. This future is defined at lines 1812-1825 in iroh/src/endpoint.rs.

// Wait for complete shutdown
ep.closed().await;

The EndpointClosed future provides additional utility through run_until, which automatically cancels a task when the endpoint closes:

use iroh::Endpoint;

// Run work until the endpoint shuts down
ep.closed().run_until(async {
    // Your async work here
    process_data().await
}).await;

Step 3: Background Task Cancellation

The actual shutdown orchestration happens in EndpointInner::close within iroh/src/socket.rs. This internal method coordinates termination of:

  • Socket and transport layers: The UDP socket and all transport implementations
  • Address lookup services: DNS and discovery actors
  • NAT traversal helpers: NAT-PMP, PCP, and UPnP mapping cleanup
  • Net-report task: Network capability reporting
  • Relay connections: Active relay transports via close_all_active_relays in iroh/src/socket/transports/relay/actor.rs

The ShutdownState struct manages a tokio_util::sync::CancellationToken that all background tasks monitor. When triggered, tasks exit their loops and release resources.

Step 4: UDP Socket Release

The underlying UDP socket remains alive while any clone of the Endpoint exists. According to the comment at lines 1712-1718 in iroh/src/endpoint.rs, the socket is finally released only after:

  1. close() has been called and completed
  2. All clones of the Endpoint have been dropped
  3. The Drop implementation of EndpointInner executes

Complete Shutdown Example

Here is a production-ready pattern for shutting down an iroh endpoint:

use iroh::{Endpoint, endpoint::presets};
use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() -> n0_error::Result<()> {
    // Build and bind the endpoint
    let ep = Endpoint::builder(presets::N0)
        .alpns(vec![b"my-alpn".to_vec()])
        .bind()
        .await?;

    // ... use endpoint for P2P connections, transfers, etc.

    // 1. Gracefully close all QUIC connections
    ep.close().await;

    // 2. Wait for background tasks and socket cleanup
    ep.closed().await;

    // 3. Endpoint is now safe to drop
    drop(ep);
    
    Ok(())
}

Pattern for Long-Running Applications

For services that need to respond to shutdown signals while monitoring the endpoint:

use iroh::Endpoint;

async fn run_service(ep: Endpoint) {
    // Spawn task that stops when endpoint closes
    let monitor = tokio::spawn({
        let closed = ep.closed();
        async move {
            closed.run_until(async {
                // Background work here
                loop {
                    tokio::time::sleep(Duration::from_secs(1)).await;
                    println!("Working...");
                }
            }).await;
            println!("Stopped because endpoint closed");
        }
    });

    // Later, trigger shutdown
    ep.close().await;
    
    // Wait for monitor to finish
    monitor.await.unwrap();
}

Per-Connection Graceful Close

Individual connections support graceful termination via Connection::close in iroh/src/endpoint/connection.rs. This sends a QUIC close frame to the remote peer with an error code and reason string, allowing the remote application to distinguish intentional closure from network failure.

// Close a specific connection gracefully
connection.close(0u32.into(), b"done");

Summary

  • Always await Endpoint::close() before dropping an endpoint to prevent abrupt connection termination
  • Use Endpoint::closed() to monitor when all background tasks and the UDP socket are fully released
  • Background tasks terminate via cancellation token in ShutdownState once close is initiated
  • The UDP socket persists until all endpoint clones are dropped after close completes
  • Individual connections should be closed with Connection::close before endpoint shutdown for maximum clarity

Frequently Asked Questions

What happens if I drop an Endpoint without calling close?

Remote peers experience a connection timeout after approximately 3 seconds, appearing as a "connection lost" error in their application. Any data in flight may not be acknowledged, and the shutdown appears abnormal rather than intentional.

How long does Endpoint::close() take to complete?

Typically less than 3 seconds. The method waits for QUIC close frames to be acknowledged by remote peers. On healthy networks this completes quickly; the timeout protects against unresponsive peers.

Can I reopen an endpoint after closing it?

No. The close method consumes the endpoint's internal state and triggers cancellation of all background tasks. Create a new Endpoint with Endpoint::builder() if you need to resume networking.

Do I need to close individual connections before closing the endpoint?

Not strictly required, but recommended. The endpoint's close method will drain all connections, but explicitly calling Connection::close first gives you control over error codes and reason strings sent to peers.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →