From 3bf1ab996408d48fe9839bc8b269d7ab1596b654 Mon Sep 17 00:00:00 2001 From: mechpanos <25664832+mechpanos@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:34:10 +0300 Subject: [PATCH] fix(gpu): only blame the backend for a crash that happened early MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The crash sentinel is armed before the event loop and disarmed after it returns, so anything that stops the process without unwinding leaves the file behind and the next launch skips that backend. A real GPU failure does that — and so does the user closing the app from Task Manager, an OOM kill, a power cut, or a panic with nothing to do with graphics. The cost is not a one-off: the skip is permanent, and each later abnormal end takes the next backend with it, walking a machine down dx12 -> vulkan -> gl -> software rasterizer. The user is told their driver is at fault the whole way. Seen on this machine: a debug build died once while opening a large drawing, and every launch afterwards reported "dx12 is not usable on this GPU" on an Intel Iris Xe. Probing the backends directly showed dx12 not merely usable but the best of the three (max_storage_buffers_per_shader_stage 262144, against 59 for Vulkan and 16 for GL). Clearing the stale sentinel brought dx12 straight back. The failures this guard was built for kill the process at GPU init or on the first drawing viewport, which is why arming it at all works. A run that keeps drawing well past that has already shown the backend is fine, so it now marks the sentinel `survived=1` after SENTINEL_PROOF_DELAY, and a sentinel carrying that marker accuses nobody. An early death still blacklists exactly as before. Verified both ways on Windows 11 with an Intel Iris Xe: killed after 40s, the next launch still chose dx12; killed after 8s, the next launch skipped dx12 and fell back to Vulkan. Co-Authored-By: Claude Opus 5 --- src/gpu_backend.rs | 84 +++++++++++++++++++++++++++++++++++++++++++--- src/main.rs | 8 +++++ 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/src/gpu_backend.rs b/src/gpu_backend.rs index 216db5e86..632e85df1 100644 --- a/src/gpu_backend.rs +++ b/src/gpu_backend.rs @@ -20,7 +20,9 @@ //! pipeline creation (catches shader-compile failures), offscreen render //! and readback. The first fully-passing backend wins; //! - backends implicated in the previous run's crash (crash sentinel) are -//! skipped; +//! skipped, but only when that run died young: one that drew for +//! `SENTINEL_PROOF_DELAY` marks the sentinel survived, so a later kill +//! (Task Manager, OOM, power cut) costs the user nothing; //! - if no hardware backend passes, one software-adapter probe (`--gpu-probe //! sw`, e.g. WARP / llvmpipe / SwiftShader) is tried: slow but alive; //! - the winning probe also reports adapter limits, so the packed @@ -292,14 +294,35 @@ pub fn previous_run_crashed() -> bool { sentinel_path().is_some_and(|p| p.exists()) } +/// How long a backend has to keep the app alive before it stops being a +/// suspect. The crashes this guard exists for kill the process at GPU init +/// or on the first drawing viewport, well inside this window; a process that +/// lived longer has already proved the backend works, so whatever ended it +/// later was something else. +pub const SENTINEL_PROOF_DELAY: Duration = Duration::from_secs(30); + +/// Marker line added to the sentinel once the backend has proved itself. +const SURVIVED_MARKER: &str = "survived=1"; + /// Backends implicated in the previous run's crash, from the stale sentinel. +/// +/// A sentinel left behind by a run that had already been drawing for +/// [`SENTINEL_PROOF_DELAY`] names no suspect. Disarming only happens on a +/// clean return from the event loop, so without this every abnormal end — +/// the user killing the app from Task Manager, an OOM kill, a power cut, a +/// panic with nothing to do with the GPU — used to blacklist whichever +/// backend happened to be in use. Backends were then given up one by one +/// until the app was left on a software rasterizer, telling the user to +/// update a driver that was never at fault. pub fn crashed_backends() -> Vec { let Some(p) = sentinel_path() else { return Vec::new(); }; - std::fs::read_to_string(p) - .unwrap_or_default() - .lines() + let text = std::fs::read_to_string(p).unwrap_or_default(); + if text.lines().any(|l| l.trim() == SURVIVED_MARKER) { + return Vec::new(); + } + text.lines() .find_map(|l| l.strip_prefix("backend=")) .unwrap_or("") .split(',') @@ -309,6 +332,23 @@ pub fn crashed_backends() -> Vec { .collect() } +/// Record that the armed backend has kept the app alive long enough to be +/// trusted. Appends to the sentinel rather than removing it, so a crash is +/// still visible to anyone reading the file. +pub fn mark_sentinel_survived() { + let Some(p) = sentinel_path() else { + return; + }; + let Ok(text) = std::fs::read_to_string(&p) else { + return; + }; + if text.lines().any(|l| l.trim() == SURVIVED_MARKER) { + return; + } + let _ = std::fs::write(&p, format!("{text}{SURVIVED_MARKER} +")); +} + pub fn arm_sentinel(backend: &str) { if let Some(p) = sentinel_path() { if let Some(parent) = p.parent() { @@ -994,8 +1034,13 @@ mod tests { assert!(ProbeCaps::from_json_line("{\"backend\":\"gl\"}").is_none()); } + /// `SENTINEL_DIR_ENV` is process-global, so the sentinel tests cannot + /// run beside each other. + static SENTINEL_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[test] fn sentinel_round_trip_records_backends() { + let _guard = SENTINEL_TESTS.lock().unwrap_or_else(|e| e.into_inner()); let dir = std::env::temp_dir().join(format!("ocs-gpu-test-{}", std::process::id())); let _ = std::fs::create_dir_all(&dir); // SAFETY: single-threaded test mutating a process-local test hook. @@ -1011,6 +1056,37 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// A run that drew long enough to prove the backend leaves a sentinel + /// behind when it is killed, but that sentinel must accuse nobody — + /// otherwise closing the app from Task Manager once costs the user a + /// working backend for good. + #[test] + fn a_backend_that_survived_is_not_blamed_for_a_later_kill() { + let _guard = SENTINEL_TESTS.lock().unwrap_or_else(|e| e.into_inner()); + let dir = std::env::temp_dir().join(format!("ocs-gpu-survived-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + // SAFETY: single-threaded test mutating a process-local test hook. + unsafe { std::env::set_var(SENTINEL_DIR_ENV, &dir) }; + + arm_sentinel("dx12"); + assert_eq!(crashed_backends(), vec!["dx12".to_string()], "a young run blames its backend"); + + mark_sentinel_survived(); + assert!(previous_run_crashed(), "the sentinel still records that the run ended badly"); + assert!( + crashed_backends().is_empty(), + "a backend that kept the app alive must not be skipped at the next launch" + ); + + // Marking twice must not corrupt the file or resurrect the accusation. + mark_sentinel_survived(); + assert!(crashed_backends().is_empty()); + + disarm_sentinel(); + unsafe { std::env::remove_var(SENTINEL_DIR_ENV) }; + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn force_hooks_drive_probes_without_spawning() { // SAFETY: single-threaded test mutating process-local test hooks. diff --git a/src/main.rs b/src/main.rs index b3564ab82..d6bd19fdf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -203,6 +203,14 @@ fn main() -> iced::Result { OpenCADStudio::gpu_backend::arm_sentinel( gpu.backend_value.as_deref().unwrap_or("auto"), ); + // The backend is only a suspect while it is young: a run that + // keeps drawing past this mark has proved it works, so a later + // end — Task Manager, an OOM kill, a power cut — must not cost + // the user that backend at the next launch. + std::thread::spawn(|| { + std::thread::sleep(OpenCADStudio::gpu_backend::SENTINEL_PROOF_DELAY); + OpenCADStudio::gpu_backend::mark_sentinel_survived(); + }); let result = app::run(); OpenCADStudio::gpu_backend::disarm_sentinel(); result