fix(host): encode-stall watchdog — heal the silent AMF/QSV freeze in place

Field reports: Windows AMD/Intel streams freeze after ~3-5 min regardless of
desktop activity. Root cause: the libavcodec AMF/QSV poll is non-blocking
(EAGAIN -> Ok(None)), and the encode loop's drain treated None as benign
without popping `inflight` — a wedged driver (QueryOutput stops producing)
meant frames kept being submitted, inflight grew unboundedly, no AU ever
reached the send thread, and nothing logged: a silent permanent freeze. The
input-side twin: once libavcodec's one-frame buffer fills, avcodec_send_frame
EAGAINs and the submit `?` killed the whole session.

Add `Encoder::reset()` (in-place encoder rebuild; implemented for AMF/QSV by
dropping the wedged libavcodec encoder so the next submit re-opens it on the
current device, forced IDR) and an encode-stall watchdog in the stream loop:
trip on a poll error, on no AU within max(2 s, 8 frame intervals) while frames
are owed, or on an owed backlog worth more than the window's frames (the
slow-leak latency-runaway form). Recovery is a bounded (5 consecutive, cleared
by any delivered AU) in-place rebuild + forced IDR — a logged ~one-second
hiccup instead of a dead stream; exhaustion or a reset-less backend still
fails the session with a clear error. Submit failures route through the same
bounded recovery. The three existing pipeline-rebuild paths (session switch,
mode switch, capture loss) now also clear the stale in-flight records that
pointed at the dropped encoder.

Backends whose poll blocks (direct NVENC sync, software) can't false-trip:
they never return Ok(None) mid-stream and drain inflight below depth each
tick. Validated: clippy -D warnings (nvenc,amf-qsv), 191 host tests, synthetic
E2E 300/300 frames, and an on-glass AMD iGPU session (1080p120 HDR hevc_amf).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 14:36:29 +02:00
parent 39889c3102
commit 06d594bc2a
3 changed files with 141 additions and 4 deletions
+12
View File
@@ -194,6 +194,15 @@ pub trait Encoder: Send {
} }
/// Pull the next encoded AU if one is ready. /// Pull the next encoded AU if one is ready.
fn poll(&mut self) -> Result<Option<EncodedFrame>>; fn poll(&mut self) -> Result<Option<EncodedFrame>>;
/// Tear the underlying hardware encoder down and rebuild it in place, keeping the session's
/// negotiated parameters — the encode-stall watchdog's recovery lever (a wedged AMF/QSV
/// driver stops emitting AUs or accepting frames without ever returning an error). Returns
/// `true` when the encoder was rebuilt: every submitted-but-unpolled frame is forfeited and
/// the next submitted frame starts a fresh stream (IDR). Default `false`: the backend has no
/// in-place rebuild and the caller must treat the stall as fatal instead.
fn reset(&mut self) -> bool {
false
}
/// Signal end-of-stream. After this, drain the remaining AUs with [`poll`](Self::poll) /// Signal end-of-stream. After this, drain the remaining AUs with [`poll`](Self::poll)
/// until it returns `None` — NVENC buffers frames internally even at `delay=0`. /// until it returns `None` — NVENC buffers frames internally even at `delay=0`.
fn flush(&mut self) -> Result<()>; fn flush(&mut self) -> Result<()>;
@@ -370,6 +379,9 @@ impl Encoder for TrackedEncoder {
fn poll(&mut self) -> Result<Option<EncodedFrame>> { fn poll(&mut self) -> Result<Option<EncodedFrame>> {
self.inner.poll() self.inner.poll()
} }
fn reset(&mut self) -> bool {
self.inner.reset()
}
fn flush(&mut self) -> Result<()> { fn flush(&mut self) -> Result<()> {
self.inner.flush() self.inner.flush()
} }
@@ -1307,6 +1307,18 @@ impl Encoder for FfmpegWinEncoder {
self.force_kf = true; self.force_kf = true;
} }
/// Encode-stall recovery: drop the wedged libavcodec encoder (its `Drop` releases the AMF/QSV
/// runtime state) and let the next `submit` rebuild it lazily on the current device, exactly
/// like first-frame bring-up. The owed AUs are forfeited (`in_flight` zeroed) and the rebuilt
/// encoder's first frame is forced IDR so the client resyncs immediately.
fn reset(&mut self) -> bool {
self.inner = None;
self.bound_device = 0;
self.in_flight = 0;
self.force_kf = true;
true
}
/// Poll for the next finished AU (single non-blocking `receive_packet`). /// Poll for the next finished AU (single non-blocking `receive_packet`).
/// ///
/// libavcodec's `hevc_amf`/`av1_amf` wrapper holds ~2 frames before releasing the oldest /// libavcodec's `hevc_amf`/`av1_amf` wrapper holds ~2 frames before releasing the oldest
+117 -4
View File
@@ -3198,6 +3198,18 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
let mut cur_mode = mode; let mut cur_mode = mode;
const MAX_CAPTURE_REBUILDS: u32 = 5; const MAX_CAPTURE_REBUILDS: u32 = 5;
let mut capture_rebuilds: u32 = 0; let mut capture_rebuilds: u32 = 0;
// Encode-stall watchdog: AMF/QSV (and async NVENC) poll non-blocking, so a wedged driver
// shows up as poll() returning None forever while submits keep succeeding — `inflight` grows,
// no AU ever reaches the send thread, and the client freezes on the last frame with nothing
// logged (field reports: AMD/Intel Windows streams freezing after minutes). Track when the
// encoder last produced an AU and rebuild it in place (bounded, like the capture rebuilds)
// when it stops. `ENCODE_STALL_WINDOW` also sizes the in-flight backlog bound: a backlog worth
// more than the window's frames means AUs still trickle (so the gap never trips) but latency
// is growing without bound — the slow-leak form of the same stall.
const ENCODE_STALL_WINDOW: std::time::Duration = std::time::Duration::from_secs(2);
const MAX_ENCODER_RESETS: u32 = 5;
let mut encoder_resets: u32 = 0;
let mut last_au_at = std::time::Instant::now();
// Last HDR mastering metadata we forwarded — re-sent as 0xCE on change/keyframe (see below). // Last HDR mastering metadata we forwarded — re-sent as 0xCE on change/keyframe (see below).
let mut last_hdr_meta: Option<punktfunk_core::quic::HdrMeta> = None; let mut last_hdr_meta: Option<punktfunk_core::quic::HdrMeta> = None;
// Frames submitted to NVENC but not yet polled (wire pts, submit stamp, pacing deadline). With a // Frames submitted to NVENC but not yet polled (wire pts, submit stamp, pacing deadline). With a
@@ -3283,6 +3295,11 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
vd = new_vd; vd = new_vd;
compositor = sw.compositor; compositor = sw.compositor;
next = std::time::Instant::now(); next = std::time::Instant::now();
// The owed AUs died with the old encoder — drop their in-flight records
// and restart the encode-stall clock for the fresh one.
inflight.clear();
last_au_at = std::time::Instant::now();
encoder_resets = 0;
tracing::info!( tracing::info!(
compositor = compositor.id(), compositor = compositor.id(),
"session switch — backend rebuilt, stream continues" "session switch — backend rebuilt, stream continues"
@@ -3317,6 +3334,11 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
(capturer, enc, frame, interval, cur_node_id) = next_pipe; (capturer, enc, frame, interval, cur_node_id) = next_pipe;
cur_mode = new_mode; cur_mode = new_mode;
next = std::time::Instant::now(); next = std::time::Instant::now();
// The owed AUs died with the old encoder — drop their in-flight records
// and restart the encode-stall clock for the fresh one.
inflight.clear();
last_au_at = std::time::Instant::now();
encoder_resets = 0;
} }
Err(e) => { Err(e) => {
tracing::error!(error = %format!("{e:#}"), ?new_mode, tracing::error!(error = %format!("{e:#}"), ?new_mode,
@@ -3485,6 +3507,12 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
cur_node_id = new_node_id; cur_node_id = new_node_id;
enc.request_keyframe(); // belt-and-suspenders; a fresh encoder opens on an IDR anyway enc.request_keyframe(); // belt-and-suspenders; a fresh encoder opens on an IDR anyway
next = std::time::Instant::now(); next = std::time::Instant::now();
// The owed AUs died with the old encoder — drop their in-flight records and
// restart the encode-stall clock (the rebuild loop above may have eaten seconds,
// which must not count against the fresh encoder).
inflight.clear();
last_au_at = std::time::Instant::now();
encoder_resets = 0;
tracing::info!( tracing::info!(
compositor = compositor.id(), compositor = compositor.id(),
"capture loss: pipeline rebuilt — stream resumes" "capture loss: pipeline rebuilt — stream resumes"
@@ -3551,7 +3579,27 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
st_queue.push(queue_us); st_queue.push(queue_us);
} }
let t_submit = std::time::Instant::now(); let t_submit = std::time::Instant::now();
enc.submit(&frame).context("encoder submit")?; if let Err(e) = enc.submit(&frame) {
// 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
// (bounded) instead of killing an otherwise healthy session; a backend without an
// in-place rebuild keeps today's fail-fast behavior.
encoder_resets += 1;
if encoder_resets > MAX_ENCODER_RESETS || !reset_stalled_encoder(&mut enc, &mut inflight)
{
return Err(e).context("encoder submit");
}
tracing::error!(error = %format!("{e:#}"), reset = encoder_resets,
max = MAX_ENCODER_RESETS,
"encoder submit failed — encoder rebuilt in place, forcing an IDR");
last_au_at = std::time::Instant::now();
// Re-pace from the rebuild and retry this frame next tick (gives the fresh encoder
// one frame period to come up instead of hammering it in a hot loop).
next = std::time::Instant::now() + interval;
std::thread::sleep(interval);
continue;
}
let submit_us = if measure { let submit_us = if measure {
t_submit.elapsed().as_micros() as u32 t_submit.elapsed().as_micros() as u32
} else { } else {
@@ -3569,9 +3617,12 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
// so the encode of N overlaps the convert/copy of N+1. NVENC's `pending` is FIFO, so poll() returns // so the encode of N overlaps the convert/copy of N+1. NVENC's `pending` is FIFO, so poll() returns
// the oldest submitted frame's AU — matching `inflight.pop_front()`. // the oldest submitted frame's AU — matching `inflight.pop_front()`.
let mut send_gone = false; let mut send_gone = false;
// A poll error is the explicit form of an encode stall (e.g. a QSV device failure);
// carry it to the shared stall recovery below instead of killing the session outright.
let mut poll_err: Option<anyhow::Error> = None;
while inflight.len() >= depth { while inflight.len() >= depth {
let t_wait = std::time::Instant::now(); let t_wait = std::time::Instant::now();
let polled = enc.poll().context("encoder poll")?; let polled = enc.poll();
let wait_us = if measure { let wait_us = if measure {
t_wait.elapsed().as_micros() as u32 t_wait.elapsed().as_micros() as u32
} else { } else {
@@ -3581,9 +3632,20 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
st_wait.push(wait_us); st_wait.push(wait_us);
} }
let au = match polled { let au = match polled {
Some(au) => au, Ok(Some(au)) => au,
None => break, // no AU ready for a submitted frame (shouldn't happen — poll blocks) // No AU ready for a submitted frame. Routine on the non-blocking backends (the
// libavcodec AMF/QSV wrapper holds ~2 frames; async NVENC drains a ready queue) —
// the frame stays in flight and the next tick re-polls. The stall watchdog below
// decides when "not ready yet" has become "the driver is wedged".
Ok(None) => break,
Err(e) => {
poll_err = Some(e);
break;
}
}; };
// The encoder is alive: feed the stall watchdog, clear the consecutive-reset counter.
last_au_at = std::time::Instant::now();
encoder_resets = 0;
let (cap_ns, sub_ns, deadline) = inflight.pop_front().expect("inflight non-empty"); let (cap_ns, sub_ns, deadline) = inflight.pop_front().expect("inflight non-empty");
let flags = if au.keyframe { let flags = if au.keyframe {
(FLAG_PIC | FLAG_SOF) as u32 (FLAG_PIC | FLAG_SOF) as u32
@@ -3624,6 +3686,39 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
if send_gone { if send_gone {
break; break;
} }
// Encode-stall watchdog. Trip on: an explicit poll error; no AU within the window while
// frames are owed (the full wedge — AMF/QSV's non-blocking poll returns None forever and
// nothing else ever errors); or an owed backlog worth more than the window's frames (the
// slow leak — AUs still trickle, so the gap never trips, but latency grows without bound).
// Recovery rebuilds the encoder in place and forces an IDR — a logged ~one-second hiccup
// instead of a silent permanent freeze — bounded so a genuinely dead encoder still ends
// the session with a clear error. The window scales with the frame interval so low-fps
// modes (where the AMF wrapper's ~2-frame hold spans seconds) can't false-trip.
let stall_window = ENCODE_STALL_WINDOW.max(interval * 8);
let stall_backlog =
depth + (stall_window.as_secs_f64() / interval.as_secs_f64().max(1e-6)).ceil() as usize;
if poll_err.is_some()
|| (!inflight.is_empty()
&& (last_au_at.elapsed() >= stall_window || inflight.len() > stall_backlog))
{
let why = match &poll_err {
Some(e) => format!("poll failed: {e:#}"),
None => format!(
"no AU for {} ms with {} frame(s) in flight",
last_au_at.elapsed().as_millis(),
inflight.len()
),
};
encoder_resets += 1;
if encoder_resets > MAX_ENCODER_RESETS || !reset_stalled_encoder(&mut enc, &mut inflight)
{
return Err(poll_err.unwrap_or_else(|| anyhow!("{why}")))
.context("encoder stalled — in-place rebuild unavailable or exhausted");
}
tracing::error!(reset = encoder_resets, max = MAX_ENCODER_RESETS, %why,
"encode stall detected — encoder rebuilt in place, forcing an IDR");
last_au_at = std::time::Instant::now();
}
match next.checked_duration_since(std::time::Instant::now()) { match next.checked_duration_since(std::time::Instant::now()) {
Some(d) => std::thread::sleep(d), Some(d) => std::thread::sleep(d),
None => next = std::time::Instant::now(), None => next = std::time::Instant::now(),
@@ -3779,6 +3874,24 @@ fn is_permanent_build_error(chain: &str) -> bool {
PERMANENT.iter().any(|p| lower.contains(p)) PERMANENT.iter().any(|p| lower.contains(p))
} }
/// Encode-stall recovery: rebuild the encoder in place (keeping capture + the session up) and
/// discard the owed in-flight frame records — their AUs died with the old encoder instance.
/// Returns `false` when the backend has no in-place rebuild ([`crate::encode::Encoder::reset`]'s
/// default); the caller then surfaces the stall as a session error instead. The forced keyframe
/// makes the rebuilt encoder's first frame an immediate decoder resync point (belt-and-suspenders:
/// a fresh encoder opens on an IDR anyway).
fn reset_stalled_encoder(
enc: &mut Box<dyn crate::encode::Encoder>,
inflight: &mut std::collections::VecDeque<(u64, u64, std::time::Instant)>,
) -> bool {
if !enc.reset() {
return false;
}
inflight.clear();
enc.request_keyframe();
true
}
fn build_pipeline( fn build_pipeline(
vd: &mut Box<dyn crate::vdisplay::VirtualDisplay>, vd: &mut Box<dyn crate::vdisplay::VirtualDisplay>,
mode: punktfunk_core::Mode, mode: punktfunk_core::Mode,