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:
@@ -26,7 +26,9 @@
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use punktfunk_core::config::{CompositorPref, FecConfig, FecScheme, GamepadPref, Role};
|
||||
use punktfunk_core::config::{
|
||||
mtu1500_shard_payload, CompositorPref, FecConfig, FecScheme, GamepadPref, Role,
|
||||
};
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use punktfunk_core::packet::{FLAG_PIC, FLAG_PROBE, FLAG_SOF};
|
||||
use punktfunk_core::quic::{
|
||||
@@ -969,11 +971,14 @@ async fn serve_session(
|
||||
fec_percent: fec_static_override().unwrap_or(FEC_ADAPTIVE_START),
|
||||
max_data_per_block: 4096,
|
||||
},
|
||||
// ~1452-byte payload keeps the IP datagram within a 1500 MTU (1452 + 40 header + 24
|
||||
// crypto + 8 IP/UDP ≈ 1500), vs the old 1200 — ~17% fewer packets for free, and an even
|
||||
// size (FEC requires even shards). Negotiated, so the client follows. Jumbo (≈8900) is a
|
||||
// future negotiated bump (needs MAX_DATAGRAM_BYTES raised + end-to-end 9000 MTU).
|
||||
shard_payload: 1452,
|
||||
// The largest even payload whose sealed datagram (header + shard + crypto) fits an
|
||||
// unfragmented IPv4/UDP packet on a 1500 MTU — 1408, giving 1472 = the exact ceiling.
|
||||
// The previous 1452 overshot it (its math forgot the header/crypto ride inside the UDP
|
||||
// payload) and silently IP-fragmented EVERY video datagram, doubling per-datagram loss
|
||||
// on Wi-Fi — the "100 Mbps badly fails on the phone" root cause. Negotiated, so the
|
||||
// client follows. Jumbo (≈8900) is a future negotiated bump (needs MAX_DATAGRAM_BYTES
|
||||
// raised + end-to-end 9000 MTU).
|
||||
shard_payload: mtu1500_shard_payload() as u16,
|
||||
encrypt: true,
|
||||
key,
|
||||
salt: *b"pkf1",
|
||||
@@ -1092,8 +1097,18 @@ async fn serve_session(
|
||||
// send loop reads `fec_target_ctl` and applies it per frame. Ignored when FEC
|
||||
// is pinned via PUNKTFUNK_FEC_PCT.
|
||||
if adaptive_fec {
|
||||
let target = adapt_fec(rep.loss_ppm);
|
||||
let prev = fec_target_ctl.swap(target, Ordering::Relaxed);
|
||||
// Fast attack, slow decay: jump straight to what the reported loss
|
||||
// needs, but come DOWN only one point per clean report (~750 ms). The
|
||||
// memoryless controller ping-ponged on periodic burst loss (Wi-Fi
|
||||
// scans / BT coexistence, a burst every few seconds): a single clean
|
||||
// window dropped FEC back to the floor, so every next burst hit an
|
||||
// unprotected stream — an unrecoverable frame, a freeze, and a
|
||||
// recovery-IDR burst, once per cycle. Decaying over ~10 windows keeps
|
||||
// the stream covered across the gap while still converging to FEC_MIN
|
||||
// on a genuinely clean link.
|
||||
let prev = fec_target_ctl.load(Ordering::Relaxed);
|
||||
let target = adapt_fec(rep.loss_ppm).max(prev.saturating_sub(1));
|
||||
fec_target_ctl.store(target, Ordering::Relaxed);
|
||||
if prev != target {
|
||||
tracing::info!(
|
||||
loss_ppm = rep.loss_ppm,
|
||||
|
||||
Reference in New Issue
Block a user