test(iouring): deterministic check of the #715 recv-theft window - #781
FumingPower3925 wants to merge 3 commits into
Conversation
Hypothesis (a) of #715 (the #685 class): an io_uring worker prepares a recv SQE that reaches the kernel only at its next submit, and the SQE names the descriptor NUMBER. finishClose / finishCloseDetached queue the recv's cancel behind it and close the descriptor at once, so a sibling worker's accept can be given the number first; the old recv then reads the new connection's request and completes as stale_recv_data_closed. Validation builds only (internal/recvtheft, the internal/zcwindow pattern; production compiles every call site away and connState keeps its layout, pinned by TestRecvTheftWitnessCompilesAway): - witness close_with_unsubmitted_recv: a close reached while the conn's recv SQE is still in the SQ ring (its SQ sequence number vs the kernel's SQ head); - a hold after the close, a hold on the sibling after it accepted the freed number, a gate that lands the dispatch goroutine's close in the promote re-arm's iteration, stale recv exemplars, and the control switch that submits before the close; - a wake hold for hypothesis (c). TestRecvTheft715ArmA asserts the property a fix must restore and fails on main by design when (a) holds; TestRecvTheft715Control (submit before close) and TestRecvTheft715ArmC (hypothesis (c)) assert the same kind of property. All three run only with CELERIS_RECV_THEFT_715=1 under -tags=validation, with two io_uring workers; none is in CI. Refs #715 #685
…d offset The zero-size field sits at connState offset 586 and kernelInflight at 588 on linux/arm64: the gap is kernelInflight's own 4-byte alignment, there with or without the field. The first version compared the two offsets and failed on a layout the field does not change. It now checks that kernelInflight is where its alignment alone puts it, and that the field is not the last one (a trailing zero-size field is what pads). Refs #715
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueComment |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
… resolve it: close paths, hijack, shutdown (celeris#685) Fixed files are off, so a recv or send SQE names its descriptor by number and the kernel resolves the number when it issues the op. finishClose and finishCloseDetached queued the ops' cancels and closed the descriptor at once, while an op could still be issued: a recv still in the SQ ring (a promoted connection's re-arm), or one linked behind a SEND that the kernel issues only after the SEND completes. A sibling worker given the freed number before this worker's next enter then had its new connection's request read by the old recv, dropped as stale_recv_data_closed, and the client was never answered (celeris#715: 80 of 80 in #781's trial). The rule, as #681 applied it to the hand-off: a close path does not release the number while the kernel owes an op on it (fdOwed: kernelInflight > 0, which counts every recv and send from the moment its SQE is written). It does everything else as before and hands the descriptor to its pendingRelease entry; drainPendingRelease closes it where it releases the connState, at the last owed op's terminal CQE. The socket's read side is shut down too (SHUT_RD on the H1 fast path, SHUT_RDWR where the path already half-closed), so an owed recv ends as soon as it is issued, even a linked one no cancel can find, and even where cancels fail (#682). No io_uring_enter is added; the close(2) moves one iteration later. The worker does not park while such a close is outstanding. hijackConn keeps the socket open under the hijacker, so it submits its cancels before handing the socket over when an op is owed (a multishot recv stays armed across its request). Worker shutdown ends the ops owed on connection descriptors (cancel, SHUT_RDWR, run the ring until their terminal CQEs, bounded at 250 ms) before it closes any, and before shutdownDrivers, which relies on nothing being submitted after it. EngineMetrics gains CloseFDDeferred (a rate) and CloseFDForced (the backstop closing a descriptor with an op still owed; must stay 0). The recv-theft CI job gets a detector control: with fdOwed forced false the three judging trials must fail every run. Fixes #685 Refs #715
Superseded by #793, the #685 fix#793 carries this PR's three test commits (
Arm A on the tree without the fix:
On #793's head, arm A passes:
#793 also enables the trials in CI (a new I am leaving this PR open for whoever closes it. |
…cv trial, run both in CI (celeris#685) The #715 trial (PR #781) counted a hit only when the sibling worker accepted B on the exact number the closer's close had freed. A fix that keeps the number allocated until the closed connection's recv has ended leaves the sibling nothing to take, so every attempt would read INCONCLUSIVE. A hit is now the sibling accepting B while the closer is parked, on whatever number; the trial records whether it was A's number (reused), whether A's number was still open at the hold (held_open), and that a number kept open is released after the closer's release (released). The verdict is unchanged: B answered and no stale recv carrying B's bytes. TestRecvTheft685Linked is the linked form of the same theft: a recv chained behind a SEND (flushSendLink) is consumed with the SEND but issued, and its descriptor number resolved, only when the SEND completes, as task work the enter that posts the SEND's CQE can leave queued. The trial blocks A's response SEND behind a send queue filled from outside the engine, lets WriteTimeout defer the close, drains A so the SEND completes and the deferred close runs with the linked recv owed, and parks the closer there (recvtheft.Options.LinkedRecv, witness close_with_linked_recv). On this tree both fail by design (laptop container, arm64, kernel 7.0): TestRecvTheft715ArmA stolen 3 of 3, TestRecvTheft685Linked stolen 5 of 5, while TestRecvTheft715Control passes. The new `recv-theft` CI job runs the four trials with two io_uring workers on both arches and requires every run to pass; it is red until the close-path fix lands. Refs #685 #715
… resolve it: close paths, hijack, shutdown (celeris#685) Fixed files are off, so a recv or send SQE names its descriptor by number and the kernel resolves the number when it issues the op. finishClose and finishCloseDetached queued the ops' cancels and closed the descriptor at once, while an op could still be issued: a recv still in the SQ ring (a promoted connection's re-arm), or one linked behind a SEND that the kernel issues only after the SEND completes. A sibling worker given the freed number before this worker's next enter then had its new connection's request read by the old recv, dropped as stale_recv_data_closed, and the client was never answered (celeris#715: 80 of 80 in #781's trial). The rule, as #681 applied it to the hand-off: a close path does not release the number while the kernel owes an op on it (fdOwed: kernelInflight > 0, which counts every recv and send from the moment its SQE is written). It does everything else as before and hands the descriptor to its pendingRelease entry; drainPendingRelease closes it where it releases the connState, at the last owed op's terminal CQE. The socket's read side is shut down too (SHUT_RD on the H1 fast path, SHUT_RDWR where the path already half-closed), so an owed recv ends as soon as it is issued, even a linked one no cancel can find, and even where cancels fail (#682). No io_uring_enter is added; the close(2) moves one iteration later. The worker does not park while such a close is outstanding. hijackConn keeps the socket open under the hijacker, so it submits its cancels before handing the socket over when an op is owed (a multishot recv stays armed across its request). Worker shutdown ends the ops owed on connection descriptors (cancel, SHUT_RDWR, run the ring until their terminal CQEs, bounded at 250 ms) before it closes any, and before shutdownDrivers, which relies on nothing being submitted after it. EngineMetrics gains CloseFDDeferred (a rate) and CloseFDForced (the backstop closing a descriptor with an op still owed; must stay 0). The recv-theft CI job gets a detector control: with fdOwed forced false the three judging trials must fail every run. Fixes #685 Refs #715
… resolve it: close paths, hijack, shutdown (celeris#685) (#793) io_uring close paths no longer release a descriptor number while an op naming it can still be issued: finishClose/finishCloseDetached keep the fd until the last owed op's terminal CQE (with a read-side shutdown), hijackConn submits its cancels before handing the socket over, and worker shutdown ends owed ops before closing. A pending SEND_ZC notification names no descriptor, so it no longer holds the fd (celeris#798 item 1); the connState still waits for it. EngineMetrics gains CloseFDDeferred and CloseFDForced (must stay 0). The recv-theft CI job runs the #715/#685 trials, a detector control under the fdOwed mutant, and the #798 SEND_ZC tests on both arches. Supersedes #781. Follow-ups tracked in #798. Fixes #685 Refs #715
|
Superseded by #793, merged as 3e7abba. #793 carries this PR's three test commits unchanged and changes how arm A is judged (the round-2 hit rule, |
Refs #715 #685
Summary
This PR adds a deterministic test of hypothesis (a) of #715 (the #685 class). The theft reproduces in every trial, and the control does not. A recv SQE sits unsubmitted in the closing worker's SQ ring when that worker closes the descriptor. A sibling worker accepts a fresh connection B under the freed number. The closing worker's next submit then issues the old recv against B's socket. That recv reads B's whole request:
stale_recv_data_closedgoes up by 1, and the recorded bytes are exactly B's request. B is never answered.TestRecvTheft715ArmA)TestRecvTheft715Control)unix.CloseTestRecvTheft715ArmC)promoteConnToAsyncand the async feed, widened to 2 ms on every hand-offArm A fails on
mainon purpose. It asserts the property a #685 fix has to restore. It is not in CI; the fix PR (engine lane D-2) should enable it.What this does and does not show
It shows:
ubuntu-24.04/ubuntu-24.04-arm) and on 7.0 (a linuxkit container), with and without-race;res= 41 =len(B's request), it comes from the closing worker under the closed connection's(fd, generation), and its first bytes areGET /b HTTP/1.1\r\nHost: recv-theft-715\r\n\r\n;hang-eofsignature of adaptive: after the epoll→io_uring promote, a fresh connection's request is read off its socket and never answered (h2c closed at the 10 s header deadline, WS handshake timeout; 7 events, mechanism not attributed) #715;io_uring_enterbefore the close, which is the fix direction io_uring close paths close the fd while a linked RECV can still resolve its number (the #657 fd-lifetime rule, not yet applied to close/hijack/shutdown) #685 names.It does not show:
promoteConnToAsyncre-arms, and the dispatch goroutine'sasyncClosedis drained in the same iteration. It uses a handler that writes nothing on aConnection: closerequest. When a response is pending,closeConndefers the close to the SEND completion, and by then the recv has been submitted. That is the common case in the real server. The new witness counterclose_with_unsubmitted_recvexists to count the precondition in a container leg; that is step 2 of the adaptive: after the epoll→io_uring promote, a fresh connection's request is read off its socket and never answered (h2c closed at the 10 s header deadline, WS handshake timeout; 7 events, mechanism not attributed) #715 plan.Arm C is a weak test of (c). The worker is the only feeder, so holding it makes the window longer but adds no second actor to race it. It rules out a lost wakeup in that window under keep-alive and pipelined load. It does not rule out (c) as a whole: the #364 revert, the hand-off claim, the h2c upgrade on the dispatch goroutine, and a response-side loss are all untouched.
How the trial works
engine/iouring/recv_theft_715_linux_test.gostarts an async-handler engine with two io_uring workers, then:/dev/nulluntil every descriptor hole is filled, and opens B's candidate client sockets. As a result, the number that A's close frees is the lowest free one.GET /closewithConnection: close(an async route, handler writes nothing). The worker W1 that owns A:promoteConnToAsync);recvtheft.Options.PromoteGate, the one test-only step on this path);finishCloseDetached). The witness finds A's recv SQE not yet consumed by the kernel, so W1 counts it and parks right afterunix.Close(recvtheft.HoldAfterClose).recvtheft.AfterAccept).The witness compares the SQ sequence number at which the recv SQE was placed with the kernel's SQ head. Every hold is bounded (10 s), and a disarmed trial releases both workers.
Tallies
Every arm is judged from the
--- PASS/FAIL/SKIPlines and oneRECVTHEFT715 ... resultline per trial. Tally script:evidence/celeris-715/hypothesis-a/scripts/tally.py. The tallies ran atad3ffb7. The two commits after it change only the production layout test. Its first version compared two offsets thatkernelInflight's own alignment keeps 2 bytes apart, and failed in a laptop container.-count-race, memlock unlimited)--cpus 4,-race, memlock unlimited)-race, memlock unlimited)close_with_unsubmitted_recvwent up by exactly 1: both arms reached the same precondition.stale_recv_data_closedwent up by 1 in every arm A trial, and by 0 in every control trial.res= 41 and B's request as its bytes. For example (x86 shard 1):{worker=1 fd=153 gen=3 res=41 head="GET /b HTTP/1.1\r\nHost: recv-theft-715\r\n\r\n"}, and B on worker 0 had no response head within 2 s.unitjob's shape) the engine starts one io_uring worker, so there is no sibling. WithCELERIS_REQUIRE_IOURING_WORKERS=1the tests fail that way (workers=1) instead of skipping.-count=2) gave the same outcome and is not in the totals.fd-number reuse. Every time the sibling accepted a connection during a hold, it got exactly the number the held close had freed: 160 of 160 sibling accepts. An attempt reached a sibling accept in 160 of 162 attempts. In the other 2 attempts, all six candidates hashed to the held worker's
SO_REUSEPORTlistener (p = 1/64 per attempt), and the test retried with a new A. 162 candidates queued on the held worker in total.Commands
Laptop containers, one at a time, through the lane slot lock (
evidence/celeris-715/hypothesis-a/scripts/run-shape.sh):GitHub-hosted runners, both arches, the celeris CI shape (
-race, memlock raised as theiouringjob raises it): probatoriumceleris-stress.ymlrun 36351992181:That run's summary is red because arm A fails, as designed.
Changes
All of the instrumentation is validation-only, following the
internal/zcwindowpattern from #693.recvtheft.Enabledis a false constant without-tags=validation, so every call site compiles away. A productionengine/iouringtest binary contains norecvtheftsymbol, and neitherrecvUnsubmittednor the two ring accessors.internal/recvtheft: the witness counter, the trial (close hold, sibling accept hold, promote gate, control switch, stale-recv exemplars) and the hypothesis (c) wake hold. The production build gets no-ops and a zero-sizeArmSeq.engine/iouring:noteRecvPlacedrecords each recv SQE's SQ sequence number;finishClose/finishCloseDetachedcount a close with an unsubmitted recv, park when a trial is armed, and in the control arm submit before closing;onAcceptedFD,promoteConnToAsyncand the async feed get their hooks;staleConnCQEhands a stale data completion's first bytes to the trial;RinggainssqPlaced/sqConsumed.connState.recvArmSeqis zero-size in production and not the last field.TestRecvTheftWitnessCompilesAwaypins that. It is a production-build test, and it passed in this PR'sUnitjob.Sizeof(connState)is 600 bytes on linux/amd64 and linux/arm64, the same asmain. Every field afterrecvArmSeqkeeps its offset. Under-tags=validationthe 4-byte field fits in existing padding, so the size is still 600.TestRecvTheft715*tests build only with-tags=validation, run only withCELERIS_RECV_THEFT_715=1, and need two io_uring workers (memlock of at least 24 MiB). Without the opt-in they skip. No CI step builds./engine/iouringwith the validation tag.Checks run:
go vetandgolangci-lintv2.13 onGOOS=linux, amd64 and arm64, with and without-tags=validation.For the #685 fix
hijackConnandshutdown. The witness covers onlyfinishClose/finishCloseDetached.