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