Repository navigation
conn: ReadAsync and FlushAsync park the connection through the same slot as the pipe adapters, so every wait is delivered exactly once and ResetRead has nothing left to do - #401
Merged
Conversation
This was referenced Oct 25, 2026
…lot as the pipe adapters, so every wait is delivered exactly once and ResetRead has nothing left to do After #399 a read could wait two ways - the pipe reader through TryReadOrPark, ReadAsync through its own copy of the arm protocol - and a flush signalled either an adapter or the connection's own source, picked by a null check. There is now one way for each. The connection is its own IRecvListener and IFlushListener: ReadAsync is TryReadOrPark(this), FlushAsync and FlushIfIdleAsync arm through the same ArmFlush as TryFlushOrPark, and the connection's OnParking readies its own value task, its OnRecvReady/OnFlushDone complete it. A parked read is one slot, _readWaiter, in place of the _armed flag and a separate listener field, and a read parks in three steps: a CompareExchange claims the slot with a mark no completion delivers to, the listener readies its value task, a second CompareExchange publishes the listener. A re-check after the claim catches a completion that landed just before it; one that lands while the listener readies marks the claim, and the read serves what it queued instead of parking. A completion takes a published park back with one CompareExchange, and only the side that takes it delivers. A read refused for overlapping or racing a parked one has touched nothing of it: the claim comes before the listener's value task, which two reads through one adapter, or two of ReadAsync's, share. What that fixes, on main unless noted: - ReadAsync's race recovery took the arm back without looking at the result, so when a recv had already taken it both delivered: the caller was served at once, and the recv completed the read's value task as well. That stale completion raced the caller's ResetRead and next read - the value task came out torn, or the next read, parked for real, looked complete and the one after it was refused as already armed, or the next recv's SetResult threw on the reactor. - ResetRead reset the value task even with a read parked on it, throwing the read's continuation away. A read now readies its own value task when it parks, so ResetRead is a no-op, kept for the callers written to the old contract. - CompleteFlush let go of the in-progress guard before disarming the flush: a flush that took the guard in the gap found it still armed and was refused, and the completion could signal the listener that flush had just stored in place of the one it was completing. The completion now disarms, takes its listener, then lets go of the guard and signals. - From #399, for two reads parking at once: the one refused had stored its listener before the exchange that refused it, so the parked one's delivery could go to it instead; and it had readied its listener before that exchange too, so two reads through one adapter could reset the value task the parked one's caller waits on. Main had neither: the pipe reader parked through ReadAsync, whose arm exchange came first. Tests (Unit, WaiterSlotTests). The races run 200,000 rounds each, a thread playing the reactor, and each asserts the race ran: a raw read racing its recv completions leaves the reads after it working; two reads parking at once cannot take each other's place; a read refused for racing a park of the same listener never readies that listener; two raw reads at once: the one refused leaves the parked one's value task to complete; a flush that starts while the last one's completion is delivering is neither refused as armed nor signalled in its place. Two are deterministic: ResetRead while a raw read is parked leaves the read to complete; a completion that lands while a read readies its listener is served by that read, which then lets go of the slot. Red-checked against #399's connection code, twice: six fail - the raw read (its value task torn, once thrown on the side playing the reactor), ResetRead, two parks (15,937 and 15,452 rounds), the same listener (15,574 and 17,110), two raw reads (every round, as ReadAsync there leaves readying its value task to ResetRead) and the flush (35 and 13). Against one CompareExchange claiming and publishing the slot after the listener readies: the same listener (9,907 and 19,176) and two raw reads (5,567 and 11,120). The last test guards the claim's own path, and passes on both: it fails when a completion leaves a claim unmarked (the read parks with the wake spent) and when a read served after a mark keeps the slot (the next one is refused). This branch passes all seven, three runs. Docs: the connection section of dev-core.
MDA2AV
force-pushed
the
fix/connection-one-park-path
branch
from
October 25, 2026 21:46
08ae546 to
200dc43
Compare
Merged
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.
#399 and #400 are merged, so this is now one commit on top of main,
200dc43.After #399 a read could wait two ways: the pipe reader through
TryReadOrPark, andReadAsyncthrough its own copy of the arm protocol. A flush signalled either an adapter or the connection's own source, chosen by a null check. Now there is one way for each:TcpConnectionimplementsIRecvListenerandIFlushListener.ReadAsyncisTryReadOrPark(this), andFlushAsync/FlushIfIdleAsyncarm through the sameArmFlushasTryFlushOrPark. ItsOnParkingreadies its own value task, and itsOnRecvReady/OnFlushDonecomplete it._readWaiterreplaces the_armedflag and the separate listener field.ResetReadis a no-op. A read readies its own value task when it parks. Callers written to the old contract keep working, including any that callResetReadwhile a read is parked, which used to throw that read's continuation away.The raw API stays as it is; only its insides changed. The public API is identical to main's (a reflection dump of the assembly, 900 lines).
How a read parks
CompareExchangetakes the slot with a mark no completion delivers to. A read racing it is refused having touched nothing, including the listener's value task, which two reads through one adapter (or two ofReadAsync's) share.OnParking). A re-check after the claim catches a completion that landed just before it.CompareExchangeputs the listener in the slot. A completion that came while the listener was readying marks the claim instead, and the read serves what was queued rather than parking.A completion takes a published park back with one
CompareExchange, and only the side that takes it delivers.A flush was already safe on ownership: it takes the in-progress guard first, then the listener readies, then the arm. What changes is the end: the completion now disarms the flush and takes its listener before letting go of the guard.
What it fixes
On main:
ReadAsync's double delivery. Its race recovery took the arm back without looking at the result. So when a recv had already taken the arm, both delivered: the caller was served at once, and the recv also completed the read's value task. That stale completion raced the caller'sResetReadand next read, with three outcomes:SetResultthrew on the reactor.The concurrency review of pipes: the TCP pipe reader and writer park on the connection itself, so a read or flush that waits has one value-task source, not two #399 hit it 403 times in 400,000 rounds. Run on main itself (
1fe45aa), this PR's raw-read race test broke 5 and 53 of 200,000 rounds, and in one of the two runs the thread playing the reactor threw fromSetResult. It reaches every raw reader:TcpConnectionStream, the userspace-TLS pipes, and the TLS handshake. On main that includes the pipe reader too, which is GenHTTP's path. The stress run behind pipes: the TCP pipe reader and writer park on the connection itself, so a read or flush that waits has one value-task source, not two #399 hit "ReadAsync already armed" there under thread hops.ResetReadduring a parked read reset the value task the read was parked on, and threw its continuation away. On main, the test's parked read is never resumed.CompleteFlushlet go of the in-progress guard before it disarmed the flush. A flush that took the guard in that gap reset the value task the completing flush's caller was waiting on. On main itself, two runs of 200,000 rounds each:CompleteFlush's own comment says that throw takes the process down.It takes a second flush starting while the first one completes, meaning writers that aren't serialized, which GenHTTP's websocket writes can be (tcp, tls: a second writer on a connection is not refused, and can corrupt memory #397). With pipes: the TCP pipe reader and writer park on the connection itself, so a read or flush that waits has one value-task source, not two #399's listeners, that flush could also be refused as already armed, or signalled in place of the one completing.
From #399, and so on main since it merged, when two reads park at once:
Main before #399 had neither: the pipe reader parked through
ReadAsync, whose arm exchange came first.Measured
wrkas in #399:wrk -t4 -c64on other cores, 2-byte body./proc/PID/stat. The kernel's TCP path is 92–95% of a request's CPU here.Run 1 had 12 rounds and five builds: main as it was before #399 and #400 (
1fe45aa), #399, this PR, the variant that claims and publishes with oneCompareExchange, and main again as the noise control.Tcp/PipeTcp/RawCompareExchangevariantRun 2 had 16 rounds,
Tcp/Pipeonly, with #399 twice. This PR vs #399 was +1.4% [−6.1..+10.1], and #399 vs itself −2.1% [−6.1..+6.6].What that says:
CompareExchange: it doesn't show.Tests
New, in
WaiterSlotTests(Unit). Five are races, each 200,000 rounds with a thread of the test's own playing the reactor's part. Each asserts that at least 1,000 rounds overlapped, so a run where the race never happened can't pass; this PR's runs overlap in about 22,000 to 142,000.conn: a raw read racing its recv completions leaves the reads after it workingconn: two reads parking at once cannot take each other's placeconn: a read refused for racing a park of the same listener never readies that listenerconn: two raw reads at once: the one refused leaves the parked one's value task to completeconn: a flush that starts while the last one's completion is delivering is neither refused as armed nor signalled in its placeTwo are deterministic:
conn: ResetRead while a raw read is parked leaves the read to completeconn: a completion that lands while a read readies its listener is served by that read, which then lets go of the slot. The completion is a wake from inside the listener'sOnParking, which is exactly the gap between the claim and the publish.Red-checked, two runs per variant:
CompareExchangeclaiming and publishing, after the listener readiesResetReadwhile a raw read is parkedReadAsyncthere leaves readying its value task toResetReadThe last test guards the claim's own path, which the other two variants don't have. Two breaks of that path, one at a time:
With a counter added, the Unit suite's races take the mark path 1,000–1,350 times per run, and the claim's re-check 9,000–18,000.
Docs: the connection section of
dev-core.All suites pass on top of main, #400 included: Unit 121, E2E 328, Http 89, Tls 194, Chaos 80, File 5, Pg 7 (+11 skipped), Redis (6 skipped, 1 pending).
tlsmodule was loaded, so no Tls case skipped.Merges cleanly with #393, #386, #384 and #363. Against #307 the only conflict is in
HardeningTests.cs, which #307 already has with main.🤖 Generated with Claude Code