Files
punktfunk/crates/punktfunk-core/src/transport/udp.rs
T
enricobuehler 50afa0467d perf(core): recvmsg_x batched receive on Apple (macOS client)
macOS/iOS have no recvmmsg(2), so the Mac client did one recv() syscall per
packet (non-allocating after the earlier fix, but still a syscall each — a
single-core wall at line rate that Moonlight avoids). Add the Darwin recvmsg_x(2)
batched-receive path (the recv counterpart of Linux recvmmsg): one syscall drains
up to RECV_BATCH datagrams into the reused ring. struct msghdr_x + the extern
aren't in the libc crate, so declared here (cfg target_vendor=apple).

Opt-in via PUNKTFUNK_RECVMSG_X (it's FFI we can't exercise off-Apple) with
auto-fallback to the tested scalar recv-loop on any unexpected error. Linux
recvmmsg + the non-Apple scalar loop are unchanged; apple.yml compiles the path.

Re GRO: Linux recv already batches via recvmmsg (32/syscall), so UDP GRO is only a
marginal add there and needs a recv-path redesign to split coalesced buffers —
deferred as low-ROI vs the Mac, which had no batching at all.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:52:39 +00:00

615 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Real UDP datagram transport — native sockets, no async runtime.
//!
//! Send is batched via `sendmmsg` ([`Transport::send_batch`], ≤64/syscall) and recv via `recvmmsg`
//! ([`Transport::recv_batch`], ≤32/syscall into a reused ring) — the 1 Gbps+ syscall lever
//! (~125k → a few-k syscalls/sec at line rate). The host additionally paces each frame's send
//! across the frame interval (see `m3.rs::paced_submit`) so a real NIC doesn't drop a line-rate
//! burst. All three layer on this same [`Transport`] seam (scalar fallbacks for loopback/non-Linux).
use super::Transport;
use crate::packet::MAX_DATAGRAM_BYTES;
use std::net::UdpSocket;
/// Receive buffer size. `Config::validate` bounds `shard_payload` so a well-formed
/// datagram (header + shard + crypto overhead) always fits in [`MAX_DATAGRAM_BYTES`];
/// the `+ 1` byte lets us detect an oversized datagram (a full read) instead of
/// silently truncating it.
const RECV_BUF: usize = MAX_DATAGRAM_BYTES + 1;
/// Build one `mmsghdr` per `iovec` (each a single-buffer message) for `sendmmsg`/`recvmmsg`. Shared
/// by `send_batch` + `recv_batch` so the raw-pointer scaffolding lives in exactly one place.
///
/// SAFETY (caller's): each returned header holds a raw pointer into `iovs`; the caller MUST keep
/// `iovs` alive and unmoved for as long as the headers are passed to the syscall.
#[cfg(target_os = "linux")]
fn mmsghdrs(iovs: &mut [libc::iovec]) -> Vec<libc::mmsghdr> {
iovs.iter_mut()
.map(|iov| {
let mut h: libc::mmsghdr = unsafe { std::mem::zeroed() };
h.msg_hdr.msg_iov = iov;
h.msg_hdr.msg_iovlen = 1;
h
})
.collect()
}
/// UDP GSO enable state (process-wide). Opt-in via `PUNKTFUNK_GSO` — it's new unsafe hot-path code,
/// and the auto-fallback (latch off on any GSO syscall error) covers kernels/paths without support.
#[cfg(target_os = "linux")]
mod gso {
use std::sync::atomic::{AtomicU8, Ordering};
static STATE: AtomicU8 = AtomicU8::new(0); // 0 = uninit, 1 = on, 2 = off
pub fn active() -> bool {
match STATE.load(Ordering::Relaxed) {
1 => true,
2 => false,
_ => {
let on = std::env::var_os("PUNKTFUNK_GSO").is_some();
STATE.store(if on { 1 } else { 2 }, Ordering::Relaxed);
on
}
}
}
/// Latch GSO off for the process after a GSO syscall error (unsupported kernel/path).
pub fn disable() {
STATE.store(2, Ordering::Relaxed);
}
}
/// True if the send error means UDP GSO isn't supported here (vs a transient/real failure) — so we
/// latch GSO off and fall back to `sendmmsg` rather than tear the stream down.
#[cfg(target_os = "linux")]
fn gso_unsupported(e: &std::io::Error) -> bool {
matches!(
e.raw_os_error(),
Some(libc::ENOPROTOOPT) | Some(libc::EOPNOTSUPP) | Some(libc::EINVAL) | Some(libc::EIO)
)
}
/// One `sendmsg` carrying a `UDP_SEGMENT` control message: the kernel splits `buf` (a back-to-back
/// concatenation of equal-size datagrams, only the final one allowed shorter) into `gso_size`-byte
/// UDP datagrams to the connected peer — one large GSO skb instead of N. `EAGAIN` (full send buffer)
/// surfaces as a `WouldBlock` error; the caller treats it as a lossy drop.
#[cfg(target_os = "linux")]
fn send_one_gso(fd: libc::c_int, buf: &[u8], gso_size: u16) -> std::io::Result<()> {
let mut iov = libc::iovec {
iov_base: buf.as_ptr() as *mut libc::c_void,
iov_len: buf.len(),
};
// Aligned control buffer for one cmsg(UDP_SEGMENT = u16). 64 B > CMSG_SPACE(2); the union forces
// cmsghdr alignment (CMSG_FIRSTHDR requires it).
#[repr(C)]
union CmsgBuf {
_align: libc::cmsghdr,
bytes: [u8; 64],
}
let mut control = CmsgBuf { bytes: [0u8; 64] };
let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1;
let rc = unsafe {
msg.msg_control = control.bytes.as_mut_ptr() as *mut libc::c_void;
msg.msg_controllen = libc::CMSG_SPACE(std::mem::size_of::<u16>() as u32) as _;
let cmsg = libc::CMSG_FIRSTHDR(&msg);
(*cmsg).cmsg_level = libc::SOL_UDP;
(*cmsg).cmsg_type = libc::UDP_SEGMENT;
(*cmsg).cmsg_len = libc::CMSG_LEN(std::mem::size_of::<u16>() as u32) as _;
std::ptr::copy_nonoverlapping(
(&gso_size as *const u16) as *const u8,
libc::CMSG_DATA(cmsg),
std::mem::size_of::<u16>(),
);
libc::sendmsg(fd, &msg, 0)
};
if rc < 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
/// Apple (macOS/iOS) batched-receive enable state. Darwin has no `recvmmsg(2)`, so our macOS client
/// does one `recv` per packet (non-allocating, but a syscall each); `recvmsg_x(2)` is the batched
/// equivalent. Opt-in via `PUNKTFUNK_RECVMSG_X` (it's FFI we can't exercise off-Apple — the scalar
/// recv-loop is the tested default), with auto-fallback if the syscall ever errors unexpectedly.
#[cfg(target_vendor = "apple")]
mod recvx {
use std::sync::atomic::{AtomicU8, Ordering};
static STATE: AtomicU8 = AtomicU8::new(0); // 0 = uninit, 1 = on, 2 = off
pub fn active() -> bool {
match STATE.load(Ordering::Relaxed) {
1 => true,
2 => false,
_ => {
let on = std::env::var_os("PUNKTFUNK_RECVMSG_X").is_some();
STATE.store(if on { 1 } else { 2 }, Ordering::Relaxed);
on
}
}
}
pub fn disable() {
STATE.store(2, Ordering::Relaxed);
}
}
/// `struct msghdr_x` from Darwin `<sys/socket.h>` (the batched-I/O variant — not in the `libc` crate).
#[cfg(target_vendor = "apple")]
#[repr(C)]
struct MsghdrX {
msg_name: *mut libc::c_void,
msg_namelen: libc::socklen_t,
msg_iov: *mut libc::iovec,
msg_iovlen: libc::c_int,
msg_control: *mut libc::c_void,
msg_controllen: libc::socklen_t,
msg_flags: libc::c_int,
msg_datalen: libc::size_t,
}
#[cfg(target_vendor = "apple")]
extern "C" {
/// Darwin batched receive: up to `cnt` datagrams in one syscall; returns the count received and
/// sets each `msg_datalen` to its byte length. Present in libSystem on all macOS/iOS.
fn recvmsg_x(
s: libc::c_int,
msgp: *mut MsghdrX,
cnt: libc::c_uint,
flags: libc::c_int,
) -> libc::ssize_t;
}
pub struct UdpTransport {
socket: UdpSocket,
}
impl UdpTransport {
/// Target kernel socket-buffer size. A high-resolution frame is a burst (a 5120×1440
/// keyframe is ~130 packets the send thread hands to `sendmmsg` at once); the default
/// UDP buffer (~208 KB on Linux) overflows on it, which EAGAINs the host send (dropping
/// packets) or drops on the client recv — and with infinite-GOP a single lost frame
/// freezes the decode until the next RFI refresh. Requested large; the OS clamps to
/// `net.core.{wmem,rmem}_max` (Linux) / `kern.ipc.maxsockbuf` (macOS).
///
/// Sized for 1 Gbps+: at ~1.2 Gbps on the wire an 8 MB buffer is only ~49 ms of steady state,
/// and a single multi-MB IDR keyframe (~4 MB ≈ 3300 packets) instantly fills most of it. 32 MB
/// gives ~200 ms of headroom and absorbs a keyframe burst without EAGAIN drops. (Paced sending
/// — `m3.rs::paced_submit` — now spreads a big frame's overflow, so this buffer mostly absorbs
/// the immediate microburst rather than a whole unpaced frame.)
const TARGET_SOCKBUF: usize = 32 * 1024 * 1024;
/// Bind `local` and `connect` to `peer`, so `send`/`recv` need no address and the
/// kernel filters to this peer. Non-blocking, matching the [`Transport`] contract.
pub fn connect(local: &str, peer: &str) -> std::io::Result<Self> {
let socket = UdpSocket::bind(local)?;
socket.connect(peer)?;
Self::grow_buffers(&socket);
socket.set_nonblocking(true)?;
Ok(UdpTransport { socket })
}
/// The bound local address (e.g. to learn the OS-assigned ephemeral port).
pub fn local_addr(&self) -> std::io::Result<std::net::SocketAddr> {
self.socket.local_addr()
}
/// Best-effort grow of SO_SNDBUF/SO_RCVBUF (see [`TARGET_SOCKBUF`]). A failure isn't fatal
/// (the stream just runs lossier); a grant far below the request means the OS cap is too
/// low for clean 4K/5K streaming, so warn once with the knob to raise.
fn grow_buffers(socket: &UdpSocket) {
let sock = socket2::SockRef::from(socket);
let _ = sock.set_send_buffer_size(Self::TARGET_SOCKBUF);
let _ = sock.set_recv_buffer_size(Self::TARGET_SOCKBUF);
// The kernel reports back the (possibly clamped, Linux-doubled) granted size.
let granted = sock
.send_buffer_size()
.unwrap_or(0)
.min(sock.recv_buffer_size().unwrap_or(0));
if granted < Self::TARGET_SOCKBUF / 4 {
tracing::warn!(
granted_kb = granted / 1024,
"UDP socket buffer capped well below target — high-resolution streaming may drop \
frames; raise net.core.wmem_max / net.core.rmem_max (Linux) for clean 4K/5K"
);
}
}
/// Apple batched receive via `recvmsg_x` — drains up to `out.len()` datagrams in one syscall into
/// the caller's reused buffers (the recv counterpart of Linux `recvmmsg`, which Darwin lacks).
/// SAFETY: each `MsghdrX` holds a raw pointer into `iovs`, which holds raw pointers into `out`'s
/// buffers; both `iovs` and `msgs` stay alive and unmoved through the syscall.
#[cfg(target_vendor = "apple")]
fn recv_batch_x(&self, out: &mut [Vec<u8>], lens: &mut [usize]) -> std::io::Result<usize> {
use std::os::fd::AsRawFd;
let fd = self.socket.as_raw_fd();
let n_bufs = out.len().min(lens.len());
if n_bufs == 0 {
return Ok(0);
}
let mut iovs: Vec<libc::iovec> = out[..n_bufs]
.iter_mut()
.map(|b| libc::iovec {
iov_base: b.as_mut_ptr() as *mut libc::c_void,
iov_len: b.len(),
})
.collect();
let mut msgs: Vec<MsghdrX> = iovs
.iter_mut()
.map(|iov| {
let mut m: MsghdrX = unsafe { std::mem::zeroed() };
m.msg_iov = iov as *mut libc::iovec;
m.msg_iovlen = 1;
m
})
.collect();
let n = unsafe {
recvmsg_x(
fd,
msgs.as_mut_ptr(),
n_bufs as libc::c_uint,
libc::MSG_DONTWAIT,
)
};
if n < 0 {
let err = std::io::Error::last_os_error();
if err.kind() == std::io::ErrorKind::WouldBlock {
return Ok(0);
}
return Err(err);
}
for (i, m) in msgs[..n as usize].iter().enumerate() {
lens[i] = m.msg_datalen;
}
Ok(n as usize)
}
}
impl Transport for UdpTransport {
fn send(&self, packet: &[u8]) -> std::io::Result<bool> {
match self.socket.send(packet) {
Ok(_) => Ok(true),
// The kernel UDP send buffer is momentarily full (a frame burst saturated the
// tx queue — common right after attaching to an already-running source that
// emits at full rate, and the dominant failure mode at 1 Gbps+). Drop this packet
// rather than fail the whole stream: the data plane is lossy + FEC-protected and the
// next frame/RFI keyframe recovers, whereas blocking would queue stale frames and add
// latency, and erroring tears the session down. `Ok(false)` surfaces the drop so the
// session counts it (`packets_send_dropped`) instead of it being invisible. Mirrors
// the `recv` WouldBlock handling above.
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => Ok(false),
Err(e) => Err(e),
}
}
/// Batched send via `sendmmsg` (up to 64 datagrams per syscall) — the connected socket needs
/// no per-message address. The socket is non-blocking, so a full send buffer surfaces as a
/// short count (or `EAGAIN` with nothing sent); we stop and report what went out rather than
/// block or retry — the data plane is lossy + FEC-protected, and blocking would queue stale
/// frames + add latency. Ports the proven GameStream `sendmmsg_all`. Non-Linux falls back to
/// the trait's scalar `send` loop (no `sendmmsg`).
#[cfg(target_os = "linux")]
fn send_batch(&self, packets: &[&[u8]]) -> std::io::Result<usize> {
use std::os::fd::AsRawFd;
const CHUNK: usize = 64;
let fd = self.socket.as_raw_fd();
let mut total_sent = 0usize;
for chunk in packets.chunks(CHUNK) {
// `hdrs` borrow `iovs` by raw pointer; both stay alive through the `sendmmsg` call.
let mut iovs: Vec<libc::iovec> = chunk
.iter()
.map(|p| libc::iovec {
iov_base: p.as_ptr() as *mut libc::c_void,
iov_len: p.len(),
})
.collect();
let mut hdrs = mmsghdrs(&mut iovs);
let n = unsafe { libc::sendmmsg(fd, hdrs.as_mut_ptr(), hdrs.len() as libc::c_uint, 0) };
if n < 0 {
let err = std::io::Error::last_os_error();
// Nothing fit in the send buffer — drop this + the remaining chunks (counted by
// the caller). A real error (not WouldBlock) still tears the session down.
if err.kind() == std::io::ErrorKind::WouldBlock {
break;
}
return Err(err);
}
total_sent += n as usize;
if (n as usize) < chunk.len() {
break; // buffer filled mid-chunk — drop the remainder
}
}
Ok(total_sent)
}
/// UDP GSO send (see [`Transport::send_gso`]). Coalesces the frame's equal-size packets into a
/// reused scratch buffer and hands the kernel ≤64-segment super-buffers via `sendmsg(UDP_SEGMENT)`
/// — one GSO skb per chunk instead of one per packet, the multi-Gbps lever. Opt-in
/// (`PUNKTFUNK_GSO`); falls back to `send_batch` when off, when packets aren't uniform-size, or on
/// any GSO error (which also latches it off for the process). Same lossy short-count contract.
#[cfg(target_os = "linux")]
fn send_gso(&self, packets: &[&[u8]]) -> std::io::Result<usize> {
use std::os::fd::AsRawFd;
if packets.is_empty() {
return Ok(0);
}
if !gso::active() {
return self.send_batch(packets);
}
// GSO needs every segment but the last to be exactly `seg` bytes. Our wire packets are all
// identical size (shards zero-padded to shard_payload), but guard and fall back if not.
let seg = packets[0].len();
let last = packets.len() - 1;
if seg == 0 || packets[..last].iter().any(|p| p.len() != seg) || packets[last].len() > seg {
return self.send_batch(packets);
}
let fd = self.socket.as_raw_fd();
// A GSO super-buffer is capped at 64 segments AND 65535 payload bytes (kernel limits).
let max_seg = (65535 / seg).clamp(1, 64);
let mut scratch: Vec<u8> = Vec::with_capacity(seg * max_seg);
let mut sent = 0usize;
for chunk in packets.chunks(max_seg) {
scratch.clear();
for p in chunk {
scratch.extend_from_slice(p);
}
match send_one_gso(fd, &scratch, seg as u16) {
Ok(()) => sent += chunk.len(),
// Send buffer momentarily full — drop the rest (counted by the caller), never block.
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
// GSO unsupported on this kernel/path — latch off and finish via sendmmsg.
Err(e) if gso_unsupported(&e) => {
gso::disable();
return Ok(sent + self.send_batch(&packets[sent..])?);
}
Err(e) => return Err(e),
}
}
Ok(sent)
}
fn recv(&self) -> std::io::Result<Option<Vec<u8>>> {
let mut buf = vec![0u8; RECV_BUF];
match self.socket.recv(&mut buf) {
// A read that fills the whole buffer means the datagram was larger than any
// valid packet — drop it rather than hand a truncated, corrupt packet up.
Ok(n) if n >= RECV_BUF => Ok(None),
Ok(n) => {
buf.truncate(n);
Ok(Some(buf))
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => Ok(None),
Err(e) => Err(e),
}
}
/// Batched receive via `recvmmsg` — drains up to `out.len()` datagrams in one syscall into the
/// caller's reused buffers (no per-packet allocation). `MSG_DONTWAIT` keeps it non-blocking
/// (the socket already is); `EAGAIN` → `0`. A datagram larger than a buffer is truncated and
/// `lens[i]` reaches the buffer size — the reassembler then rejects it as malformed, matching
/// `recv`'s oversized-drop. Apple/BSD use the `recv`-loop override below; other non-unix the
/// trait's scalar default.
#[cfg(target_os = "linux")]
fn recv_batch(&self, out: &mut [Vec<u8>], lens: &mut [usize]) -> std::io::Result<usize> {
use std::os::fd::AsRawFd;
let fd = self.socket.as_raw_fd();
let n_bufs = out.len().min(lens.len());
if n_bufs == 0 {
return Ok(0);
}
// `hdrs` borrow `iovs` (one per buffer) by raw pointer; both live through the recvmmsg call.
let mut iovs: Vec<libc::iovec> = out[..n_bufs]
.iter_mut()
.map(|b| libc::iovec {
iov_base: b.as_mut_ptr() as *mut libc::c_void,
iov_len: b.len(),
})
.collect();
let mut hdrs = mmsghdrs(&mut iovs);
let n = unsafe {
libc::recvmmsg(
fd,
hdrs.as_mut_ptr(),
n_bufs as libc::c_uint,
libc::MSG_DONTWAIT,
std::ptr::null_mut(),
)
};
if n < 0 {
let err = std::io::Error::last_os_error();
if err.kind() == std::io::ErrorKind::WouldBlock {
return Ok(0);
}
return Err(err);
}
for (i, h) in hdrs[..n as usize].iter().enumerate() {
lens[i] = h.msg_len as usize;
}
Ok(n as usize)
}
/// Batched receive for Apple/BSD targets, which have no `recvmmsg(2)`. Drains up to `out.len()`
/// datagrams per call with `libc::recv(MSG_DONTWAIT)` straight into the caller's reused `out[i]`
/// buffers — eliminating the per-packet 2 KB `vec!` allocation (and its zeroing + a copy) that
/// the scalar `recv` + trait-default `recv_batch` incur. THIS is the macOS-client throughput
/// fix: at line rate the alloc/free churn — not the syscall — was the single-core wall (Moonlight
/// batches; our client per-packet-allocated). It is still one syscall per datagram (a future
/// `recvmsg_x` batch would cut that too); `EAGAIN` ends the drain. Oversized datagrams set
/// `lens[i] == buf.len()` and the caller (`poll_frame`) drops them — same contract as `recvmmsg`.
#[cfg(all(unix, not(target_os = "linux")))]
fn recv_batch(&self, out: &mut [Vec<u8>], lens: &mut [usize]) -> std::io::Result<usize> {
// Apple: prefer the batched `recvmsg_x` syscall when enabled; a surprise error disables it
// and falls through to the always-correct scalar loop below.
#[cfg(target_vendor = "apple")]
if recvx::active() {
match self.recv_batch_x(out, lens) {
Ok(n) => return Ok(n),
Err(_) => recvx::disable(),
}
}
use std::os::fd::AsRawFd;
let fd = self.socket.as_raw_fd();
let n_bufs = out.len().min(lens.len());
let mut got = 0usize;
while got < n_bufs {
let buf = &mut out[got];
let r = unsafe {
libc::recv(
fd,
buf.as_mut_ptr() as *mut libc::c_void,
buf.len(),
libc::MSG_DONTWAIT,
)
};
if r < 0 {
let err = std::io::Error::last_os_error();
if err.kind() == std::io::ErrorKind::WouldBlock {
break; // socket drained
}
if got > 0 {
break; // report what we have; surface the error on the next empty poll
}
return Err(err);
}
lens[got] = r as usize;
got += 1;
}
Ok(got)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::transport::Transport;
/// `send_one_gso` must split one buffer into N separate UDP datagrams of `gso_size` bytes each
/// (the kernel UDP GSO segmentation) — the multi-Gbps send lever. Loopback supports GSO; if the
/// CI kernel doesn't, skip rather than fail.
#[cfg(target_os = "linux")]
#[test]
fn gso_segments_into_separate_datagrams() {
use std::os::fd::AsRawFd;
let rx = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
rx.set_read_timeout(Some(std::time::Duration::from_secs(2)))
.unwrap();
let rx_addr = rx.local_addr().unwrap();
let tx = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
tx.connect(rx_addr).unwrap();
let seg = 1000usize;
let segs = 5usize;
let mut buf = vec![0u8; seg * segs];
for i in 0..segs {
buf[i * seg..(i + 1) * seg].fill(i as u8 + 1);
}
if let Err(e) = send_one_gso(tx.as_raw_fd(), &buf, seg as u16) {
if gso_unsupported(&e) {
eprintln!("UDP GSO unsupported on this kernel — skipping");
return;
}
panic!("gso sendmsg failed: {e}");
}
// Each segment arrives as its own datagram, full size, content intact.
let mut rbuf = vec![0u8; 4096];
for i in 0..segs {
let n = rx.recv(&mut rbuf).expect("recv GSO segment");
assert_eq!(n, seg, "segment {i} should be a full {seg}-byte datagram");
assert!(
rbuf[..n].iter().all(|&b| b == i as u8 + 1),
"segment {i} content"
);
}
}
/// `send_batch` delivers a whole frame's worth of packets over real loopback UDP — exercising
/// the `sendmmsg` path on Linux (the scalar-loop default elsewhere). 100 × 200 B = 20 KB fits
/// the socket buffer, so loopback is lossless and every packet must arrive intact + in order.
#[test]
fn send_batch_delivers_over_loopback() {
let rx = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
rx.set_read_timeout(Some(std::time::Duration::from_millis(500)))
.unwrap();
let rx_addr = rx.local_addr().unwrap().to_string();
let tx = UdpTransport::connect("127.0.0.1:0", &rx_addr).unwrap();
const N: u32 = 100;
let payloads: Vec<Vec<u8>> = (0..N)
.map(|i| {
let mut v = vec![0u8; 200];
v[0..4].copy_from_slice(&i.to_le_bytes());
v
})
.collect();
let refs: Vec<&[u8]> = payloads.iter().map(|p| p.as_slice()).collect();
let sent = tx.send_batch(&refs).unwrap();
assert_eq!(
sent, N as usize,
"send_batch should hand all packets to the kernel"
);
let mut seen = std::collections::HashSet::new();
let mut buf = [0u8; 2048];
while seen.len() < N as usize {
match rx.recv(&mut buf) {
Ok(n) => {
assert_eq!(
n, 200,
"datagram boundaries preserved (one packet per recv)"
);
seen.insert(u32::from_le_bytes(buf[0..4].try_into().unwrap()));
}
Err(_) => break, // read timeout — stop and let the assert report the shortfall
}
}
assert_eq!(
seen.len(),
N as usize,
"every batched packet should arrive over loopback"
);
}
/// `recv_batch` drains many datagrams per call over real loopback UDP — exercising `recvmmsg`
/// on Linux (the scalar `recv` default elsewhere). Send 50 distinct packets, then drain in
/// batches and assert every one arrives intact with the right length.
#[test]
fn recv_batch_drains_over_loopback() {
// Receiver is the UdpTransport (the thing under test); sender is a raw socket bound to a
// known addr so the connected receiver accepts its datagrams.
let tx = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
let tx_addr = tx.local_addr().unwrap().to_string();
let rx = UdpTransport::connect("127.0.0.1:0", &tx_addr).unwrap();
let rx_addr = rx.local_addr().unwrap();
const N: u32 = 50;
for i in 0..N {
let mut p = vec![0u8; 300];
p[0..4].copy_from_slice(&i.to_le_bytes());
tx.send_to(&p, rx_addr).unwrap();
}
let mut bufs: Vec<Vec<u8>> = (0..16).map(|_| vec![0u8; RECV_BUF]).collect();
let mut lens = vec![0usize; 16];
let mut seen = std::collections::HashSet::new();
// A few drains absorb scheduling jitter; stop once all N are in or we go dry.
for _ in 0..50 {
let n = rx.recv_batch(&mut bufs, &mut lens).unwrap();
if n == 0 {
if seen.len() == N as usize {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
continue;
}
for i in 0..n {
assert_eq!(lens[i], 300, "recvmmsg reports the datagram length");
seen.insert(u32::from_le_bytes(bufs[i][0..4].try_into().unwrap()));
}
}
assert_eq!(
seen.len(),
N as usize,
"every datagram should be drained via recv_batch"
);
}
}