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
@@ -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());