Iroh Connection Lifecycle: From Endpoint Creation to QUIC Handshake Completion
The iroh connection lifecycle progresses through distinct Rust types—Connecting, Accepting, and Connection<HandshakeCompleted>—that map directly to QUIC handshake stages, starting from Endpoint creation and ending with graceful closure via close() or handle drops.
The iroh crate builds networking on top of QUIC via the noq library, exposing a type-safe API that mirrors the underlying protocol phases. Understanding the iroh connection lifecycle is essential for building robust peer-to-peer applications that handle both outgoing dials and incoming accepts efficiently. This guide examines the actual implementation in iroh/src/endpoint/connection.rs, covering each stage from initial endpoint binding to final connection teardown.
The Iroh Connection Lifecycle Stages
Stage 1: Endpoint Creation and Binding
Every connection begins with an Endpoint. Calling Endpoint::builder() followed by bind() creates a local QUIC endpoint capable of both dialing and listening. This initialization allocates the underlying UDP socket and configures the TLS stack, preparing the endpoint to handle the connection lifecycle.
Stage 2: Outgoing Connections with Connecting
When dialing a remote peer, Endpoint::connect() returns a Connecting future. Defined at line 138 of connection.rs, this struct represents an outgoing connection that has not yet completed the TLS handshake. Polling this future blocks until the cryptographic handshake finishes, at which point it transitions to the next state.
Stage 3: Optional 0-RTT for Outgoing Connections
Before the handshake completes, you can convert Connecting into a zero-round-trip connection. The into_0rtt() method (lines 528-549) returns Result<OutgoingZeroRttConnection, Connecting>. On success, you receive an OutgoingZeroRttConnection (a Connection<OutgoingZeroRtt>) that allows immediate data transmission before handshake confirmation, reducing latency for the first request.
Stage 4: Handshake Completion to Connection<HandshakeCompleted>
Once the TLS handshake succeeds, Connecting resolves to a Connection<HandshakeCompleted>. This generic struct, defined at line 735 of connection.rs, represents a fully established, secure connection ready for data transfer. The default generic state parameter is HandshakeCompleted, which provides access to the complete connection API.
Stage 5: Incoming Connections via Incoming and Accepting
On the server side, Endpoint::accept() yields an Incoming future (lines 129-132). Calling Incoming::accept() produces an Accepting state (lines 140-144), the server-side counterpart to Connecting. Polling Accepting eventually yields a Connection<HandshakeCompleted> after the cryptographic handshake finishes, mirroring the outgoing connection flow.
Stage 6: Optional 0-RTT on the Server Side
Similar to outgoing connections, incoming connections support early data. After obtaining Accepting, calling into_0rtt() (lines 594-617) returns an IncomingZeroRttConnection (Connection<IncomingZeroRtt>). This allows the server to process requests before handshake completion, with the connection later resolving to a standard Connection<HandshakeCompleted> once the handshake succeeds.
Stage 7: Data Transfer and Stream Management
Established connections expose methods for data exchange. The open_bi() method (line 232) creates bidirectional streams, while open_uni() creates unidirectional streams. These methods are available on Connection<HandshakeCompleted> and the zero-RTT variants, allowing you to send datagrams or open streams once the connection is established.
Stage 8: Path Management and Network Discovery
QUIC connections in iroh manage multiple network paths simultaneously. The paths() method (line 995) returns a PathList showing discovered routes, including relay and direct connections. Use paths_stream() or path_events() to observe path changes as the connection migrates between networks, which is crucial for maintaining connectivity across network changes.
Stage 9: Graceful Connection Closure
To shut down a connection, call close() (line 912), passing a VarInt error code and a reason byte slice. Alternatively, dropping all strong handles closes the connection implicitly. For monitoring closure without keeping the connection alive, use WeakConnectionHandle, whose upgrade() method (line 1248) returns Option<Connection>—yielding None after the connection closes.
Complete Code Example
The following example demonstrates the full lifecycle, including endpoint creation, dialing with optional 0-RTT, accepting incoming connections, and graceful closure:
use iroh::{Endpoint, presets};
// 1️⃣ Create an endpoint (listen on a random port)
let ep = Endpoint::builder(presets::Minimal)
.bind()
.await?;
// -------------------------------------------------
// 2️⃣ Outgoing connection (dial)
let conn_fut = ep.connect(remote_addr, b"my/alpn");
// 3️⃣ Optional 0-RTT before the handshake finishes
let conn_fut = match conn_fut.await?.into_0rtt() {
Ok(out0rtt) => out0rtt, // we have a zero-RTT connection
Err(connecting) => connecting, // fall back to normal connecting
};
// 4️⃣ Await the handshake (now we have a fully-established connection)
let conn = conn_fut.await?; // `Connection<HandshakeCompleted>`
// -------------------------------------------------
// 5️⃣ Incoming side – accept a new connection
if let Some(incoming) = ep.accept().await {
let accepting = incoming.accept()?; // <- `Accepting`
let conn = accepting.await?; // <- `Connection<HandshakeCompleted>`
}
// -------------------------------------------------
// 6️⃣ Use the connection (open a bidirectional stream)
let (mut send, mut recv) = conn.open_bi().await?;
send.write_all(b"hello").await?;
send.finish().await?;
let payload = recv.read_to_end(1024).await?;
// -------------------------------------------------
// 7️⃣ Close the connection gracefully
conn.close(noq::VarInt::from_u32(0), b"done");
Summary
- The lifecycle starts with
Endpoint::builder().bind()and proceeds through type-specific states that enforce correct usage at compile time. - Outgoing connections progress through
Connecting→ optionalOutgoingZeroRttConnection→Connection<HandshakeCompleted>. - Incoming connections progress through
Incoming→Accepting→ optionalIncomingZeroRttConnection→Connection<HandshakeCompleted>. - Data transfer uses
open_bi(),open_uni(), and datagram methods on established connections, with path management viapaths()and related methods. - Connections close via
close(error_code, reason)or handle drops, withWeakConnectionHandleavailable for monitoring without keeping the connection alive.
Frequently Asked Questions
What is the difference between Connecting and Accepting in iroh?
Connecting (line 138 in connection.rs) represents the client-side future awaiting an outgoing handshake, while Accepting (lines 140-144) represents the server-side state after Incoming::accept() is called. Both eventually resolve to Connection<HandshakeCompleted> but originate from different sides of the connection establishment—client-initiated versus server-accepted.
How does 0-RTT work in the iroh connection lifecycle?
0-RTT allows data transmission before the TLS handshake completes. For outgoing connections, call Connecting::into_0rtt() (lines 528-549) to get an OutgoingZeroRttConnection. For incoming connections, call Accepting::into_0rtt() (lines 594-617) to get an IncomingZeroRttConnection. These zero-RTT variants later transition to standard Connection<HandshakeCompleted> once the handshake finishes, enabling low-latency communication.
When should I use WeakConnectionHandle instead of Connection?
Use WeakConnectionHandle when you need to monitor a connection's lifecycle without preventing it from closing. Unlike Connection, which keeps the connection alive when held, WeakConnectionHandle allows the connection to close when all strong handles drop. Call upgrade() (line 1248) to attempt obtaining a Connection; it returns None if the connection has already closed.
Where does the actual QUIC state machine implementation live in iroh?
The state machine lives in the underlying noq crate, which provides the core QUIC protocol implementation. The iroh crate in iroh/src/endpoint/connection.rs provides safe Rust abstractions over noq, exposing the connection lifecycle through types like Connecting, Accepting, and Connection<State> that map cleanly to the underlying QUIC handshake phases.
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 →