Repository navigation
epoll: drainRead ends a read turn after 64 KiB and re-arms the conn; reclaim counts a recv once, the async gate is ordered with the h2c switch, the replay shares the inline flush (v1.6.0, Refs #885 #886) - #1122
Merged
Conversation
…cket full returns after a bounded read (fails on main; v1.6.0, Refs #885 item 41)
…e registration's generation and driverMu (comment only; v1.6.0, Refs #885 item 55)
…ith an EPOLL_CTL_MOD; the half-close, error and transplant steps of that event wait for the turn that drains the socket (v1.6.0, Refs #885 item 41)
… (v1.6.0, Refs #886 item 25)
…nMu by the dispatch goroutine's switch to HTTP/2, not cs.protocol (v1.6.0, Refs #885 item 28)
… function (flushInline) (v1.6.0, Refs #885 item 17)
|
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! |
… TestProbeDrainReadFloodLatency885 (v1.6.0, Refs #885 item 41)
…he h2c switch (fails on main under -race), that a switched promoted conn is served past the gate (PING ACK), and a half-close test that holds the loop (v1.6.0, Refs #885 items 28, 41)
…tachMu, as its io_uring twin does; the comment on the ERR/HUP skip says what an RST leaves (v1.6.0, Refs #885 item 28)
FumingPower3925
marked this pull request as ready for review
October 26, 2026 23:58
FumingPower3925
added a commit
that referenced
this pull request
Oct 27, 2026
…uest (v1.6.0, Refs #886) The four h2c-upgrade-carry replay tests expect 0 requests and read the 101 off the client first (expectCount886 returns at once on 0 and only holds 400 ms, so without the 101 it could pass before the replay ran). They fail on baa43d9 (the replay counts 1). Kept apart from the rest of the lane on purpose: the fix touches the two reqBatch++ lines of epoll/adopt.go that #1122 rewrites (recvCounted), so this pair is the one to drop or rebase.
FumingPower3925
added a commit
that referenced
this pull request
Oct 27, 2026
…lay (v1.6.0, Refs #886) 886.23: the recv path (drainRead, handleRecv) returns on the upgrade verdict before it counts, so a conn that upgraded on a recv counted its upgrade request as none, while the replay of a carry that is an upgrade request counted one. The replay now counts none, at the four sites (epoll sync and async, io_uring sync and async). The other rule needs drainRead/handleRecv. h2 conns count no request on either engine, so BytesPerReq moves by one request per upgraded conn that was replayed. Rebase note: #1122 changes the same lines to `if !recvCounted {`; the resolution is `if !recvCounted && !errors.Is(perr, conn.ErrUpgradeH2C) {` at both sites (tried on a merge of this branch with #1122's head: builds, the AdoptRequestCount and Reclaim tests pass).
FumingPower3925
added a commit
that referenced
this pull request
Oct 27, 2026
…uest (v1.6.0, Refs #886) The four h2c-upgrade-carry replay tests expect 0 requests and read the 101 off the client first (expectCount886 returns at once on 0 and only holds 400 ms, so without the 101 it could pass before the replay ran). They fail on baa43d9 (the replay counts 1). Kept apart from the rest of the lane on purpose: the fix touches the two reqBatch++ lines of epoll/adopt.go that #1122 rewrites (recvCounted), so this pair is the one to drop or rebase.
FumingPower3925
added a commit
that referenced
this pull request
Oct 27, 2026
…lay (v1.6.0, Refs #886) 886.23: the recv path (drainRead, handleRecv) returns on the upgrade verdict before it counts, so a conn that upgraded on a recv counted its upgrade request as none, while the replay of a carry that is an upgrade request counted one. The replay now counts none, at the four sites (epoll sync and async, io_uring sync and async). The other rule needs drainRead/handleRecv. h2 conns count no request on either engine, so BytesPerReq moves by one request per upgraded conn that was replayed. On epoll the condition sits beside #1122's: `!recvCounted && !errors.Is(perr, conn.ErrUpgradeH2C)`. A reclaim of an upgrade carry keeps the count drainRead made when it read the bytes (it counts before it transplants), so that one case still counts 1; it needs drainRead.
FumingPower3925
added a commit
that referenced
this pull request
Oct 27, 2026
…uest (v1.6.0, Refs #886) The four h2c-upgrade-carry replay tests expect 0 requests and read the 101 off the client first (expectCount886 returns at once on 0 and only holds 400 ms, so without the 101 it could pass before the replay ran). They fail on baa43d9 (the replay counts 1). Kept apart from the rest of the lane on purpose: the fix touches the two reqBatch++ lines of epoll/adopt.go that #1122 rewrites (recvCounted), so this pair is the one to drop or rebase.
FumingPower3925
added a commit
that referenced
this pull request
Oct 27, 2026
…lay (v1.6.0, Refs #886) 886.23: the recv path (drainRead, handleRecv) returns on the upgrade verdict before it counts, so a conn that upgraded on a recv counted its upgrade request as none, while the replay of a carry that is an upgrade request counted one. The replay now counts none, at the four sites (epoll sync and async, io_uring sync and async). The other rule needs drainRead/handleRecv. h2 conns count no request on either engine, so BytesPerReq moves by one request per upgraded conn that was replayed. On epoll the condition sits beside #1122's: `!recvCounted && !errors.Is(perr, conn.ErrUpgradeH2C)`. A reclaim of an upgrade carry keeps the count drainRead made when it read the bytes (it counts before it transplants), so that one case still counts 1; it needs drainRead.
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 L4 EP-READ of v1.6.0, the read path of the epoll engine. Refs #885, Refs #886.
Method: each item's premise was checked on main first, with a test that shows it as an assertion. Commits 1 and 2 are those tests and fail on main; commit 3 is a comment; commits 4 to 7 are one change each (885.17 is last so it can be dropped on its own).
885.41: the probe first (maintainer decision U-15)
TestProbeDrainReadFloodLatency885(run by hand:CELERIS_PROBE_885=1, it skips by default; the benchmark below measures the same thing, the probe is kept because it prints the control arm). Two loops, docker--cpus 2, golang:1.27.3. Conn A pipelinesGETrequests in 64 KiB writes as fast as its socket takes them and discards the responses. B is on A's loop (checked through/who, which returns the worker id) and sends one request every 5 ms. C is on the other loop and is the control: a stall C shares is the test process's CPU, not the loop. 5 runs x 2 buffer sizes (8 KiB, 64 KiB) = 10 arms each, under flood:Main's B p50 is also above C's in most arms without a stall (0.39 to 0.71 ms in six of seven, against 0.17 ms); this PR's B p50 is 0.16 to 0.33 ms, C's 0.17 to 0.24 ms. Raw: scratchpad
l4epread/out/probe-{base,head}.log.The stall is the loop:
BenchmarkDrainReadFloodBystander885(below) hit its 30 s read deadline in 1 of 10 interleaved runs of main and in 1 of 10 of the A/A twin of main, and two more runs of the twin had a max of 8 s and 10.5 s: 4 of 20 runs of main's code, against 0 of 10 with the budget (max 25 ms). In a separate series of 12 runs of main (not interleaved) 3 of the first 5 ended with a max of 27 s, 13 s or the 30 s timeout, and the process then did not end in 10 minutes; the same series on the budget build (256 KiB then) had 12 of 12 clean, max 31 ms.The deterministic gate,
TestDrainReadIsBoundedUnderAFlood885: the peer keeps the conn's socket full, and onedrainReadcall (one readiness event) must return after a bounded number of bytes. On main it reads 26.7 to 27.5 MB in one call under-raceand returns only when the watchdog stops the peer after 2 s (5 of 5 runs fail). With the budget it returns after 64 KiB plus one buffer.885.41: the fix
drainReadcounts the bytes of a turn and, atdrainReadBudget(64 KiB), re-arms the conn and returns; the loop serves the events of the batch that were ready already, and the conn gets its next turn in the nextepoll_wait.EPOLL_CTL_MODwith the conn's own interest (epollInterest(epollOut): EPOLLOUT if armed, no EPOLLIN while recv is paused), throughmodEpollOut(the hijack guard for async conns).ep_modifyre-polls the descriptor and puts it on the ready list if it is readable, so no readiness is lost, and the event carriescs.genin Pad, so a conn that closed meanwhile, or whose number was reused, is dropped by the existing identity check. This is why it is not the driver's queue (driverReadQ):connStateis pooled (a queue of pointers is a reuse hazard), it needs hunks inrun()where 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 is, andsetRecvPaused's resume path already relies on the MOD re-poll.for, so everycontinue(partial body, protocol not decided, async hand-off, upgrade) is bounded. Bytes, not reads:BufferSizeis 4 KiB to 256 KiB and a read into the body buffer can be larger.peerClosed;closeWhenFlushed's mask has no EPOLLIN by design) does not yield.Loop.readYielded, read byrun()): with unread bytes before the FIN,onPeerHalfClosecloses the conn and cuts them off. The next event carries EPOLLRDHUP/HUP/ERR again (the MOD re-polls). The first version of this change did not have it; the test below caught it.BenchmarkDrainReadFloodThroughput885,BenchmarkDrainReadFloodBystander885, 2 vCPU container):main is 287 MB/s on the flood bench. 256 KiB costs the flooding conn nothing visible but makes B wait about 0.5 ms (more than main's typical 0.3 to 0.4 ms); 64 KiB gives B less than main's typical and costs the flood about 4% (one MOD and one
epoll_waitper 64 KiB of parse). The upload reads the body in one read, so it yields once per read, not per 64 KiB. Uploads that are not read straight into the body buffer (the async engine, chunked bodies, a 256 KiB buffer) were measured by the reviewer at 4 to 5% lower single-connection throughput with the budget (1 MiB body async: 5364 to 5151 MB/s; chunked 16K sync: 4682 to 4449 MB/s; the A/A twin within 1%, ranges overlapping), the same order as the 4% of the pipelined flood: the price of the fairness; the budget is unchanged. A budget relative toBufferSizewould halve the MOD rate for big buffers (not done).Benchmarks (docker
--cpus 2, arm64, interleaved rounds of main, this PR and an A/A twin of main, medians)Loop level,
BenchmarkDrainReadLoop885(socketpair, no client; 20 rounds; ns/op):End to end (10 rounds; this PR here is all commits, so it includes
flushInlineand the gate change):EpollRecvShapes884/getns/opEpollRecvShapes884/postns/opKeepAliveDispatch/nodriverns/opKeepAliveDispatch/withdriverns/opDrainReadFloodThroughput885/pipelinens per 64 KiBDrainReadFloodThroughput885/uploadns per 4 MiBDrainReadFloodBystander885mean nsThe A/A twin differs from main by up to 16% on the ping-pong benches (two vCPUs shared with the client), so the end-to-end ping-pong rows say "no difference beyond the floor"; the loop-level rows (1 to 3%, this PR faster, the twin 1% faster) are the resolving ones: the budget is an add and a compare per read,
flushInlineone call per recv. The bystander rows of main and the twin carry the stalls above (the median hides them; the 30 s timeouts are excluded from it).Guards (pass on main, each checked against a mutant)
The read budget has to give the conn another turn, so these are guards, not failing-first tests:
TestDrainReadBudgetGivesTheConnAnotherTurn885,...AfterADirectBody885,...KeepsEPOLLOUT885,...SparesAClosingConn885,TestDrainReadBudgetEndToEnd885(a 2 MiB pipelined burst or a 1 MiB POST body written at once behind a request that takes 300 ms, so that the burst collects in the kernel behind it (all of it where the socket buffers are not capped, most of it on a CI runner); sync and async engines; with a FIN after the burst on the sync one),TestRunLoopHandlesAHalfCloseAfterAYieldedTurn885(A yields; then A holds the loop thread in a 300 ms handler while B's request and FIN are both queued behind it, so only the run loop's RDHUP step can end B; without thereadYielded = falsebeforedrainReadB is not ended, 10 of 10 runs fail, and the head passes 10 of 10; an earlier version of the test without the hold caught the mutant in about 56% of runs, 14 of 25). Mutants, all failing the named test: no MOD (1024 of 3000 answered; 513 of 2001; the end-to-end burst times out at 4096 of 65537, the async one at 4096 of 24577), the MOD with a plain interest (no EPOLLOUT event after the peer takes the response; the end-to-end burst at 50474 of 65537), nopeerClosedguard (a closing conn stops after 16384 bytes), no half-close skip (2602 of 6145 answered, then EOF). The tests need a socketpair queue of 210 KB (the defaultwmem_max), checked with theSO_SNDBUFrequests removed.886.25
reclaimTransplantre-adopts a conn onto its own loop with the carry whose bytesdrainReadhad counted; the replay counted them again.TestReclaimTransplantDoesNotCountTheCarriedRecvTwice886(a sync conn at a request boundary with the first bytes of the next request buffered, a refusing target):RequestCountbatch is 2 on main (5 of 5), 1 now.attachFD(..., recvCounted);attachAdoptedFDkeeps its signature and is the not-counted-yet case (an adoption from another engine counts its first recv here, as #886 item 5 says).885.28
TestAsyncGateProtocolReadHasAnEdgeWithTheUpgradeSwitch885(-race): one write carries a request that promotes the conn, an h2c upgrade request, the client preface, SETTINGS and a request on stream 3 whose handler runs inline on the dispatch goroutine afterswitchToH2Localhas writtencs.protocol; the test holds it there and sends a PING. On main the detector reports 6 of 6, and two races, not one: the gate's read ofcs.protocoland the read ofcs.h1Stateright after it (switchToH2Localsets it to nil;if cs.h1State != nil { cs.h1State.NowNs = now }is a check then a use). A client that waits for the 101 is ordered by x/sys:unix.Writeandunix.Readare annotated as one synchronisation, so the flush of the 101 orders it (the issue's 92k-upgrade probe could not report).Fix:
h2Switched, a flag the goroutine sets inswitchToH2Localafter the three writes, underasyncInMu(taken underdetachMu, the documented order, and not acrossProcessH2, which runs handlers), and the gate reads underasyncInMu, which it takes right after anyway (lockAsyncInUnlessSwitched): a switched conn is read after that lock, which orders the reads. A bool in the padding byte afterasyncPromoted(TestConnStateStaysInItsSizeClassstill passes at 448). Cost: for a promoted conn, none on the HTTP/1 path (the lock was taken there already); one lock pair per read for a switched one.refusesRecv(poolwait_gate_885.go), reached at the top ofdrainReadonce the pool wait's gate has setrefuseRecv, readcs.protocolfor a promoted conn the same way (found by the review of this PR's first head). Reproduced:TestRefusesRecvReadsProtocolInOrderWithTheUpgradeSwitch885is the same scenario with a second conn on the loop whose/armrequest, run inline on the loop thread, setsrefuseRecv; on main (this PR's tests without the fix) the detector reports 5 of 6 runs. Fix: for a promoted connrefusesRecvreturns!cs.switchedToH2()(h2SwitchedunderasyncInMu, an instant-long lock the goroutine never holds across a handler, so the gate pass cannot stall on it; lock order is not involved, the pass holds neither lock). Refusing onasyncPromotedalone would refuse the HTTP/2 conns the pool wait serves, so a switched conn is not refused, an HTTP/1 one is, as before. The other loop-thread readers ofprotocolandh1Statewere checked by the reviewer and are ordered (closeIdleH1and the sweep underdetachMu,tryTransplantafter the park read underasyncInMu). io_uring'sprotocolis an atomic and itsh1Stateread goes through aTryLock.stopDetachedProducers(the gate pass over the detached conns, from #1116) readcs.h1Statebefore takingdetachMu, while the switch sets it to nil: the same race, found by the second review.TestStopDetachedProducersReadsH1StateInOrderWithTheUpgradeSwitch885(a request on a second conn runs the pass inline on the loop thread while the goroutine is in the switch) is red 10 of 10 on the code before the fix, in separate-raceprocesses, and green 10 of 10 after. Fix, the pattern #1123 uses for io_uring's twin: a promoted conn'sh1Stateis tested under theTryLock, a conn the loop owns before it; the pass still never waits for a lock the goroutine holds across a handler. A PING ACK assertion after the 101 in the three edge tests (the switched conn is served past the gate) fails against a gate that refuses every promoted conn.Found and not changed: the bytes a client sends during the switch (before the 101) are appended to
asyncInBufand dropped by theasyncInBuf[:0]after it; and the loop thread blocks ondetachMufor as long as the goroutine'sProcessH2(info.Remaining)runs a handler inline (a loop stall during an async h2c switch, on main as here).885.17
flushInlineis the block ofdrainReadafter the handler (flush, resync of pendingBytes, EPOLLOUT or dirty list, OnError, refused write);flushCarriedReplayis a one-line call of it, and the "stays a copy on purpose" paragraph is gone. The direct-body block ofdrainReadis not this one and stays (it has no OnError, noremoveDirtyon an error and nodisarmEpollOutwhen everything went out; sync-only by its gate); the doc says so. The error and h2c blocks were shared by #1015 (h1verdict.go). The old objection was a call per recv: measured above, 1 to 3% faster than main at the loop level (within the floor). The existing #908 and write-clock tests guard it. Commit 7 is separate so it can be dropped (commits 8 and 9, after review round 1, add therefusesRecvtest and fix and thereadYieldedguard; comments only on the skips of the ERR/HUP and transplant steps, which are conservative); after review round 2: thestopDetachedProducerstest (red on the code before) and the PING ACK assertion, a deterministic half-close test, then thestopDetachedProducersfix and a corrected comment on the ERR/HUP skip: an RST does not purge the receive queue on Linux, the next turn reads the bytes and then the error) if a reviewer reads the cost differently.885.55
RegisterConn's comment now says what keeps the first byte (the registration's generation in Pad,driverCandidate, andlookupDriverwaiting ondriverMu, which is held from the insert to the ADD); the aa2331d wording was already in the test's comment.Release notes
RequestCount.What to attack
run()afterdrainReadthat assumes the socket is drained: I found RDHUP/HUP/ERR andtryTransplant(both skipped for the turn).EPOLLOUTis not skipped.onPeerHalfClose's deferred paths anddrainDetachQueueread no socket.modEpollOut's hijack guard, andepollOutbeing loop-thread state.recvPausedconns and WebSocket/SSE (detached) conns yielding: the interest comes fromepollInterest; the detached backpressuresetRecvPausedafter the inline flush is in the same turn and unchanged.h2Switchedgate for a conn that is promoted and has been handed back as HTTP/2 (asyncH2Promotedconsumed bydrainDetachQueue): the flag stays set until release;releaseConnStateresets it.BufferSizeand protocol (HTTP/2 conns go throughdrainReadtoo): one extra MOD andepoll_waitper 64 KiB of a saturated conn. A big-buffer deployment might want it larger; it is a package variable only for the tests.Leads found by the guards (all present on main, none changed here)
closeOnReadEndbefore the close), not only fewer responses (a truncated response is worse than a missing one).drainRead(len(asyncInBuf)+len(data) > maxPendingInputBytes) wheremaxPendingInputBytesis 4 MiB, the same as the default body limit.drainRead) and overshot into a partial pipelined request now counts 0 recvs where main counted 1 (RequestCountonly; needs sync mode, a drain, a refusing target). Not changed: it needs a per-conn "last recv counted" record.Verification (head after review round 2):
./internal/engine/epoll/...with-race(230 s, ok), the new tests-count=10 -race(ok), golangci-lint 0 issues; earlier: (226 s, ok) on the rebased branch (main baa43d9); the new tests-count=20 -race(ok); golangci-lint (0 issues). Local runs in dockergolang:1.27.3,--cpus 2, one container at a time; arm64.