Skip to content

test(iouring): deterministic check of the #715 recv-theft window - #781

Closed
FumingPower3925 wants to merge 3 commits into
mainfrom
test/celeris-715-recv-theft
Closed

FumingPower3925 wants to merge 3 commits into
mainfrom
test/celeris-715-recv-theft

Conversation

@FumingPower3925

@FumingPower3925 FumingPower3925 commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

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_closed goes up by 1, and the recorded bytes are exactly B's request. B is never answered.

arm what differs prediction under (a) result, all shapes
A (TestRecvTheft715ArmA) nothing (the tree as it is) B's request stolen, B unanswered stolen and unanswered in 80 of 80 trials
control (TestRecvTheft715Control) the close paths submit the ring before unix.Close 0 thefts 0 thefts, B answered in 80 of 80
C (TestRecvTheft715ArmC) hypothesis (c): the unlock-to-Signal window in promoteConnToAsync and the async feed, widened to 2 ms on every hand-off 0 lost if that window loses no wakeup 0 lost of 19,200 requests, 12,800 widened hand-offs

Arm A fails on main on 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:

It does not show:

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.go starts an async-handler engine with two io_uring workers, then:

  1. It opens /dev/null until 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.
  2. A sends GET /close with Connection: close (an async route, handler writes nothing). The worker W1 that owns A:
    • bails from the inline parse, promotes A and re-arms A's recv (promoteConnToAsync);
    • waits at the gate until the dispatch goroutine has queued A (recvtheft.Options.PromoteGate, the one test-only step on this path);
    • drains the queue and closes A (finishCloseDetached). The witness finds A's recv SQE not yet consumed by the kernel, so W1 counts it and parks right after unix.Close (recvtheft.HoldAfterClose).
  3. The test dials candidates for B and sends B's request on each. A candidate that hashes to W1's listener waits in W1's accept queue. The first one the sibling W2 accepts gets the freed number, and W2 parks right after it prepares B's recv, before it submits (recvtheft.AfterAccept).
  4. The test releases W1, which submits, then W2, and reads B's response with a 2 s deadline.

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/SKIP lines and one RECVTHEFT715 ... result line per trial. Tally script: evidence/celeris-715/hypothesis-a/scripts/tally.py. The tallies ran at ad3ffb7. The two commits after it change only the production layout test. Its first version compared two offsets that kernelInflight's own alignment keeps 2 bytes apart, and failed in a laptop container.

shape arch, kernel processes x -count arm A: FAIL, and stolen + unanswered control: PASS, and stolen arm C: PASS, and lost
GitHub-hosted, celeris CI shape (4 vCPU, -race, memlock unlimited) x86, 6.17.0-1022-azure 5 x 4 20 of 20; 20 of 20 20 of 20; 0 20 of 20; 0 of 4,800
same run arm64, 6.17.0-1022-azure 5 x 4 20 of 20; 20 of 20 20 of 20; 0 20 of 20; 0 of 4,800
laptop container, CI shape (--cpus 4, -race, memlock unlimited) arm64, 7.0.12-linuxkit 1 x 20 20 of 20; 20 of 20 20 of 20; 0 20 of 20; 0 of 4,800
laptop container, unconstrained (8 CPUs, no -race, memlock unlimited) arm64, 7.0.12-linuxkit 1 x 20 20 of 20; 20 of 20 20 of 20; 0 20 of 20; 0 of 4,800
total 80 of 80; 80 of 80 80 of 80; 0 80 of 80; 0 of 19,200
  • In every trial of both arms (160 of 160), the gate fired and close_with_unsubmitted_recv went up by exactly 1: both arms reached the same precondition.
  • stale_recv_data_closed went up by 1 in every arm A trial, and by 0 in every control trial.
  • In every arm A trial the stale completion came from the closing worker, on the freed number, with 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.
  • Not a result: at memlock 8 MiB (the CI unit job's shape) the engine starts one io_uring worker, so there is no sibling. With CELERIS_REQUIRE_IOURING_WORKERS=1 the tests fail that way (workers=1) instead of skipping.
  • A smoke run before the tallies (unconstrained, -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_REUSEPORT listener (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):

# unconstrained: 8 CPUs, memlock unlimited, no -race
docker run --rm --ulimit memlock=-1:-1 --security-opt seccomp=unconfined -v "$TREE":/src:ro -w /src \
  -e CELERIS_RECV_THEFT_715=1 -e CELERIS_REQUIRE_IOURING_WORKERS=1 golang:1.27 bash -c '
    go test -tags=validation -c -o /tmp/iu.test ./engine/iouring/ && cd engine/iouring &&
    for arm in ArmA Control ArmC; do /tmp/iu.test -test.v -test.count=20 -test.run "^TestRecvTheft715${arm}\$"; done'
# CI shape: the same with --cpus 4 and go test -race

GitHub-hosted runners, both arches, the celeris CI shape (-race, memlock raised as the iouring job raises it): probatorium celeris-stress.yml run 36351992181:

gh workflow run celeris-stress.yml --repo goceleris/probatorium --ref stress/runs -f target=github -f mode=stress \
  -f celeris_ref=ad3ffb707be6c8bbcb9d8c4e76e30245d07353de -f packages=./engine/iouring \
  -f run='^TestRecvTheft715(ArmA|Control|ArmC)$' -f count=4 -f shards=5 -f arches=both -f memlock=unlimited \
  -f race=true -f timeout=20m -f extra='-tags=validation CELERIS_RECV_THEFT_715=1 CELERIS_REQUIRE_IOURING_WORKERS=1'

That run's summary is red because arm A fails, as designed.

Changes

All of the instrumentation is validation-only, following the internal/zcwindow pattern from #693. recvtheft.Enabled is a false constant without -tags=validation, so every call site compiles away. A production engine/iouring test binary contains no recvtheft symbol, and neither recvUnsubmitted nor 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-size ArmSeq.
  • engine/iouring:
    • noteRecvPlaced records each recv SQE's SQ sequence number;
    • finishClose / finishCloseDetached count a close with an unsubmitted recv, park when a trial is armed, and in the control arm submit before closing;
    • onAcceptedFD, promoteConnToAsync and the async feed get their hooks;
    • staleConnCQE hands a stale data completion's first bytes to the trial;
    • Ring gains sqPlaced / sqConsumed.
  • connState.recvArmSeq is zero-size in production and not the last field. TestRecvTheftWitnessCompilesAway pins that. It is a production-build test, and it passed in this PR's Unit job. Sizeof(connState) is 600 bytes on linux/amd64 and linux/arm64, the same as main. Every field after recvArmSeq keeps its offset. Under -tags=validation the 4-byte field fits in existing padding, so the size is still 600.
  • The three TestRecvTheft715* tests build only with -tags=validation, run only with CELERIS_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/iouring with the validation tag.

Checks run: go vet and golangci-lint v2.13 on GOOS=linux, amd64 and arm64, with and without -tags=validation.

For the #685 fix

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
@FumingPower3925 FumingPower3925 added this to the v1.6.0 milestone Sep 27, 2026
@FumingPower3925 FumingPower3925 added area/engine Engine interface or implementation engine/iouring io_uring engine specifics labels Sep 27, 2026
@coderabbitai

coderabbitai Bot commented Sep 27, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 34.21053% with 25 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/recvtheft/recvtheft_off.go 0.00% 10 Missing ⚠️
engine/iouring/handoff_loss.go 0.00% 6 Missing ⚠️
engine/iouring/worker.go 72.22% 5 Missing ⚠️
engine/iouring/ring.go 0.00% 4 Missing ⚠️

📢 Thoughts on this report? Let us know!

FumingPower3925 added a commit that referenced this pull request Sep 28, 2026
… 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
@FumingPower3925

Copy link
Copy Markdown
Contributor Author

Superseded by #793, the #685 fix

#793 carries this PR's three test commits (ad3ffb7, 64ba91a, 37db222) unchanged, as 7b661f1, abddf38 and 12b700c. On top of them it adds one test change: arm A's verdict now passes on a fixed tree.

  • Why the verdict had to change. The fix keeps a closing connection's descriptor number allocated until its owed recv has ended. So the sibling worker can no longer be given that number, and every attempt would have read INCONCLUSIVE.
  • What counts as a hit now. The sibling accepting B while the closer is parked, on whatever number. The trial also records:
    • reused: whether the sibling got A's number;
    • held_open: whether A's number was still open at the hold;
    • released: whether it was closed afterwards.
  • The verdict is unchanged. B must be answered, and no stale recv may carry B's bytes.

Arm A on the tree without the fix:

On #793's head, arm A passes:

  • CI run 36366730324: 5 of 5 on each arch.
  • Laptop: 40 of 40, with held_open true, reused false and released true in every trial.

#793 also enables the trials in CI (a new recv-theft job), and it adds the linked-recv and hijack forms.

I am leaving this PR open for whoever closes it.

FumingPower3925 added a commit that referenced this pull request Sep 28, 2026
…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
FumingPower3925 added a commit that referenced this pull request Sep 28, 2026
… 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
FumingPower3925 added a commit that referenced this pull request Sep 28, 2026
… 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
@FumingPower3925

Copy link
Copy Markdown
Contributor Author

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, theftHit), so that the arm passes on the fixed tree and still fails every trial on a tree without the fix. The trials run in the recv-theft CI job on both arches. #715 stays open until a no-fault container leg on the fix shows 0 events.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/engine Engine interface or implementation engine/iouring io_uring engine specifics

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant