From 9046fc8b2a0b202a6a0be23dce89c3a7989652f8 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:44:08 +0100 Subject: [PATCH 1/3] fix: reject export previews after shared GPU device loss --- .github/workflows/ci.yml | 12 ++ apps/desktop/src-tauri/Cargo.toml | 1 + apps/desktop/src-tauri/src/export.rs | 3 + apps/desktop/src-tauri/src/gpu_context.rs | 18 +++ .../src-tauri/src/gpu_device_health.rs | 119 ++++++++++++++++++ apps/desktop/src-tauri/src/lib.rs | 1 + apps/desktop/src/routes/editor/ExportPage.tsx | 6 + 7 files changed, 160 insertions(+) create mode 100644 apps/desktop/src-tauri/src/gpu_device_health.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1bdd0e32d7..1ae98bcb221 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -312,6 +312,18 @@ jobs: shell: bash run: ./scripts/build-desktop-binaries.sh ${{ matrix.settings.target }} + - name: Test export preview device-loss handling + shell: bash + run: | + if [[ "$RUNNER_OS" == "Windows" ]]; then + export PATH="$(cygpath "$GITHUB_WORKSPACE/target/debug"):$PATH" + fi + cargo test --locked -p cap-desktop --lib gpu_device_health::tests + env: + DYLD_FRAMEWORK_PATH: ${{ format('{0}/target/Frameworks', github.workspace) }} + DYLD_LIBRARY_PATH: ${{ format('{0}/target/native-deps/lib:{0}/target/debug', github.workspace) }} + LD_LIBRARY_PATH: ${{ runner.os == 'Linux' && format('{0}/target/native-deps/lib:{0}/target/debug', github.workspace) || '' }} + - name: Test Linux desktop regressions if: ${{ runner.os == 'Linux' }} shell: bash diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 38c8ca2d2a7..928340c711e 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -189,3 +189,4 @@ nix = { version = "0.29.0", features = ["fs"] } [dev-dependencies] tauri = { workspace = true, features = ["test"] } +wgpu = { workspace = true, features = ["noop"] } diff --git a/apps/desktop/src-tauri/src/export.rs b/apps/desktop/src-tauri/src/export.rs index f5b87f89cb5..cab420a533f 100644 --- a/apps/desktop/src-tauri/src/export.rs +++ b/apps/desktop/src-tauri/src/export.rs @@ -2542,6 +2542,8 @@ async fn generate_export_preview_fast_inner( let _preview_guard = ExportPreviewActiveGuard::try_new(&editor.export_preview_active)?; + crate::gpu_context::ensure_shared_device_available()?; + let mut project_config = load_export_preview_config(editor.project_path.clone(), settings.cursor_only).await?; let meta = editor.meta().clone(); @@ -2626,6 +2628,7 @@ async fn generate_export_preview_fast_inner( &zoom_timeline, ); + crate::gpu_context::ensure_shared_device_available()?; let mut frame_renderer = FrameRenderer::new(&editor.render_constants); let mut layers = RendererLayers::new_with_options( &editor.render_constants.device, diff --git a/apps/desktop/src-tauri/src/gpu_context.rs b/apps/desktop/src-tauri/src/gpu_context.rs index 7edbe19b29a..9472df5d298 100644 --- a/apps/desktop/src-tauri/src/gpu_context.rs +++ b/apps/desktop/src-tauri/src/gpu_context.rs @@ -39,6 +39,7 @@ impl PendingScreenshots { } pub struct SharedGpuContext { + health: crate::gpu_device_health::GpuDeviceHealth, pub device: Arc, pub queue: Arc, pub adapter: Arc, @@ -145,7 +146,15 @@ async fn init_gpu_inner() -> Option { .await .ok()?; + let health = crate::gpu_device_health::GpuDeviceHealth::track(&device, |reason, message| { + tracing::error!(?reason, %message, "Shared GPU device lost"); + if reason == wgpu::DeviceLostReason::Unknown { + sentry::capture_message("Shared GPU device lost", sentry::Level::Error); + } + }); + Some(SharedGpuContext { + health, device: Arc::new(device), queue: Arc::new(queue), adapter: Arc::new(adapter), @@ -186,3 +195,12 @@ pub fn prewarm_gpu() { get_shared_gpu().await; }); } + +// Every Tauri video editor uses this OnceCell's device when initialization succeeds. +// Its resources cannot recover by reopening the editor or replacing only the device. +pub fn ensure_shared_device_available() -> Result<(), String> { + if let Some(Some(gpu)) = GPU.get() { + gpu.health.ensure_available().map_err(str::to_string)?; + } + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/gpu_device_health.rs b/apps/desktop/src-tauri/src/gpu_device_health.rs new file mode 100644 index 00000000000..382faaa7b0a --- /dev/null +++ b/apps/desktop/src-tauri/src/gpu_device_health.rs @@ -0,0 +1,119 @@ +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; + +#[derive(Clone)] +pub struct GpuDeviceHealth { + lost: Arc, +} + +impl GpuDeviceHealth { + pub fn track( + device: &wgpu::Device, + on_lost: impl Fn(wgpu::DeviceLostReason, String) + Send + 'static, + ) -> Self { + let lost = Arc::new(AtomicBool::new(false)); + let callback_lost = lost.clone(); + device.set_device_lost_callback(move |reason, message| { + callback_lost.store(true, Ordering::Release); + on_lost(reason, message); + }); + Self { lost } + } + + pub fn ensure_available(&self) -> Result<(), &'static str> { + if self.lost.load(Ordering::Acquire) { + return Err("The graphics device was lost. Restart Cap to generate an export preview."); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::AtomicUsize; + + fn device() -> (wgpu::Device, wgpu::Queue) { + wgpu::Device::noop(&wgpu::DeviceDescriptor::default()) + } + + fn preview_pipeline( + health: &GpuDeviceHealth, + device: &wgpu::Device, + ) -> Result { + health.ensure_available()?; + Ok(device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor::default())) + } + + #[test] + fn unguarded_repeated_previews_panic_after_device_loss() { + let (device, _queue) = device(); + device.destroy(); + device.poll(wgpu::PollType::Poll).unwrap(); + + for _ in 0..9 { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("NV12 Converter Pipeline Layout"), + ..Default::default() + }) + })); + assert!(result.is_err()); + } + } + + #[test] + fn repeated_previews_reject_a_lost_device_and_its_clones() { + let (device, _queue) = device(); + let reports = Arc::new(AtomicUsize::new(0)); + let callback_reports = reports.clone(); + let health = GpuDeviceHealth::track(&device, move |_, _| { + callback_reports.fetch_add(1, Ordering::Relaxed); + }); + let editor_device = device.clone(); + let editor_health = health.clone(); + let _retained_pipeline = preview_pipeline(&editor_health, &editor_device).unwrap(); + + device.destroy(); + device.poll(wgpu::PollType::Poll).unwrap(); + + for _ in 0..9 { + assert_eq!( + preview_pipeline(&editor_health, &editor_device).unwrap_err(), + "The graphics device was lost. Restart Cap to generate an export preview." + ); + } + assert_eq!(reports.load(Ordering::Relaxed), 1); + } + + #[test] + fn loss_does_not_disable_an_independent_device() { + let (lost_device, _lost_queue) = device(); + let health = GpuDeviceHealth::track(&lost_device, |_, _| {}); + lost_device.destroy(); + lost_device.poll(wgpu::PollType::Poll).unwrap(); + + let (independent_device, _queue) = device(); + let independent_health = GpuDeviceHealth::track(&independent_device, |_, _| {}); + preview_pipeline(&independent_health, &independent_device).unwrap(); + assert!(preview_pipeline(&health, &lost_device).is_err()); + } + + #[test] + fn healthy_device_validation_errors_still_panic() { + let (device, _queue) = device(); + let health = GpuDeviceHealth::track(&device, |_, _| {}); + health.ensure_available().unwrap(); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("invalid synthetic shader"), + source: wgpu::ShaderSource::Wgsl("invalid wgsl".into()), + }) + })); + assert!(result.is_err()); + health.ensure_available().unwrap(); + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index a78c370641e..e2f211d85ed 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -105,6 +105,7 @@ use screenshot_editor::{ }; mod gpu_context; +mod gpu_device_health; pub use gpu_context::{PendingScreenshot, PendingScreenshots}; use serde::{Deserialize, Serialize}; use serde_json::json; diff --git a/apps/desktop/src/routes/editor/ExportPage.tsx b/apps/desktop/src/routes/editor/ExportPage.tsx index 68e60f5f6ac..f6532fa0ab0 100644 --- a/apps/desktop/src/routes/editor/ExportPage.tsx +++ b/apps/desktop/src/routes/editor/ExportPage.tsx @@ -554,6 +554,12 @@ export function ExportPage() { if (!isPreviewCurrent(request)) return; return runPreviewRequest(request, retryCount + 1); } + if (ownedPreviewUrl) URL.revokeObjectURL(ownedPreviewUrl); + ownedPreviewUrl = null; + setPreviewUrl(null); + setPreviewDimensions(null); + setRenderEstimate(null); + setPreviewError(e instanceof Error ? e.message : String(e)); setPreviewUnavailable(true); setEstimateLoading(false); } From 61a2d4066196cc09e2530e3e6e79f0e3e0414727 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:17:51 +0100 Subject: [PATCH 2/3] test: isolate GPU loss checks from desktop runtime libraries --- .github/workflows/ci.yml | 10 +--------- apps/desktop/src-tauri/Cargo.toml | 4 ++++ 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ae98bcb221..03882d757c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -314,15 +314,7 @@ jobs: - name: Test export preview device-loss handling shell: bash - run: | - if [[ "$RUNNER_OS" == "Windows" ]]; then - export PATH="$(cygpath "$GITHUB_WORKSPACE/target/debug"):$PATH" - fi - cargo test --locked -p cap-desktop --lib gpu_device_health::tests - env: - DYLD_FRAMEWORK_PATH: ${{ format('{0}/target/Frameworks', github.workspace) }} - DYLD_LIBRARY_PATH: ${{ format('{0}/target/native-deps/lib:{0}/target/debug', github.workspace) }} - LD_LIBRARY_PATH: ${{ runner.os == 'Linux' && format('{0}/target/native-deps/lib:{0}/target/debug', github.workspace) || '' }} + run: cargo test --locked -p cap-desktop --test gpu_device_health - name: Test Linux desktop regressions if: ${{ runner.os == 'Linux' }} diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 928340c711e..1574732aa4c 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -190,3 +190,7 @@ nix = { version = "0.29.0", features = ["fs"] } [dev-dependencies] tauri = { workspace = true, features = ["test"] } wgpu = { workspace = true, features = ["noop"] } + +[[test]] +name = "gpu_device_health" +path = "src/gpu_device_health.rs" From c96708a4b7f0b5c52c27219282b78a567739124d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:57:24 +0800 Subject: [PATCH 3/3] ci: run the device-loss test after the GPUI build step --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 562c9a49d53..b70ede3ffab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -313,10 +313,6 @@ jobs: shell: bash run: ./scripts/build-desktop-binaries.sh ${{ matrix.settings.target }} - - name: Test export preview device-loss handling - shell: bash - run: cargo test --locked -p cap-desktop --test gpu_device_health - - name: Test Linux desktop regressions if: ${{ runner.os == 'Linux' }} shell: bash @@ -334,6 +330,10 @@ jobs: shell: bash run: ./scripts/build-gpui-binary.sh debug ${{ matrix.settings.target }} + - name: Test export preview device-loss handling + shell: bash + run: cargo test --locked -p cap-desktop --test gpu_device_health + - name: Test GPUI desktop shell: bash run: |