Skip to content

fix(iouring): hold a closed connection's send buffer until its SEND_ZC notification, past the release backstop (celeris#812) - #813

Draft
FumingPower3925 wants to merge 6 commits into
mainfrom
fix/celeris-812-zc-backstop
Draft

FumingPower3925 wants to merge 6 commits into
mainfrom
fix/celeris-812-zc-backstop

Conversation

@FumingPower3925

Copy link
Copy Markdown
Contributor

Fixes #812.

Stacked on #793. The base is fix/celeris-685-close-recv-theft (#793's head, bf129c8). GitHub retargets this PR to main when #793 merges, because the repo deletes merged branches. Until then, the CI workflow does not run on this PR: ci.yml triggers only on pull requests to main. So all the results below come from local runs, listed under Evidence.

The defect

On io_uring, the pendingRelease backstop released a closed connection's connState 5 s after the close, even while a SEND_ZC notification was still owed. A plain connection's connState went back to connStatePool with its sendBuf. A detached one went to the GC.

A SEND_ZC does not copy. When a peer stops reading, the unsent tail of the send stays queued on the socket as references to sendBuf's pages. After the close, the orphaned socket keeps offering that tail until one of two things happens: the peer reads, or the kernel gives up on the socket. Only then does the kernel post the notification.

Meanwhile the array had a new owner, and that owner wrote into it. When the peer read again, it received the tail from the array: another connection's bytes.

The fix

Accounting. A closed identity's closedOpsEntry now counts its SEND_ZC separately, in zcOwed. The field sits in the padding byte between handoff and fdOps, so the entry is still 32 bytes.

  • noteClosedInflight adds one for each conn whose send in flight is a SEND_ZC: sendIsZC && (sending || zcNotifPending).
  • noteStaleTerminalOp takes it off at that op's terminal CQE, which is the notification.
  • A first CQE without F_MORE has no notification to follow, so it also ends the hold, but only when the identity holds one conn. Under an (fd, generation) collision, only a notification ends a hold. Holding late costs memory. Releasing early sends a peer another connection's bytes.

The hold. Past the backstop, drainPendingRelease holds any entry that still owes a SEND_ZC (closedZCOwed → holdZCPastBackstop). This covers detached entries too. The entry stays until the notification retires the identity, and is then released the usual way.

Shutdown. Closing the ring does not end a SEND_ZC's use of its pages, and once the ring is closed nothing can report when that use ends. The Worker was the last thing holding those connStates, so once the engine was dropped, the GC could hand the arrays on.

  • So retainZCSendBufsAtShutdown runs just before the ring closes. Every send buffer a SEND_ZC may still read goes into a list that lives as long as the process, whether it belongs to a live connection (liveZCOwed) or to one queued for release (closedZCOwed).
  • No wait is added. The run loop's 250 ms send drain already waits for live connections' sends, since cs.sending stays set until the notification, and a stalled peer's notification can take minutes.
  • When something would be kept, it first collects what the kernel has already completed. It makes one enter that submits nothing and waits at most 1 ms, which also runs a DEFER_TASKRUN ring's task work, and it retires those CQEs. Nothing is submitted, because shutdownDrivers has already closed the driver descriptors.
  • Follow-ups from #793: a SEND_ZC notification counted as an owed op, the unpinned park gate and shutdown drain, and two counter/exit nits #798's TestShutdownDoesNotWaitForAZCNotification still passes. endOwedOpsAtShutdown is unchanged.

New EngineMetrics fields. All three are io_uring only, and adaptive reports the sum over both sub-engines.

CI (the recv-theft job, both arches):

Tests

test what it judges
TestBackstopHoldsASendBufferAZCNotificationStillReads (failing-first commit 33bb077) The wire. #798's fixture: a 64 KiB SEND_ZC to a loopback peer with a 4 KiB receive buffer, closed the way the closing-drain sweep closes it. The backstop is made due at once by setting the entry's releaseAtNanos; the production value is unchanged. The peer stays stalled for 300 ms of loop passes. The moment the array stops being held (the connState leaves pendingRelease, or stays there without the array), the test writes 0xAA over it, as its next owner would. Then the peer reads to EOF, and every byte must be byte(i). Four cases: notif-only, recv-and-notif, send-done-after-close (the first CQE was unread at the close), and detached-notif-only.
TestBackstopCountsItsZCHolds The same four cases, checked through the counters: held 1, CloseZCNotifForced 0, CloseFDForced 0.
TestPendingReleaseBackstopHoldsAZCSendPastItsDeadline The hold's own decision, on hand-built state with no kernel, so it also runs where SEND_ZC does not. Two cases: notification owed, and send owed with the descriptor kept. In the second, the descriptor is forced and counted, and the connState stays.
TestClosedOpsCountsTheZCSendApart zcOwed driven by hand-built CQEs: the notification, first CQE then notification, a first CQE without F_MORE, a recv's terminal CQE, a plain send, and a collision.
TestDroppingAnIdentityWithAZCOwedIsCounted Positive control of the must-stay-0 counter.
TestShutdownRetainsSendBuffersAZCMayStillRead Three cases. A live connection with its notification owed is retained and counted. A live connection whose notification is in the ring, unread, is not retained. A held closed connection is retained.

Evidence

Every number below comes from the scripts and logs in the maintainer's evidence directory, celeris-zc-backstop/fix/:

  • scripts/chain2.sh and chain3.sh: the runs.
  • scripts/zcfix-run.sh: one container per run, golang:1.27, linuxkit 7.0.12 aarch64, --security-opt seccomp=unconfined, through the laptop slot lock. Shapes: m8 is --cpus 4 with memlock 8 MiB (CI's unit job); ci is --cpus 4 with memlock unlimited.
  • scripts/zcfix-cistep.sh: runs a step of the tree's own ci.yml, verbatim.
  • scripts/zcfix-tally.py: tallies every --- PASS/FAIL/SKIP line and every result line.
  • scripts/buildcheck.sh: the build, vet and lint checks.
  • scripts/mktree.sh: builds each tree; README.txt maps the trees to commits.

All runs use -race. There were 0 data races and 0 panics in any log.

The fix against controls and mutants (TestBackstopHoldsASendBufferAZCNotificationStillReads unless stated):

tree shape × runs result per case, every run
failing-first 33bb077 (#793 head + the test) m8 × 3 15/15 --- lines FAIL Released at the first pass after the backstop with the op still owed. The peer read all 65,536 bytes plus EOF, and 61,200 were corrupt.
fix ddf0786 m8 × 3, ci × 3 111/111 PASS each (the six #812 tests, #798's two, and the three layout and metrics tests) Held past the backstop. Released 546–570 ms after the close, which is after the peer read and the notification arrived, with 0 owed. 0 corrupt. Counters (held, forced, fd_forced) = (1, 0, 0). Shutdown cases retained 1 / 0 / 1 as expected.
negative control, cp-revert: the fix tree with every production file the fix changed restored from bf129c8 m8 × 3 15/15 FAIL 61,200 corrupt
mutant M1: the backstop pools the connState with a SEND_ZC owed (the hold disabled) m8 × 2 20/20 FAIL (wire and counters) 61,200 corrupt. Counters (0, 1, 0): the must-stay-0 CloseZCNotifForced fires.
mutant M2: zcOwed taken off at the SEND_ZC's first CQE instead of its notification m8 × 2 send-done-after-close FAILs in both tests; TestClosedOpsCountsTheZCSendApart FAILs 61,200 corrupt in that case, 0 in the other three
mutant M3: entry held, but its send buffer array handed to the pool m8 × 2 20/20 FAIL 61,200 corrupt. Counters (1, 0, 0): only the wire test sees this one.

Limits

  • amd64 was build, vet and lint only. All runtime evidence is arm64. The mechanism is kernel TCP plus the Go pool, with nothing arch-specific in it. The CI steps above run on ubuntu-latest and ubuntu-24.04-arm once this PR targets main.
  • CloseZCNotifForced sees only what the accounting believes. M2 and M3 corrupt the wire without moving it. The wire test and its CI detector control are what judge the hold itself.
  • The shutdown list is never freed. It grows by the number of connections a shutdown finds with a SEND_ZC still owed after the 250 ms send drain, which means peers stalled mid-send.
  • An (fd, generation) collision that takes a closed connection's notification (the known residual in staleConnCQE) now leaves that connState held for good instead of released at the backstop. That is one connState of memory, on the safe side.
  • Not done: a gauge of what is held right now. The issue suggested one, and a rate counter was added instead.

@FumingPower3925 FumingPower3925 added this to the v1.6.0 milestone Sep 28, 2026
@FumingPower3925 FumingPower3925 added bug Something isn't working area/engine Engine interface or implementation engine/iouring io_uring engine specifics security Security hardening labels Sep 28, 2026
@coderabbitai

coderabbitai Bot commented Sep 28, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Comment @coderabbitai help to get the list of available commands.

Base automatically changed from fix/celeris-685-close-recv-theft to main September 28, 2026 08:51
FumingPower3925 added a commit that referenced this pull request Sep 28, 2026
…p only its array, show in gauges and stop at a cap (celeris#812)

The round-2 review of #813 measured what the backstop hold costs while it
lasts, and a hold lasts as long as the peer keeps its orphaned socket alive,
which a peer that reads slowly can do for as long as it likes:

  - Every held entry stayed on pendingRelease, which the loop walks every
    pass while it is not empty, one closedOps lookup each: 94 us a pass at
    10,000 held, and the request rate of live clients fell to 0.30.
  - A hold kept the whole connState (23.9 KB a held connection in the load
    probe, for a 7 KB response), although the kernel reads only the send
    buffer's array.
  - Nothing showed what was held now, and nothing stopped it growing.
  - A connection closed under the same (fd, generation) identity with no
    SEND_ZC of its own reached the backstop's release and dropped the
    shared identity, and the next pass pooled the SEND_ZC's connState with
    its array.

New tests: TestZCHoldIsOffTheReleaseWalk, TestZCHoldKeepsOnlyTheSendBuffer,
TestZCHoldCoversACollidingSibling, TestSendZCStopsWhileHeldBuffersReachTheCap,
and BenchmarkZCHeldLoopPass / BenchmarkClosedIdentityRetireWithZCHolds.
TestBackstopCountsItsZCHolds now also checks, on the wire fixture, that the
held entry is off the walk, keeps the array alone, and shows in the gauges.
TestPendingReleaseBackstopHoldsAZCSendPastItsDeadline and the shutdown test
look for the hold where it now is. The metrics tests carry the two gauges.

The names these tests use (Worker.zcHolds, zcHoldCount, zcHoldBytes, zcHold,
zcHoldBytesMax, the zcHeldNow and zcHeldBytes gauges and the EngineMetrics
fields CloseZCNotifHeldNow and CloseZCNotifHeldBytes) are declared here as
stubs with no behaviour, so the tests compile and fail on this tree; the
next commit gives them their behaviour.
FumingPower3925 added a commit that referenced this pull request Sep 28, 2026
…e, in gauges, and stop arming SEND_ZC at 16 MiB held (celeris#812)

The backstop's hold (the previous round) lasts until the SEND_ZC's
notification, which comes when the peer takes the unsent tail or the kernel
ends the orphaned socket. A peer that keeps reading, however slowly, keeps the
socket alive, so the peer decides how long the hold lasts. The round-2 review
of #813 measured what that cost: every held entry stayed on pendingRelease,
which the loop walks every pass (94 us a pass at 10,000 held, live clients
down to 0.30 of their request rate), each kept its whole connState (23.9 KB for
a 7 KB response), nothing showed what was held, and nothing stopped it growing.

- Off the walk. Past the backstop a held send buffer leaves pendingRelease
  and waits in Worker.zcHolds, keyed by its closed identity. Only a CQE of
  that identity reaches it: noteStaleTerminalOp settles the identity's holds
  (settleZCHolds), so a hold costs O(1) when it starts, at each of its
  identity's CQEs and when it ends, and nothing per loop pass. The stale-CQE
  path pays one length check, and one lookup while any hold exists.
- The array alone. Once the SEND_ZC is all the identity owes, the connState
  is released as usual (releaseHeldConnState): to the pool without the
  array, or, detached, to the GC untouched. It leaves the identity first
  (its slot set to nil, so the conn count the collision rule reads is
  kept), so no later CQE writes into a connState the pool handed on. While
  another op is owed too (a recv the close's cancel has not ended yet), the
  whole entry is held, and handed back to the walk at that op's CQE.
- Shown. CloseZCNotifHeldNow and CloseZCNotifHeldBytes are gauges of the
  arrays held right now and their capacity; a worker that shuts down takes
  its share out, and its arrays count in ShutdownZCBufRetained.
- Bounded. A worker whose held arrays reach zcHoldBytesMax (16 MiB) arms no
  new SEND_ZC (prepSendSQE): its sends copy, and a copied send's unsent tail
  is the kernel's own socket memory, which its orphan and socket-memory
  limits bound. The held bytes can then grow only by the SEND_ZCs already in
  flight on live connections.
- Decided on the identity. closedZCOwed no longer asks whether the
  connection's own send was a SEND_ZC: under an (fd, generation) collision a
  sibling with no SEND_ZC of its own reached the backstop's release, dropped
  the shared identity, and the next pass pooled the SEND_ZC's connState with
  its array. Now every conn under an identity that owes a SEND_ZC is held,
  and CloseZCNotifForced cannot move on this code; its docs now say it is a
  tripwire for a change that breaks the hold, not a measure of the kernel.

The comments that said the kernel bounds the hold now say what does.
noteStaleRecvExemplar (validation builds) skips a slot a hold emptied.
@FumingPower3925
FumingPower3925 force-pushed the fix/celeris-812-zc-backstop branch from ddf0786 to da12b3f Compare September 28, 2026 09:51
…SEND_ZC notification still guards (celeris#812)

A SEND_ZC to a peer that stopped reading keeps its notification owed for as
long as the socket lives, and a close orphans the socket with the unsent tail
still queued on the send buffer's pages. The pendingRelease backstop released
such a connState 5 s after the close, to connStatePool with its sendBuf, or
for a detached one to the GC, and when the peer read again the orphaned socket
sent the tail from whatever the array held by then.

The test builds the case with the #798 fixture (64 KiB SEND_ZC, a loopback
peer with a 4 KiB receive buffer, the closing-drain sweep's close), makes the
backstop due at once, keeps the peer stalled for 300 ms of loop passes, writes
0xAA over the array the moment the connState leaves pendingRelease (what its
next owner does), and then lets the peer read to EOF: every byte must be the
one sent, and the connState must be released once the notification arrives.
Four cases: notification only, a recv and the notification, the send's first
CQE unread at the close, and a detached connection.

Fails on this tree: every case releases at the first pass after the backstop
with the notification owed, and the peer reads the tail as 0xAA.
…C notification, past the release backstop (celeris#812)

The pendingRelease backstop released a closed connState 5 s after the close
whatever it still owed, treating an op owed that long as a kernel anomaly. A
SEND_ZC's notification is not one: a peer that stops reading keeps the unsent
tail of the send queued on the socket as references to cs.sendBuf's pages, and
after the close the orphaned socket keeps offering it until the peer reads or
the kernel gives up on the socket. The released connState went to
connStatePool with its sendBuf (a detached one to the GC), its next owner
wrote into the array, and the peer then received that tail from it: another
connection's bytes.

A closed identity's closedOps entry now counts its SEND_ZC apart (zcOwed, in
the entry's padding): one per conn whose send in flight is a SEND_ZC at the
close, taken off at that op's terminal CQE, its notification (or, for a single
conn, a first CQE without F_MORE, which has none to follow). Past the
backstop, drainPendingRelease holds an entry that still owes one until the
notification retires it, and then releases it the usual way. The descriptor is
not held with it: a notification names none and let it go already (#798); an
op that does name it past the backstop is closed and counted as before
(CloseFDForced).

Worker shutdown closes the ring, which ends no SEND_ZC's use of its pages, and
after which no notification can say when that use ends. Right before the ring
closes, it reads what the ring holds and keeps every send buffer a SEND_ZC may
still read, of a live or a held connection, in a process-lifetime list: the
Worker is all that held them, and the GC could hand the arrays on once the
engine is dropped. No wait is added: the run loop's 250 ms send drain already
waits for the live connections' sends.

New EngineMetrics (io_uring; summed by adaptive): CloseZCNotifHeld (holds, a
rate), CloseZCNotifForced (an identity dropped with a SEND_ZC owed, i.e. a send
buffer given up while the kernel may still send from it; must stay 0), and
ShutdownZCBufRetained. Tests: the backstop's hold on hand-built state (it runs
where SEND_ZC does not), the accounting on hand-built CQEs (each way a SEND_ZC
ends, and a collision), the must-stay-0 counter's positive control, the
counters on every wire case, and the shutdown retention.
…h a detector control

The recv-theft job (both arches) runs the six celeris#812 tests by name at the
runner's own 8 MiB memlock, -race, three runs, and requires every run of each
of the twenty-one cases to PASS with no FAIL and no SKIP line (a SKIP would mean
the runner gave the engine no working SEND_ZC).

Its detector control applies .github/scripts/mutant-812-backstop-releases-zc.py,
which disables the backstop hold, and requires every run of each of the four
wire cases to report the release and foreign bytes on the wire, and to FAIL: a
runner whose kernel copied the send's pages before the release would let the
wire test pass with or without the fix.
…p only its array, show in gauges and stop at a cap (celeris#812)

The round-2 review of #813 measured what the backstop hold costs while it
lasts, and a hold lasts as long as the peer keeps its orphaned socket alive,
which a peer that reads slowly can do for as long as it likes:

  - Every held entry stayed on pendingRelease, which the loop walks every
    pass while it is not empty, one closedOps lookup each: 94 us a pass at
    10,000 held, and the request rate of live clients fell to 0.30.
  - A hold kept the whole connState (23.9 KB a held connection in the load
    probe, for a 7 KB response), although the kernel reads only the send
    buffer's array.
  - Nothing showed what was held now, and nothing stopped it growing.
  - A connection closed under the same (fd, generation) identity with no
    SEND_ZC of its own reached the backstop's release and dropped the
    shared identity, and the next pass pooled the SEND_ZC's connState with
    its array.

New tests: TestZCHoldIsOffTheReleaseWalk, TestZCHoldKeepsOnlyTheSendBuffer,
TestZCHoldCoversACollidingSibling, TestSendZCStopsWhileHeldBuffersReachTheCap,
and BenchmarkZCHeldLoopPass / BenchmarkClosedIdentityRetireWithZCHolds.
TestBackstopCountsItsZCHolds now also checks, on the wire fixture, that the
held entry is off the walk, keeps the array alone, and shows in the gauges.
TestPendingReleaseBackstopHoldsAZCSendPastItsDeadline and the shutdown test
look for the hold where it now is. The metrics tests carry the two gauges.

The names these tests use (Worker.zcHolds, zcHoldCount, zcHoldBytes, zcHold,
zcHoldBytesMax, the zcHeldNow and zcHeldBytes gauges and the EngineMetrics
fields CloseZCNotifHeldNow and CloseZCNotifHeldBytes) are declared here as
stubs with no behaviour, so the tests compile and fail on this tree; the
next commit gives them their behaviour.
…e, in gauges, and stop arming SEND_ZC at 16 MiB held (celeris#812)

The backstop's hold (the previous round) lasts until the SEND_ZC's
notification, which comes when the peer takes the unsent tail or the kernel
ends the orphaned socket. A peer that keeps reading, however slowly, keeps the
socket alive, so the peer decides how long the hold lasts. The round-2 review
of #813 measured what that cost: every held entry stayed on pendingRelease,
which the loop walks every pass (94 us a pass at 10,000 held, live clients
down to 0.30 of their request rate), each kept its whole connState (23.9 KB for
a 7 KB response), nothing showed what was held, and nothing stopped it growing.

- Off the walk. Past the backstop a held send buffer leaves pendingRelease
  and waits in Worker.zcHolds, keyed by its closed identity. Only a CQE of
  that identity reaches it: noteStaleTerminalOp settles the identity's holds
  (settleZCHolds), so a hold costs O(1) when it starts, at each of its
  identity's CQEs and when it ends, and nothing per loop pass. The stale-CQE
  path pays one length check, and one lookup while any hold exists.
- The array alone. Once the SEND_ZC is all the identity owes, the connState
  is released as usual (releaseHeldConnState): to the pool without the
  array, or, detached, to the GC untouched. It leaves the identity first
  (its slot set to nil, so the conn count the collision rule reads is
  kept), so no later CQE writes into a connState the pool handed on. While
  another op is owed too (a recv the close's cancel has not ended yet), the
  whole entry is held, and handed back to the walk at that op's CQE.
- Shown. CloseZCNotifHeldNow and CloseZCNotifHeldBytes are gauges of the
  arrays held right now and their capacity; a worker that shuts down takes
  its share out, and its arrays count in ShutdownZCBufRetained.
- Bounded. A worker whose held arrays reach zcHoldBytesMax (16 MiB) arms no
  new SEND_ZC (prepSendSQE): its sends copy, and a copied send's unsent tail
  is the kernel's own socket memory, which its orphan and socket-memory
  limits bound. The held bytes can then grow only by the SEND_ZCs already in
  flight on live connections.
- Decided on the identity. closedZCOwed no longer asks whether the
  connection's own send was a SEND_ZC: under an (fd, generation) collision a
  sibling with no SEND_ZC of its own reached the backstop's release, dropped
  the shared identity, and the next pass pooled the SEND_ZC's connState with
  its array. Now every conn under an identity that owes a SEND_ZC is held,
  and CloseZCNotifForced cannot move on this code; its docs now say it is a
  tripwire for a change that breaks the hold, not a measure of the kernel.

The comments that said the kernel bounds the hold now say what does.
noteStaleRecvExemplar (validation builds) skips a slot a hold emptied.
…s, twenty-four cases)

TestZCHoldIsOffTheReleaseWalk, TestZCHoldKeepsOnlyTheSendBuffer, TestZCHoldCoversACollidingSibling and TestSendZCStopsWhileHeldBuffersReachTheCap join the step's list: both arches, by name, at the runner's own 8 MiB memlock, -race, three runs, every case PASS and no FAIL or SKIP line. The detector control is unchanged: the hold it removes is still the one the wire test judges.
@FumingPower3925
FumingPower3925 force-pushed the fix/celeris-812-zc-backstop branch from da12b3f to 50efff7 Compare September 28, 2026 09:53
@codecov

codecov Bot commented Sep 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.33333% with 23 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
engine/iouring/zc_send_buffer.go 84.00% 16 Missing ⚠️
engine/iouring/handoff_loss.go 56.25% 7 Missing ⚠️

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/engine Engine interface or implementation bug Something isn't working engine/iouring io_uring engine specifics security Security hardening

Projects

None yet

1 participant