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/