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:
@@ -2077,9 +2077,15 @@ impl Encoder for AmfEncoder {
|
|||||||
}
|
}
|
||||||
// Apply a queued force (from invalidate_ref_frames / the test hook) to THIS frame: it
|
// Apply a queued force (from invalidate_ref_frames / the test hook) to THIS frame: it
|
||||||
// becomes the clean re-anchor P-frame the client lifts its post-loss freeze on.
|
// becomes the clean re-anchor P-frame the client lifts its post-loss freeze on.
|
||||||
|
// Guard against a slot the taint sweep emptied since the force was queued: the
|
||||||
|
// HARDWARE slot still holds the tainted mark, so forcing it would re-reference the
|
||||||
|
// very corruption being recovered from — and the frame must not ship tagged
|
||||||
|
// `recovery_anchor` either (the client lifts its post-loss freeze on that tag).
|
||||||
if let Some(slot) = self.pending_force.take() {
|
if let Some(slot) = self.pending_force.take() {
|
||||||
force_slot = Some(slot);
|
if self.ltr_slots[slot].is_some() {
|
||||||
recovery_anchor = true;
|
force_slot = Some(slot);
|
||||||
|
recovery_anchor = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Mark cadence: refresh a long-term reference on every IDR and every `ltr_mark_interval`
|
// Mark cadence: refresh a long-term reference on every IDR and every `ltr_mark_interval`
|
||||||
// frames — but never on the recovery frame itself (marking rotates `next_ltr_slot` and
|
// frames — but never on the recovery frame itself (marking rotates `next_ltr_slot` and
|
||||||
@@ -2363,6 +2369,16 @@ impl Encoder for AmfEncoder {
|
|||||||
if !self.ltr_active || first < 0 || first > last {
|
if !self.ltr_active || first < 0 || first > last {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Taint sweep BEFORE picking the anchor: an LTR marked at-or-after the loss start was
|
||||||
|
// encoded inside the client's corrupt window — the client either never received it or
|
||||||
|
// decoded it against a broken reference chain. Serving it as "known-good" on a LATER
|
||||||
|
// loss ships corruption as the recovery anchor (and every subsequent mark re-samples
|
||||||
|
// it). Dropped slots stay dropped; the cadence re-marks a clean frame within ~1/4 s.
|
||||||
|
for marked in self.ltr_slots.iter_mut() {
|
||||||
|
if marked.is_some_and(|idx| idx >= first) {
|
||||||
|
*marked = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
// Pick the newest LTR strictly OLDER than the loss: the most recent known-good reference the
|
// Pick the newest LTR strictly OLDER than the loss: the most recent known-good reference the
|
||||||
// client still holds, so re-referencing it costs the least (smallest recovery-frame residual).
|
// client still holds, so re-referencing it costs the least (smallest recovery-frame residual).
|
||||||
// `ltr_slots` store the WIRE frame index of the marked frame (`submit_indexed` pins
|
// `ltr_slots` store the WIRE frame index of the marked frame (`submit_indexed` pins
|
||||||
@@ -2391,6 +2407,9 @@ impl Encoder for AmfEncoder {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
|
// The sweep may have emptied the slot an earlier (un-consumed) force pointed
|
||||||
|
// at — clear it so the next submit can't force-reference a tainted hardware slot.
|
||||||
|
self.pending_force = None;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
first,
|
first,
|
||||||
last,
|
last,
|
||||||
|
|||||||
@@ -1017,7 +1017,7 @@ impl Encoder for QsvEncoder {
|
|||||||
self.frame_idx += 1;
|
self.frame_idx += 1;
|
||||||
// --- LTR-RFI per-frame decisions (the AMF policy verbatim; see that module's doc) ---
|
// --- LTR-RFI per-frame decisions (the AMF policy verbatim; see that module's doc) ---
|
||||||
let mut mark_slot: Option<usize> = None;
|
let mut mark_slot: Option<usize> = None;
|
||||||
let mut force_slot: Option<usize> = None;
|
let mut force_ltr: Option<(usize, i64)> = None;
|
||||||
let mut recovery_anchor = false;
|
let mut recovery_anchor = false;
|
||||||
if self.ltr_active {
|
if self.ltr_active {
|
||||||
if forced {
|
if forced {
|
||||||
@@ -1035,10 +1035,16 @@ impl Encoder for QsvEncoder {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(slot) = self.pending_force.take() {
|
if let Some(slot) = self.pending_force.take() {
|
||||||
force_slot = Some(slot);
|
// Resolve the anchor NOW: a taint sweep in `invalidate_ref_frames` may have
|
||||||
recovery_anchor = true;
|
// emptied the slot since the force was queued. An empty slot means there is
|
||||||
|
// nothing clean to re-reference — the frame must ship as a plain P WITHOUT the
|
||||||
|
// `recovery_anchor` tag (the client lifts its post-loss freeze on that tag).
|
||||||
|
if let Some(idx) = self.ltr_slots[slot] {
|
||||||
|
force_ltr = Some((slot, idx));
|
||||||
|
recovery_anchor = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if force_slot.is_none() && (forced || cur_idx % self.ltr_mark_interval == 0) {
|
if force_ltr.is_none() && (forced || cur_idx % self.ltr_mark_interval == 0) {
|
||||||
let slot = self.next_ltr_slot;
|
let slot = self.next_ltr_slot;
|
||||||
self.ltr_slots[slot] = Some(cur_idx);
|
self.ltr_slots[slot] = Some(cur_idx);
|
||||||
self.next_ltr_slot = (self.next_ltr_slot + 1) % NUM_LTR_SLOTS;
|
self.next_ltr_slot = (self.next_ltr_slot + 1) % NUM_LTR_SLOTS;
|
||||||
@@ -1046,6 +1052,7 @@ impl Encoder for QsvEncoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let ltr_slots = self.ltr_slots;
|
let ltr_slots = self.ltr_slots;
|
||||||
|
let reject_ok = self.codec != Codec::Av1;
|
||||||
let inner = self.inner.as_mut().expect("ensure_inner succeeded");
|
let inner = self.inner.as_mut().expect("ensure_inner succeeded");
|
||||||
// Bound the in-flight window BEFORE submitting: drain finished AUs (buffered for
|
// Bound the in-flight window BEFORE submitting: drain finished AUs (buffered for
|
||||||
// `poll`) instead of letting the queue grow under overload.
|
// `poll`) instead of letting the queue grow under overload.
|
||||||
@@ -1130,7 +1137,7 @@ impl Encoder for QsvEncoder {
|
|||||||
(*surf).Data.TimeStamp = captured.pts_ns.wrapping_mul(9) / 100_000; // 90 kHz
|
(*surf).Data.TimeStamp = captured.pts_ns.wrapping_mul(9) / 100_000; // 90 kHz
|
||||||
// Per-frame control: forced IDR and/or the LTR reflist.
|
// Per-frame control: forced IDR and/or the LTR reflist.
|
||||||
let mut ctrl: Option<Box<FrameCtrl>> = None;
|
let mut ctrl: Option<Box<FrameCtrl>> = None;
|
||||||
if forced || mark_slot.is_some() || force_slot.is_some() {
|
if forced || mark_slot.is_some() || force_ltr.is_some() {
|
||||||
let mut c = FrameCtrl::new();
|
let mut c = FrameCtrl::new();
|
||||||
if forced {
|
if forced {
|
||||||
c.ctrl.FrameType = (vpl::MFX_FRAMETYPE_IDR
|
c.ctrl.FrameType = (vpl::MFX_FRAMETYPE_IDR
|
||||||
@@ -1148,24 +1155,55 @@ impl Encoder for QsvEncoder {
|
|||||||
c.reflist.ApplyLongTermIdx = 1;
|
c.reflist.ApplyLongTermIdx = 1;
|
||||||
use_reflist = true;
|
use_reflist = true;
|
||||||
}
|
}
|
||||||
if let Some(slot) = force_slot {
|
if let Some((slot, ltr_frame)) = force_ltr {
|
||||||
if let Some(ltr_frame) = ltr_slots[slot] {
|
// Force THIS frame to predict only from the known-good LTR — the
|
||||||
// Force THIS frame to predict only from the known-good LTR — the
|
// clean re-anchor. LongTermIdx stays 0 inside PreferredRefList
|
||||||
// clean re-anchor. LongTermIdx stays 0 inside PreferredRefList
|
// (the AV1 runtime rejects nonzero there; AVC/HEVC key on
|
||||||
// (the AV1 runtime rejects nonzero there; AVC/HEVC key on
|
// FrameOrder).
|
||||||
// FrameOrder).
|
c.reflist.PreferredRefList[0].FrameOrder = ltr_frame as u32;
|
||||||
c.reflist.PreferredRefList[0].FrameOrder = ltr_frame as u32;
|
c.reflist.PreferredRefList[0].PicStruct =
|
||||||
c.reflist.PreferredRefList[0].PicStruct =
|
vpl::MFX_PICSTRUCT_PROGRESSIVE as u16;
|
||||||
vpl::MFX_PICSTRUCT_PROGRESSIVE as u16;
|
// A preference alone is a reorder HINT (VPL spec) — the encoder may
|
||||||
use_reflist = true;
|
// still predict from the tainted short-term refs alongside it. AMF's
|
||||||
tracing::info!(
|
// ForceLTRReferenceBitfield and NVENC's invalidation are hard
|
||||||
slot,
|
// exclusions; emulate that here by rejecting every other DPB
|
||||||
ltr_frame,
|
// candidate — the short-term sliding window (the 2 most recent
|
||||||
frame = cur_idx,
|
// frames) and the other LTR slot — and capping L0 at one active
|
||||||
"QSV LTR-RFI: re-referencing known-good LTR (clean recovery, \
|
// entry. Without this the "clean recovery" frame can carry the
|
||||||
no IDR)"
|
// corruption forward, which the client cannot detect (the field
|
||||||
);
|
// failure: permanent macroblock soup under sustained loss).
|
||||||
|
// AVC/HEVC only: the AV1 runtime's universal-reflist rejection path
|
||||||
|
// is unvalidated, and an unhonored hint there still converges via
|
||||||
|
// the host's IDR escalation.
|
||||||
|
if reject_ok {
|
||||||
|
let mut rej = 0;
|
||||||
|
let mut reject = |idx: i64| {
|
||||||
|
if idx >= 0 && idx != ltr_frame {
|
||||||
|
c.reflist.RejectedRefList[rej].FrameOrder = idx as u32;
|
||||||
|
c.reflist.RejectedRefList[rej].PicStruct =
|
||||||
|
vpl::MFX_PICSTRUCT_PROGRESSIVE as u16;
|
||||||
|
rej += 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reject(cur_idx - 1);
|
||||||
|
reject(cur_idx - 2);
|
||||||
|
for (s, marked) in ltr_slots.iter().enumerate() {
|
||||||
|
if s != slot {
|
||||||
|
if let Some(idx) = *marked {
|
||||||
|
reject(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.reflist.NumRefIdxL0Active = 1;
|
||||||
}
|
}
|
||||||
|
use_reflist = true;
|
||||||
|
tracing::info!(
|
||||||
|
slot,
|
||||||
|
ltr_frame,
|
||||||
|
frame = cur_idx,
|
||||||
|
"QSV LTR-RFI: re-referencing known-good LTR (clean recovery, \
|
||||||
|
no IDR)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if use_reflist {
|
if use_reflist {
|
||||||
c.attach_reflist();
|
c.attach_reflist();
|
||||||
@@ -1265,6 +1303,17 @@ impl Encoder for QsvEncoder {
|
|||||||
if !self.ltr_active || first < 0 || first > last {
|
if !self.ltr_active || first < 0 || first > last {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Taint sweep BEFORE picking the anchor: an LTR marked at-or-after the loss start was
|
||||||
|
// encoded inside the client's corrupt window — the client either never received it or
|
||||||
|
// decoded it against a broken reference chain. Serving it as "known-good" on a LATER
|
||||||
|
// loss ships corruption as the recovery anchor, and every subsequent mark re-samples
|
||||||
|
// the soup — the sustained-loss field failure where the picture never healed. Dropped
|
||||||
|
// slots stay dropped; the cadence re-marks a clean frame within ~1/4 s.
|
||||||
|
for marked in self.ltr_slots.iter_mut() {
|
||||||
|
if marked.is_some_and(|idx| idx >= first) {
|
||||||
|
*marked = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
let mut best: Option<(usize, i64)> = None;
|
let mut best: Option<(usize, i64)> = None;
|
||||||
for (slot, marked) in self.ltr_slots.iter().enumerate() {
|
for (slot, marked) in self.ltr_slots.iter().enumerate() {
|
||||||
if let Some(idx) = *marked {
|
if let Some(idx) = *marked {
|
||||||
@@ -1287,6 +1336,9 @@ impl Encoder for QsvEncoder {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
|
// The sweep may have emptied the slot an earlier (un-consumed) force pointed
|
||||||
|
// at — clear it so the next submit can't half-apply a stale recovery.
|
||||||
|
self.pending_force = None;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
first,
|
first,
|
||||||
last,
|
last,
|
||||||
@@ -1845,6 +1897,36 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Taint sweep: a loss that predates every live LTR mark leaves NO clean anchor — every
|
||||||
|
/// slot was marked inside the client's corrupt window. The invalidate must decline (the
|
||||||
|
/// caller then serves the IDR) and no recovery_anchor AU may ship; before the sweep this
|
||||||
|
/// force-referenced a tainted mark and shipped corruption tagged as a clean recovery.
|
||||||
|
#[test]
|
||||||
|
fn qsv_live_ltr_rfi_taint_sweep_declines() {
|
||||||
|
let mut rfi_answered = None;
|
||||||
|
let Some(aus) = drive_live(Codec::H264, false, 60, |enc, i| {
|
||||||
|
if i == 30 && enc.caps().supports_rfi {
|
||||||
|
// Frame 0 lost: the IDR itself — every mark (0, 15, ...) is at-or-after it.
|
||||||
|
rfi_answered = Some(enc.invalidate_ref_frames(0, 2));
|
||||||
|
}
|
||||||
|
}) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
assert_stream_shape(&aus, 60, true);
|
||||||
|
let Some(answered) = rfi_answered else {
|
||||||
|
eprintln!("note: driver declined LTR (supports_rfi=false) — sweep not exercised");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
!answered,
|
||||||
|
"a loss covering every live LTR mark must fall back to IDR recovery"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!aus.iter().any(|a| a.recovery_anchor),
|
||||||
|
"no recovery_anchor AU may ship when the sweep left no clean LTR"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// No-IDR bitrate retarget — Phase 3 on-glass: `reconfigure_bitrate` mid-stream must be
|
/// No-IDR bitrate retarget — Phase 3 on-glass: `reconfigure_bitrate` mid-stream must be
|
||||||
/// accepted (HRD off + StartNewSequence=OFF) and must not emit a keyframe.
|
/// accepted (HRD off + StartNewSequence=OFF) and must not emit a keyframe.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// 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());
|
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
|
// 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`]).
|
// into a stable multi-second rhythm (see [`pf_frame::metronome::Metronome`]).
|
||||||
let mut recovery_cadence = pf_frame::metronome::Metronome::new();
|
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
|
} else if enc.caps().supports_rfi
|
||||||
&& enc.invalidate_ref_frames(first as i64, last as i64)
|
&& 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
|
// The RFI recovered the loss with a clean re-anchor P-frame (no IDR). Anchor
|
||||||
// keyframe cooldown so the client's echo of the SAME loss — its frames_dropped-
|
// the RFI-echo window (NOT the IDR cooldown — see `last_rfi`) so the client's
|
||||||
// driven keyframe request, arriving ~one loss-window later — is coalesced away
|
// echo of the SAME loss — its frames_dropped-driven keyframe request, arriving
|
||||||
// instead of emitting a redundant full IDR right after the cheap recovery.
|
// ~one loss-window later — is coalesced away instead of emitting a redundant
|
||||||
last_forced_idr = Some(std::time::Instant::now());
|
// full IDR right after the cheap recovery.
|
||||||
|
last_rfi = Some(std::time::Instant::now());
|
||||||
} else {
|
} else {
|
||||||
want_kf = true; // range too old / no RFI backend → coalesced keyframe below
|
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.
|
// promptly.
|
||||||
const IDR_COOLDOWN_INTRA: std::time::Duration = std::time::Duration::from_secs(2);
|
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);
|
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 {
|
let window = if enc.caps().intra_refresh {
|
||||||
IDR_COOLDOWN_INTRA
|
IDR_COOLDOWN_INTRA
|
||||||
} else {
|
} else {
|
||||||
IDR_COOLDOWN_FULL
|
IDR_COOLDOWN_FULL
|
||||||
};
|
};
|
||||||
let suppress = last_forced_idr.is_some_and(|t| t.elapsed() < window);
|
let now = std::time::Instant::now();
|
||||||
if suppress {
|
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");
|
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 {
|
} else {
|
||||||
tracing::debug!("forcing keyframe (client decode recovery)");
|
tracing::debug!("forcing keyframe (client decode recovery)");
|
||||||
enc.request_keyframe();
|
enc.request_keyframe();
|
||||||
let now = std::time::Instant::now();
|
|
||||||
last_forced_idr = Some(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) {
|
if let Some(period) = recovery_cadence.note(now) {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
period_s = format!("{:.1}", period.as_secs_f64()),
|
period_s = format!("{:.1}", period.as_secs_f64()),
|
||||||
|
|||||||
Reference in New Issue
Block a user