fix(encode): harden loss-recovery correctness across host encoders (F1–F7)
Phases 1–4 of design/encoder-recovery-hardening.md — make the shipped RFI/ freeze-until-reanchor recovery honest and rebuild-safe across every backend. F1 — frame-index domain desync: the encode loop now owns a session-lifetime `au_seq`; `Encoder::submit_indexed(au_seq + inflight)` pins NVENC inputTimeStamp and AMF LTR slots to the WIRE frame index, so `invalidate_ref_frames` compares client frame numbers in the same domain and survives adaptive-bitrate rebuilds (an internal counter desynced on the first rebuild → RFI silently dead / an AMF force-ref onto a never-decoded frame). `FrameMsg.frame_index` → `Session::seal_frame_at`; GameStream gets the same via `VideoPacketizer:: packetize(.., Some(idx))`. F2 — Windows NVENC left the client frozen ~1s per loss: NVENC RFI was transparent (no anchor tag) while the session glue armed the 750ms IDR cooldown, so the freeze only lifted on the ~1s keyframe re-ask. NVENC now mirrors AMF — `pending_anchor` tags the first post-invalidate AU (the clean re-anchor P-frame) `recovery_anchor`, incl. the covering-range dedupe re-arm; the client lifts at ~RTT. F3 — speed-test probe filler burned video frame indexes: moved to its own index space (`Packetizer::alloc_probe_index` + `Session::submit_probe_frame`) with a second client reassembly window routed on FLAG_PROBE, gated on the new VIDEO_CAP_PROBE_SEQ Hello bit (mid-session probes declined for older clients). F4 — RFI range sanity cap: forward gaps wider than `packet::RFI_MAX_RANGE` (256) resync via keyframe instead of an out-of-range RFI, host- and client-side (client huge-gap → keyframe in `RfiRecovery::observe` + the pf-client-core pump). F5 — reset() parity: Windows NVENC (teardown + lazy re-init), Linux VAAPI (drop-inner), Linux NVENC (reopen from stored OpenArgs) now give the stall watchdog a heal lever instead of ending the session. F6 — sw.rs `pending: VecDeque` (was `Option`), killing the silent AU drop at capturer pipeline depth > 1. F7 — doc sweep on the RFI/anchor comments. Verified: punktfunk-core lib tests (macOS + Linux), full punktfunk-host suite on Linux (RTX 5070 Ti), Windows compile. Owed: the on-glass client matrix (F2 freeze A/B, AMF LTR spike across a bitrate rebuild). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -161,6 +161,31 @@ impl Session {
|
||||
data: &[u8],
|
||||
pts_ns: u64,
|
||||
user_flags: u32,
|
||||
) -> Result<Vec<Vec<u8>>> {
|
||||
self.seal_frame_inner(data, pts_ns, user_flags, None)
|
||||
}
|
||||
|
||||
/// [`seal_frame`](Self::seal_frame) with the caller's **explicit** `frame_index` instead of the
|
||||
/// packetizer's internal counter. The punktfunk/1 encode loop owns the video numbering (one
|
||||
/// session-lifetime counter, stamped per AU) so the encoder's reference-frame-invalidation
|
||||
/// bookkeeping stays 1:1 with the wire across encoder rebuilds/resets — see
|
||||
/// [`Packetizer::packetize_each`]. A session must use ONE numbering style per index space.
|
||||
pub fn seal_frame_at(
|
||||
&mut self,
|
||||
data: &[u8],
|
||||
pts_ns: u64,
|
||||
user_flags: u32,
|
||||
frame_index: u32,
|
||||
) -> Result<Vec<Vec<u8>>> {
|
||||
self.seal_frame_inner(data, pts_ns, user_flags, Some(frame_index))
|
||||
}
|
||||
|
||||
fn seal_frame_inner(
|
||||
&mut self,
|
||||
data: &[u8],
|
||||
pts_ns: u64,
|
||||
user_flags: u32,
|
||||
frame_index: Option<u32>,
|
||||
) -> Result<Vec<Vec<u8>>> {
|
||||
if self.config.role != Role::Host {
|
||||
return Err(PunktfunkError::InvalidArg(
|
||||
@@ -184,35 +209,36 @@ impl Session {
|
||||
} = self;
|
||||
let mut wires = std::mem::take(wire_pool);
|
||||
let mut used = 0usize;
|
||||
let result = packetizer.packetize_each(data, pts_ns, user_flags, coder.as_ref(), {
|
||||
let wires = &mut wires;
|
||||
let used = &mut used;
|
||||
move |hdr, body| {
|
||||
if *used == wires.len() {
|
||||
wires.push(Vec::new());
|
||||
}
|
||||
let wire = &mut wires[*used];
|
||||
*used += 1;
|
||||
let seq = *next_seq;
|
||||
*next_seq = next_seq.wrapping_add(1);
|
||||
wire.clear();
|
||||
match crypto {
|
||||
Some(c) => {
|
||||
// seq(8) ‖ header(40) ‖ shard ‖ tag scratch(16), sealed over [8..].
|
||||
wire.extend_from_slice(&seq.to_be_bytes());
|
||||
wire.extend_from_slice(hdr.as_bytes());
|
||||
wire.extend_from_slice(body);
|
||||
wire.resize(wire.len() + crate::crypto::TAG_LEN, 0);
|
||||
c.seal_in_place(seq, &mut wire[8..])?;
|
||||
let result =
|
||||
packetizer.packetize_each(data, pts_ns, user_flags, frame_index, coder.as_ref(), {
|
||||
let wires = &mut wires;
|
||||
let used = &mut used;
|
||||
move |hdr, body| {
|
||||
if *used == wires.len() {
|
||||
wires.push(Vec::new());
|
||||
}
|
||||
None => {
|
||||
wire.extend_from_slice(hdr.as_bytes());
|
||||
wire.extend_from_slice(body);
|
||||
let wire = &mut wires[*used];
|
||||
*used += 1;
|
||||
let seq = *next_seq;
|
||||
*next_seq = next_seq.wrapping_add(1);
|
||||
wire.clear();
|
||||
match crypto {
|
||||
Some(c) => {
|
||||
// seq(8) ‖ header(40) ‖ shard ‖ tag scratch(16), sealed over [8..].
|
||||
wire.extend_from_slice(&seq.to_be_bytes());
|
||||
wire.extend_from_slice(hdr.as_bytes());
|
||||
wire.extend_from_slice(body);
|
||||
wire.resize(wire.len() + crate::crypto::TAG_LEN, 0);
|
||||
c.seal_in_place(seq, &mut wire[8..])?;
|
||||
}
|
||||
None => {
|
||||
wire.extend_from_slice(hdr.as_bytes());
|
||||
wire.extend_from_slice(body);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
});
|
||||
result?;
|
||||
// A smaller frame uses fewer buffers than the pool holds: drop the unused tail, same
|
||||
// as the previous `resize_with(packets.len(), ..)` did.
|
||||
@@ -258,6 +284,23 @@ impl Session {
|
||||
r.map(|_| ())
|
||||
}
|
||||
|
||||
/// Host: seal + send one **speed-test probe filler** access unit in the probe index space
|
||||
/// (its own frame counter + the [`crate::packet::FLAG_PROBE`] user-flag) so a burst never
|
||||
/// consumes video `frame_index`es — the client reassembles probe frames in a separate window
|
||||
/// and its gap detectors never see them. Only call this against a client that advertised
|
||||
/// [`crate::quic::VIDEO_CAP_PROBE_SEQ`]; an older client's single-window reassembler would
|
||||
/// drop probe-space indexes as stale against the video stream.
|
||||
pub fn submit_probe_frame(&mut self, data: &[u8], pts_ns: u64) -> Result<()> {
|
||||
let idx = self.packetizer.alloc_probe_index();
|
||||
let wires =
|
||||
self.seal_frame_inner(data, pts_ns, crate::packet::FLAG_PROBE as u32, Some(idx))?;
|
||||
let refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect();
|
||||
let r = self.send_sealed(&refs);
|
||||
drop(refs);
|
||||
self.reclaim_wires(wires);
|
||||
r.map(|_| ())
|
||||
}
|
||||
|
||||
/// Host: live-adjust the FEC recovery percentage (adaptive FEC). Affects the next
|
||||
/// [`submit_frame`](Self::submit_frame)/[`seal_frame`](Self::seal_frame); the receiver needs no
|
||||
/// notification (each packet's header carries its block's data/recovery shard counts).
|
||||
|
||||
Reference in New Issue
Block a user