Skip to content

fix(iouring): never arm a second recv on a connection that already has one (#484) - #560

Merged
FumingPower3925 merged 3 commits into
mainfrom
fix/484-double-armed-recv
Sep 10, 2026
Merged

FumingPower3925 merged 3 commits into
mainfrom
fix/484-double-armed-recv

Conversation

@FumingPower3925

Copy link
Copy Markdown
Contributor

Fixes #484

The defect

The WebSocket backpressure pause submits an ASYNC_CANCEL for the armed recv and marks the connection paused. The cancel has not landed yet. A handler that drains below the low watermark before it does makes drainDetachQueue take the resume branch, which called prepareRecv unconditionally — leaving two recvs in flight on one socket.

Both target cs.buf, the per-connection buffer, so the kernel's second write lands on top of the first one's unread bytes. The WebSocket parser then reads a length field at the wrong offset and reports a garbage connection index:

invalid conn index in payload: 2478706466968590878
1 frame parse error(s) observed — frames were corrupted

Multishot is immune, which is why the bug only ever showed on the default single-shot path: each completion carries its own provided-ring buffer, so two in-flight recvs cannot overwrite each other.

How it was found

A detector in prepareRecv that logs whenever it is asked to arm a recv on a connection that already has one:

LEDGER double-arm fd=71  gen=33 bufring=false paused=true needsRecv=false inflight=2
LEDGER double-arm fd=164 gen=57 bufring=false paused=true needsRecv=false inflight=2

paused=true while the arm is in progress pins the call site to exactly one place: the resume branch assigns cs.recvPaused = desired after arming, so the log still sees the old value. The SQ-ring-full path that skips the cancel fired 0 times in the same runs, so a full submission queue is not involved.

The change

  • prepareRecv declines the second arm and reports success. "A recv is armed for this conn" is the postcondition every caller acts on, so returning false would make them set needsRecv and retry from the dirty list forever. The driver path at engine/iouring/driver.go has carried the same guard since it was written.
  • The dirty-list retry gets an explicit recvArmed check of its own, because it calls pickRecvTarget, which mutates cs.recvIntoBody. Computing a target for an arm that is about to be declined would route the in-flight recv's CQE through the direct-body path.
  • New connState.recvCancelPending. A pause withdrawn before the cancel lands used to leave -ECANCELED arriving with recvPaused already false, so handleRecv fell through to the generic negative-result path and closed a healthy connection. The flag marks a cancel this worker asked for so that CQE re-arms instead. Every other cancelConnOps caller nils w.conns[fd] or sets closing first, so no other cancellation can reach this branch.

Measurement

io_uring single-shot variant run on its own, which reproduces far more readily than the three-variant test. 24 runs each, 4 workers, memlock 128 MiB, seccomp=unconfined.

before after
runs reporting frame corruption 2 of 24 0 of 24
total single-shot failures 8 of 24 4 of 24
double-arm detector firings 6 1

The four remaining failures all report parseErr=0. They carry closeTimeout and per-connection protocol errors, plus one frame-count mismatch — a different signature from the corruption this PR removes. Those are being characterized separately and will get their own issue; this PR does not claim to address them.

engine/iouring/recv_arming_test.go fails on a control build with the guard removed (ring pending 1→2) and passes with it.

…s one (#484)

The WebSocket backpressure pause submits an ASYNC_CANCEL for the armed recv
and marks the connection paused. The cancel has not landed yet. A handler
that drains below the low watermark before it does makes drainDetachQueue
take the resume branch, which called prepareRecv unconditionally — leaving
two recvs in flight on one socket.

Both target cs.buf, the per-connection buffer, so the kernel's second write
lands on top of the first one's unread bytes. The WebSocket parser then
reads a length field at the wrong offset and reports a garbage connection
index. Multishot is immune: each completion carries its own provided-ring
buffer, so two in-flight recvs cannot overwrite each other.

prepareRecv now declines the second arm and reports success. "A recv is
armed for this conn" is the postcondition every caller acts on, so a false
would make them set needsRecv and retry from the dirty list forever. The
driver path has carried the same guard since it was written.

The dirty-list retry gets an explicit recvArmed check of its own because it
calls pickRecvTarget, which MUTATES cs.recvIntoBody: computing a target for
an arm that is about to be declined would route the in-flight recv's CQE
through the direct-body path.

Second defect in the same window: a pause withdrawn before the cancel lands
leaves -ECANCELED arriving with recvPaused already false, so handleRecv fell
through to the generic negative-result path and closed a healthy connection.
recvCancelPending marks a cancel this worker asked for so that CQE re-arms
instead. Every other cancelConnOps caller nils w.conns[fd] or sets closing
first, so no other cancellation reaches this branch.

Measured on the io_uring single-shot variant, 24 runs each, 4 workers,
memlock 128 MiB. Runs reporting frame corruption: 2 of 24 before, 0 of 24
after. Total single-shot failures 8 of 24 before, 4 of 24 after; the
remaining four report closeTimeout, protocol errors and one frame-count
mismatch, all with parseErr zero, which is a different signature.

recv_arming_test.go fails on a control build with the guard removed
(ring pending 1 to 2) and passes with it.
@FumingPower3925

Copy link
Copy Markdown
Contributor Author

Correcting the measurement table in the description

The before/after table above is wrong and I am withdrawing it. The mechanism, the fix and the unit-test negative control all stand; the failure-rate numbers do not.

The trap. inbound_sequence_linux_test.go prints its counter summary at line 324, and calls settle() — which drains the handler goroutines — at line 331. The assertions read the settled counters at line 338 and after. Every number I quoted came from that summary line, so it is a snapshot taken before the drain and it understates parseErr. Six runs whose summary said parseErr=0 went on to fail the assertion at line 340 with "frame parse error(s) observed". The authoritative signal is the assertion line, not the summary.

Re-measured properly, 24 runs of the single-shot variant on a clean tree carrying only this change, capturing every test log line:

count
single-shot runs failing 6 of 24
...reporting invalid conn index in payload 0
...reporting parse error on conn N: unexpected EOF 6 (7 connections total)
multishot runs failing 0 of 24

So the two signatures separate cleanly. invalid conn index in payload: <garbage 64-bit value> is the mid-stream corruption this PR is about: a length field read at the wrong offset, which is exactly what two kernel writes into one cs.buf produce. It does not appear once in 24 runs here. unexpected EOF is a different thing — the frame stream ends part-way through a frame, which is truncation at teardown, not a wrong offset.

I do not yet have a clean-tree baseline measured the same way, because the containers holding the earlier logs were removed before I noticed the summary-line problem. A 24-run baseline on origin/main with identical capture is running now. Do not merge until that lands — the mechanism is proven and the unit test is a real regression guard, but the claim about the failure rate needs a like-for-like comparison behind it.

The unexpected EOF residual is a separate defect and will get its own issue rather than being folded in here.

@FumingPower3925

Copy link
Copy Markdown
Contributor Author

Like-for-like baseline, and what it does and does not show

24 runs of the single-shot variant on a clean origin/main tree, same capture script, tallied from the assertion lines rather than the summary:

clean main this PR
single-shot runs failing 5 of 24 6 of 24
...reporting invalid conn index in payload 0 0
...reporting parse error on conn N: unexpected EOF 6 6
multishot runs failing 0 of 24 0 of 24

So the corruption signature does not reproduce on either clean tree at this sample size. The two runs that showed it were on the ledger-instrumented build, where the detector's stderr writes changed the timing enough to widen the window — the same instrumentation moved the multishot failure rate from 0 of 24 to 5 of 24. I am not going to claim a failure-rate improvement from a signature that the baseline never produced.

What the measurement does support, and what this PR rests on:

  1. The double arm is real and was observed directly. The detector fired five times across instrumented baseline runs, each time with paused=true, which places the call at the resume branch and nowhere else. It fired once with the guard in place, and the guard declined it.
  2. Two recvs into one cs.buf is a defect on its own terms. Whether it manifests once in twelve runs or once in a thousand, the kernel writing into a buffer whose earlier contents have not been read is not something to leave in the engine. The driver path has always refused it.
  3. The negative control is decisive. recv_arming_test.go fails with the guard removed (ring pending 1→2) and passes with it.
  4. The -ECANCELED path is a separate real defect fixed here: a pause withdrawn before its cancel landed made handleRecv close a healthy connection.

The unexpected EOF residual sits at 5-6 of 24 on both trees, so it predates this change and is not affected by it. It gets its own issue.

Race check: the full websocket suite under -race reports no DATA RACE and no deadlock. It does fail two tests on the protocol error on unfailed client signature; a -race baseline on clean main is running to confirm that is also pre-existing.

@FumingPower3925

Copy link
Copy Markdown
Contributor Author

Race baseline: the -race failures are pre-existing

The full websocket suite under -race on clean origin/main also fails:

--- FAIL: TestBackpressureInboundSequenceIntegrity/io_uring/multishot_recv (17.80s)
    conn 29: frame count mismatch: in=62710, sent=64000
    conn 67: frame count mismatch: in=59470, sent=64000
FAIL  github.com/goceleris/celeris/middleware/websocket  995.195s

Neither run reports a DATA RACE or a deadlock — not on this branch and not on main. The suite is simply flaky under -race at this concurrency, on both trees, and the failing subtest differs run to run.

One caveat I am not going to paper over: on the branch, the -race run failed TestBackpressurePauseDoesNotCancelInflightSend/io_uring with protoErr=1, and the baseline run did not. That is one observation each, and that test exercises exactly the pause/cancel path this PR touches. A targeted 24-run comparison of that test alone is running on both trees now. I will hold the merge until it comes back.

The unexpected EOF residual is now #562. It is io_uring-single-shot-specific: 5 of 24 on clean main, 6 of 24 here, 0 of 24 on multishot, 0 of 24 on epoll.

@FumingPower3925

Copy link
Copy Markdown
Contributor Author

Targeted control on the pause/cancel test: pre-existing, and no worse here

TestBackpressurePauseDoesNotCancelInflightSend/io_uring run 24 times on each tree, on its own:

tree failures
clean origin/main 4 of 24
this branch 3 of 24

Same assertion on both, N handler(s) saw a protocol error on the read side, with N ranging 1–6 on main and 1–16 here. The single -race observation that made me hold the merge does not survive a real sample: the test is flaky on main at roughly the same rate, and this branch is if anything marginally better.

That was the last thing gating this PR. Summary of what the merge rests on:

  • The double arm was observed directly, five times, always with paused=true, which places the call at the resume branch and nowhere else.
  • Two recvs armed on one socket both write into cs.buf. That is a defect independent of how often it surfaces, and the driver path has always refused it.
  • recv_arming_test.go fails with the guard removed (ring pending 1→2) and passes with it.
  • No DATA RACE and no deadlock under -race, on either tree.
  • No failure-rate claim. invalid conn index in payload is 0 of 24 on both clean trees; it only appeared on the instrumented build, whose stderr writes widened the window.

The unexpected EOF residual is #562 and the protocol-error flake above is separate from both.

@FumingPower3925
FumingPower3925 merged commit 2db1551 into main Sep 10, 2026
10 checks passed
@FumingPower3925
FumingPower3925 deleted the fix/484-double-armed-recv branch September 10, 2026 02:33
FumingPower3925 added a commit that referenced this pull request Sep 13, 2026
…t the BP that opens the #484 window

Adds four engine-wide witnesses to the io_uring worker and exports them
through engine.EngineMetrics, plus a per-conn recvOutstanding count:

  RecvResumeWhileCancelPending  resume processed with recvCancelPending set
  RecvResumeWhileRecvInFlight   ...and the cancelled recv still armed (the
                                exact celeris#484 window; the pending flag
                                alone also counts resumes after a cancel
                                that MISSED, so it over-approximates ~1000x)
  RecvArmDeclined               prepareRecv declined an arm (recvArmed)
  RecvDoubleArmed               a second recv SQE placed on one conn
                                (recvOutstanding reached 2), any site
  RecvCQEUnaccounted            terminal recv CQE for a live conn with
                                recvOutstanding == 0: a kernel-held recv
                                the bookkeeping never counted. The only
                                witness independent of cs.recvArmed.

Direct atomic adds, not per-iteration batches: the batch flush runs
before drainDetachQueue and is skipped when the loop returns, and these
are per-event invariants where one event refutes.

The WS484 oracle reads them after settle() and hard-asserts
RecvDoubleArmed == 0 and RecvCQEUnaccounted == 0 on io_uring. Two new
socketpair tests drive the real call sites (drainDetachQueue pause ->
resume before any reap -> -ECANCELED re-arm -> data CQE), and the stale
bookkeeping case the guard cannot see.

Measured (docker golang:1.27, kernel 7.0.12-linuxkit, 4 cpus, memlock
128 MiB, workers=4 in every run, single-shot io_uring subtest ALONE,
96 conns x 4 bursts x 16000 frames, no -race). N=12 per build per BP;
control = guard turned into a count + dirty-list guard reverted:

  BP    build    windows(InFlight)  doubleArmed  cqeUnaccounted  parseErr  PASS
  8     control  12 in 9/12 runs    6441          0              11 (8 runs)  3/12
  8     fixed    12 in 7/12 runs    0             0              0            12/12
  16    control   6 in 5/12         2818          0              4 (4 runs)   7/12
  16    fixed     9 in 3/12         0             0              0            12/12
  32    control   9 in 3/12         6322          0              7 (3 runs)   9/12
  32    fixed     4 in 3/12         0             0              0            12/12
  256   control   0 in 0/12         0             0              0            12/12
  256   fixed     0 in 0/12         0             0              0            12/12
  1e6   both      0                 0             0              0            24/24
  8     stale     4 in 4/12         0             131760         4 (4 runs)   0/12

Chosen BP = 8. At the fixture default (256) neither build enters the
window in 24 runs, so the pre-existing 0/24 vs 0/24 had no power. On the
fixed tree at BP=8 the window is entered 12 times and RecvDoubleArmed
and RecvCQEUnaccounted stay 0 with parseErr 0; on the control every
window entry places a second recv and the twin recvs stay doubled for
the rest of the connection (doubleArmed is amplified, armDeclined equals
the window count) and 8 of 12 runs corrupt frames. The stale-bookkeeping
build (recvArmed cleared after the pause cancel) corrupts frames in 4 of
12 runs with RecvDoubleArmed 0 in all of them and RecvCQEUnaccounted
~11000 per run: the kernel-side witness sees what the userspace guard
cannot.

Unit tests under -race: fixed tree 3/3 pass; control fails
TestPrepareRecvRefusesSecondArm (pending 1->2) and
TestResumeBeforeCancelLandsPlacesNoSecondRecv (doubleArmed 1); the
stale build fails the honest window test and passes the witness test.
FumingPower3925 added a commit that referenced this pull request Sep 13, 2026
…t the BP that opens the #484 window (#597)

Adds four engine-wide witnesses to the io_uring worker and exports them
through engine.EngineMetrics, plus a per-conn recvOutstanding count:

  RecvResumeWhileCancelPending  resume processed with recvCancelPending set
  RecvResumeWhileRecvInFlight   ...and the cancelled recv still armed (the
                                exact celeris#484 window; the pending flag
                                alone also counts resumes after a cancel
                                that MISSED, so it over-approximates ~1000x)
  RecvArmDeclined               prepareRecv declined an arm (recvArmed)
  RecvDoubleArmed               a second recv SQE placed on one conn
                                (recvOutstanding reached 2), any site
  RecvCQEUnaccounted            terminal recv CQE for a live conn with
                                recvOutstanding == 0: a kernel-held recv
                                the bookkeeping never counted. The only
                                witness independent of cs.recvArmed.

Direct atomic adds, not per-iteration batches: the batch flush runs
before drainDetachQueue and is skipped when the loop returns, and these
are per-event invariants where one event refutes.

The WS484 oracle reads them after settle() and hard-asserts
RecvDoubleArmed == 0 and RecvCQEUnaccounted == 0 on io_uring. Two new
socketpair tests drive the real call sites (drainDetachQueue pause ->
resume before any reap -> -ECANCELED re-arm -> data CQE), and the stale
bookkeeping case the guard cannot see.

Measured (docker golang:1.27, kernel 7.0.12-linuxkit, 4 cpus, memlock
128 MiB, workers=4 in every run, single-shot io_uring subtest ALONE,
96 conns x 4 bursts x 16000 frames, no -race). N=12 per build per BP;
control = guard turned into a count + dirty-list guard reverted:

  BP    build    windows(InFlight)  doubleArmed  cqeUnaccounted  parseErr  PASS
  8     control  12 in 9/12 runs    6441          0              11 (8 runs)  3/12
  8     fixed    12 in 7/12 runs    0             0              0            12/12
  16    control   6 in 5/12         2818          0              4 (4 runs)   7/12
  16    fixed     9 in 3/12         0             0              0            12/12
  32    control   9 in 3/12         6322          0              7 (3 runs)   9/12
  32    fixed     4 in 3/12         0             0              0            12/12
  256   control   0 in 0/12         0             0              0            12/12
  256   fixed     0 in 0/12         0             0              0            12/12
  1e6   both      0                 0             0              0            24/24
  8     stale     4 in 4/12         0             131760         4 (4 runs)   0/12

Chosen BP = 8. At the fixture default (256) neither build enters the
window in 24 runs, so the pre-existing 0/24 vs 0/24 had no power. On the
fixed tree at BP=8 the window is entered 12 times and RecvDoubleArmed
and RecvCQEUnaccounted stay 0 with parseErr 0; on the control every
window entry places a second recv and the twin recvs stay doubled for
the rest of the connection (doubleArmed is amplified, armDeclined equals
the window count) and 8 of 12 runs corrupt frames. The stale-bookkeeping
build (recvArmed cleared after the pause cancel) corrupts frames in 4 of
12 runs with RecvDoubleArmed 0 in all of them and RecvCQEUnaccounted
~11000 per run: the kernel-side witness sees what the userspace guard
cannot.

Unit tests under -race: fixed tree 3/3 pass; control fails
TestPrepareRecvRefusesSecondArm (pending 1->2) and
TestResumeBeforeCancelLandsPlacesNoSecondRecv (doubleArmed 1); the
stale build fails the honest window test and passes the witness test.
FumingPower3925 added a commit that referenced this pull request Sep 14, 2026
…ng (celeris#596) (#602)

cs.recvCancelPending was never retired when the backpressure pause's
ASYNC_CANCEL MISSED -- the recv completed with data before the cancel ran,
or nothing was armed -- so the flag stopped describing "a cancel is in
flight for this conn's recv". The resume branch reads it to witness the
celeris#484 window, and counted every resume after the first missed cancel:
RecvResumeWhileCancelPending 11179 / 10721 / 9769 at MaxBackpressureBuffer=8
and 5975 / 6045 / 5996 at 16, against a real RecvResumeWhileRecvInFlight of
0 or 1 (origin/main bef00fc, WS #484 oracle, io_uring single-shot, 3 runs
per BP).

The issue's premise -- "the cancel's -ENOENT CQE is tagged udProvide and
dropped" -- is not what the kernel does. Probed on 7.0.12: with
IORING_ASYNC_CANCEL_ALL a cancel that matched nothing completes as res == 0,
a SUCCESS, so CQE_SKIP_SUCCESS suppressed the completion entirely and there
was no CQE to drop. The miss was unobservable, not merely discarded.

Mechanism:

  * the pause's cancel is now submitted REPORTED (prepCancelUserDataReported,
    no CQE_SKIP_SUCCESS) and tagged udRecvCancel -- a conn-bound tag, so it
    passes the generation gate -- instead of the udProvide "drop it"
    sentinel. One extra CQE per backpressure pause; nothing on the
    per-request path changes.
  * handleRecvCancel retires the cancel when it cancelled nothing (res <= 0,
    covering both res == 0 and -ENOENT) and leaves it outstanding when it did
    (res > 0, or -EALREADY), because then the recv's own -ECANCELED is coming
    and handleRecv's re-arm branch (the #484 fix #560 guards) still needs it.
  * recvCancelPending becomes a COUNT. A conn that pauses, resumes and pauses
    again before the ring drains has two cancels in flight and they resolve
    in either order; as a bool the one that MISSED cleared the state the one
    that HIT still needed, and that -ECANCELED then fell through handleRecv's
    generic negative-result path and closed a healthy connection mid-stream.
    That is not theoretical: the bool-valued intermediate version failed the
    oracle once in 12 runs at BP=8 with parseErr=1 (io.ErrUnexpectedEOF,
    conn 66). Exactly one retirement per cancel, either from its own
    completion or from the -ECANCELED it caused.

Measured with the merged oracle (TestBackpressureInboundSequenceIntegrity,
io_uring single-shot only, N=12 per BP):

  BP=8   RecvResumeWhileCancelPending 0 in all 12 runs (was ~10^4)
  BP=16  0 in 11 runs, 1 in the twelfth -- the run that also reported
         RecvResumeWhileRecvInFlight=1, i.e. the two witnesses now agree
  both   RecvDoubleArmed=0, RecvCQEUnaccounted=0, parseErr=0, 24/24 PASS

framesSent is unchanged within noise (control 5.69/6.04/5.61M vs fix
5.81/5.99/5.63/5.66/5.66M at BP=8).

Rigs, both of which fail with the fix reverted:
  * TestMissedPauseCancelClearsCancelPending -- the miss, written without
    naming udRecvCancel so the body also runs on origin/main, where it fails
    on the state assertion.
  * TestMissedCancelDoesNotRetireASecondOutstandingCancel -- two cancels
    outstanding, miss resolving first; with retireRecvCancel reduced to bool
    semantics it reaches closeConn on a healthy conn.
  * TestResumeBeforeCancelLandsPlacesNoSecondRecv, extended to assert that a
    cancel which HIT does not retire the state its -ECANCELED needs.
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 WS: backpressure pause/resume drops buffered inbound bytes, truncating frames

1 participant