Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
3b6906a
ring: read errno, so the transient-failure handling actually works
MDA2AV Sep 20, 2026
d1d29a4
README: the measured memlock charge, and that the counter is per uid
MDA2AV Sep 20, 2026
ac8e0a3
review: cover the setup path, give hosts a fault seam, normalise regi…
MDA2AV Sep 20, 2026
03a839e
review: size the retry to the measured reclaim latency, not to a burst
MDA2AV Sep 20, 2026
245a432
tests: claim a reactor death by port, not by winning a race
MDA2AV Sep 21, 2026
2fa156a
reactor: never close fd 0 when setup fails before the tables are filled
MDA2AV Sep 21, 2026
910cf5e
reactor: take the wake fd away before closing it, and wait out its wr…
MDA2AV Sep 21, 2026
2fd9b1a
BISECT PROBE: setup back outside the try, to test the Tls regression
MDA2AV Sep 21, 2026
26d9fe4
reactor: unregister the provided-buffer rings before closing the ring
MDA2AV Sep 21, 2026
c28e6ec
BISECT PROBE 2: on setup failure, leak the listener and ring fd, free…
MDA2AV Sep 21, 2026
85838a0
BISECT PROBE 3: close the listener on setup failure, keep the ring fd
MDA2AV Sep 21, 2026
6a33276
BISECT PROBE 4: munmap both rings on setup failure, leak only the rin…
MDA2AV Sep 21, 2026
9b6efb2
BISECT PROBE 5: close the ring via dup2, keeping its fd number occupied
MDA2AV Sep 21, 2026
bfe1802
reactor: keep the ring fd when the start failed, and drop the bisect …
MDA2AV Sep 21, 2026
648db40
reactor: point the kept-ring-fd note at #242
MDA2AV Sep 21, 2026
d72e2a3
trim the comments added by this PR
MDA2AV Sep 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ unchanged.

> Linux 6.1+ · .NET 10 / .NET 11 · experimental

Each reactor is charged against `RLIMIT_MEMLOCK`: measured at 772 KB for the ring at the default
`RingEntries`, plus 64 KB for a 4096-slot buffer ring, so about 836 KB per reactor. The common 8 MB
`ulimit -l` fits roughly ten - which is fewer than the default `ReactorCount` of 12, so a default
server does not start under it. The counter is **per uid**, not per process, so every process you run
shares one budget. A closed ring's memory is reclaimed asynchronously, so standing servers up and
tearing them down in quick succession can hit `ENOMEM` while nothing is leaking; `Ring.Create`
retries briefly before giving up, and names the errno when it does.
>
> Incremental mode charges a further page per live connection, so at the default `MaxConnections`
> a reactor can want 16 MB on top of its ring.

**[Documentation](https://mda2av.github.io/ioxide/)** - architecture, guides, and every example as
runnable code side by side.

Expand Down
35 changes: 27 additions & 8 deletions src/ioxide/Native/Native.IoUring.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,29 +69,48 @@ public static unsafe partial class Native {
public const uint IORING_SETUP_NO_SQARRAY = 1u << 16;

public const int EINVAL = 22;
public const int ENOMEM = 12;

public const int PROT_READ = 1;
public const int PROT_WRITE = 2;
public const int MAP_SHARED = 1;
public const int MAP_POPULATE = 0x8000;

[DllImport("libc", EntryPoint = "syscall")]
// glibc's syscall() reports failure as -1 with the code in errno; it never returns -errno. So
// all three need SetLastError, and the wrappers below normalise to liburing's convention - a
// negative errno, which is what every caller compares against. Two were declared without it
// (#220), leaving three branches dead: Ring.Create's NO_SQARRAY fallback and the
// EINTR/EAGAIN/EBUSY tolerance in both loops, so one signal ended a reactor.
[DllImport("libc", EntryPoint = "syscall", SetLastError = true)]
private static extern long syscall3(long nr, uint a1, IoUringParams* a2);

[DllImport("libc", EntryPoint = "syscall")]
[DllImport("libc", EntryPoint = "syscall", SetLastError = true)]
private static extern long syscall6(long nr, uint a1, uint a2, uint a3, uint a4, void* a5, nuint a6);

[DllImport("libc", EntryPoint = "syscall", SetLastError = true)]
private static extern long syscall4(long nr, uint a1, uint a2, void* a3, uint a4);

public static int io_uring_setup(uint entries, IoUringParams* p) =>
(int)syscall3(SYS_IO_URING_SETUP, entries, p);
// Test the long before narrowing: errno is only meaningful on the failure branch.
public static int io_uring_setup(uint entries, IoUringParams* p)
{
long rc = syscall3(SYS_IO_URING_SETUP, entries, p);

return rc < 0 ? -Marshal.GetLastPInvokeError() : (int)rc;
}

public static int io_uring_enter(int fd, uint toSubmit, uint minComplete, uint flags)
{
long rc = syscall6(SYS_IO_URING_ENTER, (uint)fd, toSubmit, minComplete, flags, null, 0);

return rc < 0 ? -Marshal.GetLastPInvokeError() : (int)rc;
}

public static int io_uring_enter(int fd, uint toSubmit, uint minComplete, uint flags) =>
(int)syscall6(SYS_IO_URING_ENTER, (uint)fd, toSubmit, minComplete, flags, null, 0);
public static int io_uring_register(int fd, uint opcode, void* arg, uint nrArgs)
{
long rc = syscall4(SYS_IO_URING_REGISTER, (uint)fd, opcode, arg, nrArgs);

public static int io_uring_register(int fd, uint opcode, void* arg, uint nrArgs) =>
(int)syscall4(SYS_IO_URING_REGISTER, (uint)fd, opcode, arg, nrArgs);
return rc < 0 ? -Marshal.GetLastPInvokeError() : (int)rc;
}

[DllImport("libc")] public static extern void* mmap(void* addr, nuint length, int prot, int flags, int fd, long offset);
[DllImport("libc")] public static extern int munmap(void* addr, nuint length);
Expand Down
24 changes: 21 additions & 3 deletions src/ioxide/Reactor/Loop/Reactor.Drainers.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using ioxide.utils;
using static ioxide.Native;
Expand All @@ -16,10 +16,28 @@ public sealed unsafe partial class Reactor

#region Wake

// Writers currently holding the eventfd's number, so Teardown can wait them out before it
// closes. Without the gate a writer that read the old number puts 8 bytes into whatever socket
// took it next. Off-reactor callers only, next to a syscall, so the two interlocks are free.
// Reading 0 means the reactor is gone and there is nothing to wake.
private int _wakeUsers;

private void WakeFdWrite()
{
ulong v = 1;
write(_wakeFd, &v, 8); // eventfd becomes readable → multishot poll CQE wakes the loop
Interlocked.Increment(ref _wakeUsers);
try
{
int fd = Volatile.Read(ref _wakeFd);
if (fd > 0)
{
ulong v = 1;
write(fd, &v, 8); // eventfd becomes readable → multishot poll CQE wakes the loop
}
}
finally
{
Interlocked.Decrement(ref _wakeUsers);
}
}

private void ArmWakePoll()
Expand Down
15 changes: 11 additions & 4 deletions src/ioxide/Reactor/Loop/Reactor.Loop.Incremental.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ private void SetupConnectionBufRing(TcpConnection conn)
int ret = io_uring_register(_ring.Fd, IORING_REGISTER_PBUF_RING, &reg, 1);
if (ret < 0)
{
throw new InvalidOperationException($"register pbuf_ring (inc) failed: ret={ret} gid={gid}");
throw new InvalidOperationException($"register pbuf_ring (inc) failed with errno {-ret}, gid={gid}");
}

conn.Bgid = gid;
Expand Down Expand Up @@ -177,12 +177,19 @@ private void LoopIncremental()
RearmStarvedRecvs();
QuicFireDueTimers();

// The transient three, which the loop carries on from: a signal, the kernel short of
// resources, and overflow entries it could not flush - the CQ drain below is what EBUSY
// wants anyway. Until #220 this could never match (the wrapper returned -1 for every
// failure), so the first signal delivered to a reactor thread ended it.
//
// Anything else is a lifecycle or programming error. Throwing rather than breaking,
// because a reactor that vanishes while the process reports healthy is the worst of
// both; whether the process then dies is the host's call, via Reactor.OnFault.
int rc = _ring.SubmitAndWait(1);
if (rc < 0 && rc != -EINTR && rc != -EAGAIN && rc != -EBUSY)
{
Console.Error.WriteLine($"[r{_id}] io_uring_enter failed: {rc}");

break;
throw new InvalidOperationException(
$"[r{_id}] io_uring_enter failed with errno {-rc}; this reactor cannot continue");
}

NowMs = Environment.TickCount64; // one read per batch; see Reactor.Tcp.Sweep.cs
Expand Down
18 changes: 12 additions & 6 deletions src/ioxide/Reactor/Loop/Reactor.Loop.SharedRing.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Runtime.InteropServices;
using System.Runtime.InteropServices;
using static ioxide.Native;

namespace ioxide;
Expand Down Expand Up @@ -26,9 +26,7 @@ private void InitSharedRingBuffer()
int ret = io_uring_register(_ring.Fd, IORING_REGISTER_PBUF_RING, &reg, 1);
if (ret < 0)
{
int err = Marshal.GetLastPInvokeError();

throw new InvalidOperationException($"register pbuf_ring failed: ret={ret} errno={err}");
throw new InvalidOperationException($"register pbuf_ring failed with errno {-ret}");
}

// Slot 0 overlaps the ring's tail field at offset 14; writing only addr/len/bid
Expand Down Expand Up @@ -56,11 +54,19 @@ private void LoopSharedRing()
RearmStarvedRecvs();
QuicFireDueTimers();

// The transient three, which the loop carries on from: a signal, the kernel short of
// resources, and overflow entries it could not flush - the CQ drain below is what EBUSY
// wants anyway. Until #220 this could never match (the wrapper returned -1 for every
// failure), so the first signal delivered to a reactor thread ended it.
//
// Anything else is a lifecycle or programming error. Throwing rather than breaking,
// because a reactor that vanishes while the process reports healthy is the worst of
// both; whether the process then dies is the host's call, via Reactor.OnFault.
int rc = _ring.SubmitAndWait(1);
if (rc < 0 && rc != -EINTR && rc != -EAGAIN && rc != -EBUSY)
{
Console.Error.WriteLine($"[r{_id}] io_uring_enter failed: {rc}");
break;
throw new InvalidOperationException(
$"[r{_id}] io_uring_enter failed with errno {-rc}; this reactor cannot continue");
}

NowMs = Environment.TickCount64; // one read per batch; see Reactor.Tcp.Sweep.cs
Expand Down
12 changes: 12 additions & 0 deletions src/ioxide/Reactor/Reactor.RingHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ public T GetService<T>() where T : class
/// </summary>
public Func<Reactor, TcpConnection, Task> TcpHandle = null!;

/// <summary>
/// Raised on the reactor's own thread when it is ending because of a fault rather than a
/// <see cref="Stop"/>, after the ring has been torn down. Handle it to log, restart, or bring
/// the process down deliberately.
/// </summary>
/// <remarks>
/// Without a handler the exception propagates out of <see cref="Run"/>, which on a bare
/// <c>new Thread(reactor.Run)</c> terminates the process. Which of the two is right is the
/// host's call: losing one shard of N silently is as bad as taking the server down unasked.
/// </remarks>
public Action<Reactor, Exception>? OnFault;

/// <summary>
/// The per-connection QUIC handler, invoked once per adopted connection (CID demux path).
/// Null: no handler is launched (raw engine mode, e.g. a custom <see cref="QuicConnection"/>
Expand Down
81 changes: 70 additions & 11 deletions src/ioxide/Reactor/Reactor.Runner.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Runtime.InteropServices;
using System.Runtime.InteropServices;
using static ioxide.Native;

namespace ioxide;
Expand All @@ -25,6 +25,12 @@ public void Run()
BindReactorThread();
_ring = Ring.Create(_ringEntries);

// Covers setup, not just the loop: OnStart is user code, and a throw from it used to leak
// the listener, the eventfd and both ring mappings. Ring.Create stays outside - nothing to
// tear down until it returns.
try
{

// Transports: TCP always; UDP sockets + the QUIC demux only when configured (no-ops otherwise).
OpenTcpListeners();
OpenUdpSockets();
Expand Down Expand Up @@ -53,10 +59,20 @@ public void Run()

StartTicker();

if (_incremental) LoopIncremental();
else LoopSharedRing();

Teardown();
_ranLoop = true;
if (_incremental) LoopIncremental();
else LoopSharedRing();
}
catch (Exception e) when (OnFault is not null)
{
// Handled by the host, so it does not escape to kill the process. Teardown still runs.
OnFault(this, e);
}
finally
{
// Runs on every exit path: a fatal io_uring_enter, a throw from OnStart, a full SQ.
Teardown();
}
}

// Record the owning thread (off-reactor callers detect themselves and go through the handoff
Expand Down Expand Up @@ -85,18 +101,34 @@ private void AnnounceListening()
$" (incremental={_incremental})");
}

// Teardown, still on the reactor thread, in dependency order: sockets close while the ring is
// alive (in-flight ops surface as errors/cancels and are dropped), the ring fd goes next, and
// native memory the kernel could reference (buffer slabs, UDP slot blocks) is freed only after
// that.
/// <summary>Set once the loop is entered, so Teardown can tell a failed start from a stop.</summary>
private bool _ranLoop;

// Still on the reactor thread, in dependency order: sockets close while the ring is alive
// (in-flight ops surface as errors/cancels and are dropped), the ring fd goes next, and native
// memory the kernel could reference is freed only after that.
private void Teardown()
{
bool startFailed = !_ranLoop;

CloseTcpListeners();
TeardownQuic();
CloseUdpFds();
CloseAcceptedTcpSockets();

close(_wakeFd);
// Taken away before it is closed, and its writers waited out: closing under one would
// free the number and let that writer's 8 bytes land in whatever socket took it next.
// The > 0 test also covers a throw before OpenWakeFd, where this is still 0 - stdin.
int wakeFd = Interlocked.Exchange(ref _wakeFd, 0);
if (wakeFd > 0)
{
SpinWait spin = default;
while (Volatile.Read(ref _wakeUsers) != 0)
{
spin.SpinOnce();
}
close(wakeFd);
}
if (_timerTs != null)
{
NativeMemory.Free(_timerTs);
Expand All @@ -108,7 +140,17 @@ private void Teardown()
_opTimespecs = null;
_opTimespecCapacity = 0;
}
_ring.Dispose();
// Before the fd goes: unregistering is synchronous, closing is not (io_uring_release
// defers to a workqueue), so without this the frees below could hand the kernel's pages
// back to the allocator while it still holds them.
UnregisterSharedBufRings();

// Both mappings go back either way; the descriptor is kept when the start failed, because
// releasing its number mid-run kills an unrelated live connection - #242. Bisected: closing
// it by dup2'ing /dev/null over the number is green, so the damage is the number being
// reused, not the ring teardown. Costs the ring's memlock charge until exit, which is what
// this path already did before it tore anything down at all.
_ring.Dispose(closeFd: !startFailed);

// Shared provided-buffer ring (incremental mode allocates per connection instead).
if (_bufRing != null)
Expand All @@ -124,6 +166,23 @@ private void Teardown()
FreeUdpMemory();
}

// Both are no-ops unless that ring was registered: incremental mode leaves _bufRing null (one
// ring per connection instead), and a reactor with no datagram transport leaves _udpBufRing null.
private void UnregisterSharedBufRings()
{
if (_bufRing != null)
{
var reg = new io_uring_buf_reg { bgid = BgId };
io_uring_register(_ring.Fd, IORING_UNREGISTER_PBUF_RING, &reg, 1);
}

if (_udpBufRing != null)
{
var reg = new io_uring_buf_reg { bgid = UdpBgId };
io_uring_register(_ring.Fd, IORING_UNREGISTER_PBUF_RING, &reg, 1);
}
}

// Set cross-thread by Stop(); the loops check it at the top of each iteration and exit, after which
// Run() tears the ring down on this (the reactor) thread - mandatory for a single-issuer ring.
private volatile bool _stopRequested;
Expand Down
10 changes: 9 additions & 1 deletion src/ioxide/Reactor/Transport/Tcp/Reactor.Tcp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,11 @@ private void OpenTcpListeners()
}

_listenFds = new int[1 + _tcp.ExtraPorts.Length];

// -1, not the default 0: the loop below can throw partway and Teardown runs on that path,
// where an unset slot left at 0 would have it close stdin - handing the number to the next
// socket opened, for a later teardown to shut.
Array.Fill(_listenFds, -1);
_listenPorts = new ushort[_listenFds.Length];
_listenPorts[0] = _port;
for (int i = 0; i < _tcp.ExtraPorts.Length; i++)
Expand All @@ -364,7 +369,10 @@ private void CloseTcpListeners()
{
foreach (int listenFd in _listenFds)
{
close(listenFd);
if (listenFd >= 0)
{
close(listenFd);
}
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/ioxide/Reactor/Transport/Udp/Reactor.Udp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ private void OpenUdpSockets()

int ports = udpPorts.Length;
_udpFds = new int[ports];
Array.Fill(_udpFds, -1); // unset slots must not read as fd 0; see OpenTcpListeners
_udpFdPorts = new ushort[ports];

InitUdpBufRing();
Expand Down Expand Up @@ -216,7 +217,7 @@ private void InitUdpBufRing()
int ret = io_uring_register(_ring.Fd, IORING_REGISTER_PBUF_RING, &reg, 1);
if (ret < 0)
{
throw new InvalidOperationException($"register udp pbuf_ring failed: ret={ret}");
throw new InvalidOperationException($"register udp pbuf_ring failed with errno {-ret}");
}

// Template: reserved name/control sizes only; iov unused (buffer comes from the ring).
Expand Down
Loading
Loading