fix(core/net): bind the data plane to the authenticated peer + stop adaptive FEC wedging large frames

Four defects from the punktfunk-core quality sweep, all in the data plane.

transport/udp: the hole-punch adopted the source address of ANY datagram whose
first 8 bytes matched PUNCH_MAGIC — a fixed public constant with no key, nonce
or session id — and the authenticated QUIC peer was passed only as the
no-punch fallback, so it was never used to validate. Hole-punch is the default
bring-up path (it is skipped only for a fixed --data-port), and the data socket
is an OS ephemeral, so spraying the ephemeral range during the 2500 ms punch
wait let anyone steal the video plane: the legitimate client is then filtered
out by the connect() and gets nothing, while QUIC stays healthy so no reconnect
fires. With a spoofed source the same 8 bytes aim a full-rate stream at a third
party. Take the authenticated peer IP and require the punch to match it — only
the PORT is in question (that is what a NAT remaps and what the punch exists to
discover); the client dials the same host IP as its QUIC connection, so a NAT
presents one source IP for both planes. Also budget each read from the REMAINING
window, so off-peer datagrams cannot stretch the wait past punch_timeout.

transport/udp: the punch keepalive treated every send error as fatal and broke
out of its loop permanently and silently. It holds a clone of the connected,
non-blocking data socket — exactly the socket whose transient conditions this
module defines and documents in is_transient_io. One ENOBUFS from a full wlan tx
queue or an ENETUNREACH during an AP roam killed the only thing holding the NAT
mapping open; the path recovers, video keeps flowing, and the stream dies later
when the idle timer expires the mapping during a static scene. Route it through
is_transient_io like every other send site in the file.

packet: adaptive FEC moved fec_percent live (host bands it 1..=50 while Welcome
advertises 10) but the receiver's per-block acceptance ceiling was computed once
from the negotiated percentage and never re-derived. Once FEC ramped, every
packet of a maximal block failed `total > max_total_shards`, the block never
accumulated a shard, the frame aged out, and the resulting loss drove FEC higher
still — large frames wedged at 100% loss exactly when FEC was meant to rescue
the link. Fixed on both sides, because hosts and clients update independently:
the sender clamps per-block parity to the ceiling the peer negotiated, and the
receiver sizes that ceiling from the whole clamp range rather than a stale
snapshot of it.

No wire bytes and no C ABI signature change; WIRE_VERSION and ABI_VERSION are
unchanged. Regression tests cover all three (the punch tests were confirmed to
fail without the fix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 01:09:20 +02:00
parent 15d51bc0ff
commit 7b2cdf5a7a
5 changed files with 234 additions and 14 deletions
+25 -4
View File
@@ -39,10 +39,19 @@ pub struct Packetizer {
/// DATA shards before any block's parity — all blocks' parity must stay alive until the
/// frame's second emission pass.
recovery: Vec<Vec<Vec<u8>>>,
/// The peer's per-block `data + recovery` acceptance ceiling, frozen from the **negotiated**
/// config exactly as the far side derives it in [`ReassemblerLimits::from_config`]. Adaptive
/// FEC moves `fec.fec_percent` live ([`set_fec_percent`](Self::set_fec_percent)) but the
/// receiver's ceiling is computed once at session construction and never re-derived, so parity
/// must be clamped against this or a raised percentage puts blocks over the far side's bound —
/// where every packet of the block is dropped wholesale, the frame never completes, and the
/// resulting loss pushes adaptive FEC *higher*. See the `recovery_for` clamp in `packetize_each`.
max_total_shards: usize,
}
impl Packetizer {
pub fn new(config: &Config) -> Self {
let max_data = config.fec.max_data_per_block as usize;
Packetizer {
next_frame_index: 0,
next_probe_index: 0,
@@ -52,6 +61,9 @@ impl Packetizer {
version: config.phase as u8,
tail: Vec::new(),
recovery: Vec::new(),
// Mirrors `ReassemblerLimits::from_config` — keep the two in step.
max_total_shards: (max_data + config.fec.recovery_for(max_data))
.min(config.fec.scheme.max_total_shards()),
}
}
@@ -173,6 +185,15 @@ impl Packetizer {
};
// Per-block shard geometry (deterministic — recomputed in both passes).
let block_data_count = |b: usize| ((b + 1) * max_block).min(total_data) - b * max_block;
// Parity for a `k`-shard block: the configured percentage, clamped so the block's wire
// total never exceeds what the peer will accept (see `max_total_shards`). The clamp only
// binds on blocks near `max_data_per_block`; smaller blocks keep the full adaptive range,
// so raising FEC still buys real protection wherever there is headroom. Bound as locals,
// not as a `&self` method: `emit_one` below would otherwise capture all of `self` and
// collide with the `&mut self.recovery[b]` parity borrow.
let (fec, max_total_shards) = (self.fec, self.max_total_shards);
let recovery_for =
move |k: usize| fec.recovery_for(k).min(max_total_shards.saturating_sub(k));
// One parity pool per block, reused across frames (steady-state zero-alloc).
if self.recovery.len() < block_count {
@@ -183,7 +204,7 @@ impl Packetizer {
let mut total_recovery = 0usize;
for b in 0..block_count {
let k = block_data_count(b);
let m = self.fec.recovery_for(k);
let m = recovery_for(k);
if k + m > u16::MAX as usize {
return Err(PunktfunkError::Unsupported("block shard count exceeds u16"));
}
@@ -204,7 +225,7 @@ impl Packetizer {
block_index: b as u16,
block_count: block_count as u16,
data_shards: k as u16,
recovery_shards: self.fec.recovery_for(k) as u16,
recovery_shards: recovery_for(k) as u16,
shard_index: shard_index as u16,
shard_bytes: payload as u16,
magic: PUNKTFUNK_MAGIC,
@@ -223,7 +244,7 @@ impl Packetizer {
// This block's data shards: references into `frame` (plus the staged tail).
let data_shards: Vec<&[u8]> = (first..first + k).map(shard_at).collect();
let recovery_count = self.fec.recovery_for(k);
let recovery_count = recovery_for(k);
coder.encode_into(&data_shards, recovery_count, &mut self.recovery[b])?;
for (shard_index, body) in data_shards.iter().enumerate() {
@@ -242,7 +263,7 @@ impl Packetizer {
let mut parity_left = total_recovery;
for b in 0..block_count {
let k = block_data_count(b);
let recovery_count = self.fec.recovery_for(k);
let recovery_count = recovery_for(k);
for r in 0..recovery_count {
parity_left -= 1;
let mut flags = FLAG_PIC;
+12 -1
View File
@@ -106,8 +106,19 @@ pub struct ReassemblerLimits {
impl ReassemblerLimits {
pub fn from_config(c: &Config) -> Self {
let max_data = c.fec.max_data_per_block as usize;
// Size the ceiling from the whole range adaptive FEC may reach, NOT from the percentage
// negotiated at session start: the sender moves `fec_percent` live (`Packetizer::
// set_fec_percent`, clamped to ≤ 90) and the wire is self-describing, so it never
// renegotiates. Deriving this from the start value made every packet of a large block
// fail the `total > max_total_shards` check once FEC ramped up — the block never
// accumulated a shard, the frame aged out, and the resulting loss drove FEC *higher*,
// wedging large frames at 100% loss exactly when FEC was meant to rescue the link. A
// current sender also clamps its side (`Packetizer::recovery_for`); this keeps an
// already-deployed sender that doesn't from wedging a current receiver. Still a hard
// pre-allocation bound against hostile headers — just the sender's clamp, not a stale
// snapshot of it.
let max_total =
(max_data + c.fec.recovery_for(max_data)).min(c.fec.scheme.max_total_shards());
(max_data + (max_data * 90).div_ceil(100)).min(c.fec.scheme.max_total_shards());
let total_data = c.max_frame_bytes.div_ceil(c.shard_payload.max(1)).max(1);
ReassemblerLimits {
shard_bytes: c.shard_payload,
+61
View File
@@ -579,3 +579,64 @@ fn rejects_wrong_shard_bytes_and_oversized_frame() {
.is_none());
assert_eq!(stats.snapshot().packets_dropped, 1);
}
/// Adaptive FEC raises `fec_percent` mid-session while the receiver's per-block acceptance
/// ceiling is frozen at session construction and never renegotiated. A maximal block must
/// therefore still land: the sender clamps its parity to the ceiling
/// (`Packetizer::recovery_for`), and the receiver sizes that ceiling from the whole clamp range
/// rather than the start percentage. Regression guard for the wedge this caused — every packet
/// of a large block failing `total > max_total_shards`, so the frame never completed and the
/// resulting loss drove adaptive FEC higher still.
#[test]
fn adaptive_fec_ramp_keeps_maximal_blocks_within_the_peers_ceiling() {
let cfg = e2e_config(FecScheme::Gf16, 10);
let coder = coder_for(FecScheme::Gf16);
let lim = ReassemblerLimits::from_config(&cfg);
let mut pk = Packetizer::new(&cfg);
// Ramp far past the negotiated 10% — exactly what `apply_fec_target` does under loss.
pk.set_fec_percent(50);
// A frame of full `max_data_per_block` blocks: where the ceiling actually binds.
let frame_len = cfg.shard_payload * cfg.fec.max_data_per_block as usize * 2;
let src: Vec<u8> = (0..frame_len).map(|i| (i * 131 + 7) as u8).collect();
let pkts = pk.packetize(&src, 1, 0, coder.as_ref()).unwrap();
let k = cfg.fec.max_data_per_block as usize;
let mut clamped = false;
for p in &pkts {
let hdr = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
let total = hdr.data_shards as usize + hdr.recovery_shards as usize;
assert!(
total <= lim.max_total_shards,
"block total {total} exceeds the peer's ceiling {} — every packet of this block \
would be dropped",
lim.max_total_shards
);
// The unclamped 50% would put 2 parity on a full block; the negotiated 10% ceiling
// leaves room for 1. Proves the clamp actually bound rather than passing vacuously.
if hdr.data_shards as usize == k {
assert!(
(hdr.recovery_shards as usize) < cfg.fec.recovery_for(k).max(1) + 1,
"parity must be clamped to the peer's ceiling"
);
clamped = true;
}
}
assert!(clamped, "test must exercise a maximal block");
// And the frame still reassembles byte-identically.
let mut r = Reassembler::new(lim);
let stats = StatsCounters::default();
let mut got = None;
for p in &pkts {
if let Some(f) = r.push(p, coder.as_ref(), &stats).unwrap() {
got = Some(f);
}
}
assert_eq!(
got.expect("frame must complete after an adaptive-FEC ramp")
.data,
src
);
}