How to Open a Bidirectional QUIC Stream with Iroh

To open a bidirectional QUIC stream with Iroh, instantiate a QuicClient with TLS configuration, establish a noq::Connection via create_conn(), and invoke the open_bi() method to obtain a stream implementing both AsyncRead and AsyncWrite for simultaneous duplex communication.

Iroh implements its QUIC transport layer on top of the noq library, wrapped within the iroh-relay crate. According to the n0-computer/iroh source code, bidirectional streams are created by progressing through three distinct phases: endpoint initialization, connection establishment via the QuicClient struct in iroh-relay/src/quic.rs, and stream creation using noq::Connection methods.

Understanding the QUIC Architecture

Iroh delegates raw QUIC protocol handling to the noq crate, which manages connection state, congestion control, and stream multiplexing. In this architecture, a bidirectional stream represents a single logical channel within a QUIC connection where both peers can send and receive data concurrently.

The implementation centers on two primary types:

  • noq::Connection: Represents an established QUIC connection between client and server
  • QuicClient: Wraps the endpoint configuration and TLS state required to initiate connections (defined at lines 60-66 in iroh-relay/src/quic.rs)

Step-by-Step: Establishing a Bidirectional Stream

Configure the Endpoint and TLS

Before initiating any connection, you must bind a local UDP socket through noq::Endpoint and configure TLS with the Iroh-specific ALPN identifier /iroh-qad/0. The source code demonstrates this using make_dangerous_client_config() from the iroh_relay::tls module for testing environments.

Create the Connection

Invoke QuicClient::create_conn() to perform the QUIC handshake and return a noq::Connection object. This method, implemented at lines 53-63 in iroh-relay/src/quic.rs, handles version negotiation and certificate validation, producing a connection ready for stream operations.

Open the Bidirectional Stream

Call open_bi() on the Connection instance. This async method returns a bidirectional stream suitable for both sending and receiving data. The stream implements standard async I/O traits, allowing you to use write_all() to transmit data and read() to receive responses from the peer.

Complete Working Example

The following Rust code demonstrates the full lifecycle from endpoint creation through bidirectional communication, following the pattern validated by the quic_endpoint_basic test at lines 84-106 in iroh-relay/src/quic.rs:

use std::net::SocketAddr;
use iroh_relay::quic::{QuicClient, ALPN_QUIC_ADDR_DISC};
use noq::Connection;               // re‑exported from the `noq` crate

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 1️⃣ Create a client endpoint (bind to an OS‑assigned port)
    let client_ep = noq::Endpoint::client("0.0.0.0:0".parse()?)?;
    // 2️⃣ Build a TLS config that trusts the server (dangerous for demo purposes)
    let client_cfg = iroh_relay::tls::make_dangerous_client_config();
    // 3️⃣ Wrap the endpoint + TLS config in an Iroh `QuicClient`
    let quic_client = QuicClient::new(client_ep, client_cfg);

    // 4️⃣ Connect to the remote QUIC server (replace with your server address)
    let server_addr: SocketAddr = "127.0.0.1:4433".parse()?;
    let conn: Connection = quic_client
        .create_conn(server_addr, "localhost")
        .await?;                     // ← `noq::Connection`

    // 5️⃣ Open a **bidirectional** stream
    let mut stream = conn.open_bi().await?;   // `open_bi` → `noq::Connection`

    // 6️⃣ Send data
    stream.write_all(b"Hello, Iroh!").await?;

    // 7️⃣ Receive the echo (or any response)
    let mut buf = vec![0u8; 64];
    let n = stream.read(&mut buf).await?;
    println!("Peer replied: {}", String::from_utf8_lossy(&buf[..n]));

    // 8️⃣ Gracefully close the connection (optional – the server will also close)
    conn.close(QUIC_ADDR_DISC_CLOSE_CODE, QUIC_ADDR_DISC_CLOSE_REASON);
    Ok(())
}

Connection Lifecycle and Error Handling

When the peer terminates the connection gracefully, Iroh receives a ConnectionError::ApplicationClosed event. The server-side handle_connection function in iroh-relay/src/quic.rs (lines 24-38) demonstrates proper cleanup: closing the endpoint with a predefined error code and awaiting task completion. You should expect this error variant as a normal shutdown signal rather than an exceptional condition.

Bidirectional streams automatically handle flow control and congestion management internally through the noq layer. If the remote peer closes their end of the stream while you are still reading, the read() method returns Ok(0) to indicate EOF, while write_all() continues functioning until you close your sending side or the connection terminates.

Summary

  • Use QuicClient from iroh-relay/src/quic.rs (lines 60-66) to manage endpoint configuration and TLS state with the Iroh ALPN
  • Call create_conn() (lines 53-63) to establish a noq::Connection to the remote peer
  • Invoke open_bi() on the connection to obtain a bidirectional stream supporting simultaneous read/write operations
  • Handle ApplicationClosed as a normal termination signal when the peer shuts down the connection gracefully
  • Reference the quic_endpoint_basic test in iroh-relay/src/quic.rs (lines 84-106) for a validated round-trip implementation

Frequently Asked Questions

What is the difference between unidirectional and bidirectional QUIC streams in Iroh?

Unidirectional streams permit data flow in only one direction, created using open_uni() on the noq::Connection. Bidirectional streams, created via open_bi(), provide two independent flows within a single stream—one for each direction—behaving similarly to a TCP socket. Iroh primarily uses bidirectional streams for request-response patterns where both peers need to exchange data simultaneously.

How does Iroh configure TLS for QUIC connections?

Iroh uses the ALPN protocol identifier /iroh-qad/0 (referenced as ALPN_QUIC_ADDR_DISC in the source) to negotiate application-layer protocols during the TLS handshake. For client connections, the QuicClient accepts a TLS configuration created via make_dangerous_client_config() for testing or production-grade configurations for deployment. The TLS state is bound to the noq::Endpoint during QuicClient initialization.

Why does Iroh use the noq crate instead of implementing QUIC directly?

The noq crate provides a Rust-native, async-compatible QUIC implementation that handles low-level protocol details like packet encryption, stream multiplexing, and congestion control. By building on noq, Iroh leverages battle-tested transport logic while focusing its own development on peer-to-peer networking, hole punching, and relay protocols. This abstraction is visible in iroh-relay/src/quic.rs where noq::Connection objects are the primary interface for stream operations.

How do I gracefully close a bidirectional stream in Iroh?

Call conn.close(error_code, reason) on the noq::Connection object to initiate a graceful shutdown, as implemented in the handle_connection function at lines 24-38 of iroh-relay/src/quic.rs. This sends a CONNECTION_CLOSE frame to the peer with the specified application error code. For individual streams, dropping the stream object or finishing the write side signals EOF to the remote peer while keeping the connection alive for other streams.

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 →