Skip to content

fix(iouring): make conn generations process-unique (#470) - #481

Merged
FumingPower3925 merged 3 commits into
mainfrom
fix/470-conn-generation-collision
Sep 3, 2026
Merged

FumingPower3925 merged 3 commits into
mainfrom
fix/470-conn-generation-collision

Conversation

@FumingPower3925

Copy link
Copy Markdown
Contributor

Fixes #470.

Root cause

The generation stamped into a conn-bound SQE's user_data is the only thing distinguishing one occupant of an fd from the next. acquireConnState incremented a field on the pooled connState:

cs := connStatePool.Get().(*connState)
cs.generation++   // "guarantees a reused connState/fd never shares its predecessor's gen"

connStatePool is a sync.Pool, and GC drains it under connection churn — so almost every acquire returns a freshly allocated connState whose generation goes 0 → 1. Measured on the validation workload:

973/973 connections   gen=1
628/628 close cancels gen=1

The generation provided zero disambiguation between successive occupants of an fd, which is the one thing it exists to do.

cancelConnOps submits an ASYNC_CANCEL keyed on the recv's user_data — (udRecv, fd, gen). When the fd is recycled before the kernel runs that cancel, the key matches the next connection's recv byte-for-byte. staleConnCQE sees the generations agree, accepts the CQE as that connection's own (clearing recvArmed, decrementing kernelInflight), and handleRecv treats the resulting -ECANCELED as a fatal read error and closes a healthy connection that has never been read.

staleConnCQE documents this as a KNOWN RESIDUAL at "1/65536 per reuse". That estimate assumes generations are spread across the 16-bit space. They are pinned at 1, so the real probability is ~100% for any fd reuse landing inside the cancel window.

Evidence

By connection identity (unique per-conn ids, not inference):

fd=130  live_cid=8255   killed by a cancel from cid=6241,  80us earlier   gen 1 == 1
fd=138  live_cid=23059  killed by a cancel from cid=21043, 65us earlier   gen 1 == 1

On the wire (tcpdump, loopback):

t=0.394424  client -> server  [S]                        SYN
t=0.394433  server -> client  [S.]                       SYN-ACK
t=0.394697  server -> client  [F.] seq=1 ack=127 len=0   FIN, ZERO response bytes
t=0.394717  server -> client  [R.] seq=2 ack=127         RST

ack=127 — the server ACKed the client's full 126-byte h2c request. seq=1, len=0 — it sent nothing. RST after FIN is the kernel's response to close() with a non-empty receive queue: the request was never read. 273 µs, mid-run.

The fix

Draw the generation from a process-monotonic counter instead of the pooled object's own field, skipping the reserved gen==0 (encodeUserDataGen collapses gen=0 onto the plain encodeUserData encoding). A collision now requires 65,536 intervening accepts on the same fd, which the microsecond-wide cancel window cannot span.

One production file, four functional lines. No new branches in the hot path, no behavioural change beyond the generation's source. engine/epoll has its own acquireConnState and is untouched.

Verification

Run with the real cmd/validator against a single refapp, VALIDATE_CONCURRENCY=120, per-cell parameters bit-identical to a production nightly cell.

check result
amd64, 24 cells 0 hangs / 232,128 read conns
arm64, 12 cells 0 hangs / 116,064 read conns
positive control — fix reverted, same harness 4 hangs / 6 cells / 58,032 conns
epoll, 3 cells 0 (unchanged)
std, 3 cells 0 (unchanged)
full engine/iouring suite on the SUT PASS
regression test without the fix FAILS (generation 1 reused (acquire #0 and #1))

P(0 hangs | pre-fix baseline of 1.25/cell over 24 cells) ~ 9e-14. Reverting the fix brings the bug straight back — causality, not correlation.

Performance

measurement v1.5.8 this branch delta
acquireConnState microbench 15.45 ns/op 16.22 ns/op +0.77 ns, 0 allocs
end-to-end conns / 45s (mean of 3, alternated) 1,437,649 1,438,729 +0.075%

The end-to-end difference sits inside the baseline's own 0.28% trial spread. At ~31 µs per connection the atomic is 0.0025% of the path.

Why this explains the whole history

27/27 h2c_hang events across 8 nightlies were io_uring — 0 epoll, 0 std (p ≈ 5e-13). ASYNC_CANCEL keyed on user_data has no epoll/std analogue. The event is load-correlated (4× walker fan-out → 5× events) because more churn means more fd reuse inside the cancel window.

Notes for review

  • The generation is still uint16 and wraps. A collision needs 65,536 intervening accepts on the same fd — far outside the cancel window — but that is a bound, not a proof. A wider field would need user_data layout changes; out of scope here.
  • handleRecv still treats -ECANCELED as fatal. With unique generations only a connection's own cancel can match, so that is now correct. I deliberately did not add defensive handling — it would be unneeded logic on the hot path.
  • Diagnostic apparatus used to find this is fully removed; probatorium is untouched by this branch.

The generation stamped into a conn-bound SQE's user_data is the only thing
distinguishing one occupant of an fd from the next. acquireConnState
incremented a field on the POOLED connState:

    cs := connStatePool.Get().(*connState)
    cs.generation++

connStatePool is a sync.Pool, and GC drains it under connection churn, so
almost every acquire returns a freshly allocated connState whose generation
goes 0 -> 1. Measured on the validation workload: 973/973 connections and
628/628 close-path cancels carried gen=1. The generation provided ZERO
disambiguation between successive occupants of an fd -- the one thing it
exists to do.

cancelConnOps submits an ASYNC_CANCEL keyed on the recv's user_data
(udRecv, fd, gen). When the fd is recycled before the kernel runs that
cancel, the key matches the NEXT connection's recv byte-for-byte.
staleConnCQE sees the generations agree, accepts the CQE as that
connection's own (clearing recvArmed and decrementing kernelInflight), and
handleRecv treats the resulting -ECANCELED as a fatal read error and closes
a healthy connection that has never been read.

staleConnCQE documents this as a KNOWN RESIDUAL at "1/65536 per reuse".
That estimate assumes generations are spread across the 16-bit space. They
are not: they are pinned at 1, so the collision probability is ~100% on any
fd reuse that lands inside the cancel window.

Proven by connection identity, not inference:
    fd=130  live_cid=8255   killed by a cancel from cid=6241, 80us earlier
    fd=138  live_cid=23059  killed by a cancel from cid=21043, 65us earlier
both with gen 1 == 1.

On the wire (tcpdump, loopback): the server ACKs the client's full 126-byte
h2c request, sends ZERO response bytes, then FIN followed by RST 273us
after SYN. The RST is the kernel's response to close() with a non-empty
receive queue -- the request was never read.

Fix: draw the generation from a process-monotonic counter instead of the
pooled object's own field, skipping the reserved gen==0 (encodeUserDataGen
collapses gen=0 onto the plain encodeUserData encoding). A collision now
requires 65536 intervening accepts on the same fd, which the
microsecond-wide cancel window cannot span.

No behavioural change beyond the generation's source: one atomic increment
per accepted connection, on a path that already performs accept,
getpeername and setsockopt syscalls. No new branches in the hot path.
engine/epoll has its own acquireConnState and is untouched.

Validation on the cluster (real cmd/validator, C=120, amd64):
  with fix:      0 hangs / 24 cells / 232,128 read conns
  fix reverted:  4 hangs /  6 cells /  58,032 read conns
P(0 | pre-fix baseline) ~ 9e-14.

Explains the full history: 27/27 h2c_hang events across 8 nightlies were
io_uring, 0 epoll, 0 std (p ~ 5e-13) -- ASYNC_CANCEL keyed on user_data has
no epoll/std analogue.
Records the cost of the change in conn.go: 15.45 -> 16.22 ns/op on
acquireConnState alone (+0.77 ns, 0 allocs), which is 0.0025% of a
~31us connection lifecycle and unmeasurable end to end (1,437,649 vs
1,438,729 conns per 45s across alternating trials).
Follow-up to the process-monotonic generation, from an adversarial review of
that fix. Two of its claims were wrong and the comment I wrote asserted them:

1. The wrap is PROCESS-WIDE, not per-fd. connGenSeq is drawn once per accepted
   fd across every worker, so a wrap costs 65536 accepts ANYWHERE. At this
   cluster's measured end-to-end rate (1,437,649 conns / 45s = ~31.9k
   accepts/s) a 16-bit generation wrapped every 2.05 SECONDS.

2. The binding window is NOT the microsecond cancel latency. It is the paths
   where no cancel is submitted at all: cancelConnOps skips the ASYNC_CANCEL
   when the SQ ring is full (worker.go:2504 has no else branch), leaving the
   op to the 5s pendingRelease backstop; and an armed udHeaderTimer lives for
   ReadHeaderTimeout, 10s by default. Those windows spanned 2.4 and 4.9 wraps
   of a 16-bit generation.

So the 16-bit fix reduced the #470 misroute by 3-5 orders of magnitude
(consistent with 0 hangs in 348,192 connections) but did not close it. The
residual per stranded op was ~1.3e-3 at the validator's C=120 shape.

The generation field was 16 bits only because the fd field was given 40 --
and the layout comment already noted fds and fixed-file indices are "both
< 65536 in practice". onAcceptedFD in fact REJECTS any fd >= fixedFileTableSize
(65536) outright, so 24 bits of fd is 256x the engine's own hard bound.

Re-splits user_data as op(56-63) | gen(24-55, 32 bits) | fd(0-23, 24 bits).
A collision now needs 2^32 intervening accepts -- ~37 hours at 31.9k/s -- which
no engine-side window can span. Same shift-or on the hot path: no added logic,
no measurable cost.

Also corrects the over-claiming comment in acquireConnState, and strengthens
the encoding tests: gens now span the full 32-bit range including the old
16-bit ceiling, and TestGenerationDiffersAcrossReuse asserts uniqueness
UNCONDITIONALLY. Its previous version could not -- it noted that "a fresh pool
object starts at generation 1, colliding with a once-used object's 1" and
worked around it. That comment was describing celeris#470 as a test flake.
@FumingPower3925

Copy link
Copy Markdown
Contributor Author

Verification complete

Cluster validation

Real cmd/validator, per-cell parameters bit-identical to a production nightly cell.

run engine arch cells read conns hangs
targeted C=120 (16-bit gen) iouring amd64 24 232,128 0
targeted C=120 (16-bit gen) iouring arm64 12 116,064 0
targeted C=120 (32-bit gen) iouring amd64 16 154,752 0
positive control — fix reverted iouring amd64 6 58,032 4
regression check epoll amd64 3 29,016 0
regression check std amd64 3 29,016 0

502,944 connections with the fix, zero events. Reverting reproduces the bug immediately. P(0 | pre-fix baseline of 1.25/cell over 24 cells) ~ 9e-14.

Full-breadth nightly

probatorium run 33771655960, on a branch pinning all 8 refapps to this commit — 48 cells (8 refapps x 3 engines x 2 arches), 0 hangs, 0 eof, 0 timeout.

Honest note on power: the nightly runs at the default C=30, where the historical rate was ~1-3 events per 48-cell run, so a zero there is consistent with the fix (p~0.14 if unfixed) rather than decisive on its own. Its contribution is breadth — the 27 historical events spanned six different refapps, and this covers all eight. The statistical weight is in the targeted runs above.

Correctness

  • Full engine/iouring suite passes on the SUT (both 16-bit and 32-bit generation).
  • Regression test fails without the fix: generation 1 reused (acquire #0 and #1).
  • CI green on all 7 jobs including the race-enabled Unit job.

Performance

measurement v1.5.8 this branch delta
acquireConnState microbench 15.45 ns/op 16.22 ns/op +0.77 ns, 0 allocs
end-to-end conns / 45s (mean of 3, alternated) 1,437,649 1,438,729 +0.075%

Inside the baseline's own 0.28% trial spread. At ~31 us per connection the atomic is 0.0025% of the path. The 32-bit widening adds nothing further — same shift-or.

Second commit: 32-bit generation

An adversarial review of the first commit found my safety claim was wrong in both clauses, and I had written it into the code:

  1. The wrap is process-wide, not per-fd — 65536 accepts anywhere. At the measured ~31.9k accepts/s a 16-bit generation wrapped every 2.05 seconds.
  2. The binding window is not the cancel latency but the paths where no cancel is submitted at all: cancelConnOps skips it on a full SQ ring (worker.go:2504, no else), leaving the op to the 5s pendingRelease backstop; an armed udHeaderTimer lives for 10s. Those spanned 2.4 and 4.9 wraps.

So the 16-bit fix cut the misroute by 3-5 orders of magnitude but left a residual (~1.3e-3 per stranded op). The generation was 16 bits only because the fd field had 40 — and onAcceptedFD rejects any fd >= 65536 outright, so 24 bits of fd is 256x the engine's own bound. Re-split as op(56-63) | gen(24-55, 32 bits) | fd(0-23): a collision now needs 2^32 accepts, ~37 hours at that rate.

Related, deliberately NOT in this PR

#482 — the WS recv-pause path cancels by raw fd (CANCEL_FD|CANCEL_ALL), which also kills the connection's in-flight SEND; handleSend has no -ECANCELED case and closes a healthy conn mid-broadcast. Same failure shape as #470 on a route this fix cannot reach. Kept separate to keep this PR minimal.

Note for the merger

probatorium branch test/470-verify-branch-pin pins the refapps to this commit's pseudo-version purely for the breadth run. It must not be merged — once this lands and a release tag exists, re-pin through the normal path.

@FumingPower3925
FumingPower3925 merged commit 677c536 into main Sep 3, 2026
7 checks passed
@FumingPower3925
FumingPower3925 deleted the fix/470-conn-generation-collision branch September 3, 2026 17:17
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.

io_uring: connState generations pinned at 1 let a close-path ASYNC_CANCEL kill the next conn on a recycled fd

1 participant