Repository navigation
v1.6.0 polish: io_uring engine #884
Description
Consolidated on 2026-10-02 from the per-PR 'Follow-ups from #N' bundles, by maintainer decision. From now on, review nits are fixed inside the PR before it merges, so no new bundles are filed. Each line names the bundle and item it came from; the bundle's thread keeps the full context. Everything here stays in v1.6.0.
- Cover the arming half of fix(iouring): read an async conn's header deadline before its dispatch goroutine runs, under detachMu (celeris#722) #743 (mutant Z12 survives engine/iouring): a unit test that parks the goroutine with HeaderDeadlineNs > 0 and asserts the next feed sets headerTimerArmed, and a -race test of handleHeaderTimer's early-fire branch (no test calls handleHeaderTimer). (from Follow-ups from #743: epoll checkTimeouts h1State race, arm-path test coverage, a contended bench for the TryLock feed #762 2) (Done: iouring tests: park, cancel and header-timer test hardening (v1.6.0 polish items) #1100 [diff])
- TestAsyncH2CUpgradeOnFeedLeavesH1StateToTheGoroutine (async_h2c_h1state_race_linux_test.go:151) separates its two writes with a 100 ms sleep; send the second write only after an observable signal that the first recv was promoted and the goroutine is parked, or assert the second write arrived through the feed, so a coalesced run cannot pass vacuously. (from Follow-ups from #743: epoll checkTimeouts h1State race, arm-path test coverage, a contended bench for the TryLock feed #762 6) (Done: iouring tests: park, cancel and header-timer test hardening (v1.6.0 polish items) #1100 [diff])
- TestDriverShutdownWaitsForAHandedOffClose (driver_linger_close_linux_test.go, time.Sleep(200ms) now at line 392) syncs on a sleep; add a test-only hook fired when waitDriverCloses starts, wait for it with a deadline before drain(), and rerun the M2 control. (from Follow-ups from #744: parallel driver closes at shutdown, onClose no longer tied to the fd's map removal #763 4) (Done: iouring: driver-descriptor close bound on the live path, shutdown wait without a sleep, the bound test without the shared semaphore #1057 [diff])
- Add a deterministic test for runAsyncHandler's re-check exit after taking detachMu (engine/iouring/worker.go): hold detachMu, start the handler with a non-empty asyncInBuf, call closeConn so it sets closeOwed, release, and assert the conn is enqueued and closed exactly once, so mutant Y1 (dropping the owed enqueue) fails. (from Follow-ups from #745: a test for the re-check exit's owed hand-back, closeErr on the shutdown path #764 1) (Done: iouring tests: deterministic held-completion, stall and write-order tests (v1.6.0 polish items) #1097 [diff])
- TestIouringCloseStillWaitsForABoundedHolder (engine/iouring/async_handler_stall_linux_test.go) treats 'not done after 200 ms' as proof the close waits on the holder; replace the timer with a sync point tied to closeConn's failed TryLock (test hook or polling the mutex until a waiter is queued) so mutants M7/M8 cannot survive on a loaded runner. (from Follow-ups from #745: a test for the re-check exit's owed hand-back, closeErr on the shutdown path #764 4) (Done: iouring tests: deterministic held-completion, stall and write-order tests (v1.6.0 polish items) #1097 [diff])
- The end-to-end io_uring: four worker-thread sites block on cs.detachMu while an async handler holds it, parking the whole ring (io_uring twin of #669) #704 stall tests synchronise with time.Sleep(50ms) before trigger(slow) and judge a 300 ms wall-clock budget (stallBudget704); have /slow signal a channel on entry, and replace or back the budget with a structural assertion or an opt-in env var. (from Follow-ups from #745: a test for the re-check exit's owed hand-back, closeErr on the shutdown path #764 5) (Done: iouring tests: deterministic held-completion, stall and write-order tests (v1.6.0 polish items) #1097 [diff])
- queuePendingReleaseDetached's comment (engine/iouring/worker.go, before :4427 on main) lists only detached close paths; add hijack and its reason (the request's views must outlive the handler). (from Follow-ups from #773: an AsyncHandlers arm after #774, io_uring multishot strings read before Hijack, the copy on std, the engine-side cost, doc and witness nits #785 5) (Done: iouring: remove the dead WRITEV path, one source for the detached inline-write guard, comments that say what holds (v1.6.0 polish items) #1082 [diff])
- clientSeesClose in park_close_fin_test.go treats any non-timeout read error (an RST) as the close; accept only io.EOF (or log which arrived and assert EOF), make the single-connection arms wait for BytesRead like the many-connection arms, and re-run the pre-fix(iouring): never release a descriptor number while an op can still resolve it: close paths, hijack, shutdown (celeris#685) #793 arm to confirm it still fails (CodeRabbit on test(iouring): regression arms for #712 (fixed by #793) #767). (from Follow-ups from #767: a named CI interlock for the #712 tests, and the bare-metal failing-first #795 4) (Done: iouring tests: park, cancel and header-timer test hardening (v1.6.0 polish items) #1100 [diff])
- Lengthen the setup deadlines in park_close_fin_test.go (single header-timer arm 400 ms to ~1 s, 64-connection arms 1 s to ~3 s) and the dependent close-observation bounds and parkWait712 waits, then re-confirm fail-before/pass-after (CodeRabbit on test(iouring): regression arms for #712 (fixed by #793) #767). (from Follow-ups from #767: a named CI interlock for the #712 tests, and the bare-metal failing-first #795 5) (Done: iouring tests: park, cancel and header-timer test hardening (v1.6.0 polish items) #1100 [diff])
- On 5.19+, requireAsyncCancelFlags returns nil for asyncCancelUnexpected (fails open). Decide and either refuse on an unexpected answer at any version or document why failing open is preferred, and align the gate-test rows {asyncCancelUnexpected,5,19,false}/{..,7,0,false}. No kernel is known to give such an answer. (from Follow-ups from #792: io_uring 5.19 floor — backport probe covers 1 of 4 cancel forms, floor test keyed on version, no CI seam for adaptive/Server, stale comments, SSE flake, docs pages #796 4) (Done: fix(iouring): refuse io_uring below Linux 5.19 whatever the cancel probe answers #963, ci, test, comments: the tick pass leftovers of #890, #884 and #892 #1093 [main]; see recon note)
- classifyAsyncCancelProbe's reason hard-codes 'the kernel predates Linux 5.19' (engine/iouring/probe.go:665 on main), so a 5.19+ rejection error contradicts itself; drop the version clause. (from Follow-ups from #792: io_uring 5.19 floor — backport probe covers 1 of 4 cancel forms, floor test keyed on version, no CI seam for adaptive/Server, stale comments, SSE flake, docs pages #796 5) (Done: epoll, iouring, probe: the AsyncHandlers note, the start-up multishot fields, the probe reason, the diagnostic floor and parseAddr say what is so (v1.6.0 polish items) #1079 [diff])
- Fix stale or overstated comments/log text from fix(iouring): require Linux 5.19; refuse io_uring on a kernel that rejects the IORING_ASYNC_CANCEL flags (celeris#682) #792: (a) engine.go 'peer gets no FIN' scoped to H1 fast-close; (b) the 'io_uring not available' prefix matters only to test skip helpers, not adaptive's fallback; (c) logAsyncCancelProbe messages (engine.go:301,304 on main) should say New refuses io_uring below 5.19; (d) adaptive/engine.go:136 'cannot disagree' is no longer true; (f) probe/probe.go:121 '5.10-5.18 Base-tier kernel runs epoll' is adaptive-only; (g) engine/iouring/doc.go should mention an unexpected answer below 5.19 also refuses. (from Follow-ups from #792: io_uring 5.19 floor — backport probe covers 1 of 4 cancel forms, floor test keyed on version, no CI seam for adaptive/Server, stale comments, SSE flake, docs pages #796 6) (Done: fix(iouring): refuse io_uring below Linux 5.19 whatever the cancel probe answers #963, ci, test, comments: the tick pass leftovers of #890, #884 and #892 #1093 [diff])
- probe/diagnostic.go prints io_uring_tier=base and the io_uring feature block on 5.10-5.18 although the engine refuses io_uring there; annotate that the engine needs 5.19+ (log wording only). (from Follow-ups from #792: io_uring 5.19 floor — backport probe covers 1 of 4 cancel forms, floor test keyed on version, no CI seam for adaptive/Server, stale comments, SSE flake, docs pages #796 7) (Done: epoll, iouring, probe: the AsyncHandlers note, the start-up multishot fields, the probe reason, the diagnostic floor and parseAddr say what is so (v1.6.0 polish items) #1079 [diff])
- runCancelForm682 (engine/iouring/async_cancel_floor_test.go) can close the ring while its recv is still armed if an earlier t.Fatalf runs the ring.Close defer; register a deferred drain (write a byte, reap CQ until recvUD completes, with a deadline, keep buf alive) before the ring.Close defer (test only). (from Follow-ups from #792: io_uring 5.19 floor — backport probe covers 1 of 4 cancel forms, floor test keyed on version, no CI seam for adaptive/Server, stale comments, SSE flake, docs pages #796 11b) (Done: iouring tests: park, cancel and header-timer test hardening (v1.6.0 polish items) #1100 [diff])
- Now that test(iouring): regression arms for #712 (fixed by #793) #767 (5a085e4) and fix(iouring): never release a descriptor number while an op can still resolve it: close paths, hijack, shutdown (celeris#685) #793 are both on main, run test(iouring): regression arms for #712 (fixed by #793) #767's parked-worker FIN tests against mutant M2 (closeFDOwed==0 removed from the park predicate, worker.go) and record that they fail, so the park gate is pinned; the named interlock is Follow-ups from #767: a named CI interlock for the #712 tests, and the bare-metal failing-first #795 item 1. (from 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 2) (cited by iouring tests: park, cancel and header-timer test hardening (v1.6.0 polish items) #1100, unverified [cite]; box left open)
- Add a shutdown trial that holds the worker between a linked recv's SEND CQE and shutdown() and fails with mutant M1 (endOwedOpsAtShutdown drain disabled), plus one for the multishot ENOBUFS re-arm; if no kernel issues such work after the descriptors close, remove the drain rather than keep its unmeasured cost (up to 250 ms per worker, CQEs dropped). (from 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 3) (PARTIAL: iouring: exit-window witness and arms, ZC shutdown pass counts, buffer-ring skips, rerunHandOff benchmark (v1.6.0 polish items) #1099; open: CLAIM_DIFFERS: M1 was never unpinned. The remaining decision is keep-or-remove the drain ()
- engine/iouring/fd_lifetime_close_test.go judges its ZC tests by wall clock (zcShutdownBound 100 ms vs a 250 ms regression; zcReleaseBound 200 ms, both still on main); assert fdOps(cs)==0 before endOwedOpsAtShutdown, count the drain's passes and assert one, widen zcReleaseBound to 2 s, and keep both failing on 0f36096 (CodeRabbit on fix(iouring): never release a descriptor number while an op can still resolve it: close paths, hijack, shutdown (celeris#685) #793). (from 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 6) (Done: iouring: exit-window witness and arms, ZC shutdown pass counts, buffer-ring skips, rerunHandOff benchmark (v1.6.0 polish items) #1099 [diff])
- hijackConn submits its cancels before handing over the socket only when !w.sqpoll, and endOwedOpsAtShutdown is skipped under SQPOLL; unreachable today (every tier's SQPollIdle is 0; Dormant SQPOLL tier: latent SQ-tail publish race (GetSQE) + architecturally unviable — remove or guard #377 closed). Add a comment/guard at both sites saying enabling SQPOLL must make hijackConn wait for the cancel CQE and give the drain an SQPOLL form. No behaviour change. (from 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 8) (Done: iouring: remove the dead WRITEV path, one source for the detached inline-write guard, comments that say what holds (v1.6.0 polish items) #1082 [diff])
- TestIouringSendCompletionDuringASlowAsyncHandlerDoesNotStallItsWorker has no witness that a completion was held; add one (EngineMetrics.RingBytes grew across /slow, or a test hook counting held completions > 0) and make cluster row 52's PASS require it. The interim witness big_ok=false no longer works now that fix(iouring): an async handler's direct write waits for a ring SEND of the conn's earlier bytes (celeris#751) #800 (65d1755) is on main. (from Follow-ups from #801: an end-to-end held-completion witness, the replay's place at every hand-back entry, two untested defensive paths, a 15 s wait without #800 #814 1) (PARTIAL: iouring tests: deterministic held-completion, stall and write-order tests (v1.6.0 polish items) #1097; open: Witness chosen is the hook count, not RingBytes (PR explains RingBytes cannot witness a ho)
- Add an arm that holds a completion and lets the dispatch goroutine exit through the park claim (with a drain set) and through the h2c exit, asserting drainDetachQueue's entry applies it, so the replay stays at the top of every hand-back entry. (from Follow-ups from #801: an end-to-end held-completion witness, the replay's place at every hand-back entry, two untested defensive paths, a 15 s wait without #800 #814 2) (Done: iouring tests: deterministic held-completion, stall and write-order tests (v1.6.0 polish items) #1097 [diff])
- Add a test that holds a completion, then releases and reuses the connState (or the fd number), and asserts nothing is applied to the new owner, so releaseConnState clearing heldSends and replayHeldSends' generation check are pinned (mutants Mrelease, Mgen survive today). (from Follow-ups from #801: an end-to-end held-completion witness, the replay's place at every hand-back entry, two untested defensive paths, a 15 s wait without #800 #814 3) (Done: iouring tests: deterministic held-completion, stall and write-order tests (v1.6.0 polish items) #1097 [diff])
- TestIouringSendCompletionStillWaitsForABoundedHolder does not prove handleSend reached holdOrLockSend's TryLock within its 200 ms window; add a test-only hook fired after a failed TryLock and wait for it before the window (CodeRabbit on fix(iouring): stop a ring SEND completion parking the worker on a running async handler's detachMu (celeris#750) #801). (from Follow-ups from #801: an end-to-end held-completion witness, the replay's place at every hand-back entry, two untested defensive paths, a 15 s wait without #800 #814 6) (Done: iouring tests: deterministic held-completion, stall and write-order tests (v1.6.0 polish items) #1097 [diff])
- The end-to-end test orders /big and /slow with 150 ms and 50 ms sleeps (send_completion_stall_linux_test.go:490,494 on main); have the /slow handler signal entry on a channel and wait for it before reading /big, together with Follow-ups from #801: an end-to-end held-completion witness, the replay's place at every hand-back entry, two untested defensive paths, a 15 s wait without #800 #814 item 1's witness (CodeRabbit on fix(iouring): stop a ring SEND completion parking the worker on a running async handler's detachMu (celeris#750) #801). (from Follow-ups from #801: an end-to-end held-completion witness, the replay's place at every hand-back entry, two untested defensive paths, a 15 s wait without #800 #814 7) (Done: iouring tests: deterministic held-completion, stall and write-order tests (v1.6.0 polish items) #1097 [diff])
- TestAsyncResponseWaitsForAnInFlightRingSend reaches only ringSendOutstanding's cs.sending clause; commit the reviewer's SQ-full probe (zz_review_probe_751_sqfull) as a third arm for the sendBuf clause and add a SEND_ZC arm (first CQE with F_MORE, then the notification) for zcNotifPending. (from Follow-ups from #800: test ringSendOutstanding's sendBuf and zcNotifPending clauses, one copy of the condition, the comment's locking claim, the over-cap drop on the async path #815 1) (Done: iouring tests: deterministic held-completion, stall and write-order tests (v1.6.0 polish items) #1097 [diff])
- The guarded writeFn keeps its own copy of the condition (worker.go:2761 on main: !cs.fixedFile && !cs.sending && !cs.zcNotifPending && ...); use !ringSendOutstanding(cs) so there is one source. (from Follow-ups from #800: test ringSendOutstanding's sendBuf and zcNotifPending clauses, one copy of the condition, the comment's locking claim, the over-cap drop on the async path #815 2) (Done: iouring: remove the dead WRITEV path, one source for the detached inline-write guard, comments that say what holds (v1.6.0 polish items) #1082 [diff])
- ringSendOutstanding's comment (worker.go:5184 on main) says the worker mutates all four fields under cs.detachMu; two flushSend callers (closeConn's deferred-close flush, the h2Conns drain) do not; reword to the narrower invariant that holds. (from Follow-ups from #800: test ringSendOutstanding's sendBuf and zcNotifPending clauses, one copy of the condition, the comment's locking claim, the over-cap drop on the async path #815 3) (Done: iouring: remove the dead WRITEV path, one source for the detached inline-write guard, comments that say what holds (v1.6.0 polish items) #1082 [diff])
- Over the send cap the async path dropped whole responses without closing (pre-existing). fix(epoll, iouring): send a large response whole, keep pipelined responses in order, never send a body the handler has given back (celeris#761, celeris#802, celeris#817) #805 (711d6f9) addressed it: a refused write sets writeRefused and runAsyncHandler closes on it (worker.go:5113 on main), and HTTP/1 sendCap is now unbounded per request. Remaining: run the reviewer's over-cap probe on main and record that the conn closes and no response is lost silently. (from Follow-ups from #800: test ringSendOutstanding's sendBuf and zcNotifPending clauses, one copy of the condition, the comment's locking claim, the over-cap drop on the async path #815 4) (open: MEASUREMENT fix(epoll, iouring): send a large response whole, keep pipelined responses in order, never send a body the handler has given back (celeris#761, celeris#802, celeris#817) #805)
- The h2c retry and landing arms (handoff_exit_window_test.go) assert only that no hand-off happened; assert f.w.conns[f.fd]==f.cs and slices.Contains(f.w.h2Conns, f.fd) after the drain, and that the landing arms' conn keeps its slot with its recv re-armed (mutant Mkill passes 9/9 today). (from Follow-ups from #799: assert the h2c arms' outcome, write down the exit contract, a rerunHandOff witness, an unattributed merged-tree failure, two flakes on main #816 1) (Done: iouring: exit-window witness and arms, ZC shutdown pass counts, buffer-ring skips, rerunHandOff benchmark (v1.6.0 polish items) #1099 [diff])
- Write down the exit contract in endDispatch's doc comment (engine/iouring/conn.go:633 on main does not state it): every runAsyncHandler exit other than a claim must publish asyncClosed or a non-HTTP/1 protocol before endDispatch clears asyncRun; or pin it with a test that drives every exit and checks rerunHandOff and tryTransplant refuse the conn. (from Follow-ups from #799: assert the h2c arms' outcome, write down the exit contract, a rerunHandOff witness, an unattributed merged-tree failure, two flakes on main #816 2) (Done: iouring: remove the dead WRITEV path, one source for the detached inline-write guard, comments that say what holds (v1.6.0 polish items) #1082 [diff])
- Add a log line or test counter in rerunHandOff's exiting branch (fd_lifetime.go:237 on main) and assert it in the retry arms, so they stay non-vacuous if the fixture drifts. (from Follow-ups from #799: assert the h2c arms' outcome, write down the exit contract, a rerunHandOff witness, an unattributed merged-tree failure, two flakes on main #816 3) (Done: iouring: exit-window witness and arms, ZC shutdown pass counts, buffer-ring skips, rerunHandOff benchmark (v1.6.0 polish items) #1099 [diff])
- io_uring's WRITEV scatter-gather path (bodyBuf, sendBody, iov, the WRITEV branches of flushSend/completeSend) is unreachable since fix(epoll, iouring): send a large response whole, keep pipelined responses in order, never send a body the handler has given back (celeris#761, celeris#802, celeris#817) #805 stopped installing the zero-copy body writer (celeris#817); remove it, or bring it back only for bodies the engine may keep. (from Follow-ups from #805: the memory bound after the per-request cap, epoll's closing-conn reap by read time, no deterministic H2 refusal test, io_uring's dormant WRITEV path, body nits #818 4) (Done: iouring: remove the dead WRITEV path, one source for the detached inline-write guard, comments that say what holds (v1.6.0 polish items) #1082 [diff])
- engine/iouring/zc_send_buffer.go:59-63 and zcHoldBytesMax's comment (:125-133): the memory bound is stated as 'SEND_ZCs in flight, bounded by the connection limit', which bounds sends not bytes; since fix(epoll, iouring): send a large response whole, keep pipelined responses in order, never send a body the handler has given back (celeris#761, celeris#802, celeris#817) #805 one SEND_ZC/hold can be a whole response (up to 64 MiB on HTTP/2). Reword to 'connections x their response size'. (from Follow-ups from #813: the SEND_ZC hold's memory-bound comment after #805, a WARN that becomes a Debug line under the #801 lag #845 1a) (Done: iouring: remove the dead WRITEV path, one source for the detached inline-write guard, comments that say what holds (v1.6.0 polish items) #1082 [diff])
- Decide whether to count in-flight SEND_ZC bytes against zcHoldBytesMax or cap the size of a single SEND_ZC, so the 16 MiB cap bounds held bytes (today ~62 stalled 256 KiB responses or one >=16 MiB response push a worker into copy-every-send mode while the orphaned socket lives). Review: not a correctness problem; fix(iouring): hold a closed connection's send buffer until its SEND_ZC notification, past the release backstop (celeris#812) #813's Limits section already says the cap does not bound sends in flight. (from Follow-ups from #813: the SEND_ZC hold's memory-bound comment after #805, a WARN that becomes a Debug line under the #801 lag #845 1b) (Done: iouring: remove the dead WRITEV path, one source for the detached inline-write guard, comments that say what holds (v1.6.0 polish items) #1082 [main])
- Under the fix(iouring): stop a ring SEND completion parking the worker on a running async handler's detachMu (celeris#750) #801 lag, the closing-drain teardown in checkTimeouts (worker.go:6146-6148 at c417fe3) with zc_send_buffer.go:169 does not replay held send completions, so ce.inflight == zcOwed matches wrongly: a Debug line instead of the backstop WARN (worker.go:4485) and CloseZCNotifHeld counts an already-arrived notification. Safe side, needs a kernel anomaly. Fix: replay held completions in the checkTimeouts teardown (intended for Follow-ups from #801: an end-to-end held-completion witness, the replay's place at every hand-back entry, two untested defensive paths, a 15 s wait without #800 #814, not recorded there). Same fix as io_uring: under the #801 lag, fdOps counts a held SEND_ZC first CQE as still naming the descriptor (early release missed; CloseFDForced counts past the backstop) #880. (from Follow-ups from #813: the SEND_ZC hold's memory-bound comment after #805, a WARN that becomes a Debug line under the #801 lag #845 3) (Done: fix(iouring): buffer ownership at hijack and close: keep a hijacked multishot buffer, apply held completions, hold what an uncancelled recv can still fill (celeris#868, #869, #880) #962 [diff])
Added 2026-10-03 (cluster row 63, run 37076068695, pre-registered verdict on #813):
- io_uring close-retire bookkeeping added by fix(iouring): hold a closed connection's send buffer until its SEND_ZC notification, past the release backstop (celeris#812) #813 (io_uring: the pendingRelease backstop returns a connection's send buffer to the pool while a SEND_ZC notification is still owed, so a peer that stalls >10 s then reads receives another connection's bytes #812): on arm64, CloseRetireRecv and CloseRetireZCNotif cost +7% (+8.8 ns per closed connection), above the 5% bar; x86 is +3%. Find where the hold bookkeeping adds work on the retire path when nothing is held (zcOwed accounting, pendingRelease checks), and make it free again in that case. Re-measure with the same row 63 design. (open: MEASUREMENT)
Added 2026-10-03 (#907's round-2 reviews, lane L905):
-
Worker.run, engine/iouring/worker.go (worker.go:1072-1075 at fix(epoll, iouring): a stopped engine no longer hands its loop threads back to the scheduler pinned to one CPU (celeris#905) #907's head 242d88a): whenplatform.SaveThreadAffinityfails, the loop still callsPinToCPUand registers no restore, so a main thread the loop ran on is parked still pinned (the epoll, io_uring: a stopped engine hands its loop threads back to Go still pinned to one CPU (PinToCPU is never undone before UnlockOSThread) #905 shape).sched_getaffinityinto unix.CPUSet's 1024 bits fails with EINVAL on a kernel with more than 1024 possible CPUs (nr_cpu_ids), whilesched_setaffinitystill takes the short mask. Either pin only after a successful save (a behaviour change: no pin on such hosts), or read the mask into a buffer as large as the kernel's (the Go runtime uses 8 KiB, runtime/os_linux.go getCPUCount, golang.org/issue/11823). One change in internal/platform serves both engines (epoll: the same item on v1.6.0 polish: epoll engine #885). fix(epoll, iouring): a stopped engine no longer hands its loop threads back to the scheduler pinned to one CPU (celeris#905) #907 states the gap in a comment at both sites. Code reading only, not reproduced. (from fix(epoll, iouring): a stopped engine no longer hands its loop threads back to the scheduler pinned to one CPU (celeris#905) #907) (Done: epoll, iouring, platform: the thread affinity of a host with over 1024 CPUs, and the pause after a loop or worker goroutine ended without returning #1020 [diff])
Added 2026-10-09 (#908's lane E7, PR #958):
-
handleRecv, engine/iouring/worker.go (the direct-body tail,ProcessH1(cs.ctx, rest, ...)at worker.go:3176 on main 21d49ef): a non-nil verdict from the tail is treated as an error (closeConn), includingconn.ErrUpgradeH2C.DispatchBufferedBody's ownErrUpgradeH2Cis handled (switchToH2, worker.go:3148), but the tail is not. So an h2c upgrade request pipelined right after a request whose body was received straight intobodyBufgets its 101 and then a close instead of the switch. Reachable becausebodyBufkeeps its capacity across the requests of a conn (internal/conn/h1.go:690): a 20000-byte POST, then a 2000-byte POST split over two writes whose last write carries the rest of the body and the upgrade request. HandleErrUpgradeH2Cin the tail as thederrbranch does, with a failing-first test. REPRODUCED on both engines with an instrumented build (tail entered with len(rest)=113, then the 101, thenwrite: broken pipeon the client's first HTTP/2 write): probeprobe-tail.go.txtandrun-probe-tail.sh, logsout/probe-tail-*.logunder the E7 lane evidence dir. The epoll twin is on v1.6.0 polish: epoll engine #885. (from epoll, io_uring: a transplant replay closes the conn on a parse error (no 400 sent) and on an h2c upgrade (no 101/switch), unlike the recv path it replaces #908) (Done: epoll,iouring: one handling of an HTTP/1 verdict for the recv path and the transplant replay; a pipelined h2c upgrade behind a direct-body request #1015 [diff]) - The transplant replays' success tails ignore
cs.writeRefused(engine/iouring/transplant.go: theattachAdoptedFDsync tail} else if w.flushSend(cs) { w.markDirty(cs) }and thereplayCarriedAsynctail). The recv path'srespondAndArmcloses the conn oncapCheck && cs.writeRefused; epoll's replay now does (flushCarriedReplay, fix(epoll, iouring): a transplant replay answers a carried parse error and switches an h2c upgrade as the recv path does; an epoll replay's partial flush arms EPOLLOUT (celeris#908) #958). Practically unreachable:makeWriteFn(worker.go) refuses only when the backlog is oversendCap(), which applies to H2 or detached conns, and a fresh replay conn is neither. Decide: close onwriteRefusedin both tails for parity, or state why not. Code reading only, not reproduced. (from epoll, io_uring: a transplant replay closes the conn on a parse error (no 400 sent) and on an h2c upgrade (no 101/switch), unlike the recv path it replaces #908 review) (open: WORDING) - The io_uring transplant replay (
endCarriedReplay, transplant.go) and the recv path's error tail (handleRecv, worker.go) are two copies of the same flush +OnError+closeConn/ h2c-switch blocks, as are epoll'sendCarriedReplay/flushCarriedReplay(adopt.go) anddrainRead(loop.go). Once worker.go and loop.go are free of other lanes, factor shared helpers so the replays cannot drift from the recv path; today only the epoll, io_uring: a transplant replay closes the conn on a parse error (no 400 sent) and on an h2c upgrade (no 101/switch), unlike the recv path it replaces #908 tests (adopt_carry_error_908_linux_test.goin each engine) notice a drift, and only where they happen to cover the block. (from epoll, io_uring: a transplant replay closes the conn on a parse error (no 400 sent) and on an h2c upgrade (no 101/switch), unlike the recv path it replaces #908 review) (Done: epoll,iouring: one handling of an HTTP/1 verdict for the recv path and the transplant replay; a pipelined h2c upgrade behind a direct-body request #1015 [diff])
Added 2026-10-09 (lane E4, PR #962):
-
hijackConn, internal/engine/iouring/worker.go: theSubmitafter the conn has left the tables (if fdOwed(cs) && !w.sqpoll) ignores its error, so a failed enter leaves the recv's cancel queued while the hijacker already has the socket. The refusal for a full SQ ring (fix(iouring): buffer ownership at hijack and close: keep a hijacked multishot buffer, apply held completions, hold what an uncancelled recv can still fill (celeris#868, #869, #880) #962) comes too early to cover it. Decide: retry the submit a bounded number of times, or count it. Code reading only; RULE 92 search found no tracker. (open: ENGINE-CODE) - Withdrawn (PR fix(iouring): buffer ownership at hijack and close: keep a hijacked multishot buffer, apply held completions, hold what an uncancelled recv can still fill (celeris#868, #869, #880) #962 review, lane E4): the
closeConnwindow between its headreplayHeldSendsand theTryLockofdetachMuis not a gap forzcOwed. A held completion meanscs.sending(first completion not applied) orcs.zcNotifPending(notification held) is still set, socloseConnreaches itscs.sending || cs.zcNotifPending || ...deferral and never callsfinishClosewith a completion held; the sweep anddrainDetachQueuereplay before they tear down. Nothing to do. -
BufferRing.GetBuffer/pushEntry(fix(iouring): buffer ownership at hijack and close: keep a hijacked multishot buffer, apply held completions, hold what an uncancelled recv can still fill (celeris#868, #869, #880) #962): the per-ID override adds one nil check to the multishot receive path (opt-in). Not timed. Time it in a same-run A/B with an A/A twin if multishot becomes a default. Allocation cost is pinned (TestBufferRingRetireCost). (open: MEASUREMENT) -
BufferRing.RetireBuffer(fix(iouring): buffer ownership at hijack and close: keep a hijacked multishot buffer, apply held completions, hold what an uncancelled recv can still fill (celeris#868, #869, #880) #962): each retired slot gets a heap replacement (repl), so a multishot worker with hijacks ends up, after about ring-count hijacks, with nearly every slot heap-backed:count x BufferSizebytes of live GC-accounted heap per worker (8 MiB at the defaults; ceilingbufRingCountMax32768 xBufferSizeup to 256 KiB), on top of the mapped pages that are never reused. The Hijack godoc now says so. Decide whether to allocate replacements from an off-heap pool (mmap'd, unmapped by the engine) at the price of the engine owning the unmapping of buffers a hijacker may still view; only matters if multishot becomes a default. Behaviour-changing, not done in fix(iouring): buffer ownership at hijack and close: keep a hijacked multishot buffer, apply held completions, hold what an uncancelled recv can still fill (celeris#868, #869, #880) #962. (Done: fix(iouring): buffer ownership at hijack and close: keep a hijacked multishot buffer, apply held completions, hold what an uncancelled recv can still fill (celeris#868, #869, #880) #962 [main]; see recon note)
Added 2026-10-09 (lane H964, PR #966):
- The "AsyncHandlers + EnableH2Upgrade: async dispatch applies to HTTP/1.1 only; H2 conns still run inline on the worker" info log (internal/engine/iouring/engine.go:425 on main 21d49ef) is gated on
e.cfg.AsyncHandlers && e.cfg.EnableH2Upgrade, not on whether H2 is reachable. With fix: EnableH2Upgrade=&false disables the h2c upgrade on Protocol Auto (celeris#964) #966,Protocol: AutoandEnableH2Upgrade: &falsereaches the engine with the flag false, and Auto still accepts prior-knowledge h2c connections that run inline, so the log no longer prints for them (the same gap already existed forProtocol: H2Cwith nil). Gate it onProtocol != HTTP1(H2 reachable) instead; the log text mentions the upgrade only, so reword it too. Code reading only. The io_uring twin is on v1.6.0 polish: epoll engine #885. (from EnableH2Upgrade=&false does not disable the h2c upgrade on Protocol Auto: WithDefaults turns it back on #964 review) (Done: epoll, iouring, probe: the AsyncHandlers note, the start-up multishot fields, the probe reason, the diagnostic floor and parseAddr say what is so (v1.6.0 polish items) #1079 [diff])
Added 2026-10-09 (lane E5, PR #969):
- io_uring
detectAccumhas no bound (internal/engine/iouring/worker.go, the!cs.detectedblock of the recv handler, ~3211-3255 on main b4ce620): once the first bytes are held (ErrInsufficientDatawith fewer than 4 bytes, or "PRI " with fewer than 24), every later recv is appended tocs.detectAccumbeforeDetectruns again, andErrUnknownProtocolis "re-arm recv and wait", so a conn that sends "PRI " and then only garbage keeps growing the buffer for as long as it keeps sending; no header deadline exists yet (the H1 state is built after detection) and the idle timers see activity. Attacker-driven and remote, unauthenticated, bounded only by the bandwidth they spend (about 1:1 with the memory held); each recv refreshes the conn's activity, so no idle timer fires. Recommend the maintainer triages this as a standalone issue rather than a checklist line. MEASURED with a probe (the E5 lane'sprobes/zz_probe_detectaccum_test.go, run byscripts/probe-detectaccum.sh, loglogs/probe-detectaccum.log): 128 MiB sent in 1 MiB writes after "PRI ", io_uring still has the conn open and the process heap grew by 146 MiB; epoll closes the conn at the first unrecognised bytes (and holds at most 23 bytes after fix(epoll): checkTimeouts reads the H1 state under detachMu; a short first segment is kept for protocol detection (celeris#865, celeris#870) #969). Fix direction: close onErrUnknownProtocolas epoll does (or capdetectAccumatdetect.PrefaceLen, whichDetectnever needs more than), with a failing-first test of the probe's shape. Whether this carries thesecuritylabel is for the maintainer. Found while checking io_uring for epoll: a first TCP segment shorter than protocol detection needs is overwritten by the next read (a split request is answered 405, a split h2c preface is lost) #870's class (RULE 61); epoll: a first TCP segment shorter than protocol detection needs is overwritten by the next read (a split request is answered 405, a split h2c preface is lost) #870 itself does not affect io_uring. Tracked as celeris#974 and fixed in PR fix(iouring): an unrecognised protocol prefix closes the conn instead of piling up in detectAccum, and a conn that stalls before detection is reaped by ReadHeaderTimeout (celeris#974) #975 (leave unticked until fix(iouring): an unrecognised protocol prefix closes the conn instead of piling up in detectAccum, and a conn that stalls before detection is reaped by ReadHeaderTimeout (celeris#974) #975 merges). (Done: fix(iouring): an unrecognised protocol prefix closes the conn instead of piling up in detectAccum, and a conn that stalls before detection is reaped by ReadHeaderTimeout (celeris#974) #975 [main])
Added 2026-10-09 (lane X1, PR #971):
- A
StreamWriterused withoutDetachon HTTP/1.1 is held whole in memory with no per-request cap, and a rawStreamWriter'sWrite/Flushnever report a gone client (found while correcting the docs for epoll, io_uring: a StreamWriter used without Detach is buffered whole until the handler returns (Flush is a no-op on the H1 adapter), contrary to the streaming docs #875; celeris#971 fixes the docs only, so epoll, io_uring: a StreamWriter used without Detach is buffered whole until the handler returns (Flush is a no-op on the H1 adapter), contrary to the streaming docs #875 closes with this behaviour unchanged).writeCap(internal/engine/epoll/conn.go:85,return math.MaxIntat :92) andsendCap(internal/engine/iouring/conn.go:59, :66) have no cap for a non-detached H1 conn, and the write hooks (makeWriteFn, epoll loop.go:2268, io_uring worker.go:5269 on main b4ce620) only append. The reviewer's probe wrote one 8 MiB chunk and flushed: 0 bytes reached the client before the handler returned, on epoll, io_uring and adaptive, sync and.Async()routes (evidence/lanes-20261009/X1/review-r1-perf-api-security/out.txt, large_chunk_875_review_linux_test.go). Peer gone: after the client resets, epoll/io_uring/adaptiveWriteandFlushreturned nil for 80 more writes, std returnedbroken pipeafter 2 (TestStreamWriterWriteErrorAfterPeerGoneH1_875in docs: the tier-cap row separates Adaptive's automatic choice from an explicit start; StreamWriter.Flush is not a delivery guarantee on epoll and io_uring HTTP/1.1 #971). The only disconnect signal on these engines isContext.SetWSDetachClose/SetWSErrorHandleron a detached conn (the SSE middleware's, SSE: a detached stream never learns the client is gone on native engines — handler, heartbeat and release goroutines leak per killed stream (17k goroutines/40 min) #494; pinned byTestDetachedStreamPeerGoneHooksH1_875). Decide between a cap on the pre-Detach buffer (send or fail past a size) and makingFlushpush; either needs an error return on the H1 write hooks. Behaviour-changing, not done in docs: the tier-cap row separates Adaptive's automatic choice from an explicit start; StreamWriter.Flush is not a delivery guarantee on epoll and io_uring HTTP/1.1 #971. The same item is on the other engine's checklist (v1.6.0 polish: io_uring engine #884 io_uring, v1.6.0 polish: epoll engine #885 epoll). RULE 92 search of the four repos found no tracker once epoll, io_uring: a StreamWriter used without Detach is buffered whole until the handler returns (Flush is a no-op on the H1 adapter), contrary to the streaming docs #875 closes. (from docs: the tier-cap row separates Adaptive's automatic choice from an explicit start; StreamWriter.Flush is not a delivery guarantee on epoll and io_uring HTTP/1.1 #971 review r1) (Done: conn, epoll, iouring: an HTTP/1 StreamWriter reports a closed connection and a refused write, and a response held unsent before Detach is capped (v1.6.0 polish items) #1067 [main])
Added 2026-10-10 (lane E5B, PR #975 review r1):
- The H1 header deadline restarts at detection, so on io_uring in Auto mode (and H2C with the upgrade) a client can hold a conn for up to about twice
ReadHeaderTimeoutbefore its first request is complete, against once on std. fix(iouring): an unrecognised protocol prefix closes the conn instead of piling up in detectAccum, and a conn that stalls before detection is reaped by ReadHeaderTimeout (celeris#974) #975 adds the accept-time budget for the undetected state (cs.detectDeadline) and leavesinitProtocolarming a fresh full H1 header deadline at detection. Probe (ReadHeaderTimeout300 ms;scripts/probe-budget.sh,probes/zz_budget_probe_974_linux_test.go,logs/budget-base.logandlogs/budget-head.login the E5B lane evidence dir):GET(3 bytes, undecided), a stall of 240 ms, then the rest of the request line and a header line every 50 ms: std closes at 300 to 310 ms, io_uring at 540 ms and io_uring-async at 550 ms;GET, 270 ms, then one byte per 20 ms: std 300 to 310 ms, io_uring and io_uring-async 570 ms. The same on main before fix(iouring): an unrecognised protocol prefix closes the conn instead of piling up in detectAccum, and a conn that stalls before detection is reaped by ReadHeaderTimeout (celeris#974) #975 (3a89451) and on the PR head. Fix shape: at detection, clamph1State.HeaderDeadlineNsto what is left ofcs.detectDeadline. Behaviour-changing for a client that was slow before its first byte was decided, so not done in fix(iouring): an unrecognised protocol prefix closes the conn instead of piling up in detectAccum, and a conn that stalls before detection is reaped by ReadHeaderTimeout (celeris#974) #975. (from fix(iouring): an unrecognised protocol prefix closes the conn instead of piling up in detectAccum, and a conn that stalls before detection is reaped by ReadHeaderTimeout (celeris#974) #975 review r1) (Done: fix(epoll,iouring,detect): one header budget across protocol detection, and a leading empty line and the IANA methods in Auto mode #1006 [main]) - The start-up line
io_uring engine selected tier=high multishot_recv=true provided_buffers=trueprints the tier's capabilities, but the provided-buffer ring (w.bufRing) is created only withCELERIS_IOURING_MULTISHOT_RECV=1(worker.go,if w.tier.SupportsMultishotRecv() && os.Getenv("CELERIS_IOURING_MULTISHOT_RECV") == "1"), off by default. A reader of a test log seesprovided_buffers=trueon a default run that never maps a ring: fix(iouring): an unrecognised protocol prefix closes the conn instead of piling up in detectAccum, and a conn that stalls before detection is reaped by ReadHeaderTimeout (celeris#974) #975's first round of tests and notes took the line for "the ring is in use", and none of its tests ran the detect branch's buffer returns until a multishot arm was added. Log whether the ring is in use (or name the fieldstier_supports_...). Code reading plus the fix(iouring): an unrecognised protocol prefix closes the conn instead of piling up in detectAccum, and a conn that stalls before detection is reaped by ReadHeaderTimeout (celeris#974) #975 runs. (from fix(iouring): an unrecognised protocol prefix closes the conn instead of piling up in detectAccum, and a conn that stalls before detection is reaped by ReadHeaderTimeout (celeris#974) #975 review r1) (Done: epoll, iouring, probe: the AsyncHandlers note, the start-up multishot fields, the probe reason, the diagnostic floor and parseAddr say what is so (v1.6.0 polish items) #1079 [diff]) - A leading CRLF before the request line gets the conn closed:
detect.isHTTPMethodByteaccepts only G, P, H, D, O, T and C, so "\r\nGET / HTTP/1.1..." isErrUnknownProtocol(before fix(iouring): an unrecognised protocol prefix closes the conn instead of piling up in detectAccum, and a conn that stalls before detection is reaped by ReadHeaderTimeout (celeris#974) #975 the io_uring conn hung with no answer, epoll already closed). RFC 9112 section 2.2 says a server SHOULD ignore at least one empty line received before the request-line. Methods whose first letter is outside that set (WebDAV PROPFIND/MKCOL, QUERY) are closed in Auto mode too; std (net/http) accepts any token as a method. Decide: skip a leading CRLF in detection, and widen the method set or fall back to HTTP/1 for any token. Behaviour-changing, not done in fix(iouring): an unrecognised protocol prefix closes the conn instead of piling up in detectAccum, and a conn that stalls before detection is reaped by ReadHeaderTimeout (celeris#974) #975. The epoll twin is on v1.6.0 polish: epoll engine #885. (from fix(iouring): an unrecognised protocol prefix closes the conn instead of piling up in detectAccum, and a conn that stalls before detection is reaped by ReadHeaderTimeout (celeris#974) #975 review r1) (Done: fix(epoll,iouring,detect): one header budget across protocol detection, and a leading empty line and the IANA methods in Auto mode #1006 [main])
Added 2026-10-10 (lane E10, PR #978):
-
Worker.run's three waits (SubmitAndWait,SubmitAndWaitTimeout,WaitCQETimeout, internal/engine/iouring/worker.go, the 4-mode submit block) treat any error as fatal:w.shutdown(); return. On kernels 5.19 and 6.1io_uring_enterreturns EBUSY from the wait when overflowed completions cannot be flushed into a full CQ ring (io_cqring_wait, "if we can't even flush overflow, don't wait for more": fs/io_uring.c:9415 at v5.19, io_uring/io_uring.c:2444 at v6.1; the guard is absent from v6.2 on;scripts/kernel-ebusy.sh,logs/kernel-ebusy.txtin the E10 lane evidence dir). A worker whose CQ ring overflows between two loop passes would stop serving and close every connection it owns, on the kernels at and just above the 5.19 floor. fix(iouring): shutdown: the send drain follows the shutdown budget, the owed-ops drain survives a full CQ ring, a parked recv-end error is delivered, batched counters flushed #978 makes the shutdown drain survive it (ringWaitRetryable,shutdownRingWait); the loop's own waits are untouched. Code reading plus kernel source; not reproduced (the 7.0 kernel of the laptop's Docker VM never returns it, a CQ full of 128 unreaped completions plus 40 overflowed included). Fix shape: on EBUSY/EAGAIN reap the CQ and wait again instead of shutting down, with a counter. RULE 92 search of the four repos found no tracker. (from io_uring: endOwedOpsAtShutdown gives up its drain on EBUSY (CQ ring full) and quietly reverts to the pre-#793 close-everything behaviour #873 / fix(iouring): shutdown: the send drain follows the shutdown budget, the owed-ops drain survives a full CQ ring, a parked recv-end error is delivered, batched counters flushed #978) (Done: iouring: the run loop retries a full CQ ring's EBUSY, the single-shot accept is non-blocking, the pool wait gates HTTP/1 and detached writes, the async hijack refusal comes first (v1.6.0 polish items) #1026 [diff]) -
The HTTP/2 pool wait of the io_uring shutdown still reads and serves HTTP/1 keep-alive connections:
Worker.run's!w.h2PoolSettled()branch callsstopAcceptingbut not therefuseRecvgate that fix(iouring): shutdown: the send drain follows the shutdown budget, the owed-ops drain survives a full CQ ring, a parked recv-end error is delivered, batched counters flushed #978 sets past the first 250 ms of the send drain, so a server with HTTP/2 handlers still running keeps answering the keep-alive requests of its HTTP/1 clients until the handlers end (up to the shutdown budget); epoll's loops stop reading at the cancel. Same shape as the io_uring: a shutdown that finds a SEND stalled on a client that does not read returns ~10 s late: the 250 ms send-drain bound is not enforced while the worker waits in SubmitAndWait #806 finding of fix(iouring): shutdown: the send drain follows the shutdown budget, the owed-ops drain survives a full CQ ring, a parked recv-end error is delivered, batched counters flushed #978's review (steady keep-alive traffic cannot end the drain), in the earlier wait, reachable only on a server that serves HTTP/1 and HTTP/2 together with a long H2 handler. Code reading, not probed. Fix shape: set the gate (andstopDetachedProducers) when the pool wait begins. (from fix(iouring): shutdown: the send drain follows the shutdown budget, the owed-ops drain survives a full CQ ring, a parked recv-end error is delivered, batched counters flushed #978 review r1) (Done: iouring: the run loop retries a full CQ ring's EBUSY, the single-shot accept is non-blocking, the pool wait gates HTTP/1 and detached writes, the async hijack refusal comes first (v1.6.0 polish items) #1026 [diff]) -
A hang of an HTTP/1 io_uring test, seen twice under load on the laptop's shared Docker VM and not reproduced alone (found by lane H2 while testing fix(h2): the event loop releases pool-handler streams; join split Cookie fields at HPACK decode, inside a header list bound #979; it touches no file of that PR: HTTP/1, no HTTP/2 stream). (1)
TestUseMountedMiddlewaresAnswerTheirPathsNativeEngines852/io_uring/plainhad run 3m51s when the 20-minute test timeout fired (a-count=20batch of 34 root-package tests, golang:1.27 linux/arm64,RLIMIT_MEMLOCK8 MiB so the ring capped to 1 worker, other lanes' io_uring containers running): the "running tests" block is inlanes-20261009/H2/logs/r1-hang-plain.log(the goroutine dump was cut by the filter). (2) A full root-package run on the same VM, the same hour, after every io_uring subtest failed to start for 0.02 s each (io_uring not available, ring ENOMEM retries), hit the 40-minute timeout at a test I could not identify (logs/r1-final-root.log). Not reproduced: the 852 test alone 300 times, and 2500 times each on the fix(h2): the event loop releases pool-handler streams; join split Cookie fields at HPACK decode, inside a header list bound #979 head and on d6dff48 (logs/r1-852-fix.log,r1-hang3-fix.log,r1-hang3-base.log), all pass; two full root runs afterwards passed in 4 minutes. Next step if it is seen again: keep the unfiltered output (-timeout 300s, the dump has every goroutine) and look at what the 852 server's io_uring loop and the test client wait on. (open: MEASUREMENT)
Added 2026-10-10 (lane E9, PR #980 review r3; io_uring is E10's, nothing edited here):
- io_uring's closing drain may cut a client that is steadily reading a
Connection: closeresponse, the defect fix(epoll): shutdown closes a deferred transplant's descriptor and flushes the batched counters; a closing conn is reaped on its own clock (#863, #876, epoll half of #874) #980 fixed for epoll after review.completeSend(internal/engine/iouring/worker.go,if cs.closing && sent > 0 { cs.lastActivity = now }) is the only progress stamp, so the clock moves once per SEND completion; a SEND of more than the socket queue takes can complete only as the queue drains (code reading, not verified on a ring), so a client reading through a deep send queue slowly enough could go more thanclosingDrainBound()(max(5 s, WriteTimeout)) between two completions and be reaped with the response truncated. On epoll this was measured (review r2 of fix(epoll): shutdown closes a deferred transplant's descriptor and flushes the batched counters; a closing conn is reaped on its own clock (#863, #876, epoll half of #874) #980: ReadTimeout 30 s, WriteTimeout disabled or 3 s, 40 MiB response, 32 to 128 KiB/s reader: reaped at 5.03 s / 3.05 s, main keeps the conn). The io_uring twin is code reading only (not run; needs an unconfined container and CI is the truth). Fix shape as in fix(epoll): shutdown closes a deferred transplant's descriptor and flushes the batched counters; a closing conn is reaped on its own clock (#863, #876, epoll half of #874) #980: count the peer's acknowledged bytes (tcp_infobytes_acked, sampled by the sweep at most every bound/4 for a closing conn) as progress. (from fix(epoll): shutdown closes a deferred transplant's descriptor and flushes the batched counters; a closing conn is reaped on its own clock (#863, #876, epoll half of #874) #980 review r3) (Done: epoll, iouring: a floor between sweeps, the h2 conn closing on a refused write drops its handler's frames, the closing drain counts acknowledged bytes (v1.6.0 polish items) #1056 [diff])
Added 2026-10-10 (lane E10, PR #978 round 3):
- The io_uring base tier accepts BLOCKING sockets.
baseTier.PrepareAccept(and the high tier's default branch when multishot accept is off) callsprepAccept(sqe, listenFD, 0)(internal/engine/iouring/tier.go), where the multishot paths passSOCK_NONBLOCK(sqe.go), andsockopts.ApplyFD(internal/sockopts) does not setO_NONBLOCKeither. Measured on the accepted fd of a detached stream, inside the guarded writeFn:O_NONBLOCKfalse onCELERIS_MAX_IOURING_TIER=base, true on the high tier,SO_SNDBUF32768 on both (scripts/r3-probe-blocking.sh,logs/r3probe-{high,base}.login the E10 lane evidence dir). Consequences found: (1) the detached inline-write fast path (unix.Writein the guarded writeFn) waits for the client on the base tier instead of leaving a remainder: an 8 MiB write to a client that never reads parked the producer goroutine in the kernel for as long as the engine let it, holdingdetachMu(1.5 s in the probe; a 1 MiB write to a client that reads took 246 ms,logs/r3-dbg-base3.log); (2) therefore the state fix(iouring): shutdown: the send drain follows the shutdown budget, the owed-ops drain survives a full CQ ring, a parked recv-end error is delivered, batched counters flushed #978 round 3 repairs (bytes left inwriteBufby a partial inline write) is not reachable on the base tier, and its end-to-end test cannot fail there; (3)hasPendingSendscounts a conn whosedetachMuis busy as pending, so such a producer holds the shutdown send drain to its budget, the same bound as any stalled client; it does not hang the shutdown:endOwedOpsAtShutdownshuts the socket down (unix.Shutdown(SHUT_RDWR), fd_lifetime.go) beforeWorker.shutdowntakes the lock, which wakes the producer (probe: Start returned 1.005 s after a 1 s budget began,detachMutaken 2 ms aftershutdown()was entered). Whether anything on the base tier depends on the socket being blocking was not checked. Fix shape:SOCK_NONBLOCKon the single-shot accept, then re-run the base-tier suites. RULE 92 search of the four repos (by mechanism: SOCK_NONBLOCK, blocking accept, inline write) found no tracker. (from fix(iouring): shutdown: the send drain follows the shutdown budget, the owed-ops drain survives a full CQ ring, a parked recv-end error is delivered, batched counters flushed #978 round 3) (Done: iouring: the run loop retries a full CQ ring's EBUSY, the single-shot accept is non-blocking, the pool wait gates HTTP/1 and detached writes, the async hijack refusal comes first (v1.6.0 polish items) #1026 [diff])
Added 2026-10-10 from PR #985 (lane DREDIS, redis PubSub control writes):
-
Worker.Write(internal/engine/iouring/driver.go,Writeon main 6668c8c, thedc.closingbranch) returns nil and drops the bytes when the conn is closing, while epoll and the standalone loop returnErrUnknownFDfor a number they no longer know. A driver whose caller races its own Close (redisPubSub.SubscribeagainstPubSub.Close) therefore sees success on io_uring although nothing was sent. Safe for redis: a PubSub control write that races PubSub.Close goes by descriptor number to the conn that has taken the closed descriptor #929 (the dc stays keyed to the number untilonCloseand the SEND uses the engine's dup, so the bytes cannot reach another socket); it is a semantic gap. Decide: returnErrUnknownFD(or a closing error) for parity, or state the contract in theWorkerLoop.Writedoc. RULE 92 search of the four repos (by mechanism: dc.closing, Write after UnregisterConn, silently dropped Write) found no tracker. (from fix(redis): bind PubSub control writes to their conn and release the dropped conn on reconnect #985 review) (Done: iouring: Write on a closing driver conn returns ErrUnknownFD, as epoll does #1058 [diff])
Added 2026-10-10 (lane E10B, PR #987):
-
finalizeDriver->closeOpFD(internal/engine/iouring/driver.go) starts one goroutine per finalized driver conn to run theclose(2)of the engine's duplicate (io_uring: closing a driver socket with SO_LINGER blocks the worker for the whole linger time (retire closes the engine's duplicate on the worker) #735), with no bound. With SO_LINGER and unsent data each lingering close pins an OS thread for the linger time, so N simultaneous lingering unregisters pin N threads (the runtime's limit is 10000 and exceeding it is fatal). fix(iouring): the conn table's slot writes take a leaf lock RegisterConn reads under; shutdownDrivers closes the driver descriptors in parallel (#959, #866) #987 bounds the shutdown fan-out (shutdownDriverCloseWorkers, 128); the live path is unchanged. Fix shape: the same bounded pool (closeFDsInParallel), shared with the live path. RULE 92 search of the four repos (by mechanism: closeOpFD, unbounded goroutine, shutdownDrivers linger) found no tracker. (from fix(iouring): the conn table's slot writes take a leaf lock RegisterConn reads under; shutdownDrivers closes the driver descriptors in parallel (#959, #866) #987) (Done: iouring: driver-descriptor close bound on the live path, shutdown wait without a sleep, the bound test without the shared semaphore #1057 [main]) - With the development-only
CELERIS_IOURING_FIXED_FILES=1(ACCEPT_DIRECT),w.connsis indexed by fixed-file slot, not by descriptor number, soRegisterConn's "already an HTTP connection" check (connSlotBusy, formerly a bare read) compares a real descriptor with a slot index and can refuse a driver for a number that is only a slot in use. Pre-existing; the gate is off by default (fixedFilesEnabled: "development only: the feature is incomplete", io_uring: fixed-file support is unimplemented behind a malformed accept SQE — readiness checklist before it can be enabled #541). Fix shape: key the check by the real descriptor (the fixed-file path has none) or refuse driver registration while fixed files are on. Search of the four repos by mechanism (CELERIS_IOURING_FIXED_FILES, fixed file RegisterConn) found only probatorium#301 (the cells run with fixed_files=false), not this. (from fix(iouring): the conn table's slot writes take a leaf lock RegisterConn reads under; shutdownDrivers closes the driver descriptors in parallel (#959, #866) #987) (open: ENGINE-CODE)
Added 2026-10-10 (lane E10B, round-2 review):
-
hijackConn(internal/engine/iouring/worker.go,cs := w.conns[fd]at about :2648 at 0bdcf25) reads the conn table before itsif w.asyncrefusal (about :2669), and in async mode it runs on the per-connection dispatch goroutine viah1State.HijackFn. Move thew.asynccheck above the read, or narrow the comment onconnSlotBusyin conns_slot.go that calls its read the only one off the worker goroutine (the hijack read is pre-existing, not from PR fix(iouring): the conn table's slot writes take a leaf lock RegisterConn reads under; shutdownDrivers closes the driver descriptors in parallel (#959, #866) #987). (Done: iouring: the run loop retries a full CQ ring's EBUSY, the single-shot accept is non-blocking, the pool wait gates HTTP/1 and detached writes, the async hijack refusal comes first (v1.6.0 polish items) #1026 [main]) - PR fix(iouring): the conn table's slot writes take a leaf lock RegisterConn reads under; shutdownDrivers closes the driver descriptors in parallel (#959, #866) #987 cost section: the signed paired median (fix/base +1.7% plain, +1.2% spin) is compared with an unsigned A/A floor (median |a/b-1| of 5.0% and 7.7%). Report the signed A/A medians (1.0129 plain, 1.0029 spin) and the sign test (fix>base in 21/36 plain and 19/36 spin, p about 0.4) and have E10B/scripts/pool.py print them; the conclusion (no cost resolvable above the noise) does not change. (open: TEST-HYGIENE)
-
TestCloseDriverFDsUsesTheEngineWideBound866(driver_shutdown_linger_866_linux_test.go), its comment, its t.Fatal text and its entry in the ci.yml n866 list call the bound "engine-wide", butdriverCloseSem(driver.go, package scope) is process-wide and is shared by every io_uring engine in one process; rename and reword all three together. (Done: iouring: driver-descriptor close bound on the live path, shutdown wait without a sleep, the bound test without the shared semaphore #1057 [main])
Added 2026-10-11 (lane E13, PR #992):
-
cancelAccept(worker.go; the pause'scloseListenerAfterDrainandstopAccepting) cancelled the listen accept withprepCancelFDSkipSuccessand waited oneSubmitAndWaitTimeout(50ms)for any CQE. Fixed in PR fix(epoll,iouring): a port 0 Addr is decided once, and Listen returns after its port is free (#895, #896) #992 round 3: the same keep-the-CQE cancel the shutdown drain uses, a bounded wait for the accept's own terminal CQE (TestStopAcceptingWaitForTheAcceptIsBounded896,TestStopAcceptingWithNoAcceptArmedDoesNotWait896). -
cancelAcceptgave up silently when the SQ ring had no free entry, and closed the descriptor with the accept armed (the io_uring: Listen returns while its listen sockets still hold the port, for ~2 ms, until the kernel tears the ring down #896 window, on the pause and on the!h2PoolSettledshutdown). Fixed in PR fix(epoll,iouring): a port 0 Addr is decided once, and Listen returns after its port is free (#895, #896) #992 round 3: it submits the ring once to make room (getCancelSQE) and counts and WARNs a give-up (TestStopAcceptingWithAFullSQRingReleasesTheListenSocket896, and the engine-level...SQFull896loops: 183/200 and 99/100 shutdowns found the port taken on the round-2 head, 0 on the fix). - The shutdown drain's
case udAcceptandcancelAcceptend their wait on a terminal accept CQE whose user data names the descriptor (PR fix(epoll,iouring): a port 0 Addr is decided once, and Listen returns after its port is free (#895, #896) #992 round 3 added that check to the drain, withTestShutdownDrainIgnoresAnotherSocketsAcceptEnd896, and tocancelAccept). Still open: a terminal CQE left unread from an earlier listen socket that a re-created socket shares a number with (pause, resume, shutdown together; a re-created socket usually gets the same number) would end the wait early, and the port would stay bound for the ring teardown's ~2 ms. Counting terminal accept CQEs against the cancel's result (nr) would close it, but needs the order of the cancel's CQE and the accept's terminal CQE pinned on the kernels CI runs. Narrow, not reproduced. (PARTIAL: -; open: decided 2026-10-12 B (comment). The fix itself (count terminal CQEs against the cancel's n) - A worker whose listen-socket re-create fails after
ResumeAccept(the port was taken while accepting was paused: a fixed port, or Adaptive) logs, countsErrorListenerRecreateand callsshutdown(); when every worker has done so the engine keeps running with no listener,Listendoes not return andResumeAccepthas returned nil (pinned byTestResumeAfterThePortWasTaken895, PR fix(epoll,iouring): a port 0 Addr is decided once, and Listen returns after its port is free (#895, #896) #992 round 3;Server.ResumeAccept's doc says so). Options: return an error fromListenwhen no worker is left, or makeResumeAcceptwait for the re-creates. The epoll twin is in v1.6.0 polish: epoll engine #885. RULE 92 search of the four repos (ListenerRecreate, ResumeAccept re-create) found no issue for it. (Done: fix(epoll,iouring): a port 0 Addr is decided once, and Listen returns after its port is free (#895, #896) #992 [main]; see recon note)
Added 2026-10-11 (lane E14, PR #994 review r1):
-
io_uring, the timeout sweep and the untimed SEND wait (internal/engine/iouring/worker.go; replaces this lane's round-1 text, which called the late sweep new for async conns and was not measured).
checkTimeoutsis iteration-gated (the event-loop gategate := uint32(0x3FF); if w.detachedCount > 0 || w.cfg.ReadHeaderTimeout > 0 { gate = 0x1F }) andbaseTimeoutcaps an idle wait at 100 ms (50 ms with a detached conn, 25 ms withReadHeaderTimeout > 0), so on an idle worker the sweeps are 32 x 25 ms = 0.8 s apart (default config,ReadHeaderTimeout10 s), 32 x 50 ms = 1.6 s (a detached WS/SSE conn), or 1024 x 100 ms = about 102 s (ReadHeaderTimeoutdisabled, nothing detached); and in mode 3a (waitUntimed: SEND SQEs just queued,SubmitAndWait()with no timeout, ended by the next CQE of any conn) no iteration runs at all. Measured withtimeout_bound_811_linux_test.go(WriteTimeout 1 s, clients that stop reading, ReadHeaderTimeout off, 1 run each; the time is untilActiveConnectionsis 0, which takes two sweeps: WriteTimeout's close, then the 5 s closing-drain bound): async conn, client reads 256 KiB then stops: main 126.8 s, with PR fix(iouring): a conn with an outstanding SEND is not kept on the dirty list, so a stalled client no longer keeps the worker polling (#811) #994 7.8 s (main spun about 1 s, 674 ms of CPU in the first 5 s, until WriteTimeout fired, then slept 100 s to the next sweep; the PR keeps a 1 s sweep while a SEND is outstanding:sendWatch, set by the dirty pass's give-up and re-derived by every sweep fromcs.sending || cs.zcNotifPending); inline (sync) conn, same client: main 225.9 s, PR fix(iouring): a conn with an outstanding SEND is not kept on the dirty list, so a stalled client no longer keeps the worker polling (#811) #994 128.1 s, The first sweep, which fires WriteTimeout, is still on the idle gate (about 102 s, plus any mode-3a wait), because a sync conn is never given up by the dirty pass; but that sweep finds the conn with its SEND outstanding and leavessendWatchon, so the closing-drain stage then runs on the 1 s cadence: one idle-gate period instead of two (my reading of the numbers, n = 1 each). Open: a sync conn's WriteTimeout (the first stage) is enforced up to about 102 s late withReadHeaderTimeoutdisabled, and not at all while the worker sits in mode 3a with the stalled SEND as its last submission (the next CQE of any conn ends it; io_uring: a shutdown that finds a SEND stalled on a client that does not read returns ~10 s late: the 250 ms send-drain bound is not enforced while the worker waits in SubmitAndWait #806 bounded only the draining worker,!draininginwaitUntimed). Fix shape to weigh: setsendWatchwhere a ring SEND is submitted (flushSend), not only at the give-up and from a sweep that already saw one, and cap the mode-3a wait by the nearest WriteTimeout deadline (SubmitAndWaitTimeout), or make the sweep time-based; both touch the per-request SEND path, so they need a RULE 74 measurement (mode 3a exists to skip ext_arg, ~200-500 ns per wait). Also consider a validation-build counter for a conn still SEND-outstanding after N sweeps: the spin that made the io_uring: a recv-pause whose cancel SQE is unavailable is recorded as paused while the recv stays armed #519 / io_uring: teardown paths never call removeDirty, so dead connStates stay linked in the dirty list #527 stranded-sending leaks visible is gone, and WriteTimeout is their only backstop. RULE 92: searched the goceleris repos for the mechanism (waitUntimed, untimed SubmitAndWait WriteTimeout, checkTimeouts cadence WriteTimeout, gate 0x3FF), no tracker; io_uring: a shutdown that finds a SEND stalled on a client that does not read returns ~10 s late: the 250 ms send-drain bound is not enforced while the worker waits in SubmitAndWait #806 (closed) is the draining-only neighbour. (from io_uring: an async conn stays on the dirty list while its ring SEND waits for a slow client, so the worker busy-polls (~370 ms CPU per second, measured) #811 / PR fix(iouring): a conn with an outstanding SEND is not kept on the dirty list, so a stalled client no longer keeps the worker polling (#811) #994, review r2 and r3) These WriteTimeout bounds hold on the default tier only: on the capped base tier (CELERIS_MAX_IOURING_TIER=base) the base tier accepts blocking sockets (v1.6.0 polish: io_uring engine #884 line-89 item), the async dispatch goroutine blocks in the write, and TestIouringWriteTimeoutBoundOfAStalledSend811 fails in both asserted arms on this head and on main (all 4 conns still open at 130 s); CI does not run that tier. This also explains why main does not spin on the base tier. (PARTIAL: fix(iouring): a conn with an outstanding SEND is not kept on the dirty list, so a stalled client no longer keeps the worker polling (#811) #994, epoll, iouring: WriteTimeout bounds a stalled keep-alive response on both native engines; epoll measures a parked response from its own clock (v1.6.0 polish items) #1052; open: DUP A74 A75: what is left is the idle sweep gap for the other timeouts (ReadTimeout/IdleTi) -
io_uring, test gaps found by the E13 (fix(epoll,iouring): a port 0 Addr is decided once, and Listen returns after its port is free (#895, #896) #992) round-3 Opus verify (mutants that survive every 896 test; production code uses the strict form, so these are test gaps, not defects): (a) cancelAccept's wait must not end on an accept CQE that still has IORING_CQE_F_MORE (
!cqeHasMore(c.Flags)gate, worker.go cancelAccept): a connection arriving during the stopAccepting/pause cancel wait is not tested; (b) the wait must end on the accept's own terminal CQE, not on the cancel's answer (listenCancelAnswerreturning done on a positive count is equivalent on kernel 7.0.12 where both CQEs arrive in one enter; CI's 6.x kernels may order them differently, so pin it with a rig); (c) a connection the accept delivers during the up-to-250 ms cancel wait is served by processCQE (a mutant that drops it leaks the descriptor and passes everything). Evidence: lanes-20261009/E13/verify-r3-opus/logs/vmut.log (V1, V3, V5). (from fix(epoll,iouring): a port 0 Addr is decided once, and Listen returns after its port is free (#895, #896) #992 review r3) (done in iouring tests: park, cancel and header-timer test hardening (v1.6.0 polish items) #1100 [main]; maintainer to confirm, box left open) -
WriteTimeout does not bound a stalled keep-alive HTTP/1.1 response on io_uring (and adaptive over it): a client with a 4 KiB receive buffer that reads nothing for 5 s is later sent the whole 8, 16 and 48 MiB body with
WriteTimeout: 1s(12 s stall: whole 48 MiB), where std ends it at ~1.8 MB. main 3cfee4b, arm64 container; why std engine: an SSE stream ends at the server's ReadTimeout/WriteTimeout (30 s in the bench adapter) while epoll, io_uring and adaptive keep it open; the docs say WriteTimeout: -1 is enough on every engine #855's negative test is std-only. Check thecheckTimeoutsWriteTimeout gate (worker.go,elapsed > cfg.WriteTimeout) against a response parked in the send queue. Evidence: lanes-20261009/SSE855/logs/probe-big8388608.log, probe-big16777216.log, probe-stall12.log, main-first-arm64.log (SSE855NEG lines). (from std engine: an SSE stream ends at the server's ReadTimeout/WriteTimeout (30 s in the bench adapter) while epoll, io_uring and adaptive keep it open; the docs say WriteTimeout: -1 is enough on every engine #855) (Done: epoll, iouring: WriteTimeout bounds a stalled keep-alive response on both native engines; epoll measures a parked response from its own clock (v1.6.0 polish items) #1052 [main]) -
io_uring: after a worker goroutine ends without returning (a sync handler called
runtime.Goexit, celeris#848, PR fix(epoll,iouring): a loop or worker goroutine that ends without returning is logged and counted, and sync handlers must not Goexit (#848) #997)PauseAcceptwaits to its deadline (Linger()+1s, about 2.5 s) and then returns nil: onlyWorker.shutdownandcloseListenerAfterDrainsetlistenFDClosedand callpause.Notify, and a dead worker reaches neither. The dead worker'sSO_REUSEPORTlistener also stays open, so an adaptive switch'sBeginPauseAccepton the engine it leaves does not stop that listener taking a share of new connections. Closing it inEngine.runWatched's deferred report would fix both; it is a behaviour change beyond PR fix(epoll,iouring): a loop or worker goroutine that ends without returning is logged and counted, and sync handlers must not Goexit (#848) #997's "document and fail loudly" scope. Found by reading in PR fix(epoll,iouring): a loop or worker goroutine that ends without returning is logged and counted, and sync handlers must not Goexit (#848) #997 review (not reproduced). The epoll twin is in v1.6.0 polish: epoll engine #885. (from epoll, io_uring: runtime.Goexit in a synchronous handler ends the loop or worker goroutine it runs on, and every connection that loop owns goes unserved #848) (Done: epoll, iouring, platform: the thread affinity of a host with over 1024 CPUs, and the pause after a loop or worker goroutine ended without returning #1020 [main]) -
sse.ClientPolicyClose(MaxQueueDepth 4) never ends the stream of a client that reads nothing on io_uring (and adaptive over it):OnDisconnecthad not run 20 s after the client connected, on main and on fix(std): a detached stream (SSE) is not cut at ReadTimeout or WriteTimeout (#855) #998 (std ends it after the 2 s WriteTimeout). A handler bounded at 3 s on the same engines returns at 3.0 s having pushed about 37 MiB to the unread socket, so the native writes do not block; WriteTimeout does not end such a conn either (see the stalled keep-alive response item above). The mechanism was not traced. fix(std): a detached stream (SSE) is not cut at ReadTimeout or WriteTimeout (#855) #998 documents it as a known gap inmiddleware/sse/doc.goandconfig.go. Companion item in v1.6.0 polish: middleware #888. RULE 92: searched celeris, probatorium, docs and loadgen by mechanism (ClientPolicyClose, stalled SSE client reads nothing, slow client send queue close native): no tracker besides v1.6.0 polish: middleware #888's two items. Evidence: lanes-20261009/SSE855/review-r1-pas/logs/evict-pr-v2.log, evict-off-v2.log. (from std engine: an SSE stream ends at the server's ReadTimeout/WriteTimeout (30 s in the bench adapter) while epoll, io_uring and adaptive keep it open; the docs say WriteTimeout: -1 is enough on every engine #855, review) (Done: epoll, iouring: a detached conn whose peer takes none of its queued bytes for WriteTimeout is closed (v1.6.0 polish items) #1077 [main]) -
handleRecv, engine/iouring/worker.go (the direct-body tail, found in the ADOPT lane while fixing item 37): on anAsyncHandlersengine the tailProcessH1(cs.ctx, rest, ...)runs withoutInlineMode, so an async-marked route pipelined behind a request whose body was received straight intobodyBufruns its handler on the worker thread, where the first-recv path promotes the conn to the dispatch goroutine onErrAsyncDispatch. A conn is still inline-first at that point because a split fixed-length body does not promote it (HasPendingDispatchStateexcludesbodyNeeded). Reproduced: a 20000-byte POST, then a 2000-byte POST in two writes whose last carriesGET /block(an async route): 4 of 12 other conns were answered within 700 ms while/blockwas parked (probeprobe-asynctail.go.txt,run-asynctail-probe.sh, logout/probe-asynctail.log; search outputrule92-asynctail-search.txt, all under lanes-20261009/ADOPT). epoll has no twin: its direct-body read is gated on!l.async. Fix: setInlineModearound the tail and promote onErrAsyncDispatchas the main block does (promoteConnToAsync), or stop taking the direct-body recv on async engines. RULE 92: searched celeris, probatorium, docs and loadgen by mechanism (direct-body tail, InlineMode tail async route, recvIntoBody async, bodyBuf pipelined async route): no tracker besides this checklist (from PR epoll,iouring: one handling of an HTTP/1 verdict for the recv path and the transplant replay; a pipelined h2c upgrade behind a direct-body request #1015, found while working on item 37) (open: ENGINE-CODE) -
engine exit tests (PR epoll, iouring, platform: the thread affinity of a host with over 1024 CPUs, and the pause after a loop or worker goroutine ended without returning #1020 review r2, minor): no test in either engine checks that a dead worker/loop has left the SO_REUSEPORT group BEFORE it is marked
exitedand the pause is notified (io_uring: SHUT_RD/close could move afterexited.Store+Notify; epoll:stopAcceptingcould move after the flags+Notify, and every test still passes becauseabnormalWorkerExits/abnormalLoopExits.Add(1)comes first). Also: CI runs the new io_uring tests only in the memlock-capped job (one worker), so the multi-worker pause path is checked only on a laptop; no test pins the ring close inleaveGroupAfterExit;logs/hotpath-table.txtof the lane should listpinThreadToCPU,planWorkerCPUs,moveZCSendBufs,logKeptZCSendBufs. (open: TEST-HYGIENE) -
Worker.ringWaitContinues(internal/engine/iouring/worker.go, PR iouring: the run loop retries a full CQ ring's EBUSY, the single-shot accept is non-blocking, the pool wait gates HTTP/1 and detached writes, the async hijack refusal comes first (v1.6.0 polish items) #1026 item 49) has no ceiling: with a persistent EBUSY or EAGAIN from a ring wait and no cancelled context, a worker retries for ever (50 us sleep when the CQ ring is empty, the loop's own pass reaps otherwise) where before it shut down on the first error. The first retry on a worker is logged as a Warn and counted in the unexportedringWaitRetries; nothing bounds it. Decide a ceiling counted in consecutive retries with no reaped completion (about a second's worth) that restores the old end (w.shutdown(); return) for a ring that is really failing. Behaviour-changing, not done in iouring: the run loop retries a full CQ ring's EBUSY, the single-shot accept is non-blocking, the pool wait gates HTTP/1 and detached writes, the async hijack refusal comes first (v1.6.0 polish items) #1026 (review round 1, minor). RULE 92 search of the four repos by mechanism found no tracker. (from PR iouring: the run loop retries a full CQ ring's EBUSY, the single-shot accept is non-blocking, the pool wait gates HTTP/1 and detached writes, the async hijack refusal comes first (v1.6.0 polish items) #1026 review r1) (open: DECISION) -
The HTTP/1 refusal gate of the io_uring shutdown (
refuseRecv, the send drain's since io_uring: a shutdown that finds a SEND stalled on a client that does not read returns ~10 s late: the 250 ms send-drain bound is not enforced while the worker waits in SubmitAndWait #806 and the HTTP/2 pool wait's since PR iouring: the run loop retries a full CQ ring's EBUSY, the single-shot accept is non-blocking, the pool wait gates HTTP/1 and detached writes, the async hijack refusal comes first (v1.6.0 polish items) #1026) drops an HTTP/1 request's bytes and does not re-arm the recv, so a client past the gate gets neither a response nor a close until the worker shuts down: a request whose body or headers were still arriving at the gate is never answered (net/http closes idle connections at that point), and with a ctx that has no deadline and a stuck HTTP/2 handler the pool wait, hence the hang, has no bound. A client that keeps sending also keeps refreshingcs.lastActivity(handleRecv stamps it before therefuseRecvreturn), so the idle reaper cannot reclaim the connection. Decide, for both gates together:SHUT_RDon the HTTP/1 connections at the gate, or a close of the idle ones and of the ones with no complete request, and whether the grace should come at the cancel instead of 250 ms into the wait (epoll's phases gate at their start). Behaviour-changing; PR iouring: the run loop retries a full CQ ring's EBUSY, the single-shot accept is non-blocking, the pool wait gates HTTP/1 and detached writes, the async hijack refusal comes first (v1.6.0 polish items) #1026 keeps the io_uring: a shutdown that finds a SEND stalled on a client that does not read returns ~10 s late: the 250 ms send-drain bound is not enforced while the worker waits in SubmitAndWait #806 behaviour and records it as an open question (review round 1, minor). RULE 92 search of the four repos by mechanism found no tracker. (from PR iouring: the run loop retries a full CQ ring's EBUSY, the single-shot accept is non-blocking, the pool wait gates HTTP/1 and detached writes, the async hijack refusal comes first (v1.6.0 polish items) #1026 review r1) (open: DECISION) -
io_uring: a SEND below
longSendBytes(64 KiB,internal/engine/iouring/worker.go) into a socket that is already full, on an otherwise idle worker, is still waited for with no timeout (mode 3a,waitUntimed:remainderWatchis set only by a remainder SEND or a SEND of 64 KiB or more, PR epoll, iouring: WriteTimeout bounds a stalled keep-alive response on both native engines; epoll measures a parked response from its own clock (v1.6.0 polish items) #1052), soWriteTimeoutis not enforced for that conn until another CQE arrives. Code reading only, NOT measured: the stalled arms ofTestIouringWriteTimeoutBoundOfAStalledSend811andTestIouringStalledKeepAliveResponseIsBoundedByWriteTimeout884use 3 and 16 MiB responses. Fix shape to weigh: mark a SEND submitted while the conn's previous SEND is still unacknowledged by the kernel, or give mode 3a a ceiling (a timed wait whenever the last untimed wait lasted more than a second); the 64 KiB threshold is a choice, not a measurement, and both need a RULE 74 measurement (mode 3a exists to skip ext_arg). RULE 92: searched the goceleris repos by mechanism (waitUntimed, mode 3a SubmitAndWait, remainderWatch, small SEND full socket untimed wait): only this issue. (from PR epoll, iouring: WriteTimeout bounds a stalled keep-alive response on both native engines; epoll measures a parked response from its own clock (v1.6.0 polish items) #1052, lane WTSTALL) (open: ENGINE-CODE) -
io_uring, the sweep gate has no time floor, the busy direction of v1.6.0 polish: epoll engine #885 item 29 (internal/engine/iouring/worker.go on main b3c2da0,
Worker.run:gate := uint32(0x3FF); if w.detachedCount > 0 || w.cfg.ReadHeaderTimeout > 0 { gate = 0x1F }; if w.tickCounter&gate == 0 || w.sweepWatchDue() { w.checkTimeouts() }, lines 1869-1876). Read from the code, not measured:tickCountercounts loop iterations, so a worker with steady traffic sweeps once per 32 iterations (default config,ReadHeaderTimeout10 s), which is thousands of times a second at 100k+ req/s per worker, as epoll's gate did before PR epoll, iouring: a floor between sweeps, the h2 conn closing on a refused write drops its handler's frames, the closing drain counts acknowledged bytes (v1.6.0 polish items) #1056 (4022 sweeps in 1.5 s on 2 loops under 16 busy clients on main, 119 to 122 after the floor).w.lastSweepexists (stamped incheckTimeouts) and is read only bysweepWatchDue. v1.6.0 polish: io_uring engine #884's item on the sweep and the untimed SEND wait (the one that quotes this gate) covers the idle direction (0.8 s, 1.6 s and 102 s between sweeps) and offers "make the sweep time-based" only as one fix option. Fix shape to weigh: the same floor as epoll (now - lastSweep >= sweepFloor, the clock read only at a gate hit, a backward step sweeps), which composes with the idle fix; it needs a RULE 74 measurement of the sweep's share of a worker under load (epoll's was 0.39 of a core at 8192 idle conns plus 16 busy clients, 0.02 after). RULE 92: searched the goceleris repos (celeris, probatorium, docs, loadgen, benchmarks, middlewares) by mechanism (iouring sweep gate, checkTimeouts cadence, tickCounter gate busy worker): only v1.6.0 polish: io_uring engine #884's idle-direction item and v1.6.0 polish: epoll engine #885's epoll item. (from PR epoll, iouring: a floor between sweeps, the h2 conn closing on a refused write drops its handler's frames, the closing drain counts acknowledged bytes (v1.6.0 polish items) #1056 review round 1, lane TMOCLOSE) (open: ENGINE-CODE) -
Worker.closeOnRecvEnd(internal/engine/iouring/worker.go, non-busy path aftermu.Lock()): a second recv-end error that arrives after the running handler releaseddetachMu, but before its hand-back (closeOwed) was drained, goes down the non-busy path, which callsOnError(second)itself, andcloseConnthen delivers the parked firstcs.closeErr, so a detached middleware is told [second, first]: the later death, then the original. WebSocket's handler storeswriteErrlast-wins, so the order matters. fix(iouring): shutdown: the send drain follows the shutdown budget, the owed-ops drain survives a full CQ ring, a parked recv-end error is delivered, batched counters flushed #978 kept the first error on the busy path (if cs.closeErr == nil) but not on this one. Fix shape: in the non-busy path, whencs.closeErr != nil, deliver it in place oferrand clear it (what epoll'scloseOnReadEnddoes since PR epoll: the first parked read-end error is kept andLoop.shutdowndelivers it; a loop does not park while it holds a driver conn, andRegisterConnwakes a parked one (v1.6.0 polish items) #1059, withTestSecondReadEndErrorAfterTheHandlerReturned867red before and green after). Read from the code for io_uring, not reproduced here; reaching it needs a second recv-end CQE on a conn whose close was deferred (an RST after the FIN). RULE 92: searched the goceleris repos by mechanism (closeOnRecvEnd, parked first error, second error after release): only io_uring: OnError is never called, and a second error overwrites the first, when Worker.shutdown completes a close deferred by closeOnRecvEnd #867 and Follow-ups from #745: a test for the re-check exit's owed hand-back, closeErr on the shutdown path #764, both closed, which cover the busy path and the shutdown path. (from PR epoll: the first parked read-end error is kept andLoop.shutdowndelivers it; a loop does not park while it holds a driver conn, andRegisterConnwakes a parked one (v1.6.0 polish items) #1059 review round 1, lane EPLIFE) (open: ENGINE-CODE) -
A
StreamWriterwrite refused at the engine's 64 MiB limit cannot be told apart from other errors by a user:conn.ErrWriteRefusedandconn.ErrConnClosedare ininternal/conn, soerrors.Isagainst them is impossible outside the module (a gone peer can be matched throughnet.ErrClosed, whichErrConnClosedwraps; a refusal cannot be matched at all, and the godoc ofStreamWriter.Flushcan only say "an error"). After PR conn, epoll, iouring: an HTTP/1 StreamWriter reports a closed connection and a refused write, and a response held unsent before Detach is capped (v1.6.0 polish items) #1067 a handler that streams more than 64 MiB beforeDetach, or a detached stream whose peer stopped reading, gets that error and the connection is closed; a handler that wants to react (switch toDetach, stop the producer, log) has only the message text. Fix shape: an exported sentinel (a new name inapi/, so the golden files change and it is a public API decision, for exampleceleris.ErrStreamRefusedwrapping the internal value), or document that any error that does not wrapnet.ErrClosedis a refusal. RULE 92: searched the goceleris repos by mechanism (ErrWriteRefused, StreamWriter sentinel error, StreamWriter errors.Is): no issue. (from PR conn, epoll, iouring: an HTTP/1 StreamWriter reports a closed connection and a refused write, and a response held unsent before Detach is capped (v1.6.0 polish items) #1067 review round 1, lane STREAMCAP) (open: DECISION) -
sse: the stream of a client that reads nothing ends (
OnDisconnectruns) 57 to 92 ms after connect, on epoll (adaptive over epoll: 57 ms) and io_uring (92 ms), not atWriteTimeout, when the handler sends one 8 MiB event and then 1 KB events every 5 ms withMaxQueueDepth4 andClientPolicyClose, on main 7b02cae. The cause was not established (on the SSENONREAD lane's branch, not on main: with 4 KiB events every 0.5 ms and an engine bound of 2 s,OnSlowClientwas called, on 2 epoll/sync cells of a quiet arm64 container under -race, before the bound, so a full queue does happen on a native engine; and on a 4 vCPU amd64 runner under -race and coverage (Coverage job,go test -race -covermode=atomic, of celeris PR 1077 at 9915418: run 39120214112, job 117654118987; log kept aslanes-20261009/SSENONREAD/final2/ci/coverage-9915418-run39120214112-job117654118987.log) the same producer's stream ended 0.5 to 0.7 s after connect on every epoll, io_uring and adaptive cell, before the bound; which of queue overflow and a write error ended it there was not logged); the likely one is that the drain goroutine lags behind the 5 ms events, the 4-slot queue fills and the policy fires, which isClientPolicyCloseworking on a slow drain rather than a slow client. Not traced at runtime. Candidate for io_uring, read from the code: the guarded write hook compacts the pending buffer on every partial inline write (cs.writeBuf = cs.writeBuf[:copy(cs.writeBuf, cs.writeBuf[n:])], the guarded closure OnDetach installs inworker.go) andcompleteSendcopies the remainder ofsendBuf(copy(cs.sendBuf, cs.sendBuf[sent:])), each O(backlog) underdetachMu, which the drain goroutine's next write waits for. epoll advanceswritePosand does not copy, and adaptive over epoll closed at 57 ms too, so there it is something else (the loop thread holdingdetachMuacross a flush of a multi-MB backlog is a candidate). Measure the lock wait of the drain goroutine with a profile before choosing a fix. Evidence:lanes-20261009/SSENONREAD/superseded-nosettle-red-main.out(cellsio_uring/*/steady92 ms,adaptive/async/steady57 ms). RULE 92: searched celeris, probatorium, docs and loadgen by mechanism (ClientPolicyClose drain lag, partial send compaction under detachMu, writeBuf compaction backlog, completeSend memmove): no tracker. (from the SSENONREAD lane) (open: MEASUREMENT) -
The stall bound of PR epoll, iouring: a detached conn whose peer takes none of its queued bytes for WriteTimeout is closed (v1.6.0 polish items) #1077 (a truly-detached conn whose peer takes none of its queued bytes for
WriteTimeoutis closed, epoll and io_uring; v1.6.0 polish: middleware #888 items 38 and 39, v1.6.0 polish: io_uring engine #884 item 69) has four known gaps, all read from the code or listed as not tested, none measured: (1) an HTTP/2 conn is never detached, so an SSE stream over h2c (and any h2 stream whose peer stops reading) is not covered bydetachedStalled; no test drives h2c SSE against a client that reads nothing; (2) epoll: a detached conn on a socket that reports notcp_info(not TCP) is not bounded, since the stream's inline writes are only visible through the acknowledged byte count (detachedStalledfails open,TestDetachedStallNeedsAnAckedCount888); (3) io_uring: a fixed-file (ACCEPT_DIRECT, dev-only opt-inCELERIS_IOURING_FIXED_FILES=1) conn has notcp_infosample, so onlycompleteSendcounts as progress and no test drives one; (4) "any progress" restarts the clock, with no minimum rate: a peer that opens its window by a few bytes everyWriteTimeout/4 keeps up to 64 MiB of userspace queue per conn (maxSendQueueBytesDetached) for as long as it likes, as a non-detached response does. Fix shape to weigh: for (1) detach-aware stall look on the H2 stream's queue, or document h2c as bounded only by the handler; for (4) a floor on the byte rate, which is a behaviour change for slow mobile readers. RULE 92: searched the goceleris repos (celeris, docs, probatorium, loadgen) by mechanism (h2c SSE stalled client WriteTimeout, detached stall tcp_info, fixed-file stall): no tracker. (from PR epoll, iouring: a detached conn whose peer takes none of its queued bytes for WriteTimeout is closed (v1.6.0 polish items) #1077 review round 1, lane SSENONREAD) (open: DECISION) -
io_uring: no test makes the DRAINING to SUSPENDED park's
w.sweepRetract()(internal/engine/iouring/worker.go, thelen(w.liveConns) == 0block beforewakeMu) discriminate.TestParkedWorkerRetractsResidueOfAPostSweepClosestays green with the retraction removed (main aa93b61, arm64 container,logs/t11iou-mut-M1*.login the EPTSTLOOP lane): a ReadTimeout close of a conn with its recv armed leaves its descriptor owed to the cancel's CQE, the park gatew.closeFDOwed == 0(celeris#685) keeps the worker out of the park, and the next iteration'ssweep()retracts. The arm goes red only with the retraction and thecloseFDOwedgate both removed (logs/t11iou-mut-M3.log). Find a close that leaves the last conn with nothing in flight aftersweep()(a conn with no armed recv, a detach-queue close) and test it, or decide the retraction is dead code behind the gate and say so in the comment. RULE 92: searched the four goceleris repos by mechanism (sweepRetract park, closeFDOwed, park residue retraction io_uring): only epoll/io_uring: a draining loop that loses its last connection after sweep() parks without retracting its residual gauges, so TransplantResidualBusy reports a connection nothing holds #711 (the cause) and io_uring close paths close the fd while a linked RECV can still resolve its number (the #657 fd-lifetime rule, not yet applied to close/hijack/shutdown) #685, no tracker. (from PR test: epoll park, shutdown and drain-order tests with no sleeps or disarmed timers, and one nil-by-default drain seam (v1.6.0 polish items) #1080, lane EPTSTLOOP) (open: TEST-HYGIENE) -
With a send completion held (io_uring: a ring SEND completing while an async handler holds cs.detachMu parks the worker (handleSend/completeSend, a fifth #704 site) #750), the rest of a pipelined conn's response 1 does not move while the next pipelined request's async handler runs: the held completion is applied only at the dispatch goroutine's hand-back, so the SEND of the tail is not re-submitted until the handler returns. Measured in the IOTSTSTALL lane (8 runs of
TestIouringSendCompletionDuringASlowAsyncHandlerDoesNotStallItsWorkerwith a temporary read of the client's/bigbyte count, held=2 in every run): with the handler gated, the client's read of/bigstops at about 2.2 MB (2,201,493 to 2,230,165 bytes read 1.5 s after the pings, gate still closed; 4 of 8 runs did not move at all in that window, the other 4 gained 6 to 460 kB while the socket buffers drained). The figure is the socket-buffered amount, not a constant (evidence/lanes-20261009/IOTSTSTALL/logs/meas-bigread.log; an independent 3-run measurement, 2.20 to 2.22 MB constant over 1.5 s, is inIOTSTSTALL-review-r1-correctness/logs/mut-bigread*.log). An earlier version of this item quoted 1,836,949 bytes: that figure is from the held=0 runs of an intermediate test (the io_uring: four worker-thread sites block on cs.detachMu while an async handler holds it, parking the whole ring (io_uring twin of #669) #704 give-up path, no SEND in flight), not from a held completion. Before io_uring: a ring SEND completing while an async handler holds cs.detachMu parks the worker (handleSend/completeSend, a fifth #704 site) #750 the worker itself was parked for that time, so this may be the accepted cost; decide that and say so inholdOrLockSend's comment, or split the send state fromdetachMuso the worker can apply a completion while a handler runs. RULE 92: searched the four goceleris repos by mechanism (heldSends, held send completion, pipelined response tail waits for next handler): only io_uring: a ring SEND completing while an async handler holds cs.detachMu parks the worker (handleSend/completeSend, a fifth #704 site) #750 (the cause), Follow-ups from #801: an end-to-end held-completion witness, the replay's place at every hand-back entry, two untested defensive paths, a 15 s wait without #800 #814 and Follow-ups from #813: the SEND_ZC hold's memory-bound comment after #805, a WARN that becomes a Debug line under the #801 lag #845 (test and comment follow-ups), no tracker. (from PR iouring tests: deterministic held-completion, stall and write-order tests (v1.6.0 polish items) #1097, lane IOTSTSTALL) (open: DECISION) -
io_uring, header-timer arm sites that no traffic reaches (found by the IOTSTPARK lane, PR iouring tests: park, cancel and header-timer test hardening (v1.6.0 polish items) #1100, item 1): the
if hdrDL > 0 { armHeaderTimerAt }inpromoteConnToAsynccannot be reached with a deadline pending, becauseProcessH1callsClearHeaderDeadlinebefore it returnsErrAsyncDispatch(internal/conn/h1.go, the fresh-parse site), so a fresh promotion readshdrDL == 0. The same marker athandleRecv's async-feed arm also counted 0 hits, but that count only shows that no test in the suite drips a second request's head on a promoted conn after the accept-time timer has fired; the feed-site arm is LIVE for traffic and must not be deleted (in async modeOnHeaderDeadlineArmedis nil, so a promoted keep-alive conn whose next request starts after the first timer fired gets its deadline stamped byProcessH1on the dispatch goroutine, and the worker's next feed of that request's drip readshdrDL > 0and arms the kernel timer there). Both sites were instrumented with a stderr marker and the wholeinternal/engine/iouringsuite was run on arm64 Linux 7.0.12 with the tests that stage a deadline skipped: 0 hits at each site (evidence: IOTSTPARK/logs/reachability-summary.log in the probatorium evidence root). The unit tests in PR iouring tests: park, cancel and header-timer test hardening (v1.6.0 polish items) #1100 stage the deadline, so both arms are covered, but either delete the promotion-site arm (and the read that feeds it) or name the flow that reaches it. Missing test: an end-to-end one that drips the head of a second request on a promoted keep-alive conn after the accept-time timer fired and checks the kernel timer is armed (the feed-site arm's real flow); only the unit test covers that arm today. (open: DECISION) -
io_uring, the celeris#722 race tests are weak controls (IOTSTPARK lane, PR iouring tests: park, cancel and header-timer test hardening (v1.6.0 polish items) #1100, item 2): with the pre-io_uring: promoteConnToAsync reads cs.h1State after starting the dispatch goroutine, which sets it to nil on an h2c upgrade (race detector on an async route) #722 form restored at
handleRecv's feed (an unlockedcs.h1Stateread after the feed),TestAsyncH2CUpgradeOnFeedLeavesH1StateToTheGoroutinereports the data race in 1 of 20-raceruns, for the old and the new form of the test alike (mutants/m2a-pre722-feed-.patch, logs/mutant-m2a-.log). A deterministic version holds the dispatch goroutine inside the upgrade (a test-only hook beforeswitchToH2Local's write tocs.h1State) while the worker's feed runs, so the unordered read and write always overlap. (open: TEST-HYGIENE) -
io_uring,
TestAsyncH2CUpgradeOnFeedLeavesH1StateToTheGoroutinecannot observe the feed (IOTSTPARK lane, PR iouring tests: park, cancel and header-timer test hardening (v1.6.0 polish items) #1100, item 2): it now sends its second write only after the first recv is promoted and the dispatch goroutine is parked, so the second recv takes the feed path by construction, but with the feed branch disabled (if false && w.async && cs.asyncPromoted.Load() ...inhandleRecv) the test still passes, because the inline path finishes the upgrade too. Give the feed a test-visible effect (a counter behind a nil-by-default seam, as the celeris#896 tests have, or anEngineMetricsfield) and assert it moved by one. (open: TEST-HYGIENE)
Activity
FumingPower3925 commentedon Oct 9, 2026
Lane E4 (draft PR #962): the item "from #845 3" (the closing-drain teardown in
checkTimeoutsdoes not replay held send completions) is handled there, with a failing-first test (TestClosingSweepAppliesAHeldSendNotification). Left unticked until the PR merges. Three items appended above for what the PR does not cover.test(epoll,iouring): the H1 header budget counts from accept across p…
fix(epoll,iouring): the H1 header budget counts from accept across pr…
test(iouring): a conn closed on a spent header budget gives its multi…
test(detect,h1): a leading empty line and the IANA method set are ser…
fix(detect,h1): a leading empty line is ignored and every registered …
test(h1): the empty line is skipped between pipelined requests too
test(iouring,epoll): the late-request tests leave room for a late acc…
test(epoll,iouring): the H1 header budget counts from accept across p…
fix(epoll,iouring): the H1 header budget counts from accept across pr…
test(iouring): a conn closed on a spent header budget gives its multi…
240 remaining items
sse: h2c cells for the stream and for a client that reads nothing, an…
middleware: the ring keeps the queued SSE copy behind a wrapper, nego…
tests: io_uring gate pass: a conn whose recv CQE is posted and unreap…
io_uring: the gate's first pass holds a conn whose recv CQE is posted…
io_uring: the first pass asks the sockets, then the completion queue;…
epoll, io_uring: the shutdown gate reads a request that reached the k…
tests: every HTTP/1 response after Shutdown carries Connection: close…
epoll, io_uring: every HTTP/1 response after Shutdown carries Connect…
tests: every HTTP/1 response after Shutdown carries Connection: close…
epoll, io_uring: every HTTP/1 response after Shutdown carries Connect…
tests: an Async-route handler running at Shutdown on a loop with no H…
tests: a request pipelined behind a Connection: close response after …
tests: the late-pipelined arm requires the whole body then EOF where …
epoll, io_uring: the gate reads off and drops a request that reached …
epoll, io_uring: every HTTP/1 response after Shutdown carries Connect…