Skip to content

ring: read errno, so the transient-failure handling actually works - #241

Open
MDA2AV wants to merge 4 commits into
mainfrom
fix/syscall-errno
Open

MDA2AV wants to merge 4 commits into
mainfrom
fix/syscall-errno

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Sep 20, 2026

Copy link
Copy Markdown
Owner

Fixes #220.

The bug, in three adjacent lines

[DllImport("libc", EntryPoint = "syscall")]                       // io_uring_setup
[DllImport("libc", EntryPoint = "syscall")]                       // io_uring_enter
[DllImport("libc", EntryPoint = "syscall", SetLastError = true)]  // io_uring_register  ← only this one

glibc's syscall() reports failure as -1 with the code in errno; it never returns -errno. Without SetLastError the code is lost, so three comparisons against specific errnos were dead:

Ring.cs:46 if (fd == -EINVAL) — the fallback for kernels without IORING_SETUP_NO_SQARRAY
SharedRing.cs:60 rc != -EINTR && rc != -EAGAIN && rc != -EBUSY
Incremental.cs:181 same

Measured on this machine before the fix, and now committed as tests:

io_uring_setup(0, &params)   → got -1, expected -22 (EINVAL)
io_uring_enter(-1, 0, 0, 0)  → got -1, expected  -9 (EBADF)

The last two are worse than inert — they guaranteed the opposite of their purpose. They exist so the loop survives an interrupted io_uring_enter. Instead, one signal delivered to a reactor thread returned -1, matched none of the exclusions, and ended that reactor: the process carried on with one fewer, nothing thrown, and a single stray line of output. There is no health or liveness surface anywhere in src/, and Run() returning normally is indistinguishable from a clean Stop().

Which signals actually do it (measured, and narrower than the issue claims): process-directed signals are not a trigger — 50 × kill(pid, SIGHUP) were all delivered to the main thread and both reactors survived. The triggers are thread-directed: tgkill/pthread_kill from tooling, and CPU-timer signals. SIGPROF is the one that will find you in production — an itimer-based profiler at 1 ms killed a reactor inside a 5 s window while it served 2.55 M requests.

One signal is not always enough, which is why this is intermittent rather than immediate: io_uring_enter returns the submit count when to_submit > 0, masking the EINTR, so the 250 ms ticker leaves an idle reactor alternating between vulnerable and not. Two signals ≥200 ms apart killed it every time. Verified by strace:

io_uring_enter(37, 0, 1, IORING_ENTER_GETEVENTS, NULL, 0) = -1 EINTR (Interrupted system call)
--- SIGHUP {si_code=SI_TKILL} ---
+++ exited with 0 +++

Three things the same investigation turned up

Run() had no try/finally. Anything thrown out of the loop skipped Teardown() and leaked the ring fd, both mmaps, the eventfd and the buffer slab. Since ring memory is charged against RLIMIT_MEMLOCK, leaking rings is exactly how a long-lived host ends up unable to create one.

A fatal errno now throws rather than breaking quietly. A reactor that vanishes while the process reports healthy is the worst of the options. The finally tears the ring down and the exception reaches whoever started the thread. Transients still carry on — and now genuinely do, which is the point of the fix.

Ring.Create retries ENOMEM, and this is the one with a live victim. Ring memory is released asynchronously after close(), so a host that creates and drops reactors faster than the kernel reclaims them gets ENOMEM while nothing is leaking. Reproduced standalone: 30,000 create-and-close cycles failed 14,788 times, and every failure cleared on a 5 ms retry.

This is what the GenHTTP acceptance suite hit — it reported only io_uring_setup failed: -1, which is why it looked like a mystery.

I over-attributed that in an earlier draft. Re-measured: the suite on its own peaks at 2.2 GB of the 8 GB budget across 41,728 ring setups with zero failures. locked_vm is a per-uid counter shared by every process of the same user, so the aborting run needed roughly 6 GB more charged from elsewhere — most likely the ring-churning probes I had running concurrently. The retry is still the right fix, and the diagnostic more so, but the suite is not by itself the cause.

Measured cost per reactor: 772 KB for the ring at the default RingEntries plus 64 KB for a 4096-slot buffer ring. The common 8 MB ulimit -l fits about ten. That is now in the README, along with the per-uid part, and the failure message names the errno and the cost.

Bench

Tcp/Raw, 4 reactors, four alternating pairs — SetLastError lands on io_uring_enter, the hottest syscall in the runtime:

-4.6%   +3.3%   -0.5%   +2.2%      mean +0.15%, two up two down

Independently measured at ~10 ns per enter, which at a realistic completion batch is ~0.6 ns per request — about 0.09% of the per-request budget, an order of magnitude under this box's noise floor.

The cheaper alternative (no SetLastError, read Marshal.GetLastSystemError() only on the failure branch) benchmarks free and survived 70,442 adversarial trials without a wrong read — but it reads errno after the GC-mode transition, so it depends on CoreCLR's current epilogue rather than on a contract. Not worth 0.09%.

Not addressed

The NO_SQARRAY fallback is now reachable for the first time, which means Ring.cs's _sqArray path has never executed — anywhere, ever, including CI. It reads correctly, but 6.1–6.5 has no coverage and nothing in the test suite can force the fallback. Worth a Ring.Create flags overload so CI can exercise it; I have not added one.

Also unaddressed: Reactor has no fault callback, so a host that wants to restart a dead reactor rather than take the exception has nowhere to hook. An OnFault sibling to OnStart would be the seam.

Behaviour change to note in release notes: a fatal io_uring_enter errno now propagates. A host calling Run() on a bare Thread will see the process terminate where it previously degraded silently. That is the intent, but it is a change.

glibc's syscall() reports failure as -1 with the code in errno; it never returns
-errno. syscall3 and syscall6 were declared without SetLastError, so every
io_uring_setup and io_uring_enter failure arrived as -1 and three comparisons
against specific errnos were dead code (#220):

  Ring.cs:46                      if (fd == -EINVAL)      the pre-6.6 fallback
  Reactor.Loop.SharedRing.cs:60   EINTR/EAGAIN/EBUSY      transient tolerance
  Reactor.Loop.Incremental.cs:181 EINTR/EAGAIN/EBUSY      transient tolerance

The second and third are worse than inert. They exist to let the loop survive an
interrupted enter, and instead guaranteed the opposite: one signal delivered to a
reactor thread - any PosixSignalRegistration the application installs, a
profiler's SIGPROF, SIGHUP for config reload - returned -1, matched none of the
exclusions, and ended that reactor. The process kept running with one reactor
fewer and nothing thrown, logged as anything but a stray line, or otherwise
distinguishable from a clean Stop().

Measured before the fix: io_uring_setup(0) returned -1 where -22 was expected,
io_uring_enter(-1) returned -1 where -9 was expected. Both are now tests.

The third declaration of the same libc function, syscall4 for io_uring_register,
already had SetLastError and its callers already read the errno. Only two of the
three were wrong.

Also here, because the same investigation turned them up:

Run() had no try/finally, so anything thrown out of the loop skipped Teardown()
and leaked the ring fd, both mmaps, the eventfd and the buffer slab. Ring memory
is charged against RLIMIT_MEMLOCK, so leaking rings is precisely how a long-lived
host ends up unable to create one.

A fatal errno now throws instead of breaking quietly. A reactor that vanishes
while the process reports healthy is the worst of the options; the finally tears
the ring down and the exception reaches whoever started the thread. Transients
still just carry on - and now genuinely do.

Ring.Create retries ENOMEM. A ring's memory is released ASYNCHRONOUSLY after
close, so a host that creates and drops reactors faster than the kernel reclaims
them gets ENOMEM with nothing leaking. Measured: 30,000 create-and-close cycles
failed 14,788 times, and every failure cleared on a 5ms retry. This is what
aborts the GenHTTP acceptance suite partway through, where it reported only
"io_uring_setup failed: -1". It now retries, and on giving up names the errno and
the memlock cost.

Not addressed: the NO_SQARRAY fallback is now reachable for the first time, which
means Ring.cs's _sqArray path has never executed anywhere. It looks right, but
6.1-6.5 has no coverage and nothing forces the fallback in CI.

Bench, Tcp/Raw at 4 reactors, four alternating pairs: -4.6, +3.3, -0.5, +2.2,
mean +0.15%, two up two down. SetLastError costs about 10ns per enter, which at a
realistic completion batch is ~0.6ns per request.

E2E 194, Unit 48, Http 44, Tls 142, Chaos 47, File 4.
772 KB for the ring at the default RingEntries plus 64 KB for a 4096-slot buffer
ring, measured rather than estimated. The per-uid part matters more than the
number: locked_vm is shared across every process of the same user, so a second
program churning rings eats the same budget.
…ster

Three findings from review, two of them defects in the previous commit.

The try started at the LOOP, which is not where the leak is. Ring.Create,
OpenTcpListeners, InitSharedRingBuffer, OpenWakeFd and OnStart - user code - all
sat outside it, so a throw from any of them skipped Teardown entirely. The
comment named exactly what leaks and then failed to cover it. The test harness
throws from OnStart deliberately, in every E2E run: measured at three descriptors
and ~745 KiB of RLIMIT_MEMLOCK per failed start, 50 reactors leaking 162 fds. The
try now opens immediately after Ring.Create.

Throwing a fatal errno aborted the process under the shipped host. ioxide.Kestrel
starts each reactor with a bare new Thread(reactor.Run) and catches nothing, so
what had been one dead reactor became SIGABRT and a core dump for the whole
server. The comment justifying the throw claimed "the exception reaches whoever
started the thread", which was false for this repo's own host.

Reactor.OnFault is the seam. Unhandled, the exception still propagates and a bare
Thread still ends the process - which reactor is right is the host's call, not
this library's. ioxide.Kestrel now handles it and logs: losing one shard of N
should not end the server, but it must not be silent either, which is the whole
complaint behind #220.

io_uring_register was left returning -1 three lines under a comment saying all
three wrappers normalise. Latent, since no caller compared it to a specific
errno, but it is #220's exact shape left in the file written to fix #220. Both
pbuf_ring registration sites and the UDP one now print the errno instead of -1.

Also: the README said an 8 MB memlock fits about ten reactors without noticing
that the default ReactorCount is 12, so a default server does not start under it.
And Ring.Create's retry remarks now say what it does not buy - under a genuinely
small limit a third of attempts still fail first time, and the answer there is a
bigger limit or a smaller ring.

E2E 194, Unit 48, Http 44, Tls 142, Chaos 47, File 4.
Second review pass, all four from measurement.

The retry budget was sized from the wrong experiment. 2/4/6/8/10ms came from a
BURST of 30,000 create-and-close cycles, where many rings are in flight and one
is always coming back within a few ms. A restarting server produces the
one-at-a-time case instead, and a single ring's reclaim has a median around 20ms
with a tail to 47 under load - so 30ms still failed a fifth of the time. Now
5/10/20/40/80, about 155ms, which costs nothing on the success path.

The README reasoned about 8 MB while incremental mode charges another page per
live connection: at the default MaxConnections that is up to 16 MB per reactor on
top of the ring, twice the figure the paragraph was arguing from.

_wakeFd was closed without being zeroed. WakeFdWrite runs from any thread and
guards only on _wakeFd > 0, so the stale number let a write land in whatever fd
reused it. Latent before, because Teardown only followed an explicit Stop(); it
can now follow a fault at an arbitrary instant while a host is still handing work
in, which is the change that makes it worth closing.

And the new test's own remark claimed the io_uring_register callers read the
errno and work correctly. Two of the three printed ret=-1 with no errno at all.
They are normalised now, so the remark says what is true.

Verified in review, worth recording: on main a reactor death is invisible to the
test harness - Run() returns normally and RunGuarded has nothing to record. With
the throw, the harness reports it and Summary fails the run. Teardown completes
before the process aborts, confirmed by strace: every close and munmap executes,
then SIGABRT.

E2E 194, Unit 48, Http 44, Tls 142, Chaos 47, File 4.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant