diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd28b8a808e..b70ede3ffab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -330,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: | diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 38c8ca2d2a7..1574732aa4c 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -189,3 +189,8 @@ 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" 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); }