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
+96 -10
View File
@@ -1540,6 +1540,10 @@ async fn serve_session(
// and gets no extra datagrams.
let timing_conn =
(hello.video_caps & punktfunk_core::quic::VIDEO_CAP_HOST_TIMING != 0).then(|| conn.clone());
// Probe-sequence capability: the client reassembles speed-test filler in its own index window,
// so mid-session bursts don't consume video frame indexes. An older client (bit clear) gets
// mid-session probes declined instead — see `run_probe_burst`.
let probe_seq = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ != 0;
let stats_dp = stats; // data-plane handle to the shared stats recorder
// Short label for web-console stats captures: the client's cert-fingerprint prefix, else its
// peer IP (no fingerprint = anonymous TOFU/--open client).
@@ -1595,6 +1599,7 @@ async fn serve_session(
&probe_result_tx,
&fec_target_dp,
timing_conn.as_ref(),
probe_seq,
),
Punktfunk1Source::Virtual => {
let compositor = compositor
@@ -1620,6 +1625,7 @@ async fn serve_session(
fec_target: fec_target_dp,
conn: conn_stream,
timing_conn,
probe_seq,
stats: stats_dp,
client_label,
launch: launch_for_dp,
@@ -2437,6 +2443,7 @@ fn mark_recovery_boundary(ir_wave_pos: &mut u32, is_keyframe: bool, period: u32)
}
}
#[allow(clippy::too_many_arguments)]
fn synthetic_stream(
session: &mut Session,
frames: u32,
@@ -2445,6 +2452,7 @@ fn synthetic_stream(
probe_result_tx: &tokio::sync::mpsc::UnboundedSender<ProbeResult>,
fec_target: &AtomicU8,
timing_conn: Option<&quinn::Connection>,
probe_seq: bool,
) -> Result<()> {
let interval = std::time::Duration::from_millis(1000 / 60);
for idx in 0..frames {
@@ -2453,7 +2461,7 @@ fn synthetic_stream(
}
apply_fec_target(session, fec_target);
// Service speed-test probes between synthetic frames (loopback bandwidth tests).
service_probes(session, stop, probe_rx, probe_result_tx);
service_probes(session, stop, probe_rx, probe_result_tx, probe_seq);
let data = test_frame(idx, 64 * 1024);
let pts_ns = now_ns();
session
@@ -2795,9 +2803,34 @@ const MAX_PROBE_MS: u32 = 5_000;
/// was actually offered so the client can compute delivery ratio (`received / bytes_sent`) and
/// throughput. Video is paused for the duration (the caller's loop is blocked here) — a speed test
/// is a deliberate, short interruption the client initiates.
fn run_probe_burst(session: &mut Session, req: ProbeRequest, stop: &AtomicBool) -> ProbeResult {
fn run_probe_burst(
session: &mut Session,
req: ProbeRequest,
stop: &AtomicBool,
probe_seq: bool,
) -> ProbeResult {
let target_kbps = req.target_kbps.min(MAX_PROBE_KBPS);
let duration_ms = req.duration_ms.min(MAX_PROBE_MS);
// Probe filler is sealed in the PROBE index space (its own frame counter — video indexes are
// owned by the encode loop and must stay 1:1 with the encoder's RFI bookkeeping). A client
// that didn't advertise VIDEO_CAP_PROBE_SEQ reassembles everything in one window and would
// drop probe-space frames as stale against the video stream — measuring garbage — so its
// mid-session probe is DECLINED (zeroed result) instead. Old sealing (probe filler consuming
// video indexes) is not an option anymore: those indexes are invisible to every client gap
// detector and read as a phantom multi-thousand-frame loss after the burst.
if !probe_seq {
tracing::info!(
"declining speed-test probe: client predates VIDEO_CAP_PROBE_SEQ (its reassembler \
cannot window probe-space frames)"
);
return ProbeResult {
bytes_sent: 0,
packets_sent: 0,
duration_ms: 0,
wire_packets_sent: 0,
send_dropped: 0,
};
}
if target_kbps == 0 || duration_ms == 0 {
return ProbeResult {
bytes_sent: 0,
@@ -2831,8 +2864,9 @@ fn run_probe_burst(session: &mut Session, req: ProbeRequest, stop: &AtomicBool)
let allowed = (start.elapsed().as_secs_f64() * bytes_per_sec as f64) as u64;
if bytes_sent < allowed {
// A full send buffer drops on WouldBlock/ENOBUFS (UdpTransport returns Ok) — that loss is
// part of what the probe measures (it surfaces as send_dropped), so keep going.
let _ = session.submit_frame(&filler, now_ns(), FLAG_PROBE as u32);
// part of what the probe measures (it surfaces as send_dropped), so keep going. Sealed
// in the probe index space (FLAG_PROBE + its own counter) — never a video frame_index.
let _ = session.submit_probe_frame(&filler, now_ns());
bytes_sent += chunk as u64;
packets_sent += 1;
} else {
@@ -2863,15 +2897,17 @@ fn run_probe_burst(session: &mut Session, req: ProbeRequest, stop: &AtomicBool)
}
/// Drain any pending speed-test requests and run each burst, replying with its [`ProbeResult`].
/// Called once per data-plane loop iteration so a probe runs between frames.
/// Called once per data-plane loop iteration so a probe runs between frames. `probe_seq` = the
/// client advertised [`punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ`] (see [`run_probe_burst`]).
fn service_probes(
session: &mut Session,
stop: &AtomicBool,
probe_rx: &std::sync::mpsc::Receiver<ProbeRequest>,
probe_result_tx: &tokio::sync::mpsc::UnboundedSender<ProbeResult>,
probe_seq: bool,
) {
while let Ok(req) = probe_rx.try_recv() {
let result = run_probe_burst(session, req, stop);
let result = run_probe_burst(session, req, stop, probe_seq);
let _ = probe_result_tx.send(result);
}
}
@@ -2886,16 +2922,18 @@ fn service_probes(
/// buffer → EAGAIN drop → under infinite GOP, a freeze until the next keyframe). With no slack
/// (encode ≈ interval) the budget collapses to 0 and even the overflow goes out immediately, so
/// this is never slower than unpaced.
#[allow(clippy::too_many_arguments)]
fn paced_submit(
session: &mut Session,
data: &[u8],
pts_ns: u64,
flags: u32,
frame_index: u32,
deadline: std::time::Instant,
burst_cap: usize,
) -> Result<PaceStat> {
let wires = session
.seal_frame(data, pts_ns, flags)
.seal_frame_at(data, pts_ns, flags, frame_index)
.map_err(|e| anyhow!("seal_frame: {e:?}"))?;
let mut refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect();
// FEC/recovery test knob (PUNKTFUNK_VIDEO_DROP) — same knob the GameStream plane honors.
@@ -2925,6 +2963,12 @@ struct FrameMsg {
data: Vec<u8>,
capture_ns: u64,
flags: u32,
/// The wire `frame_index` this AU is sealed with. Assigned by the encode loop's
/// session-lifetime counter (`au_seq`) — the loop owns the video numbering so the index it
/// PREDICTED at submit time (`au_seq + inflight`, handed to `Encoder::submit_indexed`) is
/// exactly what the packetizer stamps, keeping the encoder's RFI bookkeeping 1:1 with the
/// wire across encoder rebuilds/resets. Sealed via `Session::seal_frame_at`.
frame_index: u32,
/// When this frame's packets should have fully left (the next frame's due time) = the pacing
/// budget. In the past when the send thread is behind → immediate send (catch up).
deadline: std::time::Instant,
@@ -3117,6 +3161,9 @@ fn send_loop(
// `Some` = the client advertised VIDEO_CAP_HOST_TIMING: emit one 0xCF datagram per AU right
// after its last packet left the socket (capture→sent, the whole host pipeline incl. pacing).
timing_conn: Option<quinn::Connection>,
// The client advertised VIDEO_CAP_PROBE_SEQ — mid-session speed-test bursts may run in the
// probe index space (else they're declined; see `run_probe_burst`).
probe_seq: bool,
) {
boost_thread_priority(false); // transmit thread: above-normal (Apollo's encoder-thread level)
let mut last_perf = std::time::Instant::now();
@@ -3145,7 +3192,7 @@ fn send_loop(
}
// Probes run here (they need the Session); a burst pauses video — the encode thread blocks
// on the full frame channel meanwhile, which is exactly the intended pause.
service_probes(&mut session, &stop, &probe_rx, &probe_result_tx);
service_probes(&mut session, &stop, &probe_rx, &probe_result_tx, probe_seq);
// Adaptive FEC: pick up any new recovery target the control task set from client LossReports.
apply_fec_target(&mut session, &fec_target);
// Short timeout so we keep re-checking `stop` + probes when no frames are flowing.
@@ -3155,6 +3202,7 @@ fn send_loop(
&msg.data,
msg.capture_ns,
msg.flags,
msg.frame_index,
msg.deadline,
burst_cap,
) {
@@ -3472,6 +3520,12 @@ struct SessionContext {
/// thread emits one 0xCF datagram per AU (capture→sent µs) on it, so the client can split its
/// `host+network` latency stage. `None` = older client, no emission.
timing_conn: Option<quinn::Connection>,
/// The client advertised [`punktfunk_core::quic::VIDEO_CAP_PROBE_SEQ`]: speed-test bursts may
/// run mid-session in the probe index space (its reassembler keeps a separate probe window).
/// `false` = older client whose single-window reassembler would drop probe-space frames as
/// stale — mid-session probes are DECLINED for it (a zeroed [`ProbeResult`]) rather than
/// consuming video frame indexes its gap detectors can't see (the phantom-gap freeze).
probe_seq: bool,
/// Shared streaming-stats recorder. The capture loop reads `is_armed()` per frame to decide
/// whether to measure the per-stage split; the send thread builds + pushes the aggregated
/// `StatsSample` at its 2 s boundary.
@@ -3527,6 +3581,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
fec_target,
conn,
timing_conn,
probe_seq,
stats,
client_label,
launch,
@@ -3664,6 +3719,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
fec_target,
send_stats,
timing_conn,
probe_seq,
)
}
})
@@ -3689,6 +3745,16 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(seconds as u64);
let mut next = std::time::Instant::now();
let mut sent: u64 = 0;
// The session's video frame numbering, owned HERE (the wire `frame_index` of the next AU this
// loop hands to the send thread; the packetizer seals with exactly this via `seal_frame_at`).
// A submission's future index is predicted as `au_seq + inflight.len()` — exact because AUs
// are emitted FIFO, one per submission, and every event that forfeits in-flight frames
// (reset/rebuild/teardown) clears `inflight` AND the encoder's reference state, so the reused
// predictions can never meet stale bookkeeping. Passing it to `Encoder::submit_indexed` keeps
// the RFI backends' frame numbers 1:1 with the client's across encoder rebuilds — an
// encoder-internal counter desyncs on the first adaptive-bitrate rebuild (NVENC RFI then
// silently dies; AMF may anchor onto a post-loss LTR).
let mut au_seq: u32 = 0;
// Rebuild-in-place on capture loss: track the live mode (a mode switch updates it) so a rebuild
// targets the CURRENT mode, and cap consecutive rebuilds so a flapping source can't loop the
// client through endless cold restarts.
@@ -3967,7 +4033,19 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
}
if !want_kf {
if let Some((first, last)) = rfi_range {
if enc.caps().supports_rfi && enc.invalidate_ref_frames(first as i64, last as i64) {
// Sanity-cap the range before consulting the encoder: RFI can only re-reference
// history the encoder still holds (NVENC: a 5-frame DPB; AMD LTR: ~1 s of marks).
// A range wider than RFI_MAX_RANGE is either a seconds-long outage (no valid
// reference anywhere) or a phantom jump from a desynced counter — both belong on
// the keyframe path, never a force-reference that could ship corruption as a
// recovery anchor. Wrapping width: frame indexes are u32 counters.
let width = last.wrapping_sub(first);
if width > punktfunk_core::packet::RFI_MAX_RANGE {
tracing::debug!(first, last, width, "RFI range too wide — keyframe instead");
want_kf = true;
} else if enc.caps().supports_rfi
&& enc.invalidate_ref_frames(first as i64, last as i64)
{
// The RFI recovered the loss with a clean re-anchor P-frame (no IDR). Anchor the
// keyframe cooldown so the client's echo of the SAME loss — its frames_dropped-
// driven keyframe request, arriving ~one loss-window later — is coalesced away
@@ -4234,7 +4312,11 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
st_queue.push(queue_us);
}
let t_submit = std::time::Instant::now();
if let Err(e) = enc.submit(&frame) {
// This submission's future wire frame index (see `au_seq`): AUs are emitted FIFO one per
// submission, so it lands `inflight.len()` AUs after the `au_seq` the loop is about to
// assign next. The RFI backends pin their frame numbering to it.
let wire_index = au_seq.wrapping_add(inflight.len() as u32);
if let Err(e) = enc.submit_indexed(&frame, wire_index) {
// The input half of an encode stall: once the driver stops draining AUs, libavcodec's
// one-frame buffer fills and avcodec_send_frame starts failing (EAGAIN) — the same
// wedge the watchdog below catches, seen from submit. Rebuild the encoder in place
@@ -4339,6 +4421,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
data: au.data,
capture_ns: cap_ns,
flags,
frame_index: au_seq,
deadline,
encode_us,
queue_us,
@@ -4354,6 +4437,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
send_gone = true;
break;
}
au_seq = au_seq.wrapping_add(1);
sent += 1;
}
if send_gone {
@@ -4414,6 +4498,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
data: au.data,
capture_ns: cap_ns,
flags,
frame_index: au_seq,
deadline,
encode_us,
queue_us: 0,
@@ -4426,6 +4511,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
if frame_tx.send(msg).is_err() {
break;
}
au_seq = au_seq.wrapping_add(1);
sent += 1;
}
// Signal the send thread to drain + exit (drop the channel), then join it.