From a09d7ebf10d2e00ce4a729af0da34fb49ba58db4 Mon Sep 17 00:00:00 2001 From: repparw Date: Sun, 1 Nov 2026 13:48:22 -0300 Subject: [PATCH 1/7] fix(vulkan): gate context teardown on GPU completion --- src/vulkan.rs | 54 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/vulkan.rs b/src/vulkan.rs index 9d5928f7..b69f5adb 100644 --- a/src/vulkan.rs +++ b/src/vulkan.rs @@ -7,6 +7,25 @@ use std::ffi::{CStr, CString}; use std::os::raw::c_char; use tracing::{debug, info, warn}; +/// Successful GPU waits establish completion. Vulkan treats device loss as +/// completion for pending-command-buffer and resource-in-use cleanup rules. +pub(crate) fn gpu_work_completed(result: &std::result::Result<(), vk::Result>) -> bool { + matches!(result, Ok(()) | Err(vk::Result::ERROR_DEVICE_LOST)) +} + +#[cfg(test)] +mod teardown_tests { + use super::*; + + #[test] + fn only_success_or_device_loss_permits_resource_destruction() { + assert!(gpu_work_completed(&Ok(()))); + assert!(gpu_work_completed(&Err(vk::Result::ERROR_DEVICE_LOST))); + assert!(!gpu_work_completed(&Err(vk::Result::ERROR_OUT_OF_HOST_MEMORY))); + assert!(!gpu_work_completed(&Err(vk::Result::ERROR_UNKNOWN))); + } +} + mod encode_device; mod queues; use queues::QueueOverrides; @@ -628,8 +647,43 @@ struct VideoContextInner { debug_messenger: Option<(ash::ext::debug_utils::Instance, vk::DebugUtilsMessengerEXT)>, } +impl VideoContextInner { + /// Make sure retired timeline semaphores are no longer referenced by GPU + /// submissions before freeing them. A context-owned device must idle all + /// its queues before destruction. For a borrowed device, only wait on + /// pixelforge's queues; the caller remains responsible for any other work + /// it submitted using pixelforge's exported TimelinePoints. + fn wait_before_teardown(&self) -> std::result::Result<(), vk::Result> { + if self.owns_device { + return unsafe { self.device.device_wait_idle() }; + } + let mut queues = vec![self.compute_queue, self.transfer_queue]; + if let Some(queue) = self.video_encode_queue { + if !queues.contains(&queue) { + queues.push(queue); + } + } + if let Some(queue) = self.video_decode_queue { + if !queues.contains(&queue) { + queues.push(queue); + } + } + for queue in queues { + unsafe { self.device.queue_wait_idle(queue)? }; + } + Ok(()) + } +} + impl Drop for VideoContextInner { fn drop(&mut self) { + let completion = self.wait_before_teardown(); + if !gpu_work_completed(&completion) { + tracing::error!(?completion, "Cannot prove GPU completion; retaining Vulkan device and retired semaphores"); + // ash handles have no automatic destructor. Retain rather than + // destroy resources with unresolved GPU references. + return; + } // Ours whoever owns the device, and gone before the device can be. let retired = std::mem::take( self.retired_semaphores From 4714928fd3441425b2b082608ae301a0b9fe0a26 Mon Sep 17 00:00:00 2001 From: repparw Date: Sun, 1 Nov 2026 13:48:34 -0300 Subject: [PATCH 2/7] fix(converter): preserve resources after unresolved fence wait --- src/converter/mod.rs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/converter/mod.rs b/src/converter/mod.rs index 15eadaa7..a12d211c 100644 --- a/src/converter/mod.rs +++ b/src/converter/mod.rs @@ -12,7 +12,7 @@ use crate::encoder::ColorDescription; use crate::error::{PixelForgeError, Result}; use crate::sync::TimelinePoint; use crate::video::TimelineChain; -use crate::vulkan::VideoContext; +use crate::vulkan::{VideoContext, gpu_work_completed}; use ash::vk; use tracing::debug; @@ -1143,7 +1143,23 @@ impl ColorConverter { impl Drop for ColorConverter { fn drop(&mut self) { // Nothing below may be destroyed while a conversion still uses it. - let _ = self.wait_idle(); + if let Err(error) = self.wait_idle() { + tracing::error!(?error, "Converter fence wait failed during teardown"); + // A failed fence wait leaves completion unknown. Synchronize + // against the queue that submitted this conversion before cleanup. + let completion = unsafe { + self.context.device().queue_wait_idle(self.context.compute_queue()) + }; + if !gpu_work_completed(&completion) { + tracing::error!(?completion, "Cannot prove converter GPU completion; retaining Vulkan resources"); + // Keep the device alive if this is the last converter using it. + // Raw Vulkan handles are deliberately leaked on this path. + std::mem::forget(self.context.clone()); + return; + } + self.in_flight = false; + self.destroy_src_view(); + } unsafe { let device = self.context.device(); // Not destroyed here: an encode may still be waiting on a point From 60c2353d0753b043e1fd8fa433ab71cc6c79f13e Mon Sep 17 00:00:00 2001 From: repparw Date: Sun, 1 Nov 2026 13:48:44 -0300 Subject: [PATCH 3/7] fix(encoder): preserve resources after unresolved queue waits --- src/encoder/codec.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/encoder/codec.rs b/src/encoder/codec.rs index 32444f6e..d9bc960f 100644 --- a/src/encoder/codec.rs +++ b/src/encoder/codec.rs @@ -25,7 +25,7 @@ use crate::encoder::resources::{ use crate::encoder::{ColorDescription, EncodeConfig, FrameType, RateControlMode}; use crate::error::{PixelForgeError, Result}; use crate::sync::TimelinePoint; -use crate::vulkan::VideoContext; +use crate::vulkan::{VideoContext, gpu_work_completed}; /// Per-encoder state shared by every codec. /// @@ -403,9 +403,19 @@ impl Drop for CodecEncoder { let common = &mut self.common; let device = common.context.device(); // Wait on just the queues this encoder used, not the whole device. - let _ = device.queue_wait_idle(common.context.transfer_queue()); + let transfer = device.queue_wait_idle(common.context.transfer_queue()); + if !gpu_work_completed(&transfer) { + tracing::error!(?transfer, "Cannot prove encoder transfer completion; retaining Vulkan resources"); + std::mem::forget(common.context.clone()); + return; + } if let Some(q) = common.context.video_encode_queue() { - let _ = device.queue_wait_idle(q); + let encode = device.queue_wait_idle(q); + if !gpu_work_completed(&encode) { + tracing::error!(?encode, "Cannot prove video encode completion; retaining Vulkan resources"); + std::mem::forget(common.context.clone()); + return; + } } common.pipeline.destroy(device); From d36307e5e30bf8cdcccb05d21d75058befd9e14e Mon Sep 17 00:00:00 2001 From: repparw Date: Sun, 1 Nov 2026 13:51:27 -0300 Subject: [PATCH 4/7] style(converter): format synchronization fallback --- src/converter/mod.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/converter/mod.rs b/src/converter/mod.rs index a12d211c..719beebf 100644 --- a/src/converter/mod.rs +++ b/src/converter/mod.rs @@ -1148,10 +1148,15 @@ impl Drop for ColorConverter { // A failed fence wait leaves completion unknown. Synchronize // against the queue that submitted this conversion before cleanup. let completion = unsafe { - self.context.device().queue_wait_idle(self.context.compute_queue()) + self.context + .device() + .queue_wait_idle(self.context.compute_queue()) }; if !gpu_work_completed(&completion) { - tracing::error!(?completion, "Cannot prove converter GPU completion; retaining Vulkan resources"); + tracing::error!( + ?completion, + "Cannot prove converter GPU completion; retaining Vulkan resources" + ); // Keep the device alive if this is the last converter using it. // Raw Vulkan handles are deliberately leaked on this path. std::mem::forget(self.context.clone()); From 4ecba435f783e4feeac6bae7c0f8508df5f5d825 Mon Sep 17 00:00:00 2001 From: repparw Date: Sun, 1 Nov 2026 13:51:35 -0300 Subject: [PATCH 5/7] style(encoder): format failed-wait diagnostics --- src/encoder/codec.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/encoder/codec.rs b/src/encoder/codec.rs index d9bc960f..5ab280a9 100644 --- a/src/encoder/codec.rs +++ b/src/encoder/codec.rs @@ -405,14 +405,20 @@ impl Drop for CodecEncoder { // Wait on just the queues this encoder used, not the whole device. let transfer = device.queue_wait_idle(common.context.transfer_queue()); if !gpu_work_completed(&transfer) { - tracing::error!(?transfer, "Cannot prove encoder transfer completion; retaining Vulkan resources"); + tracing::error!( + ?transfer, + "Cannot prove encoder transfer completion; retaining Vulkan resources" + ); std::mem::forget(common.context.clone()); return; } if let Some(q) = common.context.video_encode_queue() { let encode = device.queue_wait_idle(q); if !gpu_work_completed(&encode) { - tracing::error!(?encode, "Cannot prove video encode completion; retaining Vulkan resources"); + tracing::error!( + ?encode, + "Cannot prove video encode completion; retaining Vulkan resources" + ); std::mem::forget(common.context.clone()); return; } From 6bf0aa719bf886e0337ca4161e882d6b569343a2 Mon Sep 17 00:00:00 2001 From: repparw Date: Sun, 1 Nov 2026 13:51:47 -0300 Subject: [PATCH 6/7] style(vulkan): deduplicate borrowed queues and format diagnostics --- src/vulkan.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/vulkan.rs b/src/vulkan.rs index b69f5adb..0abc3178 100644 --- a/src/vulkan.rs +++ b/src/vulkan.rs @@ -657,7 +657,10 @@ impl VideoContextInner { if self.owns_device { return unsafe { self.device.device_wait_idle() }; } - let mut queues = vec![self.compute_queue, self.transfer_queue]; + let mut queues = vec![self.compute_queue]; + if !queues.contains(&self.transfer_queue) { + queues.push(self.transfer_queue); + } if let Some(queue) = self.video_encode_queue { if !queues.contains(&queue) { queues.push(queue); @@ -679,7 +682,10 @@ impl Drop for VideoContextInner { fn drop(&mut self) { let completion = self.wait_before_teardown(); if !gpu_work_completed(&completion) { - tracing::error!(?completion, "Cannot prove GPU completion; retaining Vulkan device and retired semaphores"); + tracing::error!( + ?completion, + "Cannot prove GPU completion; retaining Vulkan device and retired semaphores" + ); // ash handles have no automatic destructor. Retain rather than // destroy resources with unresolved GPU references. return; From 6a91940856e20d58663a1afcd71665f6bb475c01 Mon Sep 17 00:00:00 2001 From: repparw Date: Sun, 1 Nov 2026 13:52:37 -0300 Subject: [PATCH 7/7] fix(encoder): wait for compute writes to input slots before teardown --- src/encoder/codec.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/encoder/codec.rs b/src/encoder/codec.rs index 5ab280a9..32fcce06 100644 --- a/src/encoder/codec.rs +++ b/src/encoder/codec.rs @@ -402,7 +402,18 @@ impl Drop for CodecEncoder { unsafe { let common = &mut self.common; let device = common.context.device(); - // Wait on just the queues this encoder used, not the whole device. + // The converter may still be writing this encoder's input image on + // the compute queue, even if no encode submission followed it. + // Wait for compute as well as the encoder's transfer and video queues. + let compute = device.queue_wait_idle(common.context.compute_queue()); + if !gpu_work_completed(&compute) { + tracing::error!( + ?compute, + "Cannot prove encoder input conversion completed; retaining Vulkan resources" + ); + std::mem::forget(common.context.clone()); + return; + } let transfer = device.queue_wait_idle(common.context.transfer_queue()); if !gpu_work_completed(&transfer) { tracing::error!(