Skip to main content

yip_io/
uring.rs

1//! io_uring driver using one ring over UDP + TUN with provided-buffer receives.
2//!
3//! This backend keeps one ring alive and drives both fds from it. UDP is now
4//! unconnected (the addressed socket seam, #33): receives use single-shot
5//! `recvmsg`, each carrying its own dedicated buffer + `sockaddr_storage`, so
6//! the driver recovers each datagram's source address; a fresh recv is
7//! re-armed on the same slot after every completion. Sends use `sendmsg` with
8//! an explicit per-datagram destination (see [`EgressDatagram::dst`]). TUN
9//! uses a single pooled read that is re-submitted after each completion;
10//! multishot `read` is not yet relied on.
11
12use std::io;
13use std::net::SocketAddr;
14use std::os::fd::RawFd;
15use std::time::Instant;
16
17use io_uring::{cqueue, opcode, squeue, types, IoUring};
18
19use crate::poll::{Dispatch, DispatchOut, EgressDatagram};
20use crate::{sockaddr_to_std, std_to_sockaddr, MAX_DATAGRAM_BATCH, MAX_WIRE_DATAGRAM};
21
22// io_uring SQ depth (CQ is 2x this = the kernel default). Sized to hold the
23// deeper UDP recv queue (`UDP_RECV_DEPTH` = 256) plus in-flight sends
24// (`SEND_SLOTS` = 256) and TUN reads without the SQ filling mid-iteration
25// (which forces extra submit-retry syscalls) or the CQ overflowing into the
26// (NODROP) kernel backlog. Raised 512 -> 1024 alongside the recv-depth bump.
27const RING_ENTRIES: u32 = 1024;
28const RING_BUFS: usize = 256;
29const TUN_READ_DEPTH: usize = 16;
30/// How many single-shot UDP `recvmsg` requests are kept outstanding at once.
31/// Each has its own dedicated buffer + `sockaddr_storage` (no provided-buffer
32/// pool, unlike TUN reads) — see the module doc for why UDP recv moved off
33/// multishot `RecvMulti`/`BUFFER_SELECT`.
34///
35/// This also caps how many datagrams a single `poll_once` can harvest before it
36/// must re-arm, so it bounds the UDP drain rate per wake cycle. The poll
37/// fallback (`PlainIo::drain_udp`) drains the WHOLE socket backlog per wakeup
38/// (`recvmmsg(64)` until a short read); a shallow depth here does not, so under
39/// CPU contention the socket receive queue can outpace the driver and overflow
40/// a clamped `net.core.rmem_max`, dropping datagrams the poll driver would not.
41/// Measured on the CI runner (constrained container): the uring bulk flow
42/// delivered 80-96% while poll held 99.3% in the same run — and the run did NOT
43/// fall back to poll (no "falling back to PollDriver" log line), so it was this
44/// drain-depth deficit, not a driver crash. Raised 16 -> 256 (matches
45/// `RING_BUFS`; ~590 KB of slots) so one `poll_once` drains up to ~256 datagrams
46/// (~51 ms of a 5000 pps flow), closing the gap to the poll driver. Total
47/// outstanding (256 recv + up to 256 send + TUN) stays within the CQ — see
48/// `RING_ENTRIES`.
49const UDP_RECV_DEPTH: usize = 256;
50const BUF_GROUP: u16 = 17;
51const SEND_SLOTS: usize = 256;
52const TAG_SHIFT: u32 = 56;
53const TAG_UDP_RECV: u64 = 1_u64 << TAG_SHIFT;
54const TAG_TUN_RECV: u64 = 2_u64 << TAG_SHIFT;
55const TAG_REPROVIDE: u64 = 3_u64 << TAG_SHIFT;
56const TAG_SEND_SLOT: u64 = 4_u64 << TAG_SHIFT;
57const TAG_KIND_MASK: u64 = 0xff_u64 << TAG_SHIFT;
58const TAG_PAYLOAD_MASK: u64 = !TAG_KIND_MASK;
59const SQE_FLAGS_BUFFER_SELECT: squeue::Flags = squeue::Flags::BUFFER_SELECT;
60const MAX_WIRE_DATAGRAM_I32: i32 = 2048;
61const MAX_WIRE_DATAGRAM_U32: u32 = 2048;
62const RING_BUFS_U16: u16 = 256;
63const MAX_GSO_DATAGRAMS: usize = MAX_DATAGRAM_BATCH;
64const MAX_GSO_PAYLOAD: usize = MAX_WIRE_DATAGRAM * MAX_GSO_DATAGRAMS;
65/// Cap on egress datagrams staged for GSO within one `poll_once`, bounding the
66/// dedup pass in `flush_pending_gso`.
67const MAX_PENDING_GSO_DATAGRAMS: usize = 512;
68/// Completion-queue busy-poll budget: when busy-poll is enabled, how many times
69/// `poll_once` spins checking for a completion before falling back to a blocking
70/// wait. Spinning trades CPU for lower wakeup latency — measured to cut tunnel
71/// RTT from ~0.46 ms (blocking) to ~0.26 ms, beating the epoll `PollDriver`.
72/// This is a "burn CPU for latency" knob (yip's north star), off by default and
73/// enabled with `YIP_URING_BUSYPOLL=1`. The value wants per-hardware tuning; it
74/// is intentionally large because the spin must outlast a full intra-round-trip
75/// event gap to catch the imminent reply completion.
76const CQ_SPIN_BUDGET: u32 = 2_000_000;
77/// Blocking-wait timeout so `tick` fires on cadence even when the tunnel is
78/// idle (parity with poll.rs's 10 ms `epoll_wait` timeout). Requires the io_uring
79/// `EXT_ARG` feature (kernel 5.11+); on older kernels the wait is unbounded.
80const TICK_TIMEOUT_NS: u32 = 10_000_000;
81const GSO_CONTROL_PAYLOAD_LEN_U32: u32 = 2;
82const GSO_CONTROL_SPACE: usize = 64;
83
84#[derive(Clone, Copy)]
85struct GsoMeta {
86    segment_size: u16,
87    datagram_count: usize,
88    /// Shared destination of every datagram in this coalesced send —
89    /// `can_coalesce_gso_tagged` guarantees every datagram in a GSO batch
90    /// shares one `dst`, so recovering a fallback/unsent datagram (below)
91    /// can reuse it directly.
92    dst: SocketAddr,
93}
94
95/// Which fd an in-flight send slot targets, so completion-error handling can
96/// mirror `PollDriver`: TUN writes are always dropped, UDP sends drop on
97/// transient buffer pressure but propagate genuinely fatal errors.
98#[derive(Clone, Copy, PartialEq, Eq)]
99enum SendKind {
100    Udp,
101    Tun,
102}
103
104/// Per-send-slot `sendmsg` context: the datagram's own destination
105/// (`name`/`namelen`), its iovec, and (GSO sends only) the `UDP_SEGMENT`
106/// control message. Every UDP send now goes through `sendmsg` (the socket is
107/// unconnected — the addressed seam), so both plain and GSO sends need
108/// `msg_name` populated; only GSO sends need the `control` cmsg.
109struct GsoSendContext {
110    iov: libc::iovec,
111    msg: libc::msghdr,
112    control: [u8; GSO_CONTROL_SPACE],
113    name: libc::sockaddr_storage,
114}
115
116impl GsoSendContext {
117    fn new() -> Self {
118        Self {
119            iov: libc::iovec {
120                iov_base: std::ptr::null_mut(),
121                iov_len: 0,
122            },
123            msg: crate::empty_msghdr(),
124            control: [0_u8; GSO_CONTROL_SPACE],
125            // SAFETY: `sockaddr_storage` is plain-old-data (integers/byte
126            // arrays); the all-zero bit pattern is a valid value for it.
127            // `set_destination` overwrites it before every send.
128            name: unsafe { std::mem::zeroed() },
129        }
130    }
131
132    /// Point `msg_name`/`msg_namelen` at this datagram's destination.
133    fn set_destination(&mut self, dst: SocketAddr) {
134        let (storage, len) = std_to_sockaddr(dst);
135        self.name = storage;
136        self.msg.msg_name = std::ptr::addr_of_mut!(self.name).cast::<libc::c_void>();
137        self.msg.msg_namelen = len;
138    }
139
140    /// Prepare a plain (non-GSO) `sendmsg`: payload + destination, no cmsg.
141    fn prepare_plain(&mut self, payload_ptr: *mut u8, payload_len: usize, dst: SocketAddr) {
142        self.iov.iov_base = payload_ptr.cast::<libc::c_void>();
143        self.iov.iov_len = payload_len;
144        self.msg.msg_iov = std::ptr::addr_of_mut!(self.iov);
145        self.msg.msg_iovlen = 1;
146        self.msg.msg_control = std::ptr::null_mut();
147        self.msg.msg_controllen = 0;
148        self.msg.msg_flags = 0;
149        self.set_destination(dst);
150    }
151
152    /// Prepare a GSO `sendmsg`: payload + destination + `UDP_SEGMENT` cmsg.
153    fn prepare_gso(
154        &mut self,
155        payload_ptr: *mut u8,
156        payload_len: usize,
157        segment_size: u16,
158        dst: SocketAddr,
159    ) -> io::Result<()> {
160        self.iov.iov_base = payload_ptr.cast::<libc::c_void>();
161        self.iov.iov_len = payload_len;
162        self.msg.msg_iov = std::ptr::addr_of_mut!(self.iov);
163        self.msg.msg_iovlen = 1;
164        self.msg.msg_control = self.control.as_mut_ptr().cast::<libc::c_void>();
165        // SAFETY: `CMSG_SPACE` is a pure size computation for the provided
166        // payload length and does not dereference pointers.
167        let cmsg_space = usize::try_from(unsafe { libc::CMSG_SPACE(GSO_CONTROL_PAYLOAD_LEN_U32) })
168            .map_err(|_| io::Error::other("cmsg space does not fit usize"))?;
169        if cmsg_space > self.control.len() {
170            return Err(io::Error::other("GSO control buffer too small"));
171        }
172        self.msg.msg_controllen = cmsg_space as crate::CmsgLen;
173        self.msg.msg_flags = 0;
174        self.set_destination(dst);
175
176        // SAFETY: `self.msg` points to valid in-struct iovec/control storage.
177        // We write exactly one SOL_UDP/UDP_SEGMENT cmsg payload (u16 segment size)
178        // into the allocated control region.
179        unsafe {
180            let cmsg = libc::CMSG_FIRSTHDR(std::ptr::addr_of!(self.msg));
181            if cmsg.is_null() {
182                return Err(io::Error::other("missing first cmsg header"));
183            }
184            (*cmsg).cmsg_level = libc::SOL_UDP;
185            (*cmsg).cmsg_type = libc::UDP_SEGMENT;
186            let cmsg_len: usize = usize::try_from(libc::CMSG_LEN(GSO_CONTROL_PAYLOAD_LEN_U32))
187                .map_err(|_| io::Error::other("cmsg len does not fit usize"))?;
188            (*cmsg).cmsg_len = cmsg_len as crate::CmsgLen;
189            let segment_ptr = libc::CMSG_DATA(cmsg).cast::<u16>();
190            *segment_ptr = segment_size;
191        }
192        Ok(())
193    }
194}
195
196/// One outstanding single-shot UDP `recvmsg` request: its own dedicated
197/// payload buffer + `sockaddr_storage`, plus the `iovec`/`msghdr` that
198/// self-reference them.
199///
200/// These are allocated once into a `Box<[UdpRecvSlot]>` that is never resized
201/// after `UringDriver::new` — so the addresses `iov`/`msg` point at (`buf`,
202/// `name`, and `iov` itself) stay valid for the driver's whole lifetime,
203/// exactly like `recv_pool`'s stable buffer addresses below.
204struct UdpRecvSlot {
205    buf: [u8; MAX_WIRE_DATAGRAM],
206    name: libc::sockaddr_storage,
207    iov: libc::iovec,
208    msg: libc::msghdr,
209}
210
211impl UdpRecvSlot {
212    /// A slot with every field zeroed and no pointers fixed up yet. Callers
213    /// must fix up `iov`/`msg` to self-reference `buf`/`name` immediately
214    /// after the slot reaches its final (never-to-move-again) address — see
215    /// `UringDriver::new`.
216    fn zeroed() -> Self {
217        // SAFETY: every field is plain-old-data (byte array / integers /
218        // pointers); the all-zero bit pattern is valid for all of them. Null
219        // `iov`/`msg` pointers are never submitted to the kernel — they are
220        // fixed up before the first `arm_udp_recv_slot` call.
221        unsafe { std::mem::zeroed() }
222    }
223}
224
225/// One-ring io_uring driver handling UDP + TUN.
226pub struct UringDriver {
227    ring: IoUring,
228    udp_fd: RawFd,
229    tun_fd: RawFd,
230    recv_pool: Box<[[u8; MAX_WIRE_DATAGRAM]; RING_BUFS]>,
231    /// Fixed pool of outstanding single-shot UDP `recvmsg` requests (see
232    /// [`UdpRecvSlot`]); indexed by `TAG_UDP_RECV`'s payload bits.
233    udp_recv_slots: Box<[UdpRecvSlot]>,
234    in_flight: Vec<Option<Vec<u8>>>,
235    gso_meta: Vec<Option<GsoMeta>>,
236    gso_ctx: Vec<Option<GsoSendContext>>,
237    send_kind: Vec<Option<SendKind>>,
238    /// Reusable send-buffer pool: released in-flight buffers are recycled here
239    /// instead of freed, so the hot send path does no per-packet allocation.
240    free_bufs: Vec<Vec<u8>>,
241    /// Reusable scratch for a received datagram, so recv dispatch does no
242    /// per-packet allocation (poll.rs dispatches from a reused stack buffer).
243    recv_scratch: Vec<u8>,
244    /// Reusable completion-drain buffer, so `poll_once` does no per-iteration
245    /// allocation.
246    cqe_buf: Vec<(u64, i32, u32)>,
247    /// Busy-poll the completion queue before blocking (opt-in via
248    /// `YIP_URING_BUSYPOLL=1`) — lower RTT at the cost of CPU while active.
249    busy_poll: bool,
250    /// Adaptive-spin state: true while an exchange is active (recent completions),
251    /// false once a wait times out. Gates whether `busy_poll` actually spins, so
252    /// an idle tunnel does not burn CPU.
253    active: bool,
254    /// Egress datagrams staged this `poll_once`, tagged with their FEC fate
255    /// group. Flushed by `flush_pending_gso` before `poll_once` returns, so no
256    /// cross-call buffering / added latency.
257    pending_gso: Vec<EgressDatagram>,
258    /// Whether the kernel supports `EXT_ARG` (5.11+), i.e. a timed blocking wait.
259    /// When true, the idle wait is bounded so `tick` fires on cadence.
260    ext_arg: bool,
261    gso_enabled: bool,
262    started: Instant,
263    /// Count of sends dropped because the in-flight slot table (bounded by
264    /// `SEND_SLOTS`) was momentarily exhausted. Surfaced in the drop logs and
265    /// via a test-only accessor so aggregate drop pressure is observable.
266    dropped_sends: u64,
267    #[cfg(test)]
268    gso_submission_count: usize,
269    #[cfg(test)]
270    force_gso_submit_failure: bool,
271}
272
273impl UringDriver {
274    /// Build and arm a ring over the provided UDP and TUN file descriptors.
275    pub fn new(udp_fd: RawFd, tun_fd: RawFd) -> io::Result<Self> {
276        let ring = IoUring::new(RING_ENTRIES)?;
277        let ext_arg = ring.params().is_feature_ext_arg();
278        let recv_pool = Box::new([[0_u8; MAX_WIRE_DATAGRAM]; RING_BUFS]);
279
280        // Build the UDP recv slot pool, then fix up each slot's self-referencing
281        // iovec/msghdr pointers now that every slot has reached its final,
282        // never-to-move-again address inside the boxed slice.
283        let mut udp_recv_slots: Box<[UdpRecvSlot]> = (0..UDP_RECV_DEPTH)
284            .map(|_| UdpRecvSlot::zeroed())
285            .collect::<Vec<_>>()
286            .into_boxed_slice();
287        let namelen = libc::socklen_t::try_from(std::mem::size_of::<libc::sockaddr_storage>())
288            .expect("size_of::<sockaddr_storage>() fits socklen_t");
289        for slot in udp_recv_slots.iter_mut() {
290            slot.iov = libc::iovec {
291                iov_base: slot.buf.as_mut_ptr().cast::<libc::c_void>(),
292                iov_len: slot.buf.len(),
293            };
294            slot.msg = crate::empty_msghdr();
295            slot.msg.msg_name = std::ptr::addr_of_mut!(slot.name).cast::<libc::c_void>();
296            slot.msg.msg_namelen = namelen;
297            slot.msg.msg_iov = std::ptr::addr_of_mut!(slot.iov);
298            slot.msg.msg_iovlen = 1;
299        }
300
301        let mut in_flight = Vec::with_capacity(SEND_SLOTS);
302        let mut gso_meta = Vec::with_capacity(SEND_SLOTS);
303        let mut gso_ctx = Vec::with_capacity(SEND_SLOTS);
304        let mut send_kind = Vec::with_capacity(SEND_SLOTS);
305        for _ in 0..SEND_SLOTS {
306            in_flight.push(None);
307            gso_meta.push(None);
308            gso_ctx.push(None);
309            send_kind.push(None);
310        }
311
312        let mut driver = Self {
313            ring,
314            udp_fd,
315            tun_fd,
316            recv_pool,
317            udp_recv_slots,
318            in_flight,
319            gso_meta,
320            gso_ctx,
321            send_kind,
322            free_bufs: Vec::with_capacity(SEND_SLOTS),
323            recv_scratch: Vec::with_capacity(MAX_WIRE_DATAGRAM),
324            cqe_buf: Vec::with_capacity(
325                usize::try_from(RING_ENTRIES).expect("RING_ENTRIES fits usize"),
326            ),
327            busy_poll: std::env::var_os("YIP_URING_BUSYPOLL").is_some(),
328            active: false,
329            pending_gso: Vec::with_capacity(MAX_PENDING_GSO_DATAGRAMS),
330            ext_arg,
331            gso_enabled: true,
332            started: Instant::now(),
333            dropped_sends: 0,
334            #[cfg(test)]
335            gso_submission_count: 0,
336            #[cfg(test)]
337            force_gso_submit_failure: false,
338        };
339
340        driver.provide_all_buffers()?;
341        for i in 0..UDP_RECV_DEPTH {
342            driver.arm_udp_recv_slot(i)?;
343        }
344        for _ in 0..TUN_READ_DEPTH {
345            driver.arm_tun_read()?;
346        }
347        driver.ring.submit()?;
348        Ok(driver)
349    }
350
351    fn push_entry(&mut self, entry: io_uring::squeue::Entry) -> io::Result<()> {
352        // SAFETY: the SQE points to memory owned by `self` (recv pool or send slot
353        // buffers) whose lifetime outlives kernel use until matching CQE processing.
354        let first_try = unsafe { self.ring.submission().push(&entry) };
355        if first_try.is_ok() {
356            return Ok(());
357        }
358
359        self.ring.submit()?;
360        // SAFETY: same as above; we retried after flushing SQ entries.
361        unsafe {
362            self.ring
363                .submission()
364                .push(&entry)
365                .map_err(|_| io::Error::other("uring submission queue full"))?;
366        }
367        Ok(())
368    }
369
370    fn provide_all_buffers(&mut self) -> io::Result<()> {
371        let base_ptr = self.recv_pool.as_mut_ptr().cast::<u8>();
372        let entry = opcode::ProvideBuffers::new(
373            base_ptr,
374            MAX_WIRE_DATAGRAM_I32,
375            RING_BUFS_U16,
376            BUF_GROUP,
377            0,
378        )
379        .build()
380        .user_data(TAG_REPROVIDE);
381        self.push_entry(entry)?;
382        self.submit_and_wait_1()?;
383        let cqe = self
384            .ring
385            .completion()
386            .next()
387            .ok_or_else(|| io::Error::other("missing provide-buffer completion"))?;
388        if cqe.result() < 0 {
389            return Err(io::Error::from_raw_os_error(-cqe.result()));
390        }
391        Ok(())
392    }
393
394    fn reprovide_buffer(&mut self, bid: u16) -> io::Result<()> {
395        let idx = usize::from(bid);
396        if idx >= RING_BUFS {
397            return Err(io::Error::other("provided buffer id out of range"));
398        }
399        let ptr = self.recv_pool[idx].as_mut_ptr();
400        let entry = opcode::ProvideBuffers::new(ptr, MAX_WIRE_DATAGRAM_I32, 1, BUF_GROUP, bid)
401            .build()
402            .user_data(TAG_REPROVIDE | u64::from(bid));
403        self.push_entry(entry)
404    }
405
406    /// (Re-)arm one single-shot UDP `recvmsg` on slot `idx`. Unlike the old
407    /// multishot `RecvMulti`, this surfaces the datagram's source address (via
408    /// the slot's own `sockaddr_storage`) — the whole point of the addressed
409    /// socket seam — at the cost of one submission per completion instead of
410    /// one submission serving an unbounded burst. Acceptable: io_uring is
411    /// opt-in and correctness (recovering `src`) comes first (see #33).
412    fn arm_udp_recv_slot(&mut self, idx: usize) -> io::Result<()> {
413        // Restore `msg_namelen` to the full `sockaddr_storage` capacity before
414        // re-submitting: `recvmsg` writes back the *actual* source address size
415        // on completion (16 for a v4 sender, 28 for v6), so without this reset
416        // a slot that last received a v4 datagram would offer only 16 bytes of
417        // name capacity to the next `recvmsg`, truncating a v6 source address
418        // to a wrong value on a dual-stack underlay.
419        let namelen = libc::socklen_t::try_from(std::mem::size_of::<libc::sockaddr_storage>())
420            .expect("size_of::<sockaddr_storage>() fits socklen_t");
421        self.udp_recv_slots[idx].msg.msg_namelen = namelen;
422        let msg_ptr = std::ptr::addr_of_mut!(self.udp_recv_slots[idx].msg);
423        let tag = TAG_UDP_RECV | u64::try_from(idx).expect("udp recv slot index fits u64");
424        let entry = opcode::RecvMsg::new(types::Fd(self.udp_fd), msg_ptr)
425            .build()
426            .user_data(tag);
427        self.push_entry(entry)
428    }
429
430    fn arm_tun_read(&mut self) -> io::Result<()> {
431        // We intentionally use single-shot read + re-submit because multishot
432        // read behavior can vary by fd type and kernel support.
433        let entry = opcode::Read::new(
434            types::Fd(self.tun_fd),
435            std::ptr::null_mut(),
436            MAX_WIRE_DATAGRAM_U32,
437        )
438        .buf_group(BUF_GROUP)
439        .build()
440        .flags(SQE_FLAGS_BUFFER_SELECT)
441        .user_data(TAG_TUN_RECV);
442        self.push_entry(entry)?;
443        Ok(())
444    }
445
446    fn now_ms(&self) -> u64 {
447        u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX)
448    }
449
450    #[cfg(test)]
451    fn gso_submission_count(&self) -> usize {
452        self.gso_submission_count
453    }
454
455    #[cfg(test)]
456    fn in_flight_used_count(&self) -> usize {
457        self.in_flight.iter().filter(|slot| slot.is_some()).count()
458    }
459
460    #[cfg(test)]
461    fn dropped_sends(&self) -> u64 {
462        self.dropped_sends
463    }
464
465    #[cfg(test)]
466    fn set_force_gso_submit_failure(&mut self, force: bool) {
467        self.force_gso_submit_failure = force;
468    }
469
470    /// Park a send buffer in a free in-flight slot, or recycle it and fail if
471    /// the table is full.
472    fn store_in_flight(&mut self, buf: Vec<u8>) -> io::Result<usize> {
473        let Some((idx, slot)) = self
474            .in_flight
475            .iter_mut()
476            .enumerate()
477            .find(|(_, slot)| slot.is_none())
478        else {
479            self.recycle_buf(buf);
480            return Err(io::Error::new(
481                io::ErrorKind::WouldBlock,
482                "no free in-flight send slots available",
483            ));
484        };
485        *slot = Some(buf);
486        Ok(idx)
487    }
488
489    /// Take an owned buffer as an in-flight payload (used by the GSO coalescer,
490    /// which builds its buffer directly).
491    fn alloc_in_flight_slot(
492        &mut self,
493        payload: Vec<u8>,
494        payload_limit: usize,
495    ) -> io::Result<usize> {
496        if payload.len() > payload_limit {
497            self.recycle_buf(payload);
498            return Err(io::Error::other("payload exceeds in-flight slot limit"));
499        }
500        self.store_in_flight(payload)
501    }
502
503    /// Copy `data` into a recycled send buffer and park it in an in-flight slot.
504    /// No allocation once the pool is warm.
505    fn alloc_in_flight_slot_copy(
506        &mut self,
507        data: &[u8],
508        payload_limit: usize,
509    ) -> io::Result<usize> {
510        if data.len() > payload_limit {
511            return Err(io::Error::other("payload exceeds in-flight slot limit"));
512        }
513        let mut buf = self.free_bufs.pop().unwrap_or_default();
514        buf.clear();
515        buf.extend_from_slice(data);
516        self.store_in_flight(buf)
517    }
518
519    /// Return a spent send buffer to the pool for reuse (bounded so the pool
520    /// never grows past the slot count).
521    fn recycle_buf(&mut self, buf: Vec<u8>) {
522        if self.free_bufs.len() < SEND_SLOTS {
523            self.free_bufs.push(buf);
524        }
525    }
526
527    /// Send one addressed datagram via `sendmsg` (the socket is unconnected —
528    /// the addressed seam — so every UDP send needs an explicit destination).
529    fn queue_udp_send(&mut self, dg: &EgressDatagram) -> io::Result<()> {
530        let slot_id = self.alloc_in_flight_slot_copy(&dg.bytes, MAX_WIRE_DATAGRAM)?;
531        self.send_kind[slot_id] = Some(SendKind::Udp);
532        let (payload_ptr, payload_len) = {
533            let slot_buf = self.in_flight[slot_id]
534                .as_mut()
535                .ok_or_else(|| io::Error::other("missing in-flight buffer for udp send"))?;
536            (slot_buf.as_mut_ptr(), slot_buf.len())
537        };
538        let ctx = self.gso_ctx[slot_id].get_or_insert_with(GsoSendContext::new);
539        ctx.prepare_plain(payload_ptr, payload_len, dg.dst);
540        let tag = TAG_SEND_SLOT | u64::try_from(slot_id).expect("slot id fits u64");
541        let msg_ptr = std::ptr::from_ref(&ctx.msg);
542        let entry = opcode::SendMsg::new(types::Fd(self.udp_fd), msg_ptr)
543            .flags(u32::try_from(libc::MSG_NOSIGNAL).expect("MSG_NOSIGNAL fits u32"))
544            .build()
545            .user_data(tag);
546        if let Err(e) = self.push_entry(entry) {
547            self.release_in_flight_slot(slot_id);
548            return Err(e);
549        }
550        Ok(())
551    }
552
553    fn queue_tun_write(&mut self, frame: &[u8]) -> io::Result<()> {
554        let slot_id = self.alloc_in_flight_slot_copy(frame, MAX_WIRE_DATAGRAM)?;
555        self.send_kind[slot_id] = Some(SendKind::Tun);
556        let (ptr, len_u32) = {
557            let slot_buf = self.in_flight[slot_id]
558                .as_ref()
559                .ok_or_else(|| io::Error::other("missing in-flight buffer for tun write"))?;
560            let len_u32 = u32::try_from(slot_buf.len())
561                .map_err(|_| io::Error::other("tun write buffer too large"))?;
562            (slot_buf.as_ptr(), len_u32)
563        };
564        let tag = TAG_SEND_SLOT | u64::try_from(slot_id).expect("slot id fits u64");
565        let entry = opcode::Write::new(types::Fd(self.tun_fd), ptr, len_u32)
566            .build()
567            .user_data(tag);
568        if let Err(e) = self.push_entry(entry) {
569            self.release_in_flight_slot(slot_id);
570            return Err(e);
571        }
572        Ok(())
573    }
574
575    fn release_in_flight_slot(&mut self, slot_id: usize) {
576        if slot_id >= self.in_flight.len() {
577            return;
578        }
579        if let Some(buf) = self.in_flight[slot_id].take() {
580            self.recycle_buf(buf);
581        }
582        self.gso_meta[slot_id] = None;
583        self.gso_ctx[slot_id] = None;
584        self.send_kind[slot_id] = None;
585    }
586
587    /// Rejects any batch that contains two datagrams of the same FEC fate
588    /// group — the invariant that keeps a source symbol and its own repair
589    /// out of the same (fate-shared) GSO skb — *or* mixed destinations, since
590    /// a coalesced `UDP_SEGMENT` send has exactly one `msg_name` and would
591    /// silently misdirect every datagram after the first to the wrong peer.
592    /// This is the single correctness choke point for GSO+FEC(+addressing)
593    /// safety.
594    fn can_coalesce_gso_tagged(datagrams: &[EgressDatagram]) -> Option<u16> {
595        crate::gso::can_coalesce(datagrams)
596    }
597
598    /// GSO-send a batch of fate-tagged, addressed datagrams. Only coalesces
599    /// when `can_coalesce_gso_tagged` proves every datagram is the same
600    /// length, a distinct fate group, *and* shares one destination;
601    /// otherwise (or on any GSO submit failure) falls back to per-datagram
602    /// sends.
603    fn queue_udp_batch_tagged(
604        &mut self,
605        datagrams: &[EgressDatagram],
606        allow_gso: bool,
607    ) -> io::Result<()> {
608        if datagrams.is_empty() {
609            return Ok(());
610        }
611        if allow_gso && self.gso_enabled {
612            if let Some(segment_size) = Self::can_coalesce_gso_tagged(datagrams) {
613                let max_chunk = Self::max_gso_datagrams_for_segment(segment_size);
614                for chunk in datagrams.chunks(max_chunk) {
615                    // Every datagram in `datagrams` shares one `dst` (proven by
616                    // `can_coalesce_gso_tagged` above), so any chunk's dst is that
617                    // same shared destination.
618                    let dst = chunk[0].dst;
619                    if self.queue_udp_gso(chunk, segment_size, dst)? {
620                        continue;
621                    }
622                    eprintln!("uring: GSO submit failed, trying per-datagram sends");
623                    for dg in chunk {
624                        self.queue_udp_send(dg)?;
625                    }
626                }
627                return Ok(());
628            }
629        }
630        for dg in datagrams {
631            self.queue_udp_send(dg)?;
632        }
633        Ok(())
634    }
635
636    /// Flush all datagrams staged this `poll_once` in fate-safe GSO batches.
637    /// Each pass takes at most one datagram per distinct `(fate, dst)` pair
638    /// (arrival order) — so a coalesced skb never carries two symbols of one
639    /// FEC object *and* never mixes destinations — and defers the rest to the
640    /// next pass. Bounded by `MAX_PENDING_GSO_DATAGRAMS`.
641    ///
642    /// Grouping by fate alone (pre-multipeer) let a batch mix `dst`s whenever
643    /// two different peers happened to emit distinct-fate datagrams in the
644    /// same `poll_once`; `queue_udp_batch_tagged`'s `can_coalesce_gso_tagged`
645    /// check would then reject the *whole* mixed chunk and fall back to
646    /// per-datagram sends, silently losing the GSO win for same-peer pairs
647    /// that were incidentally batched with a different peer's datagram.
648    /// Grouping by `(fate, dst)` keeps distinct peers in separate chunks so
649    /// same-peer datagrams still coalesce.
650    fn flush_pending_gso(&mut self) {
651        while !self.pending_gso.is_empty() {
652            let mut chunk: Vec<EgressDatagram> = Vec::with_capacity(self.pending_gso.len());
653            let mut deferred: Vec<EgressDatagram> = Vec::with_capacity(self.pending_gso.len());
654            for dg in self.pending_gso.drain(..) {
655                let dst_conflict = chunk
656                    .first()
657                    .is_some_and(|c: &EgressDatagram| c.dst != dg.dst);
658                let fate_conflict = chunk.iter().any(|c| c.fate == dg.fate);
659                if dst_conflict || fate_conflict {
660                    deferred.push(dg);
661                } else {
662                    chunk.push(dg);
663                }
664            }
665            self.pending_gso = deferred;
666            if let Err(e) = self.queue_udp_batch_tagged(&chunk, true) {
667                self.dropped_sends += 1;
668                eprintln!(
669                    "uring: drop udp send batch from tun: {e} (dropped_sends={})",
670                    self.dropped_sends
671                );
672            }
673        }
674    }
675
676    fn max_gso_datagrams_for_segment(segment_size: u16) -> usize {
677        crate::gso::max_gso_run_len(segment_size, MAX_GSO_DATAGRAMS)
678    }
679
680    fn queue_udp_gso<T: AsRef<[u8]>>(
681        &mut self,
682        datagrams: &[T],
683        segment_size: u16,
684        dst: SocketAddr,
685    ) -> io::Result<bool> {
686        if datagrams.len() > MAX_GSO_DATAGRAMS {
687            return Ok(false);
688        }
689        let payload_len = usize::from(segment_size);
690        let total_len = payload_len
691            .checked_mul(datagrams.len())
692            .ok_or_else(|| io::Error::other("GSO payload size overflow"))?;
693        if total_len > MAX_GSO_PAYLOAD {
694            return Ok(false);
695        }
696        let mut coalesced = self.free_bufs.pop().unwrap_or_default();
697        coalesced.clear();
698        coalesced.reserve(total_len);
699        for datagram in datagrams {
700            coalesced.extend_from_slice(datagram.as_ref());
701        }
702        let slot_id = match self.alloc_in_flight_slot(coalesced, MAX_GSO_PAYLOAD) {
703            Ok(slot_id) => slot_id,
704            Err(_) => return Ok(false),
705        };
706        self.send_kind[slot_id] = Some(SendKind::Udp);
707        #[cfg(test)]
708        if self.force_gso_submit_failure {
709            self.release_in_flight_slot(slot_id);
710            return Ok(false);
711        }
712        let payload_ptr = {
713            let payload = self.in_flight[slot_id]
714                .as_mut()
715                .ok_or_else(|| io::Error::other("missing in-flight buffer for GSO send"))?;
716            payload.as_mut_ptr()
717        };
718        let payload_len_now = self.in_flight[slot_id]
719            .as_ref()
720            .ok_or_else(|| io::Error::other("missing in-flight GSO payload"))?
721            .len();
722        let ctx = self.gso_ctx[slot_id].get_or_insert_with(GsoSendContext::new);
723        if let Err(e) = ctx.prepare_gso(payload_ptr, payload_len_now, segment_size, dst) {
724            self.release_in_flight_slot(slot_id);
725            return Err(e);
726        }
727        self.gso_meta[slot_id] = Some(GsoMeta {
728            segment_size,
729            datagram_count: datagrams.len(),
730            dst,
731        });
732        let tag = TAG_SEND_SLOT | u64::try_from(slot_id).expect("slot id fits u64");
733        let msg_ptr = std::ptr::from_ref(&ctx.msg);
734        let entry = opcode::SendMsg::new(types::Fd(self.udp_fd), msg_ptr)
735            .flags(u32::try_from(libc::MSG_NOSIGNAL).expect("MSG_NOSIGNAL fits u32"))
736            .build()
737            .user_data(tag);
738        if self.push_entry(entry).is_err() {
739            self.release_in_flight_slot(slot_id);
740            return Ok(false);
741        }
742        #[cfg(test)]
743        {
744            self.gso_submission_count = self.gso_submission_count.saturating_add(1);
745        }
746        Ok(true)
747    }
748
749    /// Recover a failed GSO send's datagrams for per-datagram retry. `fate` is
750    /// not preserved (set to `0`) — these are always retried via
751    /// `queue_udp_send`, which ignores `fate` (it's only consulted by the GSO
752    /// coalescing decision, which this path has already abandoned); `dst` is
753    /// `meta.dst`, the one destination `can_coalesce_gso_tagged` guaranteed
754    /// every datagram in this send shared.
755    fn recover_gso_fallback_datagrams(&self, slot_id: usize) -> Vec<EgressDatagram> {
756        let Some(meta) = self.gso_meta.get(slot_id).and_then(|meta| *meta) else {
757            return Vec::new();
758        };
759        let Some(payload) = self.in_flight.get(slot_id).and_then(|slot| slot.as_ref()) else {
760            return Vec::new();
761        };
762        let segment_len = usize::from(meta.segment_size);
763        let mut datagrams = Vec::with_capacity(meta.datagram_count);
764        for i in 0..meta.datagram_count {
765            let start = i
766                .checked_mul(segment_len)
767                .expect("segment index multiplication should not overflow");
768            let end = start
769                .checked_add(segment_len)
770                .expect("segment end computation should not overflow");
771            if end > payload.len() {
772                break;
773            }
774            datagrams.push(EgressDatagram {
775                fate: 0,
776                dst: meta.dst,
777                bytes: payload[start..end].to_vec(),
778            });
779        }
780        datagrams
781    }
782
783    /// Same as [`Self::recover_gso_fallback_datagrams`] but for a *partial*
784    /// send completion: only the datagrams past `bytes_sent` are recovered.
785    fn recover_gso_unsent_datagrams(
786        &self,
787        slot_id: usize,
788        bytes_sent: usize,
789    ) -> Vec<EgressDatagram> {
790        let Some(meta) = self.gso_meta.get(slot_id).and_then(|meta| *meta) else {
791            return Vec::new();
792        };
793        let Some(payload) = self.in_flight.get(slot_id).and_then(|slot| slot.as_ref()) else {
794            return Vec::new();
795        };
796        let segment_len = usize::from(meta.segment_size);
797        if segment_len == 0 || bytes_sent >= payload.len() {
798            return Vec::new();
799        }
800        let sent_segments = bytes_sent / segment_len;
801        if sent_segments >= meta.datagram_count {
802            return Vec::new();
803        }
804
805        let mut datagrams = Vec::with_capacity(meta.datagram_count - sent_segments);
806        for i in sent_segments..meta.datagram_count {
807            let start = i
808                .checked_mul(segment_len)
809                .expect("segment index multiplication should not overflow");
810            let end = start
811                .checked_add(segment_len)
812                .expect("segment end computation should not overflow");
813            if end > payload.len() {
814                break;
815            }
816            datagrams.push(EgressDatagram {
817                fate: 0,
818                dst: meta.dst,
819                bytes: payload[start..end].to_vec(),
820            });
821        }
822        datagrams
823    }
824
825    fn is_gso_unsupported_errno(errno: i32) -> bool {
826        errno == libc::EIO
827            || errno == libc::EINVAL
828            || errno == libc::ENOPROTOOPT
829            || errno == libc::EOPNOTSUPP
830            || errno == libc::ENOTSUP
831    }
832
833    /// Transient UDP-send errno: the socket send buffer is momentarily full, so
834    /// drop this datagram rather than tear down the tunnel (parity with
835    /// `poll.rs::send_to_udp`). `EWOULDBLOCK == EAGAIN` on Linux; both listed.
836    fn is_transient_send_errno(errno: i32) -> bool {
837        errno == libc::EAGAIN || errno == libc::EWOULDBLOCK || errno == libc::ENOBUFS
838    }
839
840    /// Wait for at least one completion, retrying on `EINTR`.
841    ///
842    /// `IoUring::submit_and_wait` wraps the blocking `io_uring_enter` syscall,
843    /// which returns `EINTR` if a signal is delivered while it waits. The
844    /// `io-uring` crate does not retry internally, so without this a signal
845    /// would propagate out of `poll_once` and terminate the whole tunnel —
846    /// parity with `poll.rs`, whose `epoll_wait` loop `continue`s on `EINTR`.
847    fn submit_and_wait_1(&self) -> io::Result<()> {
848        loop {
849            match self.ring.submit_and_wait(1) {
850                Ok(_) => return Ok(()),
851                Err(e) if e.raw_os_error() == Some(libc::EINTR) => continue,
852                Err(e) => return Err(e),
853            }
854        }
855    }
856
857    /// Block for at least one completion, but wake after `TICK_TIMEOUT_NS` so the
858    /// caller can run `tick` on cadence even with no traffic (parity with
859    /// poll.rs's `epoll_wait` timeout — fixes `tick` starving on an idle tunnel).
860    /// Returns `true` if the wait timed out with no completion (caller should
861    /// stop draining and let `tick` fire), `false` if a completion likely
862    /// arrived. Falls back to an unbounded wait on kernels without `EXT_ARG`.
863    fn wait_for_completions(&self) -> io::Result<bool> {
864        if !self.ext_arg {
865            self.submit_and_wait_1()?;
866            return Ok(false);
867        }
868        let ts = types::Timespec::new().nsec(TICK_TIMEOUT_NS);
869        let args = types::SubmitArgs::new().timespec(&ts);
870        loop {
871            match self.ring.submitter().submit_with_args(1, &args) {
872                Ok(_) => return Ok(false),
873                Err(e) if e.raw_os_error() == Some(libc::ETIME) => return Ok(true),
874                Err(e) if e.raw_os_error() == Some(libc::EINTR) => continue,
875                Err(e) => return Err(e),
876            }
877        }
878    }
879
880    fn handle_dispatch_udp(
881        &mut self,
882        d: &mut impl Dispatch,
883        src: SocketAddr,
884        datagram: &[u8],
885        now_ms: u64,
886    ) {
887        match d.on_udp(src, datagram, now_ms) {
888            DispatchOut::None => {}
889            DispatchOut::Tun(inner) => {
890                if let Err(e) = self.queue_tun_write(inner) {
891                    self.dropped_sends += 1;
892                    eprintln!(
893                        "uring: drop tun write: {e} (dropped_sends={})",
894                        self.dropped_sends
895                    );
896                }
897            }
898            DispatchOut::Udp(pkts) => {
899                // `allow_gso=false`: control/ARQ-retransmit traffic is never
900                // GSO-coalesced, matching pre-addressing behavior exactly.
901                if let Err(e) = self.queue_udp_batch_tagged(pkts, false) {
902                    self.dropped_sends += 1;
903                    eprintln!(
904                        "uring: drop udp send batch: {e} (dropped_sends={})",
905                        self.dropped_sends
906                    );
907                }
908            }
909            DispatchOut::Both(inner, pkts) => {
910                if let Err(e) = self.queue_tun_write(inner) {
911                    self.dropped_sends += 1;
912                    eprintln!(
913                        "uring: drop tun write: {e} (dropped_sends={})",
914                        self.dropped_sends
915                    );
916                }
917                if let Err(e) = self.queue_udp_batch_tagged(pkts, false) {
918                    self.dropped_sends += 1;
919                    eprintln!(
920                        "uring: drop udp send batch: {e} (dropped_sends={})",
921                        self.dropped_sends
922                    );
923                }
924            }
925        }
926    }
927
928    fn handle_dispatch_tun(&mut self, d: &mut impl Dispatch, frame: &[u8], now_ms: u64) {
929        // Stage this object's symbols; GSO batching across objects happens in
930        // `flush_pending_gso` at the end of `poll_once`. Flush early if the
931        // staging buffer fills mid-drain.
932        self.pending_gso.extend_from_slice(d.on_tun(frame, now_ms));
933        if self.pending_gso.len() >= MAX_PENDING_GSO_DATAGRAMS {
934            self.flush_pending_gso();
935        }
936    }
937
938    /// Process at least one CQE and dispatch resulting I/O.
939    pub fn poll_once<D: Dispatch>(&mut self, d: &mut D) -> io::Result<()> {
940        // Flush SQEs queued by the previous iteration's processing (re-armed
941        // recvs, sends). Then reap completions, busy-polling the completion
942        // queue for a bounded window before falling back to a blocking wait.
943        self.ring.submit()?;
944        // Drain into a reused buffer (no per-iteration allocation). Taken out of
945        // `self` so the drain borrow of `self.ring` does not conflict with the
946        // mutable `self` access while processing each CQE.
947        let mut cqes = std::mem::take(&mut self.cqe_buf);
948        cqes.clear();
949        // Adaptive busy-poll: spin only while an exchange is active, so we catch
950        // imminent completions at low latency but never burn CPU on an idle
951        // tunnel. `self.active` carries "did the previous wait get real work"
952        // across calls; it is set true when completions arrive and false when a
953        // wait times out. With busy-poll off (default) the budget is always 0, so
954        // the spin never runs and we block immediately — the prior behavior.
955        let budget = if self.busy_poll && self.active {
956            CQ_SPIN_BUDGET
957        } else {
958            0
959        };
960        let mut spins = 0_u32;
961        loop {
962            for cqe in &mut self.ring.completion() {
963                cqes.push((cqe.user_data(), cqe.result(), cqe.flags()));
964            }
965            if !cqes.is_empty() {
966                // Got work — stay hot so the next wait spins for the imminent
967                // follow-up (e.g. the reply completion of this round trip).
968                self.active = true;
969                break;
970            }
971            if spins >= budget {
972                // Nothing ready (spin budget exhausted, or busy-poll off/idle) —
973                // block until a completion arrives or the tick timeout elapses.
974                // On timeout, go cold (stop spinning next time) and fall through
975                // so `tick` fires on cadence even with no traffic.
976                if self.wait_for_completions()? {
977                    self.active = false;
978                    break;
979                }
980            } else {
981                spins += 1;
982                std::hint::spin_loop();
983            }
984        }
985
986        let now_ms = self.now_ms();
987        for &(user_data, result, flags) in &cqes {
988            let kind = user_data & TAG_KIND_MASK;
989            if kind == TAG_SEND_SLOT {
990                let slot_id_u64 = user_data & TAG_PAYLOAD_MASK;
991                let slot_id =
992                    usize::try_from(slot_id_u64).expect("send slot id user_data fits usize");
993                if result < 0 {
994                    let errno = -result;
995                    if self.gso_meta.get(slot_id).and_then(|meta| *meta).is_some() {
996                        let fallback_datagrams = self.recover_gso_fallback_datagrams(slot_id);
997                        self.release_in_flight_slot(slot_id);
998                        if Self::is_gso_unsupported_errno(errno) {
999                            self.gso_enabled = false;
1000                            eprintln!(
1001                                "uring: UDP_SEGMENT unsupported ({errno}); disabling GSO and retrying per datagram"
1002                            );
1003                        } else {
1004                            eprintln!(
1005                                "uring: GSO send completion error ({errno}); retrying per datagram"
1006                            );
1007                        }
1008                        for datagram in &fallback_datagrams {
1009                            if let Err(e) = self.queue_udp_send(datagram) {
1010                                eprintln!("uring: drop GSO fallback datagram: {e}");
1011                            }
1012                        }
1013                        continue;
1014                    }
1015                    // Non-GSO send/write completion error. Mirror the
1016                    // PollDriver contract (poll.rs `send_to_tun`/`send_to_udp`):
1017                    // a failed TUN write is always dropped (a lost inner frame
1018                    // beats tearing down the tunnel), and a failed UDP send is
1019                    // dropped only on transient buffer pressure — a genuinely
1020                    // fatal error propagates so a supervisor can restart, rather
1021                    // than the tunnel going silently dark forever.
1022                    let kind = self.send_kind.get(slot_id).and_then(|k| *k);
1023                    let err = io::Error::from_raw_os_error(errno);
1024                    self.release_in_flight_slot(slot_id);
1025                    match kind {
1026                        Some(SendKind::Tun) => {
1027                            eprintln!("uring: tun write error on slot {slot_id} (dropped): {err}");
1028                            continue;
1029                        }
1030                        _ => {
1031                            if Self::is_transient_send_errno(errno) {
1032                                eprintln!("uring: udp send dropped on slot {slot_id}: {err}");
1033                                continue;
1034                            }
1035                            return Err(err);
1036                        }
1037                    }
1038                } else if let Some(meta) = self.gso_meta.get(slot_id).and_then(|entry| *entry) {
1039                    let bytes_sent =
1040                        usize::try_from(result).expect("non-negative send result fits usize");
1041                    let expected_len = usize::from(meta.segment_size)
1042                        .checked_mul(meta.datagram_count)
1043                        .expect("GSO expected payload length should not overflow");
1044                    if bytes_sent < expected_len {
1045                        let unsent = self.recover_gso_unsent_datagrams(slot_id, bytes_sent);
1046                        self.release_in_flight_slot(slot_id);
1047                        eprintln!(
1048                            "uring: partial GSO completion ({bytes_sent}/{expected_len}); retrying {} datagrams",
1049                            unsent.len()
1050                        );
1051                        for datagram in &unsent {
1052                            if let Err(e) = self.queue_udp_send(datagram) {
1053                                eprintln!("uring: drop partial GSO retry datagram: {e}");
1054                            }
1055                        }
1056                        continue;
1057                    }
1058                }
1059                self.release_in_flight_slot(slot_id);
1060                continue;
1061            }
1062
1063            if kind == TAG_REPROVIDE {
1064                if result < 0 {
1065                    eprintln!(
1066                        "uring: provide-buffer completion error: {}",
1067                        io::Error::from_raw_os_error(-result)
1068                    );
1069                }
1070                continue;
1071            }
1072
1073            if kind == TAG_UDP_RECV {
1074                let slot_idx_u64 = user_data & TAG_PAYLOAD_MASK;
1075                let slot_idx =
1076                    usize::try_from(slot_idx_u64).expect("udp recv slot index fits usize");
1077                if slot_idx >= self.udp_recv_slots.len() {
1078                    return Err(io::Error::other(
1079                        "kernel returned udp recv slot out of range",
1080                    ));
1081                }
1082                if result < 0 {
1083                    let errno = -result;
1084                    // Transient: re-arm the same slot and keep going. Anything
1085                    // else is fatal — a permanently failing recv would otherwise
1086                    // silently blind the tunnel rather than propagating so a
1087                    // supervisor can restart (mirrors the TAG_TUN_RECV contract
1088                    // below and `poll.rs`'s `drain_udp`).
1089                    if errno == libc::EAGAIN || errno == libc::EWOULDBLOCK || errno == libc::ENOBUFS
1090                    {
1091                        self.arm_udp_recv_slot(slot_idx)?;
1092                        continue;
1093                    }
1094                    return Err(io::Error::other(format!(
1095                        "udp recv completion error: {}",
1096                        io::Error::from_raw_os_error(errno)
1097                    )));
1098                }
1099                let n = usize::try_from(result).expect("non-negative CQE result fits usize");
1100                if n > MAX_WIRE_DATAGRAM {
1101                    self.arm_udp_recv_slot(slot_idx)?;
1102                    return Err(io::Error::other("kernel returned oversized datagram"));
1103                }
1104                // Recover the sender's address from this slot's own
1105                // `sockaddr_storage`/`msg_namelen` — the whole point of moving
1106                // off multishot `RecvMulti` (see the module doc).
1107                let namelen = self.udp_recv_slots[slot_idx].msg.msg_namelen;
1108                let src = match sockaddr_to_std(&self.udp_recv_slots[slot_idx].name, namelen) {
1109                    Ok(addr) => addr,
1110                    Err(e) => {
1111                        eprintln!(
1112                            "uring: dropping udp datagram with unparseable source address: {e}"
1113                        );
1114                        self.arm_udp_recv_slot(slot_idx)?;
1115                        continue;
1116                    }
1117                };
1118                let mut scratch = std::mem::take(&mut self.recv_scratch);
1119                scratch.clear();
1120                scratch.extend_from_slice(&self.udp_recv_slots[slot_idx].buf[..n]);
1121                self.handle_dispatch_udp(d, src, &scratch, now_ms);
1122                self.recv_scratch = scratch;
1123                self.arm_udp_recv_slot(slot_idx)?;
1124                continue;
1125            }
1126
1127            // Everything from here on is TAG_TUN_RECV: provided-buffer,
1128            // single-shot, re-armed after every completion.
1129            let bid_opt = cqueue::buffer_select(flags);
1130            if result < 0 {
1131                let errno = -result;
1132                let err = io::Error::from_raw_os_error(errno);
1133                if let Some(bid) = bid_opt {
1134                    self.reprovide_buffer(bid)?;
1135                }
1136                // ENOBUFS on a recv completion means the provided-buffer ring was
1137                // momentarily exhausted (no buffer for the kernel to place this
1138                // frame) — transient, not fatal: drop the frame and re-arm;
1139                // buffers are re-provided as other completions process.
1140                // (Treating ENOBUFS as fatal tore the driver down under burst and
1141                // flaked the uring unit tests on the CI runner.)
1142                if (errno == libc::EAGAIN || errno == libc::EWOULDBLOCK || errno == libc::ENOBUFS)
1143                    && kind == TAG_TUN_RECV
1144                {
1145                    self.arm_tun_read()?;
1146                    continue;
1147                }
1148                if kind == TAG_TUN_RECV {
1149                    return Err(io::Error::other(format!(
1150                        "tun recv completion error: {err}"
1151                    )));
1152                }
1153                return Err(io::Error::other(format!(
1154                    "unexpected completion kind 0x{kind:016x} with error {err}"
1155                )));
1156            }
1157
1158            let bid = if kind == TAG_TUN_RECV {
1159                bid_opt.ok_or_else(|| {
1160                    io::Error::other(
1161                        "recv completion missing buffer_select; aborting to avoid pool-slot leak",
1162                    )
1163                })?
1164            } else {
1165                if let Some(bid) = bid_opt {
1166                    self.reprovide_buffer(bid)?;
1167                }
1168                return Err(io::Error::other(format!(
1169                    "unexpected completion kind 0x{kind:016x}"
1170                )));
1171            };
1172
1173            let idx = usize::from(bid);
1174            if idx >= RING_BUFS {
1175                return Err(io::Error::other("kernel returned buffer id out of range"));
1176            }
1177            let n = usize::try_from(result).expect("non-negative CQE result fits usize");
1178            if n > MAX_WIRE_DATAGRAM {
1179                self.reprovide_buffer(bid)?;
1180                return Err(io::Error::other("kernel returned oversized datagram"));
1181            }
1182
1183            let mut scratch = std::mem::take(&mut self.recv_scratch);
1184            scratch.clear();
1185            scratch.extend_from_slice(&self.recv_pool[idx][..n]);
1186            self.handle_dispatch_tun(d, &scratch, now_ms);
1187            self.recv_scratch = scratch;
1188            self.reprovide_buffer(bid)?;
1189            self.arm_tun_read()?;
1190        }
1191
1192        // Flush TUN-egress datagrams staged this pass in fate-safe GSO batches.
1193        self.flush_pending_gso();
1194
1195        if let Some(pkts) = d.tick(now_ms) {
1196            for pkt in pkts {
1197                if let Err(e) = self.queue_udp_send(pkt) {
1198                    eprintln!("uring: drop tick packet: {e}");
1199                }
1200            }
1201        }
1202
1203        // SQEs queued above (re-armed recvs, tick sends) are flushed by the
1204        // `submit()` at the top of the next `poll_once`, before the next wait.
1205        self.cqe_buf = cqes;
1206        Ok(())
1207    }
1208}
1209
1210/// Run the io_uring loop forever for production use.
1211///
1212/// The `tun_fd` here is always a plain device (`yipd` only opts into
1213/// `IFF_VNET_HDR`/GSO offload for the poll driver — see `bin/yipd/src/
1214/// tunnel.rs`), so every PollDriver fallback below passes `vnet_hdr = false`.
1215pub fn run_uring<D: Dispatch>(udp_fd: RawFd, tun_fd: RawFd, d: &mut D) -> io::Result<()> {
1216    // Fall back to the PollDriver on ANY UringDriver failure (init or runtime)
1217    // rather than killing the tunnel. The DataPlane state lives in `d` and the
1218    // fds are borrowed, so PollDriver takes over cleanly (it re-sets the fds
1219    // non-blocking). This makes io_uring safe to opt into even on kernels where
1220    // it is buggy or unsupported — e.g. the multishot-recv EINVAL on 6.12 (#25)
1221    // now degrades to poll instead of a fatal exit.
1222    let mut driver = match UringDriver::new(udp_fd, tun_fd) {
1223        Ok(driver) => driver,
1224        Err(e) => {
1225            eprintln!("uring: init failed ({e}); falling back to PollDriver");
1226            return crate::poll::run_poll(udp_fd, tun_fd, false, d);
1227        }
1228    };
1229    loop {
1230        if let Err(e) = driver.poll_once(d) {
1231            eprintln!("uring: fatal driver error ({e}); falling back to PollDriver");
1232            drop(driver);
1233            return crate::poll::run_poll(udp_fd, tun_fd, false, d);
1234        }
1235    }
1236}
1237
1238/// Probe whether io_uring + provided-buffer registration is available.
1239pub fn uring_available() -> bool {
1240    let mut ring = match IoUring::new(8) {
1241        Ok(r) => r,
1242        Err(_) => return false,
1243    };
1244    let mut tiny = [0_u8; 64];
1245    let entry = opcode::ProvideBuffers::new(tiny.as_mut_ptr(), 64, 1, BUF_GROUP, 0)
1246        .build()
1247        .user_data(TAG_REPROVIDE);
1248    // SAFETY: `tiny` lives until completion and the entry references only that
1249    // stack buffer for the short probe.
1250    let push_res = unsafe { ring.submission().push(&entry) };
1251    if push_res.is_err() {
1252        return false;
1253    }
1254    if ring.submit_and_wait(1).is_err() {
1255        return false;
1256    }
1257    let Some(cqe) = ring.completion().next() else {
1258        return false;
1259    };
1260    cqe.result() >= 0
1261}
1262
1263#[cfg(test)]
1264mod tests {
1265    use super::*;
1266    use std::net::UdpSocket;
1267    use std::os::fd::AsRawFd;
1268    use std::sync::Mutex;
1269    use std::time::Duration;
1270
1271    static URING_SERIAL: Mutex<()> = Mutex::new(());
1272
1273    struct EchoDispatch {
1274        scratch: Vec<EgressDatagram>,
1275    }
1276
1277    impl EchoDispatch {
1278        fn new() -> Self {
1279            Self {
1280                scratch: Vec::new(),
1281            }
1282        }
1283    }
1284
1285    impl Dispatch for EchoDispatch {
1286        fn on_udp(&mut self, src: SocketAddr, dg: &[u8], _now_ms: u64) -> DispatchOut<'_> {
1287            self.scratch = vec![EgressDatagram {
1288                fate: 0,
1289                dst: src,
1290                bytes: dg.to_vec(),
1291            }];
1292            DispatchOut::Udp(&self.scratch)
1293        }
1294
1295        fn on_tun(&mut self, _inner: &[u8], _now_ms: u64) -> &[EgressDatagram] {
1296            &[]
1297        }
1298
1299        fn tick(&mut self, _now_ms: u64) -> Option<&[EgressDatagram]> {
1300            None
1301        }
1302    }
1303
1304    /// Counts `tick` invocations; produces no I/O. Used to prove the idle wait
1305    /// still fires `tick` on cadence.
1306    struct TickCountDispatch {
1307        ticks: usize,
1308    }
1309
1310    impl Dispatch for TickCountDispatch {
1311        fn on_udp(&mut self, _src: SocketAddr, _dg: &[u8], _now_ms: u64) -> DispatchOut<'_> {
1312            DispatchOut::None
1313        }
1314
1315        fn on_tun(&mut self, _inner: &[u8], _now_ms: u64) -> &[EgressDatagram] {
1316            &[]
1317        }
1318
1319        fn tick(&mut self, _now_ms: u64) -> Option<&[EgressDatagram]> {
1320            self.ticks += 1;
1321            None
1322        }
1323    }
1324
1325    /// Returns 5 same-length datagrams all tagged with ONE fate group, i.e. the
1326    /// symbols of a single FEC object (source + its repair). A GSO driver must
1327    /// NEVER coalesce these — losing them together would defeat FEC.
1328    struct GsoDispatch {
1329        dst: SocketAddr,
1330        scratch: Vec<EgressDatagram>,
1331    }
1332
1333    impl GsoDispatch {
1334        fn new(dst: SocketAddr) -> Self {
1335            Self {
1336                dst,
1337                scratch: Vec::new(),
1338            }
1339        }
1340    }
1341
1342    impl Dispatch for GsoDispatch {
1343        fn on_udp(&mut self, _src: SocketAddr, _dg: &[u8], _now_ms: u64) -> DispatchOut<'_> {
1344            DispatchOut::None
1345        }
1346
1347        fn on_tun(&mut self, _inner: &[u8], _now_ms: u64) -> &[EgressDatagram] {
1348            self.scratch.clear();
1349            for i in 0_u8..5_u8 {
1350                let mut datagram = vec![b'a'; 64];
1351                datagram[0] = b'0' + i;
1352                self.scratch.push(EgressDatagram {
1353                    fate: 7,
1354                    dst: self.dst,
1355                    bytes: datagram,
1356                });
1357            }
1358            &self.scratch
1359        }
1360
1361        fn tick(&mut self, _now_ms: u64) -> Option<&[EgressDatagram]> {
1362            None
1363        }
1364    }
1365
1366    /// Returns `datagram_count` same-length datagrams each tagged with a DISTINCT
1367    /// fate group, i.e. one symbol from each of N different FEC objects. A GSO
1368    /// driver may coalesce these (a dropped skb costs each object at most one
1369    /// symbol, recoverable from its repair in a different skb).
1370    struct GsoLargeBatchDispatch {
1371        dst: SocketAddr,
1372        scratch: Vec<EgressDatagram>,
1373        datagram_count: usize,
1374        datagram_size: usize,
1375    }
1376
1377    impl GsoLargeBatchDispatch {
1378        fn new(dst: SocketAddr, datagram_count: usize, datagram_size: usize) -> Self {
1379            Self {
1380                dst,
1381                scratch: Vec::new(),
1382                datagram_count,
1383                datagram_size,
1384            }
1385        }
1386    }
1387
1388    impl Dispatch for GsoLargeBatchDispatch {
1389        fn on_udp(&mut self, _src: SocketAddr, _dg: &[u8], _now_ms: u64) -> DispatchOut<'_> {
1390            DispatchOut::None
1391        }
1392
1393        fn on_tun(&mut self, _inner: &[u8], _now_ms: u64) -> &[EgressDatagram] {
1394            self.scratch.clear();
1395            for i in 0..self.datagram_count {
1396                let mut datagram = vec![b'z'; self.datagram_size];
1397                datagram[0] = u8::try_from(i % 251).expect("modulo bound fits u8");
1398                self.scratch.push(EgressDatagram {
1399                    fate: u16::try_from(i).expect("datagram_count fits u16"),
1400                    dst: self.dst,
1401                    bytes: datagram,
1402                });
1403            }
1404            &self.scratch
1405        }
1406
1407        fn tick(&mut self, _now_ms: u64) -> Option<&[EgressDatagram]> {
1408            None
1409        }
1410    }
1411
1412    fn make_pipe() -> io::Result<(RawFd, RawFd)> {
1413        let mut fds = [0_i32; 2];
1414        // SAFETY: `fds` points to valid writable storage for two file descriptors.
1415        let rc = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) };
1416        if rc < 0 {
1417            return Err(io::Error::last_os_error());
1418        }
1419        Ok((fds[0], fds[1]))
1420    }
1421
1422    fn write_pipe_once(fd: RawFd, bytes: &[u8]) {
1423        // SAFETY: `fd` is the write-end of a valid pipe and `bytes` points to
1424        // initialized memory for the duration of this syscall.
1425        let wrote = unsafe { libc::write(fd, bytes.as_ptr().cast::<libc::c_void>(), bytes.len()) };
1426        assert!(wrote >= 0, "pipe write should succeed");
1427        assert_eq!(
1428            usize::try_from(wrote).expect("non-negative write count fits usize"),
1429            bytes.len(),
1430            "must write full trigger frame into pipe"
1431        );
1432    }
1433
1434    fn test_dst() -> SocketAddr {
1435        "127.0.0.1:1".parse().expect("valid test address")
1436    }
1437
1438    fn other_dst() -> SocketAddr {
1439        "127.0.0.1:2".parse().expect("valid test address")
1440    }
1441
1442    fn dg(fate: u16, len: usize) -> EgressDatagram {
1443        dg_to(fate, len, test_dst())
1444    }
1445
1446    fn dg_to(fate: u16, len: usize, dst: SocketAddr) -> EgressDatagram {
1447        EgressDatagram {
1448            fate,
1449            dst,
1450            bytes: vec![b'x'; len],
1451        }
1452    }
1453
1454    #[test]
1455    fn can_coalesce_gso_tagged_rejects_duplicate_fate() {
1456        // Two same-length datagrams of the SAME fate (an object's source + its
1457        // repair) must never be judged coalesceable.
1458        let dgs = [dg(3, 64), dg(3, 64)];
1459        assert!(UringDriver::can_coalesce_gso_tagged(&dgs).is_none());
1460    }
1461
1462    #[test]
1463    fn can_coalesce_gso_tagged_accepts_distinct_fates_same_length() {
1464        let dgs = [dg(3, 64), dg(4, 64), dg(5, 64)];
1465        assert_eq!(UringDriver::can_coalesce_gso_tagged(&dgs), Some(64));
1466    }
1467
1468    #[test]
1469    fn can_coalesce_gso_tagged_rejects_mismatched_length() {
1470        let dgs = [dg(3, 64), dg(4, 32)];
1471        assert!(UringDriver::can_coalesce_gso_tagged(&dgs).is_none());
1472    }
1473
1474    #[test]
1475    fn can_coalesce_gso_tagged_rejects_mixed_destinations() {
1476        // Distinct fates and equal length would otherwise coalesce, but a
1477        // coalesced `UDP_SEGMENT` send has exactly one `msg_name` — datagrams
1478        // bound for different peers must never share a skb.
1479        let dgs = [dg_to(3, 64, test_dst()), dg_to(4, 64, other_dst())];
1480        assert!(UringDriver::can_coalesce_gso_tagged(&dgs).is_none());
1481    }
1482
1483    #[test]
1484    fn uring_loopback_roundtrip_recycles_recv_buffers() {
1485        let _guard = URING_SERIAL
1486            .lock()
1487            .unwrap_or_else(std::sync::PoisonError::into_inner);
1488        let a = UdpSocket::bind("127.0.0.1:0").expect("bind sender");
1489        let b = UdpSocket::bind("127.0.0.1:0").expect("bind uring peer");
1490        a.connect(b.local_addr().expect("sender local addr"))
1491            .expect("connect sender");
1492        b.connect(a.local_addr().expect("uring peer local addr"))
1493            .expect("connect uring peer");
1494        a.set_nonblocking(true).expect("set sender nonblocking");
1495
1496        let (tun_rd, tun_wr) = make_pipe().expect("make tun placeholder pipe");
1497        let mut dispatch = EchoDispatch::new();
1498
1499        let mut driver = match UringDriver::new(b.as_raw_fd(), tun_rd) {
1500            Ok(driver) => driver,
1501            Err(_) => {
1502                eprintln!(
1503                    "SKIP uring_loopback_roundtrip_recycles_recv_buffers: \
1504                     io_uring ring or buffer registration unavailable (RLIMIT_MEMLOCK)"
1505                );
1506                // SAFETY: these fds came from `pipe2` and are still open here.
1507                unsafe {
1508                    libc::close(tun_rd);
1509                    libc::close(tun_wr);
1510                }
1511                return;
1512            }
1513        };
1514
1515        // Interleave send and drain so the kernel UDP receive buffer never has to
1516        // hold more than `WINDOW` datagrams at once. Recycling of the `RING_BUFS`
1517        // provided-buffer pool is proven by round-tripping far MORE than
1518        // `RING_BUFS` datagrams *through* the pool — not by buffering that many
1519        // simultaneously. Blasting all of them first relied on the kernel holding
1520        // `> RING_BUFS` at once, but the default `net.core.rmem_default` (~208 KB)
1521        // only holds ~230 small datagrams (per-skb truesize overhead), so that
1522        // pattern dropped below `RING_BUFS` and failed on stock-configured boxes.
1523        let total = 384_usize;
1524        const WINDOW: usize = 64;
1525        let mut sent_payloads = Vec::with_capacity(total);
1526        let mut got = Vec::with_capacity(total);
1527        let mut recv_buf = [0_u8; MAX_WIRE_DATAGRAM];
1528        let mut next_to_send = 0_usize;
1529        let mut stall = 0_usize;
1530        for _ in 0..8000 {
1531            // Refill the in-flight window: cap unacked datagrams at `WINDOW`
1532            // (far under the ~230 the recv buffer holds), so nothing is dropped.
1533            while next_to_send < total && next_to_send < got.len() + WINDOW {
1534                let payload = format!("uring-echo-{next_to_send}").into_bytes();
1535                a.send(&payload).expect("send test datagram");
1536                sent_payloads.push(payload);
1537                next_to_send += 1;
1538            }
1539
1540            driver.poll_once(&mut dispatch).expect("poll_once succeeds");
1541
1542            let before = got.len();
1543            loop {
1544                match a.recv(&mut recv_buf) {
1545                    Ok(n) => got.push(recv_buf[..n].to_vec()),
1546                    Err(e) if e.kind() == io::ErrorKind::WouldBlock => break,
1547                    Err(e) => panic!("recv failed: {e}"),
1548                }
1549            }
1550            if got.len() >= total {
1551                break;
1552            }
1553            // No-progress early exit: once the window has filled, no further
1554            // `got` progress means a rare drop stalled it. Stop instead of
1555            // spinning the full poll budget — we still round-trip well over
1556            // `RING_BUFS` before any such stall, so the assert below holds.
1557            if got.len() == before {
1558                stall += 1;
1559                if stall >= 200 {
1560                    break;
1561                }
1562            } else {
1563                stall = 0;
1564            }
1565        }
1566
1567        // SAFETY: these fds came from `pipe2` and are still open here.
1568        unsafe {
1569            libc::close(tun_rd);
1570            libc::close(tun_wr);
1571        }
1572
1573        // Send and drain are interleaved (bounded `WINDOW`), so nothing is
1574        // dropped and `got` reaches `total` on any box. The test's real point is
1575        // recv-buffer *recycling*: round-tripping more datagrams than the
1576        // `RING_BUFS` provided-buffer pool holds proves buffers were reprovided
1577        // and reused. A genuine buffer leak stalls throughput at <= `RING_BUFS`,
1578        // so this still fails on it. (Integer comparison; no float, no `as`.)
1579        assert!(
1580            got.len() > RING_BUFS,
1581            "expected more than RING_BUFS={RING_BUFS} datagrams to round-trip \
1582             (proving recv buffers were recycled), got {}",
1583            got.len()
1584        );
1585        // Every datagram we did receive must be an intact echo of one we sent.
1586        for payload in &got {
1587            assert!(
1588                sent_payloads.contains(payload),
1589                "received a payload that was never sent"
1590            );
1591        }
1592        drop(driver);
1593        std::thread::sleep(Duration::from_millis(200));
1594    }
1595
1596    #[test]
1597    fn uring_idle_poll_fires_tick_via_timeout() {
1598        let _guard = URING_SERIAL
1599            .lock()
1600            .unwrap_or_else(std::sync::PoisonError::into_inner);
1601        // An idle driver: no UDP peer sends anything, nothing is written to the
1602        // TUN pipe, so no completion ever arrives.
1603        let sock = UdpSocket::bind("127.0.0.1:0").expect("bind idle udp");
1604        let (tun_rd, tun_wr) = make_pipe().expect("make tun placeholder pipe");
1605        let mut driver = match UringDriver::new(sock.as_raw_fd(), tun_rd) {
1606            Ok(driver) => driver,
1607            Err(_) => {
1608                eprintln!("SKIP uring_idle_poll_fires_tick_via_timeout: io_uring unavailable");
1609                // SAFETY: these fds came from `pipe2` and are still open here.
1610                unsafe {
1611                    libc::close(tun_rd);
1612                    libc::close(tun_wr);
1613                }
1614                return;
1615            }
1616        };
1617
1618        let mut dispatch = TickCountDispatch { ticks: 0 };
1619        // With no traffic, `poll_once` must still return via the tick timeout and
1620        // fire `tick` — before this fix it blocked forever, starving `tick`.
1621        let started = std::time::Instant::now();
1622        driver
1623            .poll_once(&mut dispatch)
1624            .expect("idle poll_once returns via the tick timeout");
1625        assert!(
1626            dispatch.ticks >= 1,
1627            "tick must fire on the idle-timeout path (fired {})",
1628            dispatch.ticks
1629        );
1630        assert!(
1631            started.elapsed() < Duration::from_secs(2),
1632            "idle poll_once must return promptly via timeout, not block"
1633        );
1634
1635        drop(driver);
1636        // SAFETY: these fds came from `pipe2` and are still open here.
1637        unsafe {
1638            libc::close(tun_rd);
1639            libc::close(tun_wr);
1640        }
1641    }
1642
1643    #[test]
1644    fn uring_gso_same_object_datagrams_are_never_coalesced() {
1645        let _guard = URING_SERIAL
1646            .lock()
1647            .unwrap_or_else(std::sync::PoisonError::into_inner);
1648        let a = UdpSocket::bind("127.0.0.1:0").expect("bind sender");
1649        let b = UdpSocket::bind("127.0.0.1:0").expect("bind uring peer");
1650        a.connect(b.local_addr().expect("sender local addr"))
1651            .expect("connect sender");
1652        b.connect(a.local_addr().expect("uring peer local addr"))
1653            .expect("connect uring peer");
1654        a.set_nonblocking(true).expect("set sender nonblocking");
1655
1656        let (tun_rd, tun_wr) = make_pipe().expect("make tun placeholder pipe");
1657        // GsoDispatch returns 5 datagrams all sharing ONE fate (one FEC object),
1658        // all destined for `a` (the loopback peer that will receive them).
1659        let mut dispatch = GsoDispatch::new(a.local_addr().expect("sender local addr"));
1660        let mut driver = match UringDriver::new(b.as_raw_fd(), tun_rd) {
1661            Ok(driver) => driver,
1662            Err(_) => {
1663                eprintln!(
1664                    "SKIP uring_gso_same_object_datagrams_are_never_coalesced: \
1665                     io_uring ring or buffer registration unavailable (RLIMIT_MEMLOCK)"
1666                );
1667                // SAFETY: these fds came from `pipe2` and are still open here.
1668                unsafe {
1669                    libc::close(tun_rd);
1670                    libc::close(tun_wr);
1671                }
1672                return;
1673            }
1674        };
1675
1676        write_pipe_once(tun_wr, b"trigger-gso");
1677        let mut got = Vec::new();
1678        let mut recv_buf = [0_u8; MAX_WIRE_DATAGRAM];
1679        for _ in 0..1024 {
1680            driver.poll_once(&mut dispatch).expect("poll_once succeeds");
1681            loop {
1682                match a.recv(&mut recv_buf) {
1683                    Ok(n) => got.push(recv_buf[..n].to_vec()),
1684                    Err(e) if e.kind() == io::ErrorKind::WouldBlock => break,
1685                    Err(e) => panic!("recv failed: {e}"),
1686                }
1687            }
1688            if got.len() >= 5 {
1689                break;
1690            }
1691        }
1692
1693        // The invariant: a source symbol and its own repair must never share a
1694        // GSO skb, so 5 same-fate datagrams must NEVER be coalesced — they go out
1695        // as individual sends.
1696        assert_eq!(
1697            driver.gso_submission_count(),
1698            0,
1699            "same-object (same-fate) datagrams must never be GSO-coalesced"
1700        );
1701
1702        let expected: Vec<Vec<u8>> = (0_u8..5_u8)
1703            .map(|i| {
1704                let mut datagram = vec![b'a'; 64];
1705                datagram[0] = b'0' + i;
1706                datagram
1707            })
1708            .collect();
1709        assert_eq!(got.len(), expected.len(), "must receive all datagrams");
1710        for datagram in expected {
1711            assert!(got.contains(&datagram), "missing datagram from output");
1712        }
1713
1714        // SAFETY: these fds came from `pipe2` and are still open here.
1715        unsafe {
1716            libc::close(tun_rd);
1717            libc::close(tun_wr);
1718        }
1719        drop(driver);
1720        std::thread::sleep(Duration::from_millis(200));
1721    }
1722
1723    #[test]
1724    fn uring_gso_submit_fallback_uses_per_datagram_send() {
1725        let _guard = URING_SERIAL
1726            .lock()
1727            .unwrap_or_else(std::sync::PoisonError::into_inner);
1728        let a = UdpSocket::bind("127.0.0.1:0").expect("bind sender");
1729        let b = UdpSocket::bind("127.0.0.1:0").expect("bind uring peer");
1730        a.connect(b.local_addr().expect("sender local addr"))
1731            .expect("connect sender");
1732        b.connect(a.local_addr().expect("uring peer local addr"))
1733            .expect("connect uring peer");
1734        a.set_nonblocking(true).expect("set sender nonblocking");
1735
1736        let (tun_rd, tun_wr) = make_pipe().expect("make tun placeholder pipe");
1737        // Distinct fates so GSO is genuinely attempted (then forced to fail).
1738        let mut dispatch =
1739            GsoLargeBatchDispatch::new(a.local_addr().expect("sender local addr"), 5, 64);
1740        let mut driver = match UringDriver::new(b.as_raw_fd(), tun_rd) {
1741            Ok(driver) => driver,
1742            Err(_) => {
1743                eprintln!(
1744                    "SKIP uring_gso_submit_fallback_uses_per_datagram_send: \
1745                     io_uring ring or buffer registration unavailable (RLIMIT_MEMLOCK)"
1746                );
1747                // SAFETY: these fds came from `pipe2` and are still open here.
1748                unsafe {
1749                    libc::close(tun_rd);
1750                    libc::close(tun_wr);
1751                }
1752                return;
1753            }
1754        };
1755        driver.set_force_gso_submit_failure(true);
1756
1757        write_pipe_once(tun_wr, b"trigger-gso-fallback");
1758        let mut got = Vec::new();
1759        let mut recv_buf = [0_u8; MAX_WIRE_DATAGRAM];
1760        for _ in 0..1024 {
1761            driver.poll_once(&mut dispatch).expect("poll_once succeeds");
1762            loop {
1763                match a.recv(&mut recv_buf) {
1764                    Ok(n) => got.push(recv_buf[..n].to_vec()),
1765                    Err(e) if e.kind() == io::ErrorKind::WouldBlock => break,
1766                    Err(e) => panic!("recv failed: {e}"),
1767                }
1768            }
1769            if got.len() >= 5 {
1770                break;
1771            }
1772        }
1773
1774        assert_eq!(
1775            driver.gso_submission_count(),
1776            0,
1777            "forced submit failure should prevent GSO send submissions"
1778        );
1779        let expected: Vec<Vec<u8>> = (0..5_usize)
1780            .map(|i| {
1781                let mut datagram = vec![b'z'; 64];
1782                datagram[0] = u8::try_from(i % 251).expect("fits u8");
1783                datagram
1784            })
1785            .collect();
1786        assert_eq!(
1787            got.len(),
1788            expected.len(),
1789            "must receive all fallback datagrams"
1790        );
1791        for datagram in expected {
1792            assert!(
1793                got.contains(&datagram),
1794                "missing datagram from fallback output"
1795            );
1796        }
1797
1798        // SAFETY: these fds came from `pipe2` and are still open here.
1799        unsafe {
1800            libc::close(tun_rd);
1801            libc::close(tun_wr);
1802        }
1803        drop(driver);
1804        std::thread::sleep(Duration::from_millis(200));
1805    }
1806
1807    #[test]
1808    fn uring_gso_large_batch_chunks_payload_to_udp_limits() {
1809        let _guard = URING_SERIAL
1810            .lock()
1811            .unwrap_or_else(std::sync::PoisonError::into_inner);
1812        let a = UdpSocket::bind("127.0.0.1:0").expect("bind sender");
1813        let b = UdpSocket::bind("127.0.0.1:0").expect("bind uring peer");
1814        a.connect(b.local_addr().expect("sender local addr"))
1815            .expect("connect sender");
1816        b.connect(a.local_addr().expect("uring peer local addr"))
1817            .expect("connect uring peer");
1818        a.set_nonblocking(true).expect("set sender nonblocking");
1819
1820        let (tun_rd, tun_wr) = make_pipe().expect("make tun placeholder pipe");
1821        let datagram_count = 64usize;
1822        let datagram_size = 1400usize;
1823        let mut dispatch = GsoLargeBatchDispatch::new(
1824            a.local_addr().expect("sender local addr"),
1825            datagram_count,
1826            datagram_size,
1827        );
1828        let mut driver = match UringDriver::new(b.as_raw_fd(), tun_rd) {
1829            Ok(driver) => driver,
1830            Err(_) => {
1831                eprintln!(
1832                    "SKIP uring_gso_large_batch_chunks_payload_to_udp_limits: \
1833                     io_uring ring or buffer registration unavailable (RLIMIT_MEMLOCK)"
1834                );
1835                // SAFETY: these fds came from `pipe2` and are still open here.
1836                unsafe {
1837                    libc::close(tun_rd);
1838                    libc::close(tun_wr);
1839                }
1840                return;
1841            }
1842        };
1843
1844        write_pipe_once(tun_wr, b"trigger-large-gso");
1845        for _ in 0..2048 {
1846            driver.poll_once(&mut dispatch).expect("poll_once succeeds");
1847            if driver.gso_submission_count() > 0 {
1848                break;
1849            }
1850        }
1851        assert!(
1852            driver.gso_submission_count() > 0,
1853            "driver must submit at least one GSO send"
1854        );
1855
1856        let mut got = Vec::new();
1857        let mut recv_buf = [0_u8; MAX_WIRE_DATAGRAM];
1858        for _ in 0..4096 {
1859            loop {
1860                match a.recv(&mut recv_buf) {
1861                    Ok(n) => got.push(recv_buf[..n].to_vec()),
1862                    Err(e) if e.kind() == io::ErrorKind::WouldBlock => break,
1863                    Err(e) => panic!("recv failed: {e}"),
1864                }
1865            }
1866            if got.len() >= datagram_count {
1867                break;
1868            }
1869            driver.poll_once(&mut dispatch).expect("poll_once succeeds");
1870        }
1871
1872        // Independently computed expected chunk count — deliberately does NOT
1873        // call `max_gso_datagrams_for_segment` (the function under test). These
1874        // literals mirror the module constants MAX_UDP_PAYLOAD (65507),
1875        // MAX_GSO_SEGMENTS_PER_SEND (32), and MAX_GSO_DATAGRAMS (64); keep them in
1876        // sync with the constants by hand if those change.
1877        let mtu_cap = 65_507_usize / datagram_size;
1878        let max_chunk = mtu_cap.min(32).min(64);
1879        let expected_submissions = datagram_count.div_ceil(max_chunk);
1880        assert!(expected_submissions >= 2, "test should exercise chunking");
1881        assert!(
1882            driver.gso_submission_count() >= expected_submissions,
1883            "driver must chunk large GSO batches into multiple submissions \
1884             ({} distinct-fate datagrams, expected >= {expected_submissions} sends, \
1885             got {})",
1886            datagram_count,
1887            driver.gso_submission_count()
1888        );
1889        assert_eq!(
1890            got.len(),
1891            datagram_count,
1892            "must receive all datagrams from a large GSO batch"
1893        );
1894
1895        // SAFETY: these fds came from `pipe2` and are still open here.
1896        unsafe {
1897            libc::close(tun_rd);
1898            libc::close(tun_wr);
1899        }
1900        drop(driver);
1901        std::thread::sleep(Duration::from_millis(200));
1902    }
1903
1904    #[test]
1905    fn uring_in_flight_send_table_reuses_slots_after_completions() {
1906        let _guard = URING_SERIAL
1907            .lock()
1908            .unwrap_or_else(std::sync::PoisonError::into_inner);
1909        let a = UdpSocket::bind("127.0.0.1:0").expect("bind sender");
1910        let b = UdpSocket::bind("127.0.0.1:0").expect("bind uring peer");
1911        a.connect(b.local_addr().expect("sender local addr"))
1912            .expect("connect sender");
1913        b.connect(a.local_addr().expect("uring peer local addr"))
1914            .expect("connect uring peer");
1915        a.set_nonblocking(true).expect("set sender nonblocking");
1916
1917        let (tun_rd, tun_wr) = make_pipe().expect("make tun placeholder pipe");
1918        let mut dispatch = EchoDispatch::new();
1919        let mut driver = match UringDriver::new(b.as_raw_fd(), tun_rd) {
1920            Ok(driver) => driver,
1921            Err(_) => {
1922                eprintln!(
1923                    "SKIP uring_in_flight_send_table_reuses_slots_after_completions: \
1924                     io_uring ring or buffer registration unavailable (RLIMIT_MEMLOCK)"
1925                );
1926                // SAFETY: these fds came from `pipe2` and are still open here.
1927                unsafe {
1928                    libc::close(tun_rd);
1929                    libc::close(tun_wr);
1930                }
1931                return;
1932            }
1933        };
1934
1935        // Interleave send and drain (bounded `WINDOW`) so the kernel UDP receive
1936        // buffer never has to hold more than `WINDOW` datagrams at once. Blasting
1937        // all `SEND_SLOTS * 3` first relied on the kernel buffering `> SEND_SLOTS`
1938        // simultaneously, but the default `net.core.rmem_default` (~208 KB) only
1939        // holds ~230 small datagrams, so that pattern dropped below `SEND_SLOTS`
1940        // and failed on stock-configured boxes.
1941        let total = SEND_SLOTS * 3;
1942        const WINDOW: usize = 64;
1943        let mut recv_buf = [0_u8; MAX_WIRE_DATAGRAM];
1944        let mut received = 0_usize;
1945        let mut next_to_send = 0_usize;
1946        let mut stall = 0_usize;
1947        for _ in 0..8000 {
1948            while next_to_send < total && next_to_send < received + WINDOW {
1949                let payload = format!("slot-reuse-{next_to_send}").into_bytes();
1950                a.send(&payload).expect("send test datagram");
1951                next_to_send += 1;
1952            }
1953
1954            driver.poll_once(&mut dispatch).expect("poll_once succeeds");
1955            let before = received;
1956            loop {
1957                match a.recv(&mut recv_buf) {
1958                    Ok(_) => received += 1,
1959                    Err(e) if e.kind() == io::ErrorKind::WouldBlock => break,
1960                    Err(e) => panic!("recv failed: {e}"),
1961                }
1962            }
1963            if received >= total {
1964                break;
1965            }
1966            // No-progress early exit: once the window has filled, no further
1967            // `received` progress means a rare drop stalled it. Stop instead of
1968            // spinning the full poll budget of idle 10 ms waits.
1969            if received == before {
1970                stall += 1;
1971                if stall >= 200 {
1972                    break;
1973                }
1974            } else {
1975                stall = 0;
1976            }
1977        }
1978
1979        // Send and drain are interleaved (bounded `WINDOW`), so `received`
1980        // reaches `total` on any box. The test's real point is send-slot *reuse*:
1981        // round-tripping more datagrams than the fixed `SEND_SLOTS` table can hold
1982        // at once proves the table was reused, and the `in_flight_used_count() ==
1983        // 0` check below proves it drained. A genuine slot leak caps throughput at
1984        // <= `SEND_SLOTS` and leaves the table non-empty, so both asserts still
1985        // fail on it. (Integer comparison; no float, no `as`.)
1986        assert!(
1987            received > SEND_SLOTS,
1988            "expected more than SEND_SLOTS={SEND_SLOTS} datagrams to round-trip \
1989             (proving the send table was reused), got {received}"
1990        );
1991
1992        // The echoed data can arrive at `a` before the driver reaps the matching
1993        // SEND completion CQE (observed on slower CI runners), so the loop above
1994        // can exit with send CQEs still pending. Drain them before asserting slot
1995        // hygiene. Because every echoed send physically completed, its CQE is
1996        // already queued, so `poll_once` (submit_and_wait(1)) returns without
1997        // blocking; a genuine slot leak would never reach 0 and still fail below.
1998        for _ in 0..1000 {
1999            if driver.in_flight_used_count() == 0 {
2000                break;
2001            }
2002            driver
2003                .poll_once(&mut dispatch)
2004                .expect("poll_once drains pending send completions");
2005        }
2006        assert_eq!(
2007            driver.in_flight_used_count(),
2008            0,
2009            "all in-flight slots must be released after completions"
2010        );
2011
2012        // Any echoes not received should be reflected as bounded slot-exhaustion
2013        // drops; the counter can never exceed the total datagrams sent.
2014        assert!(
2015            driver.dropped_sends() <= u64::try_from(total).expect("total fits u64"),
2016            "dropped_sends must not exceed total sent"
2017        );
2018
2019        // SAFETY: these fds came from `pipe2` and are still open here.
2020        unsafe {
2021            libc::close(tun_rd);
2022            libc::close(tun_wr);
2023        }
2024        drop(driver);
2025        std::thread::sleep(Duration::from_millis(200));
2026    }
2027}