Skip to content

fix(iouring): restore the unconditional recv-pause state assignment (regression from #511) - #516

Closed
FumingPower3925 wants to merge 3 commits into
mainfrom
fix/iouring-pause-retry-spin
Closed

fix(iouring): restore the unconditional recv-pause state assignment (regression from #511)#516
FumingPower3925 wants to merge 3 commits into
mainfrom
fix/iouring-pause-retry-spin

Conversation

@FumingPower3925

@FumingPower3925 FumingPower3925 commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Fixes a regression I introduced in #511, currently on main.

What #511 got wrong

#511 stopped marking a conn paused when the cancel SQE could not be obtained. The intent was to avoid recording a pause that had not actually been submitted — but with no retry, that means the middleware's requested pause never takes effect: recvPauseDesired stays true, recvPaused stays false, and PauseRecv only enqueues on the false→true transition, so nothing tries again.

Under sustained backpressure the peer keeps filling a buffer nobody drains and the conn hangs. That regressed the #482 guard with close-timeouts on kernel 6.17 CI runners, where io_uring probes at tier=optional and the ring fills far more readily than on the cluster at tier=high.

What my first attempt got wrong

The first version of this PR re-enqueued the conn to retry. That was worse: the worker loop drains the queue every iteration, so it spun on that conn instead of reaping the completions that would free the ring — and because the append happens while the drain iterates its swapped-out spare slice, each pass appended the same conn again.

The fix

Restore the shipped semantics — mark the conn paused unconditionally — and keep the one genuine improvement from #511: getCancelSQE submits and retries once, so the cancel actually lands far more often than with a bare GetSQE.

The residual risk is the one #482 already documented: on a full ring the recv is still kernel-armed while we record it as paused, so a later resume can arm a second multishot recv. That is pre-existing, rare, and strictly less harmful than a pause that never happens at all.

How the cause was identified

The guard failed on three PRs that touch neither io_uring nor WebSockets, and on main itself:

main                          6e9262c   482 guard: FAIL
fix/h1-detached-non-ws-bytes  0974855   482 guard: FAIL
fix/iouring-pause-retry-spin  749ae8a   482 guard: FAIL   (first attempt — did not fix it)
fix/basicauth-pbkdf2          105fc52   482 guard: pass   (before #511 merged)
fix/session-cookie-before-write a68289f 482 guard: pass   (before #511 merged)

The transition sits exactly at #511. I initially read the first failure as CI flakiness because main looked green at the time; that was wrong, and the second and third occurrences are what forced the correct diagnosis.

Note the cluster passes this test either way at tier=high — only the constrained runner exposes it, which is why local verification was not sufficient here.

…rker

#511 added a re-enqueue when getCancelSQE could not obtain an SQE, so the
pause would be retried. That was wrong in two ways, and it regressed main.

The worker loop calls drainDetachQueue on every iteration. Re-enqueueing the
conn and re-arming detachQPending therefore makes the worker drain the queue
again immediately instead of reaping the completions that would free the ring
-- and because the append happens while the drain iterates its swapped-out
spare slice, each pass appends the SAME conn again, so the queue grows without
bound for as long as the ring stays full.

The symptom is the celeris#482 regression guard failing on io_uring with
close-timeouts: TestBackpressurePauseDoesNotCancelInflightSend reported
conns that never completed their Close handshake, on kernel 6.17 CI runners
where io_uring probes at tier=optional and the ring fills more readily than on
the cluster. It failed on two PRs that touch neither io_uring nor WebSockets
(#513 ratelimit, #515 h1), which is what identified #511 rather than either of
them as the cause.

Drop the re-enqueue and keep the correctness property: still do NOT record the
conn as paused when the cancel was not submitted, because its multishot recv
is still kernel-armed and a resume would arm a second one. The pause is simply
deferred -- recvPauseDesired stays true and recvPaused stays false, so the next
drain retries. A conn under backpressure is by definition still moving data, so
another drain is imminent. Until then recv stays armed, which costs throughput
on one conn but is always safe, unlike a phantom pause.
@FumingPower3925

Copy link
Copy Markdown
Contributor Author

Cluster verification, msa2-server (amd64, kernel 7.0.0-30), the #482 regression guard with this fix, 3 consecutive runs:

--- PASS: TestBackpressurePauseDoesNotCancelInflightSend (22.38s)
--- PASS: TestBackpressurePauseDoesNotCancelInflightSend (22.66s)
--- PASS: TestBackpressurePauseDoesNotCancelInflightSend (22.55s)
PASS

Zero close-timeouts. engine/iouring full suite also PASS on the same host.

My #511 stopped marking a conn paused when the cancel SQE could not be
obtained. With no retry that means the middleware's requested pause NEVER
takes effect: recvPauseDesired stays true, recvPaused stays false, and
PauseRecv only enqueues on the false->true transition, so nothing tries again.
Under sustained backpressure the peer keeps filling a buffer nobody drains and
the conn hangs -- which regressed the celeris#482 guard with close-timeouts on
kernel 6.17 CI runners, where io_uring probes at tier=optional and the ring
fills far more readily than on the cluster at tier=high.

The first attempt at a fix re-enqueued the conn instead. That was worse: the
worker loop drains the queue every iteration, so it spun on that conn rather
than reaping the completions that would free the ring, and each pass appended
the same conn again.

Restore the shipped semantics -- mark the conn paused unconditionally -- and
keep the one genuine improvement from #511: getCancelSQE submits and retries
once, so the cancel actually lands far more often than with a bare GetSQE.

The residual risk is the one #482 documented: on a full ring the recv is still
kernel-armed while we record it as paused, so a later resume can arm a second
multishot recv. That is pre-existing, rare, and strictly less harmful than a
pause that never happens at all.
@FumingPower3925 FumingPower3925 changed the title fix(iouring): do not re-enqueue a dropped recv-pause, it spins the worker fix(iouring): restore the unconditional recv-pause state assignment (regression from #511) Sep 7, 2026
revive's empty-block fired on the sqe == nil arm, which after restoring the
unconditional pause assignment held only the explanation. Invert to
`if sqe := w.getCancelSQE(); sqe != nil` and keep the reasoning above the
branch, where it explains the unconditional assignment rather than an empty
arm. No behaviour change.
@FumingPower3925

Copy link
Copy Markdown
Contributor Author

Superseded by #518, which reverts #511 verbatim and is green on CI.

#516 restored only the unconditional cs.recvPaused assignment and still failed, which localised the cause to the other half of #511: getCancelSQE calling Submit() from inside the drain loop. The revert is the correct resolution; the double-arm concern #511 was chasing is pre-existing and now tracked separately.

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.

1 participant