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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user