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:
@@ -112,6 +112,48 @@ fn lossless_stream_is_exact() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The client's latency-bound escape hatch: `flush_backlog` must discard every queued datagram
|
||||
/// (counting them dropped), reset the reassembler so half-assembled frames from the flushed past
|
||||
/// can't linger, and leave the session healthy — the next submitted frame recovers byte-exact.
|
||||
#[test]
|
||||
fn flush_backlog_discards_queue_and_recovers() {
|
||||
let (host_tp, client_tp) = loopback_pair(0, 0);
|
||||
let mut host = Session::new(
|
||||
config(Role::Host, FecScheme::Gf16, false, 0),
|
||||
Box::new(host_tp),
|
||||
)
|
||||
.unwrap();
|
||||
let mut client = Session::new(
|
||||
config(Role::Client, FecScheme::Gf16, false, 0),
|
||||
Box::new(client_tp),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let frames = sample_frames();
|
||||
// Read one frame first so the client's recv ring exists and may hold an undelivered tail.
|
||||
host.submit_frame(&frames[0], 0, 0).unwrap();
|
||||
client.poll_frame().unwrap();
|
||||
// Queue a multi-frame backlog, then flush it: everything pending is discarded.
|
||||
for (i, f) in frames.iter().enumerate().skip(1) {
|
||||
host.submit_frame(f, i as u64 * 1_000_000, 0).unwrap();
|
||||
}
|
||||
let flushed = client.flush_backlog().unwrap();
|
||||
assert!(flushed > 0, "a queued backlog must be discarded");
|
||||
assert_eq!(client.stats().packets_dropped, flushed);
|
||||
assert!(
|
||||
matches!(
|
||||
client.poll_frame(),
|
||||
Err(punktfunk_core::PunktfunkError::NoFrame)
|
||||
),
|
||||
"nothing pending after a flush"
|
||||
);
|
||||
// The stream resumes cleanly: the next frame (the "recovery keyframe") completes byte-exact.
|
||||
let recovery = vec![0xA5u8; 100_000];
|
||||
host.submit_frame(&recovery, 99_000_000, 0).unwrap();
|
||||
let got = client.poll_frame().expect("post-flush frame completes");
|
||||
assert_eq!(got.data, recovery);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_round_trips_client_to_host() {
|
||||
let (host_tp, client_tp) = loopback_pair(0, 0);
|
||||
|
||||
Reference in New Issue
Block a user