fix(core,host): make the native data plane survive real Wi-Fi links
Root-caused live on a phone at 100 Mbps (stream stuck seconds behind, then oscillating): a stack of transport defects, each amplifying the next. - MTU-safe shards: shard_payload 1452 overshot the IPv4/1500 budget (the old math forgot the 40 B header + 24 B crypto ride inside the UDP payload and counted IP+UDP as 8 B) — the kernel silently split EVERY video datagram into two IP fragments, doubling per-datagram loss on Wi-Fi. New config::mtu1500_shard_payload() = 1408 (1472 sealed = the exact ceiling), negotiated in the Welcome, pinned by a unit test. - Android batched I/O: recv/send batching was cfg(linux); Android is target_os="android" and silently fell back to a syscall per datagram. The libc crate binds neither recvmmsg/sendmmsg nor mmsghdr for Android, so a local bionic extern binding provides them (API 21+, floor is 28); cbindgen excludes them from the C header. The pump/runtime threads also get the Apple-QoS analogue on Android: nice −8 (below the decode thread's −10). - Latency-bounded receive: packets are consumed strictly in order at exactly the arrival rate, so a standing queue (Wi-Fi stall, power-save clumping) NEVER drains — observed as a stream permanently 6-7 s behind with both 32 MB socket buffers full. The pump now flushes the entire backlog (Session::flush_backlog: discard ring + kernel queue at memcpy speed, reset the reassembler) and requests a keyframe when frames keep completing > 400 ms behind the skew-corrected capture clock (30 consecutive, 2 s cooldown, logged). - Time-based loss window: the reassembler declared an incomplete frame lost a fixed 4 INDICES behind the newest — 33 ms at 120 fps, inside normal Wi-Fi retry/reorder timescales, so merely-late frames were pruned every few seconds, each costing a recovery-IDR burst + an inflated loss report. Now 120 ms of capture time (LOSS_WINDOW_NS), same fuse at every refresh rate, with a 64-index hard cap bounding memory against hostile pts. - Adaptive-FEC hysteresis: the controller was memoryless — one clean 750 ms report dropped FEC from 8 % straight back to the 1 % floor, so periodic burst loss (Wi-Fi scan / BT coexistence beats) always hit an unprotected stream and ping-ponged 1↔8 % with a frozen frame per cycle (observed in the host log as alternating loss_ppm=0/50000). Attack stays instant; decay is now one point per clean report. Verified: full core suite (incl. new flush + time-window tests) on macOS + Linux, host release build, arm64 cargo-ndk build, and a 30 s wired probe run at 2800x1260@120 — 3559/3559 frames, zero loss, capture→received p50 5.3 ms (host 5.1 + network 0.3). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
//! 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 `punktfunk1.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).
|
||||
//! ([`Transport::recv_batch`], ≤32/syscall into a reused ring) on Linux AND Android (which is
|
||||
//! `target_os = "android"`, not `"linux"` — it needs its own bionic binding, see [`android_mmsg`])
|
||||
//! — 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 `punktfunk1.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 and the remaining targets).
|
||||
|
||||
use super::Transport;
|
||||
use crate::packet::MAX_DATAGRAM_BYTES;
|
||||
@@ -57,16 +59,51 @@ fn is_transient_io(e: &std::io::Error) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// `sendmmsg`/`recvmmsg` + `mmsghdr` for Android, where the `libc` crate binds only the syscall
|
||||
/// number (`SYS_recvmmsg`) and neither the wrapper functions nor the struct — even though bionic
|
||||
/// has exported both since API 21 (below our API-28 floor), and Rust's `target_os = "android"` is
|
||||
/// NOT `"linux"`, so the batched paths below silently excluded Android and the client fell back to
|
||||
/// one syscall per datagram. The struct layout is stable kernel ABI (`struct mmsghdr` in
|
||||
/// `linux/socket.h`): a `msghdr` followed by the received byte count.
|
||||
#[cfg(target_os = "android")]
|
||||
mod android_mmsg {
|
||||
#[repr(C)]
|
||||
#[allow(non_camel_case_types)]
|
||||
pub struct mmsghdr {
|
||||
pub msg_hdr: libc::msghdr,
|
||||
pub msg_len: libc::c_uint,
|
||||
}
|
||||
extern "C" {
|
||||
pub fn sendmmsg(
|
||||
sockfd: libc::c_int,
|
||||
msgvec: *mut mmsghdr,
|
||||
vlen: libc::c_uint,
|
||||
flags: libc::c_int,
|
||||
) -> libc::c_int;
|
||||
pub fn recvmmsg(
|
||||
sockfd: libc::c_int,
|
||||
msgvec: *mut mmsghdr,
|
||||
vlen: libc::c_uint,
|
||||
flags: libc::c_int,
|
||||
timeout: *mut libc::timespec,
|
||||
) -> libc::c_int;
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "android")]
|
||||
use android_mmsg::{mmsghdr, recvmmsg, sendmmsg};
|
||||
#[cfg(target_os = "linux")]
|
||||
use libc::{mmsghdr, recvmmsg, sendmmsg};
|
||||
|
||||
/// 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> {
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
fn mmsghdrs(iovs: &mut [libc::iovec]) -> Vec<mmsghdr> {
|
||||
iovs.iter_mut()
|
||||
.map(|iov| {
|
||||
let mut h: libc::mmsghdr = unsafe { std::mem::zeroed() };
|
||||
let mut h: mmsghdr = unsafe { std::mem::zeroed() };
|
||||
h.msg_hdr.msg_iov = iov;
|
||||
h.msg_hdr.msg_iovlen = 1;
|
||||
h
|
||||
@@ -575,9 +612,9 @@ impl Transport for UdpTransport {
|
||||
/// 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")]
|
||||
/// frames + add latency. Ports the proven GameStream `sendmmsg_all`. Other targets fall back
|
||||
/// to the trait's scalar `send` loop (no `sendmmsg`).
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
fn send_batch(&self, packets: &[&[u8]]) -> std::io::Result<usize> {
|
||||
use std::os::fd::AsRawFd;
|
||||
const CHUNK: usize = 64;
|
||||
@@ -593,7 +630,7 @@ impl Transport for UdpTransport {
|
||||
})
|
||||
.collect();
|
||||
let mut hdrs = mmsghdrs(&mut iovs);
|
||||
let n = unsafe { libc::sendmmsg(fd, hdrs.as_mut_ptr(), hdrs.len() as libc::c_uint, 0) };
|
||||
let n = unsafe { 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 (or a stale ICMP from a connected-socket blip) —
|
||||
@@ -723,9 +760,9 @@ impl Transport for UdpTransport {
|
||||
/// 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")]
|
||||
/// `recv`'s oversized-drop. Android uses the local bionic binding (see [`android_mmsg`]).
|
||||
/// Apple/BSD use the `recv`-loop override below; other non-unix the trait's scalar default.
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
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();
|
||||
@@ -743,7 +780,7 @@ impl Transport for UdpTransport {
|
||||
.collect();
|
||||
let mut hdrs = mmsghdrs(&mut iovs);
|
||||
let n = unsafe {
|
||||
libc::recvmmsg(
|
||||
recvmmsg(
|
||||
fd,
|
||||
hdrs.as_mut_ptr(),
|
||||
n_bufs as libc::c_uint,
|
||||
@@ -772,7 +809,7 @@ impl Transport for UdpTransport {
|
||||
/// 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")))]
|
||||
#[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user