From d3abf13d4dfbe9923c22dd40d408dc838d105bee Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Mon, 6 Jul 2026 04:33:54 -0300 Subject: [PATCH 1/2] fix: split io_uring UDP availability gate --- README.md | 6 ++ docs/concepts/architecture.md | 2 +- docs/reference/modules.md | 2 +- src/main.zig | 43 +++++++++----- src/net/io_uring.zig | 103 +++++++++++++++++++++++++++++++--- 5 files changed, 134 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 341e5c5..9342c8b 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,11 @@ Trust authorization order: | **meshguard** (Zig, userspace) | **3.93 Gbps** | **3.94 Gbps** | libsodium AVX2, UDP GRO, zero-copy GSO, NAPI busy-poll | | boringtun (Rust, userspace) | 1.82 Gbps | 1.81 Gbps | Cloudflare userspace WG | +Linux UDP sockets can use an `io_uring` recvmsg/sendmsg ring when runtime +probing and initialization succeed; startup logs print the active UDP path. +The poll+recvmsg path remains the fallback, and the separate TUN `io_uring` +reader stays disabled pending bare-metal TUN read validation. + ### Optimization History | Optimization | Download | Δ | @@ -408,6 +413,7 @@ Core functionality is implemented and under active benchmarking: - [x] WireGuard tunnel FFI API (open/send/recv/close for encrypted audio/data channels) - [x] Compile-time AEAD backend selection (libsodium on Linux, std.crypto on Android/macOS/FreeBSD/Windows) - [x] Multi-queue TUN (`IFF_MULTI_QUEUE` — Linux, parallel I/O via flow-hash distribution) +- [x] `io_uring` UDP ring (Linux runtime-gated, fallback to poll+recvmsg) - [x] `io_uring` TUN reader (implemented, runtime-disabled pending bare-metal TUN validation) - [x] IPv6 dual-stack (ULA `fd99:6d67::/64`, deterministic from pubkey via Blake3) - [x] DNS / mDNS seed discovery diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md index 0dbc7a7..141dbab 100644 --- a/docs/concepts/architecture.md +++ b/docs/concepts/architecture.md @@ -78,7 +78,7 @@ meshguard is organized into six top-level modules, each with a focused responsib - **`utun.zig`** / **`fbsdtun.zig`** / **`wintun.zig`** — macOS, FreeBSD, and Windows userspace tunnel devices - **`darwincfg.zig`** / **`freebsdcfg.zig`** / **`wincfg.zig`** — platform interface configuration helpers - **`io.zig`** — Event loop abstraction layer -- **`io_uring.zig`** — Linux io_uring integration for async I/O +- **`io_uring.zig`** — Linux io_uring UDP ring and TUN reader gates for async I/O - **`pipeline.zig`** — Packet processing pipeline with batched encrypt/decrypt ## Packet Flow diff --git a/docs/reference/modules.md b/docs/reference/modules.md index 538849f..2d092c6 100644 --- a/docs/reference/modules.md +++ b/docs/reference/modules.md @@ -90,7 +90,7 @@ Reference map of all source modules and their responsibilities. | `offload.zig` | GSO/GRO offload: `IFF_VNET_HDR`, segmentation offload for high-throughput paths | | `pipeline.zig` | Packet processing pipeline: batched encrypt/decrypt with multi-queue TUN support | | `io.zig` | Event loop abstraction layer | -| `io_uring.zig` | Linux io_uring integration for async I/O | +| `io_uring.zig` | Linux io_uring UDP ring and TUN reader gates for async I/O | ## `docker/` diff --git a/src/main.zig b/src/main.zig index 4eeba70..e8dfa84 100644 --- a/src/main.zig +++ b/src/main.zig @@ -3293,8 +3293,8 @@ fn userspaceEventLoop( // Set all TUN fds to non-blocking (only for legacy poll+read path). // io_uring manages its own waiting and needs blocking fds. - const use_io_uring = lib.net.IoUring.isAvailable(); - if (!use_io_uring) { + const use_tun_io_uring = lib.net.IoUring.isTunReaderAvailable(); + if (!use_tun_io_uring) { for (0..opened_workers) |w| { const flags = posix.system.fcntl(tun_fds[w], posix.F.GETFL, @as(usize, 0)); switch (posix.errno(flags)) { @@ -3353,10 +3353,10 @@ fn userspaceEventLoop( }; // Log io_uring TUN reader choice (detection happened above) - if (use_io_uring) { - writeFormatted(stdout, " io_uring: available, using ring-based TUN reader\n", .{}) catch {}; + if (use_tun_io_uring) { + writeFormatted(stdout, " io_uring TUN: using ring-based TUN reader\n", .{}) catch {}; } else { - writeFormatted(stdout, " io_uring: unavailable, using poll+read TUN reader\n", .{}) catch {}; + writeFormatted(stdout, " io_uring TUN: {s}, using poll+read TUN reader\n", .{lib.net.IoUring.tunReaderUnavailableReason()}) catch {}; } // Parallel pipeline: TUN readers + encrypt workers @@ -3369,7 +3369,7 @@ fn userspaceEventLoop( data_pool, crypto_queue, }; - const thread = if (use_io_uring) + const thread = if (use_tun_io_uring) std.Thread.spawn(.{}, tunReaderIoUring, args) else std.Thread.spawn(.{}, tunReaderPipeline, args); @@ -3425,17 +3425,34 @@ fn userspaceEventLoop( var gro_rx = BatchUdp.GROReceiver{}; var udp_ring: lib.net.IoUring.UdpRing = undefined; + var udp_ring_init_error: ?anyerror = null; + const udp_ring_available = lib.net.IoUring.isUdpRingAvailable(); const use_udp_ring = blk: { - udp_ring.init(udp_sock.fd) catch break :blk false; + if (!udp_ring_available) break :blk false; + udp_ring.init(udp_sock.fd) catch |err| { + udp_ring_init_error = err; + break :blk false; + }; break :blk true; }; defer if (use_udp_ring) udp_ring.deinit(); - if (use_udp_ring) { - const sqpoll_msg = if (udp_ring.sqpoll) "SQPOLL" else "submit"; - const buffer_msg = if (udp_ring.registered_buffers) "registered buffers" else "unregistered buffers"; - writeFormatted(stdout, " io_uring UDP: recvmsg/sendmsg ring active ({s}, {s})\n", .{ sqpoll_msg, buffer_msg }) catch {}; - } else { - writeFormatted(stdout, " io_uring UDP: unavailable, using poll+recvmsg path\n", .{}) catch {}; + const udp_path = lib.net.IoUring.selectUdpPath(udp_ring_available, use_udp_ring); + switch (udp_path) { + .io_uring => { + const sqpoll_msg = if (udp_ring.sqpoll) "SQPOLL" else "submit"; + const buffer_msg = if (udp_ring.registered_buffers) "registered buffers" else "unregistered buffers"; + writeFormatted(stdout, " UDP path: io_uring recvmsg/sendmsg ring active ({s}, {s})\n", .{ sqpoll_msg, buffer_msg }) catch {}; + }, + .poll_recvmsg => |reason| switch (reason) { + .runtime_unavailable => writeFormatted(stdout, " UDP path: poll+recvmsg (io_uring runtime unavailable)\n", .{}) catch {}, + .init_failed => { + if (udp_ring_init_error) |err| { + writeFormatted(stdout, " UDP path: poll+recvmsg (io_uring init failed: {s})\n", .{@errorName(err)}) catch {}; + } else { + writeFormatted(stdout, " UDP path: poll+recvmsg (io_uring init failed)\n", .{}) catch {}; + } + }, + }, } const MAX_DECRYPTED = 64; diff --git a/src/net/io_uring.zig b/src/net/io_uring.zig index 14fbcba..a8ac89d 100644 --- a/src/net/io_uring.zig +++ b/src/net/io_uring.zig @@ -4,6 +4,7 @@ const posix = std.posix; const linux = std.os.linux; const IoUring = linux.IoUring; const Offload = @import("offload.zig"); +const Udp = @import("udp.zig"); const UDP_RECV_USER_DATA: u64 = 0x1000_0000_0000_0000; const UDP_SEND_USER_DATA: u64 = 0x2000_0000_0000_0000; @@ -30,6 +31,22 @@ fn initRing(entries: u16, use_sqpoll: bool) !IoUring { }; } +pub const UdpFallbackReason = enum { + runtime_unavailable, + init_failed, +}; + +pub const UdpPath = union(enum) { + io_uring, + poll_recvmsg: UdpFallbackReason, +}; + +pub fn selectUdpPath(runtime_available: bool, init_succeeded: bool) UdpPath { + if (!runtime_available) return .{ .poll_recvmsg = .runtime_unavailable }; + if (!init_succeeded) return .{ .poll_recvmsg = .init_failed }; + return .io_uring; +} + /// TUN reader backed by io_uring. /// Pre-submits N read SQEs and resubmits on each completion. /// The caller processes completed reads identically to the poll()+read() path. @@ -365,17 +382,33 @@ pub const UdpRing = struct { } }; +/// Check whether the UDP io_uring path should be attempted. This is intentionally +/// independent from the TUN-reader gate: UDP sockets can use recvmsg/sendmsg rings +/// even while TUN character-device reads remain disabled pending validation. +pub fn isUdpRingAvailable() bool { + if (builtin.os.tag != .linux) return false; + var ring = initRing(8, true) catch return false; + ring.deinit(); + return true; +} + /// Check if io_uring is available AND compatible with TUN devices. -/// Currently disabled: TUN character devices (tun_chr_read_iter) do not -/// reliably support io_uring IORING_OP_READ — reads fail silently or -/// return errors in LXC containers. The io_uring TUN reader code is -/// preserved for future bare-metal testing. -pub fn isAvailable() bool { - // TODO: Enable after validating io_uring reads on TUN devices work - // on bare metal (outside LXC containers). +pub fn isTunReaderAvailable() bool { + // TUN character devices (tun_chr_read_iter) still need bare-metal validation: + // reads have failed silently or returned errors in LXC containers. Keep the + // reader code preserved, but do not let that disable the UDP ring path. return false; } +pub fn tunReaderUnavailableReason() []const u8 { + return "disabled pending bare-metal TUN read validation"; +} + +/// Back-compat alias for older call sites; prefer the split gates above. +pub fn isAvailable() bool { + return isTunReaderAvailable(); +} + test "UdpRing parses UDP_GRO cmsg segment size" { var slot = UdpRing.RecvSlot{}; slot.setup(); @@ -393,3 +426,59 @@ test "UdpRing parses UDP_GRO cmsg segment size" { try std.testing.expectEqual(@as(u16, 1440), slot.segmentSize()); } + +test "io_uring UDP and TUN availability gates are independent" { + switch (selectUdpPath(false, true)) { + .poll_recvmsg => |reason| try std.testing.expectEqual(UdpFallbackReason.runtime_unavailable, reason), + .io_uring => return error.TestUnexpectedResult, + } + switch (selectUdpPath(true, false)) { + .poll_recvmsg => |reason| try std.testing.expectEqual(UdpFallbackReason.init_failed, reason), + .io_uring => return error.TestUnexpectedResult, + } + switch (selectUdpPath(true, true)) { + .io_uring => {}, + .poll_recvmsg => return error.TestUnexpectedResult, + } + try std.testing.expect(!isTunReaderAvailable()); + _ = isUdpRingAvailable(); +} + +test "UdpRing receives loopback datagram when runtime available" { + if (!isUdpRingAvailable()) return; + + var rx = try Udp.UdpSocket.bindAddr(.{ 127, 0, 0, 1 }, 0); + defer rx.close(); + var tx = try Udp.UdpSocket.bindAddr(.{ 127, 0, 0, 1 }, 0); + defer tx.close(); + + var ring: UdpRing = undefined; + try ring.init(rx.fd); + defer ring.deinit(); + + const payload = "meshguard-udp-ring-probe"; + try std.testing.expectEqual(payload.len, try tx.sendTo(payload, .{ 127, 0, 0, 1 }, rx.port)); + + var cqes: [UdpRing.RECV_DEPTH + UdpRing.SEND_DEPTH]linux.io_uring_cqe = undefined; + var attempts: usize = 0; + while (attempts < 100) : (attempts += 1) { + const n = ring.copyCompletions(&cqes, 0) catch 0; + for (cqes[0..n]) |cqe| { + ring.noteSendCompletion(cqe); + const recv = ring.recvCompletion(cqe) orelse { + if (UdpRing.recvSlotFromUserData(cqe.user_data)) |slot| { + ring.resubmitRecv(slot, rx.fd) catch {}; + } + continue; + }; + try std.testing.expectEqualSlices(u8, payload, recv.data); + try std.testing.expectEqualSlices(u8, &[_]u8{ 127, 0, 0, 1 }, &recv.sender_addr); + try std.testing.expect(recv.sender_port != 0); + try ring.resubmitRecv(recv.slot, rx.fd); + return; + } + std.Thread.sleep(1 * std.time.ns_per_ms); + } + + return error.UdpRingProbeTimedOut; +} From 864720a01b3e49e1660bdd7732a872e04068aea9 Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Mon, 6 Jul 2026 04:40:21 -0300 Subject: [PATCH 2/2] test: mirror UDP ring fallback in probe --- src/net/io_uring.zig | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/net/io_uring.zig b/src/net/io_uring.zig index a8ac89d..3f9fd89 100644 --- a/src/net/io_uring.zig +++ b/src/net/io_uring.zig @@ -453,7 +453,8 @@ test "UdpRing receives loopback datagram when runtime available" { defer tx.close(); var ring: UdpRing = undefined; - try ring.init(rx.fd); + // Match production fallback behavior when socket-specific ring setup is blocked. + ring.init(rx.fd) catch return; defer ring.deinit(); const payload = "meshguard-udp-ring-probe";