How Iroh's Connection State Machine Manages the Connection Lifecycle

Iroh implements a compile-time type-state pattern in iroh/src/endpoint/connection.rs that explicitly models the connection lifecycle through three distinct phases—Connecting/Accepting, 0-RTT, and HandshakeCompleted—preventing invalid operations at each stage.

Iroh builds its networking stack on top of the QUIC library noq, exposing a generic Connection<State> struct that enforces valid state transitions at compile time. The iroh connection state machine splits the connection lifecycle into three main compile-time states defined in iroh/src/endpoint/connection.rs, ensuring that methods like opening streams or querying remote identity are only available after the appropriate handshake phase completes.

The Type-State Architecture

The core of the state machine is a generic struct parameterized by a marker trait:

pub struct Connection<State: ConnectionState = HandshakeCompleted> {
    inner: noq::Connection,
    data: State::Data,
}

The ConnectionState trait is sealed, with only three implementations defined in the same file:

  • Connecting – Exists after Endpoint::connect() but before the TLS handshake finishes
  • Accepting – Exists after an Incoming connection is accepted but before the handshake completes
  • HandshakeCompleted – The fully established connection state

Additional marker structs IncomingZeroRtt and OutgoingZeroRtt handle 0-RTT variants, ensuring that early data operations are type-safe.

Outgoing Connection Lifecycle

Outgoing connections begin with Endpoint::connect(), which returns a Connecting future. According to the implementation in iroh/src/endpoint/connection.rs (lines 40-46 and 71-78), polling this future resolves the underlying noq::Connecting and registers the connection with the endpoint's socket layer via conn_from_noq_conn:

let fut = conn_from_noq_conn(noq_conn, &self.ep)?;
self.register_with_socket = Some(Box::pin(fut.err_into()));

When the future completes, it yields a Connection<HandshakeCompleted>.

Attempting 0-RTT

To enable 0-RTT (zero round-trip time) for outgoing connections, call Connecting::into_0rtt() (lines 528-548). This consumes the Connecting state and returns an OutgoingZeroRttConnection containing a shared future that resolves to ZeroRttStatus::Accepted or ZeroRttStatus::Rejected:

let conn = endpoint
    .connect(server_addr, b"/iroh/echo/1")
    .await?          // `Connecting`
    .into_0rtt()?    // `OutgoingZeroRttConnection`
    .handshake_completed()
    .await?;         // `ZeroRttStatus`

match conn {
    ZeroRttStatus::Accepted(c) => println!("0-RTT accepted, remote ID {}", c.remote_id()),
    ZeroRttStatus::Rejected(c) => println!("0-RTT rejected, fall-back connection {}", c.remote_id()),
}

If rejected, streams opened during the 0-RTT phase must be recreated.

Incoming Connection Lifecycle

Incoming connections follow a path from Incoming to Accepting to HandshakeCompleted. The router in iroh/src/protocol.rs (lines 624-647) manages this via the handle_connection loop, which calls incoming.accept() to create an Accepting state.

Protocol handlers can intercept connections before the handshake completes by implementing on_accepting:

use iroh::protocol::{ProtocolHandler, AcceptError};

struct MyHandler;
impl ProtocolHandler for MyHandler {
    async fn on_accepting(
        &self,
        accepting: Accepting,
    ) -> Result<Connection, AcceptError> {
        // Turn the incoming connection into a 0-RTT connection
        let zrtt_conn = accepting.into_0rtt();
        
        // Use the connection immediately (streams available before handshake)
        let (mut send, mut recv) = zrtt_conn.accept_bi().await?;
        // … application logic …
        Ok(zrtt_conn)
    }
}

The into_0rtt() method on the server side (lines 600-626) always succeeds because servers can fall back to normal handshake if the client did not send 0-RTT data.

Early Filtering and Rejection

Before the handshake completes, the router applies an incoming filter (lines 558-585 in protocol.rs). The filter returns an IncomingFilterOutcome that supports four actions:

  • Accept – Continue normal processing
  • Retry – Send a QUIC RETRY packet to force address validation (useful for NAT traversal)
  • Reject – Send CONNECTION_REFUSED immediately
  • Ignore – Silently drop the packet
let router = Router::builder(endpoint)
    .incoming_filter(Arc::new(|inc| {
        if inc.remote_addr_validated() {
            IncomingFilterOutcome::Accept
        } else {
            IncomingFilterOutcome::Reject
        }
    }))
    .accept(b"/my/proto/1", Echo)
    .spawn();

These actions are performed directly on the Incoming object via incoming.retry(), incoming.refuse(), or incoming.ignore().

Weak Handles and Graceful Shutdown

The state machine supports non-owning references via WeakConnectionHandle (lines 127-138). This allows code to monitor connection status without preventing the connection from closing naturally. When all strong Connection handles are dropped, the underlying QUIC connection closes, but weak handles can still be upgraded to check termination status or await closure during endpoint shutdown.

Summary

  • Iroh's connection state machine uses a type-state pattern in iroh/src/endpoint/connection.rs to enforce compile-time safety across connection phases.
  • Outgoing connections transition from Connecting to HandshakeCompleted, optionally passing through OutgoingZeroRtt.
  • Incoming connections move from Incoming to Accepting via the router's accept loop, supporting early interception via on_accepting.
  • 0-RTT support is available for both outgoing and incoming connections, with explicit ZeroRttStatus handling for rejected early data.
  • Early filtering allows rejection, retry, or ignore actions before the TLS handshake completes.
  • Weak handles enable monitoring and graceful shutdown without owning the connection lifecycle.

Frequently Asked Questions

What is the difference between Connecting and Accepting states in iroh?

Connecting represents an outgoing connection initiated by Endpoint::connect() that has not yet completed the TLS handshake, while Accepting represents an incoming connection that has been accepted from an Incoming object but is still completing the handshake. Both states prevent stream operations until the handshake finishes, but Accepting additionally supports the into_0rtt() conversion and early rejection via the incoming filter.

How does iroh handle 0-RTT connection failures?

When a client attempts 0-RTT by calling into_0rtt(), the resulting OutgoingZeroRttConnection contains a shared future that resolves to ZeroRttStatus::Accepted or ZeroRttStatus::Rejected. If rejected, the connection still exists but streams opened during the 0-RTT phase are broken and must be recreated. Server-side 0-RTT always succeeds in conversion but may fall back to normal handshake if the client did not provide early data.

Can I reject a connection before the TLS handshake completes?

Yes. The router supports an incoming_filter closure that receives the Incoming object and returns IncomingFilterOutcome::Reject to send CONNECTION_REFUSED, Retry to force a QUIC retry packet for address validation, or Ignore to silently drop the packet. This occurs in iroh/src/protocol.rs before the Accepting state is created.

Why does iroh use a type-state pattern for connections?

The type-state pattern enforces the QUIC protocol rules at compile time, ensuring that methods like open_bi() or remote_id() are only callable on Connection<HandshakeCompleted>. This prevents runtime errors where an application might try to use a connection before the handshake finishes, and it eliminates the need for runtime state checks or unwraps during the connection lifecycle.

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 →