Skip to content

quic: route a migrated client to the reactor that owns its connection - #210

Merged
MDA2AV merged 11 commits into
mainfrom
feat/quic-cid-steering
Aug 20, 2026
Merged

quic: route a migrated client to the reactor that owns its connection#210
MDA2AV merged 11 commits into
mainfrom
feat/quic-cid-steering

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Fixes #205.

Every reactor binds the QUIC port with SO_REUSEPORT, and the kernel picks between them by hashing
the sender's address. That is the right answer only while the address holds still. A NAT rebind or a
client changing network re-hashes to a reactor that has never heard of the connection, whose
short-header packets it drops - so the connection dies on the reactor that could still have served
it. #209 taught the transport to migrate; this is what lets the packets reach it. It bites the
default configuration, since ReactorCount defaults to one per core.

The connection cannot move to meet the packet: the ngtcp2_conn, the picotls session, the open
streams and their ring-bound buffers are owned by one reactor thread. So the datagram moves instead,
which is ordinary message passing over the queue reactors already expose.

Every server-minted connection id now carries its owner in the first byte, chosen so
cid[0] % ReactorCount is that reactor with the rest left random.

QuicOptions.Routing

never migrates migrates
Forward (default) nothing - path never entered ~8.5 µs/datagram
KernelFilter free with headroom, ~-12% at saturation nothing

Forward has the receiving reactor copy the datagram and post it to the owner. The copy is the
point: the payload lives in that reactor's provided-buffer ring, returned as soon as dispatch ends,
so passing a pointer would be a use-after-free under load. Only short headers are forwarded - a long
header's id is chosen by the client, and routing on a byte the peer controls would let anyone aim
traffic at a reactor of their choosing.

KernelFilter attaches a classic-BPF program to the reuseport group. It needs reactors to open
their UDP sockets in shard order (the program answers with a position in the group, and that is bind
order) - a rendezvous that exists only under this mode. It degrades rather than fails: if the kernel
refuses the program, forwarding stays underneath. Correctness never depends on the filter, only cost
does.

The forward cost is a cross-thread wake, not work: with every datagram forwarded, CPU/request moved
2.44 → 2.46 µs while throughput halved at fixed concurrency and the reactors sat a third idle. So
Forward charges only connections that migrate; KernelFilter charges every packet a little kernel
work. Unless a large share of clients migrate, Forward wins in aggregate.

Tests

StartQuicSharded is new because every other QUIC entry point in the harness pins
ReactorCount = 1, where a datagram has nowhere wrong to land. Each mode has a test that
discriminates in the opposite direction - Forward asserts datagrams were forwarded, KernelFilter
asserts none were and that the program attached, so it cannot pass vacuously as Forward under
another name. Confirmed both ways: suppressing the forward makes the Forward test fail.

431 pass, 0 fail. Benchmarks vs main: +0.4% / -2.0% / +1.4% / -1.0% / +0.8% - all inside noise.

Also here

A latent build break. iq_sync_path called ngtcp2_sockaddr_eq, which lives in
lib/ngtcp2_addr.h and is not shipped under lib/includes. It compiled only by implicit
declaration and fails outright on GCC 14+. Replaced with a local mirror over the public types; a
plain memcmp will not do, since sockaddr padding and flowinfo would read as an address change and
fire migration callbacks on a connection that never moved.

iq_abi, a exported-surface revision the managed side checks when building an engine, so a stale
libioxide_ngtcp2.so fails at startup with a clear message rather than passing garbage across a
boundary that just grew two parameters. Stale .so files have silently invalidated audit runs here
before.

UdpOptions.SocketBufferBytes (default unchanged at 8 MiB) plus a read-back. SO_RCVBUF clamps
to net.core.rmem_max rather than failing, so on a stock box the 8 MiB request is granted at ~1/39th
and the only symptom is 4.2% of datagrams dropped under load. ioxide now reports the shortfall once.
It deliberately does not advise raising the cap: granting the full 8 MiB cost ~45% of h3
throughput at saturation on unmodified main, trading early drops for a deep standing queue. The
message states the fact and leaves the judgement.

Docs. /how-ioxide-does-h3 covers the whole surface - layers, TLS on QUIC being a separate stack
from TLS on TCP, SNI, ReplaceCertificates, mutual TLS, migration, and both routing modes.
learn/quic-h3.html said unknown short-header packets are dropped as stale traffic, true when
written and no longer so.

MDA2AV added 11 commits August 19, 2026 20:50
…honour it

The receive/send buffer asked for on every UDP socket was a private const of 8 MiB, with a comment
noting the kernel clamps it to net.core.rmem_max and calling the result "best-effort headroom". The
comment was right and the consequence was invisible: on a stock Linux box that ceiling is 212,992
bytes, so the 8 MiB request is granted at about a fortieth of its size, and the only symptom is
datagrams dropped under load - which reads as a bug in whatever is running on top.

Measured here, unmodified: 4.2% of datagrams dropped to rcvbuf overflow on the h3 benchmark at
saturation. setsockopt does not fail in this case, it clamps and reports success, so nothing said
so.

Two changes. SocketBufferBytes becomes a UdpOptions knob (default unchanged at 8 MiB), because an
operator who raises the ceiling has no way to tell ioxide to use it. And the granted size is read
back with getsockopt and reported once per process when it falls well short - once, not per reactor
per port, since the clamp is a property of the machine.

The message deliberately stops at the fact and does not tell anyone to raise the cap. Granting the
full 8 MiB on this machine cost about 45% of h3 throughput at saturation on unmodified main: the
drops stopped, a deep standing queue took their place, and peers timed out and retransmitted
instead - 1.2 datagrams per request became 5.2. A shallow buffer drops early, and early drops are
the signal congestion control is built to read. Which behaviour is wanted depends on the
deployment, so this reports and leaves the judgement.

A zero or negative request is refused rather than clamped, because it would leave the socket on the
kernel minimum and look identical to the clamp it is trying to make visible.
…s connection (#205)

Fixes #205. Every reactor binds the QUIC port with SO_REUSEPORT and the kernel chooses between them
by hashing the sender's address. That is the right answer only while the address holds still, so a
NAT rebind or a client changing network re-hashes to a reactor that has never heard of the
connection, whose short-header packets it then drops. #209 taught the transport to migrate; this is
what lets the packets reach the connection that could act on it. It bites the default configuration,
since ReactorCount defaults to one per core.

The connection cannot move to meet the packet. The ngtcp2 conn, the picotls session, the open
streams and their ring-bound buffers are owned by one reactor thread and documented reactor-thread-
only throughout; moving live state to whichever reactor a datagram landed on is precisely what
shared-nothing forbids. So the datagram moves instead, which is ordinary message passing and rides
ScheduleOnReactor - already public, already used for exactly this.

Every connection id the server mints now carries its owning reactor in the first byte, chosen so
cid[0] % ReactorCount is that reactor while the rest stays random (iq_stamp_shard). A reactor that
receives a short header for an id it does not have reads that byte, copies the datagram and posts it
to the owner.

The copy is the point, not an inefficiency: the payload lives in the receiving reactor's io_uring
provided-buffer ring, which is returned as soon as dispatch ends, so handing the owner a pointer
into it would be a use-after-free under load. What crosses a thread is bytes, never reactor state.

Only short headers are forwarded. A short header means the handshake finished, so the id is one this
server minted and its first byte really does name the owner. A long header carries an id the CLIENT
chose, and routing on a byte the peer controls would let anyone aim traffic at a reactor of their
choosing.

QuicOptions.Routing offers the alternative. KernelFilter additionally attaches a classic-BPF program
to the reuseport group so the kernel routes by connection id directly. It is not the default, and
the measurements say why - h3 benchmark, two reactors, one machine:

    Forward       nothing at all until a client moves, then ~8.5us per datagram
    KernelFilter  free with CPU headroom, ~-12% throughput at saturation, nothing for migrated

The forward cost is a cross-thread wake, not work: CPU per request moved 2.44 -> 2.46us with every
datagram forwarded, while throughput halved at fixed concurrency, and the reactors sat 36% idle. So
Forward charges only the connections that actually migrate; KernelFilter charges every packet a
little kernel work, invisible until there is no headroom left. Unless a large share of clients
migrate, Forward is cheaper in aggregate.

KernelFilter needs reactors to open their UDP sockets in ShardIndex order, since the program answers
with a position in the reuseport group and that position is bind order. That rendezvous exists only
under KernelFilter; the default leaves startup untouched. It also degrades rather than fails: if the
kernel refuses the program, ioxide says so and forwarding stays underneath. Correctness never
depends on the filter, only cost does.

Both modes are tested and each discriminates in the opposite direction - Forward asserts datagrams
were forwarded, KernelFilter asserts none were and that the program actually attached, so it cannot
pass vacuously as Forward under another name. Confirmed both ways: suppressing the forward makes the
Forward test fail. StartQuicSharded is new because every other QUIC entry point in the harness pins
ReactorCount = 1, where a datagram has nowhere wrong to land.

QuicForwardsSent/Received/Dropped and QuicStaleDatagrams are exposed for operators. The distinction
matters and only the shard byte makes it possible: a short header for an unknown id that belongs
elsewhere is a routing event, while one addressed here is ordinary - a migration retires ids and
packets in flight still carry them.

Also here, found on the way and not separable from the shim rebuild:

iq_sync_path was calling ngtcp2_sockaddr_eq, which lives in lib/ngtcp2_addr.h and is NOT shipped
under lib/includes. It compiled only by implicit declaration and stops building outright on GCC 14+,
where that is an error. Replaced with a local mirror over the public types; a plain memcmp will not
do, since sockaddr padding and flowinfo would read as an address change and fire migration
callbacks on a connection that never moved.

iq_abi is a new exported surface revision the managed side checks when it builds an engine, so a
stale libioxide_ngtcp2.so fails at startup with a clear message instead of passing garbage across a
boundary that just grew two parameters. Stale .so files have silently invalidated audit runs here
before.
The learn section had QUIC & HTTP/3, which walks a datagram through the transport and is written for
someone reading the code. What it does not answer is the set of questions a deployment actually has:
which certificate is served for which name, how one gets renewed without dropping traffic, how
client certificates are checked, what happens when a client's address changes, and what a fleet of
reactors does about it. Those were spread across doc comments and samples or nowhere at all.

/how-ioxide-does-h3 collects them. It covers the three layers and what each owns, why TLS on QUIC is
a separate stack from TLS on TCP with its own configuration (the thing most likely to catch someone
running both), SNI and why the host table closes at CreateFactory, ReplaceCertificates and its three
surprising properties, mutual TLS and why PeerCommonName exists rather than substring-matching the
subject, connection migration, and the routing modes added in #205 with the measurements behind the
default.

It links to QUIC & HTTP/3 rather than repeating it: this page is what the server does, that one is
how the code does it.

quic-h3.html gains the same routing material in short form, because its ingress walkthrough said
unknown short-header packets are dropped as stale traffic - true when it was written, and no longer
true for a fleet, where such a packet may belong to a sibling reactor. The full comparison lives on
the new page so the two cannot drift.
… the panes

Every sample carries its full knob set at the shipping default, so a reader can see what is
configurable without going looking. QuicOptions.Routing was missing from all twelve QUIC and
HTTP/3 samples.

Set explicitly to QuicRouting.Forward - the default - with what the choice actually means: Forward
costs nothing until a client changes address, KernelFilter has the kernel route by connection id
instead and costs a little on every packet. The comment points at /how-ioxide-does-h3 rather than
restating the measurements, so there is one place for them to be wrong.

Site panes regenerated from the samples, since they are generated rather than written.
H3TestClient built a fresh HTTP/3 session inside Request() - ih3_client_new plus a new control,
QPACK encoder and QPACK decoder stream - on every call. HTTP/3 permits exactly one control stream
per peer, and RFC 9114 6.2.1 requires a second one to be treated as a connection error of type
H3_STREAM_CREATION_ERROR. So from the second request onward this client was speaking invalid HTTP/3.

The server was right and said so. nghttp3 returned H3_STREAM_CREATION_ERROR from read_stream on the
duplicate stream (type 0x00 carrying SETTINGS, and later a duplicate QPACK decoder), the run loop
set _protocolFailed, exited, and closed the connection - about a millisecond after the first
response. Every nghttp3 run loop did it: buffered sync, buffered async, streaming, and streamed
response. The pure-C# stack did not, because it never saw a second control stream: it only ever ran
one request per connection in these tests.

None of that was visible, for the worst possible reason. The requests already in flight kept being
answered off state the transport had already dismantled - the CIDs unregistered, the connection out
of _quicConnSet, PeerAddr freed and zeroed, the transport's reference dropped - so three more
requests returned 200 apiece and every test stayed green. A test client that provokes a connection
error and then passes anyway is worse than one that fails.

Found while investigating why a migrated connection's pinned socket died instantly: the pin is
released in QuicRemoveConnection, which this was triggering after the first response. Fixing this is
a prerequisite for that work, and for any test that means to exercise more than one request over one
connection.

The session now stands up once per connection; only the bidi request stream and the response state
are per request.

431 pass across E2E, Unit, Chaos, Http, Tls and File. Connections now live to the end of their test
and exit clean (isClosed=True, protocolFailed=False) rather than on a protocol failure.
H3TestClient never called iq_conn_handle_expiry. The shim has exported it, and iq_conn_expiry
beside it, since the engine binding landed - the client simply never fired either. So it had no
loss recovery whatsoever: a dropped datagram left its packet unacked forever, nothing retransmitted,
the congestion window filled, and writev_stream began answering 0 with nothing consumed. Both ends
then sat silent until the 10 s write deadline called it a stall.

The diagnosis took a while because the symptom accuses the server. What settled it was that the
client's own sent and received datagram counts were FROZEN across every spin - it was neither
sending nor receiving, so it was not blocked on anything the peer had done. Flow control looks
different and recovers: the streaming-upload test shows n=-208 (STREAM_DATA_BLOCKED) and comes back.
Here it was n=0 with consumed=-1, which is ngtcp2 saying it has nothing to send at all.

Nothing here ever lost a packet before, which is why an omission this size survived: every
connection served one request over loopback and was gone. Once connections started living across
several requests, and an address change started discarding datagrams in flight to the forwarder's
old socket, the gap became a hang - and it presented as a server that stopped answering after a
migration, which is exactly the bug it is not.

Timers fire from PumpIn, which every wait loop already drives.

Three consecutive full E2E runs green; the migration tests had been failing roughly two runs in
three before this.
…d to mean

The mtls test "a client certificate that is not valid yet is refused" failed today, reporting that
the server had served a not-yet-valid certificate. It had not. The fixture had gone stale.

Spec fixtures are cached by their spec rather than by freshness, deliberately, because most of them
are supposed to be invalid and re-minting an expired one every run would defeat the test. That is
safe in one direction only. An expired certificate stays expired forever; one minted to be NOT YET
VALID becomes valid the moment its notBefore arrives. This fixture is minted with
notBefore = now + 1 day, so the copy left in /tmp yesterday became a perfectly good certificate
overnight, the server accepted it exactly as it should, and the test called that a product bug.

A test that fails once a day has passed is a bug in the test, and one that accuses the product of a
security defect is worse than most.

The cached file is now reused only while its actual validity state still matches the intent - not
yet started if the spec asked for that, expired if it asked for that. Confirmed both ways: planting
an already-valid certificate in the not-yet-valid fixture's place makes the cache re-mint it
(CN=alice back to CN=future-alice, notBefore tomorrow), where before it was served as-is and the
test failed.

Unrelated to the QUIC work in flight; found because the date rolled over mid-session.
…r one hop

Cross-reactor forwarding (#205) is correct but permanent. The kernel picks a socket by hashing the
sender's address, and after a migration that address does not change back - so every one of that
client's datagrams keeps landing on the wrong reactor and keeps paying a cross-thread hop for the
life of the connection.

A datagram socket bound to the same address as the others but connect()ed to one peer is a MORE
SPECIFIC match than a wildcard bind, and the kernel's lookup takes the narrowest match before it
ever reaches the reuseport hash. So the owning reactor opens one toward the peer's new address and
the datagrams arrive there directly.

Three things about that, each measured rather than assumed. connect() on a datagram socket puts
nothing on the wire - it is a local declaration, and the peer is never told. Adding one does not
re-scatter anybody else: eight established peers, none moved. And it cannot bootstrap itself, since
the owning reactor only learns the new address from a datagram and the datagrams are going
elsewhere - forwarding has to deliver the first one. The two are complements: forwarding makes the
claim possible, the claim stops forwarding being forever.

The claim is made from the reactor's sweep, NOT from the engine's path-change report, and that
distinction is the whole difference between working and thrashing. ngtcp2 reports a path many times
while it validates one, alternating between the old address for data and the new one for
PATH_CHALLENGE probes. Claiming on each report tore down a working socket and built another, with
datagrams already queued on the one it closed - losing packets in order to avoid a hop, which is the
wrong way round. Measured: five claims for a single address change, flip-flopping between two ports.
From the sweep it is one claim per address, because by then the path has settled.

Only connections that actually moved are claimed. The sweep visits every connection every 250 ms, so
a guard that asked merely "is this address claimed yet" would spend a descriptor and an armed
receive on every connection on the server to change nothing - they are already being delivered here
by the hash. PeerAddressMoved is the signal, set where the address is adopted.

Bounded and best-effort throughout. A ceiling of 512 concurrent claims, because ngtcp2 adopts a new
path BEFORE it finishes validating it, so a forged datagram can reach here; past the ceiling the
connection simply keeps forwarding. A bind that fails is swallowed for the same reason - this runs
under a callback whose exceptions fault the connection, and faulting a working connection to skip an
optimisation is a bad trade. Ignored entirely under KernelFilter, where the kernel already routes by
connection id.

QuicPinsCreated and QuicPinsOpen expose it. Tests cover both directions: that the forwarding stops
after a claim, that one address change produces one claim rather than one per path report, and that
a fleet whose clients never move claims nothing and forwards nothing.

Also here, because the migration tests could not be trusted until it was proven: both h3 stacks now
assert that the SAME connection object serves before and after the address changes, and that the
factory ran exactly once. Status codes cannot tell migration from a client quietly re-handshaking,
and a reconnect would take the h3 session, the QPACK tables and any application state with it while
the tests stayed green. Confirmed both ways - asserting a DIFFERENT connection makes both fail.

436 tests pass; E2E stable across three consecutive runs. Benchmarks against main: -0.4% Tls/OpenSsl,
-0.3% Nghttp3Response, -1.1% ManagedBuffered, -0.1% ManagedStreamedBoth, and Nghttp3Buffered repeats
at +2.5%/+1.9% against a baseline that measures +2.1% against itself - so no regression on either h3
stack, buffered or streamed.
Roughly half the prose added across #205 and the claim work said the same thing twice, or explained
the investigation rather than the code. Kept: why the packet moves instead of the connection, why
the copy is not optional, why the claim runs from the sweep, and the classic-BPF listing, which is
unreadable without it.

One comment was also wrong - it said the claim runs inside ngtcp2's path-change callback, which
stopped being true when it moved to the sweep.

No behaviour change; 436 tests pass.
Two pages covering one subject, with the routing material duplicated across both. Now one:
deployment first (layers, TLS on QUIC, SNI, rotation, mTLS, migration, routing, the counters), then
the packet-level walk - ingress, read surface, egress, the engines, timers, and the invariants.

learn/quic-h3.html is deleted and every sidebar and inbound link repointed. No broken links.
@MDA2AV
MDA2AV merged commit ad637c4 into main Aug 20, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

quic: multi-reactor has no connection-id routing, so a rebinding client reaches the wrong reactor

1 participant