Browse / github.com/storytold/craft-launcher / patch
Survive a lost graphics device: open a new window instead of crashing (#274)
EGUI buffer size error Receive the following error when attempting to open the launcher: ``` thread 'main' (145546) panicked at /usr/src/debug/artcraft-launcher/registry/src/index.crates.io-1949cf8c6b5b557f/egui-wgpu-0.36.2/src/renderer.rs:984:17: Failed to create staging buffer for index data. Index count: 6063. Required index buffer size: 24252. Actual size 24576 and capacity: 24576 (bytes) note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace ``` Installed via AUR pkgbuild script, but error also happens on flatpak
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- 00xJS (imported from pull request #612, open)
- Base
345c509fe853cfa14e6441a79b834b38985cc3bd- Size
- +171 −5 in 5 files
- Review
- pending
- Shared
- 2026-10-28
- Id
269cb3daa4e18f788b6da5e3
How it went
No other operator has reported using it yet.
Diff
raw · Cargo.lock apps/artcraft-launcher/Cargo.toml apps/artcraft-launcher/src/gpu_health.rs apps/artcraft-launcher/src/logging.rs apps/artcraft-launcher/src/main.rs
@@ -290,6 +290,7 @@ dependencies = [ "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2",+ "pollster 1.0.1", "rfd", "ron", "serde",@@ -66,3 +66,7 @@ rfd = { version = "=0.17.2", default-features = false, features = ["xdg-portal", [build-dependencies] # Icon + VERSIONINFO for artcraft-launcher.exe; build.rs only uses it when targeting Windows. winresource = "0.1"++[dev-dependencies]+# gpu_health's device-loss test blocks on wgpu's adapter request. Already in Cargo.lock through wgpu.+pollster = "1"new file mode 100644@@ -0,0 +1,104 @@+//! The window's graphics device (#274). A driver reset (for example after the PC slept) loses the+//! wgpu device the window draws with, and egui-wgpu can't draw another frame on it: one with shapes+//! panics creating its staging buffers ("Failed to create staging buffer for index data"), and+//! wgpu's default handler panics on the errors that follow. [`WindowGpu`] records the loss+//! instead, the shell then draws nothing, closes the window and opens a new one with a fresh+//! device, at most [`ReopenBudget::MAX`] times in [`ReopenBudget::WINDOW`].++use std::sync::Arc;+use std::sync::atomic::{AtomicBool, Ordering};+use std::time::{Duration, Instant};++use eframe::wgpu;++/// The window's device and whether wgpu reported it lost.+pub struct WindowGpu {+ device: wgpu::Device,+ lost: Arc<AtomicBool>,+}++impl WindowGpu {+ /// Watch `device`: its loss sets a flag rather than panicking, and an uncaptured error is+ /// logged rather than ending the launcher (wgpu's default handler panics).+ pub fn watch(device: &wgpu::Device) -> Self {+ let lost = Arc::new(AtomicBool::new(false));+ let flag = lost.clone();+ // Called by wgpu with its own locks held: only record, never call back into wgpu.+ device.set_device_lost_callback(move |reason, message| {+ log::error!("graphics device lost ({reason:?}): {message}");+ flag.store(true, Ordering::Release);+ });+ device.on_uncaptured_error(Arc::new(|e: wgpu::Error| log::error!("graphics error: {e}")));+ Self { device: device.clone(), lost }+ }++ /// Whether the device is lost. Polls it (non-blocking) so a loss while the window sat idle is+ /// noticed before the next frame is drawn with it; `Device::poll` panics on a lost device, so+ /// a panic counts as a loss too, logged as handled rather than as an internal error.+ pub fn lost(&self) -> bool {+ if self.lost.load(Ordering::Acquire) {+ return true;+ }+ if crate::logging::catch_expected(|| self.device.poll(wgpu::PollType::Poll)).is_err() {+ self.lost.store(true, Ordering::Release);+ }+ self.lost.load(Ordering::Acquire)+ }+}++/// How often a lost window may be replaced: a GPU that keeps failing must not open windows in a+/// loop. Past the budget the launcher stays in the tray (or quits without one), as when the+/// window is closed.+#[derive(Default)]+pub struct ReopenBudget {+ recent: Vec<Instant>,+}++impl ReopenBudget {+ pub const MAX: usize = 3;+ pub const WINDOW: Duration = Duration::from_secs(10 * 60);++ /// Whether a new window may open at `now`, counting it if so.+ pub fn allow(&mut self, now: Instant) -> bool {+ self.recent.retain(|t| now.saturating_duration_since(*t) < Self::WINDOW);+ if self.recent.len() >= Self::MAX {+ return false;+ }+ self.recent.push(now);+ true+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn reopening_is_limited_per_ten_minutes() {+ let mut budget = ReopenBudget::default();+ let t = Instant::now();+ assert!((0..ReopenBudget::MAX).all(|i| budget.allow(t + Duration::from_secs(i as u64))));+ assert!(!budget.allow(t + Duration::from_secs(60)), "a GPU that keeps failing");+ assert!(budget.allow(t + ReopenBudget::WINDOW + Duration::from_secs(1)), "an old loss no longer counts");+ }++ /// A destroyed device reads as lost once wgpu notices (after its submitted work), and the+ /// errors that follow don't panic. Skips without an adapter.+ #[test]+ fn a_destroyed_device_reads_as_lost_without_panicking() {+ let instance = wgpu::Instance::default();+ let Ok(adapter) = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) else {+ eprintln!("skipping: no graphics adapter");+ return;+ };+ let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) else { return };+ let gpu = WindowGpu::watch(&device);+ assert!(!gpu.lost());+ device.destroy();+ let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(Duration::from_secs(30)) });+ assert!(gpu.lost());+ // An error on the lost device is logged, not a panic from wgpu's default handler.+ let _ = device.create_buffer(&wgpu::BufferDescriptor { label: None, size: 4, usage: wgpu::BufferUsages::UNIFORM, mapped_at_creation: false });+ assert!(gpu.lost());+ }+}@@ -176,10 +176,29 @@ pub fn install() -> Option<Logs> { Some(Logs { sink }) } +thread_local! {+ /// Set while [`catch_expected`] runs: a panic then is one its caller handles.+ static EXPECTED_PANIC: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };+}++/// Run `f`, which may panic in a way the caller handles (`gpu_health`: `Device::poll` panics on a+/// lost device). The panic hook logs such a panic as handled, not as an internal error; every+/// other panic is still reported.+pub fn catch_expected<R>(f: impl FnOnce() -> R) -> std::thread::Result<R> {+ EXPECTED_PANIC.with(|e| e.set(true));+ let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));+ EXPECTED_PANIC.with(|e| e.set(false));+ result+}+ /// Report a panic through the logger (standard error and the log file), with a backtrace when /// `RUST_BACKTRACE` asks for one; standard error alone when `RUST_LOG` turned errors off. pub fn install_panic_hook() { std::panic::set_hook(Box::new(|info| {+ if EXPECTED_PANIC.with(std::cell::Cell::get) {+ log::info!("handled: {info}");+ return;+ } let trace = std::backtrace::Backtrace::capture(); let report = if trace.status() == std::backtrace::BacktraceStatus::Captured { format!("internal error: {info}\n{trace}")@@ -197,6 +216,16 @@ pub fn install_panic_hook() { #[cfg(test)] mod tests { use super::*;++ /// An expected panic comes back as an error, and only while `catch_expected` runs is a panic+ /// treated as expected (#612 review).+ #[test]+ fn expected_panics_are_caught_and_the_mark_is_cleared() {+ assert!(catch_expected(|| panic!("expected")).is_err());+ assert!(!EXPECTED_PANIC.with(std::cell::Cell::get));+ assert_eq!(catch_expected(|| 7).ok(), Some(7));+ assert!(!EXPECTED_PANIC.with(std::cell::Cell::get));+ } use log::Log; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -5,6 +5,7 @@ #![cfg_attr(windows, windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod gpu_health; mod logging; mod motion; mod settings_file;@@ -100,7 +101,9 @@ fn main() -> ExitCode { window = launcher.tray.is_none(); } // One pass per window: where a window can't hide, closing to the tray ends its pass and the- // launcher carries on without one until the window is asked for again.+ // launcher carries on without one until the window is asked for again. A window whose+ // graphics device was lost ends its pass too, and the next one starts at once (#274).+ let mut reopens = gpu_health::ReopenBudget::default(); loop { if window { let result = {@@ -119,6 +122,14 @@ fn main() -> ExitCode { log::error!("{e}"); return ExitCode::FAILURE; }+ if let Some(hidden_again) = launcher.reopen_window.take() {+ if reopens.allow(std::time::Instant::now()) {+ hidden = hidden_again;+ continue;+ }+ log::error!("the graphics device keeps being lost; carrying on without a window");+ launcher.closed_to_tray = launcher.tray.is_some();+ } if !std::mem::take(&mut launcher.closed_to_tray) { return ExitCode::SUCCESS; }@@ -474,6 +485,9 @@ struct Launcher { started: bool, /// The window closed to carry on in the tray, not to quit. closed_to_tray: bool,+ /// The window's graphics device was lost: open a new window (hidden if this is `true`) with a+ /// fresh device instead of carrying on without one (#274).+ reopen_window: Option<bool>, /// eframe's settings file, which the launcher writes itself while there is no window. settings_path: Option<PathBuf>, waker: Option<Arc<Waker>>,@@ -497,6 +511,7 @@ impl Launcher { run_again, started: false, closed_to_tray: false,+ reopen_window: None, settings_path, waker: None, }@@ -633,6 +648,8 @@ struct Shell<'a> { /// The "show on the current desktop" setting last applied to the window (macOS). #[cfg_attr(not(target_os = "macos"), allow(dead_code))] applied_follow: Option<bool>,+ /// The graphics device the window draws with (none in tests).+ gpu: Option<gpu_health::WindowGpu>, } impl<'a> Shell<'a> {@@ -640,7 +657,8 @@ impl<'a> Shell<'a> { let window_hides = window_can_hide(cc.display_handle().ok().map(|d| d.as_raw())); // Started hidden at login but there's no tray to come back from: show the window. let visible = !hidden || launcher.tray.is_none();- let mut shell = Self { launcher, window_hides, visible: true, quitting: false, applied_follow: None };+ let gpu = cc.wgpu_render_state.as_ref().map(|rs| gpu_health::WindowGpu::watch(&rs.device));+ let mut shell = Self { launcher, window_hides, visible: true, quitting: false, applied_follow: None, gpu }; if !visible { shell.put_away(&cc.egui_ctx); } else if hidden {@@ -706,7 +724,7 @@ impl eframe::App for Shell<'_> { // The close button puts the window away in the tray unless the user chose to quit. That is // the window manager's button or the header's own (no decorations), which asks to close // with `ViewportCommand::Close` and arrives here the same way.- if ctx.input(|i| i.viewport().close_requested()) && !self.quitting && !self.launcher.closed_to_tray {+ if ctx.input(|i| i.viewport().close_requested()) && !self.quitting && !self.launcher.closed_to_tray && self.launcher.reopen_window.is_none() { match on_close(self.launcher.tray.is_some(), self.launcher.ui.settings.close_to_tray, self.window_hides) { OnClose::Hide => { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);@@ -733,6 +751,16 @@ impl eframe::App for Shell<'_> { true }); });+ // Last, after everything drawn this frame: egui-wgpu can't draw on a lost device, and a+ // frame with shapes panics inside it. Draw nothing and swap in a new window (#274).+ if self.gpu.as_ref().is_some_and(gpu_health::WindowGpu::lost) {+ ui.ctx().graphics_mut(|g| *g = Default::default());+ if self.launcher.reopen_window.is_none() {+ log::error!("opening a new window with a fresh graphics device");+ self.launcher.reopen_window = Some(!self.visible);+ ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);+ }+ } } fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {@@ -986,7 +1014,7 @@ mod tests { fn quitting_overrides_a_close_to_the_tray() { let (mut launcher, _show, _) = launcher(); let ctx = launcher.windowless.clone();- let mut shell = Shell { launcher: &mut launcher, window_hides: false, visible: true, quitting: false, applied_follow: None };+ let mut shell = Shell { launcher: &mut launcher, window_hides: false, visible: true, quitting: false, applied_follow: None, gpu: None }; shell.put_away(&ctx); assert!(shell.launcher.closed_to_tray); shell.quit(&ctx);@@ -997,7 +1025,7 @@ mod tests { fn a_window_that_hides_is_not_closed_to_the_tray() { let (mut launcher, _show, _) = launcher(); let ctx = launcher.windowless.clone();- let mut shell = Shell { launcher: &mut launcher, window_hides: true, visible: true, quitting: false, applied_follow: None };+ let mut shell = Shell { launcher: &mut launcher, window_hides: true, visible: true, quitting: false, applied_follow: None, gpu: None }; shell.put_away(&ctx); assert!(!shell.visible); assert!(!shell.launcher.closed_to_tray);