Add io_uring UDP event loop path#100
Conversation
Agent-Logs-Url: https://github.com/igorls/meshguard/sessions/71f00ba9-16d4-42e0-82d3-49200c7d7dcc Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>
Agent-Logs-Url: https://github.com/igorls/meshguard/sessions/71f00ba9-16d4-42e0-82d3-49200c7d7dcc Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>
Agent-Logs-Url: https://github.com/igorls/meshguard/sessions/71f00ba9-16d4-42e0-82d3-49200c7d7dcc Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>
Agent-Logs-Url: https://github.com/igorls/meshguard/sessions/71f00ba9-16d4-42e0-82d3-49200c7d7dcc Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>
Agent-Logs-Url: https://github.com/igorls/meshguard/sessions/71f00ba9-16d4-42e0-82d3-49200c7d7dcc Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>
Agent-Logs-Url: https://github.com/igorls/meshguard/sessions/71f00ba9-16d4-42e0-82d3-49200c7d7dcc Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>
- Rename msghdr/msghdr_const fields to match Zig 0.16.0 std (msg_name→name, msg_namelen→namelen, etc.) - Cast fcntl return value via @intcast to handle c_int→usize mismatch when libc is linked
There was a problem hiding this comment.
Pull request overview
Adds an io_uring-backed UDP receive path to the Linux userspace control-plane event loop (with fallback to the existing poll() + recvmsg GRO path), plus a small unit test covering UDP GRO cmsg segment-size parsing.
Changes:
- Introduces
UdpRinginsrc/net/io_uring.zigwith pre-submittedrecvmsgslots and optional buffer registration / SQPOLL setup. - Updates
userspaceEventLoop()to preferUdpRingcompletions for UDP receives while preserving GRO segmentation handling. - Adds a focused test validating UDP_GRO cmsg segment-size parsing.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| src/net/io_uring.zig | Adds UdpRing implementation (recv/send scaffolding, fd flag management) and a UDP_GRO cmsg parsing unit test. |
| src/main.zig | Integrates UdpRing into the Linux userspace control-plane event loop with runtime logging and poll-based fallback. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (use_udp_ring) { | ||
| const n_cqes = udp_ring.copyCompletions(&cqe_buffer, 0) catch 0; | ||
| var n_decrypted: usize = 0; |
There was a problem hiding this comment.
udp_ring.copyCompletions(... ) catch 0 silently suppresses io_uring errors and treats them as "no CQEs", which can leave the loop spinning/backing off with no visibility and no fallback. Consider handling the error explicitly (log once and disable use_udp_ring / fall back to poll()), so failures don't get masked.
| }; | ||
| slot.iov = .{ .base = &slot.buf, .len = data.len }; | ||
| slot.msg.namelen = @sizeOf(posix.sockaddr.in); | ||
| slot.busy = true; |
There was a problem hiding this comment.
In sendTo, the slot is marked busy = true before queueing/submitting the SQE. If ring.sendmsg() or ring.submit() returns an error, the function exits early and the slot remains permanently busy, reducing send capacity. Consider setting busy only after the SQE is successfully queued (or using an errdefer to clear it on failure).
| slot.busy = true; | |
| slot.busy = true; | |
| errdefer slot.busy = false; |
| if ((flags & O_NONBLOCK_FLAG) != 0) { | ||
| // Blocking fds let io_uring arm async socket receive instead of completing | ||
| // immediately with EAGAIN on every pre-submitted recvmsg SQE. The saved | ||
| // original flags are restored by restoreFdFlags() during cleanup. | ||
| const set_rc = posix.system.fcntl(fd, posix.F.SETFL, flags & ~O_NONBLOCK_FLAG); | ||
| switch (posix.errno(set_rc)) { | ||
| .SUCCESS => {}, | ||
| else => |err| return posix.unexpectedErrno(err), | ||
| } | ||
| } |
There was a problem hiding this comment.
UdpRing.init clears O_NONBLOCK on the shared UDP socket fd. The control-plane still sends responses via udp_sock.sendTo(...) (which uses sendto(..., flags=0)), so those sends can now block under socket backpressure. To preserve prior non-blocking behavior, consider sending with MSG_DONTWAIT on that socket (or routing control-plane sends through UdpRing.sendTo / io_uring instead of direct sendto).
| if ((flags & O_NONBLOCK_FLAG) != 0) { | |
| // Blocking fds let io_uring arm async socket receive instead of completing | |
| // immediately with EAGAIN on every pre-submitted recvmsg SQE. The saved | |
| // original flags are restored by restoreFdFlags() during cleanup. | |
| const set_rc = posix.system.fcntl(fd, posix.F.SETFL, flags & ~O_NONBLOCK_FLAG); | |
| switch (posix.errno(set_rc)) { | |
| .SUCCESS => {}, | |
| else => |err| return posix.unexpectedErrno(err), | |
| } | |
| } | |
| // Keep the socket's original status flags unchanged. This fd is shared with | |
| // control-plane code paths that may still use direct sendto/sendTo calls, and | |
| // clearing O_NONBLOCK here would make those sends block under backpressure. |
| pub fn resubmitRecv(self: *UdpRing, slot: u16, fd: posix.fd_t) !void { | ||
| try self.submitRecv(slot, fd); | ||
| _ = try self.ring.submit(); | ||
| } | ||
|
|
There was a problem hiding this comment.
resubmitRecv calls ring.submit() for every completed packet, which can reintroduce a syscall-per-packet when SQPOLL isn't active and reduces the benefit of pre-submitted receives. Consider providing a queueResubmitRecv (no submit) and submitting once after processing a batch of CQEs (or resubmitting all drained slots and calling submit() once).
| pub fn resubmitRecv(self: *UdpRing, slot: u16, fd: posix.fd_t) !void { | |
| try self.submitRecv(slot, fd); | |
| _ = try self.ring.submit(); | |
| } | |
| pub fn queueResubmitRecv(self: *UdpRing, slot: u16, fd: posix.fd_t) !void { | |
| try self.submitRecv(slot, fd); | |
| } | |
| pub fn resubmitRecv(self: *UdpRing, slot: u16, fd: posix.fd_t) !void { | |
| try self.queueResubmitRecv(slot, fd); | |
| } | |
| pub fn submitRecvResubmissions(self: *UdpRing) !u32 { | |
| return try self.ring.submit(); | |
| } |
| 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 {}; |
There was a problem hiding this comment.
The startup log says "recvmsg/sendmsg ring active", but the control-plane still sends via udp_sock.sendTo(...) (regular sendto) rather than UdpRing.sendTo, so "sendmsg" isn't actually active here. Consider adjusting the message (e.g., "recvmsg ring active") or switching control-plane sends to the ring if intended.
| writeFormatted(stdout, " io_uring UDP: recvmsg/sendmsg ring active ({s}, {s})\n", .{ sqpoll_msg, buffer_msg }) catch {}; | |
| writeFormatted(stdout, " io_uring UDP: recvmsg ring active ({s}, {s})\n", .{ sqpoll_msg, buffer_msg }) catch {}; |
Replaces the Linux userspace UDP control-plane polling path with an
io_uringreceive/send path when available, while preserving the existingpoll()+ GRO fallback. TUNio_uringremains runtime-disabled pending bare-metal validation.io_uring UDP ring
UdpRingwith pre-submittedIORING_OP_RECVMSGslots.IORING_OP_SENDMSGsupport using ring-owned send buffers.IORING_SETUP_SQPOLL, falling back to normal submission if unavailable.Control-plane integration
UdpRingfor UDP receive completions.poll()+recvmsgpath remains the fallback whenio_uringsetup fails.Runtime safety
O_NONBLOCKfor the UDP fd while the ring is active so pre-submitted receives can arm asynchronously.Coverage
Example shape of the new receive path: