Repository navigation
epoll: shutdown closes the idle HTTP/1 conns of a loop with no HTTP/2 conn before its send drain waits (v1.6.0, Refs #885 #1104) - #1163
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
flake-shutdown-idle-conns-nonh2-loop
branch
from
October 28, 2026 20:44
fc07abb to
393c82e
Compare
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.
Flakes lane.
TestShutdownPoolWaitGateRequestsInProgressAndIdleConns885/async-routefailed on #1154 (run 40732286713, attempt 1, job 122971491083, the io_uring base tier job; the failing half is epoll):read tcp ...: i/o timeout at 1.501s, want a FIN (io.EOF) once the gate has passed. This is a product defect in the epoll engine's shutdown (a user can see it), not a test race. It is a regression of unreleased v1.6.0 code: the hold comes from shutdown's send drain (#760, phase 2b), which v1.5.11 did not have (v1.5.11'sshutdown()closed every conn at once). The gate (#1116) hides it on a loop with an HTTP/2 conn. It is not a regression of #1148/#1153. Refs #885, Refs #1104, Refs #1154, Refs #1165, Refs #1166Root cause
A loop with no HTTP/2 conn has nothing to wait for (
h2PoolSettledis true at once), so it never raises the pool wait's gate and goes straight intoLoop.shutdown(). Phase 2b of that isdrainSends, which polls every conn that has a response queued until its peer takes it, up to the budget of the Shutdown (20 s in the test), and phase 3, which closes the conns, runs only after it. So an idle keep-alive conn (or one that sent nothing) on the same loop as ONE slow download got no FIN for as long as the drain lasted. A loop that does have an HTTP/2 conn closes its idle conns 250 ms in (poolWaitGate->closeIdleH1); io_uring is, from reading its code, not affected (its drain runs inside the run loop andcloseIdlePacedcloses idle conns past the 250 ms floor, also on a worker with no HTTP/2 conns; not measured, io_uring is not available in my docker); net/http'sShutdowncloses idle conns at once.In the CI test there are 24 HTTP/2 conns on 4 SO_REUSEPORT loops, so a loop without one is rare (about
(3/4)^24, 0.1%, for the download's loop), which is why it showed once.The CI log also over-reports: the test read the conns one after the other, and a Go read on a conn whose deadline has passed fails before it looks at the socket (FIN or not), so the first late conn ("idle keep-alive 3") became five reports (idle 4, 5, silent 0, 1 were never really waited for). The test now reads all of them at the same time and reports each one's own wait.
The "+33 requests (want 32)" line is a log line of a passing wait:
RequestCountcounts the loop's read turns, not requests; it is not part of the failure and is untouched.What a user could notice (release-note-worthy), and what this PR does not fix
On epoll, during
Shutdown/ context cancel, a keep-alive client whose conn was idle waited (up to the Shutdown budget, with no answer) for its FIN if another client on the same loop was slowly reading a large response and the loop had no HTTP/2 conn. After this PR such a conn gets its FIN at once, unless it isasyncPromoted: the flag is sticky and is set for every conn that ever served an Async route and, on a server with any Async route, for every plain conn whose request once arrived in pieces. Those conns are still held behind the drain for the whole budget (measured end to end by the review of this PR: 8 of 8 Async-route and split-head idle conns timed out at 3 s; tracked as #1165, the "follow-up F2" of #1116/#1138). Also not fixed here: a request in progress (slow upload, headers in pieces) on a loop with no HTTP/2 conn is not read and answered as the gate does on loops with one (#1166); and an Async-route conn on such a loop is not "cut at once" in general: with a slow download on its loop it first waits behind the drain, and is then cut by phase 3.Differences from net/http's
Shutdown, stated so nobody reads "like std": std closesStateIdleconns at once, but a conn that was accepted and has sent nothing (StateNew) is not closed until 5 s after accept, and a request it sends meanwhile is answered; the pass (like the gate) closes such a conn at once. This is deliberate (it mirrors the gate), and main's phase 3 closed it too, with an RST when bytes had arrived.OnDisconnectis still not uniform at shutdown: conns closed by this pass (and by the gate) fire it and count incloseCount/activeConns; conns closed in phase 3 still do neither.Reproduction
internal/engine/epollTestShutdownClosesAnIdleConnWhileAnotherConnsSendDrainWaits1104: a rig loop with one conn whose peer reads nothing (2 MiB queued) and one idle conn;shutdown()with a 20 s budget. Main: no FIN 1.54 s in (the test's bound), the drain still waiting. Fix: FIN at once.golang:1.27.3,-race,--cpus1 and 2, one and two parallel processes) all passed. So, at the test/server level I lowered the number of HTTP/2 conns (experiment only, not committed; env knob) to move the loop-without-H2 probability, with the same test,-race,--cpus 2, arms (sync + async-route) that had at least one conn without its FIN at 1.5 s:(The model
P(download's loop has no HTTP/2 conn) = (3/4)^novershoots the measured rate for n >= 3: not every conn on a loop-without-H2 is held; the rate falls with n as it predicts, and it is the unit test that is the proof, not the table.)Commits
tests: a loop with no HTTP/2 conn holds the FIN of its idle HTTP/1 conns behind another conn's send drain(red on main)epoll: shutdown closes the idle HTTP/1 conns of a loop before anything in it waits ...:Loop.shutdown()runs the gate's owncloseIdleH1(false, now)first (after the adoption queue and the drivers, before phase 1), until a pass closes fewer than its cap (closeIdleH1returns the count now). The conns are then in the state the gate's code was written for (nothing torn down yet: dispatch goroutines live, detached streams not ended; I first placed it after phase 2 and moved it: phase 1 has already runCloseH1on every conn by then). Same rules as the gate's: a conn with a response queued or unacked, a request in progress, a dispatch goroutine running a handler, or a detached stream not ended is left to the drain and to phase 3. Conns with unread bytes are closed as phase 3 would close them (an RST either way; the gate's first-pass read-and-answer is not used here, the run loop is already gone). Same teardown as the gate'scloseConn, so such a conn is now counted closed and firesOnDisconnectat shutdown (phase 3 closes with a bareclose(2)and does neither): the same as the conns a loop with HTTP/2 conns closes at its gate.TestShutdownIdlePassTearsAConnThatServedARequestDownOnce1104pins, for a conn that has served a request, oneOnDisconnect, onecloseCount,activeConns0 and a FIN (sync, 20 of 20 under-race); itspromotedsub-case models production (dispatch goroutine parked,asyncPromotedset) and pins what this PR does NOT do: the pass leaves the conn live and uncounted, phase 3 closes it, with nothing counted (epoll shutdown: an idle conn that is asyncPromoted is held behind another conn's send drain for the whole Shutdown budget #1165 turns this case around). An earlier version of that sub-case ran the goroutine withoutasyncPromotedand so showed a close production never makes; the review found it.epoll: shutdown's idle pass has a bound of its own; its unit test models a promoted conn as production does ...(review r1 of this PR): the pass loop is bounded bylen(liveConns)/gateClosesPerPass + 1passes (closeIdleH1countscloseConncalls, andcloseConncan leave a conn in place when a dispatch goroutine owes the close, so the count alone does not prove termination).tests: the shutdown gate test reads every idle conn's FIN at the same time and reports each one's own waittests: the shutdown gate test holds the wait with 64 HTTP/2 conns, so that no loop is left without one: the same test also fails, for the same reason and at the same rate, on its other assertions (upload cut, Async-route conn closed) when a loop has no HTTP/2 conn; 24 big + 40 small (512 KiB, still above a stream's initial window) conns make a loop without one4 (3/4)^64~ 0 on 4 loops (0.3% on 16 loops).Verification
-race,--cpus 2: all*1104tests ofinternal/engine/epoll-count=20ok (166 s), and theinternal/engine/epollwhole package ok (379 s) with the pass at the top ofshutdown(). The test at 64 HTTP/2 conns,-count=15: 30 of 30 arms pass, every idle conn has its FIN 251 to 262 ms in.go vetlinux;mage apiCheckok;golangci-lintv2.15 local (CI pins v2.14): 3errcheckfindings oncase <-done:lines that are on main, none in this diff. io_uring tests cannot run in this docker (io_uring not available), so the io_uring side is CI's; it is untouched. A broader local-run 'Shutdown|Drain'pass of the root package andtest/serverwith the io_uring cases skipped is in the evidence directory of the lane.Not verified
The CI occurrence itself (which conn was on a loop without an HTTP/2 conn) cannot be observed after the fact; the mechanism is shown by the unit test and by the lowered-H2 runs, not by a replay of that run.