Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
3 changes: 3 additions & 0 deletions apps/desktop/src-tauri/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/src-tauri/src/gpu_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ impl PendingScreenshots {
}

pub struct SharedGpuContext {
health: crate::gpu_device_health::GpuDeviceHealth,
pub device: Arc<wgpu::Device>,
pub queue: Arc<wgpu::Queue>,
pub adapter: Arc<wgpu::Adapter>,
Expand Down Expand Up @@ -145,7 +146,15 @@ async fn init_gpu_inner() -> Option<SharedGpuContext> {
.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),
Expand Down Expand Up @@ -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(())
}
119 changes: 119 additions & 0 deletions apps/desktop/src-tauri/src/gpu_device_health.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};

#[derive(Clone)]
pub struct GpuDeviceHealth {
lost: Arc<AtomicBool>,
}

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<wgpu::PipelineLayout, &'static str> {
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();
}
}
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/routes/editor/ExportPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading