QUIC & HTTP/3

ioxide serves HTTP/3 through three layers, each with one job: the core's QUIC transport routes UDP datagrams to logical connections, ioxide.quic runs the protocol engine (ngtcp2 + picotls, bundled native), and ioxide.h3 turns decrypted stream bytes into requests and responses (nghttp3, bundled native). This page walks the whole path a request takes, and ends with the invariants that were learned the hard way.

Why QUIC can't reuse the TCP plumbing

The TCP side is built around one fact: one connection = one fd. The reactor's connection table is keyed by fd, recv completions arrive per-fd, and the kernel does the demultiplexing. QUIC inverts all of it:

So QUIC gets its own demux in the reactor, its own connection type, and an engine that owns all the cryptography - while reusing the UDP layer (multishot recvmsg + GRO) and the io_uring send path underneath.

The layer map

kernel
io_uring UDP - multishot recvmsg, GRO coalescing in, GSO segmentation out. One socket per reactor via SO_REUSEPORT.
Reactor/Transport/Udp/*
core · demux
QuicDispatch - splits GRO trains per segment, parses the DCID (RFC 8999), routes to a live connection or adopts a new one via the factory.
Reactor/Transport/Quic/Reactor.Quic.cs
ioxide.quic
QuicEngineConnection - feeds datagrams to ngtcp2 (decrypt, ACK, flow control), copies decrypted stream events into the recv queue, pumps egress back out with GSO batching. picotls does the TLS 1.3 handshake.
src/ioxide.quic/* + libioxide_quic.so
core · read surface
QuicConnection - the handler-facing API: ReadAsync over an SPSC ring of stream items, SendStream, the two-owner refcount. Mirrors TcpConnection's pattern, shares no code with it.
Connection/Quic/*
handler
Reactor.QuicHandle - your delegate, launched once per adopted connection; the QUIC twin of TcpHandle.
ioxide.h3
H3Connection - nghttp3 rides the read surface: assembles requests (QPACK, framing), runs your req => resp callback, drains response frames back through SendStream.
src/ioxide.h3/* + libioxide_h3.so

Wiring it up is two lines in the host:

var engine = new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["h3"]);

var config = new ServerConfig
{
    // Tcp defaults still bind :8080 - there is no TCP opt-out yet.
    Quic = new QuicOptions
    {
        Port = 8443,                   // UDP port, bound automatically on every reactor
        LocalCidLength = 8,            // must match the engine's cidLength
        ConnectionFactory = engine.CreateFactory(),
    },
};

reactor.TcpHandle  = Handlers.Raw;
reactor.QuicHandle = (r, conn) => new H3Connection(conn).RunAsync(
    static req => H3Response.Text($"hello {req.Path}"));

Ingress: the life of a datagram

A client datagram lands as a multishot recvmsg completion on the reactor thread, and everything below happens inline in that dispatch - same as the TCP side, there is no handoff to another thread anywhere in the path.

  1. GRO split. The kernel may hand us a train - several datagrams from the same peer socket coalesced into one buffer. Connections share client sockets, so one train can interleave packets of different connections. QuicDispatch splits the train and routes each segment independently.
  2. DCID demux. The first bytes of a QUIC packet are cleartext and version-independent (RFC 8999). Long headers carry an explicit DCID length; short headers carry exactly the LocalCidLength bytes this endpoint mints. The DCID is looked up in the reactor's _quicConns dictionary.
  3. Adopt or route. A known DCID goes straight to its connection. An unknown long-header packet is a new handshake: the factory runs iq_accept (validates the Initial, creates the ngtcp2 conn, mints our SCID), the reactor snapshots the peer address, registers the CIDs, inits the two-owner refcount, and launches your QuicHandle fault-observed. Unknown short-header packets are dropped - stale traffic from dead connections.
  4. Engine read. OnDatagram feeds the payload to iq_conn_read. ngtcp2 decrypts, handles ACKs and flow control, and fires callbacks mid-call - the important one being stream data.
  5. Copy and enqueue. ngtcp2's decrypted spans die when iq_conn_read returns, so OnStreamData copies each event into a pooled buffer and enqueues a (StreamId, bytes, Fin) item on the connection's SPSC recv ring. Stream lifecycle (closed / reset / stop-sending) rides the same ring as Kind-tagged items, so the handler sees everything in order.
  6. Fire once. Only after iq_conn_read has fully unwound does the engine fire the read signal - the same inline-resume IVTS as TCP. The handler resumes synchronously on the reactor thread, drains the ring, sends responses. By the time the loop re-enters the kernel, those responses are already staged.
Why "fire once, after the read unwinds"? The handler resumes inline when the signal fires. If OnStreamData fired it directly, the handler would run - and call SendStream, re-entering ngtcp2 - while ngtcp2 is still executing iq_conn_read above it on the same stack. Deferring the wake to after the engine call makes reentrancy impossible by construction.

The read surface

The handler-facing API deliberately mirrors TcpConnection - the arm flag, the sticky pending bit that closes the lost-wakeup race, the generation token - but it is a separate implementation. The two transports share a pattern, not a base class: TCP's items are buffer-ring slots with bids; QUIC's are pooled copies tagged with stream ids. The write sides have nothing in common at all.

// A raw QUIC handler: one per connection, streams demuxed by the item's StreamId.
reactor.QuicHandle = async (r, conn) =>
{
    try
    {
        while (true)
        {
            QuicRecvSnapshot snap = await conn.ReadAsync();

            while (conn.TryGetItem(in snap, out QuicRecvRing.Item item))
            {
                if (item.Kind == QuicStreamEvent.Data)
                    conn.SendStream(item.StreamId, item.AsSpan(), item.Fin);  // echo
                conn.ReturnItem(in item);   // pooled buffer back to the pool
            }

            if (snap.IsClosed) break;
            conn.ResetRead();
        }
    }
    finally { conn.DecRef(); }   // release the handler's ref
};

Lifecycle is the same two-owner refcount as TCP: the transport holds one reference, the handler holds the other, and teardown (freeing the peer-address block, unrouting CIDs) only runs when both are gone - so an evicted connection can never be freed under a live handler. All teardown funnels through QuicRemoveConnection, whether the engine closed (error, idle) or the sweep evicted.

Egress: SendStream and the retention contract

There is no IBufferWriter, no write slab, and no FlushAsync on the QUIC side - deliberately. TCP hands you a raw byte pipe and you await the send for backpressure. In QUIC the engine owns framing, pacing, congestion control and retransmission; SendStream(streamId, bytes, fin) hands bytes over and returns. Awaiting a flush would await nothing meaningful.

Two hard rules shape the implementation:

War story. Both rules exist because their violations shipped first. Passing fixed spans of reused buffers to ngtcp2 was harmless for a while - only because a timer bug meant retransmission never ran. The day the timer was fixed, every loss retransmitted STREAM frames out of recycled memory: segfaults in ngtcp2_pkt_encode_stream_frame on a good day, silently corrupted frames on the wire on a bad one.

On the wire side, datagrams produced during one engine cycle are batched into a single UDP_SEGMENT (GSO) send - one syscall for up to a 63 KB run of equal-size datagrams instead of one per packet.

The native engines and their shims

Neither ngtcp2 nor nghttp3 is P/Invoked directly. Both APIs are built on large versioned structs and callback tables whose layout shifts between releases - marshaling those from C# would break silently on every upstream bump. Instead each package bundles a small C shim that owns every struct layout (compiled against the exact vendored headers) and exposes a flat, stable ABI:

Both .so files are committed and packed into the NuGet runtimes/linux-x64/native/, so consumers install nothing.

TLS 1.3 and ALPN

QUIC folds TLS into the transport (RFC 9001): handshake messages travel in CRYPTO frames, and TLS's job shrinks to the handshake plus key derivation - packet protection itself is QUIC's own AEAD, applied by ngtcp2. In this stack picotls runs that handshake; the managed side never touches TLS at all. That's why QuicConnection has no handshake or crypto surface, and why the cert/key go into QuicEngine's constructor.

ALPN is enforced in the shim's client-hello hook: new QuicEngine(cert, key, alpn: ["h3"]) installs an allowlist - a client offering none of the listed protocols fails the handshake with no_application_protocol, per the RFC. With no allowlist the server accepts whatever the client offers first. The chosen token is surfaced as QuicConnection.NegotiatedProtocol after the handshake.

The HTTP/3 layer

ioxide.h3 references only the core - not ioxide.quic. It needs nothing but the abstract read/write surface, so it would ride any future engine (quicly, etc.) unchanged. One H3Connection wraps one QuicConnection:

reactor.QuicHandle = (r, conn) => new H3Connection(conn).RunAsync(
    static req => H3Response.Text($"hello {req.Path} via {req.Method}"));

Inside RunAsync:

  1. Lazy setup. On the first wake (which is by definition post-handshake), it opens the three server unidirectional streams H3 requires - control + QPACK encoder/decoder - binds them into nghttp3, and the SETTINGS preface rides out on that same drain.
  2. Assembly. Every recv item is fed to nghttp3_conn_read_stream; nghttp3 demuxes uni-stream types itself and fires header/data/end callbacks, which accumulate into an H3Request (method, path, headers, body).
  3. Dispatch. Completed requests run your callback on the reactor thread; the H3Response (status, headers, body) is submitted back to nghttp3, whose output frames are drained through SendStream - response bodies are copied into shim memory that lives until the stream closes, because nghttp3 holds references too.
  4. Lifecycle mirroring. The Kind-tagged items keep nghttp3's view of every stream in sync with QUIC's: reset → shutdown_stream_read, stop-sending → shutdown_stream_write, closed → close_stream. A cancelled request is torn down on both sides instead of half-ignored.

QPACK runs with a zero-size dynamic table (the nghttp3 default we keep): static-table-only compression, no encoder/decoder state to corrupt, still ~87% header savings in practice.

Timers

QUIC is timer-hungry - loss detection and PTO probes need millisecond deadlines, and the reactor's 250 ms ticker is far too coarse: a retransmit that waits 250 ms per loss turns load spikes into self-sustaining storms. So deadlines are split:

Rules learned the hard way

Each of these is load-bearing; every one of them shipped broken first and was found by a benchmark, a packet capture, or a core dump.

RuleOr else
Demux GRO trains per segmentTrains interleave datagrams of different connections (they share the client's 4-tuple); routing a whole train by its first DCID feeds other connections' packets to the wrong engine, which silently drops them.
Retain stream bytes until ackedngtcp2 keeps pointers into your buffers for retransmission. Reused buffers become corrupted retransmitted frames - or a segfault inside the packet encoder.
Fire the read signal after iq_conn_read unwindsThe handler resumes inline and re-enters ngtcp2 mid-callback, on the same stack.
Never discard engine-deferred bytesnghttp3 already accounted them as written; the stream starves forever and the connection live-locks around it.
initial_max_streams is a window, not a capExtend it as streams close (ngtcp2_conn_extend_max_streams_*) or every connection stalls for good after its first 100 requests.
An engine expiry can already be in the pastUnsigned expiry - now underflows and schedules the retransmit timer ~584 years out, permanently killing that connection's loss recovery.
Free H3 response bodies only at stream closenghttp3 holds references into the body for output it already accepted; freeing on stop-sending is a use-after-free.

Continue with Architecture for the reactor model these layers ride on, or core internals for the TCP-side counterparts.