From a38adad943509be99ab808722e7f7b6ccc59564f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 19 Jul 2026 21:34:16 +0200 Subject: [PATCH] fix(encode): bound GPU waits, validate encode status, repair command-buffer and cache invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five of the nine medium findings from the pf-encode sweep. The remaining four need cross-crate plumbing or an unwind refactor and are deliberately left out. - vulkan_video `enqueue` waited on the backpressure fence with `u64::MAX`. That wait runs ON the host encode thread — the same thread the stall watchdog's `reset()` would run on — so a wedged GPU parked the one thread that could recover the session: no error, no reset, and teardown blocking on the join. This is the DEFAULT encode path for AMD/Intel Linux hosts (both shipped build recipes enable `vulkan-encode` and `vulkan_encode_enabled()` defaults true). Now bounded by ENCODE_FENCE_TIMEOUT_NS with expiry surfaced as an error. - vulkan_video `import_cached` evicted a cached dmabuf import and destroyed its image/view/memory with no fence wait, while up to `ring_depth - 1` submitted frames may still reference it — a GPU-side use-after-free. `Drop` and `reset` both idle first; this was the one unguarded destroy. Now idles before the eviction loop, guarded on the length test so the steady state pays nothing. - vulkan_video `read_slot` never asked for the encode's operation status, so a FAILED encode was indistinguishable from a successful one and its feedback was read as if it described real bitstream. Now requests WITH_STATUS_KHR and refuses anything that is not COMPLETE. - linux/pyrowave `encode_frame` opens its recording window early and has six fallible steps inside it; every one returned with `cmd` still RECORDING, and nothing repaired it (one `begin_command_buffer` in the file, and neither `reset()` nor `Drop` touches `cmd`), so the next frame called `begin` on a recording buffer — invalid usage. `submit` now resets the buffer on error; legal on all these paths since the pool carries RESET_COMMAND_BUFFER and the buffer is not pending. - windows/pyrowave `encode_frame` ignored `frame.width`/`height` and imported planes at the encoder's configured extent, so a ring recreate at a new mode (the IDD capturer does this autonomously on a confirmed descriptor change) read the planes under a stale VkImageCreateInfo. Added the size guard its QSV and AMF siblings already carry, and keyed the plane cache on (address, width, height) so a recycled COM address cannot resurrect an import of a different size. NOTE: a recycle at the SAME size is still theoretically possible; the complete fix keys on the capturer's ring generation and needs that plumbed onto `PyroFrameShare`. Co-Authored-By: Claude Opus 4.8 --- crates/pf-encode/src/enc/linux/pyrowave.rs | 19 ++++++- .../pf-encode/src/enc/linux/vulkan_video.rs | 57 +++++++++++++++++-- crates/pf-encode/src/enc/windows/pyrowave.rs | 36 ++++++++++-- 3 files changed, 101 insertions(+), 11 deletions(-) diff --git a/crates/pf-encode/src/enc/linux/pyrowave.rs b/crates/pf-encode/src/enc/linux/pyrowave.rs index 43ccd6eb..d2d04169 100644 --- a/crates/pf-encode/src/enc/linux/pyrowave.rs +++ b/crates/pf-encode/src/enc/linux/pyrowave.rs @@ -1176,7 +1176,24 @@ impl Encoder for PyroWaveEncoder { fn submit(&mut self, frame: &CapturedFrame) -> Result<()> { // SAFETY: single-threaded encoder; `encode_frame` records/submits on handles this // struct owns and waits its own fence before touching results. - unsafe { self.encode_frame(frame) } + let r = unsafe { self.encode_frame(frame) }; + if r.is_err() { + // `encode_frame` opens the recording window early and has several fallible steps + // inside it (cursor prep, dmabuf import, format mapping, the CPU-RGB staging path, + // an unsupported-payload bail, and the encode call itself). Every one returns with + // `self.cmd` still RECORDING, and nothing downstream repairs it — there is exactly + // one `begin_command_buffer` in this file and `reset()`/`Drop` never touch `cmd` — + // so the NEXT frame would call `begin` on a recording buffer, which is invalid usage. + // Legal here on every path: the pool carries RESET_COMMAND_BUFFER and the buffer is + // not pending (we never reached the submit, or the submit itself failed). + // SAFETY: `self.cmd` is owned by this encoder and, on these paths, not in flight. + unsafe { + let _ = self + .device + .reset_command_buffer(self.cmd, vk::CommandBufferResetFlags::empty()); + } + } + r } fn caps(&self) -> EncoderCaps { diff --git a/crates/pf-encode/src/enc/linux/vulkan_video.rs b/crates/pf-encode/src/enc/linux/vulkan_video.rs index d99c97b4..e3b51e93 100644 --- a/crates/pf-encode/src/enc/linux/vulkan_video.rs +++ b/crates/pf-encode/src/enc/linux/vulkan_video.rs @@ -37,6 +37,12 @@ const DPB_SLOTS: u32 = 8; /// latency — on-glass validated as rock-solid at 1080p@240, so it is the real-time default; /// backpressure kicks in at the 2nd unread frame. Distinct from `DPB_SLOTS` (reference pool). const RING_DEFAULT: usize = 2; + +/// Ceiling on any blocking GPU fence wait on the encode thread (5 s). Generous against a real +/// encode (single-digit ms even on a loaded GPU) and against a driver hiccup, but finite: this is +/// the thread the stall watchdog's `reset()` runs on, so an unbounded wait would deadlock the very +/// path that recovers the session. Matches the Windows NVENC retrieve-thread budget. +const ENCODE_FENCE_TIMEOUT_NS: u64 = 5_000_000_000; /// AV1 base quantizer index (0..=255) seeded into every frame. CBR rate control overrides it per /// frame; it only matters as the starting point and for the (rate-control-ignored) constant-Q path. const AV1_BASE_Q_IDX: u8 = 128; @@ -868,6 +874,15 @@ impl VulkanVideoEncoder { let (img, mem, view) = self.import_dmabuf(d, cw, ch)?; // Bound the cache; evict oldest (FIFO). A stable PipeWire pool never trips this in steady state // (all imports resident); it only cycles across a pool change (which also rebuilds the session). + // Up to `ring_depth - 1` submitted frames may still be executing against a cached image + // (`enqueue` only drains down to `frames.len()`, and `record_submit` imports before it + // records), so destroying an evicted import here is a GPU-side use-after-free. `Drop` and + // `reset()` both idle the device first; this was the one unguarded destroy. Guarded on the + // length test so the steady-state path — where the cache is resident and never evicts — + // pays nothing. + if self.import_cache.len() >= IMPORT_CACHE_CAP { + let _ = self.device.device_wait_idle(); + } while self.import_cache.len() >= IMPORT_CACHE_CAP { let (_, _, oi, om, ov) = self.import_cache.remove(0); self.device.destroy_image_view(ov, None); @@ -1849,8 +1864,25 @@ impl VulkanVideoEncoder { unsafe fn read_slot(&mut self, slot: usize) -> Result { let dev = self.device.clone(); let f = &self.frames[slot]; - let mut fb = [[0u32; 2]; 1]; - dev.get_query_pool_results(f.query_pool, 0, &mut fb, vk::QueryResultFlags::WAIT)?; + // Ask for the operation status alongside the two feedback words: without it a FAILED encode + // is indistinguishable from a successful one, and its offset/bytes-written are read as if + // they described real bitstream. The status rides as a trailing element (signed: + // `VkQueryResultStatusKHR` is >0 COMPLETE, 0 NOT_READY, <0 error). + let mut fb = [[0i32; 3]; 1]; + dev.get_query_pool_results( + f.query_pool, + 0, + &mut fb, + vk::QueryResultFlags::WAIT | vk::QueryResultFlags::WITH_STATUS_KHR, + )?; + let status = fb[0][2]; + if status <= 0 { + anyhow::bail!( + "vulkan-encode: encode feedback for slot {slot} reports status {status} \ + (not COMPLETE) — dropping the frame rather than shipping its bitstream" + ); + } + let fb = [[fb[0][0] as u32, fb[0][1] as u32]]; // The (offset, bytes-written) pair is driver-reported: validate it against the bitstream // allocation BEFORE mapping, or the `from_raw_parts` below reads outside the buffer and // ships whatever it finds straight onto the wire. Checked in u64 so the add cannot wrap, @@ -1892,8 +1924,25 @@ impl VulkanVideoEncoder { // and free it — that oldest slot is exactly the round-robin `ring` cursor we reuse next. while self.in_flight.len() >= self.frames.len() { let slot = self.in_flight.pop_front().unwrap(); - self.device - .wait_for_fences(&[self.frames[slot].fence], true, u64::MAX)?; + // Bounded, not `u64::MAX`: this runs ON the host encode thread, which is also the + // thread the stall watchdog's `reset()` would run on. An infinite wait against a + // wedged GPU/driver therefore parks the one thread that could recover the session — + // it never errors, never resets, and teardown blocks joining it. Surfacing expiry as + // an error hands control back to the existing recovery path (same convention as the + // pyrowave and Windows NVENC backends). + match self.device.wait_for_fences( + &[self.frames[slot].fence], + true, + ENCODE_FENCE_TIMEOUT_NS, + ) { + Ok(()) => {} + Err(vk::Result::TIMEOUT) => anyhow::bail!( + "vulkan-encode: fence for slot {slot} did not signal within {} ms — GPU or \ + driver wedged; failing the submit so the session can reset", + ENCODE_FENCE_TIMEOUT_NS / 1_000_000 + ), + Err(e) => return Err(e.into()), + } let done = self.read_slot(slot)?; self.pending.push_back(done); } diff --git a/crates/pf-encode/src/enc/windows/pyrowave.rs b/crates/pf-encode/src/enc/windows/pyrowave.rs index a68fd3d9..72fdc3b6 100644 --- a/crates/pf-encode/src/enc/windows/pyrowave.rs +++ b/crates/pf-encode/src/enc/windows/pyrowave.rs @@ -43,6 +43,9 @@ const BS_SLACK: usize = 256 * 1024; /// (a desktop-switch device recreate), in which case the stale imports are evicted + destroyed. const IMPORT_CACHE_CAP: usize = 8; +/// Plane-import cache key: the texture's COM address plus the extent it was imported at. +type PlaneKey = (isize, u32, u32); + // --- Vulkan enum values not surfaced by pyrowave-sys' bindgen (only enums *reachable* from the // pyrowave C API are generated; these plain #define / flags-typedef values are stable spec // constants). bindgen renders every reachable Vulkan enum as a `u32` type alias, so these u32 @@ -136,8 +139,8 @@ pub struct PyroWaveEncoder { // Imported plane textures, cached by the out-ring texture's raw pointer (stable per ring slot): // the full-res R8 Y plane and the half-res R8G8 CbCr plane, imported SEPARATELY (a single planar // NV12 import is unreliable on NVIDIA at arbitrary sizes). - y_images: Vec<(isize, pw::pyrowave_image)>, - cbcr_images: Vec<(isize, pw::pyrowave_image)>, + y_images: Vec<(PlaneKey, pw::pyrowave_image)>, + cbcr_images: Vec<(PlaneKey, pw::pyrowave_image)>, width: u32, height: u32, @@ -351,10 +354,16 @@ impl PyroWaveEncoder { /// /// # Safety /// Same contract as [`import_plane`]. + /// Keyed on `(texture address, width, height)` rather than the bare address: the COM pointer + /// carries no reference here, so a released texture's address can be recycled by a later + /// allocation and return an import describing the WRONG surface. Folding the extent in means a + /// recycled address at a different size can never alias. (A recycle at the SAME size is still + /// possible in principle — the complete fix is to key on the capturer's ring generation, which + /// needs that generation plumbed onto `PyroFrameShare`.) unsafe fn cached_plane( - cache: &mut Vec<(isize, pw::pyrowave_image)>, + cache: &mut Vec<(PlaneKey, pw::pyrowave_image)>, make: impl FnOnce() -> Result, - key: isize, + key: PlaneKey, ) -> Result { if let Some((_, img)) = cache.iter().find(|(k, _)| *k == key) { return Ok(*img); @@ -423,6 +432,21 @@ impl PyroWaveEncoder { !self.pw_enc.is_null(), "pyrowave: encode after a failed reset (encoder was destroyed and not rebuilt)" ); + // The plane textures are imported at the encoder's CONFIGURED extent, not the frame's, so a + // capture that changed size would be read under a stale `VkImageCreateInfo`. This is + // reachable without any client Reconfigure: the IDD capturer autonomously recreates its ring + // on a confirmed display-descriptor change (e.g. a fullscreen game mode-setting the virtual + // display). Refuse instead — the session must reopen the encoder at the new mode. Mirrors + // the guard the QSV and AMF backends already carry. + anyhow::ensure!( + frame.width == self.width && frame.height == self.height, + "pyrowave: captured frame {}x{} != encoder {}x{} (the capturer recreated its ring at a \ + new mode — the encoder must be reopened)", + frame.width, + frame.height, + self.width, + self.height + ); let FramePayload::D3d11(d3d) = &frame.payload else { bail!("pyrowave (Windows) needs a D3D11 frame (the capturer must be in pyrowave mode)") }; @@ -465,7 +489,7 @@ impl PyroWaveEncoder { }; let pw_dev = self.pw_dev; let y_img = { - let key = d3d.texture.as_raw() as isize; + let key = (d3d.texture.as_raw() as isize, w, h); let tex = &d3d.texture; Self::cached_plane( &mut self.y_images, @@ -474,7 +498,7 @@ impl PyroWaveEncoder { )? }; let cbcr_img = { - let key = share.cbcr.as_raw() as isize; + let key = (share.cbcr.as_raw() as isize, cw, ch); let tex = &share.cbcr; Self::cached_plane( &mut self.cbcr_images,