fix(encode/nvenc-windows): fail safe when nobody sets the input ring depth

`set_input_ring_depth` is a DEFAULTED trait method, so a caller that forgets it
fails silently — and one did. The GameStream loop opens an encoder
(gamestream/stream.rs:671 and the rebuild at :831) and never called it, while the
Windows IDD-push capturer declares a ring of 2 and `async_inflight_cap()` defaults
to 4. With PUNKTFUNK_NVENC_ASYNC=1 a Moonlight session therefore pipelined four
encodes against a two-texture ring, letting the capturer rotate a texture out from
under a live encode: torn or mixed frames, never an error — precisely the corruption
the cap exists to prevent, and precisely what the trait doc warns "fails silently and
intermittently".

Guarded at the single point of CONSUMPTION rather than by plumbing the setter into
every loop: `input_ring_depth` has exactly one reader in the workspace, so one
fail-safe covers every caller including ones not yet written, whereas fixing N call
sites only fixes the N someone remembered. An unconfigured ring is now treated as
the shallowest any capturer here declares, so the unconfigured path degrades to less
pipelining — a latency cost, not corruption.

The two GameStream sites also pass the REAL depth, because the fail-safe is a floor,
not a substitute: `idd_depth` is configurable and a deeper ring is free pipelining
the fallback would forfeit.

Default (sync) sessions are unaffected — the cap is only read while the async
retrieve thread exists, and that is opt-in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-25 02:41:57 +02:00
co-authored by Claude Opus 5
parent 78fe77b049
commit ab63e0dad3
2 changed files with 26 additions and 1 deletions
+17 -1
View File
@@ -1215,9 +1215,23 @@ impl Encoder for NvencD3d11Encoder {
// despite this comment previously claiming otherwise. Since this backend encodes the
// capturer's textures in place, exceeding the capturer's declared `pipeline_depth` lets it
// rotate a texture out from under a live encode — torn frames, silently.
// FAIL SAFE when nobody told us. `set_input_ring_depth` is a defaulted trait method, so a
// caller that forgets it fails SILENTLY — and one did: the GameStream loop opens an encoder
// (`gamestream/stream.rs`) and never calls it, while the Windows IDD-push capturer declares a
// ring of 2 and `async_inflight_cap()` defaults to 4. That let a Moonlight session pipeline
// four encodes against a two-texture ring, i.e. exactly the in-place overwrite this cap
// exists to prevent, as torn/mixed frames and never an error.
//
// Guarding at the single point of CONSUMPTION rather than at each call site is deliberate:
// it covers every present and future caller, including ones that have not been written yet,
// whereas plumbing the setter into N loops only fixes the N sites someone remembered. An
// unknown ring is treated as the shallowest one any capturer in this tree declares, so the
// unconfigured path degrades to less pipelining — a latency cost, not corruption. Callers
// that DO configure it are unaffected.
const UNCONFIGURED_RING_DEPTH: usize = 2;
let cap = match self.input_ring_depth {
Some(d) => async_inflight_cap().min(d.max(1)),
None => async_inflight_cap(),
None => async_inflight_cap().min(UNCONFIGURED_RING_DEPTH),
};
while self.async_rt.is_some() && self.pending.len() >= cap {
let done = {
@@ -1414,6 +1428,8 @@ impl Encoder for NvencD3d11Encoder {
// RFI is probed once at open (`rfi_supported`); HDR SEI rides keyframes whenever the
// session is in HDR mode. Both are the real capabilities the session glue routes on.
EncoderCaps {
// The Windows capture path composites the pointer; this backend never reads `frame.cursor`.
blends_cursor: false,
supports_rfi: self.rfi_supported,
// In-band mastering/CLL is attached as keyframe SEI on HEVC/H.264 only — AV1 carries
// it in METADATA OBUs (`HDR_MDCV`/`HDR_CLL`), which this backend doesn't emit yet
@@ -686,6 +686,13 @@ fn stream_body(
true,
)
.context("open video encoder for stream")?;
// Tell the encoder how deep the capturer lets it pipeline. Without this an in-place backend
// (Windows direct-NVENC, which encodes the capturer's textures with no CopyResource) bounds
// itself by an env cap instead of the ring it is actually reading, and the capturer rotates a
// texture out from under a live encode — torn/mixed frames, never an error. The backend now
// also fails safe when nobody tells it, but pass the REAL depth: `idd_depth` is configurable
// and a deeper ring is free pipelining the fallback would forfeit.
enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
// FEC overhead percent (Sunshine default 20). Override with PUNKTFUNK_FEC_PCT (0 = data-only).
let fec_pct: u8 = std::env::var("PUNKTFUNK_FEC_PCT")
.ok()
@@ -841,6 +848,8 @@ fn stream_body(
true, // metadata-cursor capture — see the first open
)
.context("reopen encoder after rebuild")?;
// A rebuilt encoder starts unconfigured — same reason as the first open above.
enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
supports_rfi = enc.caps().supports_rfi;
enc.request_keyframe();
last_keyframe = Some(Instant::now());