Repository navigation
engines: HTTP/2 ReadTimeout bounds each request (408 and RST_STREAM), ReadHeaderTimeout bounds a header block, IdleTimeout ignores PINGs on a stream-less conn: stage 1 of the per-stream deadlines (v1.6.0, Refs #889) - #1150
Merged
Conversation
…d io_uring (red on main: 21 cells; v1.6.0, Refs #889) An h2 stream whose request is not whole ReadTimeout after its HEADERS is answered 408 and reset with RST_STREAM(NO_ERROR), absolute from the HEADERS, whatever DATA or PING the client sends; the conn and its other streams go on. Cells: the pinned ping cell flipped (the response cell stays pinned), a DATA dribbler, a slow uploader before and after the deadline, the reset-window scenario of the design review (2100 newer streams and a newer reset, then the expired uploader's DATA and trailers), the mixed cell (quiet SSE and an unfinished request: one stream reset, the conn stays) and IdleTimeout below ReadTimeout. Guards green on main: an upload that completes in time, a 1 MiB upload, a flowing download longer than ReadTimeout, the pinned response cell.
…byte every 250 ms (Refs #889) An uploader that is silent for ReadTimeout is a silent client, and a connection with no stream open that has been silent that long is closed whatever became of its streams (as on main); the cells model an uploader that is slow but sending, which is the case the reset window and the tombstone are about.
…ROR) at the deadline (D1; v1.6.0, Refs #889) A request that is not whole ReadTimeout after its HEADERS is answered 408 and reset with RST_STREAM(NO_ERROR); the conn and its other streams go on. Absolute from the HEADERS, not moved by DATA or PING (std's rule). Armed only for a stream opened without END_STREAM (a GET pays one test that is made anyway), the instant in the unused Stream.StartTimeNs with a flag bit, a per-conn armed count and an earliest-deadline atomic hint: no table, no lock, no allocation. Disarmed at END_STREAM (zeroing StartTimeNs so the handler's latency does not include the upload), on a reset, a delete, a GOAWAY. The sweep (both engines, a pass over h2Conns before the conn scan) reads one hint per h2 conn; when it is due, H2State.Sweep takes H2State.mu with TryLock, walks the streams under the manager's lock and expires them through the inline writer. An expired stream is remembered until its client ends it (markResetOpen and a tombstone, 2 x ReadTimeout at most, 1024 entries at most): the DATA and trailers in flight are ignored and credited whatever the age of the stream, never GOAWAY(PROTOCOL_ERROR) as when the 2048-id reset window has forgotten it. ServerOwes no longer consults requestOpen when there is a request deadline (A1 retired for ReadTimeout > 0): a quiet client got the whole connection closed, the streams the server was answering included, where the deadline resets one. After an expiry the A3 stamp is cleared so the conn is not kept another timeout.
…ter its first frame ends the h2 conn with GOAWAY(ENHANCE_YOUR_CALM) (red on main: 3 cells; v1.6.0, Refs #889) HEADERS without END_HEADERS and one byte of CONTINUATION every 300 ms, which restarts the connection's clock each time: on main the conn is still open 5 s later on epoll, adaptive and io_uring (the cap of 32 CONTINUATION frames would end it at 9.6 s). Guards green on main: a fragmented block completed at once or after 0.5 s is served, and with ReadHeaderTimeout off (-1) a dribble is left alone.
…YOUR_CALM) and close (D3; v1.6.0, Refs #889) HEADERS without END_HEADERS arms a deadline in the processor (one atomic store, only for a fragmented block; the CONTINUATION frames that follow move nothing); the block completing, or the conn ending, disarms it. The same sweep pass reads it with the request-deadline hint and, past it, sends GOAWAY(ENHANCE_YOUR_CALM) naming the last stream the server opened and closes the conn once the GOAWAY is out (epoll closeWhenFlushed, io_uring closeConn after flushSend): HPACK state is the connection's, a half-read block cannot be skipped, and the peer was slow, not malformed.
…eTimeout (red on main: 9 cells; v1.6.0, Refs #889) net/http's idle timer runs from the last stream's close and no frame touches it; on the native engines every read restamped the conn's clock, so a client that only pings held a bare connection for ever: after a request, from the start, and after an expired request, IdleTimeout 1 s and a PING every 300 ms. Guards green on main: a client using the conn is not idle, IdleTimeout off leaves a PING-only conn alone (pinned: ReadTimeout is a request's deadline), and an uploader still sending DATA for the stream the server reset keeps the conn.
…n with no stream open, a PING does not move it (D0; v1.6.0, Refs #889) The sweep pass (H2State.Sweep) notes, per h2 conn, the last client stream ID it saw, whether a stream is open, and whether a frame of a reset or expired stream was ignored (a cold flag): any of them is stream activity and restarts the idle period; a PING, a SETTINGS or a connection WINDOW_UPDATE is none, as net/http's idle timer, which no frame touches, ignores them. IdleTimeout past that closes the conn (it is IdleTimeout alone, as net/http's: ReadTimeout is a request's deadline and an h2 client that holds an idle conn with PINGs under the default IdleTimeout of 600 s is left alone as before). Costs no counter on the request path: HEADERS already store the last client stream ID; the pass reads it and the open count. The conn's own clock, the last read, still closes a silent conn at ReadTimeout or IdleTimeout as before.
…ock, the idle clock and PINGs, no lock when nothing is due (v1.6.0, Refs #889)
…a body through ProcessH2 (v1.6.0, Refs #889)
…e the per-request deadline, the header-block deadline and the h2 idle clock (comment only; v1.6.0, Refs #889)
…its unfinished request, not another timeout later (Refs #889)
… reset on io_uring, whose clock a completed send restamps (Refs #889)
…with the reset of the request that held it (Refs #889)
…ne hints are one struct in H2State, beside the idle clock's state, which is read every 100 ms (v1.6.0, Refs #889) First measurement of the pass over h2Conns: 10.7 ns per h2 conn per sweep on epoll (12.7 us to 56 us for 4096 conns): the manager, the processor and H2State are three heap objects and the pass read three cache lines of them. The hints (request deadline, tombstones, header block) are now one struct that the conn layer embeds at the head of H2State and points the manager at; the idle clock's look at the manager's counters is made at most every 100 ms. Now about 4 ns.
… the idle-below-read cell (Refs #889)
… (a pinger or a trickle may be writing; found by -race; Refs #889)
|
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! |
# Conflicts: # config.go
…t is still being taken: a slow reader and a pinging client (red: 4 cells, epoll and adaptive; v1.6.0, Refs #889) 8 MiB, a 4 MiB stream window, IdleTimeout 1 s. A client that reads about 1 MB/s and sends WINDOW_UPDATEs gets 5.1 to 5.2 MB and an EOF on epoll and adaptive (origin/main delivers it whole); one that pings every 300 ms and reads after 2.5 s gets 49152 bytes and an EOF (main: whole). io_uring is whole in both. The stream is gone from the manager once its last DATA is in the buffers, and D0 closed the conn with the tail still in the write buffer and the kernel's send queue, and the client's unread frames turn the close into a reset.
…p (red: 3 cells with 8 per sweep, the last of the hundred 0.3 s late on epoll and 1.27 s on io_uring; Refs #889)
…the idle close sends GOAWAY(NO_ERROR); a burst of expiries is answered in one sweep (review r3; v1.6.0, Refs #889) F1b/F1c (the regression): Sweep's CloseNow is a proposal. The engine closes the conn only if it holds no output: the write buffer, the dirty list, EPOLLOUT, a SEND in flight, and the kernel's send queue (TIOCOUTQ, the notion the pool-wait gate uses before it closes a conn; an error counts as 'holds'): the stream is gone from the manager once its last DATA is in the buffers and a close with the peer's frames unread in the receive queue is a reset, which discards the queue. A conn that holds output calls H2State.ExtendIdle: IdleTimeout runs again from there, so D0 does not fight the rate rule of #1119 (it does nothing while a response is parked) and a client that finishes taking its download is not closed on the instant. The smaller safe change was chosen over a GOAWAY and closeWhenFlushed for the held case: a close that waits for the peer to take the bytes would re-implement the closing drain for a conn that is not closing. The close that does go ahead sends GOAWAY(NO_ERROR) first (F6; best effort, TryLock), flushed inline on epoll, staged before closeConn on io_uring. F4: expired requests are answered in batches of 32 under a 2 ms budget until none is due (they were 8 per sweep: the last of 100 was 0.3 s late on epoll, 1.27 s on io_uring). N1: reqArmed, which nothing read, is gone (one atomic add and one CAS fewer per POST). N3: a client RST_STREAM on an expired stream ends its memory. N4: ExpireRequest marks the stream closed on the conn writer as sendRSTStreamAndMarkClosed does. The dangerous leak direction is pinned: with a request deadline every stream whose request is open carries it, and the hint is due by the earliest (TestEveryRequestOpenStreamCarriesADeadline889).
…w pinger, ACK lag) Refs #889
…ess cap) ExtendIdle takes the bytes the conn still holds (buffers plus the kernel's send queue, unsent and unacknowledged) and refuses, so that the engine closes the conn, when the count has not gone down since the last extension: a client that stopped reading, or that pings faster than its round trip, no longer holds a conn with no stream open for as long as it likes. On io_uring the close of a conn with a SEND in flight is not held a further drain bound. Refs #889
…d does in ExpireRequest Refs #889
…m, not the last frame sent Under -race on a loaded runner the 2100 requests take over 100 ms, and the 408 arrived at 0.9 s after the last of them (a CI failure of adaptive/trailers). Refs #889
CI's skip rule allows no skip, and the cell needs root and tc. Refs #889
…ust say GOAWAY, frames on a reset stream are not activity Refs #889
…tes held; io_uring's idle close keeps its GOAWAY; frames on a reset stream are not activity F0 (r5): the held bytes counted the SEND in flight and the kernel's queue for the same bytes on io_uring and went up while a client read; the cap reset a reader at 500 KB/s. ExtendIdle now takes the tcp_info bytes_acked and extends when the peer acknowledged more than the control frames the server wrote since the last extension. No count: extended, as before the cap. F1: only a conn whose extension was refused is reaped at once on io_uring; the idle close of a conn with nothing to send keeps the drain for its GOAWAY. F2: only the DATA and trailers of an expired request (tombstone) are stream activity. F4: the expiry budget is a variable and the unit test counts sweeps. Refs #889
The DATA of an expired request is stream activity for the 2 x ReadTimeout its tombstone lives; the cell watched 3 s after the reset and closed at 3.9 s under -race on adaptive. Refs #889
…ry engine (netem cell) Refs #889
… is refused), an explicit baseline flag, io_uring's refused close gets one sweep for its GOAWAY, the size pin counts the malloc header F7 (r6): ExtendIdle took a 0 acknowledged-bytes count as no count and extended for ever; it is a count unless the host stack does not fill bytes_acked (internal/tcpack: a one-way latch, set by any socket's count above 0 or by a loopback probe once). The baseline is a bool, not max(a,1) (a second 0 wrapped). F9 (r6): closeRefused set the drain clock back past its bound, the same pass reaped the conn before the GOAWAY's SQE was submitted; now the bound runs out at the next sweep. F8 (r6): sizeof(H2State)+8 <= 704. Refs #889
Filled ran the loopback probe inside sync.Once.Do on the first caller, an event loop or worker thread, where a broken loopback could block it (and every other caller behind the Once) for a second or two. Start() runs the probe in a goroutine, once; the engines call it when they are built; Filled and Usable answer from the latch at once (false, the fail-open reading of a 0, until the probe says yes). Refs #889
closeRefused took the drain's baseline sample of the peer's acknowledged bytes and set the drain clock to run out at the next sweep; the ACKs of bytes in flight at the decision still arrive after it, and the next sweep's own sample read them as the peer taking bytes and gave the conn another bound (up to WriteTimeout). The zero-window cell failed once on a CI runner (conn open 9.5 s after). closeRefused now sets closeSampled past the instant of that sample instead; red 10 of 10 before (a sweep that reaps panics on the rig's nil ring, which is the proof). Refs #889
…t; a count that appears late opens no hole Under load the cell failed on io_uring (15 of 20 at 2 cpus, 20 of 20 at 1, -race, 6 busy loops): the 4 KiB buffer was set after the connect, the window the handshake offered stayed, and acknowledged bytes grew by 33 to 65 KB a period for a client that reads nothing, a slow reader by the cap's rule. Set before the connect (as the review's probe did) it passes 60 of 60 at both. Refs #889
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, stage 1 of the per-stream deadlines (the std / net/http model) for HTTP/2 on epoll, io_uring and adaptive: D1 + D0 + D3, with the M2 and M3 changes of the design review. Refs #889.
Draft. Head after review r5. Nothing in the trackers is ticked or edited. Design:
design-per-stream-deadlines.mdv2 (approved by the user after the Opus design review, APPROVE-WITH-CHANGES, M1 to M4); the write deadline (D2, the #1119 minimum-rate rule) is stage 2 and is not in this PR. #1134 (A, A1, A2, A3) is the base this builds on.What it does
ReadTimeoutafter its HEADERS is answered 408 and reset with RST_STREAM(NO_ERROR) (RFC 9113 8.1); the conn and its other streams go on. Absolute from the HEADERS (std's rule and the documented meaning ofConfig.ReadTimeout): DATA and PING do not move it. A request whole at its HEADERS (every GET) has no deadlineManager.ArmRequest/DisarmRequest/ExpiredRequests,Processor.ExpireRequest,H2State.SweepmarkResetOpen,resetIDs, and a tombstone map: allocated on the first expiry, at most 1024 entries, each for at most 2 x ReadTimeout): the slow uploader's DATA and trailers in flight are ignored and credited to the conn's window whatever the age of the stream. Without it a stream older than the 2048-id reset window, after a newer reset, turned the uploader's next DATA intoGOAWAY(PROTOCOL_ERROR, "DATA on idle stream"): every stream of the conn gonedeadline.go,handleData,admitHeadersServerOwesno longer consultsrequestOpenwhen there is a request deadline (A1 retired for ReadTimeout > 0): a quiet client got the whole conn closed, SSE siblings included, where D1 resets one stream. The D pass runs before the conn scan in both sweeps; after an expiry the A3 stamp is clearedManager.ServerOwes,checkTimeoutsReadHeaderTimeout(default 10 s) after its first frame ends the conn: GOAWAY(ENHANCE_YOUR_CALM) and close. A fragmented block that completes in time is servedProcessor.armHeaderBlock,ExpireHeaderBlockIdleTimeoutruns from the last stream activity the sweep saw (a new client stream ID, a stream open, the DATA or trailers of an expired request still remembered, at most 2 x ReadTimeout); PING, SETTINGS, conn WINDOW_UPDATE and frames for a stream the server reset for another cause do not move it (net/http's idle timer is touched by no frame). IdleTimeout alone, as net/http: with it off a PING-only conn is left alone (pinned). The close is a proposal: the engine closes at once a conn that holds no output (write buffers, SEND in flight, and the kernel's send queue byTIOCOUTQ); a conn that holds output gets one more idle period (ExtendIdle(now, acked, ok)) only if the peer acknowledged more bytes since the last extension than the control frames the server wrote in the meantime (tcp_infobytes_acked, the source #1119's rule uses); otherwise (a client that stopped reading, or that pings faster than its round trip) it is closed within a few periods: GOAWAY(NO_ERROR) where it can still be sent, then the close (the tail may be lost). Without an acknowledged-bytes count the period is extended (fail-open, below). Silent conns are still closed by the conn's own clock at min(ReadTimeout, IdleTimeout)H2State.Sweep,ExtendIdleThe native path runs a handler only at END_STREAM (the body is buffered up to
MaxRequestBodySize; no request streaming, bidi or extended CONNECT), so an expired request has no handler to race and the 408 is always possible.std is not touched. celeris std answers 400, not 408, when a request body read times out (
bridge.go131-135:io.ReadAllfails; net/http then resets with RST_STREAM(NO_ERROR)); a 3-lineerrors.Is(err, os.ErrDeadlineExceeded)to 408 is possible, but it is std's behaviour to test and review on its own, so it is a follow-up.Commits (base 3f516f1; tests first, each rule a red commit then its code)
13f5a2d21 red cells,eff6322(uploader trickles)921ca9f502a60a3 red cells3838939213986f9 red cells6954ccc8a2e5c3H2State.Sweepunit tests;98e90d1benchmarks27ef121godoc (comment only)56c9fba,a73dfd4,e1fa329(the silent conn goes with the reset; io_uring's clock; Idle below Read),d7ae9d5,a0b7dea(found by-race: the test client'srequest()writes under the framer's lock),4d1e0f9,f3de4f9(lint)469070dthe sweep's look at an h2 conn is one cache line (10.7 ns to 4.3 ns per conn, below)All new cells against origin/main 3f516f1 (
logs/d1-red-main-final.log): 39 red, 27 green (the guards). Red: the flipped ping cell, a DATA dribbler (absolute), the slow uploader after the deadline, the reset-window scenario (DATA and trailers, 2100 newer streams and a newer reset), the mixed cell (quiet SSE + unfinished request) and Idle below Read, the silent client's conn, the header-block dribble, PING-only conns (after a request, from the start, after an expiry), the uploader after the reset. Green on main and still green (guards): an upload that completes before the deadline, a 1 MiB flow-controlled upload, a flowing 8 MiB download taking 2 s (longer than ReadTimeout), the pinned response cell (a pinging client that never takes a 1 MiB response: still pinned until stage 2, the test comment names it), a fragmented header block completed at once and after 0.5 s, ReadHeaderTimeout off, a busy conn at IdleTimeout 1 s, IdleTimeout off.Cells per engine (epoll, adaptive, io_uring; std unchanged): e.g. ping cell
status="408" reset=true rst=NO_ERROR at 1.0 sand the next request on the same conn served; header blockGOAWAY ENHANCE_YOUR_CALM at 1.00 to 1.02 s; PING-only conn closed at 1.00 to 1.14 s with IdleTimeout 1 s (logs/d1-green-*.log).Review r3 (Opus, REQUEST-CHANGES: one MAJOR, no blocker) and what changed
h2_idle_tail_889_linux_test.go, from the reviewer's probes): on the first head 4 red (epoll and adaptive, both cells; io_uring green) and, with the burst cell, 7 red and 2 green (logs/d1-r3-red.log), after the fix 9 of 9 green (logs/d1-r3-green.log: epoll and adaptive whole at 2.5 s and 8.7 s). A mutant that skips the held-output check: 4 red again (logs/mutant-r3-no-holds-output.log); the PING-only cells (reaped at IdleTimeout) stay green. The fix chosen, and why:Sweep'sCloseNowis now a proposal; the engine closes only if the conn holds no output: write buffer, dirty list, EPOLLOUT,writeSince, and the kernel's send queue (TIOCOUTQ, unsent plus unacknowledged: the notion the pool-wait gate already uses before it closes a conn; an ioctl error counts as "holds"); otherwiseH2State.ExtendIdlerestarts the idle period. This is the smaller safe change: GOAWAY pluscloseWhenFlushedwould re-implement the closing drain for a conn that is not closing, while the check does nothing while a response is parked, so D0 did not fight epoll: the write timeout of a parked response is a minimum rate on acknowledged bytes, no ceiling, inbound bytes are not progress (v1.6.0, Refs #885) #1119's minimum-rate rule (merged, 46753f9: it governs parked responses and the conn scan's exemption); r4 then capped the extension (below), which is a different, weaker test than the rate rule, and a client that finishes taking its download is not closed on the instant (IdleTimeout runs again from the drain). OneTIOCOUTQsyscall, and, when output is held, onegetsockopt(TCP_INFO)(r5), only when a conn is already about to be closed as idle.TryLock, flushed inline on epoll; on io_uring staged and sent through the deferred close, whose drain gives the SEND its time: r5 F1 found that the r4 rework lost it, fixed). The closes of the conn scan (a silent client after the 408) stay bare closes, as on main: the scan readsh2Stateunder the detachMu logic and has many close sites; follow-up (Findings 6).TestH2BurstOfExpiriesIsAnsweredInOneSweep889, 3 red with 8 per sweep,logs/d1-r3-burst-red.log), now all 100 within one sweep (1.014, 1.024 and 1.172 s).reqArmed(written, never read in production) removed: one atomic add and one CAS fewer per POST. N3 a client RST_STREAM on an expired stream ends its memory. N4ExpireRequestcallsMarkStreamClosedon the conn writer assendRSTStreamAndMarkCloseddoes; r4 found that both writer adapters'MarkStreamClosedare empty (internal/conn/response.go~685 and ~1047), so the call is a no-op today: it stays for a writer that records closed streams, the comment now says so, and what keeps a late write from following the reset is the stream's removal from the manager. N2 (a cached clock instead oftime.Now()per POST, wall-clock steps) and N5 to N7 are noted, not changed (bounded, harmless: the reviewer's grading).TestEveryRequestOpenStreamCarriesADeadline889; a mutant that skips the arm for one opener is red,logs/mutant-r3-opener-without-arm.log): a request-open stream without the flag would makeServerOwestrue for it and nothing would ever reset it.x/nethttp2.Transportreturns the 408 to the caller and keeps the conn (all three engines, 1.001 to 1.004 s); curl 8.14.1 (nghttp2) reports408and treats the RST_STREAM(NO_ERROR) as success; the h2c upgrade arms its deadline in the frames trailing the upgrade (408 at 1.0 s, stream 5 served); late frames on an expired stream (DATA, WINDOW_UPDATE, PRIORITY, empty trailers) keep the conn; 100 simultaneous expiries keep the conn with no GOAWAY; the conn window is credited for the ignored DATA (1 MiB upload completes after an expiry).Review r4 (Opus, APPROVE-WITH-FIXES) and what changed
Tests first where a test applies; merged main (a merge commit; nothing rebased).
ExtendIdlerestarted the idle period for as long as the conn held output, so a client that stops reading and keeps pinging held a stream-less conn for ever (the reviewer: 60 s with IdleTimeout 1 s, epoll, adaptive, io_uring; origin/main holds it as long, so not a regression, but D0 is where it is bounded), and a client that reads everything but pings faster than its round trip held it too, because the PING ACKs are always unacknowledged bytes in the send queue (reviewer: 10 s at 300 ms RTT, 200 ms pings, netem). (The measure below is r4's; r5 replaced it, see "Review r5" below, because on io_uring it double counted.) The rule was:ExtendIdle(now, outstanding)remembered the bytes the conn holds (write buffers plusTIOCOUTQ, unsent and unacknowledged; never the unsent-only count) at each extension, and at the next expiry refuses unless the count is lower; the engine then sends GOAWAY(NO_ERROR) best effort and closes (a reset that drops the tail is accepted: the client is not taking it). Stream activity clears the baseline. Bounds, as tested: a reader that does not read, within two idle periods (+ one 100 ms look); a reader at 1 MB/s with pings, the whole 8 MiB (the count goes down at every period); pings faster than the RTT: the count of unacknowledged ACKs goes up and down, never below the last extension for long, so it ends after as many extensions as there are ACKs in flight at most (2 here) and is closed within 4 periods, measured 2.5 s on epoll and adaptive, 3.7 s on io_uring with IdleTimeout 1 s (held 10 s on the previous head). On io_uring the close of a conn with a SEND in flight is deferred behind the drain (closingDrainBound, up to WriteTimeout, celeris#761):closeIdle(nowcloseRefused) sets that clock back ascloseStalleddoes (taking the acknowledged-bytes baseline first, or the sweep's first sample would count as progress and give the bound again), so the conn is reaped at once (in the sweep that refused it; r5 narrowed that to the refused conn).beef41f; the cap isddeada2): zero-window pinger, 3 red on the previous head (open and unread after 10 s on epoll, adaptive, io_uring); ACK lag (-tags netem889: root andtcwith NET_ADMIN, not built in CI: the CI skip rule allows no skip, and it would run nothing there): 3 red (still open after 6 s) on the previous head; both green with the cap. The existing tail cells stay green (the PING one now waits 1.5 s instead of 2.5 s: one idle period, which the cap rightly ends at the second; the 1 MB/s reader whole at 8.7 s).logs/mutant-mc*.log): always extend: 3 e2e + unit red; equal count extends (>for>=): unit red (the e2e cell is not, because the PING ACKs make the count of the zero-window conn go up); baseline never recorded: 3 e2e + unit; activity does not clear the baseline: unit; io_uring close held a drain bound: 1 e2e (io_uring).TestH2BurstOfExpiriesIsAnsweredInOneSweep889/io_uringwas 3 of 10 red under 6 busy loops (spreads 206 to 308 ms against 150 ms). The bound is now 800 ms (what a loaded machine adds, the client's reads included; the old 8-per-sweep behaviour was 1.2 s on io_uring): 30 of 30 green under 6 busy loops (3 engines x 10; the largest spread 0.30 s); the one-sweep semantics are pinned by the unit test.ExtendIdlecomment said the period restarts "once the output has gone"; the code restarts it at once, the comment says so. NIT-a (the io_uring byte count on a fixed-file conn: the ioctl would read a ring index): the conn is not asked (development-only mode,CELERIS_IOURING_FIXED_FILES=1, off by default). r5 turnedoutstanding()back intoholdsOutput(), a yes/no. N4 (above). NIT-c (the hot path section below).41421ba):TestH2ExpiredRequestDataAfterTheResetWindow889/adaptive/trailersfailed once under-racein CI (the 408 at "900 ms"): its lower bound counted from the last frame the client sent, and 2100 requests on a loaded runner take over 100 ms; the bound now counts from the HEADERS of stream 1. 5 of 5 under 6 busy loops with-race.Review r5 (Opus, REQUEST-CHANGES: one BLOCKER, one MAJOR, both in the new D0 cap on io_uring) and what changed
Tests first (
61db56e), the fix after (b6d5d6e), unit tests with it.outstanding()added the in-flight SEND'ssendBuftoTIOCOUTQ, and the two hold the same bytes (the reviewer's trace:sendBufconstant at 2,296,102 for the whole SEND, TIOCOUTQ rising 245 KB when the kernel pulled the next chunk of it), so the count went up while the client read,ExtendIdlerefused, and the close was a reset: a plain client (default buffers, no PING, a WINDOW_UPDATE per frame) at 200 to 500 KB/s was cut at 1 MiB of 4. Fix: the measure is the bytes the peer acknowledged (tcp_infobytes_acked, monotonic, counted once, the source epoll: the write timeout of a parked response is a minimum rate on acknowledged bytes, no ceiling, inbound bytes are not progress (v1.6.0, Refs #885) #1119's rule reads), on both engines (one rule).ExtendIdle(now, acked, ackedOK)extends when the peer acknowledged more since the last extension than the control frames the server wrote to it in that time (ctlOut, counted where the state flushes its own buffer: PING and SETTINGS acknowledgements, WINDOW_UPDATEs): without that floor the PING answers, acknowledged one by one at the pace of the RTT, would count as the client taking the response (the ACK-lag case).TIOCOUTQremains the "does it hold output at all" test, a yes/no, not a sum. A flat acknowledged count refuses (zero window), a growing one extends (any reader).getsockoptfailed, a fixed-file conn, or a 0 on a host whose stack does not fillbytes_acked; see r6 F7 below: a 0 on a Linux stack that fills it is a count): fail-open, the period is extended, as before the cap. Why: closing a conn whose progress cannot be seen is the r3 failure (a reader cut), and the stacks that cannot answer are exactly the ones where that would happen (gVisor); such a conn is bounded by the conn's own clock only, as epoll: the write timeout of a parked response is a minimum rate on acknowledged bytes, no ceiling, inbound bytes are not progress (v1.6.0, Refs #885) #1119's rule falls back to the flush clock. A disagreement with the review (F3 NIT): it called fail-closed after two periods the right policy for a failing ioctl; with the acknowledged count as the measure, an ioctl that fails is the "holds output" answer (yes), and a socket on whichTIOCOUTQfails is one on which the next read or write fails too and closes the conn; closing it earlier for a measurement error trades a bounded hold for a possible cut of a live reader.TestH2IdleClockDoesNotCutAReaderThatReads889): red on ade1035: 1 of 6 (io_uring 4 MiB, cut at 1 MiB), green after; a reader taking 64 KiB every 500 ms with a 4 KiB receive buffer set before connect and a PING every 300 ms, 2 MiB (...BurstyReader889): red on io_uring (cut at 786432), epoll and adaptive green, green after. (My first version of the bursty cell set the buffer after connect and was red on epoll too, for the wrong reason: zero-window probe back-off, no progress for more than a period.) The existing cells stay green: zero-window pinger 3 of 3 (closed at 3.5 s), the 8 MiB PING tail, the 8 MiB reader at 1 MB/s (8.8 to 9.3 s), and the netem ACK-lag cell (-tags netem889) 6 of 6 green (closed after 2.5 to 3.5 s), 6 of 6 red without the control-frame floor.closeIdleset the drain clock back for every idle close, including a conn with nothing to send but its GOAWAY, and the same sweep'sliveConnspass then reaped it and shut the socket before the GOAWAY's SEND was submitted (the reviewer: 0 of 5 on ade1035, 5 of 5 on the r4 head). Now only a conn whose extension was refused (output not going out; a GOAWAY behind a SEND the peer does not take is lost anyway) is set back and reaped at once (closeRefused); every other idle close iscloseConn, which defers behind the GOAWAY's SEND and gives it its drain. Cells:TestH2IdleCloseSendsGoAway889(3 conns per engine) and the three PING-only cells (after-a-request,from-the-start, expired-then-ping), which accepted a bare EOF, now require GOAWAY(NO_ERROR) before the close: red on ade1035: 4 cells (io_uring only), green on all engines after.TestH2FramesOnAResetStreamAreNotActivity889(no :path, no END_STREAM, DATA every 300 ms): red on ade1035 on 3 of 3 engines (open at 6 s), closed at 1.03 to 1.11 s after; units for both sides (TestSweepFramesOnAResetStreamAreNotActivity889). The uploader cell now watches 1.7 s after the reset instead of 3 s (it saw a close at 3.9 s under-race, when the tombstone's 2 s ended; the new rule's consequence, not a regression).internal/conn, e2e = the cells of the table):<for<=)outstanding()comments are gone with it (the engines'holdsOutputis a yes/no; its comment says what a failing ioctl means); the release note's "about two periods" is "a few periods" (zero window: two; ACK lag: up to four, measured 2.5 to 3.5 s with IdleTimeout 1 s); the stale Q3, "staged before closeConn" and "next sweep" lines above are corrected. The netem cell is not run in CI (nothing references-tags netem889: it needs root,tcand NET_ADMIN, and no existing job has them; adding a privileged job is not done here), so it is not CI evidence: its evidence is the manual root-container runs listed in Evidence. The hot words of the sweep's look are the first 40 bytes ofH2State(the size pin is corrected in r6 F8).Review r6 (Opus, APPROVE-WITH-FIXES: F0 and F1 confirmed fixed with the reviewer's own probes; three MINOR and nits)
closeRefusedset the drain clock back past its bound, so the same sweep reaped the conn before the GOAWAY's SQE was submitted (bare EOF, 0 of 3; epoll and adaptive 3 of 3). Now the bound runs out at the next sweep (lastActivity = now - bound + 1): the loop submits the SQE in between, and a zero-window peer is still reaped one sweep later, not after the drain bound. Submitting the ring before the reap was not used (on an SQPOLL tier a submit only wakes the poller). ThecloseRefusedcomment ("lost anyway") was true only for a zero-window peer and is rewritten. Cell: the netem ACK-lag cell now requires GOAWAY(NO_ERROR) before the close on every engine (-tags netem889, manual): red on ebf390e: io_uring 3 of 3 (bare EOF at 2.50 to 2.52 s; epoll and adaptive green), green after: 15 of 15 (5 rounds x 3 engines, GOAWAY seen every time, closed after 2.4 to 3.5 s). Not CI evidence.bytes_acked == 0let a peer with its own TCP stack that never acknowledges data (or advertises a zero window from the start) hold a conn whose output fits in the kernel's send queue for ever; on Linux since 4.1 a 0 is a real count (a fresh socket reads 0: the passive side does not count the SYN-ACK). Now 0 is a count, unless the host's stack is known not to fill the field:internal/tcpackanswers that once per process, latched one-way (any socket that has reported a count above 0, or a loopback probe: one byte, the writer'stcp_inforead for up to 100 ms), both engines. The probe runs in a goroutine and is never waited for (tcpack.Start, called when an engine is built;FilledandUsableanswer from the latch at once, so an event-loop or worker thread never blocks on a broken loopback; until the probe says yes, a 0 reads as "no count", the behaviour before the cap). The first version ran the probe insidesync.Once.Doon the first caller, a loop thread: found by the coordinator's read of the diff. The baseline is an explicitd0Hasflag, not themax(a, 1)marker (a second 0 wrapped to a 4 GiB "progress" and extended). Fail-open stays for a failedgetsockopt, a fixed-file conn and a host that does not fill the field; the godoc and theExtendIdlecomment say so. Tests: unit (a-zero-count-is-a-count: the first extension is given, the second at 0 refused) andtcpack(the latch is one-way, the probe runs once, an error is never usable, the probe sees this host's stack, a probe that blocks on a channel does not blockUsable/Filledcallers and the latch sets when it completes, a probe that never completes leaves a 0 as "no count"; red against the synchronous version: 2 tests). No e2e cell (a never-ACKing peer needs a user-space TCP stack; not built), so this is pinned by the units, said plainly. The unit cell cannot be red on ebf390e as an assertion (it references the new flag: a compile failure there); the old behaviour is demonstrated by the marker mutants below.H2Statecosts 704 (main's 560 costs 576), and the edge is 696, not 640. So this PR moves every h2 conn'sH2Statefrom 576 to 704 bytes (+128 B), small next to a conn's buffers but not "staying in its class" (the earlier body lines and test comment said so; corrected). The test now pinssizeof + 8 <= 704. Stage 2 has 56 bytes of room inH2State. The same arithmetic forStream(1024 costs 1152, 120 bytes free) is in the design note (the existingStreampin is h2: end or reset a stream whose handler returns without finishing its response; log and count a Goexit #1030's and is not touched here).tcpack.Usable):tcpackunit; the latch never set:tcpackunit; the oldmax(a, 1)marker (two forms): unit;acked == 0is no count inExtendIdle: unit.Evidence
CI failure at 7392da9 and what it was (
TestH2IdleClockClosesAConnWhoseClientStopsReadingButPings889/io_uring,-race, a GitHub runner; the zero-window cell, conn still open 9.5 s after): not the async probe and not a hole. Reproduced under load (6 busy loops,-race): before, io_uring 15 of 20 red at 2 cpus and 20 of 20 at 1, also on ebf390e (5 of 10), so older than the probe. A trace of the judgements (holds true, the count readable every time, so the latch and the probe were not involved) showed the client's acknowledged bytes growing by 33 to 65 KB per period while it read nothing: the cell set its 4 KiB receive buffer after the connect, the window the handshake had offered stayed, and under load the kernel went on accepting bytes; by the cap's rule (more acknowledged than the control frames) that is a slow reader, and the cap extended it as designed. The cell now sets the buffer before the connect (as the reviewer's probe does): 60 of 60 green on all three engines at 2 and at 1 cpu under the same load. Also from this:closeRefusedno longer lets the next sweep take its own sample of the peer's acknowledged bytes (closeSampledset past it): the ACKs of bytes in flight at the decision arrive after it and the sample gave the conn another drain bound; a unit test with a real socket pair (TestRefusedCloseIsNotRescuedByAStragglerAck889) is red 10 of 10 before, green 10 of 10 after; this was a real race, found while looking, but not the CI failure (the loaded rate did not move with it: 13 of 20 red after). A unit subtest pins that a count appearing late (probe answered mid-life) opens no hole.After the probe change (tcpack in the background; head below):
internal/conn,internal/tcpackandinternal/protocol/...-race -count=5ok; the changed cells-race -count=5 -cpu 2ok (601 s);./internal/engine/epoll/...ok (372 s) and./internal/engine/iouring/...ok (486 s) under-race; lint v2.14.0 0 issues (also-tags netem889);GOOS=windowsanddarwinvet ok;apidumpok; size tests pass (logs/r7-verify.log). The r6 numbers below are for the head before it; no other code changed.After r6 (head below): the new and changed cells (the r5 pattern plus the GOAWAY cells)
-race -count=5 -cpu 2ok (606 s: the zero-window cell, the reader cells, the GOAWAY cells and the PING-only cells 5 times each);./internal/conn,./internal/tcpackand./internal/protocol/...-race -count=5ok;./internal/engine/epoll/...ok (400 s) and./internal/engine/iouring/...ok (491 s) under-raceonce; the wholetest/serverH2|889set non-race: 1281 PASS, 0 FAIL, 0 SKIP (logs/r6-verify.log); the netem cell (manual, root container, not CI) 15 of 15 green without-raceand 9 of 9 with it (GOAWAY seen every time, closed after 2.4 to 3.7 s); golangci-lint v2.14.0 0 issues (also-tags netem889);GOOS=windowsanddarwingo vetok;apidumpok (53 packages); size tests pass (connState 448, Stream 1024, H2State sizeof + 8 <= 704);BenchmarkSweepH2Deadlines889(epoll, 4096 conns, nothing due), interleaved with the r5 head, 3 rounds: 28.8 to 36.9 us against 30.1 to 35.5 us (the machine was shared; no difference).After r5 (head below): the new and changed cells (
TestH2IdleClock*,TestH2IdleClose*,TestH2FramesOnAReset*,TestH2Burst*,TestH2PingOnly*,TestH2ExpiredRequest*,TestH2Uploader*)-race -count=5 -cpu 2ok (604 s; the first such run found the uploader cell's bound, above);./internal/connand./internal/protocol/...-race -count=5ok;./internal/engine/epoll/...ok (396 s) and./internal/engine/iouring/...ok (488 s) under-raceonce; the wholetest/serverH2|889set non-race: 1281 PASS, 0 FAIL, 0 SKIP (logs/r5-verify.log,logs/r5-full.log); the netem cell (-tags netem889, root container with NET_ADMIN, manual, not CI) 5 of 5 rounds x 3 engines green (closed after 2.35 to 3.56 s, IdleTimeout 1 s); golangci-lint v2.14.0 0 issues (also-tags netem889);GOOS=windowsanddarwingo vetok;apidumpok (53 packages); size tests pass: connState 448, Stream 1024, and a new one pins H2State at 640 (a malloc size class).BenchmarkSweepH2Deadlines889(epoll, 4096 conns, nothing due): 28.7 to 29.6 us, 256 conns 1.33 to 1.43 us, where r4 recorded 31.2 us and +2.9 ns per conn: the sweep's look is unchanged by the rework.After r4 (head below): the new and changed cells (
TestH2IdleClock*,TestH2Burst*)-race -count=5 -cpu 2ok (152 s);./internal/connand./internal/protocol/...-race -count=5ok;./internal/engine/epoll/...ok (396 s) and./internal/engine/iouring/...ok (487 s) under-raceonce (logs/r4-verify.log); the wholetest/serverH2|889set non-race: 1262 PASS, 0 FAIL (and 1 SKIP, the netem cell, which then skipped without its env; it is now behind-tags netem889and not built in CI; run separately in a root container with NET_ADMIN: red 3 of 3 on the previous head, green 3 of 3 and then 12 of 12 more, closed after 2.4 to 3.6 s; one earlier run failed in its setup under netem without the cell's own message;logs/r4-full.log); the burst cell 30 of 30 under 6 busy loops; golangci-lint v2.14.0 0 issues (internal/conn, protocol, engine, test/server, root);GOOS=windowsanddarwingo vetok;apidump(whatmage apiCheckruns) ok, 53 packages;TestConnStateStaysInItsSizeClass(448) andTestStreamStaysInItsSizeClass889(1024) pass.test/servercells 75 of 75 green on the branch (all three native engines; 66 before r3, 9 added): the first 66-race -count=10 -cpu 2ok (757 s,logs/d1-race10.log; its first run found a race in the test client, fixed ina0b7dea); after r3 the new and changed cells-race -count=5 -cpu 2ok (396 s,logs/d1-r3-race.log). Unit tests (internal/protocol/h2/stream,internal/conn):-race -count=5ok.-raceonce, after r3 (logs/d1-r3-race.log):./internal/protocol/...,./internal/conn/...ok;./internal/engine/epoll/...ok (405 s);./internal/engine/iouring/...ok (492 s). Main (with epoll: the write timeout of a parked response is a minimum rate on acknowledged bytes, no ceiling, inbound bytes are not progress (v1.6.0, Refs #885) #1119 and cache: the Store.Set default bound is 1 second (v1.6.0, Refs #888) #1151) is merged in. Wholetest/servernon-race: ok (817 s) before the last commits.TestConnStateStaysInItsSizeClass(448 bytes) and theStreamsize test (1024 bytes) pass: nothing grew..;GOOS=windowsanddarwingo vetof./internal/conn/... ./internal/protocol/... .ok;mage apiCheckok (theConfigchange is comment only).Mutants (each applied, the named tests run, file restored;
logs/mutant-md*.log)ServerOwesconsultsrequestOpen(M2)Hot path and cost
A GET pays one test (the
endStreamtestadmitHeadersmakes anyway); nothing else on its path.TestRequestDeadlineArmedOnlyWhenTheRequestIsNotWhole889,TestRequestDeadlineNeverAllocatesOnTheRequestPath889(0 allocs for arm and disarm, one flag load for a stream that never armed). A POST with a body pays, at its HEADERS, one clock read, a plain store, a flag Or and the earliest-deadline hint (a CAS loop), and at END_STREAM a flag And;StartTimeNsis zeroed at the disarm so the handler's latency does not include the upload. No lock, no table, no allocation. (The armed-request counter, with its atomic add and CAS, was removed by N1.)Measured by the r4 reviewer, natively on arm64 (Mac, go1.27.3, 15 rounds interleaved over 5 binaries, medians vs main; an A/A copy of main as the control):
H2Config{})ProcessH2Headers889/single(GET, main 394 ns)ProcessH2Headers889/pipelined8(main 3132 ns)ProcessH2Post889/single(POST with a body, main 619 ns)ProcessH2Post889/pipelined8(main 4994 ns)An earlier 2 x 5-round run: POST +5.7% and +5.4% (A/A +1.0% and -0.5%). So the cost of the feature is about 30 to 45 ns per POST that carries a body (+5 to 8% of the ProcessH2 work on this host), from the arm and the disarm; a GET pays within noise (+1 to 2%); with the deadlines off the head equals main. Allocations unchanged (POST 4 and 32, GET 2 and 16). The N1 removal is not resolvable at this noise. darwin clock reads are slower than Linux's vDSO, so amd64 is likely smaller (the docker run below: +1.0% and +0.2%), but amd64 is unmeasured (docker amd64 emulation is not a timing source): the cluster P-run covers it. The builder's earlier docker run (arm64 in a VM, 8 rounds with a twin that itself differed from main by up to 10%) is kept for the sweep numbers:
Docker
--cpus 2, arm64, 8 rounds interleaved main / branch / A/A twin of main (the Mac was in use: the twin differs from main by up to 10% in medians and 15% in minimums), medians and minimums (logs/bench7-table.md):ProcessH2Headers889/single(a GET)ProcessH2Headers889/pipelined8ProcessH2Post889/single(POST + body)ProcessH2Post889/pipelined8InlineDispatch893AsyncRequest951PoolDispatchStreamedResponse889/closedThe POST minimums there (+6 to +7%) were the one place a cost showed; the reviewer's run above resolves it. The sweep gains a pass over the h2 conns (
BenchmarkSweepH2Deadlines889: n h2 conns onh2Conns, nothing due, main vs branch): epoll 4096 conns 13.4 us to 31.2 us (+4.3 ns per h2 conn per sweep), 256 conns +2.9 ns; io_uring 4096: 30.7 us to 47.9 us (+4.2 ns), 256: +3.6 ns, the same whether the conn has no stream, an exempt stream or an armed request (CheckTimeouts865/888, the conns not onh2Conns, move within the twin's spread). The first version of the pass cost 10.7 ns per conn (three heap objects, three cache lines); commit469070dputs the hints in one struct at the head ofH2Stateand reads the idle clock's counters every 100 ms. At 10 000 h2 conns on a loop and 40 sweeps a second: 43 us per sweep, 0.17% of a core. amd64 is unmeasured (docker amd64 emulation is not a timing source): the cluster P-run covers it.Release notes (draft)
ReadHeaderTimeoutalso bounds an HTTP/2 header block (a HEADERS frame without END_HEADERS): a block not complete within ReadHeaderTimeout ends the connection with GOAWAY(ENHANCE_YOUR_CALM).IdleTimeoutfor an HTTP/2 connection with no stream open runs from the last stream activity; PING, SETTINGS and connection WINDOW_UPDATE frames do not move it, so a client that only pings no longer holds a bare connection for ever: PING-only connections are reaped at IdleTimeout when IdleTimeout > 0 (with IdleTimeout off they are left alone, as before). It is IdleTimeout alone (ReadTimeout is a request's deadline). A connection that still has output pending is held only while the output makes progress: a connection with bytes to send or unacknowledged (in its buffers or in the kernel's send queue) gets another idle period for as long as its client acknowledges more bytes in each period than the control frames the server sent it; a client that stops reading, or that pings faster than its round trip, is closed within a few periods (the zero-window cell bounds it at 3.5 s with IdleTimeout 1 s; ACK lag: measured 2.4 to 3.6 s with 300 ms RTT and 200 ms pings, at most about four periods; GOAWAY(NO_ERROR) where it can still be sent, then the close; the tail may be lost). The measure is the bytes the peer acknowledged beyond the control frames the server sent it: a client that reads, however slowly, is not cut. A peer that has acknowledged nothing (a count of 0: a client with its own TCP stack that never acknowledges data) is refused like any other; where the cap cannot judge (the host's stack does not fillbytes_acked, as gVisor's is believed not to, a failedgetsockopt, a development-only fixed-file conn) the period is extended, as without the cap.http2.Transportreturns the 408 to the caller (measured), curl reports 408, and clients that treat a reset with NO_ERROR after a complete response as success do likewise. Keep-alive PINGs from clients and proxies (gRPC keepalive, a Transport'sReadIdleTimeout) no longer hold a connection with no open stream past IdleTimeout (default 600 s); withIdleTimeout: -1such connections are left open as before.Findings, not fixed here
H2State.SweeptakesH2State.muwithTryLockwhen something is due; a held lock (the dispatch goroutine of an h2c upgrade, for the instant it runs the first frames) means "next sweep". Never blocks the loop.2572288 of 8388608 bytes: unexpected EOFon epoll and adaptive on origin/main and on the head alike (io_uring whole): the scan closes with the tail queued once IdleTimeout passes (its clock is the last read). The fix is the same check as D0's (outstanding, orwriteSince) in the scan; a follow-up issue. D0 does not add to it (it no longer closes a conn that holds output and is getting it out).Unverified
active=epoll); an end-to-end h2c upgrade (a request that arms its deadline in the frames trailing the upgrade, on the dispatch goroutine; covered by reading and the lock logic: it is underH2State.mu, and the conn joinsh2Connsonly afterwards); a real client other than the raw framer (x/net/http2.Transportand curl reading the 408, planned for the Opus round).For the reviewer to attack
resetIDs(2048 ids) is the fallback; is the 1024-entry cap reachable by an attacker without a rate that the other budgets would stop first?d0Checkis stamped by the sweep, not by a frame: a stream that opens and ends between two 100 ms looks shows only through the last client stream ID; is there a path (a stream ID that does not move: a refused stream, PRIORITY) where real activity is invisible and a busy conn is closed at IdleTimeout? The conn's own clock still needs to be due for the conn scan to close it; D0 closes on its own (CloseNow).flushH2Conn) so a close in the same sweep does not truncate it; io_uring relies oncloseConnsending what was staged. AwriteRefusedconn?Sweepreads the hints withoutH2State.mu(atomics), the stream'sStartTimeNsunderManager.muread lock inExpiredRequestswhile the arm is on the loop thread (or the upgrade goroutine) underH2State.mu; a-racerun is clean.Content-Length: 0, nodate/serverheader; compare with what a handler's 408 looks like.ReadTimeout,ReadHeaderTimeout,IdleTimeout(comment only).