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:
2026-07-07 07:34:24 +02:00
parent b8df6c28b8
commit e490564316
9 changed files with 418 additions and 52 deletions
+53 -16
View File
@@ -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.