Skip to content

epoxide: ioxide's thread-per-core runtime on epoll, whose connections any thread can read and write - #384

Open
MDA2AV wants to merge 22 commits into
mainfrom
feat/epoxide
Open

MDA2AV wants to merge 22 commits into
mainfrom
feat/epoxide

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Oct 17, 2026

Copy link
Copy Markdown
Owner

What

A new project, src/epoxide (package epoxide, namespace epoxide): 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.

  • One reactor per core: its own thread, epoll set, SO_REUSEPORT listener and connection table.
    Handlers start on the reactor and resume inline there, and the per-reactor
    SynchronizationContext brings Task.Delay/HttpClient/Task.Run continuations home - as in ioxide.
  • ioxide's API, so code ports by changing the namespace: Reactor, ServerConfig/TcpOptions,
    TcpConnection (raw ReadAsync/TryGetItem/ReturnBuffer, GetSpan/Advance/FlushAsync),
    TcpConnectionPipeReader/Writer, TcpConnectionDualPipe, TcpConnectionStream, ReactorTimer
    (for RingTimer), TlsService/TlsSession/TlsConnectionDualPipe.
  • Samples Playground/Epoxide/{Raw,Pipe,ThreadPool,TlsPipes,KtlsPipes}, registered in
    bench/samples.tsv. ThreadPool runs its whole handler on the pool.

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 recv and a flush is a send, made on the caller's own thread. So a
handler that moves to the thread pool (Task.Run, ConfigureAwait(false)) keeps reading and
writing its connection from there, and nothing is posted to the reactor. Reactor.ConnectAsync
works 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/send at the next readiness edge and resumes the
caller - inline when it is on that reactor, on its own context or the thread pool when it is not.

How it holds together:

  • Connections are registered once, edge-triggered. Who may recv (and who may send the rest of a
    flush) 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.
  • A short recv counts as drained, so a request costs one recv and one send - strace over 220K
    requests: 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.
  • A reader running inline on its reactor gives the loop back after 16 reads in a row, so a peer
    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.
  • A descriptor closes only when both the handler and the reactor have let go, never under a
    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).
  • Recv buffers come from a per-reactor lock-free pool that grows on demand (nothing is registered
    with the kernel), and only bytes a reader has not taken yet hold one.
  • TLS: each session serializes its own BoringSSL calls with a lock, held only across the
    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, against
2 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% or
more.

Workload ioxide (io_uring) epoxide (epoll) epoxide vs ioxide
Pipe: Tcp/Pipe vs Epoxide/Pipe 936K req/s, 2.16 µs/req 849K req/s, 2.37 µs/req -9.2% req/s, +9.8% CPU/req
Raw: Tcp/Raw vs Epoxide/Raw 965K req/s, 2.09 µs/req 888K req/s, 2.27 µs/req -8.0% req/s, +8.9% CPU/req

Every 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 recv and a
send per request (strace: one of each), while ioxide's multishot recv and batched submits fold
both into its ring.

The fixes below cost nothing measurable. Epoxide/Pipe before 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 this
benchmark, which is plaintext as asked.

Tests

tests-epoxide/ - a separate folder, as asked - holds every ioxide test that covers what epoxide
has, 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).

Suite Tests From ioxide
Epoxide.Tests.Unit 16 version, clock, syscall errno, config refused at construction (RingSetupMessageTests becomes ConfigValidationTests, without the ring-only cases)
Epoxide.Tests.E2E 106 ioxide's Core/ tests, less the io_uring-only ones; new: ThreadPoolTests (12), ConnectTests (11), and regression tests for the bugs below
Epoxide.Tests.Tls 197 all 194 of ioxide's TLS tests but one; new: thread-pool sessions, a call after Dispose refused, a FIN behind the last request
Epoxide.Tests.Chaos 24 TCP 13 of 13, reactor 5 of 6, TLS 6 of 6
  • Same name and assertion wherever the behaviour is the same.
  • Translated where a test guards something only io_uring has, to what it protects on epoll. For
    example, "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.
  • Not ported where epoxide has nothing like it: zero-copy send, MSG_WAITALL, incremental buffer
    rings, ring setup, UDP, QUIC, h2, h3, and the clients.
  • Proven to bite: the 43 new or translated tests in Unit, E2E and Chaos each had their key
    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:

  • reads, writes and flushes made on the pool;
  • parked reads and flushes awaited from the pool resuming there, never on the reactor;
  • an awaiter on the reactor still resuming inline;
  • full duplex, 8 MiB each way, byte-exact;
  • a peer reset mid-flush and mid-read;
  • DecRef on the pool, in both orders;
  • Stop() while pool handlers hold connections;
  • 48 clients on 2 reactors served from the pool.

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.

Bug Effect Commit
A recv that takes the peer's FIN along with its last bytes came back short, and short meant drained the next read parked until the read timeout (forever with 0): up to 197 of 200 connections, depending on the shape epoxide: a recv that takes the peer's FIN ...
A pipe reader or stream released after DecRef on the pool, or by another task mid-read, met the reactor's reclaim a NullReferenceException that 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 ...
A writer's closed re-check took back a parked flush the reactor was already sending sends from a reset slab, EndFlush/SetResult twice ... claims a parked flush with a CAS ...
Stop() never drained registrations, posted continuations or timers a racing ConnectAsync never settled (20 of 20 rounds); a handler parked on a post or a ReactorTimer never let go, and kept its descriptor open Stop() settles what only its loop would have run ...
A reactor with Tcp == null registered no TCP sweep outbound connections had no read or send timeout: a stalled flush was still parked at 90 s of a 60 s timeout ... with no listener sweeps its outbound connections ...
A reader inline on its reactor kept reading while every recv came back full one flooding peer held the whole reactor: another connection's pings went unanswered, and a TLS test whose writer was on another thread stalled once in 40 runs, its flush's writable edge never handled (the TLS port's one unexplained failure had the same shape) ... gives the loop back after 16 reads in a row ...
Pool chunk and event-buffer sizes computed in int a 32 MiB RecvBufferSize failed every recv with OutOfMemoryException; a huge MaxEvents died in Run ... sizes are computed past int ...

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

  • No UDP, QUIC, HTTP/2 or HTTP/3, and no clients (pg, redis, httpclient, file): TCP and TLS first.
  • Not packed or published: CI builds and tests it, but there is no dotnet pack step, so merging releases nothing.
  • ioxide itself is untouched, including its test harness, which has the same check-then-record order
    in RunGuarded.
  • The write side has the read side's shape, in a weaker form: a handler streaming inline on its
    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.

MDA2AV added 22 commits October 17, 2026 17:08
… 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
…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.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant