fix(encode): make LTR-RFI loss recovery sound under sustained loss
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 1m6s
apple / swift (push) Successful in 1m17s
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
ci / bench (push) Successful in 5m40s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5m6s
apple / screenshots (push) Successful in 6m12s
deb / build-publish (push) Successful in 9m5s
docker / deploy-docs (push) Successful in 24s
deb / build-publish-host (push) Successful in 13m49s
windows-host / package (push) Successful in 16m17s
arch / build-publish (push) Successful in 17m24s
android / android (push) Successful in 12m53s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m31s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m5s
ci / rust (push) Successful in 25m35s

Field report (lid-closed Intel laptop, ~6-19% sustained loss): the stream
never healed — permanent macroblock soup. Three stacked bugs:

- QSV answered RFI with PreferredRefList only, a reorder HINT per the VPL
  spec — the recovery frame could keep predicting from tainted short-term
  refs. Now rejects every other DPB candidate (RejectedRefList) and caps
  L0 at one active entry (AVC/HEVC), matching AMF's hard
  ForceLTRReferenceBitfield / NVENC invalidation semantics.
- Neither QSV nor AMF taint-swept LTR slots across losses: a slot marked
  inside the client's corrupt window became the "known-good" anchor of the
  NEXT loss, propagating corruption through every recovery. Both now drop
  slots at-or-after the loss start before picking an anchor, and guard a
  queued force whose slot the sweep emptied (no false recovery_anchor tag).
- The native plane re-anchored the FULL IDR cooldown on every successful
  RFI, so under sustained loss the client's escalating keyframe requests
  were coalesced away indefinitely (field log: dozens swallowed, one IDR
  per ~8 s). RFI now anchors a 300 ms echo window with a 2-swallow budget
  per loss episode; a client still asking past that gets its IDR.

Live-validated on Arc (qsv feature): 6/6 including the new
qsv_live_ltr_rfi_taint_sweep_declines (a loss covering every live mark
declines the RFI and falls back to IDR recovery).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 17:53:48 +02:00
parent ba1caf0281
commit fecbec2daf
3 changed files with 176 additions and 32 deletions
+51 -8
View File
@@ -1166,6 +1166,21 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// clock now — that coalesces the keyframe storm a client fires while its decoder wedges on the cold
// opening GOP, instead of answering it with a redundant second IDR.
let mut last_forced_idr: Option<std::time::Instant> = Some(std::time::Instant::now());
// A successful LTR-RFI recovery anchors THIS clock, not the IDR cooldown: it justifies
// swallowing the client's `frames_dropped`-driven echo of the SAME loss (arriving ~one
// loss-window later), but must never indefinitely defer the client's ESCALATION — a
// keyframe request that keeps coming because the RFI recovery did not actually heal its
// decoder. Re-anchoring the full IDR cooldown here (the old behavior) livelocked under
// sustained loss: each new loss → RFI → cooldown re-anchored → the wedged client's IDR
// pleas coalesced away forever, and the picture never recovered (the lid-closed Intel
// laptop field report: permanent macroblock soup, dozens of swallowed requests per IDR).
let mut last_rfi: Option<std::time::Instant> = None;
// Keyframe requests swallowed on RFI-echo grounds since the last real IDR / quiet period.
// Capped: requests past the cap mean RFI is not healing this client — escalate to the IDR.
let mut rfi_echo_swallowed: u32 = 0;
// When the previous keyframe request arrived — a long quiet gap means the client healed
// and the next request opens a NEW loss episode (the echo-swallow budget resets).
let mut last_kf_request: Option<std::time::Instant> = None;
// Self-diagnosis for the periodic-stutter class: warns when the served recovery IDRs settle
// into a stable multi-second rhythm (see [`pf_frame::metronome::Metronome`]).
let mut recovery_cadence = pf_frame::metronome::Metronome::new();
@@ -1536,11 +1551,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
} 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
// instead of emitting a redundant full IDR right after the cheap recovery.
last_forced_idr = Some(std::time::Instant::now());
// The RFI recovered the loss with a clean re-anchor P-frame (no IDR). Anchor
// the RFI-echo window (NOT the IDR cooldown — see `last_rfi`) so the client's
// echo of the SAME loss — its frames_dropped-driven keyframe request, arriving
// ~one loss-window later — is coalesced away instead of emitting a redundant
// full IDR right after the cheap recovery.
last_rfi = Some(std::time::Instant::now());
} else {
want_kf = true; // range too old / no RFI backend → coalesced keyframe below
}
@@ -1562,19 +1578,46 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// promptly.
const IDR_COOLDOWN_INTRA: std::time::Duration = std::time::Duration::from_secs(2);
const IDR_COOLDOWN_FULL: std::time::Duration = std::time::Duration::from_millis(750);
// The RFI-echo window: how long after a successful LTR-RFI recovery a keyframe
// request is presumed to be the client's echo of the SAME loss (the recovery frame
// is still in flight / just decoding) rather than an escalation. Field data: the
// echo lands ~110-130 ms after the RFI on a LAN-ish RTT.
const RFI_ECHO_WINDOW: std::time::Duration = std::time::Duration::from_millis(300);
// How many requests the echo window may swallow per loss episode. Requests past
// this budget mean the LTR-RFI recoveries are NOT healing the client (anchor lost,
// or corrupt client-side) — serve the IDR it is asking for. Without the cap, a
// sustained-loss session (RFI every few hundred ms, each re-opening the window)
// suppressed the client's escalation indefinitely.
const RFI_ECHO_MAX_SWALLOWED: u32 = 2;
// A quiet gap since the last keyframe request = the client healed; the next
// request opens a NEW loss episode with a fresh echo-swallow budget.
const KF_EPISODE_RESET: std::time::Duration = std::time::Duration::from_secs(1);
let window = if enc.caps().intra_refresh {
IDR_COOLDOWN_INTRA
} else {
IDR_COOLDOWN_FULL
};
let suppress = last_forced_idr.is_some_and(|t| t.elapsed() < window);
if suppress {
let now = std::time::Instant::now();
if last_kf_request.is_some_and(|t| now.duration_since(t) > KF_EPISODE_RESET) {
rfi_echo_swallowed = 0;
}
last_kf_request = Some(now);
let idr_recent = last_forced_idr.is_some_and(|t| t.elapsed() < window);
let rfi_echo = last_rfi.is_some_and(|t| t.elapsed() < RFI_ECHO_WINDOW)
&& rfi_echo_swallowed < RFI_ECHO_MAX_SWALLOWED;
if idr_recent {
tracing::debug!("keyframe request coalesced — within the IDR cooldown");
} else if rfi_echo {
rfi_echo_swallowed += 1;
tracing::debug!(
swallowed = rfi_echo_swallowed,
"keyframe request coalesced — echo of an RFI-recovered loss"
);
} else {
tracing::debug!("forcing keyframe (client decode recovery)");
enc.request_keyframe();
let now = std::time::Instant::now();
last_forced_idr = Some(now);
rfi_echo_swallowed = 0; // the IDR resets the episode — echoes of IT coalesce via the cooldown
if let Some(period) = recovery_cadence.note(now) {
tracing::warn!(
period_s = format!("{:.1}", period.as_secs_f64()),