Repository navigation
epoll: the pool wait stops serving HTTP/1 and detached writes and closes idle keep-alive conns, queued driver rounds are not idle rounds (v1.6.0, Refs #885) - #1116
Merged
Conversation
|
Caution Review failedThe pull request is closed.
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
FumingPower3925
force-pushed
the
l3-ep-timeouts
branch
from
October 26, 2026 17:45
88e0eb1 to
2a6d68a
Compare
FumingPower3925
force-pushed
the
l3-ep-timeouts
branch
from
October 26, 2026 18:05
2a6d68a to
0d36ed5
Compare
FumingPower3925
force-pushed
the
l3-ep-timeouts
branch
from
October 26, 2026 18:35
0d36ed5 to
ba5b5f2
Compare
… (Refs #885 items 42, 49) Tests only, each failing on main as an assertion: the pool wait that keeps serving HTTP/1 and detached writes (epoll and adaptive-on-epoll arms of the shutdown test), idle keep-alive clients left waiting, a producer that is never told, and a queued driver flood that counts empty rounds as idle ones.
…ses idle keep-alive conns, queued driver rounds are not idle rounds (Refs #885 items 42, 49) Item 49: poolWaitGate, 250 ms into the wait for HTTP/2 pool handlers, refuses HTTP/1 reads (a request in progress on a conn the loop parses is read on and answered with Connection: close, as std's Shutdown does), ends detached producers (TryLock, hijack re-tested under the lock, a stranded remainder put on the dirty list) and closes the HTTP/1 conns that hold nothing: idle keep-alive conns, conns that sent no byte, and detached streams the gate has ended, on a sync or an Async route, once their queue has gone (at most 64 per 10 ms pass). Item 42: a round whose timeout a driver queue forced to 0 does not count toward consecutiveEmpty.
FumingPower3925
force-pushed
the
l3-ep-timeouts
branch
from
October 26, 2026 19:26
ba5b5f2 to
ad7c63f
Compare
FumingPower3925
marked this pull request as ready for review
October 26, 2026 19:54
This was referenced Oct 26, 2026
FumingPower3925
added a commit
that referenced
this pull request
Oct 28, 2026
…ion: close and ends its conn (v1.6.0, Refs #885 #884) Engine.Shutdown raises an engine-wide flag (a loop that sees Listen's context cancelled raises it too), which each HTTP/1 conn's H1State carries (H1State.Shutdown). The response head builders read it where the head is built, so a handler already running when Shutdown began (a sync handler on the loop's thread, an Async-route handler on its goroutine) and a request that arrives in the 250 ms before the gate are answered with one Connection: close, and handleH1Request ends the conn after it (errConnectionClose: the bytes read behind the request are dropped, as net/http does not serve a pipelined request behind a response that closes its conn). A handler's own Connection header on such a response is dropped for the one close. 1xx heads (101 is a handshake), detached streams, hijacked conns and HTTP/2 are not touched. The gate's CloseAfter marks (#1116, #1123, #1138) are unchanged: both feed the one decision. std (net/http) already closes after such a response; the wire tests assert it. Server.Shutdown's godoc says what each engine does.
FumingPower3925
added a commit
that referenced
this pull request
Oct 29, 2026
…ion: close and ends its conn (v1.6.0, Refs #885 #884) Engine.Shutdown raises an engine-wide flag (a loop that sees Listen's context cancelled raises it too), which each HTTP/1 conn's H1State carries (H1State.Shutdown). The response head builders read it where the head is built, so a handler already running when Shutdown began (a sync handler on the loop's thread, an Async-route handler on its goroutine) and a request that arrives in the 250 ms before the gate are answered with one Connection: close, and handleH1Request ends the conn after it (errConnectionClose: the bytes read behind the request are dropped, as net/http does not serve a pipelined request behind a response that closes its conn). A handler's own Connection header on such a response is dropped for the one close. 1xx heads (101 is a handshake), detached streams, hijacked conns and HTTP/2 are not touched. The gate's CloseAfter marks (#1116, #1123, #1138) are unchanged: both feed the one decision. std (net/http) already closes after such a response; the wire tests assert it. Server.Shutdown's godoc says what each engine does.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #885 (items 42, 49)
Draft: lane L3 EP-TIMEOUTS of v1.6.0. Two commits, verify-first: the first holds the tests (assertions on the real code, each checked failing on main or, for the review-round ones, on the previous head), the second the fix. This PR is 885.49 and 885.42 only: Opus review r1 of the first revision rejected the 885.51 ceiling and approved these two; 885.51 is the separate draft PR #1119. Review r2 of the shrunken head (2a6d68a) and r3 of the next one (ba5b5f2) asked for the fixes listed below (r2 M1, M2, m1 to m3, n2; r3 M1, m1, m2, n4).
Premises, checked on main before any fix (commit 1 against main's code, docker golang:1.27.3, -race)
TestShutdownPoolWaitStopsServingH1AndDetached884, epoll and adaptive-on-epoll arms added: 16 of 16 keep-alive requests answered 1.5 s into the shutdown, 672 / 688 ticks in 900 ms (the issue measured 688 to 704).TestShutdownPoolWaitGateClosesIdleH1AndEndsDetached885and...RequestsInProgressAndIdleConns885(see below): idle keep-alive clients still connected 2.5 s into a 20 s wait, producers still writing; on the previous head (2a6d68a, before the fixes of this round) the detached streams' clients timed out with no FIN 2 s after their last byte, a connection with no byte sent stayed open, and a 64 KiB-into-2 MiB upload was reset ("broken pipe"/"connection reset") at the gate.TestQueuedZeroTimeoutRoundsDoNotStretchTheIdleWait885: 32 turns queued on an AF_UNIX pair, last read seesconsecutiveEmpty=31and the next idle wait 2 ms (want 0 and 1 ms). Two turns are enough to fail (SO_SNDBUF 212992, the stock wmem_max: 1 on main, 0 here), so it does not skip on a stock runner.What changed
49 (
poolwait_gate_885.go, one call inLoop.run): 250 ms after the pool wait began (h2DrainStart), at most once per 10 ms:refuseRecvis set:drainReadreads no more from a conn with!detected || protocol == HTTP1, the predicate of io_uring'srefuseRecv, except a request in progress (r2 M2): a conn whose parse state holds a partial request (HasPendingData: headers in pieces, a body still arriving) or whose protocol decision has begun (detectN > 0) is read on and answered, as std'sShutdownanswers the request of an active conn, and closed when it next comes up idle;detached producers are ended with
TryLock(as shutdown's phase 1 ends them:asyncClosed, the parked read-end error,detachClosed,OnDetachClosebehind theWSReadycheck;hijackedre-tested under the lock); a producer's nextWritegets an error wrappingnet.ErrClosed; a conn left with bytes queued, no EPOLLOUT armed and not on the dirty list is put on the dirty list on every pass (drainDetachQueueskips adetachClosedconn);closeIdleH1 (r1 M2, extended by r2 M1 and m2): an HTTP/1 conn that holds nothing (no engine queue, no kernel send queue by
TIOCOUTQ, no request in progress, no dispatch goroutine) is closed with a FIN, as std'sShutdowncloses idle conns: an idle keep-alive conn, a conn that was accepted and sent no byte (a browser preconnect), and a detached stream the gate has ended, once what was queued for it has gone out, on a sync route or on an Async one (r2 M1 for the sync route; r3 M1 for the Async one, whose conn a dispatch goroutine owns: its state is read underdetachMu(TryLock),detachClosedis only set under that lock by the gate, after the handler had left it, the producer's writes no-op, andcloseConntakes the lock as shutdown's own teardown does; the Async-route arms of the FIN test run-race). At most 64 conns per pass (r2 n2: a loop with thousands of idle conns keeps serving the HTTP/2 conns the wait is for).r3 m1, Connection: close: once the gate has passed, every HTTP/1 conn the loop parses itself is marked close-after (
H1State.CloseAfter, a field and three lines ininternal/conn/h1.go, outside the lane's files: nothing sets it on io_uring), so a request still read on (one in progress) is answered withConnection: closeand its conn ends once the response has gone, as net/http's response written afterShutdownbegan does: a client does not write its next request to a conn that is about to close.42 (
run): a round with no events does not count towardconsecutiveEmptywhen a driver queue was owed (owedDriverReads != 0).Tests added in this round (commit 1; the sync arm is r2 m3)
TestShutdownPoolWaitGateRequestsInProgressAndIdleConns885, armssync(no Async route: the default synchronous loop) andasync-route(an Async route exists: dispatch goroutines), the wait held by 24 HTTP/2 responses of 16 MiB that nobody gives window to (the reviewer's probe, as assertions): idle keep-alive conns get a FIN within 1.5 s; conns with no byte sent get a FIN (they are accepted about a second after the SYN: TCP_DEFER_ACCEPT); a 16 MiB download queued in the engine for a slow reader arrives whole; on the sync loop a 2 MiB POST with 64 KiB sent when the shutdown begins is answeredgot 2097152after the gate; on the async-route loop a conn an Async route promoted is not closed (pinned: the documented exception).TestShutdownPoolWaitGateClosesIdleH1AndEndsDetached885gains the FIN assertion: after the flushed bytes of each detached stream the client reads EOF or unexpected EOF within 2 s (a reset does not pass: r3 n4), in arms for streams on a sync route and on an Async route (r3 M1: it fails on the previous head with an i/o timeout for the Async-route arms). The sync arm of the in-progress test assertsresp.Closeand the FIN after the answer (r3 m1).Not done / known gaps (the loop's
follow-ups.mdhas the drafts)worker.go~7901, and its detach queue skips adetachClosedconn ~6579), nor reads a request in progress, nor closes a conn that sent no byte. So after this PR the engines diverge on the idle close: epoll closes, io_uring waits for the end of the wait. Follow-up (F1); not touched here.markDirtyflush of detached bytes nothing else would flush has no test that fails without it (with the conns observed the remainder is always on EPOLLOUT); a WS conn not yetWSReadyat the gate is untested.iouring/worker.go~3913 still says epoll's pool wait does not stop reading ("celeris#885"): stale after this PR; reported for the next io_uring PR.Release-note lines
Writereturns an error wrappingnet.ErrClosed, what was already queued is still flushed, and the stream's connection is then closed (a FIN). A response still going out is delivered whole. A request already in progress when the gate comes (headers in pieces, a body arriving) is read on and answered withConnection: close, except on a server that registers anyAsyncroute: there, such a request is not read (its connection is promoted to a dispatch goroutine whatever its route) and, like an idle connection on anAsyncroute, it waits for the end of the wait. io_uring's gate does not close idle connections yet (it refuses reads and ends detached producers only): the two engines differ on this until a follow-up.epoll_waitstart from a stretched timeout.loop.go outside the named functions (for rebasers)
Struct fields
refuseRecv/producersStopNext; oneif l.refuseRecv && refusesRecv(cs)at the head ofdrainRead(a load and branch per read event; the predicate runs only after the gate); theconsecutiveEmptybranch inrun; the call in thectx.Err()block ofrun.checkTimeoutsandsweepWriteClockare untouched (the sweep is byte-identical to main). ThedrainReadgate measured within noise (BenchmarkKeepAliveDispatch/nodriver12338 base, 12606 head ns/op, A/A 11880,--cpus 2).Verification
TestShutdownPoolWaitStopsServingH1AndDetached884:-race -count=20(ok on the final head: the gate tests x20, 405 s;internal/connand the fullinternal/engine/epollpackage-race: ok);TestQueuedZero...,TestConnStateStaysInItsSizeClass:-count=20; the full./internal/engine/epoll/package-race; golangci-lint v2.14: 0 issues. The io_uring arms of the shutdown tests are not verified locally (docker has no io_uring): CI is the evidence.