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:
2026-07-12 11:17:19 +02:00
parent 0dc414f197
commit fdda7144ed
15 changed files with 801 additions and 152 deletions
@@ -149,6 +149,22 @@ fn nvenc_input(format: PixelFormat) -> (Pixel, bool) {
}
}
/// The [`NvencEncoder::open`] arguments, kept on the encoder so [`Encoder::reset`] can rebuild it
/// in place with the session's negotiated parameters — the encode-stall watchdog's recovery lever
/// (drop the wedged libavcodec encoder, reopen fresh, forfeit the owed AUs, restart at an IDR).
#[derive(Clone, Copy)]
struct OpenArgs {
codec: Codec,
format: PixelFormat,
width: u32,
height: u32,
fps: u32,
bitrate_bps: u64,
cuda: bool,
bit_depth: u8,
chroma: ChromaFormat,
}
pub struct NvencEncoder {
enc: encoder::video::Encoder,
/// Reusable 4-bpp CPU input frame (CPU path only; `None` for the zero-copy/CUDA path).
@@ -181,6 +197,8 @@ pub struct NvencEncoder {
/// open so the pump's per-AU `caps()` doesn't re-read `PUNKTFUNK_IR_PERIOD_FRAMES`; the pump marks
/// every Nth AU with `USER_FLAG_RECOVERY_POINT` for the client's clean re-anchor.
intra_refresh_period: u32,
/// The open arguments, for the in-place [`reset`](Encoder::reset) rebuild.
args: OpenArgs,
}
// `CudaHw` holds raw `AVBufferRef`s and `sws_444` a raw `SwsContext`; the encoder lives on a single
@@ -534,6 +552,17 @@ impl NvencEncoder {
} else {
0
},
args: OpenArgs {
codec,
format,
width,
height,
fps,
bitrate_bps,
cuda,
bit_depth,
chroma,
},
})
}
}
@@ -582,6 +611,35 @@ impl Encoder for NvencEncoder {
self.force_kf = true;
}
/// Encode-stall recovery: drop the wedged libavcodec encoder and reopen it fresh with the
/// session's negotiated parameters (the stored [`OpenArgs`]) — the drop-and-reopen lever the
/// QSV/VAAPI paths use, so the encode-stall watchdog can heal a wedged NVENC/driver instead of
/// ending the session. Owed AUs are forfeited; the fresh encoder opens on an IDR.
fn reset(&mut self) -> bool {
let a = self.args;
match Self::open(
a.codec,
a.format,
a.width,
a.height,
a.fps,
a.bitrate_bps,
a.cuda,
a.bit_depth,
a.chroma,
) {
Ok(mut fresh) => {
fresh.force_kf = true;
*self = fresh; // drops the wedged encoder (frees its contexts) in the same step
true
}
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "NVENC in-place reopen failed");
false
}
}
}
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
let mut pkt = Packet::empty();
match self.enc.receive_packet(&mut pkt) {
@@ -1126,6 +1126,19 @@ impl Encoder for VaapiEncoder {
self.force_kf = true;
}
/// Encode-stall recovery: drop the wedged libavcodec encoder (its `Drop` releases the VA
/// surfaces/filter graph/devices) and let the next `submit` rebuild it lazily from the first
/// frame's payload, exactly like first-frame bring-up — the same drop-and-reopen lever the
/// Windows QSV path has. The owed AUs are forfeited (`in_flight` zeroed) and the rebuilt
/// encoder's first frame is forced IDR so the client resyncs immediately. Without this the
/// encode-stall watchdog had no lever on Linux AMD/Intel and a wedged driver ended the session.
fn reset(&mut self) -> bool {
self.inner = None;
self.in_flight = 0;
self.force_kf = true;
true
}
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
// With `async_depth > 1`, `submit` no longer waits for the ASIC — the AU for the frame we
// just sent lands ~one hardware-encode-time later. Wait for it (bounded) so it still ships