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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
This commit is contained in:
Claude committed 2026-10-10 22:54:56 +00:00
1 parent 1c7fcccef2
commit 3c063b5075
7 files changed
+130 -33

No files matched your search

+6 -14
View File
@@ -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<u8> = 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<wgpu::Buffer> = 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<wgpu::Buffer> = 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: &params(u0, f0), usage: wgpu::BufferUsages::UNIFORM });
let ub = g.buffer_init(None, &params(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));
+31 -6
View File
@@ -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<T>(_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),
+9 -4
View File
@@ -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<T>(&self, f: impl FnOnce() -> T) -> Result<T, String> {
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())
+2 -7
View File
@@ -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<Vec<SimPartic
};
let d = &g.device;
let births: Vec<u8> = 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<Vec<SimPartic
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let ub = d.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("particles"),
contents: &uniforms(req, n, s0, prev_n),
usage: wgpu::BufferUsages::UNIFORM,
});
let ub = g.buffer_init(Some("particles"), &uniforms(req, n, s0, prev_n), wgpu::BufferUsages::UNIFORM);
let bg = d.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &p.bgl,
+22
View File
@@ -746,6 +746,28 @@ fn within_memory_returns_the_work_when_there_is_room() {
assert_eq!((img.width, img.height), (64, 32));
}
/// A buffer whose allocation fails (here: over the device's buffer size limit; on Adreno an
/// internal `ERROR_INITIALIZATION_FAILED`) is reported to the caller's error scope instead of
/// panicking in `create_buffer_init`'s mapped range on a frame thread (#591).
#[test]
fn a_failed_buffer_allocation_is_an_error_not_a_panic() {
hold_gpu_lock();
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else { return };
let required_limits =
wgpu::Limits { max_buffer_size: 4096, max_storage_buffer_binding_size: 4096, max_uniform_buffer_binding_size: 4096, ..Default::default() };
let desc = wgpu::DeviceDescriptor { required_limits, ..Default::default() };
let (device, queue) = pollster::block_on(adapter.request_device(&desc)).expect("a device with a small buffer limit");
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let made = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
crate::context::buffer_init(&device, &queue, Some("too big"), &[0u8; 8192], wgpu::BufferUsages::STORAGE);
crate::context::buffer_init(&device, &queue, Some("unaligned"), &[1u8; 7], wgpu::BufferUsages::UNIFORM);
}));
let error = pollster::block_on(scope.pop());
assert!(made.is_ok(), "a failed allocation must not panic");
assert!(error.is_some(), "the failed allocation reaches the error scope");
}
/// Precomp layers (not collapsed) render their nested comp resident on the GPU
/// (`walk::draw_precomp`): match the CPU (nested blend modes, effects inside and outside,
/// transforms, opacity, a blend mode on the precomp layer, half resolution,