Repository navigation
epoll, io_uring: the shutdown gate reads a request that reached the kernel before it, instead of closing the conn (v1.6.0, Refs #885 #884 #1104) - #1138
Merged
Conversation
… kernel, on epoll and io_uring; the accept queue's reset is the listener's (Refs #884 #885 #1104) The gate's pass (epoll closeIdleH1, io_uring closeIdleH1) looks at the engine's own state and the kernel's send queue, never at the receive queue, so a request that reached the kernel before the gate but was not read yet is cut by the close. Red on main: the epoll pair (first requests; the next request of keep-alive conns) and the io_uring pair (the same two shapes). Green controls: idle conns still get a FIN, and stopAccepting's close of the listener resets the accept queue (the source of a reset for a conn that sent no byte).
…request that reached the kernel before it, instead of closing the conn (Refs #884 #885 #1104) The gate's first pass asks the kernel's receive queue (TIOCINQ) of every conn it would close. A conn that holds bytes has a request in progress: it is marked to be read on (connState.gateRead, honoured by refusesRecv / gateReadsOn), read once on epoll (the read event may already have been taken: holdUnreadAtGate drives drainRead), and answered with Connection: close. A request that arrives after the first pass begins after the gate and is refused as before. initProtocol marks an HTTP/1 state created past the gate close-after. io_uring: the same for non-fixed conns; a fixed-file conn cannot be asked. Adds the hook test of the window between the question and close(2).
|
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! |
…ed holds a request, whichever tier (Refs #884 #885 #1104) CI's base-tier job (kernel 6.17, no multishot recv, no provided buffers) failed the two io_uring tests of the previous commit: the request is not in the socket's queue there but a recv CQE already posted and not reaped (socket-queue-nonempty=0 cq-unreaped=live), so the TIOCINQ question answered 0 and the pass closed the conns. Reproduced locally with CELERIS_MAX_IOURING_TIER=base. Adds the deterministic unit tests of both shapes (CQE posted; bytes in the socket queue) and of the EOF CQE control, on the gate rig. The CQE-posted test is red here.
… and not reaped, as well as one with bytes in its socket queue (Refs #884 #885 #1104) holdPostedRecvs walks the unreaped completion queue once, on the first pass, and marks the conn of every recv CQE with bytes (Res > 0, generation matching, not an ended detached stream) to be read on: the reap that follows delivers it, handleRecv reads it past the gate and answers it close-after. Covers the base tier (the recv completes at once) as the socket-queue question covers a ring whose completions are task work. Nothing is added per request or per iteration: the walk runs on the first pass only.
… a later pass keeps a held conn while its bytes are unread (Refs #884 #885 #1104) Review r1: under COOP_TASKRUN and on the base tier the ioctls of the walk can themselves turn a later conn's bytes into a recv CQE, so the completion queue is read after every socket has been asked (sweepUnreadAtGate), and a conn the first pass held is closed by a later pass only once its socket is empty (M2). gateReadsOn does not read on for an ended detached stream. Comment fixes (tier dependence of where the bytes are; epoll ioctl-error branch; the emulated accept).
FumingPower3925
marked this pull request as ready for review
October 27, 2026 11:22
This was referenced Oct 28, 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.
Lane L20 GATE-UNREAD of v1.6.0. Refs #885, Refs #884, Refs #1104.
The shutdown gate of the pool wait (epoll
closeIdleH1, #1116; io_uringcloseIdleH1, #1123) closes an HTTP/1 conn it judges idle. It looked at the engine's own state and at the kernel's SEND queue, never at the kernel's RECEIVE queue, so a request that had reached the kernel before the gate but that the engine had not read yet was cut by the close. Found by the flakes lane while chasing the CI failure ofTestShutdownPoolWaitGateRequestsInProgressAndIdleConns885/sync(#1135).Method: commit 1 is the tests (red on main), commit 2 is the fix. Every number below is from docker
golang:1.27.3,--cpus 2,-race, non-root, kernel 7.0.12-linuxkit.CELERIS_MAX_IOURING_TIER=baseafter the first push, found by CI)waitEngineBusy1104) is unnecessary once this landsCommits
tests: the shutdown gate closes a conn whose request is unread in the kernel, on epoll and io_uring; the accept queue's reset is the listener's(red on main)epoll, io_uring: the shutdown gate reads, and answers close-after, a request that reached the kernel before it, instead of closing the conntests: io_uring gate pass: a conn whose recv CQE is posted and unreaped holds a request, whichever tier(red on commit 2)io_uring: the gate's first pass holds a conn whose recv CQE is posted and not reaped, as well as one with bytes in its socket queueio_uring: the first pass asks the sockets, then the completion queue; a later pass keeps a held conn while its bytes are unread(review r1)Commits 4 to 6 answer a failure CI showed on the first push: the job
io_uring base tier (CELERIS_MAX_IOURING_TIER=base)failed both io_uring end-to-end tests of commit 2 (kernel 6.17, tier base:socket-queue-nonempty=0 cq-unreaped=19, 18 conns closed with their request delivered). My local kernel (7.0.12-linuxkit, DEFER_TASKRUN) kept the request in the socket's queue, soTIOCINQfound it; on the base tier the recv completes when the bytes arrive, the request is a posted CQE the pass has not reaped, and the socket queue is empty. Reproduced locally withCELERIS_MAX_IOURING_TIER=baseon commit 2 before changing anything (3 of 3 runs red,socket-queue-nonempty=0 cq-unreaped=live).Tests, and the failing-on-main numbers
A handler running inline on a loop thread holds that loop (clients' requests pile up in the kernel while the loop cannot read them), then runs the gate's pass the way the loop runs it at the top of an iteration (
poolWaitGateon epoll;refuseRecv = true; closeIdleH1()on io_uring).-race -count=10, on main (commit 1 alone) against this PR:TestPoolWaitGateDoesNotCloseAConnWithAnUnreadRequest1104(the flakes lane's test, with the accept emulated inside the pass; see the reviewer notes)...KeepAliveConnWithAnUnreadRequest1104(served, idle conns; the next request in the kernel)...SilentConnWithAUnreadFirstRequest1104(Protocol Auto, TCP_DEFER_ACCEPT off: accepted with no byte, first request arrives during the hold; no emulated accept)...ReadsAnUnreadRequestPastTheReadBudget1104(a 24 KiB POST against a 1 KiBdrainReadBudget, the shape of the CI upload)TestGateDoesNotCloseAKeepAliveConnWithAnUnreadRequest1104(default tier)TestGateDoesNotCloseAConnWithAnUnreadFirstRequest1104(DEFER_ACCEPT off, Protocol Auto)TestPoolWaitGateClosesIdleConnsWithAFIN1104(control: idle conns that hold nothing still get a FIN)TestListenerCloseResetsTheAcceptQueue1104(characterisation, below)TestPoolWaitGateRequestInTheWindowBeforeCloseIsCut1104(commit 2: the residual race, below)Every answer the fixed gate gives to a request it found in the kernel carries
Connection: close(the tests assert the count equals the conns of the held loop, on both engines). Raw: scratchpadl20gateunread/out/{c1-epoll-main,c1-iouring-main,fix-epoll,fix-iouring}.log.An EOF on epoll (and on io_uring when the bytes were already a CQE: the close is then a FIN), a reset on io_uring when they were in the socket: epoll's
closeConnsendsshutdown(SHUT_WR)beforeclose, so the client reads the FIN first and the request, small, is simply lost; io_uring's close sends the RST for the unread bytes.The fix
The gate's first pass (the one that raises
refuseRecv) asks the kernel's receive queue of every conn it would otherwise close (TIOCINQ, ahead of the cap and the send-side tests, so a conn those leave for a later pass is not met unasked). A conn that holds bytes has a request in progress, which the shutdown contract answers (Connection: close, asstd's Shutdown answers an active conn):gateRead(a bool in the padding ofconnState; size unchanged,TestConnStateStaysInItsSizeClasspasses) andrefusesRecv/ io_uring'sgateReadsOnreturn "read on" for it;holdUnreadAtGatealso reads it once, now (drainRead, under the same 64 KiB budget): the epoll event for those bytes may already have been taken (a read refused at the gate consumes the edge, and with EPOLLET nothing reports bytes that were already there);handleRecvnow reads them on;initProtocol(both engines) creates the HTTP/1 state close-after when the gate is up, so a conn whose protocol is decided past the gate (a first request found by the pass; also the bytes of a decision begun) is answered close-after.Later passes do not ask the receive queue: bytes that arrive after the first pass are a request that begins after the gate, refused as before (the #1116 tests for that pass unchanged). A conn that fails the ioctl is left to the next pass; an ended detached stream is not asked (its inbound bytes are the application's).
Hot path (read from the diff): nothing is added to a per-request or per-iteration path.
poolWaitGateruns on its timer; the receive-queue question is asked on the first pass only, one ioctl per conn the pass would close. One bool test inrefusesRecv/gateReadsOn, evaluated only whilerefuseRecvis set. One store at a conn's protocol decision (initProtocol:CloseAfter = refuseRecv), per conn, not per request, on both engines. One atomic pointer load per conn closed, on the gate's pass (the test hook). Function bodies touched outside the gate files:initProtocolon epoll (loop.go) and io_uring (worker.go), plus the io_uringWorkerstruct fieldgateSwept; none incheckTimeouts. io_uring's first pass additionally reads the unreaped CQ once (holdPostedRecvs).The close/read race
close(2)with unread bytes is an RST, so a conn is asked about its receive queue before it is closed, and a conn that holds bytes is read, not closed. In the pass the question is asked ahead of the cap and the send-side tests (so no conn is left unasked), which puts the send-queue ioctl (TIOCOUTQ) and the cap test between the question andclose(2): a few microseconds. What cannot be closed is the window between the question andclose(2): a request that reaches the kernel in it meets a conn judged idle.TestPoolWaitGateRequestInTheWindowBeforeCloseIsCut1104makes it deterministic withtestHookGateBeforeClose(nil by default): a request sent in the window is cut (the client reads EOF; 10 of 10), the other conns of the pass are unaffected, nothing hangs. I chose not to close that window:shutdown(SHUT_WR)thenclosedoes not help: Linuxclosestill resets a socket whose receive queue is not empty (and epoll already does it);std's Shutdown has the same window when it closes an idle conn, and it adds a per-conn closing state, an interaction withrefusesRecvand a time bound to the hot-adjacent code. Not done; listed under findings.io_uring twin
The flakes lane thought the window was different (recv armed before the gate sees the conn). It is the same defect, with a tier-dependent place for the request. The pass runs at the top of the worker iteration, before the iteration enters the ring and takes its CQ snapshot, so anything the kernel delivered since the last reap meets a conn that looks idle (
detectAccumempty, nothing queued):live=19 socket-queue-nonempty=18 cq-unreaped=1; CI default tier 6.17:live=33 socket-queue-nonempty=32 cq-unreaped=1live=19 socket-queue-nonempty=0 cq-unreaped=19; local withCELERIS_MAX_IOURING_TIER=base:live=22 socket-queue-nonempty=0 cq-unreaped=22The fix asks both places, sockets first (
sweepUnreadAtGate, first pass only):TIOCINQ(holdUnreadAtGate) and marks the conngateRead;holdPostedRecvs:BeginCQhead to tail, nothing consumed) and the conn of every recv CQE with bytes (Res > 0, generation matching the live conn, not an ended detached stream) is marked;handleRecv, withrefuseRecvup, reads the marked conn on (gateReadsOn) and answers it close-after.The order matters under COOP_TASKRUN and on the base tier: the ioctls of the first walk can themselves run task work that turns a later conn's bytes into a CQE; asking the sockets first and the queue second means a request is in one of the two places when each is asked (the review r1 point). An EOF or an error CQE is not a request (
Res <= 0). A later pass closes a marked conn only once its socket is empty (a conn whose recv has not been re-armed leaves the bytes in the socket; without the rule the later pass would have closed it with them unread, an RST). This works for single-shot and multishot recv, with and without provided buffers, and for a recv into the body buffer (the CQE names the conn either way).Nothing is added per request or per iteration: the CQ walk and the second socket walk run on the first pass only (O(CQ ring) and O(live conns), once).
What remains uncovered, with evidence:
close(2): the race any close has (stdhas it), shown for epoll by the window test.ownsDescriptorNumber); their posted CQEs are still seen. This is unreachable in a working configuration: with the development-onlyCELERIS_IOURING_FIXED_FILES=1the engine does not serve even the first request (the warning says "this path is INCOMPLETE (celeris#541)"; measured: both new e2e tests fail at the setup request,first 0: read: connection reset by peer, 5 of 5, before the gate is involved).TestNoTierEnablesSQPoll884pins that).holdPostedRecvsreads the CQ whatever the mode, so a request already posted would be seen; not run, there is no way to select it.Tests, deterministic on both shapes:
TestGatePassHoldsAConnWithARecvCQEPosted1104arms a real recv on a socketpair conn, lets the kernel post the CQE, leaves it unreaped and runs the pass (red on commit 4, 10 of 10 at both tiers);TestGatePassHoldsAConnWithBytesInTheSocketQueue1104leaves the bytes in the socket;...WhoseRecvCQEIsAnEOF1104(control: an EOF CQE is not a request),...IgnoresAPostedRecvCQEOfAnotherGeneration1104,TestGateLaterPassKeepsAHeldConnWhileItsBytesAreUnread1104andTestGateReadsOnIgnoresTheMarkOfAnEndedDetachedStream1104pin the rules above. The two e2e tests (held worker, 32 clients) run at the default tier and atCELERIS_MAX_IOURING_TIER=base; they log which shape the kernel gave.The silent conn's reset
A conn that has sent nothing has no unread byte, and the gate's close of it is a FIN (
TestPoolWaitGateClosesIdleConnsWithAFIN1104: 10 of 10, EOF). NoSO_LINGERexists in the engines (grep;sockopts.ApplyFDsets none), so it is not that. The reset comes fromstopAccepting: the first loop iteration that sees the cancelled context closes the listener, and the kernel resets every conn still in that listener's accept queue (its doc says so;std's Shutdown has the same). The CI test dials the silent conns, the download and the upload last and sleeps 1.3 s ("TCP_DEFER_ACCEPT hands the engine a conn with no byte after about a second"); a loop that is behind, serving 24 HTTP/2 downloads of 16 MiB under-race -cover, can have all three in its accept queue at the cancel.TestListenerCloseResetsTheAcceptQueue1104(a held loop, 32 silent conns, 32 conns with a request, 2.5 s, then the cancel): the held loop's conns read ECONNRESET (22 of 32 silent, 13 of 32 with a request in the run shown; the free loop's silent ones got a FIN from its own shutdown). The timing in the CI log (257 ms) is the times the test acted, not the times the resets arrived. The same evidence points the other two CI failures there: a gate close on epoll gives an EOF for a small request (above), but the CI sawconnection reset by peeron the download head's read and on the upload's write. I cannot prove from the log that the CI conns were in the accept queue; this is the cause consistent with all three, and the only one that explains the silent conn.What this does not fix, and #1135
The lane description said this fix makes #1135's
waitEngineBusy1104unnecessary (#1135 has since merged; this branch carries it). It does only in part: it is unnecessary for conns the engine has accepted (the gate no longer closes them with their request unread), and still needed for conns still in the kernel's accept queue at the cancel, whichstopAcceptingresets whatever the gate does. The wait (untilActiveConnectionsandRequestCountshow the engine has taken everything) is what addresses those; keep it. I did not touch #1135's files.Mutants
Each reverted after its run (
-race -count=1, the named tests):TIOCINQhold removed (if false && first ...)...UnreadRequest1104,...KeepAlive...1104red (the run predates the silent and budget tests, which main's code also fails)refusesRecvignoresgateReadcloseConnremoved fromcloseIdleH1)TestPoolWaitGateClosesIdleConnsWithAFIN1104and the window test red; and the #1116 wire testsTestShutdownPoolWaitGateClosesIdleH1AndEndsDetached885(4 arms) andTestShutdownPoolWaitGateRequestsInProgressAndIdleConns885(sync, async-route) redinitProtocoldoes not mark close-after...SilentConnWithAUnreadFirstRequest1104red (0 of 14 answers carry Connection: close)gateReadsOnignoresgateReadinitProtocoldoes not mark close-afterTestGateDoesNotCloseAConnWithAnUnreadFirstRequest1104redholdPostedRecvsnot called)TestGatePassHoldsAConnWithARecvCQEPosted1104red; base tier: that plus both e2e tests redRes < 0forRes <= 0)...WhoseRecvCQEIsAnEOF1104red...IgnoresAPostedRecvCQEOfAnotherGeneration1104redTestGateLaterPassKeepsAHeldConnWhileItsBytesAreUnread1104red (both)gateReadsOnignoreswriteEndedTestGateReadsOnIgnoresTheMarkOfAnEndedDetachedStream1104redTestGatePass*884tests and the new control/hold tests red, both tiersLocal results
Final head (after the second merge of main; the full runs below were on
5eb6bb7, then main's #1136 merged and the new io_uring tests were re-run, 3 of 3 at both tiers):-race -count=10: io_uring at the default tier and atCELERIS_MAX_IOURING_TIER=base, epoll at the default: all pass.-count=10: at baseTestGatePassHoldsAConnWithARecvCQEPosted1104and both e2e tests 10 of 10 red; at the default tier the CQE unit test 10 of 10 red (the e2e tests pass there: the request is in the socket).deferTaskrun: falseinSelectTier's high tier, in a scratch worktree, not committed; kernel 7.0.12, tier=high): all io_uring tests 10 of 10 pass; the diag shows the CQE shape (socket-queue-nonempty=0 cq-unreaped=live). Review r1 measured the first push red there.-race -count=3: epollTestShutdown|TestDrain|TestPoolWaitok (55 s); io_uringTestShutdown|TestDrain|TestGateok at the default tier (46 s) and at base (47 s);test/serverTestShutdown|TestDrain|TestIOUringShutdownGateok (357 s)../internal/engine/iouring/...-raceonce: ok at the default tier (485 s) and at base (488 s, the CI base-tier job's setting);./internal/engine/epoll/...ok (265 s).errcheckfindings main already has in files this PR does not touch.--cpus 2, non-root, one process at a time.Release note
shutdown: with the HTTP/2 pool wait's gate (epoll, io_uring), an HTTP/1 request that had reached the kernel before the gate but was not read yet is answered (with
Connection: close) instead of being cut by the close of its connection.Findings not fixed here
std). Draining the queue (accept, answer or close with a FIN) before closing the listener would remove that class; it is a behaviour decision, and the cause of the CI flake as far as I can tell.close(2).asyncPromoted) with a request in the kernel is still left to the end of the wait on epoll (follow-up F2 of 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's review); io_uring looks inside it.drainReadalready has, but it is not tested here.For the reviewer to attack
holdUnreadAtGaterunsdrainReadinline inside the walk ofliveConns(loop thread, handler latency added to the pass; the walk goes from the top down so a close of the current conn is safe). Reentrancy:Loop.readYieldedis reset before each event's owndrainRead, so a yield inside the pass leaves nothing behind; confirm. An async-mode loop can promote the conn inside the pass.CloseAfter = refuseRecvininitProtocolalso reaches an adopted or transplanted conn on a loop past the gate.TestPoolWaitGateDoesNotCloseAConnWithAnUnreadRequest1104emulates an accept inside the pass (acceptAll); production accepts nothing at the gate (stopAcceptingruns at the cancel). The keep-alive, silent and budget arms are the production-reachable ones, andProtocol: Autoin them is what makes a conn undetected (withHTTP1the state exists at accept).holdUnreadAtGatereturns "held" on an ioctl error; a conn held whose recv is not armed (needsRecv,recvPaused) waits, with its bytes in the socket, until its recv is re-armed or the end of the wait (a later pass no longer closes it while the bytes are unread). The COOP_TASKRUN order (sockets, then the queue) is by reading only.sweepUnreadAtGatecallsgateCloseablea second time per conn on the first pass (idempotent: it only marks close-after and takes a TryLock).