From af9b4bb2223dd5339114aabd73d0d9ebf4f61fa1 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:35:32 -0400 Subject: [PATCH] app: startup steps in the crash journal, and a Windows exception filter (#299) egui-wgpu's startup hook and the app's own steps (window, app setup, viewport pipelines, first frame, running) go into the journal. On Windows, native_crash.rs (adapted from SolidCraft, the only module allowed unsafe code; the app crate now denies instead of forbids it) writes the exception code, faulting module and offset and a minidump. A nightly/on-demand CI job runs the app's tests on Windows, including a child that faults on purpose. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- .github/workflows/ci.yml | 17 ++ AGENTS.md | 4 +- ATTRIBUTION.md | 2 +- Cargo.lock | 1 + apps/fieldcraft/Cargo.toml | 26 ++- apps/fieldcraft/src/main.rs | 44 ++++- apps/fieldcraft/src/native_crash.rs | 290 ++++++++++++++++++++++++++++ docs/crash-reports.md | 23 ++- 8 files changed, 394 insertions(+), 13 deletions(-) create mode 100644 apps/fieldcraft/src/native_crash.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84e1bbb..53a4d53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -196,6 +196,23 @@ jobs: - name: docs/oracle.md is current run: git diff --exit-code docs/oracle.md + windows: + # The desktop app's tests on Windows, nightly and on demand: the Windows-only code + # (apps/fieldcraft/src/native_crash.rs, the exception filter) builds and a child process that + # faults on purpose leaves its native crash report (#299). Releases build Windows too. + name: Windows app tests + if: github.event.schedule == '23 3 * * *' || github.event_name == 'workflow_dispatch' + runs-on: windows-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + shared-key: windows + save-if: ${{ github.ref == 'refs/heads/develop' }} + - run: cargo test --locked --profile ci -p fieldcraft + advisories: name: Dependency licences, advisories and sources if: github.event.schedule != '41 4 * * 0' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || github.event.pull_request.user.login == 'dependabot[bot]') diff --git a/AGENTS.md b/AGENTS.md index d9ead4e..5c8d195 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,9 @@ FieldCraft everywhere: crates `fieldcraft-*`, binaries `fieldcraft` and `fieldcr ## Never crash People trust FieldCraft with their models; 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"`). + `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The one exception, in the app crate + (which denies it instead), has no safe API: the Windows-only `apps/fieldcraft/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, model files, solver decks, diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index e71fe39..4159383 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -208,7 +208,7 @@ is committed and listed here with its exports. | crates/ui-egui/src/fields.rs (form rows: a label column of fixed width, then the field) | SolidCraft `crates/ui-egui/src/dialogs.rs` | MIT OR Apache-2.0 | | crates/io/src/fs.rs (atomic writes) | SolidCraft `crates/io/src/safe_write.rs` | MIT OR Apache-2.0 | | crates/io/src/recovery.rs (recovery folder, entries, locks) | SolidCraft `crates/engine/src/recovery.rs` | MIT OR Apache-2.0 | -| crates/engine/src/crash.rs, crates/engine/src/crash_tests.rs (crash reports: panic hook, locked command journal, unclean-exit detection, report folders and limits, replay), apps/fieldcraft-cli/src/crashes.rs, docs/crash-reports.md (structure) | SolidCraft `crates/engine/src/crash.rs`, `crash_tests.rs`, `apps/solidcraft-cli/src/crashes.rs`, `docs/crash-reports.md` | MIT OR Apache-2.0 | +| crates/engine/src/crash.rs, crates/engine/src/crash_tests.rs (crash reports: panic hook, locked command journal, unclean-exit detection, startup steps, native crash reports, report folders and limits, replay), apps/fieldcraft/src/native_crash.rs (Windows exception filter and minidump), apps/fieldcraft-cli/src/crashes.rs, docs/crash-reports.md (structure) | SolidCraft `crates/engine/src/crash.rs`, `crash_tests.rs`, `apps/solidcraft/src/native_crash.rs`, `apps/solidcraft-cli/src/crashes.rs`, `docs/crash-reports.md` | MIT OR Apache-2.0 | | apps/fieldcraft/src/control_server.rs | SolidCraft `apps/solidcraft/src/control_server.rs` | MIT OR Apache-2.0 | | crates/mcp/src/ (JSON-RPC framing, MCP lifecycle, tool schema checks, the control-channel backend, base64) | SolidCraft `crates/mcp/src/` | MIT OR Apache-2.0 | | crates/io/src/roots.rs (the rules of read and write roots) | VectorCraft's automation roots (docs/mcp.md there) | MIT OR Apache-2.0 | diff --git a/Cargo.lock b/Cargo.lock index 5a6c147..9f3f041 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1286,6 +1286,7 @@ dependencies = [ "serde_json", "signal-hook", "ureq", + "windows-sys 0.61.2", ] [[package]] diff --git a/apps/fieldcraft/Cargo.toml b/apps/fieldcraft/Cargo.toml index c5dc9d4..ec8d1c7 100644 --- a/apps/fieldcraft/Cargo.toml +++ b/apps/fieldcraft/Cargo.toml @@ -6,8 +6,18 @@ edition.workspace = true license.workspace = true publish = false -[lints] -workspace = true +# The workspace lints, except unsafe_code: denied, not forbidden, so that src/native_crash.rs +# (the Windows exception filter, which has no safe API) can allow it. Keep the rest in sync with +# [workspace.lints]. +[lints.rust] +unsafe_code = "deny" + +[lints.clippy] +dbg_macro = "warn" +todo = "warn" +too_many_arguments = "allow" +type_complexity = "allow" +neg_cmp_op_on_partial_ord = "allow" [dependencies] fieldcraft-engine = { workspace = true } @@ -29,6 +39,18 @@ getrandom = "0.3" [target.'cfg(unix)'.dependencies] signal-hook = "0.3" +# The exception filter and minidump of a native crash (src/native_crash.rs). +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Storage_FileSystem", + "Win32_System_Diagnostics_Debug", + "Win32_System_Kernel", + "Win32_System_LibraryLoader", + "Win32_System_Threading", +] } + [build-dependencies] # The Windows executable's icon resource (build.rs). embed-resource = "3.0.12" diff --git a/apps/fieldcraft/src/main.rs b/apps/fieldcraft/src/main.rs index 9da2e34..6278252 100644 --- a/apps/fieldcraft/src/main.rs +++ b/apps/fieldcraft/src/main.rs @@ -9,9 +9,11 @@ //! `fieldcraft_ui_egui::control` and docs/control-protocol.md. #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -#![forbid(unsafe_code)] +// Unsafe code is denied (Cargo.toml): only the Windows exception filter allows it (native_crash.rs). mod control_server; +#[cfg(windows)] +mod native_crash; mod terminate; mod update; mod usage; @@ -50,8 +52,8 @@ struct App { autosaver: Option, /// App time of the last autosave check. autosaved_at: f64, - /// The first frame was drawn (the crash journal says the app is up). - running: bool, + /// Frames drawn so far, up to 2 (the second means the first was presented: the app is up). + frames: u8, /// Sends the anonymous usage events, when this run may (docs/privacy.md). usage: Option, } @@ -68,9 +70,13 @@ impl App { impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - if !self.running { - self.running = true; - crash::activity("running"); + 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 { + crash::phase(crash::RUNNING); + } } self.app.logic(ctx); if self.app.now - self.autosaved_at >= AUTOSAVE_SECONDS { @@ -163,10 +169,18 @@ fn start_reporting() -> Vec { b.version_line(), crash::default_dir().map(|d| d.display().to_string()).unwrap_or_default() ); - crash::activity("startup"); crashed } +/// 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 (#299). +fn startup_step(step: &'static str, detail: &str) { + if step == eframe::egui_wgpu::STEP_ADAPTER_CHOSEN { + crash::set_environment(&format!("Renderer: {detail}")); + } + crash::phase(step); +} + /// Command-line options. #[derive(Debug, Default, PartialEq)] struct Args { @@ -224,6 +238,12 @@ fn main() -> eframe::Result { fieldcraft_io::recovery::use_automation_dir(); } let crashed = start_reporting(); + // 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(windows)] + native_crash::install(); + eframe::egui_wgpu::set_startup_hook(startup_step); + crash::phase("window"); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("FieldCraft") @@ -246,6 +266,7 @@ fn main() -> eframe::Result { "FieldCraft", options, Box::new(move |cc| { + crash::phase("app setup"); let mut session = Session::default(); // The window's model: its commands go into the crash journal. session.crash_journal = crash::enabled(); @@ -266,7 +287,13 @@ fn main() -> eframe::Result { if let Some(rs) = &cc.wgpu_render_state { let info = rs.adapter.get_info(); log::info!("graphics {:?} {} ({}), MSAA {}x (requested {MSAA}x)", info.backend, info.name, info.driver, rs.msaa_samples); + crash::set_environment(&format!("Renderer: {:?} {} ({}), MSAA {}x", info.backend, info.name, info.driver, rs.msaa_samples)); + crash::phase("viewport pipelines (shader compile)"); app.set_wgpu(rs, DEPTH_BITS, rs.msaa_samples, &cc.egui_ctx); + // The graphics driver is loaded now; it may have put its own exception filter + // in front of ours. + #[cfg(windows)] + native_crash::reinstall(); } // `kill` or Ctrl-C closes the window as Close does: autosaved, no crash (#248). let ctx = cc.egui_ctx.clone(); @@ -294,7 +321,8 @@ fn main() -> eframe::Result { Autosaver::new(&dir).map_err(|e| log::warn!("autosave unavailable: {e}")).ok() }); app.announce_crashes(&crashed); - Ok(Box::new(App { app, autosaver, autosaved_at: 0.0, running: false, usage })) + crash::phase("first frame"); + Ok(Box::new(App { app, autosaver, autosaved_at: 0.0, frames: 0, usage })) }), ) } diff --git a/apps/fieldcraft/src/native_crash.rs b/apps/fieldcraft/src/native_crash.rs new file mode 100644 index 0000000..328b8b5 --- /dev/null +++ b/apps/fieldcraft/src/native_crash.rs @@ -0,0 +1,290 @@ +//! Windows: what a crash in native code leaves behind (docs/crash-reports.md, #299). +//! +//! 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 (`fieldcraft_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. Everything that +//! can be tested without Windows (reading the file, the report, the startup steps) lives in +//! `fieldcraft_engine::crash` and is tested there. +//! +//! 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 only one in FieldCraft (the +//! app crate denies unsafe code instead of forbidding it for this). 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. +//! +//! Adapted from SolidCraft's `apps/solidcraft/src/native_crash.rs` (MIT OR Apache-2.0), see +//! ATTRIBUTION.md. +#![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 `fieldcraft_engine::crash::install`; nothing without it). +pub fn install() { + let Some((info, dump)) = fieldcraft_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("FIELDCRAFT_NATIVE_CRASH_CHILD") else { return }; + let build = fieldcraft_engine::crash::Build { version: "9.9.9", commit: "abc1234" }; + let fresh = fieldcraft_engine::crash::install(PathBuf::from(dir), build); + println!("FRESH {}", serde_json::to_string(&fresh).unwrap_or_default()); + if std::env::var_os("FIELDCRAFT_NATIVE_CRASH_FAULT").is_none() { + fieldcraft_engine::crash::end_session(); + return; + } + super::install(); + fieldcraft_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("FIELDCRAFT_NATIVE_CRASH_CHILD", dir); + if fault { + c.env("FIELDCRAFT_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!("fieldcraft-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("fieldcraft"), "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 = fieldcraft_engine::crash::list(&dir); + assert_eq!(reports.len(), 1, "{reports:?}"); + let r = fieldcraft_engine::crash::read(&dir, &reports[0].id).unwrap(); + assert!(r["message"].as_str().unwrap().contains("EXCEPTION_ACCESS_VIOLATION (0xC0000005) in fieldcraft"), "{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/docs/crash-reports.md b/docs/crash-reports.md index dc1eabf..106dc69 100644 --- a/docs/crash-reports.md +++ b/docs/crash-reports.md @@ -53,10 +53,31 @@ error stream. — an abort, a stack overflow, a crash in a graphics driver, the process being killed outright (SIGKILL) — found at the next start), `message`, `location`, `thread`, `backtrace`, `app`, `system`, `command`, `recent_commands` (up to 32), `last_activity` (the last control request, such as - `control ui.capture`), `base`, `count`, `first_seen`, `last_seen`, `notes` + `control ui.capture`), `startup_phase` (`{step, ms}`: the step a run that never drew its first + frame died in), `native` (Windows: what the exception filter caught), `base`, `count`, + `first_seen`, `last_seen`, `notes`; `system.environment` names the renderer, GPU and driver - `model.fieldcraft`: the model the replay starts from - `replay.json`: `{"commands": [{command, params}, …]}` - `log.txt`: the crashed run's lines of the log +- `minidump.dmp` (Windows, a crash in native code): open it with WinDbg or Visual Studio + +### Startup steps and native crashes + +While the app gets up, the journal keeps the step it is in: the app's own (`window`, `app setup`, +`viewport pipelines (shader compile)`, `first frame`, and `running` once a frame was presented) +and egui-wgpu's in between (`graphics instance`, `window surface`, `adapter request`, `adapter +chosen`, when the renderer is recorded, `device creation`, `egui renderer (shader compile)`, +`surface configure`, `first frame (present)`). A run that dies before `running` is reported "during startup (device creation, 1.2 s +after launch)", so a graphics driver crash names its step. + +On Windows an unhandled-exception filter (`apps/fieldcraft/src/native_crash.rs`) catches a crash +in native code (an access violation in a graphics driver, a stack overflow): before the process +goes it writes the exception code, the faulting module and the offset into it, and a minidump, +next to the journal. The next start puts them into the report: "crashed with +EXCEPTION_ACCESS_VIOLATION (0xC0000005) in igvk64.dll+0x64EA72 during startup (device +creation…)", `backtrace` the code, the module and offset, and the thread, `native` the raw fields, +and `minidump.dmp`. After a stack overflow there is no stack left for a minidump; the rest is +still written. The next start after a crash asks "FieldCraft closed unexpectedly": **Report It…** opens Report a Bug for that report.