From afa4f6e1cb779acf81fe62df89e977bcc8374be3 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 4 Oct 2026 13:02:08 +0100 Subject: [PATCH] reactor: a ring the kernel refuses reaches OnFault instead of ending the process Ring.Create ran outside the try that hands faults to OnFault, so a host that set OnFault still lost the process when io_uring_setup failed - on a kernel older than 6.1, for one. It now has its own catch. The message names the cause instead of a bare errno: a kernel too old for SINGLE_ISSUER | DEFER_TASKRUN, too many entries, io_uring disabled by policy. --- src/ioxide/Native/Native.IoUring.cs | 2 + src/ioxide/Reactor/Reactor.RingHost.cs | 4 +- src/ioxide/Reactor/Reactor.Runner.cs | 16 ++++-- src/ioxide/io_uring/Ring.cs | 51 ++++++++++++++++--- .../Core/ReactorSetupTeardownTests.cs | 38 ++++++++++++++ tests/Ioxide.Tests.Unit/Program.cs | 1 + .../RingSetupMessageTests.cs | 37 ++++++++++++++ 7 files changed, 136 insertions(+), 13 deletions(-) create mode 100644 tests/Ioxide.Tests.Unit/RingSetupMessageTests.cs diff --git a/src/ioxide/Native/Native.IoUring.cs b/src/ioxide/Native/Native.IoUring.cs index a2c01ed..8945818 100644 --- a/src/ioxide/Native/Native.IoUring.cs +++ b/src/ioxide/Native/Native.IoUring.cs @@ -70,6 +70,8 @@ public static unsafe partial class Native { public const int EINVAL = 22; public const int ENOMEM = 12; + public const int EPERM = 1; + public const int ENOSYS = 38; public const int PROT_READ = 1; public const int PROT_WRITE = 2; diff --git a/src/ioxide/Reactor/Reactor.RingHost.cs b/src/ioxide/Reactor/Reactor.RingHost.cs index 140ea76..e3b7213 100644 --- a/src/ioxide/Reactor/Reactor.RingHost.cs +++ b/src/ioxide/Reactor/Reactor.RingHost.cs @@ -59,8 +59,8 @@ public T GetService() where T : class /// /// Raised on the reactor's own thread when it is ending because of a fault rather than a - /// , after the ring has been torn down. Handle it to log, restart, or bring - /// the process down deliberately. + /// - a kernel that refuses to create the ring included - after the ring has + /// been torn down. Handle it to log, restart, or bring the process down deliberately. /// /// /// Without a handler the exception propagates out of , which on a bare diff --git a/src/ioxide/Reactor/Reactor.Runner.cs b/src/ioxide/Reactor/Reactor.Runner.cs index b4cbdcf..b185ef7 100644 --- a/src/ioxide/Reactor/Reactor.Runner.cs +++ b/src/ioxide/Reactor/Reactor.Runner.cs @@ -23,11 +23,21 @@ public sealed unsafe partial class Reactor public void Run() { BindReactorThread(); - _ring = Ring.Create(_ringEntries); + + // A kernel that refuses the ring is a fault the host must hear of too (#270); it gets its own + // catch because there is nothing to tear down until Create returns. + try + { + _ring = Ring.Create(_ringEntries); + } + catch (Exception e) when (OnFault is not null) + { + OnFault(this, e); + return; + } // 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. + // the listener, the eventfd and both ring mappings. try { diff --git a/src/ioxide/io_uring/Ring.cs b/src/ioxide/io_uring/Ring.cs index cc296b8..c6ec7c8 100644 --- a/src/ioxide/io_uring/Ring.cs +++ b/src/ioxide/io_uring/Ring.cs @@ -67,6 +67,48 @@ private static int SetupWithMemlockRetry(uint entries, IoUringParams* parameters private static int EstimateRingKib(uint entries) => (int)((entries * (64 + 4) + entries * 2 * 16 + 4096) / 1024); + // A failed setup in terms a host can act on: a bare "errno 22" sends people to limits and + // permissions first, when the usual cause is a kernel older than the setup flags (#270). + internal static string DescribeSetupFailure(int errno, uint entries, string kernelRelease) + { + string failed = $"io_uring_setup failed with errno {errno} for a ring of {entries} entries on Linux {kernelRelease}"; + return errno switch + { + EINVAL when entries > 32768 => $"{failed}: the kernel allows at most 32768 (ServerConfig.RingEntries).", + EINVAL when KernelOlderThan(kernelRelease, 6, 1) + => $"{failed}: the kernel does not know SINGLE_ISSUER | DEFER_TASKRUN, and ioxide needs Linux 6.1 or later.", + ENOMEM => $"{failed}: it costs roughly {EstimateRingKib(entries)} KiB of RLIMIT_MEMLOCK, and the kernel " + + "reclaims a closed ring's memory asynchronously - raise `ulimit -l`, lower " + + "ServerConfig.RingEntries, or create reactors less abruptly.", + EPERM => $"{failed}: io_uring is disabled here, by the kernel.io_uring_disabled sysctl or by a seccomp " + + "profile such as a container runtime's default.", + ENOSYS => $"{failed}: this kernel was built without io_uring.", + _ => failed + ".", + }; + } + + private static string KernelRelease() + { + try + { + return File.ReadAllText("/proc/sys/kernel/osrelease").Trim(); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + return Environment.OSVersion.Version.ToString(); + } + } + + // "5.15.167.4-microsoft-standard-WSL2" is older than 6.1; "6.14.0-37-generic" is not. + private static bool KernelOlderThan(string release, int major, int minor) + { + string[] parts = release.Split('.', '-'); + return parts.Length >= 2 + && int.TryParse(parts[0], out int a) + && int.TryParse(parts[1], out int b) + && (a < major || (a == major && b < minor)); + } + public static Ring Create(uint entries) { // Prefer NO_SQARRAY (6.6+): the SQ slot index is implicit, dropping one @@ -86,14 +128,7 @@ public static Ring Create(uint entries) if (fd < 0) { - throw new InvalidOperationException( - $"io_uring_setup failed with errno {-fd}" - + (fd == -ENOMEM - ? $". A ring of {entries} entries costs roughly {EstimateRingKib(entries)} KiB of " - + "RLIMIT_MEMLOCK, and the kernel reclaims a closed ring's memory " - + "asynchronously - raise `ulimit -l`, lower ServerConfig.RingEntries, or " - + "create reactors less abruptly." - : string.Empty)); + throw new InvalidOperationException(DescribeSetupFailure(-fd, entries, KernelRelease())); } var ring = new Ring diff --git a/tests/Ioxide.Tests.E2E/Core/ReactorSetupTeardownTests.cs b/tests/Ioxide.Tests.E2E/Core/ReactorSetupTeardownTests.cs index 9a5bc8e..08f7e6e 100644 --- a/tests/Ioxide.Tests.E2E/Core/ReactorSetupTeardownTests.cs +++ b/tests/Ioxide.Tests.E2E/Core/ReactorSetupTeardownTests.cs @@ -69,5 +69,43 @@ public static void Register(Runner runner) "teardown after the failed bind closed fd 0 - stdin - and the number is now free " + "for the next socket the process opens"); }); + + runner.Test("reactor: a ring the kernel refuses reaches OnFault instead of ending the process", () => + { + // Every kernel refuses more than 32768 entries with EINVAL - the errno a kernel older than + // 6.1 gives for SINGLE_ISSUER | DEFER_TASKRUN (#270). Ring.Create ran outside the try that + // hands faults to OnFault, so a host that asked to hear of faults lost the process instead. + Exception? reported = null; + var reactor = new Reactor(0, new ServerConfig { ReactorCount = 1, RingEntries = 65_536 }) + { + TcpHandle = (_, connection) => + { + connection.DecRef(); + return Task.CompletedTask; + }, + OnFault = (_, e) => reported = e, + }; + + Exception? escaped = null; + var thread = new Thread(() => + { + try + { + reactor.Run(); + } + catch (Exception e) + { + escaped = e; + } + }); + thread.Start(); + + Assert.True(thread.Join(TimeSpan.FromSeconds(10)), "Run should have returned after the ring was refused"); + Assert.True(escaped is null, + $"the refused ring was thrown out of Run past OnFault - on a host's thread, that ends the process: {escaped?.Message}"); + Assert.True(reported is InvalidOperationException, $"OnFault was not told: {reported}"); + Assert.True(reported!.Message.Contains("32768"), + $"the message does not say what the kernel refused: {reported.Message}"); + }); } } diff --git a/tests/Ioxide.Tests.Unit/Program.cs b/tests/Ioxide.Tests.Unit/Program.cs index cc0cf0c..0ee8f52 100644 --- a/tests/Ioxide.Tests.Unit/Program.cs +++ b/tests/Ioxide.Tests.Unit/Program.cs @@ -13,6 +13,7 @@ private static int Main() VersionTests.Register(runner); SyscallErrnoTests.Register(runner); + RingSetupMessageTests.Register(runner); DemuxParseTests.Register(runner); MessageTests.Register(runner); ResponseCapTests.Register(runner); diff --git a/tests/Ioxide.Tests.Unit/RingSetupMessageTests.cs b/tests/Ioxide.Tests.Unit/RingSetupMessageTests.cs new file mode 100644 index 0000000..31cf040 --- /dev/null +++ b/tests/Ioxide.Tests.Unit/RingSetupMessageTests.cs @@ -0,0 +1,37 @@ +using ioxide; + +namespace Ioxide.Tests; + +/// +/// What a refused io_uring_setup says (#270). "errno 22" on its own sent people to limits and +/// permissions, when the usual cause is a kernel older than the setup flags - WSL2's 5.15 refused +/// both attempts with EINVAL. +/// +internal static class RingSetupMessageTests +{ + public static void Register(Runner runner) + { + runner.Test("ring setup: a kernel older than 6.1 is named as the cause", () => + { + string message = Ring.DescribeSetupFailure(22, 8192, "5.15.167.4-microsoft-standard-WSL2"); + + Assert.True(message.Contains("5.15.167.4-microsoft-standard-WSL2") && message.Contains("Linux 6.1 or later"), + $"the message does not say the kernel is too old: {message}"); + }); + + runner.Test("ring setup: too many entries is named, not the kernel", () => + { + string message = Ring.DescribeSetupFailure(22, 65_536, "6.14.0-37-generic"); + + Assert.True(message.Contains("at most 32768"), $"the message does not name the entry limit: {message}"); + Assert.True(!message.Contains("6.1 or later"), $"a 6.14 kernel was blamed for being too old: {message}"); + }); + + runner.Test("ring setup: io_uring disabled by policy says so", () => + { + string message = Ring.DescribeSetupFailure(1, 8192, "6.14.0-37-generic"); + + Assert.True(message.Contains("disabled"), $"EPERM was not explained: {message}"); + }); + } +}