Skip to content

fix(iouring): an async handler's direct write waits for a ring SEND of the conn's earlier bytes (celeris#751) - #800

Merged
FumingPower3925 merged 2 commits into
mainfrom
fix/celeris-751-async-write-order
Sep 28, 2026
Merged

FumingPower3925 merged 2 commits into
mainfrom
fix/celeris-751-async-write-order

Conversation

@FumingPower3925

@FumingPower3925 FumingPower3925 commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

With AsyncHandlers on io_uring, the dispatch goroutine writes each response straight to the socket (unix.Write(cs.fd, cs.writeBuf) at the end of a runAsyncHandler iteration). It did so without asking whether a ring SEND of the conn's earlier bytes was still in flight. Those bytes are the rest of the previous response: its own direct write was short, the goroutine left the remainder in writeBuf, and the worker moved it to sendBuf and submitted a SEND. A pipelined request answered while that SEND is outstanding went out ahead of it, in the middle of the previous response, and the client parses garbage. The h2c-upgrade exit's direct write of the 101 had the same gap.

The detached conns' guarded writeFn has always refused the raw write in that state (!cs.sending && !cs.zcNotifPending && len(cs.sendBuf) == 0 && len(cs.bodyBuf) == 0). epoll is not affected: its pending bytes stay in writeBuf, which the goroutine's own flush writes first.

Fixes #751

Failing-first

End to end, on current main. The issue's probe (lane EP-1's zz_probe_send_stall_linux_test.go, added by -overlay) re-run on main dfd044f, m8, -race -count=5: a client with a 4 KiB SO_RCVBUF pipelines /big (3 MiB) and, 150 ms later, /slow. In 5/5 runs /big arrived with the right length and the wrong bytes, and /slow's response failed to parse (malformed HTTP response "789abcdef0123456789abcdef…": the rest of /big arrived after /slow's head). Log main/logs/probe-sendstall-dfd044f-m8.log, script main/probe.sh.

The regression test is deterministic. TestAsyncResponseWaitsForAnInFlightRingSend plays the kernel and the client on one promoted async conn of an fdlFixture:

  1. Request 1 (/big, 1 MiB): the goroutine's direct write fills the socketpair (219,264 bytes), and the worker submits the other 829,419 as a ring SEND. The test captures that SEND and keeps it in flight.
  2. The client reads what arrived, so the socket is empty again.
  3. Request 2 is answered while the SEND is outstanding: arm response is GET /small, arm h2c_upgrade is an h2c upgrade (its 101).
  4. The test performs the SEND (it writes sendBuf to the socket) and completes it, and then any SEND the worker submits after it.

The client must then parse response 1 whole, byte for byte, and then the answer to request 2. No timing is involved: the window is an input.

main dfd044f with the test added by -overlay (byte-identical to this PR's, sha256 b7aa7fd7…, 751/logs/ff-test.sha256), -race -v -count=3, Docker linux/arm64, 4 CPUs; m8 is CI's shape (8 MiB memlock, one io_uring worker), unl has unlimited memlock:

arm main m8 main unl this PR m8 (-count=10) this PR unl (-count=10)
response FAIL 3/3 FAIL 3/3 PASS 10/10 PASS 10/10
h2c_upgrade FAIL 3/3 FAIL 3/3 PASS 10/10 PASS 10/10

0 data races in all four logs. On main, in every run, 103 bytes (response) or 143 bytes (h2c_upgrade) of the answer to request 2 reached the client while the SEND was in flight, and response 1's body differs at byte 219,157, where HTTP/1.1 200 OK\r\ndate: … or HTTP/1.1 101 Switching Protocols\r\n… begins. With the fix, 0 bytes arrive early, and the worker sends them in a second SEND after the first completes (sends=2). Logs 751/logs/{ff-dfd044f,fix-b340cbe-new}-{m8,unl}.log.

The fix

ringSendOutstanding(cs) is the guarded writeFn's condition: a SEND or its SEND_ZC notification is outstanding, or sendBuf/bodyBuf hold bytes the worker has taken from writeBuf and not yet sent. The worker mutates all four under cs.detachMu, which the goroutine holds here, so no SEND can start while it looks.

  • The response's direct write: when a ring SEND is outstanding, the bytes stay in writeBuf and the goroutine enqueues the conn (partial = true), as for a full socket buffer. The worker's completeSend sends writeBuf after that SEND, in order.
  • The h2c exit's direct write of the 101: the same. The bytes stay in writeBuf, and the exit's asyncH2Promoted entry puts the conn on the dirty list, where completeSend finds them.

The guarded writeFn keeps its own copy of the condition; this PR does not touch it.

Controls

Run by 751/suite.sh (stage ctl), m8, -race, each an -overlay of the fix head's worker.go (the tree is never edited). NEG is main's worker.go, copied from the pristine main worktree.

control what it removes verdict arms that fail
NEG the whole fix KILLED response, h2c_upgrade
M1 the response direct write's check KILLED response only
M2 the h2c-upgrade direct write's check KILLED h2c_upgrade only

Log: 751/logs/controls-m8.log.

Suites

Docker linux/arm64, 4 CPUs, seccomp=unconfined, at this head (b340cbe); logs 751/logs/.

suite shape PASS FAIL SKIP data races rc
./engine/iouring -race -v m8 (1 worker) 342 0 5 0 0
./engine/iouring -race -v unl 345 0 2 0 0
./adaptive/... -race -v unl 115 0 0 0 0
root package -race -v m8 544 0 4 0 0

Every skip is the environment's: at m8 the three TestListenCloses… init-failure tests need two workers' memlock; in both shapes the two TestPauseAccept…SynackRetriesZero tests need net.ipv4.tcp_synack_retries=0 (the VM reads 5); in the root package the three io_uring TestAdaptiveSettledRouteRetime592 subtests skip at 8 MiB (#709), and TestRouteAdaptive_SettleReopenCost is opt-in.

Cost

Measured under the laptop's timing lock (no other container running: containers_at_start=[] and containers_at_end=[] in the log), linux/arm64, -count=10, benchstat -col /impl (bench.sh; the benchmark is 751/bench/zz_bench_direct_write_gate_linux_test.go, added by -overlay, not committed):

arm what it runs sec/op
impl=main the direct write as main does it: 100 bytes to /dev/null 147.6 ns ± 6%
impl=fix ringSendOutstanding, then the same write 143.8 ns ± 2% (vs main: ~, p=0.093)
impl=gate the check alone 1.641 ns ± 0%

0 B/op and 0 allocs/op in all three. /dev/null is the cheapest write(2) there is, and a socket write costs more, so the check is at most about 1.1% of the path it guards, and below the noise of that path. No lock and no atomic are added: the four fields are read under the detachMu the goroutine already holds. The end-to-end bound is a perf-checkpoint row in the cluster queue, the io_uring async columns: PASS iff no RPS or CPU/req regression beyond the detectable floor, both arches. Logs bench-logs/751-gate.log, bench-logs/benchstat-751.txt.

Cluster

Queued, not dispatched (evidence/_queue/cluster.tsv, lane EP-3): the ordering test on bare metal, both arches, -race -count=20; PASS iff both arms PASS 20x per arch with 0 FAIL/SKIP/race and every celeris751 ORDER line reads early=0 sends=2 with the window entered (tail>0). And the perf-checkpoint row for the io_uring async columns (see Cost).

Related: #811

A response left behind an outstanding SEND puts the conn on the worker's dirty list, where main already puts it when the socket buffer is full, and a listed conn whose SEND waits for a stalled client makes the worker busy-poll. Measured with a stalled client and a pipelined second request: 368 to 374 ms CPU per second on main, 371 to 376 ms on this head, so this PR does not change it. That pre-existing defect is #811.

Relation to #750

#750 (a SEND completion parks the worker on a running handler's detachMu) is fixed separately in #801. The two are independent diffs. With both, a completion held for the goroutine keeps cs.sending set, so this check keeps the next response in writeBuf until the completion is applied.

Follow-ups

The review's minor findings and nits are in #815:

Reproduce

Every number above comes from evidence/lanes-20260927/EP-3/751/suite.sh (stages ff fix ctl pkg amd64), 751/make_mutants.sh, main/probe.sh, bench.sh and tools/ in the probatorium evidence tree.

…f the conn's earlier bytes (celeris#751)

The dispatch goroutine wrote each response straight to the socket without
asking whether a ring SEND of the previous response's tail (a short direct
write the worker finished with a SEND) was still in flight, so a pipelined
response went out in the middle of the previous one. The h2c-upgrade exit's
direct write of the 101 had the same gap. Both now leave writeBuf to the
worker while a SEND or its notification is outstanding or sendBuf/bodyBuf
hold bytes (the condition the detached conns' guarded writeFn has always
tested, read under the detachMu the goroutine already holds); completeSend
sends it after that SEND, in order.
@FumingPower3925 FumingPower3925 added this to the v1.6.0 milestone Sep 28, 2026
@FumingPower3925 FumingPower3925 added bug Something isn't working area/engine Engine interface or implementation platform/linux Linux-specific (io_uring, epoll) engine/iouring io_uring engine specifics labels Sep 28, 2026
@coderabbitai

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: goceleris/celeris/.coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 16934a9f-be90-4a37-b0d8-4af85bb9ec86

📥 Commits

Reviewing files that changed from the base of the PR and between b340cbe and 692593f.

📒 Files selected for processing (1)
  • engine/iouring/worker.go

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 6 remain after this review.


📝 Walkthrough

Walkthrough

The async response paths defer direct writes when earlier ring output remains outstanding. A Linux-only test checks response ordering for pipelined requests with a normal response and an h2c upgrade.

Changes

Async response ordering

Layer / File(s) Summary
Guard async direct writes
engine/iouring/worker.go
The async response and H2-upgrade paths defer direct writes when a ring send, notification, or staged ring buffer is outstanding. ringSendOutstanding checks these conditions.
Verify pipelined response ordering
engine/iouring/async_write_order_test.go
The Linux-only test simulates SEND completion while a second request is dispatched. It checks that the first response body is intact and precedes the second response for normal and h2c-upgrade cases.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix · Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to 69259

Later response bytes remain queued until earlier output completes, including for h2c upgrades. No actionable merge-blocking risk is established.

Architecture Summary

Architecture risk: 🟡 Medium · up to 69259

The change affects 1 system.

Changed systems: engine

Architecture concerns
No architecture-level concerns identified.

Review details

Systems and components

  • observed — engine (service) was modified; 2 changed files map to changed impact.

Before / after behavior

  • observed — Modified behavior in engine/iouring/async_write_order_test.go: Added a Linux-only test file’s imports and comments describing the async-write ordering scenario, along with a large patterned response body used to force a partial direct write.
  • observed — Modified behavior in engine/iouring/async_write_order_test.go: Added a handler that returns the large body for /big and ok for other paths, with content type and length headers.
  • observed — Modified behavior in engine/iouring/async_write_order_test.go: Added a nonblocking socket client reader and a helper that waits for the dispatch goroutine to become idle or exit, failing if it does not finish within 10 seconds.
  • observed — Modified behavior in engine/iouring/async_write_order_test.go: Added a helper that simulates in-flight SENDs by writing sendBuf to the socket, draining the client when the socket is full, completing each SEND, and repeating worker processing. It fails on unexpected SQE types, unresolvable socket backpressure, or more than 16 SENDs.

Reliability and maintainability

  • inferred — Risk-relevant change factors for engine: blast_radius_1; direct_dependents_1
🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning Issue #751 requires ordering tests, guards on both direct-write paths, and a cost measurement. engine/iouring/async_write_order_test.go tests plain and h2c-upgrade responses while a simulated SEND i… Add and report a benchmark or cluster measurement that compares the guarded path with an unguarded baseline and records the cost.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title uses the required conventional-commit form, identifies the io_uring fix, summarizes the ordering change, and ends with the issue reference (celeris#751).
Description check ✅ Passed The description directly explains the io_uring response-ordering bug, the guard in worker.go, the regression tests, and validation results.
Out of Scope Changes check ✅ Passed The diff changes only engine/iouring/worker.go and adds engine/iouring/async_write_order_test.go. The changes implement issue #751 ordering guards and regression tests. No unrelated change is show…
Full details: Linked Issues check

Explanation

Issue #751 requires ordering tests, guards on both direct-write paths, and a cost measurement. engine/iouring/async_write_order_test.go tests plain and h2c-upgrade responses while a simulated SEND is outstanding. engine/iouring/worker.go guards both paths with ringSendOutstanding, which checks SEND, notification, sendBuf, and bodyBuf. No benchmark or cluster measurement reports the guard cost; that check remains queued.

  • Fix all pre-merge checks with AI

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

@codecov

codecov Bot commented Sep 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

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 bug Something isn't working engine/iouring io_uring engine specifics platform/linux Linux-specific (io_uring, epoll)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

io_uring: an async handler's direct write goes out while a ring SEND of the previous response is still in flight, interleaving pipelined responses

1 participant