From 3c063b50750802aa33aebb0e9ae7f1c1359bc27b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 22:19:09 +0000 Subject: [PATCH] GPU: a failed allocation reported as an internal error, or a lost device, no longer crashes the app (#591) On Adreno (Windows ARM64) a failed vkAllocateMemory surfaces as an internal ERROR_INITIALIZATION_FAILED, not out-of-memory. Gpu::within_memory only scoped OutOfMemory, and wgpu's DeviceExt::create_buffer_init maps the new buffer at creation and panics ("Failed to get mapped range") when the allocation failed, taking down a frame thread. Buffers with initial contents are now created unmapped and filled through queue.write_buffer (context::buffer_init), so a failed allocation is only an invalid buffer and an error for the caller's scope, and within_memory scopes Internal errors too, discarding the frame for the CPU path as it does for out-of-memory. The device was then lost, and egui-wgpu panicked on the main thread staging the next frame's index buffer on the dead device. Once the host reports the presentation device lost, the app draws nothing more (no mesh to stage), auto-saves unsaved changes, keeps the crash-recovery marker so the next launch offers them, and closes its window. Tests: a device with a small buffer limit gets an over-limit and an unaligned buffer_init without a panic and sees the error in its scope; the UI harness reports a device loss and checks the next frame has no shapes, closes the window and wrote the auto-save. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/gpu/src/adv3d.rs | 20 ++++++------------ crates/gpu/src/context.rs | 37 ++++++++++++++++++++++++++++------ crates/gpu/src/lib.rs | 13 ++++++++---- crates/gpu/src/particles.rs | 9 ++------- crates/gpu/src/tests.rs | 22 ++++++++++++++++++++ crates/ui-egui/src/lib.rs | 35 ++++++++++++++++++++++++++++++-- crates/ui-egui/tests/ui_gpu.rs | 27 +++++++++++++++++++++++++ 7 files changed, 130 insertions(+), 33 deletions(-) diff --git a/crates/gpu/src/adv3d.rs b/crates/gpu/src/adv3d.rs index f3b9fee2e..6ae11445a 100644 --- a/crates/gpu/src/adv3d.rs +++ b/crates/gpu/src/adv3d.rs @@ -15,7 +15,6 @@ use effectcraft_render::three_d::Dof; use effectcraft_render::three_d::adv::{Prepared, Rendered, Scene, Target}; use effectcraft_render::three_d::bokeh; -use wgpu::util::DeviceExt; use crate::context::{Enc, GpuContext, GpuImage}; @@ -443,14 +442,11 @@ fn raster_into(e: &mut Enc, s: &Scene) -> Option<(wgpu::Texture, wgpu::Texture)> b }) .collect(); - let vbuf = dev.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("adv3d vertices"), contents: &vb, usage: wgpu::BufferUsages::VERTEX }); + let vbuf = g.buffer_init(Some("adv3d vertices"), &vb, wgpu::BufferUsages::VERTEX); let ib: Vec = s.indices.iter().flat_map(|i| i.to_le_bytes()).collect(); - let ibuf = dev.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("adv3d indices"), contents: &ib, usage: wgpu::BufferUsages::INDEX }); - let ub = dev.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("adv3d globals"), contents: &bufs[0], usage: wgpu::BufferUsages::UNIFORM }); - let sbs: Vec = bufs[1..] - .iter() - .map(|b| dev.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("adv3d data"), contents: b, usage: wgpu::BufferUsages::STORAGE })) - .collect(); + let ibuf = g.buffer_init(Some("adv3d indices"), &ib, wgpu::BufferUsages::INDEX); + let ub = g.buffer_init(Some("adv3d globals"), &bufs[0], wgpu::BufferUsages::UNIFORM); + let sbs: Vec = bufs[1..].iter().map(|b| g.buffer_init(Some("adv3d data"), b, wgpu::BufferUsages::STORAGE)).collect(); let mut entries = vec![wgpu::BindGroupEntry { binding: 0, resource: ub.as_entire_binding() }]; for (i, b) in sbs.iter().enumerate() { entries.push(wgpu::BindGroupEntry { binding: i as u32 + 1, resource: b.as_entire_binding() }); @@ -736,7 +732,7 @@ fn dispatch(e: &mut Enc, kernel: &str, u0: [u32; 4], f0: [f32; 4], b: Binds, gro let g = e.g; let post = &pipes(g).post; let Some((_, pipe)) = post.kernels.iter().find(|(k, _)| *k == kernel) else { return }; - let ub = g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: None, contents: ¶ms(u0, f0), usage: wgpu::BufferUsages::UNIFORM }); + let ub = g.buffer_init(None, ¶ms(u0, f0), wgpu::BufferUsages::UNIFORM); let mut entries = vec![ wgpu::BindGroupEntry { binding: 0, resource: ub.as_entire_binding() }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(b.color.unwrap_or(&post.dummy_color)) }, @@ -859,11 +855,7 @@ fn depth_of_field(e: &mut Enc, acc: &wgpu::Buffer, depth: &wgpu::Buffer, (w, h): for v in [f32::INFINITY.to_bits(), 0, 0, 0] { init.extend_from_slice(&v.to_le_bytes()); } - let range = g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("adv3d radius range"), - contents: &init, - usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, - }); + let range = g.buffer_init(Some("adv3d radius range"), &init, wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC); let f0 = [dof.focus as f32, dof.aperture as f32, dof.blur_level as f32, scale as f32]; let b = Binds { bufs: [None, Some(depth), None, None, Some(&radius), Some(&range), None], ..Default::default() }; dispatch(e, "radius_of", [w, h, 0, 0], f0, b, pixel_groups(w, h)); diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index c5065eae5..788757bfe 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -5,7 +5,6 @@ use std::sync::{Arc, Mutex, Weak}; use effectcraft_effects::Buf; use effectcraft_raster::Image; -use wgpu::util::DeviceExt; /// Compute entry points in `kernels.wgsl` (one pipeline each). const ENTRIES: &[&str] = &[ @@ -325,7 +324,34 @@ fn init_resource(_device: &wgpu::Device, _label: &str, create: impl FnOnce() Ok(create()) } +/// A buffer holding `contents` (padded to 4 bytes), written through the queue. Unlike +/// `DeviceExt::create_buffer_init`, which maps the buffer at creation and panics when the +/// allocation failed (a driver reports it as an internal error, not out-of-memory: Adreno's +/// `ERROR_INITIALIZATION_FAILED`, #591), a failed allocation only yields an invalid buffer and +/// an error for the caller's error scope. +pub(crate) fn buffer_init(device: &wgpu::Device, queue: &wgpu::Queue, label: Option<&str>, contents: &[u8], usage: wgpu::BufferUsages) -> wgpu::Buffer { + let align = wgpu::COPY_BUFFER_ALIGNMENT as usize; + let padded = contents.len().div_ceil(align).saturating_mul(align); + let buffer = + device.create_buffer(&wgpu::BufferDescriptor { label, size: padded as u64, usage: usage | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); + if padded > 0 { + if padded == contents.len() { + queue.write_buffer(&buffer, 0, contents); + } else { + let mut data = contents.to_vec(); + data.resize(padded, 0); + queue.write_buffer(&buffer, 0, &data); + } + } + buffer +} + impl GpuContext { + /// [`buffer_init`] on this context's device. + pub(crate) fn buffer_init(&self, label: Option<&str>, contents: &[u8], usage: wgpu::BufferUsages) -> wgpu::Buffer { + buffer_init(&self.device, &self.queue, label, contents, usage) + } + /// Build on an existing device (the desktop app shares egui-wgpu's). `Err` when the /// adapter cannot run the compositor (no compute shaders, e.g. WebGL2; no float storage /// textures) or the device was created with limits below what its pipelines bind. @@ -549,9 +575,8 @@ impl GpuContext { view_formats: &[], }) })?; - let dummy_buf = init_resource(&device, "dummy storage buffer", || { - device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("dummy"), contents: &[0u8; 16], usage: wgpu::BufferUsages::STORAGE }) - })?; + let dummy_buf = + init_resource(&device, "dummy storage buffer", || buffer_init(&device, &queue, Some("dummy"), &[0u8; 16], wgpu::BufferUsages::STORAGE))?; let dummy_tex = init_resource(&device, "dummy texture view", || dummy.create_view(&Default::default()))?; let max_dim = device.limits().max_texture_dimension_2d; Ok(GpuContext { @@ -996,7 +1021,7 @@ impl<'g> Enc<'g> { self.missing = true; return; }; - let ub = g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: None, contents: &p.bytes(), usage: wgpu::BufferUsages::UNIFORM }); + let ub = g.buffer_init(None, &p.bytes(), wgpu::BufferUsages::UNIFORM); let sv = src.texture.create_view(&Default::default()); let av = aux.map(|a| a.texture.create_view(&Default::default())); let ov = out.texture.create_view(&Default::default()); @@ -1229,7 +1254,7 @@ impl<'g> Enc<'g> { while bytes.len() < 16 || !bytes.len().is_multiple_of(4) { bytes.push(0); } - self.g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("data"), contents: &bytes, usage: wgpu::BufferUsages::STORAGE }) + self.g.buffer_init(Some("data"), &bytes, wgpu::BufferUsages::STORAGE) } /// Convert to the viewer's premultiplied RGBA8 texture (egui-wgpu's native texture format), diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 723da308d..252145c14 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -264,17 +264,22 @@ impl Gpu { } } - /// Run work in a native thread-local out-of-memory scope. A failed result is discarded - /// and cached allocations are released; callers can retry with a fresh CPU renderer. + /// Run work in native thread-local out-of-memory and internal-error scopes. A failed + /// result is discarded and cached allocations are released; callers can retry with a fresh + /// CPU renderer. Some drivers report a failed allocation as an internal error rather than + /// out-of-memory (Adreno's `ERROR_INITIALIZATION_FAILED`, #591), so both count. /// Browser callers use the host/owned-device failure bridge and bounded readbacks instead /// of blocking their event loop on an error-scope future. pub fn within_memory(&self, f: impl FnOnce() -> T) -> Result { self.ctx.check_health()?; #[cfg(not(target_arch = "wasm32"))] { - let scope = self.ctx.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); + let oom = self.ctx.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); + let internal = self.ctx.device.push_error_scope(wgpu::ErrorFilter::Internal); let out = f(); - match pollster::block_on(scope.pop()) { + let internal = pollster::block_on(internal.pop()); + let oom = pollster::block_on(oom.pop()); + match internal.or(oom) { Some(e) => { self.ctx.clear_uploads(); Err(e.to_string()) diff --git a/crates/gpu/src/particles.rs b/crates/gpu/src/particles.rs index 015edc823..7934dee0c 100644 --- a/crates/gpu/src/particles.rs +++ b/crates/gpu/src/particles.rs @@ -6,7 +6,6 @@ use std::sync::{Mutex, OnceLock}; use effectcraft_effects::psim::{ParticleSystem, SimParticle, SimRequest}; -use wgpu::util::DeviceExt; use crate::context::GpuContext; @@ -152,7 +151,7 @@ pub(crate) fn simulate(g: &GpuContext, req: &SimRequest) -> Option = req.births.iter().flat_map(|b| b.to_le_bytes()).collect(); - let births = d.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("births"), contents: &births, usage: wgpu::BufferUsages::STORAGE }); + let births = g.buffer_init(Some("births"), &births, wgpu::BufferUsages::STORAGE); let prev = prev.unwrap_or_else(|| { d.create_buffer(&wgpu::BufferDescriptor { label: Some("particles"), size: STATE_BYTES, usage: wgpu::BufferUsages::STORAGE, mapped_at_creation: false }) }); @@ -162,11 +161,7 @@ pub(crate) fn simulate(g: &GpuContext, req: &SimRequest) -> Option, gpu_failure_applied: Option, + /// The graphics device was lost and the window is closing (see `close_after_device_loss`); + /// `true` keeps crash recovery for the next launch. + device_lost_close: Option, /// Commands from the native menu bar. pub command_inbox: Option>, /// Pointer position in comp pixels (Info panel). @@ -347,6 +350,7 @@ impl EffectcraftApp { gpu_checked: false, gpu_failures: None, gpu_failure_applied: None, + device_lost_close: None, command_inbox: None, pointer_comp: None, dialog_state: Default::default(), @@ -643,6 +647,26 @@ impl EffectcraftApp { ctx.request_repaint(); } + /// After the graphics device is lost, eframe's renderer cannot draw again: any mesh makes + /// egui-wgpu panic on the main thread when it cannot stage its index buffer (#591). Draw + /// nothing from then on, auto-save a project with unsaved changes (keeping the crash + /// recovery marker, so the next launch offers it) and close the window. + fn close_after_device_loss(&mut self, ctx: &egui::Context) { + if self.device_lost_close.is_some() { + return; + } + let keep_recovery = self.session.is_dirty(); + if keep_recovery { + match self.session.autosave_now().and_then(|path| self.session.autosave_wait().map(|()| path)) { + Ok(path) => log::warn!("graphics device lost: auto-saved {path}; the next launch offers to recover it"), + Err(e) => log::error!("graphics device lost: {e}"), + } + } + self.device_lost_close = Some(keep_recovery); + log::warn!("graphics device lost: closing the window"); + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + /// Set up the GPU compositor on egui-wgpu's device (once). Without an adapter that runs /// compute shaders (WebGL2, software GL) everything stays on the CPU. pub(crate) fn init_gpu(&mut self, frame: &eframe::Frame) { @@ -1672,8 +1696,11 @@ impl EffectcraftApp { impl eframe::App for EffectcraftApp { fn on_exit(&mut self) { - // Clean exit: no crash recovery next launch. - self.session.end_recovery(); + // Clean exit: no crash recovery next launch (unless the graphics device was lost with + // unsaved changes, which are in the auto-save it offers). + if self.device_lost_close != Some(true) { + self.session.end_recovery(); + } } fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { @@ -1732,6 +1759,10 @@ impl eframe::App for EffectcraftApp { } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + if self.gpu_failure_applied == Some(true) { + self.close_after_device_loss(ui.ctx()); + return; + } self.init_gpu(frame); if !self.fonts_ready { ui.ctx().request_repaint(); diff --git a/crates/ui-egui/tests/ui_gpu.rs b/crates/ui-egui/tests/ui_gpu.rs index 92d18a44f..58536f16f 100644 --- a/crates/ui-egui/tests/ui_gpu.rs +++ b/crates/ui-egui/tests/ui_gpu.rs @@ -86,3 +86,30 @@ fn viewer_draws_gpu_frames_that_match_the_cpu() { cpu_shot.save(format!("{d}/ui_cpu_viewer.png")).unwrap(); } } + +/// Once the graphics device is lost, eframe's renderer cannot stage buffers and egui-wgpu +/// panicked on the main thread drawing the next frame (#591). The app now draws nothing, +/// auto-saves unsaved changes and closes its window instead. +#[test] +fn a_lost_device_draws_nothing_autosaves_and_closes() { + let dir = std::env::temp_dir().join(format!("ec-device-lost-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut s = Session { autosave_folder_override: Some(dir.clone()), ..Default::default() }; + s.execute("comp.new", json!({"name": "Unsaved", "width": 64, "height": 64, "duration": 1})).unwrap(); + assert!(s.is_dirty()); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + assert!(!h.output().shapes.is_empty(), "the UI draws before the loss"); + let bridge = effectcraft_ui_egui::gpu_failure::GpuFailureBridge::new(&h.ctx); + h.state_mut().set_gpu_failure_bridge(bridge.clone()); + bridge.report("GPU device lost (Unknown): Device is lost", true); + h.step(); + let closing = h.output().viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|v| v.commands.contains(&egui::ViewportCommand::Close)); + assert!(closing, "the window closes"); + assert!(h.output().shapes.is_empty(), "nothing for the lost renderer to stage"); + h.step(); + assert!(h.output().shapes.is_empty()); + let saved = h.state().session.autosave.last_path.clone().expect("unsaved changes auto-saved"); + assert!(std::path::Path::new(&saved).is_file(), "{saved}"); + let _ = std::fs::remove_dir_all(&dir); +}