Repository navigation
v1.6.0 polish: epoll engine #885
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.
- Fix the EPOLLOUT comments' premise: armEpollOut (loop.go:2978 on main) and loop.go:645 say a flush 'stops at EAGAIN', but flushWrites/flushWritesV make one write and return on a short count; suggested 'a partial flush (short write or EAGAIN) leaves the send buffer full'. Also driver.go:256 says 'EPOLLIN stays edge-triggered' though that MOD drops EPOLLET for EPOLLIN. (from Follow-ups from #698: shutdown leaves a deferred transplant's descriptor open (pre-existing), EPOLLOUT comment premise, CI visibility of the new test, stale comments #727 2) (Done: epoll: a generation in every HTTP epoll_event, onPeerHalfClose under detachMu, EPOLLOUT comments (v1.6.0 polish) #1025 [diff])
- Update TestATransplantWaitsForARelink's comments (async_handler_stall_linux_test.go:311-331) that still give the round-1 reason (a transplant returning cs to the pool); say relinkPending is now defence in depth. (from Follow-ups from #698: shutdown leaves a deferred transplant's descriptor open (pre-existing), EPOLLOUT comment premise, CI visibility of the new test, stale comments #727 4) (Done: epoll: a collision for the freed number retries, a guard for the batch counters, short-write benchmarks, and the relink comment says what is so (v1.6.0 polish items) #1088 [diff])
- Add a nil-by-default or build-tagged test hook between driverRead's dc.closed check and unix.Read (engine/epoll/driver.go) and a test that unregisters, closes and reuses the number from inside it, so fix(epoll): never read a driver conn's descriptor number after UnregisterConn has returned (celeris#710) #772's surviving mutant m1-toctou fails; measure the hook's cost or build-tag it. (fix(eventloop): never read a driver conn's descriptor number after UnregisterConn has returned (celeris#784) #843 did the same for the standalone loop only.) (from Follow-ups from #772: the standalone driver event loop reads a reused descriptor number after UnregisterConn (#710 there), the WorkerLoop contract sentence, a test for the check-and-read critical section, UnregisterConn doc #784 3) (PARTIAL: epoll: the driver read's check and read, closeDriver's DEL and a refused ADD's rollback have tests, with two nil-by-default seams (v1.6.0 polish items) #1086; open: PR epoll: the driver read's check and read, closeDriver's DEL and a refused ADD's rollback have tests, with two nil-by-default seams (v1.6.0 polish items) #1086 body says outright: "item 3 stays open until the A/B is done or the hook is build)
- Add one sentence to epoll UnregisterConn's doc (engine/epoll/driver.go:163-165): a read that completed before UnregisterConn took dc.mu is still delivered, so onRecv can run once more, concurrently with or after onClose. (from Follow-ups from #772: the standalone driver event loop reads a reused descriptor number after UnregisterConn (#710 there), the WorkerLoop contract sentence, a test for the check-and-read critical section, UnregisterConn doc #784 4) (Done: epoll: the driver read's check and read, closeDriver's DEL and a refused ADD's rollback have tests, with two nil-by-default seams (v1.6.0 polish items) #1086 [diff])
- Add a Config{Engine: Epoll, AsyncHandlers: true} arm with no Async route to TestHijackKeepsRequestViews (hijack_keeps_request_views_linux_test.go) and to CI's exact arm-PASS tally step, so Context.Hijack pools the receive buffer while the hijacker still holds request views: kept strings read another connection's bytes (epoll 7/20, io_uring 10/20) #733 is pinned on the AsyncHandlers-inline path now that fix: keep a hijacked request's receive buffer out of the pool on epoll and io_uring, and copy the request values at Hijack (celeris#733) #773 and fix(epoll): do not touch a connection's state after a handler that ran inline on an async loop hijacked it (celeris#769) #774 have both merged. (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 1) (Done: tests: hijack request views on AsyncHandlers, forced connState reuse and its witness, inline-run assertions (v1.6.0 polish items) #1087, tests: the inline-hijack asserts pin their routes Sync, so a loaded host cannot promote them (Refs #885 item 7) #1101 [diff])
- Add a test that forces the connState's reuse between releaseConnState and the reset in drainRead's inline branch (a test hook running another conn's accept on the same P), so the nil-check mutant (cs.h1State != nil instead of the ErrHijacked check) fails. (from Follow-ups from #774: epoll drops a first segment shorter than protocol detection needs, a test for the connState-reuse face, an inline witness, the cost sentence #786 2) (Done: tests: hijack request views on AsyncHandlers, forced connState reuse and its witness, inline-run assertions (v1.6.0 polish items) #1087 [diff])
- engine/epoll/hijack_inline_async_loop_linux_test.go checks only hijacked.Load() == n; assert /hj ran inline (loops' asyncPromoted stays 0 in the async-loop-no-async-routes arm, and an equivalent observable in TestHijackWithAsyncHandlersOnEpoll), and cite fix(epoll): do not touch a connection's state after a handler that ran inline on an async loop hijacked it (celeris#769) #774's failing-first numbers as the unfixed-code control. (from Follow-ups from #774: epoll drops a first segment shorter than protocol detection needs, a test for the connState-reuse face, an inline witness, the cost sentence #786 3) (Done: tests: hijack request views on AsyncHandlers, forced connState reuse and its witness, inline-run assertions (v1.6.0 polish items) #1087, tests: the inline-hijack asserts pin their routes Sync, so a loaded host cannot promote them (Refs #885 item 7) #1101 [diff])
- Commit the review's ~30-line test of RegisterConn's rollback in ./engine/epoll (register a regular file's fd, which EPOLL_CTL_ADD refuses with EPERM; assert hasDriverConns reset, fd absent from driverConns, Write returns ErrUnknownFD), with the no-rollback mutant as failing-first control. (from Follow-ups from #776: a test for RegisterConn's rollback, an amd64 failing-first run, the 200 ms bound, the Coverage linger flake on pre-#744 branches #787 1) (Done: epoll: the driver read's check and read, closeDriver's DEL and a refused ADD's rollback have tests, with two nil-by-default seams (v1.6.0 polish items) #1086 [diff])
- Raise the 200 ms 'lost' bound in engine/epoll/driver_register_first_edge_linux_test.go:71 to 1-5 s (keep the count) or to seconds with a stop at the first loss (CodeRabbit's design), then re-run the failing-first control on aa2331d. (from Follow-ups from #776: a test for RegisterConn's rollback, an amd64 failing-first run, the 200 ms bound, the Coverage linger flake on pre-#744 branches #787 3) (Done: 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 [diff])
- Add an epoll arm to engine/epoll/park_retracts_residue_test.go that reaches the post-sweep close through a production path (e.g. a detached conn closed via the detach queue on a paused loop) instead of disarming the timerfd from the test goroutine (still done at :129 on main 24cdb7b); it must fail with loop.go's park retraction removed and pass on main. (from Follow-ups from #766: an epoll park-retraction arm through a production post-sweep close, and the always-true liveConns guard #794 2) (Done: 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 [diff])
- In parkResidue, give the pre-sweep control arm its own long ReadHeaderTimeout (e.g. 10 s), keep 400 ms for the post-sweep arm, and close the client right after PauseAccept with no 50 ms sleep (still at :177 on main), so the control cannot fall onto the post-sweep path; re-run the negative control and apply the same check to the io_uring pair (CodeRabbit on fix(engine): retract a loop's or worker's residual gauges where it parks (celeris#711) #766). (from Follow-ups from #766: an epoll park-retraction arm through a production post-sweep close, and the always-true liveConns guard #794 3) (Done: 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 [diff])
- Add a test of the cancel path's race: release the handler before StartWithContext's watcher stores the budget (or delay the watcher with a hook) and check the epoll drain still extends to the budget, so a drain that reads the budget once at start would fail. (from Follow-ups from #807: shutdown-drain bytes missing from BytesWritten, no test of the cancel path's budget race #819 2) (Done: 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 [diff])
- TestShutdownSendsTheWholeResponse orders the client after the drain with fixed sleeps (shutdown_send_drain_linux_test.go:79-82); add a test-only hook in Loop.shutdown (drain entered) and wait on it (CodeRabbit on fix(epoll): shutdown sends what the sockets have not taken yet before it closes the conns (celeris#760) #807). (from Follow-ups from #807: shutdown-drain bytes missing from BytesWritten, no test of the cancel path's budget race #819 4) (Done: 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 [diff])
- h2PoolSettled (epoll and io_uring) ignores writeRefused after GoAway: 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 the shutdown GOAWAY can be refused when the conn backlog is over its cap (64 MiB for HTTP/2), so it is lost and the conn closes only at its next write-queue drain. Review calls it harmless; check writeRefused after GoAway and close explicitly, plus a test. (from Follow-ups from #808: std h2c conns get no GOAWAY and outlive the drain, a pool-wait bound test that passes on main, cost wording #820 7) (open: WORDING)
Added 2026-10-03 (#907's round-2 reviews, lane L905):
-
Loop.run, engine/epoll/loop.go (loop.go:405-408 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 (io_uring: the same item on v1.6.0 polish: io_uring engine #884). 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):
-
drainRead, engine/epoll/loop.go (the direct-body tail,ProcessH1(cs.ctx, rest, ...)at loop.go:1333 on main 21d49ef): a non-nil verdict from the tail is treated as an error (flush,closeWhenFlushed), includingconn.ErrUpgradeH2C.DispatchBufferedBody's ownErrUpgradeH2Cis handled (switchToH2, loop.go:1311), 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 io_uring twin is on v1.6.0 polish: io_uring engine #884. (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]) - epoll's transplant replay (
endCarriedReplay,flushCarriedReplay, adopt.go) copiesdrainRead's error, h2c-upgrade and inline-flush blocks (loop.go); factor shared helpers once loop.go is free of other lanes so the replay 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.go) notice a drift, and only where they cover the block. The io_uring twin is on v1.6.0 polish: io_uring engine #884. (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) (PARTIAL: 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; open: decided 2026-10-12 A (refactor together with 885.16). Item names three blocks (error, h2c-)
Added 2026-10-09 (#771/#775's lane E3, PR #960):
- The HTTP-to-HTTP twin of epoll: a driver event harvested before UnregisterConn is dispatched by number after it, and closes the conn registered on the reused number (the epoll twin of #707) #771 on epoll, by reading only (NOT measured; start with a probe): the run loop dispatches each event of an
epoll_waitbatch byev.Fd, and an HTTP conn's events carry Pad 0 (the driver conns' now carry a generation, fix(epoll): a driver event is dispatched to the registration it was collected for, and the conn table slot is cleared under driverMu (celeris#771, celeris#775) #960). A conn F closed earlier in the same batch by a handler that is not F's own event (thetimerFDbranch'scheckTimeouts, a transplant, an h2 pass), then a new conn accepted on F's number byacceptAll(the listen fd's event, later in the batch), then F's own stale event (EPOLLRDHUP/EPOLLHUP) dispatched:onPeerHalfClose(F)/closeConn(F)run on the new conn. The io_uring HTTP path carries a generation (encodeUserDataGen, io_uring: connState generations pinned at 1 let a close-path ASYNC_CANCEL kill the next conn on a recycled fd #470). Fix direction: a generation per HTTP conn in Pad (Pad 0 stays "none" for the listen/timer/wake fds), checked indrainRead/handleWritable/onPeerHalfClose/closeConn's event entry points. (from epoll: a driver event harvested before UnregisterConn is dispatched by number after it, and closes the conn registered on the reused number (the epoll twin of #707) #771) (Done: epoll: a generation in every HTTP epoll_event, onPeerHalfClose under detachMu, EPOLLOUT comments (v1.6.0 polish) #1025 [diff])
Items from review round 2 of #960 (lane E3, epoll fd identity / slot lock):
- (perf-api-security, minor) The body puts commit 1's keep-alive shift down to 'binary layout and noise alone', but the shift is consistent across rounds, so it is not noise. Commit 1 changes only closeConn and shutdown, which no keep-alive request runs through. Even so, A775 ran slower than base in most rounds. Layout is a fair guess, but nobody has tested it. The body should say the shift is consistent and its cause is untested, rather than ca (open: WORDING)
- (perf-api-security, minor) The whole-PR keep-alive cost is bounded too loosely in the body's wording. 'Inside the A/A floor' compares a median of 16 paired ratios against a floor built from single pairs (median |A/A-1|, 0.077 and 0.067). With drivers registered, the end-to-end data do not rule out a cost of up to about 12%, not about 7%. That configuration is the production one for apps whose redis or postgres drivers run on the engine's worke (open: WORDING)
- (perf-api-security, nit) BenchmarkCloseChurn has extreme outlier samples that the body never mentions. A single A/A pair can differ by up to about 9x. The median floor of 0.032 hides this, so a reader cannot tell the instrument behaves this way. (open: WORDING)
- (perf-api-security, minor) Contended driverMu in closeConn is still unmeasured. The body discloses this. BenchmarkKeepAliveDispatch/withdriver registers only an idle driver conn, and CloseChurn registers none. So no arm has a driver goroutine calling Write (lookupDriver RLock) while the loop closes HTTP conns under the new write lock. acceptAll already takes the same write lock once per accepted connection, so this is disclosed and in line wit (open: MEASUREMENT)
- (correctness, minor) TestCloseDriverSparesConnOnReusedNumber771 pins where the closeDriver EPOLL_CTL_DEL sits relative to the test hook, not that it is inside the dc.mu claim section. A mutant that moves the DEL to just after dc.mu.Unlock() but before testHookCloseDriverClaimed passes every 771 and 775 test. In production that mutant still has epoll: a driver event harvested before UnregisterConn is dispatched by number after it, and closes the conn registered on the reused number (the epoll twin of #707) #771's second window: an UnregisterConn(A) can return between the unlock and the DEL, the calle (Done: epoll: the driver read's check and read, closeDriver's DEL and a refused ADD's rollback have tests, with two nil-by-default seams (v1.6.0 polish items) #1086 [diff])
- (correctness, nit) The deadlock section in the PR body lists 'detachMu → driverMu (hijackConn, now also closeConn's slot clear)'. closeConn's new slot clear runs with no detachMu held: both detachMu sections in closeConn unlock before CloseH2, the DEL and the slot clear. No caller can hold detachMu when it enters closeConn either, since closeConn's own TryLock/Lock would self-deadlock. So that ordering edge does not exist. The claim is (open: WORDING)
- (correctness, nit) TestDriverTakesNumberFreedByCloseConn775 calls t.Fatalf when another descriptor of the process takes the freed number between close(2) and the hook's F_GETFD check (in that case reused stays false). The window is a few microseconds and nothing else in that test opens descriptors, so this is theoretical. It could become a t.Skip, or a retry with a second connection, so a rare collision does not look like a defect. (Done: epoll: a collision for the freed number retries, a guard for the batch counters, short-write benchmarks, and the relink comment says what is so (v1.6.0 polish items) #1088 [diff])
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/epoll/engine.go:200 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 epoll twin is on v1.6.0 polish: io_uring engine #884. (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):
-
onPeerHalfClose, internal/engine/epoll/loop.go (~907 on main b4ce620) readscs.h1State(cs.h1State != nil && cs.h1State.Detached.Load()) on the loop thread with nodetachMu, while an async dispatch goroutine'sswitchToH2Localsets it to nil under that lock: the same data race as epoll: checkTimeouts reads cs.h1State without detachMu while switchToH2Local can set it to nil (data race, possible nil dereference; epoll twin of #722) #865, which fix(epoll): checkTimeouts reads the H1 state under detachMu; a short first segment is kept for protocol detection (celeris#865, celeris#870) #969 fixes incheckTimeoutsonly. A race report, not an observed crash: the gc compiler merges the two loads ofcs.h1Statein that expression into one (arm64 and amd64 disassembly of main b4ce620, E5 lanescripts/disasm-phc.sh,logs/disasm-phc.log); fix(epoll): checkTimeouts reads the H1 state under detachMu; a short first segment is kept for protocol detection (celeris#865, celeris#870) #969 loads the pointer once into a local so the code does not depend on that, and leaves the unlocked read. ThecsWritePending(cs)read after it is unlocked too (the tests around TestPeerHalfCloseLeavesAClosingConnToItsHandler pin that only for a conn whose close is already owed). REPRODUCED with a scratch probe under-race(E5 laneprobes/zz_probe865_sibling_test.go, run byscripts/probe-sibling.sh, loglogs/probe865-sibling.log): 2 to 3 DATA RACE reports at loop.go:907 against the writesswitchToH2Localmakes under the lock. Not fixed in fix(epoll): checkTimeouts reads the H1 state under detachMu; a short first segment is kept for protocol detection (celeris#865, celeris#870) #969 because skipping a busy conn is not safe here: EPOLLRDHUP is edge-triggered, so a skipped half-close is not delivered again; the choice (read underTryLockand, when a running handler holds it, leave the close to the dispatch goroutine ascloseOnReadEnddoes) belongs with the async-abort work (epoll, io_uring: two panic windows on the async dispatch goroutine still leave a lock held after #840 (a panic under asyncInMu, a panic inside OnDetach) #844). Failing-first: the probe, with a floor on the writer's iterations as TestCheckTimeoutsReadsH1StateUnderDetachMu has. (Done: epoll: a generation in every HTTP epoll_event, onPeerHalfClose under detachMu, EPOLLOUT comments (v1.6.0 polish) #1025 [diff]) - (Not reproduced, unproven.)
drainRead's async gate readscs.protocolon the loop thread with no lock (l.async && cs.asyncPromoted && cs.protocol == engine.HTTP1, internal/engine/epoll/loop.go ~1449 on main b4ce620) whileswitchToH2Localwritescs.protocol = engine.H2Con the dispatch goroutine, and ask.go assertscs.protocolis loop-thread-owned. An end-to-end probe (async h2c Upgrade, then the preface after the 101, about 92k upgrades under-race, review of fix(epoll): checkTimeouts reads the H1 state under detachMu; a short first segment is kept for protocol detection (celeris#865, celeris#870) #969) produced 0 race reports, so some synchronisation edge probably covers it in practice. Worth a look together with theonPeerHalfCloseitem above. (open: DECISION) -
checkTimeoutsis iteration-driven, so a busy loop sweeps thousands of times a second, not every 25 ms (internal/engine/epoll/loop.go, thegate := uint32(0x3FF); if l.detachedCount > 0 || l.cfg.ReadHeaderTimeout > 0 { gate = 0x1F }block on main b4ce620; ReadHeaderTimeout defaults to 10 s, so 0x1F, every 32epoll_waititerations, is the default). MEASURED (E5 lane, review of fix(epoll): checkTimeouts reads the H1 state under detachMu; a short first segment is kept for protocol detection (celeris#865, celeris#870) #969;scripts/cadence/,logs/cadence-summary.md; 2 loops,AsyncHandlers, 8192 idle keep-alive conns, 6 interleaved rounds under the timing lock, arm64 container): about 120 sweeps a second in all with no traffic (the 25 ms timerfd gives 80), 1,800 with one busy client, 5,400 to 6,200 (medians of the two base arms) with sixteen; sweep time summed over the two loop threads is 12.9% of a core at sixteen busy clients on main (b4ce620) and 19.2% with fix(epoll): checkTimeouts reads the H1 state under detachMu; a short first segment is kept for protocol detection (celeris#865, celeris#870) #969'sTryLockper async conn (head/base 1.47, A/A 1.01). A floor between sweeps (skip the call when less than the timerfd period has passed since the last one) would cut that on both by about 100x with the same 25 ms bound the timerfd already promises, at the price of reaping a detached conn's expired idle deadline up to that period later than the 32-iteration gate does now (the gate's comment gives WS idle-close precision as the reason for the tight gate). Behaviour-changing, so not in fix(epoll): checkTimeouts reads the H1 state under detachMu; a short first segment is kept for protocol detection (celeris#865, celeris#870) #969. Failing-first: a test that counts sweeps over a second of traffic. (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-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 in 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 [diff]; maintainer to confirm, box left open)
Added 2026-10-10 (lane E15, review of PR #973):
- The MaxEvents split divides by the host's NUMA node count, not by the nodes the process may use (internal/engine/epoll/engine.go,
Listen:topo := platform.DetectNUMA(), thenif topo.NumNodes > 1 { resolved.MaxEvents = resolved.MaxEvents / topo.NumNodes ... }, on main b4ce620 and still so on fix(engine): pin loops inside the process's CPU set and off little CPUs, report a failed pin, document the inline handler's pin (#909 #919) #973's head). A process restricted to one node (numactl --cpunodebind, a Kubernetes single-numa-node pod, a cpuset of one socket) has every loop on that node and still gets MaxEvents / NumNodes. fix(engine): pin loops inside the process's CPU set and off little CPUs, report a failed pin, document the inline handler's pin (#909 #919) #973 makes the CPU plan follow the allowed set (platform.PlanWorkerCPUsintersects each node's CPU list with it) but leaves this split as it was.PlanWorkerCPUsalso callsDetectNUMA, soListenreads the topology twice (startup only, negligible). Behaviour-changing (a larger MaxEvents on a restricted host), so not in fix(engine): pin loops inside the process's CPU set and off little CPUs, report a failed pin, document the inline handler's pin (#909 #919) #973. A fix would take the count of nodes the allowed set meets from the plan instead of fromDetectNUMA. io_uring has no such split (grep forNumNodesfinds only this one use, RULE 61). Code reading only, not measured. Failing-first: a test that stands in a plan covering one node of two and checks the resolved MaxEvents. (from fix(engine): pin loops inside the process's CPU set and off little CPUs, report a failed pin, document the inline handler's pin (#909 #919) #973 review r1) (open: ENGINE-CODE)
Added 2026-10-10 (lane E5B, PR #975):
- epoll has no header deadline before protocol detection (internal/engine/epoll/loop.go,
drainRead's!cs.detectedblock andcheckTimeouts; on main 3a89451). The H1 header deadline lives incs.h1State, whichinitProtocolcreates at detection, so in Auto mode (and H2C with the h2c upgrade) a conn that sends nothing, sends "GE" and stalls, or sends "PRI " and stalls is not underReadHeaderTimeout; onlyReadTimeout/IdleTimeoutreap it. MEASURED (E5B lane,scripts/probe-epoll-deadline.sh,logs/probe-epoll-deadline.log; arm64 container): withReadHeaderTimeout300 ms andReadTimeout/IdleTimeout5 min, silent, "GE" and "PRI " conns were all still open after 4 s. std and, with 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, io_uring close the same three at about the header timeout (TestStalledPreDetectionConnIsReaped974, which leaves epoll out and points here). TheDisableDeferAcceptdoc (config.go, resource/config.go) says a silent client "is then subject to ReadHeaderTimeout like any other"; that is false on epoll in Auto mode. Fix shape as io_uring's 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: an absolute accept-time deadline (accept + ReadHeaderTimeout, not extended by a read) swept incheckTimeoutsfor!cs.detectedconns, then add epoll todeadlineEngines974. Behaviour-changing for a client that connects and stays silent pastReadHeaderTimeout(10 s by default; it is reaped at 60 s today). RULE 92: searched the four repos for the mechanism (pre-detection deadline, ReadHeaderTimeout undetected), no tracker. (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) (Done: fix(epoll,iouring,detect): one header budget across protocol detection, and a leading empty line and the IANA methods in Auto mode #1006 [diff]) - The epoll twin of the leading-CRLF / method-set item on v1.6.0 polish: io_uring engine #884 (internal/protocol/detect, shared): epoll's drain closes on
ErrUnknownProtocolas well, so a leading CRLF before the request line, and a method starting outside G, P, H, D, O, T, C, closes the conn in Auto mode. (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 [diff])
Added 2026-10-10 (lane E10, PR #978, RULE 61 sweep of #867):
- epoll has both halves of io_uring: OnError is never called, and a second error overwrites the first, when Worker.shutdown completes a close deferred by closeOnRecvEnd #867, which 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 fixes for io_uring only.
closeOnReadEnd(internal/engine/epoll/loop.go:1801 on main 9943e0e) parks the error withcs.closeErr = errunconditionally, so a second read-end error for the same conn replaces the first; keep the first (if cs.closeErr == nil). AndLoop.shutdownphase 1 (loop.go:3800-3817) setsdetachClosedand firesOnDetachClosefor a detached or async conn but never delivers a parkedcs.closeErr, which onlycloseConndoes (loop.go:3638-3644:flushWrites, thenOnError), so a close that shutdown finishes in its place never tells the middleware. Same shape as the io_uring fix (Worker.shutdown, 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: deliver underdetachMu, beforeOnDetachClose), with failing-first tests likeshutdown_closeerr_867_linux_test.go. Code reading; the io_uring twin is reproduced in 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 tests. (from io_uring: OnError is never called, and a second error overwrites the first, when Worker.shutdown completes a close deferred by closeOnRecvEnd #867 / 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: 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 [diff])
Added 2026-10-10 (lane E9, PR #980):
- epoll measures ReadTimeout/IdleTimeout (and, for a dirty conn, WriteTimeout) from the last READ for a keep-alive conn backpressured on EPOLLOUT too, so a large response a client takes steadily for longer than ReadTimeout is cut (internal/engine/epoll/loop.go,
checkTimeouts;lastActivityis stamped bydrainRead/accept/adopt only). PR 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 gives a conn whose close is deferred (closeWhenFlushed, EPOLLRDHUP mid-response) its own clock (closeSince, restarted on send progress byhandleWritable/flushDirty); the keep-alive conn has no such clock. MEASURED on 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's head (lanes-20261009/E9/probe-keepalive.sh,logs/probe-keepalive.log; arm64 container): ReadTimeout 1 s, WriteTimeout 30 s, a 40 MiB keep-alive response read at 16 MiB/s: 18.9 MB received, cut at 1.13 s. Fix shape: stamp send progress for any conn that has bytes queued, from the partial-write branches (handleWritable,flushDirty,armEpollOut) and not fromflushWrites, then measure WriteTimeout from it for a conn withepollOut/dirtyset; needs a RULE 74 measurement of the dirty pass. RULE 92: searched the four repos for the mechanism (checkTimeouts lastActivity write backpressure ReadTimeout), no tracker besides epoll: checkTimeouts measures ReadTimeout from the last read, so a closeWhenFlushed response that the client reads steadily for longer than ReadTimeout is cut #876, whose scope is the closing conn. (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) (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 [diff]) - epoll,
onPeerHalfClose(internal/engine/epoll/loop.go, thecsWritePending(cs)case):markClosing(PR 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) readscsPendingBytes(cs)(writeBuf, bodyBuf, sendfile) withoutdetachMuwhile a dispatch goroutine may be writing, the same race class as the existing unlockedcsWritePendingread on that branch (the race report already listed under the async-abort work, epoll, io_uring: two panic windows on the async dispatch goroutine still leave a lock held after #840 (a panic under asyncInMu, a panic inside OnDetach) #844); fold it into that fix (take the lock or defer the stamp to the next flush). (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 r1) (Done: epoll: a generation in every HTTP epoll_event, onPeerHalfClose under detachMu, EPOLLOUT comments (v1.6.0 polish) #1025 [diff]) - epoll has no guard that the batch counters the run loop flushes inline (
reqBatch,bytesReadBatch,bytesWrittenBatch, loop.go run loop) andflushBatchedCounters(shutdown, PR 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) cover the same set: a fourth batch counter added to one is silently missing from the other. io_uring'sflushBatches(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) has a reflection guard over the*Batchfields; add the same test for epoll (a test-only change). (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 r1) (Done: epoll: a collision for the freed number retries, a guard for the batch counters, short-write benchmarks, and the relink comment says what is so (v1.6.0 polish items) #1088 [diff]) - epoll, a conn closing because an h2 write was refused (
writeRefused) stays onh2Connsand goes on taking handler frames, asmarkClosing's comment says (internal/engine/epoll/loop.go, the run loop's h2Conns pass:if cs.writeRefused { l.closeWhenFlushed(cs); continue }). io_uring cuts both:makeWriteFnreturns at once whencs.closingis set andcloseConnrunsCloseH2andremoveH2Connbefore it defers. Under 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's drain clock a peer that is still taking bytes (acknowledged bytes count as progress since review round 3) keeps such a conn alive for as long as the handler streams, where main cut it at ReadTimeout from the last read. Fix shape: drop writes on, or unlink fromh2Conns, an epoll h2 conn oncecloseWhenFlushedhas marked it. Code reading by the round-2 correctness review, not measured. (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 r2) (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 ADDR, PR #986 review r1; cross-ref #884 for the io_uring call site):
- A zoned (link-local) peer cost one uncached
net.InterfaceByIndex(an RTM_GETLINK dump: 17 us at 12 interfaces, 256 us and 1.7 MB at 212) per accept on the event loop. Resolved in PR fix(engines): format IPv4-mapped peers and unscoped listeners as std does (#925) #986 (draft, not merged), commit c031842: the zone name now comes from oneSIOCGIFNAMEioctl on the accepted socket (about 255 ns whatever the interface count, no lock, no cache, never stale). (from fix(engines): format IPv4-mapped peers and unscoped listeners as std does (#925) #986 review r1, resolved in r3)
Added 2026-10-11 (lane DFAIR, PR #993):
-
epoll: a driver conn registered on a loop that
PauseAccepthas parked is never served (internal/engine/epoll/loop.go, the park at the end ofrun:l.listenFD < 0 && l.connCount == 0 && l.acceptPaused.Load() && ...;connCountcounts HTTP conns only, and a parked loop leavesepoll_waituntilResumeAccept). MEASURED (DFAIR lane,probes/zz_probe_parked_driver_linux_test.go,scripts/parked-main.sh,logs/parked-main-r1; origin/main 61ea3cb, arm64 container, 3 processes): afterPauseAccepton an engine with no HTTP conns, a byte written to the peer of a driver conn registered onWorkerLoop(0)never reached itsonRecvwithin 3 s, in 3 of 3 processes. Reachable through the adaptive engine by code reading only (internal/adaptive/provider.go pins an fd to the sub-engine loop it was registered on, and a demoted sub-engine is paused); not run through adaptive. Fix shape: do not park whilehasDriverConnsis set, or serve the driver conns on the way into the park; failing-first is the probe. PR fix(epoll,eventloop): spend at most 8 reads on an engine driver conn per turn, and never wait on a conn's recvMu in the standalone worker (celeris#930, celeris#931) #993 (round 3) already keeps a loop from parking while it owes a driver conn a read turn (len(l.driverReadQ) == 0in the park condition; testTestAPausedLoopServesTheBacklogItOwesBeforeItParks930), so the second shape needs no extra queue handling, but a conn that is registered after the park, or whose bytes arrive after it, is still stranded. AWritefrom off the loop on a conn whose loop deferred its close after a failed flush (PR fix(epoll,eventloop): spend at most 8 reads on an engine driver conn per turn, and never wait on a conn's recvMu in the standalone worker (celeris#930, celeris#931) #993,readErr) still fails with EPIPE and closes the conn at once, before the owed bytes are delivered:readErris loop-thread state andWritecannot read it; main has the same race, so it is not a regression. RULE 92: searched the four repos for the mechanism (driver parked loop PauseAccept, standby driver, suspended driver, RegisterConn paused), no tracker. (from epoll: a driver conn whose inflow never pauses holds the engine loop in driverRead, so every other conn on that loop waits (the engine twin of #881) #930 / PR fix(epoll,eventloop): spend at most 8 reads on an engine driver conn per turn, and never wait on a conn's recvMu in the standalone worker (celeris#930, celeris#931) #993) (Done: 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 [diff]) -
epoll:
drainRead(internal/engine/epoll/loop.go, thefor {read loop of an HTTP conn) reads a conn until EAGAIN with no bound on the number of reads, the HTTP twin of epoll: a driver conn whose inflow never pauses holds the engine loop in driverRead, so every other conn on that loop waits (the engine twin of #881) #930 and eventloop: the standalone driver loop drains a conn to EAGAIN before the next event, so a conn whose inflow never pauses starves the others on its worker #881: a client that keeps its socket full (large pipelined uploads) holds the loop for as long as its inflow does not pause, and the loop's other conns wait. Code reading only, not measured. A fix needs a RULE 74 measurement on the HTTP read hot path (PR fix(epoll,eventloop): spend at most 8 reads on an engine driver conn per turn, and never wait on a conn's recvMu in the standalone worker (celeris#930, celeris#931) #993 measured the cost of a per-turn queue for driver conns at about 0.7 us per 256 KiB turn on an emptyonRecv). RULE 92: searched celeris and probatorium for the mechanism (drainRead, read budget, pipelined flood), no tracker. (from epoll: a driver conn whose inflow never pauses holds the engine loop in driverRead, so every other conn on that loop waits (the engine twin of #881) #930 / PR fix(epoll,eventloop): spend at most 8 reads on an engine driver conn per turn, and never wait on a conn's recvMu in the standalone worker (celeris#930, celeris#931) #993) (open: DECISION) -
epoll: while
driverReadQis non-empty the run loop waits inepoll_waitwith timeout 0 (PR fix(epoll,eventloop): spend at most 8 reads on an engine driver conn per turn, and never wait on a conn's recvMu in the standalone worker (celeris#930, celeris#931) #993), and the rounds that return no events still incrementconsecutiveEmpty(internal/engine/epoll/loop.go, after the batch;adaptiveTimeoutMs), so once a flood ends the first idle waits start from a stretched timeout (base2 or base4, capped at maxMs) as for an idle loop. No correctness impact (an event ends the wait at once); thelistenHotpath already behaves the same. Fix shape: do not count a round towardconsecutiveEmptywhen the timeout was forced to 0 by a queue. Code reading by the PR fix(epoll,eventloop): spend at most 8 reads on an engine driver conn per turn, and never wait on a conn's recvMu in the standalone worker (celeris#930, celeris#931) #993 review r1, not measured. (from PR fix(epoll,eventloop): spend at most 8 reads on an engine driver conn per turn, and never wait on a conn's recvMu in the standalone worker (celeris#930, celeris#931) #993 review r1) (open: ENGINE-CODE)
Added 2026-10-11 (lane E14, PR #994 review r1, RULE 61 sibling of #811):
- epoll, a truly detached WS/SSE conn under backpressure stays on the dirty list, and
adaptiveTimeoutMsreturns 0 whilel.dirtyHead != nil, so the loop may poll (epoll_wait(0)plus a write that returns EAGAIN) every pass for as long as the peer does not read: the epoll twin of 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 (io_uring, 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). Code reading, NOT measured:internal/engine/epoll/loop.goflushDirty's partial-write branch (detachedWS := cs.h1State != nil && cs.h1State.Detached.Load() ... if !detachedWS { l.armEpollOut(cs) }, comment "Truly-detached WS/SSE conns stay on the dirty list") andadaptiveTimeoutMs(if l.dirtyHead != nil { return 0 }), at origin/main 3185649 and 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's merge base. Non-detached conns already arm EPOLLOUT and leave the list, which is the shape 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 gives io_uring. Failing-first shape: a detached WebSocket (or SSE) conn whose server side keeps writing, a client that stops reading (small SO_RCVBUF, server SO_SNDBUF 64 KiB), process CPU over a 2 s window under a 10% budget (the io_uring testTestIouringStalledAsyncSendDoesNotBusyPoll811is the template), then the client reads and every byte must arrive in order. Fix shape to weigh: a detached conn that is backpressured arms EPOLLOUT and leaves the list too, with the writer goroutine's own wake (the detached writers flush from their goroutines, so the loop's owed work for the conn may be only the OUT arm); needs the RULE 10 check of who flushes a detached conn afterwards. RULE 92: searched the goceleris repos for the mechanism (detached dirty list spin, epoll detached WebSocket backpressure busy, epoll_wait zero timeout dirty, flushDirty detached, adaptiveTimeoutMs dirtyHead), no tracker. (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 r1) (Done: epoll: a backpressured detached conn waits on EPOLLOUT instead of the dirty list, and every EPOLL_CTL_MOD derives its interest from the conn's state (v1.6.0 polish item) #1060 [diff])
Added 2026-10-11 (lane E13, PR #992 round 3):
-
epoll and io_uring
parseAddr(internal/engine/epoll/loop.go, internal/engine/iouring/worker.go) is a hand-written parser beside thenet.ParseIPit already calls:[::ffff:1.2.3.4]:0, which Go and the std engine accept, is rejected by both (the octet loop cannot read an IPv4-mapped IPv6 host). Replacing the loop withip.To4()would match std for that and for the leading-zero and range cases PR fix(epoll,iouring): a port 0 Addr is decided once, and Listen returns after its port is free (#895, #896) #992 added by hand, but would also change what the shorthand hosts (10:80,127.1:80) do beyond the error PR fix(epoll,iouring): a port 0 Addr is decided once, and Listen returns after its port is free (#895, #896) #992 made of them; decide together. RULE 92 search of the four repos (parseAddr, ffff) found only the closed IPv6 bracket notation support in epoll/io_uring address parser #66 (bracket notation), another defect. (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]) -
epoll: a loop whose listen-socket re-create fails after
ResumeAccept(createListenSocketorepoll_ctl ADDin the run loop) countsErrorListenerRecreateand callsshutdown(); when every loop has, the engine keeps running with no listener,Listendoes not return andResumeAcceptreturned 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). The io_uring twin and the options are in v1.6.0 polish: io_uring engine #884. The run-loop path is lane DFAIR's (PR fix(epoll,eventloop): spend at most 8 reads on an engine driver conn per turn, and never wait on a conn's recvMu in the standalone worker (celeris#930, celeris#931) #993) at the time of writing, so it was documented, not changed. (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) -
WriteTimeout does not bound a stalled keep-alive HTTP/1.1 response on epoll (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. checkTimeouts closes oncs.dirty && elapsed > WriteTimeout(loop.go,elapsedfromlastActivity, the last read): check whether a response parked on EPOLLOUT is ever dirty, and epoll: checkTimeouts measures ReadTimeout from the last read, so a closeWhenFlushed response that the client reads steadily for longer than ReadTimeout is cut #876's closing-drain clock for the live (not closing) case. 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 [diff]) -
epoll: after a loop 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: onlyLoop.shutdownandcloseListenerAfterDrainsetlistenFDClosedand callpause.Notify, and a dead loop reaches neither. The dead loop'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 the dead loop's listener 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 io_uring twin is in v1.6.0 polish: io_uring engine #884. (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 [diff]) -
sse.ClientPolicyClose(MaxQueueDepth 4) never ends the stream of a client that reads nothing on epoll (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 [diff]) -
epoll's wait for HTTP/2 pool handlers does not gate HTTP/1 reads or detached writes: in
Loop.run(internal/engine/epoll/loop.go) a cancelled context callsstopAcceptingand thenh2PoolSettled, and until the handlers end (up to the shutdown budget) the loop keeps reading and answering the keep-alive requests of its HTTP/1 clients and lets detached streams (Server-Sent Events, WebSocket) write; its shutdown phases (the stop of detached producers, the send drain) run only after the pool settles. The v1.6.0 polish: io_uring engine #884 item 50 text says epoll's loops stop reading at the cancel; that holds for its shutdown, not for the pool wait. Measured withTestShutdownPoolWaitStopsServingH1AndDetached884(shutdown_h2_pool_linux_test.go of 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) with epoll in its arm list: 48 held HTTP/2 streams so that every loop is in the pool wait, 16 keep-alive clients and 16 detached streams, 1.5 s into the shutdown 16 of 16 requests were answered and 688 to 704 ticks were written in 0.9 s (three runs,scripts/epoll_arm.sh,logs/epoll-arm.logof the IOACCEPT lane evidence dir: 688, 704, 704; io_uring before 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's gate: 16 of 16 and 704, adaptive on io_uring 688; after it: 0 and 0). Fix shape: the same gate in the pool-wait branch ofLoop.run, 250 ms after the wait begins (a request in flight at the cancel is still answered), refusing HTTP/1 reads and ending the detached producers; the open question of 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 (grace at the cancel or 250 ms in; what an HTTP/1 client past the gate sees) applies to both engines. RULE 92: searched celeris, probatorium, loadgen and docs by mechanism (h2PoolSettled keep-alive, pool wait HTTP/1 detached refuseRecv, shutdown waits for HTTP/2 handlers detached SSE keeps writing): 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, item 50 of v1.6.0 polish: io_uring engine #884) (open: ENGINE-CODE 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)
Added 2026-10-16 (lane PINPLAN, PR #1049, celeris#1036):
-
docs repo (goceleris/docs,
src/content/docs/, applies to both native engines, so it is listed once here): PR epoll, io_uring: on a big.LITTLE host one pinned loop per non-little CPU, none unpinned #1049 makes the epoll and io_uring engines start one pinned loop per non-little CPU by default on an arm64 big.LITTLE host (8 on msr1, not GOMAXPROCS = 12) and honour a user-setWorkersabove that by wrapping over the non-little CPUs, each pinned. The docs say "default GOMAXPROCS" without the exception, and none of them mentions CPU pinning at all. Lines to change:configuration.mdlines 170-174 (theWorkersrow and its paragraph: add "on a big.LITTLE host the native engines start one pinned loop per non-little CPU by default; a set value is honoured and wraps");engines.mdline 365 (Config.WorkersdefaultGOMAXPROCS) and line 377 (theWorkers: 12example comment "default is GOMAXPROCS");deployment.mdlines 326-345 ("Workers and GOMAXPROCS": the default is not GOMAXPROCS on arm64 big.LITTLE; the loops are pinned to the allowed CPUs and a container cpuset ortasksetnarrows them);performance.mdlines 245 and 263 (WorkersdefaultGOMAXPROCS, "the right starting point"). Take the wording from the README "CPU pinning" paragraph and theConfig.Workersgodoc of PR epoll, io_uring: on a big.LITTLE host one pinned loop per non-little CPU, none unpinned #1049. RULE 92: searched celeris, probatorium, loadgen and docs by mechanism (big.LITTLE docs, cpu_capacity, capability-aware CPU pinning, Workers GOMAXPROCS default): only arm64 (msr1) loop engines lose 15-62% rps at 128c-256c and on post-4k since the bench on dbbaaee; #973's pin policy leaves 4 of 12 loops unpinned and is the lead suspect #1036 (the cause) and no tracker for the docs lines. (from PR epoll, io_uring: on a big.LITTLE host one pinned loop per non-little CPU, none unpinned #1049, task PINPLAN) (open: WORDING) -
epoll: the write clock of a keep-alive response parked on EPOLLOUT (
connState.writeSince, 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) sees progress only at a flush, and a flush follows an EPOLLOUT edge, which the socket raises once about half of its send queue has drained, or a read of the client (since review round 2 of 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 a client that sends while it reads, a pipelined request or an HTTP/2 PING, has the room it made taken and noted bydrainRead's inline flush), soWriteTimeout(now "no progress for WriteTimeout" on epoll) can close a client that is reading steadily, sends nothing and takes less than half a queue perWriteTimeout. The case that is closed earlier than on main:ReadTimeout-1, a deep autotuned send queue and a slow reader: main held it untilIdleTimeout(600 s), the clock closes it atWriteTimeout(60 s). Code reading, not measured. Two more consequences of the same clock (review round 1 of 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): (1) a reader that takes about half a send queue perWriteTimeoutis never closed and holds the conn and its whole queued response (an HTTP/1 response has nowriteCap; a Slowloris-class hold, though an attacker who reconnects everyReadTimeoutheld the same resources on main, so this changes the per-conn lifetime, not the concurrent memory): MEASURED on arm64 (16 MiB keep-alive response,ReadTimeout1 s,WriteTimeout3 s,IdleTimeout2 s, clientSO_RCVBUF4 KiB, 4 to 16 KiB read every 250 ms): main closed all 6 arms at 1.5 to 2.5 s, the PR head 266c6f8 held all 6 for the 15 s window with 240 to 960 KiB of the 16 MiB delivered (std's absoluteWriteTimeoutand io_uring's bound close it); (2)ReadTimeoutandIdleTimeoutof a conn holding a queued response run from the later of the last read and the clock, so the earlier ofReadTimeoutandWriteTimeoutis the time with no visible progress (ReadTimeout5 s,WriteTimeout60 s: a client that pauses for 6 s is closed, std allows 60 s). Fix shapes: an absolute ceiling on a parked response measured from the request (for exampleIdleTimeout, or a new cap) or a minimum-rate rule, which bound (1); samplingtcp_infobytes_ackedas a second kind of progress (noteClosingAckedexists for the closing drain and takes one getsockopt, at most every bound/4) answers the early close but lengthens (1), so it needs one of the bounds with it. Needs the RULE 74 measurement of the sweep. RULE 92: searched the goceleris repos by mechanism (tcp_info bytes_acked WriteTimeout live conn, write progress clock, EPOLLOUT edge half queue): only the closing-drain text of epoll: checkTimeouts measures ReadTimeout from the last read, so a closeWhenFlushed response that the client reads steadily for longer than ReadTimeout is cut #876 and item 45 of 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: DECISION) -
epoll, HTTP/2 (h2c): a stream handler that answers with ONE
ResponseWriter.WriteResponseof a large body (3.5 MiB and 24 MiB tried) delivers 90 bytes to the client (the server preface and SETTINGS ACK) and then nothing: the conn stays open and the client's read times out at 5 s, on main (eb75c84) and on 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 alike, for a handler withoutRouteAsync(loop thread) with and withoutAsyncHandlers, and for one withRouteAsynctrue on anAsyncHandlersengine (not tried:RouteAsynctrue withoutAsyncHandlers); a handler that writes the same bytes throughstream.Streamer(WriteHeaderthenWritein 1 MiB chunks) delivers all of it (24 MiB at 6 MiB/s). Found while writing the end-to-end test of 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 for HTTP/2; not investigated. The probe may be at fault: clientSETTINGS_INITIAL_WINDOW_SIZE2^31-1 and a connectionWINDOW_UPDATEof 2^31-1-65535 sent before the request, a GET with END_STREAM, a barex/nethttp2.Frameras the client that reads the raw socket and does not parse frames; the construction is not ruled out. Probe and logs:r3-h2probe-base.log(main) andr3-h2probe-head.log(PR head) in the WTSTALL lane evidence, probezz_h2_probe_linux_test.go. Not tried: io_uring and std, a client with the default window, a response of 1 MiB or less,x/net'shttp2.Transport. Possibly related to HTTP/2: a 64 MiB response to x/net's http2.Transport takes ~16 s on epoll, io_uring and adaptive, 0.07 s on std (net/http's client: 0.3-0.6 s) #809 (a 64 MiB response tohttp2.Transporttakes ~16 s on epoll). RULE 92: searched the goceleris/celeris issues by mechanism (h2c large response WriteResponse stall, HTTP/2 WriteResponse large body no data, h2 sync handler large response flow control stall epoll): only HTTP/2: a 64 MiB response to x/net's http2.Transport takes ~16 s on epoll, io_uring and adaptive, 0.07 s on std (net/http's client: 0.3-0.6 s) #809 is near it. (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: MEASUREMENT) -
epoll and io_uring (shared
internal/platform): the NIC interrupt detection of PR platform, epoll, io_uring: warn when a NIC interrupt vector is serviced by an allowed CPU that runs no loop and the rx-port rule did not free on purpose; README host-tuning note #1062 (celeris#1036) builds its interface table withnet.Interfaces()and then(*Interface).Addrs()for each of up to 512 interfaces; everyAddrs()call is a full RTM_GETADDR dump, including for down and loopback interfaces that a wildcard listener discards, so a hostNetwork node with hundreds of veths pays hundreds of dumps at start, and the adaptive engine runs the same detection again at the lazy standby's Listen while the server is already serving. Cheap fix: in wildcard mode skipAddrs()for down and loopback interfaces; better: onesyscall.NetlinkRIB(RTM_GETADDR, ...)dump mapped by interface index. The real figure is not measured (the fixture benchmark excludes netlink); read it from theirq_tookInfo field of the first cluster run (item on v1.6.0 polish: measurements and evidence records #892). RULE 92: searched the goceleris repos by mechanism (NetlinkRIB, RTM_GETADDR, osIfaceAddrs, effective_affinity_list, net.Interfaces Addrs startup): no tracker. (from PR platform, epoll, io_uring: warn when a NIC interrupt vector is serviced by an allowed CPU that runs no loop and the rx-port rule did not free on purpose; README host-tuning note #1062 review round 1, task IRQPLACE) (open: ENGINE-CODE) -
epoll and io_uring
parseAddr(internal/engine/epoll/loop.go, internal/engine/iouring/worker.go) refuses a zoned IPv6 bind address:net.Listen("tcp", "[fe80::1234%dz0]:0")binds on std (measured in a container with a dummy linkdz0,std-zoned-listen.login the ENGCFG lane evidence dir), butnet.ParseIPrejects the zone,parseAddrreturns "invalid addr" and the native engines fail to start on the sameConfig.Addr. Fix shape: parse withnetip.ParseAddr(it keeps the zone) and setSockaddrInet6.ZoneIdfromnet.InterfaceByName; the port-0 resolution (bindiag.WithPort) keeps the host as written, so it needs no change. The table row[fe80::1%eth0]:80ofTestParseAddrReadsAnAddressAsNetParseIPDoes(PR 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) pins the current error and says so. RULE 92: searched the four goceleris repos by mechanism (zoned listen address, IPv6 zone Config.Addr, scope id bind, fe80): no tracker (v1.6.0 polish: CI and tests #890 matches only the peer-address test, item 72). (from PR 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, lane ENGCFG) (open: ENGINE-CODE) -
Loop.RegisterConn(internal/engine/epoll/driver.go): the comment above the map insert says the worker looks a driver conn up only whilehasDriverConnsis set and that publishing before the ADD is what keeps the first byte (celeris#770). Since epoll: a driver event harvested before UnregisterConn is dispatched by number after it, and closes the conn registered on the reused number (the epoll twin of #707) #771 a driver event carries its registration's generation,driverCandidateroutes on it, andlookupDriverwaits ondriverMu, whichRegisterConnholds from the insert to the ADD, so the order inside the lock loses nothing: measured on main aa93b61 (arm64 container), a mutant with the ADD first and the publish after, still underdriverMu, passesTestRegisterConnDeliversBytesQueuedBeforeIt, while one that runs the ADD outsidedriverMuloses the byte at attempt 698 (logs/t9-mut-*.log, EPTSTLOOP lane). Rewrite the comment to say what holds the order now (the lock and the generation), and item 9's "aa2331d" control with it. Not edited in 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 because epoll, io_uring: on a big.LITTLE host one pinned loop per non-little CPU, none unpinned #1049 changes this file. RULE 92: searched the four goceleris repos by mechanism (RegisterConn comment, hasDriverConns, driverCandidate publish order, generation in epoll_event): only epoll: RegisterConn arms a driver fd before it publishes the conn, so on a loop with no driver conn the first edge can be dropped and onRecv never runs #770 (the cause) and epoll: a driver event harvested before UnregisterConn is dispatched by number after it, and closes the conn registered on the reused number (the epoll twin of #707) #771, no tracker for the stale comment. (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: WORDING) -
epoll:
replayCarriedAsync(internal/engine/epoll/adopt.go:261-275 on 6a6b07f) has the sibling of the fix(epoll): do not touch a connection's state after a handler that ran inline on an async loop hijacked it (celeris#769) #774 guard, returning onconn.ErrHijackedbefore it writescs.h1State.InlineMode = false, and no test pins it. Measured with a review mutant of PR tests: hijack request views on AsyncHandlers, forced connState reuse and its witness, inline-run assertions (v1.6.0 polish items) #1087 (a2: theInlineModewrite moved above theErrHijackedreturn, the pre-fix(epoll): do not touch a connection's state after a handler that ran inline on an async loop hijacked it (celeris#769) #774 shape ofdrainRead): the whole./internal/engine/epollpackage still passes without-race(336 top-level PASS, 0 FAIL;review-r1-correctness/logs/e-full-a2.logof the EPTSTHIJACK lane evidence dir). The code is right today. It cannot be reached in-tree: epoll's transplant source carriesBufferedonly when!l.async(transplant.go:177) and io_uring's transplant_source.go:184 never sets it, so an async-loop carry replay holds no bytes; it is reachable through the publicengine.TransplantTargetAPI with a hand-builtCarryover. Add a test that adopts a conn onto an AsyncHandlers epoll loop with a carry whose first request hijacks inline, then serves another conn that reuses the releasedconnState(thetestHookHijackReleasedseam of PR tests: hijack request views on AsyncHandlers, forced connState reuse and its witness, inline-run assertions (v1.6.0 polish items) #1087 forces the reuse) and asserts the reissued state'sInlineModeis untouched andAsyncPromotedConnsstays 0. RULE 92: searched the four goceleris repos, issues and PRs, by mechanism (replayCarriedAsync, InlineMode with ErrHijacked, carry replay hijack, TransplantTarget Buffered, AsyncPromotedConns;logs/search-replay-carried-20261020T081359Z.txt, scriptsearch-replay-carried.sh): only fix(epoll, iouring): an adopted conn's carried requests get the async dispatch rule under AsyncHandlers (celeris#543) #898/Follow-ups from #898 (celeris#543): a promoting replay's RequestCount differs by engine; a replay parse error closes without the error response; a replayed h2c upgrade closes the conn #899/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/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 (the replay itself) and iouring: a transplant's carry-replay flushSend runs without detachMu while a detached writeFn writes the same send state (data race) #1084 (a data race in the io_uring twin's flushSend), none about a hijack in a carry replay. (from PR tests: hijack request views on AsyncHandlers, forced connState reuse and its witness, inline-run assertions (v1.6.0 polish items) #1087 review round 1, lane EPTSTHIJACK) (open: TEST-HYGIENE)
Activity
fix(epoll): checkTimeouts cadence documented as iteration-driven; asy…
fix(epoll): a conn whose protocol is not detected yet is under ReadHe…
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 …
perf(epoll): detectDeadline does not move connState across a size class
test(epoll): the stalled-before-detection arms of the 974 test cover …
fix(epoll): a conn whose protocol is not detected yet is under ReadHe…
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 …
perf(epoll): detectDeadline does not move connState across a size class
FumingPower3925 commentedon Oct 13, 2026
New checklist item (lane CTXDETACH, PR #1014 review r1):
H1State.NowNsis written only on the inline recv path ofinternal/engine/epoll/loop.go(cs.h1State.NowNs = now); theProcessH1calls inserveAsync(loop.go), the carry/rest call after an H2 ask (loop.go,rest), and adopt.go (two calls) do not refresh it beforeconn.ProcessH1, which copies it toStream.StartTimeNs(internal/conn/h1.go,s.StartTimeNs = state.NowNs). On an async-promoted conn, and on those carry/rest requests, the request's start time is the conn's last inline recv, so the duration the router records (recoverAndRelease, and the detached elapsed inContext.Detach's done func) is inflated by the idle time between that recv and the request. Found by code reading in the PR Context.Detach: a panic out of OnDetach no longer retains the Context and a goroutine; done() records a real elapsed time #1014 review (checked against origin/main 25afce5 by grep); not measured. Fix shape to weigh: refreshNowNswhere the async handler is dispatched (one clock read per request on a path that already allocates a goroutine) or stamp the stream when the request is parsed. A hot-path change needs a RULE 74 measurement. RULE 92: searched celeris and probatorium by mechanism (NowNs, StartTimeNs), no tracker.148 remaining items
epoll: the write timeout of a parked response is a minimum rate on ac…
epoll: shutdown closes the idle HTTP/1 conns of a loop before anythin…
tests: the shutdown gate test reads every idle conn's FIN at the same…
tests: the shutdown gate test holds the wait with 64 HTTP/2 conns, so…
epoll: shutdown's idle pass has a bound of its own; its unit test mod…
epoll: shutdown closes the idle HTTP/1 conns of a loop with no HTTP/2…
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…