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 912d7de2e6
commit eea23c5647
9 changed files with 418 additions and 52 deletions
+39
View File
@@ -290,6 +290,45 @@ impl Session {
}
}
/// Client: discard the ENTIRE pending receive backlog — the current recv ring plus everything
/// queued in the kernel socket buffer — and reset the reassembler. Returns how many datagrams
/// were thrown away (counted into `packets_dropped`).
///
/// This is the latency-bound escape hatch: the receive path has no other way to skip ahead.
/// Packets arrive strictly in order, so once a standing queue forms (the pump transiently
/// slower than the wire, a Wi-Fi stall, power-save delivery clumping), the client plays that
/// far behind FOREVER — it consumes at exactly the arrival rate, so the backlog never shrinks
/// (observed live: a stream stuck 67 s behind, socket buffers full end to end). Discarding
/// is memcpy-speed (no decrypt/reassembly/allocation), so this empties even a 32 MB buffer in
/// milliseconds; the caller then requests a keyframe and the stream resumes live. The iteration
/// cap (4096 batches ≈ 128k datagrams ≈ 190 MB) only guards against a line-rate sender
/// outpacing the discard loop indefinitely.
pub fn flush_backlog(&mut self) -> Result<u64> {
if self.config.role != Role::Client {
return Err(PunktfunkError::InvalidArg(
"flush_backlog called on a host session",
));
}
// The undelivered tail of the current ring is backlog too.
let mut flushed = self.recv_count.saturating_sub(self.recv_idx) as u64;
self.recv_count = 0;
self.recv_idx = 0;
if !self.recv_scratch.is_empty() {
for _ in 0..4096 {
let n = self
.transport
.recv_batch(&mut self.recv_scratch, &mut self.recv_lens)?;
if n == 0 {
break;
}
flushed += n as u64;
}
}
self.reassembler.reset();
StatsCounters::add(&self.stats.packets_dropped, flushed);
Ok(flushed)
}
/// Client: serialize and send one input event to the host.
pub fn send_input(&mut self, event: &InputEvent) -> Result<()> {
if self.config.role != Role::Client {