forked from 1b-agent/fieldcraft-upstream
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:
1 parent
ed25de6521
commit
af9b4bb222
8 files changed
+394
-13
No files matched your search
@@ -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]')
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -1286,6 +1286,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"signal-hook",
|
"signal-hook",
|
||||||
"ureq",
|
"ureq",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 }))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in new issue
Block a user