feat(iouring): SEND_ZC exposure counters so the race tier and the bench A/B can prove the branch ran - #601
Merged
Conversation
…told from an unexercised branch (celeris#591)
celeris#585 (SEND_ZC fabric A/B) and celeris#587 (race tier on the ZC send
state) are both blocked on the same missing witness: nothing said whether a
SEND_ZC was submitted, whether its NOTIF arrived, or whether the detached
inline-egress guard blocked on zcNotifPending. A clean result was
indistinguishable from "the branch never ran".
Mechanism, five validation counters (-tags=validation, no-op stubs in
production) at the four real sites:
IouringSendZCSubmits / ...Detached prepSendSQE's ZC arm; the detached
split is keyed on cs.h1State.Detached
IouringSendZCNotifs handleSend's cqeIsNotif branch
IouringInlineGuardBlockedZC the initProtocol guarded closure when
the raw unix.Write is declined with a
NOTIF outstanding (read under detachMu)
IouringZCCompletionWithPendingWrite a NOTIF that lands with cs.writeBuf
non-empty (read under detachMu)
and four always-on engine.EngineMetrics fields fed by a nil-safe zcStats on
the worker, summed by adaptive: ZCSendsSubmitted, ZCNotifs, InlineBytes
(the detached raw unix.Write, which bypasses the ring and so can never be
zero-copy), RingBytes (the ring share; InlineBytes+RingBytes is the egress
fabric split of BytesWritten).
No new per-request hot-path cost: the submit/notif adds sit inside branches
that only a ZC send reaches, the inline-bytes add is on the detached WS/SSE
egress path next to the bytesWritten add already there, and RingBytes is
accumulated in a worker-local ringBytesBatch published with one atomic per
event-loop iteration, exactly like bytesWrittenBatch.
Measured, docker golang:1.27 (kernel 7.0.12-linuxkit), --cpus 4, memlock
128 MiB, seccomp=unconfined, workers=4, send_zc=true, -race,
TestSendZCCountersOnDetachedWrite (new, middleware/websocket), 10 runs:
SMALL 200 x 256B, each frame drained before the next:
ZCSendsSubmitted+=0 ZCNotifs+=0 InlineBytes+=52000 RingBytes+=0
validation submits+=0 detached+=0 notifs+=0
BIG 8 pipelined waves x 64 x 65536B, first wave undrained (client
SO_RCVBUF clamped to 8 KiB so the send buffer fills):
ZCSendsSubmitted+=16 ZCNotifs+=16 InlineBytes+=2158592 RingBytes+=31400960
validation submits+=16 detached+=16 notifs+=16 notif_pending_write+=1
Identical in 10/10 runs, 10/10 PASS, both with and without the tag.
Negative control, CELERIS_IOURING_SEND_ZC=off (send_zc=false in the listen
log), same 10 runs: BIG ZCSendsSubmitted+=0 ZCNotifs+=0, validation
submits/detached/notifs all 0, while InlineBytes+=2158592 and
RingBytes+=31400960 are byte-identical — the same egress, zero zero-copy.
IouringInlineGuardBlockedZC stays 0 on this host: the kernel reports
IORING_NOTIF_USAGE_ZC_COPIED on loopback, so the NOTIF follows its send CQE
within the same drain and a dispatch-goroutine write never lands inside the
window. Positive control that the site is live, not dead code: a throwaway
build with a 2 ms sleep holding detachMu after cs.zcNotifPending is set
moved it to 69 in one run (notif_pending_write 1 -> 14); reverted, not
committed.
engine/iouring and middleware/websocket full suites, -race, in the container:
green without the tag (iouring 102.1s, websocket 964.4s). With
-tags=validation the iouring suite is green and websocket fails only
TestBackpressureInboundSequenceIntegrity/io_uring/multishot_recv — 1 of 5
runs on this branch and 1 of 5 runs on unmodified origin/main under the same
tag and container (SEND_ZC ENOMEM/RLIMIT_MEMLOCK fallback), i.e. pre-existing
and unrelated. golangci-lint run ./... clean on darwin, GOOS=linux, and
GOOS=linux --build-tags=validation; go vet clean in all modes.
This was referenced Sep 26, 2026
FumingPower3925
added a commit
that referenced
this pull request
Sep 27, 2026
… with its detachMu mutant (celeris#587) (#693) The io_uring SEND_ZC first completion and the detached inline-egress guard were never judged by the race detector: on loopback the notification lands in the same batch as the first completion (#601 measured IouringInlineGuardBlockedZC at 0), and nothing showed -race watches that path. internal/zcwindow adds a validation-only hold after handleSend on a CQE_F_MORE completion (a false constant in production; handleSend itself is unchanged), and TestSendZCWindowGuardUnderRace streams 256 x 64 KiB frames to a slow reader, requiring every frame intact and the guard to decline while a notification is outstanding. A new CI job zc-window (x86 and arm64) runs the test committed, with SEND_ZC off, and with a mutant that deletes the detachMu acquire, 3 processes each, each judged on its own log; the mutant script exits 2 if its anchor drifts. Verified: CI run 36295432420 committed 3/3 PASS with 0 races, ZC off 3/3 PASS with every witness 0, mutant 3/3 FAIL each with its own DATA RACE, on both arches; laptop arms A-F 10/10 each; handleSend, run and processCQE have identical instruction sequences to main on both arches. On the final head 1074bfe (merged with main 3fe9620) every CI job is green, including zc-window on both arches, and CodeRabbit left no actionable comments. No follow-up issue: no review finding remains open. Closes #587
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #591. Measured, not argued: the rig that found the defect is the rig that judges the fix, and the negative control was run.
Mechanism
Five validation counters (-tags=validation; no-op Counter stubs in production) added to validation/{assertions,counters,disabled}.go and bumped at the four real sites in engine/iouring/worker.go: IouringSendZCSubmits and IouringSendZCSubmitsDetached inside prepSendSQE's
useSendZCarm (detached split keyed on cs.h1State.Detached, nil-guarded for driver conns); IouringSendZCNotifs in handleSend's cqeIsNotif branch; IouringInlineGuardBlockedZC in the initProtocol guarded closure's newelse if cs.zcNotifPendingarm (read under the held detachMu); IouringZCCompletionWithPendingWrite when a NOTIF lands with len(cs.writeBuf)>0 (also under detachMu). Four always-on engine.EngineMetrics fields — ZCSendsSubmitted, ZCNotifs, InlineBytes, RingBytes — are fed by a new nil-safezcStatsstruct (submits/notifs/inlineBytes/ringBytes atomics) hung off Engine.metrics.zc and wired to each Worker in createWorkers, published in iouring Engine.Metrics() and summed in adaptive/engine.go Metrics() alongside DetachedConnections/DetachWindowCloses. No new per-request hot-path cost: the submit and notif adds sit inside branches only a ZC send reaches (every sub-4096 and every linked send falls through to plain SEND untouched); the inline-bytes add is on the detached WS/SSE egress path beside the w.bytesWritten.Add already there; RingBytes accumulates in a worker-localringBytesBatchin completeSend (plain uint64 +=) and is published with one atomic per event-loop iteration next to bytesWrittenBatch.Verification
New TestSendZCCountersOnDetachedWrite (middleware/websocket/send_zc_counters_linux_test.go, io_uring only). Container: docker run --rm --cpus 4 --security-opt seccomp=unconfined --ulimit memlock=134217728:134217728 -v :/src -w /src -v /Users/fuming/go/pkg/mod:/go/pkg/mod -v gocache484:/root/.cache/go-build golang:1.27; engine log confirms
workers=4 sqpoll=false send_zc=true, tier=high, kernel 7.0.12-linuxkit. Command: go test -tags=validation -race -count=10 -run TestSendZCCountersOnDetachedWrite -v ./middleware/websocket/ — 10/10 PASS, numbers identical in all 10 runs. SMALL phase (200 x 256B, each frame fully drained before the next): ZCSendsSubmitted+=0 ZCNotifs+=0 InlineBytes+=52000 RingBytes+=0; validation submits+=0 detached+=0 notifs+=0 guard_blocked+=0 notif_pending_write+=0. BIG phase (8 pipelined waves x 64 x 65536B = 32 MiB, first wave undrained, client SO_RCVBUF clamped to 8 KiB): ZCSendsSubmitted+=16 ZCNotifs+=16 InlineBytes+=2158592 RingBytes+=31400960; validation submits+=16 detached+=16 notifs+=16 guard_blocked+=0 notif_pending_write+=1. Same test without the tag (count=10): 10/10 PASS, identical engine-metric numbers, all validation deltas 0 (the production stubs), which the test asserts explicitly. Unit level: engine/iouring/send_zc_gate_test.go extended with TestPrepSendSQEWitnessesTrackTheZCArm (5 cases pinning that the witnesses move exactly with the opcode: 4095B->opSEND/0 submits, 4096B attached->opSENDZC/1 submit/0 detached, 4096B and 16384B detached->opSENDZC/1 submit/1 detached), TestPrepSendSQELinkedCountsNoSubmit, TestZCStatsNilSafe; validation-counter expectations are scaled by a build-tag const (zc_witness_tag_on/off_test.go) so the same tests run in both modes.Negative control
ZC policy forced off via the CELERIS_IOURING_SEND_ZC knob: docker run ... -e CELERIS_IOURING_SEND_ZC=off ... go test -tags=validation -race -count=10 -run TestSendZCCountersOnDetachedWrite -v ./middleware/websocket/ — 10/10 PASS, engine log shows
send_zc=falsein both the "engine selected" and "engine listening" lines (20 occurrences over 10 runs). BIG phase: ZCSendsSubmitted+=0 ZCNotifs+=0, validation submits/detached/notifs all 0, while InlineBytes+=2158592 and RingBytes+=31400960 are byte-for-byte identical to the ZC-on arm — the same egress went over the same fabric with zero zero-copy, so the 16/16 above is attributable to the ZC branch and not to traffic volume. Second control, for the one counter the rig does not move: IouringInlineGuardBlockedZC stays 0 on this host because the kernel reports IORING_NOTIF_USAGE_ZC_COPIED on loopback (copy fallback), so the NOTIF follows its send CQE inside the same drain pass and a dispatch-goroutine write never lands in the window. A throwaway build withtime.Sleep(2*time.Millisecond)added while detachMu is held after cs.zcNotifPending is set moved it to guard_blocked+=69 (and notif_pending_write 1 -> 14) in one of 5 runs, proving the site is live and not dead code; that sleep was reverted and is not in the commit. Third control, for the failing suite: see regression_risk.Regression risk
Low. Production builds compile against the validation no-op stubs, so all five validation adds vanish; the always-on adds are confined to ZC-only branches, the detached egress path, and a per-iteration batch flush. adaptive sums the four new fields additively, which is correct because they are io_uring-only and cumulative. One measured caveat: under -tags=validation -race the middleware/websocket suite fails TestBackpressureInboundSequenceIntegrity/io_uring/multishot_recv. I ran the negative control for this — go test -tags=validation -race -count=5 -run TestBackpressureInboundSequenceIntegrity on an unmodified origin/main tree extracted with
git archive origin/maininto a separate directory, same container and flags: 1 FAIL / 4 PASS. Same command on this branch: 1 FAIL / 4 PASS. Identical rate, and the failure signature is the same (SEND_ZC returned ENOMEM (RLIMIT_MEMLOCK), falling back to regular SENDon all workers, then echo-write-on-closed-connection and frame-count mismatches). Pre-existing and unrelated to this change.Not verified
Adversarial review
Skeptic 1 — refuted: False, mergeable: True
The named mechanism is addressed at the cited lines and no invariant is broken. prepSendSQE (engine/iouring/worker.go:4221) bumps the submit witnesses strictly inside the useSendZC arm, so the plain-SEND and linked-send hot paths gain zero instructions; handleSend's cqeIsNotif branch (:2562) bumps notifs before taking detachMu and reads cs.writeBuf under detachMu in both the mutex and nil-mutex branches; the initProtocol guarded closure's new
else if cs.zcNotifPendingexecutes under the already-held detachMu and does not alter control flow (the fall-through to mu.Unlock and the detach-queue enqueue is unchanged), so the inline-egress exclusivity rule stated in that comment (!sending && !zcNotifPending && sendBuf/bodyBuf empty) is intact. The ring-byte add in completeSend sits AFTER thesent < 0guard, so it cannot take the negative-zcSentBytes value that would corrupt an unsigned add, and it mirrors the pre-existing bytesWrittenBatch exactly (same accumulation site, same per-iteration flush, same absence of an end-of-loop flush). git grep confirms only two bytesWritten sites in engine/iouring (the detached inline unix.Write and the batched completeSend), so the InlineBytes/RingBytes complement claim is true rather than asserted. Production cost is real-zero: validation.Counter isstruct{}with empty Add/Load in validation/disabled.go, and engine/iouring already imported validation via validation_check.go, so no new production dependency or binary weight. The rig demonstrably flipped (0 submits on the drained 256B phase, 16 submits/16 notifs on the congested 64KiB phase) and the negative control was run with numbers that actually discriminate: with CELERIS_IOURING_SEND_ZC=off the same burst produced byte-identical InlineBytes (2158592) and RingBytes (31400960) with submits/notifs at 0, which rules out traffic volume as the explanation for the 16/16. The e2e test asserts != 0 rather than exact counts, so it is not brittle to host send-buffer behaviour, and the unit tests pin that the witnesses move exactly with the opcode (4095B -> opSEND/0, 4096B -> opSENDZC/1) plus a linked-send-counts-nothing case and a nil-zcStats case. The commit contains 13 source/test files only: no scratchpad, no logs, no debug prints, and the throwawaytime.Sleepused to exercise the guard counter is genuinely absent from the diff. The build-tag const pair is the standard linux&&validation / linux&&!validation shape, not a stray tag. tier.go's two other ZC arms are uninstrumented but provably dead —PrepareSend(has no callers anywhere in the repo — so this is not an undercount today.Skeptic 2 — refuted: False, mergeable: True
I could not refute the branch. (1) The change is at the cited sites: all five validation counters land in engine/iouring/worker.go at :4221-4231 (inside the useSendZC arm of prepSendSQE, Detached split nil-guarded on h1State), :2563-2588 (the cqeIsNotif branch, with the writeBuf read taken under detachMu), and :1760-1770 (a new
else if cs.zcNotifPendingon the inline-egress guard, under the already-held mu); four always-on EngineMetrics fields are wired through engine/iouring/engine.go and summed additively in adaptive/engine.go. (2) The rig genuinely flipped and the negative control is present with numbers: SMALL 0 submits -> BIG 16 submits/16 notifs, and CELERIS_IOURING_SEND_ZC=off gives 0/0 with RingBytes byte-identical, which is a true single-variable flip. The reported byte totals are internally consistent, which corroborates that they were observed rather than asserted: SMALL InlineBytes 52000 = 200x(256+4), BIG 2158592+31400960 = 33559552 = 512x(65536+10). InlineBytes+RingBytes == BytesWritten exactly, because git grep shows only two byte-count sites in the engine (worker.go:1744 inline, :2772 completion) and both are instrumented. (3) No double count and no overflow: the cqeHasMore branch returns without calling completeSend so a ZC send reaches completeSend exactly once via the NOTIF, andringBytesBatch += uint64(sent)sits after thesent < 0early return so the -ENOMEM path cannot wrap it. (4) Hot-path claim holds structurally: plain SEND and linked sends gain zero instructions, completeSend gains one non-atomic uint64 add flushed with one atomic per event-loop iteration next to bytesWrittenBatch. (5) Nothing improper is committed: 13 files, no scratchpad/logs/debug prints, the only time.Sleep calls in the diff are the two documented settle waits in the test, and the deliberately widened detachMu window used as the guard-counter control is genuinely absent from worker.go. Build tags are correctly paired (linux && validation / linux && !validation) in both packages so neither mode redeclares, and validation/disabled.go Counter is a stateless struct withfunc (Counter) Add(uint64) {}, so production really is a no-op — consistent with the test asserting all validation deltas are 0 without the tag. Residual, non-blocking observations: theelse if cs.zcNotifPendingattributes a decline to ZC even when cs.fixedFile or an empty writeBuf is also blocking (near-theoretical with fixed files default-off, but it makes IouringInlineGuardBlockedZC an over-count rather than a precise witness); BIG shows only 16 ZC submits against 31.4 MB of ring bytes and the report never says whether w.sendZC flipped false mid-run via the ENOMEM fallback at worker.go:2729 (that exact log line did appear in the same container during the backpressure test), so 16/16 may be a pre-fallback prefix; the new rig never runs in CI because .github/workflows/ci.yml:121 excludes middleware/websocket and line 141 runs only ^TestBackpressure, which removes the memlock flake risk but also means it is not a standing gate; noteInlineBytes doubles the atomics at the inline-write site (w.bytesWritten is already engine-wide shared, not per-worker) and was not benchmarked; and the probatorium half of #591 is untouched so the issue must stay open.