Repository navigation
tcp: a Segmented connection keeps its own spill segments, each twice the last, so a writer off the reactor never shares one with another connection - #406
Merged
Conversation
… reactor and the reactor's send completions never hand one slab to two connections The pool of Segmented overflow slabs was a bare Stack<nint>, commented reactor-thread only. It is not: RentOverflow rents on the writer's thread, wherever its handler runs, and CompleteFlush returns on the reactor's. A handler that resumes on the thread pool writes from there - an await with ConfigureAwait(false) on a task the reactor completed is enough, since the reactor thread's synchronization context is not the default one and .NET queues that continuation to the pool. So one connection's rent overlapped another's return, and a push and a pop that overlap hand a slab to two connections or keep it twice: each wrote into the other's response, and the shutdown drain freed the duplicates twice (#402). Rent and return take a lock now, and so does the shutdown drain, where a live handler may still rent. It is only taken past the primary slab, where a response already pays for a vectored SENDMSG. Tests: "reactor: a write slab is never out to two renters at once, whichever threads rent and return it" (Unit, WriteSlabPoolTests: four threads, 100,000 rounds each, asserting the rounds overlapped and the slabs came back out of the pool), and "core: two connections writing past their slabs from the thread pool each get only their own bytes (Segmented)" (E2E, CoreTests: two clients, 60 requests of 20-200 KB each against a 4 KB slab, every byte checked). Red-checked against main's pool, twice: the Unit test hands out a held slab in 192,346 and 206,971 of 400,000 rents, and the E2E test reads a wrong byte at 28,672 and 8,192 - slab boundaries - before the process aborts with "corrupted size vs. prev_size while consolidating". Locked, both pass, three runs.
The repo targets net10 and net11, so lock (_writeSlabGate) compiles to Lock.EnterScope instead of Monitor. Same cost: a rent-and-return pair measured 23.1 ns uncontended on .NET 10, against 24.2 ns with an object.
…f the reactor never shares one with another connection Replaces the lock on the reactor's slab pool with a stack per connection. Its writer rents from it and the reactor returns to it once that connection's send has completed, and the flush already orders those two, so it needs no lock. Freed on recycle, as Grow's overgrown slab is shrunk back. SpareSlabTests replaces WriteSlabPoolTests, whose pool is gone, and the stop tests count the connection's spares instead of the reactor's. Each changed assertion confirmed both ways, flipped to the defect's value: 4 slabs were kept between responses, not 0; none after the recycle, not 4; none outlived the reactor, not the spilled count.
…ection, so a response that spills a lot spills into few Each further segment of a response is twice the last, from twice the slab up to 64 slabs, and the segments stay in _ov for the connection's next response, segment k reusing segment k, until recycle. Replaces the per-connection stack of slab-size spares; the flush still orders a connection's rent and return, so there is still no lock. SpillSegmentTests replaces SpareSlabTests. Each assertion confirmed both ways, flipped to the defect's value: new segments for the second response, none kept, kept past the recycle, and slab-size segments all fail, and so does the stop test asserting its kept segments outlive the reactor.
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.
Fixes #402.
With
WriteOverflow = Segmented, a response that outgrows the write slab spills into extra fixed-size slabs. Those came from one pool per reactor: a bareStack<nint>that every connection on the reactor shared, commented "Reactor-thread-only, so no locking". Two kinds of thread used it:RentOverflow), wherever the handler runs.CompleteFlush) or a connection was recycled (Clear). It also happened on the writer's thread, for a flush on a closed connection (DropStaged).A handler that resumes on the thread pool writes from there, and one
await … ConfigureAwait(false)on a task the reactor completed is enough to put it there. One connection's rent then overlaps another connection's return. A push and a pop that overlap hand one slab to two connections, or keep it in the pool twice. Each connection then writes into the other's response, and the shutdown drain frees the duplicates twice.The fix: each connection keeps its own spill segments, so nothing is shared and nothing needs a lock.
_ov, the connection's list of segments, and its next response reuses them in order, segment k for segment k. One connection's rent and return can't overlap: the writer rents while it builds a response, the reactor returns only after that send has completed, and writes are refused in between. That flush handoff is what already protects the primary slab.Clearfrees the kept segments, the same way it shrinks Grow's overgrown slab back, so a pooled connection keeps only its base slab.What changes for memory: a connection keeps the segments of its biggest response until it's recycled, which with doubling can be up to about twice that response. Grow, the default, already keeps its grown slab the same way. The shared pool took slabs back after every send and kept up to 4,096 per reactor. Segments also aren't shared across connections any more, so a new connection's first spill allocates.
Earlier revisions are still in this PR's history. The first put a lock on the shared pool, which let the reactor wait on a thread-pool thread holding it. The second kept a per-connection stack of slab-size spares, which lost more locality as connections grew (see below).
Tests
core: two connections writing past their slabs from the thread pool each get only their own bytes (Segmented)(E2E,CoreTests), unchanged from the first revision.corrupted size vs. prev_size while consolidating.tcp: a Segmented connection reuses its spill segments for its next response, and frees them when it is recycled(Unit,SpillSegmentTests): spills 3 segments, completes the send and spills 3 again. The second response must get the same 3 in the same order, the connection must keep exactly 3, and none afterClear.tcp: each spill segment of a Segmented response is twice the last, from twice the slab up to 64 slabs(Unit,SpillSegmentTests): spills 8 segments from a 4 KB slab and checks their sizes, 8 KB doubling to 256 KB, then 256 KB.reactor: a stop frees its pooled connections and its spare write slabstests read the reactor's pool by name, so they now count the connection's kept segments. Flipped to the defect's value (they outlive the reactor), the segmented one fails with 0 left.All suites pass: Unit 123, E2E 329, 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. The Pg and Redis skips need servers this box doesn't run.Benchmark
A small HTTP/1.1 server, not in the repo: Segmented, a 16 KB slab, and 64 KB bodies written in 4 KB spans, so every response spills. 2 reactors on CPUs 2–3,
wrk -t4on CPUs 16–19, a 2 s warm-up then 5 s, builds in rotated order. Medians of server CPU per request:Connection: close, 64 connections (4 rounds)Connection: close: 3% above main. Each new connection allocates its segments and frees them on recycle, where the pool reused slabs it already had.Merges cleanly with #363, #384, #386, #393, #407 and #408. Against #307 the only conflict is in
HardeningTests.cs, which #307 already has with main.API: none.
🤖 Generated with Claude Code