Repository navigation
engines: ReadTimeout and IdleTimeout skip an HTTP/2 conn that has a stream open, proposal with the policy decision pending (v1.6.0, Refs #889) - #1134
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
l19-h2-timeouts
branch
from
October 27, 2026 06:56
e5d36b4 to
ad467ed
Compare
…IdleTimeout on epoll, adaptive and io_uring (red on main; v1.6.0, Refs #889) Raw h2c client that sends nothing after its request: epoll and adaptive close the conn at the timeout (1.004 s with 1 s) under a stream that sends every 200 ms, io_uring when the gap between two chunks is longer than the timeout. std keeps the stream. Guards: a conn without a stream is still reaped by each timeout alone (preface only, SETTINGS only, partial frames, finished stream, client RST_STREAM), and a client that pings keeps the stream.
…tream open (epoll, io_uring, adaptive; v1.6.0, Refs #889) The timeout sweep measured both from the last read on the conn whatever streams it had, so an h2c SSE stream whose client sent no frame was closed at ReadTimeout (1.004 s with 1 s). H2State.ActiveStreams (the stream manager's CountActiveStreams, an atomic load) is looked at once ReadTimeout or IdleTimeout is due, under detachMu as the h1-to-h2c upgrade writes h2State off the loop thread. A conn with no stream open is closed as before.
… IdleTimeout now skip (v1.6.0, Refs #889)
…held lock and a read under -race (v1.6.0, Refs #889)
FumingPower3925
force-pushed
the
l19-h2-timeouts
branch
from
October 27, 2026 07:22
ad467ed to
7d1368b
Compare
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…hole and sends nothing is closed by ReadTimeout (red on the proposal; pinned: a pinging client is not bounded; v1.6.0, Refs #889) 6 cells fail on the previous commit (3 native engines x sync and async routes): a POST without END_STREAM and a silent client holds the conn for good once ReadTimeout skips every conn with a stream open. The 6 cells with a client that pings every 500 ms pass, and pin that main does not bound that either (measured on f4ae3ad: open past 6 s on epoll, adaptive and io_uring).
…streams all have a whole request (A1; v1.6.0, Refs #889) A second atomic count in the stream manager, requestOpen (Open and HalfClosedLocal streams), moved with activeStreams in updateActiveCount, the one place a state change reaches them. The sweep's look is H2State.ServerOwes: a stream open and none with a request still owed by the client. A client that opened a POST and sends nothing is closed by ReadTimeout again. Two atomic loads per exempt conn per sweep, no scan. Unit tests of the counts (state changes, delete, reset, duplicate Closed) and of the sweep's look with a request open and whole; the benchmark fixture uses the new OpenStreamForTest seam.
…othing is closed by ReadTimeout (red on the previous commit; v1.6.0, Refs #889) 12 cells fail on the previous commit (3 native engines x sync and async routes x a 1 MiB and an 8 MiB response the client grants no window for): the response tail is buffered for the client's WINDOW_UPDATE and nothing else bounds it (measured on main with ReadTimeout and IdleTimeout off: open past 8 s).
…ponse waits for the client's window (A2; v1.6.0, Refs #889) ServerOwes also needs outboundHeld (the bytes the streams buffer for the peer's window, already charged and refunded where the buffer changes) and sendWindowWaiters (pool handlers waiting in AwaitSendWindow) to be zero: then ReadTimeout measures 'the client owes a WINDOW_UPDATE and sent nothing'. Two more atomic loads per exempt conn per sweep; no scan of the streams.
… ends, and the client's next request is served (red on epoll and adaptive; v1.6.0, Refs #889) On the previous commit epoll and adaptive close the conn 4 to 20 ms after a 2.5 s stream ended (ReadTimeout or IdleTimeout 1 s: the clock is the client's last frame), with no GOAWAY: 8 cells fail (the next request 400 ms later gets EOF; the conn is not kept for the timeout). io_uring, whose completed send restamps the clock, passes (1.02 to 1.06 s).
…eam was last seen (A3; v1.6.0, Refs #889) When a sweep finds an exempt conn it notes the time in its own field (epoll: detectDeadline, free once the protocol is detected, since connState has no room left in its size class; io_uring: h2OwedAt). A conn due for ReadTimeout or IdleTimeout with no stream owed is kept until the timeouts are due from that time, so a client whose long stream just ended can send its next request on the conn, as with net/http's idle timer, which starts when the last stream closes. Not lastActivity: it is the sweep's write clock on both engines. Only the cold path (a conn already past a timeout) reads the field.
… in one section, a race test of the lock-free read after the upgrade, the final ReadTimeout godoc (v1.6.0, Refs #889)
This was referenced Oct 27, 2026
…nts twice (red; v1.6.0, Refs #889) handleGoAway reads the stream's state and then stores Closed; a pool handler's SetState(Closed) is a Swap without the manager's lock. If it lands between the two, both lower the counts for one stream and the bystander's are lowered too (requestOpen can read 0 while another stream's request is still owed). A race test with a spinning goroutine fails in 5 of 5 runs at -cpu 2.
SetState and takeLocked swap the state in; handleGoAway read it and stored Closed, which could lower the counts a second time for a stream a pool handler had just closed. The race test of the previous commit passes (5 of 5).
…at a send does on io_uring, the pinned ping cells name their tracking item (v1.6.0, Refs #889)
…that nothing releases it (v1.6.0, Refs #889)
FumingPower3925
marked this pull request as ready for review
October 27, 2026 17:15
This was referenced Oct 28, 2026
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 L19 H2-TIMEOUTS of v1.6.0. Refs #889, Refs #885, Refs #884.
Draft. Nothing in the trackers is ticked or edited. The policy is decided: ship A + A1 + A2 + A3 (the smallest combination that is no worse than main for any client, the recommendation of Opus r1), and build per-stream deadlines (option D below) as the next piece of work, before the tag. The branch is the proposal (A) and three refinements (A1, A2, A3), each a pair of commits (tests first, then code). Opus r2: approve-with-fixes, no blocker or major; its fixes are the last commits (F1 to F6 below).
Found by the 889.34 probe of PR #1131 and reproduced by its review. The second defect it uncovered, io_uring's write clock, is its own PR: #1136.
Method: the defect was reproduced on main f4ae3ad on every engine with a failing test (commit 1); every number below has a log behind it (docker
golang:1.27.3,--cpus 2,--security-opt seccomp=unconfined --ulimit memlock=-1, uid 1000, linux/arm64, kernel 7.0.12-linuxkit; io_uring ran,tier=high; adaptive ran on epoll). Logs are in the lane's scratch (logs/), cited by name.The defect and the rule it gets
The timeout sweep of epoll, io_uring and adaptive applied ReadTimeout and IdleTimeout, measured from the client's last read, to an h2 conn whatever streams it had open: an h2c SSE stream whose client sends no frame was closed at the timeout (1.003 to 1.007 s with 1 s on epoll and adaptive; io_uring when the gap between two server writes exceeds it). std keeps the stream.
The rule this branch builds, by steps:
AwaitSendWindow): the client owes a WINDOW_UPDATECommits (base f4ae3ad)
94dfe2etests,18f7a0dthe change (H2State.ActiveStreams,h2StreamOpenin both sweeps),055f5dcbenchmark,7d1368bunit tests6e8047ctests,09621bfcodelogs/red-a1-evidence.log)f01b84ctests,997df26codelogs/red-a2-evidence.log)0e6b88etests,4a5e435codelogs/red-a3-evidence.log)cf9e09f200cef0);f8b4789race test, then the one-Swap fix inhandleGoAway(F2); godoc and pinned-test comment (F1, F4); this body (F3, F5, F6)Dropping a refinement: drop its two commits and, for A1 and A2, change the one expression they edited (
Manager.ServerOwes, one clause per line, and the call sites that A1 renamed fromActiveStreams() > 0; the renamedh2ServerOwesand the benchmark fixture are A1's).What changed in each sweep
internal/protocol/h2/stream/manager.go:requestOpen atomic.Uint32, moved withactiveStreamsinupdateActiveCount(the one place a state change reaches them) and inTryOpenStream;Manager.ServerOwes()=activeStreams > 0 && requestOpen == 0 && outboundHeld == 0 && sendWindowWaiters == 0.outboundHeld(bytes buffered for the peer's window) andsendWindowWaitersalready existed and are maintained where the buffer is charged and refunded; four atomic loads, no scan of the streams, no lock.internal/conn/h2.go:H2State.ActiveStreams,H2State.ServerOwes, andOpenStreamForTest(a seam for the engines' sweep tests, asEnqueueWriteis).checkTimeoutsand io_uringcheckTimeouts: the Idle and Read checks becometimeoutsDue(elapsed); only once due the sweep callsh2ServerOwes(cs): exempt (and noted, A3) if the server owes, skipped if the look could not be made, otherwise closed unless the timeouts are not due from the time a stream was last owed (A3).h2ServerOwesreadscs.h2StateunderdetachMu(the h1-to-h2c upgrade writes it off the loop thread) withTryLock; with the lock held epoll reads the switch flag and the busy test in oneasyncInMusection (F3 of the review) and, if switched, the pointer without the lock (ordered by the flag); io_uring skips the conn, as itssnapshotH1Deadlinesalready does. The WriteTimeout arm is untouched.detectDeadline, which is free once the protocol is detected (connStateis at its 448-byte size class,TestConnStateStaysInItsSizeClass; accessorsh2OwedAt/noteH2Owedcheckdetected); io_uring has its ownh2OwedAt. NotlastActivity: it is the write clock of the sweep on both engines, and restamping it would loosen io_uring's WriteTimeout.config.go: theConfig.ReadTimeoutgodoc states the final behaviour of the combination (the exemption, A3, what is not bounded);IdleTimeoutcross-refers to it. It changes with the decision.Failing on main: reproduction per engine
Raw h2c client (
x/net/http2framer): after its request it sends nothing. Async route, a 12-byte chunk every 200 ms ("steady") or every 2 s ("quiet"); the timeout under test is 1 s, the others off. Request to server close (logs/red-main-final2.log):io_uring is spared on the steady stream because a completed send restamps its clock. On the branch every cell is open for the whole hold.
Tests
test/server, no helper of #1132/#1133, no skip (CI skip allow-list untouched),engines761+startServerConfig761:h2_conn_timeouts_889_linux_test.go(A): steady and quiet streams not cut (read, idle, and the write guard on the native engines); a conn without a stream is still reaped by each timeout alone (56 cells: preface only, SETTINGS only, partial frames, HEADERS without END_HEADERS, a finished stream, a client RST_STREAM, 101 streams against MAX_CONCURRENT_STREAMS and a reset of all); a client that pings keeps the stream (option B's premise).h2_silent_streams_889_linux_test.go(A1, A2): the silent or dead clients, 18 cells = 3 native engines x sync and async routes x {POST without END_STREAM, a 1 MiB and an 8 MiB response the client grants no window for}, ReadTimeout 1 s, IdleTimeout off, WriteTimeout 2 s: closed at about the timeout. And a pinned test, 6 cells: a client that sends a PING every 500 ms keeps an unfinished POST and an unread 1 MiB response open past 6 s on all three native engines, as on main; it passes before and after, and changes when per-stream deadlines exist.h2_conn_after_stream_889_linux_test.go(A3): a 2.5 s stream ends; 400 ms later the client's next request on the same conn is served, and the conn is kept for about the timeout and then reaped (12 cells, read and idle).h2_server_owes_889_linux_test.goin both): answers with a request open and whole, with and withoutdetachMu; the held lock (handler running on HTTP/1, other holder, switched to h2); race tests: a writer ofh2StateunderdetachMu, and the real upgrade order (writeh2State, seth2SwitchedunderasyncInMu, keepdetachMu) read lock-free. Stream package: the counts through every way a stream ends (state change, delete, reset, duplicate Closed, a buffered tail dropped with its stream, a waiting handler).Local (branch head
cf9e09f): newtest/servercells 115 of 115 green, 0 skips (logs/final-new-cells.log); with-racethe same set and then-run 'H2|Shutdown|Detach|Hijack|SSE'green (logs/race2-testserver.log, 124 s and 490 s);go test -raceof./internal/engine/epoll/...,./internal/conn/...,./internal/protocol/...ok (logs/race2-epoll-conn-protocol.log, epoll 245 s) and./internal/engine/iouring/...ok (logs/race2-iouring.log, 485 s);mage apiCheckok;GOOS=windowsanddarwingo vetof./internal/conn/... ./internal/protocol/... .ok;golangci-lintclean on the new code (threeerrcheckhits in files this PR does not touch, a cross-GOOS artefact). Earlier, on A alone: wholetest/serverandtest/...green (689 s).Mutants (each applied, the named tests run, file restored)
h2StreamOpennever exempts (= main)logs/mutant-m1-never-exempt.log)logs/mutant-m2-exempt-every-h2.log)h2Stateread beforedetachMuDATA RACEin both packages under-race(logs/mutant-m3-read-outside-lock.log)updateActiveCountnever lowers the countlogs/mutant-m4-count-never-lowered.log)ServerOweswithout therequestOpenclauserequest-opencells (logs/mutant-a1-no-request-clause.log)outboundHeldandsendWindowWaitersclauseslogs/mutant-a2-no-outbound-clause.log)logs/mutant-a3-no-restart.log)h2Statemade before the flag is seenDATA RACE(logs/mutant-m6-switched-read-unordered.log)io_uring's A3 cannot be observed by an end-to-end test: a completed send restamps its clock, so a stream that ends with a send ends with a fresh clock (measured: 1.0 to 1.7 s after the end, the spread being the idle worker's sweep cadence); it is covered by reading and by sharing the code shape with epoll.
Decision pending: ReadTimeout and IdleTimeout on h2 connections with active streams
The maintainer has decided (A + A1 + A2 + A3, then D). What the measurements say, with the correction the review forced:
What main's read sweep actually bounds. Only a silent or dead peer. A client that sends any frame once per ReadTimeout restarts the conn clock. Measured on main f4ae3ad, ReadTimeout 1 s, WriteTimeout 2 s, a PING every 500 ms (
logs/ping-attacker-main.log,TestZZRevPingAttacker889of the reviewer, run again here on all four engines):So "A opens a slowloris hole main does not have" was overstated: main has no per-stream bound either; an active attacker passes it for one frame per ReadTimeout (bounded by MAX_CONCURRENT_STREAMS x the connection count). What A alone loses is the reaping of silent or dead peers with a stream open:
request-openand the response cells above go from closed at 1 s to open for good (checked withReadHeaderTimeout2 s as well: still open;logs/probe-silent-readheader2s-branch.log). A1 + A2 give that reaping back (the 18 cells above,logs/final-new-cells.log) while every stream the server is writing to stays open.Options
logs/red-a3-evidence.log).TestH2ReadTimeoutCountsAnyClientFrame889passes on main on all four engines. The B that would change something is a server PING keepalive on conns with a stream open; it would reap dead peers only (an attacker ACKs a PING), not slowloris. Not built.Release-note wording if the combination is chosen: say plainly that a client that sends any frame once per ReadTimeout can hold streams open, as it can on main, bounded only by MaxConcurrentStreams x the connection count.
Benchmark note: the sweep, not a request path
ActiveStreams,ServerOwesandh2ServerOwesare reached only fromcheckTimeouts(epoll: at most every 25 ms; io_uring about 50 to 100 ms) and only for a conn past ReadTimeout or IdleTimeout. A1 does touch the h2 stream-state path: the counters are maintained inupdateActiveCount, which the state changes already called: one more atomic add inTryOpenStreamand one more atomic transition (a CAS loop) when a request completes (Open to HalfClosedRemote), per request. The in-memory h2 request benchmarks, 12 rounds interleaved main, branch (cf9e09f) and an A/A twin (logs/bench5/table.md), medians and minimums, branch vs main (the twin vs main in brackets):InlineDispatch893+4.1%, +0.7% (twin -1.4%, -1.9%; about 4 ns on 102),AsyncRequest951+2.2%, -6.3% (twin +6.7%, -5.7%),PoolDispatch-0.7%, +4.1% (twin -3.0%, +6.5%),ProcessH2Headers889/single-2.1%, +8.2% (twin +1.9%, -2.3%),/pipelined8-3.9%, +10.6% (twin +1.7%, +12.3%),StreamedResponse889/closed-5.8%, +7.3% (twin -0.7%, +10.4%),/unclosed-3.7%, +3.5% (twin -2.4%, +15.6%); allocations unchanged. TheInlineDispatch893samples overlap completely (main 88.7 to 140.3 ns/op, branch 89.4 to 132.6, twin 87.0 to 138.3), so the +4.1% median is not resolvable from this data, in either direction, and "two atomics explain it" is not claimed. What holds is the bound by construction: one atomic add (TryOpenStream) and one compare-and-swap (Open to HalfClosedRemote) per request, uncontended, on a path of about 100 ns in this micro-benchmark and microseconds per real request. Measured on arm64 only (the Mac); a LOCK-prefixed operation costs more on amd64 and is unmeasured: native amd64 was not available (docker amd64 emulation is not a timing source), so the cluster P-run covers it. The minimums of this busy host move 10 to 15% between twin and main. Not claimed: no cost on the h2 request path.Docker
--cpus 2(the Mac was busier than in the first round: main medians are 20 to 30% higher), 12 rounds, interleaved main (f4ae3ad), A (7d1368b), A+A1+A2+A3 (cf9e09f) and an A/A twin of main (a copy of main's binary), the order rotated,-benchtime 200ms; ns per whole sweep; medians and minimums; full tablelogs/bench3-table.md:CheckTimeouts865/sync(existing)CheckTimeouts865/asyncH2889/h1-within/sync(defaults set, nothing due)H2889/h2-within/sync(h2 conns, a stream open)CheckTimeouts888/attached(existing)H2889/h1-withinH2889/h2-withinThe within rows never run the new code (the conn is not past a timeout); their differences are layout and noise, inside the twin's spread (the iouring medians move up to 8% between twin and main). Not claimed: a speedup; and a copy of main's binary is a floor for run-to-run noise, not for code-layout effects of a rebuild.
The cost that is new is per exempt conn per sweep (past the timeout with a stream owed: on every sweep for the stream's life), extra ns per conn over
h2-withinin the same runs, medians (minimums in brackets):OutboundPending)The counter version costs 1.6 to 4.1 ns more per exempt conn than A (the two extra atomic loads and the A3 store), against 32 to 43 for the scan. At 10 000 exempt conns on a loop and 40 sweeps a second: 0.5% of a core on epoll (12 ns x 10 000 x 40).
Findings, not fixed here
TryBufferOutboundbuffers a body of any size when the conn holds nothing) has no WriteTimeout bound on the native engines; measured on main with ReadTimeout and IdleTimeout off and WriteTimeout 2 s: the 1 MiB and 8 MiB cells stay open past 8 s on epoll, adaptive and io_uring; std resets them at 2.01 s. The read sweep (60 s by default) was its only bound; A2 keeps it.WriteTimeoutcuts a quiet h2 stream at a chunk: its own PR, iouring: the write clock of a conn starts when its send starts, so a quiet h2 stream is not cut at a chunk (v1.6.0, Refs #889) #1136 (found by this PR's quiet-stream test failing in CI once, https://github.com/goceleris/celeris/actions/runs/40464118254; the write arm of that test was removed from this PR).ReadTimeoutdoes not reap an idle h2 conn;IdleTimeoutdoes; the reap test has no std/read cell for that reason. std's idle timer, measured, closes a conn 2.00 s after the end of a stream with IdleTimeout 1 s.sse_detach_deadlines_855_linux_test.go(WriteTimeout arm only) can add the read and both arms back once the decision lands; this PR does not touch that file.For the reviewer to attack
h2OwedAtindetectDeadline(guarded bydetected): is there a path that setsdetectDeadlineafter detection (adoption, the transplant, the H1 handoff of the adaptive engine)?outboundHeld,sendWindowWaitersorrequestOpenmakesServerOwes()false, so the conn is simply not exempt (back to main); only anactiveStreamsleak with nothing else held would exempt a silent conn for good, and the paths probed (RST of a parked pool handler, of a buffered tail, GOAWAY during SSE, a handler panic after a first DATA) all behave as main.HalfClosedLocalcounted as request-open: a server that answers before the request is whole (early 4xx) keeps the client owing; right?Configgodoc says what the combination does; it must change if the maintainer chooses otherwise.