Repository navigation
epoxide: ioxide's thread-per-core runtime on epoll, whose connections any thread can read and write - #384
Open
MDA2AV wants to merge 25 commits into
Open
epoxide: ioxide's thread-per-core runtime on epoll, whose connections any thread can read and write#384MDA2AV wants to merge 25 commits into
MDA2AV wants to merge 25 commits into
Conversation
… any thread can read and write directly
… raw and thread-pool samples
… chaos suites on it Epoxide.Tests.Harness is tests/Ioxide.Tests.Harness against src/epoxide only: Runner (plus a name filter and KnownCoreBug, for tests that reproduce an unfixed core bug and stay out of the green run unless EPOXIDE_KNOWN_CORE_BUGS=run), TestServer with the same per-process port windows, Client, TestCert (cached under epoxide-e2e-*), Handlers and Wire (partial, so the TLS suite adds its handlers beside them), TcpSockets, FdCount, Forwarder, FakeServer, ExecInheritance, ReactorAffinity and Externals. Dropped: H3TestClient, CurlH3, the UDP/QUIC/file-client starters. Internals reads what epoxide does not expose (recv-pool counts, generations, refs) by UnsafeAccessor and reflection, and Hop moves a handler to the thread pool. Epoxide.Tests.Chaos carries ChaosClient, ChaosServer, TcpChaosTests (13) and ReactorChaosTests (5; the incremental-ring case has no epoxide counterpart). 18 passed.
…d and write - each one serializes its own BoringSSL calls instead of posting them to the reactor
… samples line for line on epoll
…ssions used from the thread pool - two tests reproduce a core bug and are listed as known
…e, with epoxide's own thread-pool and connect tests E2E ports tests/Ioxide.Tests.E2E/Core (all but QuicTests and UdpTests). Where a test guards something only io_uring has, it is translated to what it protects on epoll: the recv-queue overflow cases become a reader that falls behind getting every byte, the op-table growth cases become a burst of timer waits and waits armed from other threads, the CQ-overflow case becomes more ready connections than MaxEvents, the send-in-flight cases drive the reactor's end with a reset, and the recv-buffer reclaim cases count the reactor's pool, since epoxide's pool grows instead of running dry. SEND_ZC, MSG_WAITALL, incremental buffer rings and ring setup have no counterpart. New: ThreadPoolTests (11) - a handler on the thread pool reads, writes and flushes there; parked reads and flushes awaited from the pool resume on it, an awaiter on the reactor resumes inline (counted through a wrapping SynchronizationContext installed in OnStart); full duplex on two pool threads; peer resets mid-flush and mid-read; DecRef from the pool in both orders; Stop() with pool-thread handlers holding connections; 48 clients served entirely from the pool. ConnectTests (10) - ConnectAsync from the pool and from the reactor, to epoxide and to a plain socket, refused and unreachable, cross-reactor resumption, and after Stop(). Unit: VersionTests, ClockTests, SyscallErrnoTests (on the epoll and socket wrappers, with a control showing an import without SetLastError loses the errno), ConfigValidationTests (the construction half of RingSetupMessageTests). Three tests reproduce core bugs and are registered with KnownCoreBug, out of the green run: E1 a ConnectAsync racing Stop() never settles (20 of 20), E2 RecvBufferSize/WriteSlabSize below 4 throw from inside BufferPool naming 'bufferSize', E3 MaxEvents has no upper bound. E2E: 97 passed, 0 failed, 1 known. Unit: 14 passed, 0 failed, 2 known.
…ex thread-pool test checks its two loops overlap in time rather than per operation
…stop strands what only its reactor would run, a listener-less reactor has no clocks, a 32 MiB buffer wraps the pool's chunk size E4 (two tests): a handler on its reactor parked on a continuation posted back to it, or on a ReactorTimer, never resumes once Stop() has run - the loop that would run either has exited - so it never lets go and its descriptor stays open for the life of the process. E5: ServerConfig.Tcp == null registers no TCP sweep, so ConnectAsync's connections get none of TcpOptions' clocks: a flush its peer stopped draining was still parked 90 s into the default 60 s send timeout. E2, the large end: BufferPool sizes a chunk as bufferSize << 6 in int, so a 32 MiB RecvBufferSize wraps and the first recv throws OutOfMemoryException. Each fails as described when run (EPOXIDE_KNOWN_CORE_BUGS=run), and passes against a scratch copy of epoxide with the patches proposed in the report, with every suite still green there (Unit 16/16, E2E 102/102, Chaos 18/18). The full-duplex thread-pool test no longer sends a separate go-byte: the first recv could carry it with the start of the stream, and the handler's drain of it swallowed stream bytes. The client now pauses now and then on each side, so both server directions park every run (it had once parked a single flush). Discrimination: the key assertion of each new or translated test (43 of them) was flipped to the defect's expectation in this tree, built and run - every one failed against the real values - and restored. Unit 14 passed, 2 known; E2E 97 passed, 5 known; Chaos 18 passed - twice each.
…st epoll's limit Probed: at INT_MAX / sizeof(struct epoll_event) Run works; one above, MaxEvents * the event size wraps in int and the event buffer's allocation throws OutOfMemoryException before any epoll_wait.
…t that covers what epoxide has, ported to tests-epoxide/, and epoxide's own thread-pool and connect tests
…oll - the same BoringSSL and kTLS - with its suite, its chaos tests and two samples # Conflicts: # bench/samples.tsv # ioxide.slnx
…ps at an urgent mark, no longer counts as drained - the next read ends at once instead of at the read timeout Linux's tcp_recvmsg returns the queued data and takes a FIN queued behind it in the same call, so the recv comes back short with n > 0, and the edge that reported both is already spent: the next ReadAsync parked until ReadTimeoutMs, and with 0 for good. An edge that carries EPOLLRDHUP, EPOLLHUP, EPOLLERR or EPOLLPRI - registered now, since a recv stops short at an urgent mark the same way - sets ReadUntilEmpty for the rest of the connection's life, with a full fence before OnReadable so the reader's next CAS-then-recv sees it. Found by both the TLS port and the core review: 128 to 197 of 200 connections waited out the read timeout, depending on the shape; none in over 50,000 since. The two TLS tests that reproduced it run now, beside a plaintext one - a request and the FIN back to back, inline and on the thread pool - which fails with the fix reverted (3 inline handlers still reading 3 s after the FIN).
… lock, so a release from another thread, or the reactor's reclaim at recycle, cannot meet a read A thread-pool handler that releases its adapter after DecRef - `using var`, or Complete() below a try/finally that lets go - did it while the reactor recycled the connection and reclaimed the same buffers. The pipe reader threw a NullReferenceException on the reactor, which ended it; the stream returned a buffer twice, linking the pool's free list into a cycle that hands one buffer to every connection. Complete or Dispose from another task while a read was parked or completing raced the read's ingest the same way. Each adapter takes its own lock where its state moves - ingest, advance, release - and not for a read that finds nothing held; the caller resumes outside it. A read already parked when the caller completes still gets what lands, as in ioxide; only the reclaim at recycle stops it, since what is left is the reactor's to return then. The recv queue's indices carry on across lives instead of restarting at 0, so a snapshot kept from a previous life finds nothing to take. Two tests. Released after DecRef on the pool, 5,000 connections per adapter: with the fix reverted, the exception kills the reactor and the rest are refused. Released by another task while a read loop runs, 400 per adapter: 395 of 400 finished with the fix reverted.
…at takes it back on close cannot own it at the same time OnWritable read WrParked and sent, while TryParkFlush's closed re-check could take the same flush back (Parked -> Idle) and run EndFlush: the reactor then went on sending from a reset slab and over overflow slabs already returned, and ran EndFlush and SetResult a second time. It now claims the flush (Parked -> Idle) before sending, and parks it again when the socket is still full or the send failed. Found by the core review, proven with ordering hooks in a scratch copy: writerReclaims=1 and reactorSentAfterWriterReclaimed=1 before, writerReclaims=0 after. The window is the few nanoseconds between the writer's two CASes, so no test reaches it unaided.
…ation queued for it, a continuation posted to it, its timers A ConnectAsync from another thread that raced Stop() stayed in _registerQ, which the teardown never drained: the connect never settled, and its socket and the live count leaked, so the pools were never freed (20 of 20 rounds). A handler parked on a continuation posted back to its reactor, or on a ReactorTimer, never resumed once the loop had exited, so it never let go and its descriptor stayed open for the life of the process. The teardown fences after setting _stopped - producers enqueue, then read the flag - and drains the registrations (refused, which settles the connect), the posts and the timers. A cancelled timer completes with ReactorTimer.ECanceled (-125), which Expired() refuses. Anything posted or armed after the stop runs on the thread pool. The three tests that reproduced these run now.
…so their read and send timeouts and the deferred FIN apply The TCP sweep was registered only when ServerConfig.Tcp was set, so ConnectAsync's connections on an outbound-only reactor got none of TcpOptions' clocks: a flush its peer stopped draining was still parked 90 s into the 60 s send timeout. Such a reactor registers the sweep with its first outbound connection. The test that reproduced it runs now; it takes the 60 s default.
… buffer constructs as it does in ioxide BufferPool sized a 64-buffer chunk as bufferSize << 6 in int: a 32 MiB RecvBufferSize wrapped, asked for about 16 EiB, and every recv threw OutOfMemoryException. MaxEvents times the event size wrapped the same way in Run; a MaxEvents above what epoll_wait accepts is refused at construction now, by name. A buffer smaller than the pool's four-byte free-list link is spaced four bytes apart instead of refused with the pool's own parameter name. The three tests that reproduced these run now.
…s unseen, the README lists the TLS suite, and CI runs all four suites RunGuarded checked ObservedDeaths and then recorded the failure, so a claim landing in between left the death reported as "a test reactor died and no test observed it" (once in 8 TLS runs). It records first and withdraws when claimed; the test claims before it removes, so one of the two sees the other. ioxide's harness has the same order.
…n a row, so a peer that keeps its buffer full cannot hold every other connection on that reactor A reader receives on its own thread while the socket has data, and a recv that fills its buffer leaves the read Ready, so the next ReadAsync reads again at once. On the reactor's thread - a handler running inline - a peer that sends faster than the handler consumes keeps every recv full, and the handler never gave the loop back: every other connection on the reactor, and this one's own parked flush, waited for good. ioxide cannot do this, since a reader there drains only what the ring has already delivered. With 4 KiB buffers, a flooding client left another connection's pings unanswered (1 of 100, then 3 s timeouts); with 32 KiB buffers it took a reader slower than loopback - TLS decrypt is - which is how a TLS test with a writer on another thread came to stall once in 40 runs: the reactor never got to the flush's writable edge. Past 16 reads in a row on the reactor's thread the reader parks and is queued to be attended on the next pass, where AttendNow reads for it as an edge would. The pings are then answered 100 of 100 (p99 3.4 ms under the flood). The new test fails with the budget off (0 and 2 of 20 answered) and passes with it.
…waits anything but its connection continues on the thread pool and keeps using the connection there ioxide's context brings a handler back to its reactor after an await of a timer, an HttpClient or a Task.Run, because only the reactor may submit to its ring. epoxide has no such rule - any thread reads and writes a connection - so nothing needs the handler back, and the context is gone. A handler starts on the reactor, and a read or flush that waits still resumes it inline there; an await of anything else continues where it completes. One the reactor's thread came with is cleared, since the loop would keep it from running what is posted to it. An async void that throws is no longer caught by the reactor: it ends the process, as anywhere in .NET. An await of another reactor's connection resumes on the pool, never on that reactor. The fault path depended on the context. A faulted handler's ref is released after a generation check, which is only atomic on the thread that recycles the connection: from a pool thread, the connection could be recycled and reused between the check and the release, letting go of its next life under its new handler. The release is posted to the reactor when the handler faulted elsewhere. With a delay injected between the check and the release, 4 of 5 next lives were cut off before their response without the post, twice, and 0 of 5 with it, three times. ScheduleOnReactor keeps the post queue for what has to run on the reactor (timers armed from another thread among it). The affinity tests pin the new contract, and the counting context the inline tests used is gone: a resume that is not inline now lands on the pool, which OnReactorThread sees.
…tes it, wherever it awaited from, so a handler that went to the thread pool is back on the reactor at its next read Without the reactor's context, a handler that awaited anything else stayed on the pool for the rest of its connection: every later read or flush that had to wait was resumed through a pool dispatch, a cross-thread hop per wait. A handler that awaited once at the start of a keep-alive connection served a quarter fewer requests than with the context. The reactor is the thread that sees the event, so running the awaiter there costs no hop. Reads, flushes, ReactorTimer waits and ConnectAsync resume their awaiter inline on the reactor that completes them, wherever it awaited from; one with a SynchronizationContext or TaskScheduler of its own still resumes there. So code after a wait runs on the reactor, and blocking work belongs in Task.Run, as in ioxide: a test that spun waiting for the reactor right after a read held it, and goes back to the pool first now. Three builds per case, alternated in a mirrored order over 3 rounds (2 reactors, wrk -t8 -c64), in req/s and CPU-us/req - with the context / an awaiter on the pool resuming on the pool / this: - awaiting only the connection (Epoxide/Pipe): 823K 2.45 / 798K 2.54 / 823K 2.46 - one Task.Run at the connection's start: 800K 2.53 / 603K 3.29 / 810K 2.50 - a Task.Run per request: 486K 4.09 / 506K 3.78 / 509K 3.75 - Epoxide/ThreadPool, back on the reactor after its first wait: 539K 3.60 / 530K 3.64 / 777K 2.61 The tests that pinned a pool awaiter resuming on the pool pin it resuming on the reactor; those whose point is the pool's side (pool I/O, full duplex, TLS duplex, load) go back to the pool after each wait, and Hop.ToReactor covers an await made on another reactor. An operation that completes before its await registers still resumes where it was awaited, so the location checks wait on a peer that answers late instead of one that answers at once.
…s at its deadline instead of up to 2 ms after it epoll_wait takes its timeout in milliseconds, and the wait rounded a timer's deadline up twice: once to the millisecond, once more on top for the ticker's coarse clock. A 10 ms ReactorTimer came back in 11.25 ms on an idle reactor, and under load it was worse, since every timer that came due during one rounded-up sleep fired in the same pass and the last of them waited for all the others. On the arena's async profile - a flat GET /delay/10 over 32,000 held connections - epoxide answered 28.6% fewer requests than edixoi (2.02M against 2.83M, 15.66 ms against 11.23 ms a request) while using 38 cores to its 63: waiting, not working. The loop waits with epoll_pwait2 (Linux 5.11, glibc 2.35), whose timeout is a timespec, to the earliest deadline itself; only the ticker keeps its +1 ms, since it reads the coarse clock. Where the kernel or libc predates it, the loop falls back to epoll_wait, rounded up once. The async profile on this box (4 reactors on 4 P-cores, the arena's gcannon on the E-cores, a flat /delay/10), req/s and ms a request - edixoi / before / after: - 2,000 connections: 198K 10.12 / 156K 12.85 / 197K 10.20 - 4,000 connections: 395K 10.15 / 274K 14.62 / 390K 10.27 - 8,000 connections: 734K 10.35 / 530K 14.34 / 733K 10.37 with 9% less CPU than edixoi at the same rate. A new test pins it: the median of twenty 5 ms waits ends under 0.5 ms past its deadline; it fails against the old wait at 1,055 and 1,064 us. The ticker test's probe of a parked reactor's timeout reads epoll_pwait2's timespec as well as epoll_wait's milliseconds.
This was referenced Oct 25, 2026
This was referenced Oct 25, 2026
This branch has not been deployed
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.
What
A new project,
src/epoxide(packageepoxide, namespaceepoxide): ioxide's shared-nothing,thread-per-core runtime with epoll in place of io_uring. TCP and TLS - the same bundled BoringSSL,
the same optional kTLS - and no UDP, QUIC, h2 or h3.
SO_REUSEPORTlistener and connection table.Handlers start on the reactor, and a read or flush that waits resumes them inline there - as in
ioxide.
Reactor,ServerConfig/TcpOptions,TcpConnection(rawReadAsync/TryGetItem/ReturnBuffer,GetSpan/Advance/FlushAsync),TcpConnectionPipeReader/Writer,TcpConnectionDualPipe,TcpConnectionStream,ReactorTimer(for
RingTimer),TlsService/TlsSession/TlsConnectionDualPipe.Playground/Epoxide/{Raw,Pipe,ThreadPool,TlsPipes,KtlsPipes}, registered inbench/samples.tsv.ThreadPoolstarts its handler on the pool, which is back on the reactor after its first wait.The difference: any thread may use a connection
An io_uring ring has one issuer, so ioxide hands every off-reactor operation to the reactor. epoll
has no such rule: a read is a
recvand a flush is asend, made on the caller's own thread. So ahandler that moves to the thread pool (
Task.Run,ConfigureAwait(false)) keeps reading andwriting its connection from there, and nothing is posted to the reactor.
Reactor.ConnectAsyncworks from any thread too, so clients can live on the pool.
Only a read with nothing to read, or a flush the socket cannot take, waits on the reactor: it
parks, and the reactor finishes the
recv/sendat the next readiness edge and resumes thecaller inline, wherever it awaited from - the reactor is the thread that saw the event, so anywhere
else would cost a hop. A caller with a
SynchronizationContextof its own resumes on that instead.So there is no reactor
SynchronizationContext. ioxide's brings a handler back to its reactor afteran await of a timer, an
HttpClientor aTask.Run, because only the reactor may submit to itsring. Here such an await continues where it completes, usually the thread pool, and the handler's
next read or flush that waits brings it back to the reactor. Code after a wait therefore runs on
the reactor, and blocking work belongs in
Task.Run, as in ioxide. Anasync voidthat throws endsthe process, as anywhere in .NET, instead of being caught by the reactor.
How it holds together:
recv(and who maysendthe rest of aflush) is handed between the caller and the reactor by a CAS state machine (Idle / Ready / Parked),
so exactly one of them touches the socket at a time and no edge is lost.
recvcounts as drained, so a request costs onerecvand onesend- strace over 220Krequests: 220,341 recv, 220,324 send, 13,844 epoll_wait. Not once an edge has reported the peer's
FIN or urgent data, and not under kTLS RX: those read to EAGAIN.
that keeps its buffer full cannot hold the reactor. ioxide gets that bound for free, since a
reader there drains only what the ring has delivered.
handler that may be inside a syscall on it from another thread (the reactor's own end shuts the
socket down and wakes the handler instead).
with the kernel), and only bytes a reader has not taken yet hold one.
synchronous crypto, instead of posting them to the reactor. Removing the lock brings back
ioxide's "decrypt failed (error 5)" race; the tests that catch it are in the suite.
Benchmark (plaintext, both over pipes)
Each sample was run with
bench/any.sh: wrk with 8 threads and 64 connections for 10 s, against2 reactors pinned to CPUs 2 and 6, with wrk on CPUs 0-1 and 12-15. The samples alternated
ioxide, epoxide, epoxide, ioxide for 5 rounds, so drift cancels out. CPU per request is the
server's own utime+stime from
/proc, over the requests it served, with the reactors at 90% ormore.
Tcp/PipevsEpoxide/PipeTcp/RawvsEpoxide/RawEvery ioxide run beat every epoxide run, in both workloads, and repeats of one sample within a
round differ by at most 5.3%, so the gap is real. It is the syscalls: epoxide makes a
recvand asendper request (strace: one of each), while ioxide's multishot recv and batched submits foldboth into its ring.
The fixes below cost nothing measurable.
Epoxide/Pipebefore them (4ba8f02) against after,alternated over 3 rounds the same way, is 853K against 846K req/s and 2.37 against 2.38 µs/req,
inside the noise.
The TLS samples (
Epoxide/TlsPipes,Epoxide/KtlsPipes) serve, but are not part of thisbenchmark, which is plaintext as asked.
Without the reactor's
SynchronizationContextThree builds per case, alternated in a mirrored order over 3 rounds with the same setup: with the
context; without it, an awaiter on the pool resuming on the pool; and the final build, where a wait
resumes its awaiter inline on the reactor wherever it awaited from. req/s and CPU per request:
Epoxide/Pipe)Task.Runat the connection's start, then keep-aliveTask.Runawaited per requestEpoxide/ThreadPool(handler started on the pool)With the context, a request that awaits outside work crosses threads twice, out to the pool and
posted back; without it, once. Leaving a handler on the pool cost a hop on every later wait - a
quarter of the throughput in the second row - and resuming it inline on the reactor costs none. The
two
Task.Runcases are scratch copies ofEpoxide/Pipe, not committed.The reactor's wait in nanoseconds
epoll_waittakes its timeout in milliseconds, and the loop rounded a timer's deadline up twice, so a10 ms
ReactorTimercame back in 11.25 ms on an idle reactor, and later under load: every timer thatcame due in one rounded-up sleep fired in the same pass. On HttpArena's async profile, a flat
GET /delay/10over 32,000 held connections, that left epoxide 28.6% behind edixoi (2.02M against2.83M req/s) while it used 38 cores to edixoi's 63. The loop now waits with
epoll_pwait2(Linux5.11, glibc 2.35) to the deadline itself, and falls back to
epoll_wait, rounded up once, whereeither predates it. The same profile on this box, with the arena's gcannon, req/s and ms a request:
with 9-14% less CPU than edixoi at the same rate.
Tests
tests-epoxide/- a separate folder, as asked - holds every ioxide test that covers what epoxidehas, ported, plus tests for what is epoxide's own. Run twice on the final commit, pinned:
343 passed, 0 failed, 0 skipped, both times. The cross-thread TLS tests, where the last bug below
showed, then ran 100 more times without a failure (it had failed 1 run in 40 before its fix).
Epoxide.Tests.UnitRingSetupMessageTestsbecomesConfigValidationTests, without the ring-only cases)Epoxide.Tests.E2ECore/tests, less the io_uring-only ones; new:ThreadPoolTests(12),ConnectTests(11), and regression tests for the bugs belowEpoxide.Tests.TlsEpoxide.Tests.Chaosexample, "recv-queue overflow closes the connection" becomes "a reader that falls behind
back-pressures its peer": epoxide only reads when asked, so its queue cannot overflow. ioxide's
affinity tests pin an await coming back to the reactor through its context; epoxide's pin that it
continues on the pool, and that the next read that waits brings it back.
rings, ring setup, UDP, QUIC, h2, h3, and the clients.
assertion flipped to the defect's expectation, built and run, and every one failed. Each fix below
was also reverted against the tests that pin it.
The new thread-pool tests cover:
DecRefon the pool, in both orders;Stop()while pool handlers hold connections;Bugs the port turned up, fixed here
Three agents ported TLS, ported the tests, and reviewed the core adversarially. A stress loop of
the cross-thread TLS tests found one more. Each bug below was reproduced, and each fix is pinned by
a test that fails with the fix reverted, except the flush race, which needs ordering hooks to reach.
epoxide: a recv that takes the peer's FIN ...DecRefon the pool, or by another task mid-read, met the reactor's reclaimNullReferenceExceptionthat ended the reactor, or a buffer returned twice: a free-list cycle handing one buffer to every connection... keep their read state under a lock ...EndFlush/SetResulttwice... claims a parked flush with a CAS ...Stop()never drained registrations, posted continuations or timersConnectAsyncnever settled (20 of 20 rounds); a handler parked on a post or aReactorTimernever let go, and kept its descriptor openStop() settles what only its loop would have run ...Tcp == nullregistered no TCP sweep... with no listener sweeps its outbound connections ...... gives the loop back after 16 reads in a row ...intRecvBufferSizefailed every recv withOutOfMemoryException; a hugeMaxEventsdied inRun... sizes are computed past int ...Removing the context exposed one more, fixed with it: a faulted handler's ref was released after a
generation check, which is atomic only on the thread that recycles connections. From a pool thread
the connection could be recycled and reused in between, letting go of its next life under its new
handler. The release is posted to the reactor now. Proven with a delay injected between the check
and the release - 4 of 5 next lives cut off without the post, 0 with it - since no unaided test
reaches that window.
The harness also had a race, in ioxide's harness too: a reactor death a test had already claimed
could still be reported as unseen. Fixed in the epoxide copy only.
Not in this PR
dotnet packstep, so merging releases nothing.in
RunGuarded.reactor to a peer that keeps up never sees EAGAIN, so it yields only once the socket fills. The
response size bounds it, and only the handler can cause it, never a peer. A flush budget like
the read side's would close it.