From d20b0fab65d5f024b91142e3e980860623ad2fe5 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Wed, 14 Oct 2026 17:53:52 -0400 Subject: [PATCH] Native crash capture on Windows, and startup steps in crash reports (#1215) A crash inside a graphics driver left only "an unclean exit between commands": no backtrace, no module, no hint that it was at startup. - The crash journal now keeps the startup step the app is in (window, graphics instance, surface, adapter request, adapter chosen, device creation, renderer and viewport shader compiles, surface configure, first frame and its present, running) and the renderer as soon as the adapter is picked. egui-wgpu tells the app about its steps through a small hook. An unclean exit before the first frame names the step. - Windows: an unhandled-exception filter (src/native_crash.rs, the one Windows-only module allowed unsafe) writes the exception code, the faulting module and offset, and a minidump next to the journal; the next start puts them into the report. - The Windows CI job runs a child that faults on purpose and checks the report. Co-authored-by: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- AGENTS.md | 8 +- CLAUDE.md | 8 +- CONTRIBUTING.md | 5 +- Cargo.lock | 1 + apps/solidcraft/Cargo.toml | 17 +- apps/solidcraft/src/main.rs | 39 +++- apps/solidcraft/src/native_crash.rs | 285 ++++++++++++++++++++++++++++ crates/engine/src/crash.rs | 212 +++++++++++++++++++-- crates/engine/src/crash_tests.rs | 81 ++++++++ crates/ui-egui/src/issue_report.rs | 2 +- docs/crash-reports.md | 18 +- vendor/README.md | 4 + vendor/egui-wgpu/src/lib.rs | 41 ++++ vendor/egui-wgpu/src/winit.rs | 10 + 15 files changed, 703 insertions(+), 30 deletions(-) create mode 100644 apps/solidcraft/src/native_crash.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 841b505e..ea56a9e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -328,7 +328,7 @@ jobs: with: key: windows-startup - name: Backend order and probing on Windows - run: cargo test -p solidcraft --profile ci --locked -- startup::tests + run: cargo test -p solidcraft --profile ci --locked -- startup::tests native_crash::tests - name: Build the app run: cargo build -p solidcraft --profile ci --locked - name: Start the app under different settings diff --git a/AGENTS.md b/AGENTS.md index a83fb38b..dd5e3ea9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,9 +27,11 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa ## Never crash People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.** - **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, - `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The one exception is two functions in - `apps/solidcraft/src/open_documents.rs` (that crate denies it instead): macOS hands Finder files - only to an Objective-C method. + `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The two exceptions, in the app crate + (which denies it instead), have no safe API: two functions in + `apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C + method) and the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that + records crashes inside native code such as graphics drivers). - **Errors are `Result`** through the crate's error type and `?`. An unfinished feature returns an error ("not supported yet: …"); it never panics. - **Input-derived numbers are hostile** (command parameters, design files, control-channel diff --git a/CLAUDE.md b/CLAUDE.md index b2d05925..005b8219 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,9 +27,11 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa ## Never crash People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.** - **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, - `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The one exception is two functions in - `apps/solidcraft/src/open_documents.rs` (that crate denies it instead): macOS hands Finder files - only to an Objective-C method. + `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The two exceptions, in the app crate + (which denies it instead), have no safe API: two functions in + `apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C + method) and the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that + records crashes inside native code such as graphics drivers). - **Errors are `Result`** through the crate's error type and `?`. An unfinished feature returns an error ("not supported yet: …"); it never panics. - **Input-derived numbers are hostile** (command parameters, design files, control-channel diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5c696371..2ff450ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -107,8 +107,9 @@ The [README](README.md#architecture) has the layer table. The short version: `cargo xtask assets` enforces this. 4. **Never crash.** People trust a CAD app with their work. - Non-test code never uses `unwrap`, `expect`, `panic!` or indexing on input-derived data. - There's no `unsafe` outside `apps/solidcraft/src/open_documents.rs` (macOS file-open events), - and the compiler denies it. + There's no `unsafe` outside `apps/solidcraft/src/open_documents.rs` (macOS file-open events) + and `apps/solidcraft/src/native_crash.rs` (Windows native crash capture), and the compiler + denies it. - An unsupported case returns an error ("not supported yet: …"); it never panics. - Every call into truck goes through `solidcraft_kernel::guard`. - Every crash fix comes with a regression test. diff --git a/Cargo.lock b/Cargo.lock index f537310a..a572a852 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3466,6 +3466,7 @@ dependencies = [ "solidcraft-engine", "solidcraft-ui-egui", "ureq", + "windows-sys 0.61.2", "winit", ] diff --git a/apps/solidcraft/Cargo.toml b/apps/solidcraft/Cargo.toml index 55825d62..ab7835c1 100644 --- a/apps/solidcraft/Cargo.toml +++ b/apps/solidcraft/Cargo.toml @@ -7,8 +7,9 @@ license.workspace = true publish = false default-run = "solidcraft" -# The workspace lints, except unsafe_code: denied, not forbidden, so that src/open_documents.rs can -# allow it (macOS file-open events have no safe API). Keep the rest in sync with [workspace.lints]. +# The workspace lints, except unsafe_code: denied, not forbidden, so that src/open_documents.rs +# (macOS file-open events) and src/native_crash.rs (Windows exception filter) can allow it: neither +# has a safe API. Keep the rest in sync with [workspace.lints]. [lints.rust] unsafe_code = "deny" @@ -41,6 +42,18 @@ quick-xml = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] } +# Native crash capture (src/native_crash.rs): the exception filter, the faulting module, a minidump. +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Storage_FileSystem", + "Win32_System_Diagnostics_Debug", + "Win32_System_IO", + "Win32_System_Kernel", + "Win32_System_LibraryLoader", + "Win32_System_Memory", + "Win32_System_Threading", +] } [target.'cfg(target_os = "macos")'.dependencies] # Files opened from Finder and the Dock (src/open_documents.rs); all already come with eframe and rfd. diff --git a/apps/solidcraft/src/main.rs b/apps/solidcraft/src/main.rs index 4896e2aa..1c6beb3d 100644 --- a/apps/solidcraft/src/main.rs +++ b/apps/solidcraft/src/main.rs @@ -19,6 +19,8 @@ #[path = "../../../assets/icons/app/shared/source/app_icon.rs"] mod app_icon; mod control_server; +#[cfg(target_os = "windows")] +mod native_crash; #[cfg(target_os = "macos")] mod open_documents; #[cfg(any(target_os = "windows", target_os = "linux"))] @@ -32,14 +34,24 @@ struct App { solve: SolveApp, /// The settings folder whose start marker the first frame clears (`startup::started`). starting: Option, + /// Frames begun; the second one means the first was drawn (startup is over). + frames: u32, #[cfg(any(target_os = "windows", target_os = "linux"))] space_mouse: space_mouse::SpaceMouse, } impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - if let Some(dir) = self.starting.take() { - startup::started(&dir); + if self.frames < 2 { + self.frames += 1; + // A second frame right away, which means the first was presented: the app is up. + ctx.request_repaint(); + if self.frames == 2 { + solidcraft_engine::crash::phase(solidcraft_engine::crash::RUNNING); + if let Some(dir) = self.starting.take() { + startup::started(&dir); + } + } } // The design on screen is the one whose commands a crash report replays. self.solve.session.crash_journal = solidcraft_engine::crash::enabled(); @@ -392,6 +404,12 @@ fn main() -> eframe::Result { solidcraft_engine::crash::install(dir, build) }) .unwrap_or_default(); + // A crash in native code (a graphics driver) says where it happened: the exception and the + // module on Windows, and on every system the startup step the app was in. + #[cfg(target_os = "windows")] + native_crash::install(); + eframe::egui_wgpu::set_startup_hook(startup_step); + solidcraft_engine::crash::phase("window"); let options = eframe::NativeOptions { viewport: app_viewport()?, depth_buffer: DEPTH_BITS, @@ -435,6 +453,7 @@ fn main() -> eframe::Result { options, Box::new(move |cc| { created_in_app.store(true, std::sync::atomic::Ordering::SeqCst); + solidcraft_engine::crash::phase("app setup"); solidcraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx); let mut app = SolveApp::new(Session::default(), services(cc)); app.custom_titlebar = CUSTOM_TITLEBAR; @@ -445,7 +464,12 @@ fn main() -> eframe::Result { } if let Some(rs) = &cc.wgpu_render_state { eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples); + solidcraft_engine::crash::phase("viewport pipelines (shader compile)"); app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, rs.msaa_samples); + // The graphics driver is loaded now; it may have put its own exception filter + // in front of ours. + #[cfg(target_os = "windows")] + native_crash::reinstall(); // A saved anti-aliasing the device can't do: back to the default, with a notice. solidcraft_ui_egui::prefs::check_msaa(&mut app, u32::from(samples)); let info = rs.adapter.get_info(); @@ -497,9 +521,11 @@ fn main() -> eframe::Result { } #[cfg(any(target_os = "windows", target_os = "linux"))] let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx); + solidcraft_engine::crash::phase("first frame"); Ok(Box::new(App { solve: app, starting, + frames: 0, #[cfg(any(target_os = "windows", target_os = "linux"))] space_mouse, })) @@ -512,6 +538,15 @@ fn main() -> eframe::Result { } } +/// egui-wgpu is about to start a step of getting the graphics device and the window's surface up: +/// the crash journal keeps it, and the adapter once it is picked. +fn startup_step(step: &'static str, detail: &str) { + if step == eframe::egui_wgpu::STEP_ADAPTER_CHOSEN { + solidcraft_engine::crash::set_environment(&format!("Renderer: {detail}")); + } + solidcraft_engine::crash::phase(step); +} + /// The renderer settings: with `cpu`, the software adapter (WARP, lavapipe, llvmpipe). The /// backends come from `startup::choose_backends` (on Windows D3D12 first, and only the backend /// in use is started); `preference` is `graphics.json`'s `backend`. diff --git a/apps/solidcraft/src/native_crash.rs b/apps/solidcraft/src/native_crash.rs new file mode 100644 index 00000000..bcaf847b --- /dev/null +++ b/apps/solidcraft/src/native_crash.rs @@ -0,0 +1,285 @@ +//! Windows: what a crash in native code leaves behind (docs/crash-reports.md). +//! +//! A crash inside a graphics driver or another DLL (an access violation, a stack overflow) is no +//! Rust panic: the panic hook never runs and the next start only found "an unclean exit". An +//! unhandled-exception filter now writes, before the process goes, a small JSON file with the +//! exception code, the faulting module and the offset into it, and a minidump, next to the run's +//! crash journal (`solidcraft_engine::crash::native_paths`). The next start puts both into the +//! unclean exit's report, with the startup step the journal says the app was in. +//! +//! Installing an exception filter and asking the system which module holds an address have no +//! safe API, hence the `unsafe_code` allowance on this module (the app crate denies it; see +//! `open_documents.rs` for the other one). The filter runs on a thread that may have a broken +//! heap or almost no stack left: it doesn't allocate, formats into a buffer on the stack, and +//! skips the minidump after a stack overflow. +#![allow(unsafe_code)] + +use std::fmt::Write as _; +use std::os::windows::ffi::OsStrExt; +use std::path::Path; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +use windows_sys::Win32::Foundation::{CloseHandle, GENERIC_WRITE, HANDLE, HMODULE, INVALID_HANDLE_VALUE}; +use windows_sys::Win32::Storage::FileSystem::{CREATE_ALWAYS, CreateFileW, FILE_ATTRIBUTE_NORMAL, WriteFile}; +use windows_sys::Win32::System::Diagnostics::Debug::{ + EXCEPTION_CONTINUE_SEARCH, EXCEPTION_POINTERS, MINIDUMP_EXCEPTION_INFORMATION, MiniDumpNormal, MiniDumpWithThreadInfo, + MiniDumpWithUnloadedModules, MiniDumpWriteDump, SetUnhandledExceptionFilter, +}; +use windows_sys::Win32::System::LibraryLoader::{ + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, GetModuleFileNameW, GetModuleHandleExW, +}; +use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentProcessId, GetCurrentThreadId}; + +/// Where the filter writes (NUL-terminated UTF-16), set once by [`install`]. +struct Paths { + info: Vec, + dump: Vec, +} + +static PATHS: OnceLock = OnceLock::new(); +/// The filter that was installed before ours (called after ours), as an address. +static PREVIOUS: AtomicUsize = AtomicUsize::new(0); +/// The filter has started writing (a second exception while it does is left alone). +static BUSY: AtomicBool = AtomicBool::new(false); + +const STATUS_ACCESS_VIOLATION: u32 = 0xC000_0005; +const STATUS_IN_PAGE_ERROR: u32 = 0xC000_0006; +const STATUS_STACK_OVERFLOW: u32 = 0xC000_00FD; + +/// Catch native crashes from now on (after `solidcraft_engine::crash::install`; nothing without it). +pub fn install() { + let Some((info, dump)) = solidcraft_engine::crash::native_paths() else { return }; + let wide = |p: &Path| p.as_os_str().encode_wide().chain([0]).collect::>(); + if PATHS.set(Paths { info: wide(&info), dump: wide(&dump) }).is_ok() { + set_filter(); + } +} + +/// Put the filter back on top: a DLL loaded since (a graphics driver) may have set its own, +/// which is then called after ours. +pub fn reinstall() { + if PATHS.get().is_some() { + set_filter(); + } +} + +fn set_filter() { + // SAFETY: `filter` has the signature the system calls it with and stays valid for the + // process's life. + let previous = unsafe { SetUnhandledExceptionFilter(Some(filter)) }; + if let Some(p) = previous + && p as usize != filter as *const () as usize + { + PREVIOUS.store(p as usize, Ordering::SeqCst); + } +} + +/// The unhandled-exception filter: record, then let the previous filter (or the system) go on. +unsafe extern "system" fn filter(pointers: *const EXCEPTION_POINTERS) -> i32 { + if !BUSY.swap(true, Ordering::SeqCst) { + // SAFETY: the system passes valid exception pointers for the duration of the call. + unsafe { record(pointers) }; + } + let previous = PREVIOUS.load(Ordering::SeqCst); + if previous != 0 && previous != filter as *const () as usize { + // SAFETY: the address came from `SetUnhandledExceptionFilter` as a filter of this type. + let previous: unsafe extern "system" fn(*const EXCEPTION_POINTERS) -> i32 = unsafe { std::mem::transmute(previous) }; + // SAFETY: as for this filter. + return unsafe { previous(pointers) }; + } + EXCEPTION_CONTINUE_SEARCH +} + +/// A fixed buffer to format into without allocating (what doesn't fit is cut off). +struct Buf { + bytes: [u8; 2048], + len: usize, +} + +impl std::fmt::Write for Buf { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + let room = self.bytes.len().saturating_sub(self.len); + let n = s.len().min(room); + if let (Some(to), Some(from)) = (self.bytes.get_mut(self.len..self.len + n), s.as_bytes().get(..n)) { + to.copy_from_slice(from); + self.len += n; + } + Ok(()) + } +} + +/// The usual names of the exception codes a crash shows. +fn code_name(code: u32) -> &'static str { + match code { + STATUS_ACCESS_VIOLATION => "EXCEPTION_ACCESS_VIOLATION", + STATUS_IN_PAGE_ERROR => "EXCEPTION_IN_PAGE_ERROR", + STATUS_STACK_OVERFLOW => "EXCEPTION_STACK_OVERFLOW", + 0xC000_001D => "EXCEPTION_ILLEGAL_INSTRUCTION", + 0xC000_0094 => "EXCEPTION_INT_DIVIDE_BY_ZERO", + 0xC000_0096 => "EXCEPTION_PRIV_INSTRUCTION", + 0xC000_008C => "EXCEPTION_ARRAY_BOUNDS_EXCEEDED", + 0xC000_0374 => "STATUS_HEAP_CORRUPTION", + 0xC000_0409 => "STATUS_STACK_BUFFER_OVERRUN", + 0xC000_0602 => "STATUS_FAIL_FAST_EXCEPTION", + 0x8000_0003 => "EXCEPTION_BREAKPOINT", + 0xE06D_7363 => "C++ exception", + _ => "exception", + } +} + +/// Write what `pointers` says into the info file, then the minidump. +unsafe fn record(pointers: *const EXCEPTION_POINTERS) { + let Some(paths) = PATHS.get() else { return }; + if pointers.is_null() { + return; + } + // SAFETY: checked non-null; the system keeps it valid during the filter. + let rec = unsafe { (*pointers).ExceptionRecord }; + if rec.is_null() { + return; + } + // SAFETY: as above. + let rec = unsafe { &*rec }; + let code = rec.ExceptionCode as u32; + let address = rec.ExceptionAddress as usize; + let mut module: HMODULE = std::ptr::null_mut(); + let mut name = [0u16; 512]; + // SAFETY: FROM_ADDRESS takes the address in place of a name; UNCHANGED_REFCOUNT means no + // handle to release; `name` is as long as we say. + let len = unsafe { + if GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + address as *const u16, + &mut module, + ) != 0 + { + GetModuleFileNameW(module, name.as_mut_ptr(), name.len() as u32) as usize + } else { + 0 + } + }; + // SAFETY: no preconditions. + let thread = unsafe { GetCurrentThreadId() }; + let mut b = Buf { bytes: [0; 2048], len: 0 }; + let _ = write!(b, "{{\"code\":\"0x{code:08X}\",\"name\":\"{}\",\"address\":\"0x{address:016X}\",\"thread\":{thread}", code_name(code)); + if len > 0 { + let _ = write!(b, ",\"offset\":\"0x{:X}\",\"module\":\"", address.wrapping_sub(module as usize)); + for c in char::decode_utf16(name.iter().take(len).copied()) { + let _ = match c.unwrap_or(char::REPLACEMENT_CHARACTER) { + '"' => b.write_str("\\\""), + '\\' => b.write_str("\\\\"), + c if (c as u32) < 0x20 => b.write_str("?"), + c => b.write_char(c), + }; + } + let _ = b.write_str("\""); + } + if matches!(code, STATUS_ACCESS_VIOLATION | STATUS_IN_PAGE_ERROR) && rec.NumberParameters >= 2 { + let what = match rec.ExceptionInformation[0] { + 0 => "read of", + 1 => "write to", + 8 => "execution at", + _ => "access to", + }; + let _ = write!(b, ",\"detail\":\"{what} 0x{:016X}\"", rec.ExceptionInformation[1]); + } + let _ = b.write_str("}"); + let bytes = b.bytes.get(..b.len).unwrap_or_default(); + // SAFETY: NUL-terminated path; the handle is closed below. + unsafe { + let file = create(&paths.info); + if !file.is_null() { + let mut written = 0u32; + WriteFile(file, bytes.as_ptr(), bytes.len() as u32, &mut written, std::ptr::null_mut()); + CloseHandle(file); + } + } + // After a stack overflow there is no stack left for writing a minidump. + if code == STATUS_STACK_OVERFLOW { + return; + } + // SAFETY: as above; the exception information points at this thread's exception. + unsafe { + let file = create(&paths.dump); + if !file.is_null() { + let exception = MINIDUMP_EXCEPTION_INFORMATION { ThreadId: thread, ExceptionPointers: pointers as *mut _, ClientPointers: 0 }; + MiniDumpWriteDump( + GetCurrentProcess(), + GetCurrentProcessId(), + file, + MiniDumpNormal | MiniDumpWithThreadInfo | MiniDumpWithUnloadedModules, + &exception, + std::ptr::null(), + std::ptr::null(), + ); + CloseHandle(file); + } + } +} + +/// A new file at `path` (NUL-terminated) for writing, or null. +unsafe fn create(path: &[u16]) -> HANDLE { + // SAFETY: the caller passes a NUL-terminated path. + let h = unsafe { CreateFileW(path.as_ptr(), GENERIC_WRITE, 0, std::ptr::null(), CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, std::ptr::null_mut()) }; + if h == INVALID_HANDLE_VALUE { std::ptr::null_mut() } else { h } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + /// The child half: install crash reporting and the filter, then fault in native code. + #[test] + fn child() { + let Some(dir) = std::env::var_os("SOLIDCRAFT_NATIVE_CRASH_CHILD") else { return }; + let build = solidcraft_engine::crash::Build { version: "9.9.9", commit: "abc1234", built: "2026-10-14" }; + let fresh = solidcraft_engine::crash::install(PathBuf::from(dir), build); + println!("FRESH {}", serde_json::to_string(&fresh).unwrap_or_default()); + if std::env::var_os("SOLIDCRAFT_NATIVE_CRASH_FAULT").is_none() { + solidcraft_engine::crash::end_session(); + return; + } + super::install(); + solidcraft_engine::crash::phase("device creation"); + // SAFETY: none; this is the crash under test (a read of address 16, as a null struct + // pointer's field would be). + let v = unsafe { std::ptr::read_volatile(16 as *const u64) }; + println!("unreachable {v}"); + } + + fn run(dir: &std::path::Path, fault: bool) -> std::process::Output { + let mut c = std::process::Command::new(std::env::current_exe().unwrap()); + c.args(["--exact", "native_crash::tests::child", "--test-threads=1", "--nocapture"]).env("SOLIDCRAFT_NATIVE_CRASH_CHILD", dir); + if fault { + c.env("SOLIDCRAFT_NATIVE_CRASH_FAULT", "1"); + } + c.output().unwrap() + } + + #[test] + fn an_access_violation_is_reported_with_its_module_step_and_minidump() { + let dir = std::env::temp_dir().join(format!("solidcraft-native-crash-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let out = run(&dir, true); + assert_eq!(out.status.code().map(|c| c as u32), Some(super::STATUS_ACCESS_VIOLATION), "{out:?}"); + let sessions: Vec = std::fs::read_dir(dir.join("sessions")).unwrap().flatten().map(|e| e.path()).collect(); + let info = sessions.iter().find(|p| p.to_string_lossy().ends_with(".native.json")).expect("the filter wrote what it caught"); + let v: serde_json::Value = serde_json::from_slice(&std::fs::read(info).unwrap()).unwrap(); + assert_eq!(v["code"], "0xC0000005", "{v}"); + assert_eq!(v["detail"], "read of 0x0000000000000010", "{v}"); + assert!(v["module"].as_str().unwrap().to_lowercase().contains("solidcraft"), "the fault is in the test binary: {v}"); + let dump = sessions.iter().find(|p| p.extension().is_some_and(|e| e == "dmp")).expect("a minidump"); + assert_eq!(std::fs::read(dump).unwrap().get(..4), Some(&b"MDMP"[..])); + // The next start reports it. + let out = run(&dir, false); + assert!(out.status.success(), "{out:?}"); + let reports = solidcraft_engine::crash::list(&dir); + assert_eq!(reports.len(), 1, "{reports:?}"); + let r = solidcraft_engine::crash::read(&dir, &reports[0].id).unwrap(); + assert!(r["message"].as_str().unwrap().contains("EXCEPTION_ACCESS_VIOLATION (0xC0000005) in solidcraft"), "{r}"); + assert!(r["message"].as_str().unwrap().contains("during startup (device creation"), "{r}"); + assert!(r.get("minidump_path").is_some(), "{r}"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/crash.rs b/crates/engine/src/crash.rs index f3c8844d..6342e048 100644 --- a/crates/engine/src/crash.rs +++ b/crates/engine/src/crash.rs @@ -17,8 +17,15 @@ //! written again; at most [`MAX_NEW_PER_SESSION`] new reports per run, [`MAX_REPORTS`] //! reports, [`MAX_TOTAL_BYTES`] on disk, nothing older than [`MAX_AGE_DAYS`] days. //! +//! - **Startup steps** ([`phase`]): the journal keeps the step the app is in while it gets its +//! window and graphics device up, so a crash inside a graphics driver still says where it +//! happened, and the renderer ([`set_environment`]) once it is known. +//! - **Native crashes** (Windows): the app's exception filter writes what it caught (exception +//! code, faulting module and offset) and a minidump next to the journal ([`native_paths`]); +//! the next start puts both into the unclean exit's report. +//! //! Reports are folders `crashes/