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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-16 01:11:58 -04:00
1 parent ed25de6521
commit af9b4bb222
8 files changed
+394 -13

No files matched your search

+17
View File
@@ -196,6 +196,23 @@ jobs:
- name: docs/oracle.md is current - name: docs/oracle.md is current
run: git diff --exit-code docs/oracle.md 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: advisories:
name: Dependency licences, advisories and sources 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]') 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]')
+3 -1
View File
@@ -24,7 +24,9 @@ FieldCraft everywhere: crates `fieldcraft-*`, binaries `fieldcraft` and `fieldcr
## Never crash ## Never crash
People trust FieldCraft with their models; a crash loses their work. **This outranks feature work.** 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!`, - **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<T, E>`** through the crate's error type and `?`. An unfinished feature - **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
returns an error ("not supported yet: …"); it never panics. returns an error ("not supported yet: …"); it never panics.
- **Input-derived numbers are hostile** (command parameters, model files, solver decks, - **Input-derived numbers are hostile** (command parameters, model files, solver decks,
+1 -1
View File
@@ -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/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/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/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 | | 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/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 | | 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 |
Generated
+1
View File
@@ -1286,6 +1286,7 @@ dependencies = [
"serde_json", "serde_json",
"signal-hook", "signal-hook",
"ureq", "ureq",
"windows-sys 0.61.2",
] ]
[[package]] [[package]]
+24 -2
View File
@@ -6,8 +6,18 @@ edition.workspace = true
license.workspace = true license.workspace = true
publish = false publish = false
[lints] # The workspace lints, except unsafe_code: denied, not forbidden, so that src/native_crash.rs
workspace = true # (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] [dependencies]
fieldcraft-engine = { workspace = true } fieldcraft-engine = { workspace = true }
@@ -29,6 +39,18 @@ getrandom = "0.3"
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
signal-hook = "0.3" 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] [build-dependencies]
# The Windows executable's icon resource (build.rs). # The Windows executable's icon resource (build.rs).
embed-resource = "3.0.12" embed-resource = "3.0.12"
+36 -8
View File
@@ -9,9 +9,11 @@
//! `fieldcraft_ui_egui::control` and docs/control-protocol.md. //! `fieldcraft_ui_egui::control` and docs/control-protocol.md.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] #![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)] #![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; mod control_server;
#[cfg(windows)]
mod native_crash;
mod terminate; mod terminate;
mod update; mod update;
mod usage; mod usage;
@@ -50,8 +52,8 @@ struct App {
autosaver: Option<Autosaver>, autosaver: Option<Autosaver>,
/// App time of the last autosave check. /// App time of the last autosave check.
autosaved_at: f64, autosaved_at: f64,
/// The first frame was drawn (the crash journal says the app is up). /// Frames drawn so far, up to 2 (the second means the first was presented: the app is up).
running: bool, frames: u8,
/// Sends the anonymous usage events, when this run may (docs/privacy.md). /// Sends the anonymous usage events, when this run may (docs/privacy.md).
usage: Option<usage::Sender>, usage: Option<usage::Sender>,
} }
@@ -68,9 +70,13 @@ impl App {
impl eframe::App for App { impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if !self.running { if self.frames < 2 {
self.running = true; self.frames += 1;
crash::activity("running"); // 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); self.app.logic(ctx);
if self.app.now - self.autosaved_at >= AUTOSAVE_SECONDS { if self.app.now - self.autosaved_at >= AUTOSAVE_SECONDS {
@@ -163,10 +169,18 @@ fn start_reporting() -> Vec<crash::Summary> {
b.version_line(), b.version_line(),
crash::default_dir().map(|d| d.display().to_string()).unwrap_or_default() crash::default_dir().map(|d| d.display().to_string()).unwrap_or_default()
); );
crash::activity("startup");
crashed 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. /// Command-line options.
#[derive(Debug, Default, PartialEq)] #[derive(Debug, Default, PartialEq)]
struct Args { struct Args {
@@ -224,6 +238,12 @@ fn main() -> eframe::Result {
fieldcraft_io::recovery::use_automation_dir(); fieldcraft_io::recovery::use_automation_dir();
} }
let crashed = start_reporting(); 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 { let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default() viewport: egui::ViewportBuilder::default()
.with_title("FieldCraft") .with_title("FieldCraft")
@@ -246,6 +266,7 @@ fn main() -> eframe::Result {
"FieldCraft", "FieldCraft",
options, options,
Box::new(move |cc| { Box::new(move |cc| {
crash::phase("app setup");
let mut session = Session::default(); let mut session = Session::default();
// The window's model: its commands go into the crash journal. // The window's model: its commands go into the crash journal.
session.crash_journal = crash::enabled(); session.crash_journal = crash::enabled();
@@ -266,7 +287,13 @@ fn main() -> eframe::Result {
if let Some(rs) = &cc.wgpu_render_state { if let Some(rs) = &cc.wgpu_render_state {
let info = rs.adapter.get_info(); let info = rs.adapter.get_info();
log::info!("graphics {:?} {} ({}), MSAA {}x (requested {MSAA}x)", info.backend, info.name, info.driver, rs.msaa_samples); 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); 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). // `kill` or Ctrl-C closes the window as Close does: autosaved, no crash (#248).
let ctx = cc.egui_ctx.clone(); 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() Autosaver::new(&dir).map_err(|e| log::warn!("autosave unavailable: {e}")).ok()
}); });
app.announce_crashes(&crashed); 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 }))
}), }),
) )
} }
+290
View File
@@ -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<u16>,
dump: Vec<u16>,
}
static PATHS: OnceLock<Paths> = 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::<Vec<u16>>();
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<PathBuf> = 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);
}
}
+22 -1
View File
@@ -53,10 +53,31 @@ error stream.
— an abort, a stack overflow, a crash in a graphics driver, the process being killed outright — 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`, (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 `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 - `model.fieldcraft`: the model the replay starts from
- `replay.json`: `{"commands": [{command, params}, …]}` - `replay.json`: `{"commands": [{command, params}, …]}`
- `log.txt`: the crashed run's lines of the log - `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 The next start after a crash asks "FieldCraft closed unexpectedly": **Report It…** opens Report a
Bug for that report. Bug for that report.