Repository navigation
epoll, io_uring: every HTTP/1 response after Shutdown carries Connection: close and ends its conn (v1.6.0, Refs #885 #884) - #1169
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 |
FumingPower3925
force-pushed
the
conn-close-after-shutdown-884
branch
from
October 28, 2026 23:47
78f19a2 to
69bf50e
Compare
… and ends its conn, on every engine (red on main; v1.6.0, Refs #885 #884) Wire tests for std, epoll, io_uring and adaptive (sync and async routes, in flight at Shutdown and in the first 250 ms), conn-level tests, engine-level tests of what raises the flag, an engine-switch guard, and the H1 respond benchmark. The tests compile against H1State.Shutdown and the engines' shutdownBegun, declared here and unused; main fails them.
…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
force-pushed
the
conn-close-after-shutdown-884
branch
from
October 29, 2026 00:24
69bf50e to
bc3df25
Compare
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…a closing conn behind its Connection: close response; the 417 says close (v1.6.0, Refs #885 #884) A conn closing after a response that said Connection: close, with a late pipelined request in its receive queue, was either read and served (epoll: holdUnreadAtGate, a second response behind the close) or closed with those bytes unread (io_uring; and epoll if only skipped), and close(2) with unread bytes sends an RST that destroys the response in flight (a 16 MiB body arrived at 14.5 to 15.3 MiB). The first pass of the gate now reads such a conn's queue and discards it unparsed, on both engines; the conn closes itself once its response is out. Also: the 417 of a refused Expect says Connection: close, and the docs say what is and is not covered.
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
Refs #884
What a user sees (release note)
Every HTTP/1 response written after
Server.Shutdownhas begun carriesConnection: closeand its connection is closed after it, on all engines (std, epoll, io_uring, adaptive), as net/http's does: a request that was already in its handler when Shutdown began (sync or Async route) and a request that arrives on a keep-alive connection in the first 250 ms of the wait. A request pipelined behind such a response is not served (one already in the socket when the engine's gate looks at the connection is read off and dropped; see below). A handler's ownConnectionheader on such a response is replaced by the oneclose; this also applies to a response to a client's ownConnection: closeor an HTTP/1.0 request outside any shutdown (it used to carry both lines). The 417 of a refusedExpectnow saysConnection: close. On the native engines an HTTP/1.0 client gets the header too; std (net/http) closes the connection without a header for an HTTP/1.0 client.The gap
Engine.Shutdownon epoll and io_uring only recorded the budget; the engines' gate (#1116, #1123, #1138) closes idle conns and marks the conns it meets close-after (H1State.CloseAfter), but only from 250 ms into the wait, andCloseAfteris read when a request begins. 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 reached an idle keep-alive conn in the first 250 ms answered keep-alive and left the conn open.Design
Engine.shutdownBegun(anatomic.Bool, per engine) is raised first thing inEngine.Shutdown, and by a loop/worker that sees Listen's context cancelled (StartWithContextcancels the context Listen was derived from, which the loops see at the same time as the watcher that calls Shutdown). It is never lowered; an adaptive engine switch does not touch it (pause/resume, not Shutdown or a cancel; guard test below).conn.H1State.Shutdown *atomic.Boolpoints at it (set ininitProtocol, next toCloseAfter). The H1 response adapter reads it where a final response head is built (status >= 200):closing(status). It then emits the oneconnection: close(the same bytes the client-requested close emits), clears the adapter'skeepAlive, andhandleH1Requestturns that intoreq.KeepAlive = false, which is the existing path toerrConnectionClose: the engine flushes and closes, and ProcessH1 drops the bytes already read behind the request (a pipelined request is not served; net/http does not either). No new close path.H1Stateon an Async route).Connectionheader on a closing response is dropped for the oneclose(cold path: only a closing response scans; no allocation unless such a header exists). Also applies to a clientConnection: closerequest outside shutdown (it used to emit both lines).Interaction with #1138 (stated plainly)
I reused the close-after decision (
req.KeepAlive->errConnectionClose, plus the same header bytes) and did not add a second close path. But the flag is a second input to it, next toCloseAfter, not a replacement: per-connCloseAfteris a plain bool "set and read on the goroutine that runs ProcessH1", read when a request begins. The loop cannot raise it at Shutdown start for a conn whose dispatch goroutine owns it (a data race), and it is read before a handler already in flight writes its response. The gate'sCloseAfterwrites are untouched; #1138's tests are unchanged and green. No design conflict found.Late pipelined requests (found by the Opus review of this PR)
A client that pipelines its next request after the handler began (it reaches the kernel while the response is still flushing) left a request in the receive queue of a conn that this change now closes after its response. On epoll the gate's first pass (
closeIdleH1, #1104's read-on) read it and served it, a second response behind theConnection: closeone (on main only a client's ownConnection: closedid this). Skipping such a conn is not enough:close(2)with unread bytes sends an RST that destroys the response in flight (a 16 MiB body arrived at 14.5-15.3 MiB, then ECONNRESET), on epoll and, measured here, on io_uring. The first pass now reads a closing conn's receive queue and discards it unparsed (discardUnreadOfClosingConn, bounded to 64 reads of 4 KiB, not for HTTP/2, detached, fixed-file or dispatch-goroutine-owned conns); the conn closes itself once its response is out. The contract test has a late-pipelined arm (second request written after the handler entered, counting handler must stay at 0, whole 16 MiB body then EOF, not a reset) on every native arm where main delivers the whole body.Adaptive
The sub-engines raise their flags in their
Shutdown, which adaptive calls once it holdse.mu; a switch that holds it (a standby build waits up to 5 s for its bind) delays the flags, and the sub-engines cannot be reached without that lock. A response written in that wait is keep-alive; a Shutdown that gives the lock up cancels Listen's context, which every loop and worker turns into the flag. Documented inadaptive.Engine.Shutdown.What is NOT changed (known limitations)
stopDetachedProducers) as before.connection: closein its head, like net/http's.middleware/websocket(does not pass the adapter), and a 101 throughWriteHeaderis exempt by status. Untouched.expectation417Responseis a raw write without a Connection header (the conn closes anyway); predates this change.StreamWriterresponse that is still in its handler (detachClosed, phase 1), so such a response is lost; predates this change (noted in the wire test; follow-up candidate).Connection: closeoutside shutdown still does not close the conn; predates.Disagreements with the brief
Connection: close, an HTTP/1.0 response carries none (net/http adds the header only for HTTP/1.1+) though the conn closes. The brief's "(response Connection: close)" for 1.0 is what native does (a superset); the std arm only asserts it closes and has no more than one Connection line.H1State.Shutdown,Engine.shutdownBegunon epoll and io_uring) so the tests compile on main and fail by assertion.Evidence
Commit 1 on top of main (tests + the three inert declarations) vs commit 2:
internal/connTestShutdownFlag*(13 tests, 27 incl. subtests)test/serverTestShutdownConnCloseContract884, 14 arms (the late-pipelined arm needs the whole 16 MiB body then EOF on the native pool-wait arms; the arm without the discard fails on epoll and, measured, on io_uring) (std x2, epoll, io_uring, adaptive-epoll, adaptive-io_uring; pool-wait/direct; sync/async-route; 11 request shapes each, in flight and in the window)internal/engine/epollandiouringTestShutdownCallRaisesTheFlag884(inline/async),TestListenCancelRaisesTheFlag884test/integrationTestEngineShutdownCallAloneSaysClose884(4 arms),TestAdaptiveSwitchKeepsKeepAlive884-race:
-count=20 -cpu 2on the contract test (test/server), the engine-level tests (epoll, io_uring), conn tests, integration tests: all pass. Whole root package,test/server,internal/conn,internal/engine/epoll,internal/engine/iouring,internal/adaptiveunder -race once: pass. golangci-lint v2.14 (linux): 0 issues.GOOS=windows/darwin/linux go vet ./...clean;mage apiCheckmatches (no exported API change).CI notes:
test/integrationis not run by the CI workflow (only its skip guard is), which is why the Engine.Shutdown-alone and Listen-cancel-alone checks also exist in the engine packages. The newtest/serverand engine tests contain not.Skip.Mutants (each must go red)
initProtocoldoes not wire the flag (epoll / io_uring); loop/worker not given the engine's flag (epoll / io_uring)Engine.Shutdowndoes not raise it (epoll / io_uring)TestShutdownCallRaisesTheFlag884, integrationTestListenCancelRaisesTheFlag884RaisedInsideHandler(+ wire in-flight arms)CloseAfterremovalAndCloseAfterAreOneDecision884req.KeepAlive(plain / streamed path)pool-wait/syncarms (reset after 14.5-15.3 MiB //v/secondserved)Connection: closeTestExpectationFailedSaysClose884Not killable deterministically: none left among those listed (the loop-side setter is killed by
TestListenCancelRaisesTheFlag884; the server-levelcancelarms alone would only see a microsecond race).Benchmarks (darwin arm64, Apple M5; main b10b4b4 vs branch, interleaved,
-count12 each, benchstat)BenchmarkProcessH1Respond884(new, a handler writing a response through the adapter; ns/op, allocs/op identical on all arms: 64 B, 1 alloc):Existing:
ProcessH1Backlogged89055.1 -> 51.9 ns (-5.8%, p=0.003),H1StreamWrite884write 35.95 -> 36.47 (), flush 1.905 -> 1.988 (), 0 allocs. RootContextString~,ContextBlob+7.5% (p=0.004),HandleStreamFull~:ContextBlobandHandleStreamFullrun on a test recorder and do not execute the changed code (they never reachinternal/conn); read as code-layout noise of the root test binary; allocs identical (80 B/2, 288 B/6).Cost on the hot path with the flag wired and down: one nil test and one atomic load of a word written once, per final response; no new allocation. (Not "unchanged": the last interleaved run, main 92eb215 vs this branch, read +3.05% geomean on
ProcessH1Respond884(fused-200 +4.9%, p=0.02; the other arms ~), the earlier run -4.1%; the machine's noise is of that size. Allocs identical.)Draft; not for merge. Milestone v1.6.0.
CI note
The first two heads failed Coverage / Unit steps because the new tests assumed 2 ring workers: with the runner's 8 MiB memlock io_uring starts 1 worker, and a gated sync handler (or an inline "async" test handler) then holds the only ring so neither GOAWAY nor the flag could be seen. Fixed (soft barrier in the wire test;
/gateis a real Async route in the engine tests) and reproduced/verified locally with--ulimit memlock=8388608(workers=1). Head bc3df25 passed all 29 checks; the head after the review fixes is in the PR.