Native crash capture on Windows, and startup steps in crash reports (#1215)

A crash inside a graphics driver left only "an unclean exit between
commands": no backtrace, no module, no hint that it was at startup.

- The crash journal now keeps the startup step the app is in (window,
  graphics instance, surface, adapter request, adapter chosen, device
  creation, renderer and viewport shader compiles, surface configure,
  first frame and its present, running) and the renderer as soon as the
  adapter is picked. egui-wgpu tells the app about its steps through a
  small hook. An unclean exit before the first frame names the step.
- Windows: an unhandled-exception filter (src/native_crash.rs, the one
  Windows-only module allowed unsafe) writes the exception code, the
  faulting module and offset, and a minidump next to the journal; the
  next start puts them into the report.
- The Windows CI job runs a child that faults on purpose and checks the
  report.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-14 17:53:52 -04:00
1 parent 0ddd660089
commit d20b0fab65
15 files changed
+703 -30

No files matched your search

+1 -1
View File
@@ -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
+5 -3
View File
@@ -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<T, E>`** 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
+5 -3
View File
@@ -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<T, E>`** 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
+3 -2
View File
@@ -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.
Generated
+1
View File
@@ -3466,6 +3466,7 @@ dependencies = [
"solidcraft-engine",
"solidcraft-ui-egui",
"ureq",
"windows-sys 0.61.2",
"winit",
]
+15 -2
View File
@@ -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.
+37 -2
View File
@@ -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<std::path::PathBuf>,
/// 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`.
+285
View File
@@ -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<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 `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::<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("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<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("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);
}
}
+197 -15
View File
@@ -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/<time>-<signature>/` holding `report.json`, and when known
//! `design.solidcraft` and `replay.json`. Nothing is ever sent anywhere.
//! `design.solidcraft`, `replay.json` and `minidump.dmp`. Nothing is ever sent anywhere.
use std::backtrace::Backtrace;
use std::cell::{Cell, RefCell};
@@ -93,8 +100,13 @@ struct State {
known: Mutex<HashMap<u64, (u64, String)>>,
written: AtomicUsize,
started: u64,
/// When the app started, in milliseconds (for how far into the start a step came).
started_ms: u64,
}
/// The startup step that ends startup: the app is up and drawing.
pub const RUNNING: &str = "running";
static STATE: OnceLock<State> = OnceLock::new();
/// The hook is writing a report (a panic inside it must not write another).
static IN_HOOK: AtomicBool = AtomicBool::new(false);
@@ -131,6 +143,10 @@ fn now() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs())
}
fn now_ms() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
}
/// `secs` since 1970 as (year, month, day, hour, minute, second) in UTC.
fn civil(secs: u64) -> (i64, i64, i64, u64, u64, u64) {
let days = i64::try_from(secs / 86_400).unwrap_or(0);
@@ -279,10 +295,12 @@ fn frames(bt: &str) -> Vec<String> {
pub fn install(dir: PathBuf, build: Build) -> Vec<Summary> {
let _ = fs::create_dir_all(dir.join("sessions"));
let started = now();
let started_ms = now_ms();
for path in dead_journals(&dir) {
unclean_exit(&dir, &build, &path);
let _ = fs::remove_file(&path);
}
remove_stray_native_files(&dir);
prune(&dir, started);
// Reports the user has been told about (by id).
let seen_file = dir.join(".seen");
@@ -304,6 +322,7 @@ pub fn install(dir: PathBuf, build: Build) -> Vec<Summary> {
known: Mutex::new(known),
written: AtomicUsize::new(0),
started,
started_ms,
})
.is_ok()
{
@@ -323,6 +342,53 @@ pub fn set_environment(text: &str) {
&& let Ok(mut e) = s.environment.lock()
{
*e = limited(text, 500);
if let Ok(mut j) = s.journal.lock() {
let line = json!({ "environment": *e }).to_string();
j.write(&line);
j.environment = Some(line);
}
}
}
/// The app is about to start a step of getting up (`"window"`, `"device creation"`, …; [`RUNNING`]
/// once it draws): the journal keeps the latest, so a crash nothing catches (inside a graphics
/// driver, say) is still reported with the step it happened in.
pub fn phase(step: &str) {
let Some(s) = STATE.get() else { return };
if let Ok(mut j) = s.journal.lock() {
if j.phase_step.as_deref() == Some(step) {
return;
}
let line = json!({ "phase": limited(step, 200), "ms": now_ms().saturating_sub(s.started_ms) }).to_string();
j.write(&line);
j.phase = Some(line);
j.phase_step = Some(step.to_string());
}
}
/// Where a native crash handler writes what it caught for this run: a small JSON file
/// (`code`, `name`, `address`, `module`, `offset`, `detail`, `thread`) and a minidump. The next
/// start reports both with this run's unclean exit. `None` before [`install`] or without a journal.
pub fn native_paths() -> Option<(PathBuf, PathBuf)> {
let s = STATE.get()?;
let j = s.journal.lock().ok()?;
j.path.as_ref().map(|p| native_paths_for(p))
}
fn native_paths_for(journal: &Path) -> (PathBuf, PathBuf) {
(journal.with_extension("native.json"), journal.with_extension("dmp"))
}
/// Native crash files whose journal is gone (their report was written, or never will be).
fn remove_stray_native_files(dir: &Path) {
for path in fs::read_dir(dir.join("sessions")).into_iter().flatten().flatten().map(|e| e.path()).take(200) {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default();
let stem = name.strip_suffix(".native.json").or_else(|| name.strip_suffix(".dmp"));
if let Some(stem) = stem
&& !path.with_file_name(format!("{stem}.jsonl")).exists()
{
let _ = fs::remove_file(&path);
}
}
}
@@ -473,6 +539,8 @@ pub(crate) fn command_done(
recent,
base: json!("the design as it was when the command started"),
notes: Vec::new(),
fields: serde_json::Map::new(),
attach: Vec::new(),
};
write_report(state, kind, Some(&p), extra);
}
@@ -508,6 +576,10 @@ struct Journal {
header: String,
/// Where the running command's line starts (to take it back when it changed nothing).
running_at: Option<u64>,
/// The latest startup step's line and name, and the renderer's line (kept across rebases).
phase: Option<String>,
phase_step: Option<String>,
environment: Option<String>,
}
impl Journal {
@@ -517,7 +589,18 @@ impl Journal {
// Append-only handles cannot truncate on Windows; seek explicitly for each write.
let file = OpenOptions::new().create(true).truncate(false).read(true).write(true).open(&path).ok().filter(|f| f.try_lock().is_ok());
let header = json!({ "start": utc(started), "pid": std::process::id(), "version": build.version, "commit": build.commit }).to_string();
let mut j = Journal { path: file.as_ref().map(|_| path), file, bytes: 0, full: false, last_doc: 0, header, running_at: None };
let mut j = Journal {
path: file.as_ref().map(|_| path),
file,
bytes: 0,
full: false,
last_doc: 0,
header,
running_at: None,
phase: None,
phase_step: None,
environment: None,
};
j.rebase(&json!("new"), 0);
j
}
@@ -547,6 +630,9 @@ impl Journal {
let h = self.header.clone();
self.write(&h);
self.record(&json!({ "base": base }));
for line in [self.phase.clone(), self.environment.clone()].into_iter().flatten() {
self.write(&line);
}
}
fn run(&mut self, id: &str, params: &Value) {
@@ -601,6 +687,10 @@ struct JournalRead {
recent: Vec<Value>,
complete: bool,
reported: bool,
/// The latest startup step and how many milliseconds after the start it began.
phase: Option<(String, u64)>,
/// The renderer, once known.
environment: Option<String>,
}
// Read through the lock-owning file object. On Windows an independently opened
@@ -626,7 +716,10 @@ fn read_journal_file(mut file: File) -> JournalRead {
r.header = v;
continue;
}
r.reported = v.get("reported").is_some();
// Startup steps and the renderer say nothing about whether the run was reported.
if v.get("phase").is_none() && v.get("environment").is_none() {
r.reported = v.get("reported").is_some();
}
if let Some(b) = v.get("base") {
r.base = b.clone();
r.replay.clear();
@@ -655,6 +748,10 @@ fn read_journal_file(mut file: File) -> JournalRead {
}
} else if v.get("full").is_some() {
r.complete = false;
} else if let Some(p) = v.get("phase").and_then(Value::as_str) {
r.phase = Some((p.to_string(), v.get("ms").and_then(Value::as_u64).unwrap_or(0)));
} else if let Some(e) = v.get("environment").and_then(Value::as_str) {
r.environment = Some(e.to_string());
}
}
r.in_flight = running;
@@ -678,33 +775,84 @@ fn dead_journals(dir: &Path) -> Vec<PathBuf> {
fn unclean_exit(dir: &Path, build: &Build, journal: &Path) {
let j = read_journal(journal);
let (native_file, dump_file) = native_paths_for(journal);
let native = fs::read(&native_file).ok().and_then(|b| serde_json::from_slice::<Value>(&b).ok()).filter(Value::is_object);
if j.reported {
return;
}
let was = j.header.get("version").and_then(Value::as_str).unwrap_or("?");
let when = j.header.get("start").and_then(Value::as_str).unwrap_or("?");
let doing = j.in_flight.as_ref().and_then(|c| c.get("command")).and_then(Value::as_str);
let message = match doing {
Some(c) => format!("SolidCraft {was} (started {when}) ended without a clean exit while running `{c}`"),
None => format!("SolidCraft {was} (started {when}) ended without a clean exit between commands"),
// A run that never got to drawing died while starting up: say at which step.
let starting = j.phase.as_ref().filter(|(p, _)| p != RUNNING).map(|(p, ms)| (p.clone(), *ms));
let during = match (&starting, doing) {
(Some((p, ms)), _) => format!("during startup ({p}, {:.1} s after launch)", *ms as f64 / 1000.0),
(None, Some(c)) => format!("while running `{c}`"),
(None, None) => "between commands".to_string(),
};
let field = |k: &str| native.as_ref().and_then(|n| n.get(k)).and_then(Value::as_str).map(|v| limited(v, 300));
let (message, location, signature_text, backtrace) = match &native {
Some(_) => {
let code = field("code").unwrap_or_else(|| "?".into());
let name = field("name").unwrap_or_else(|| "exception".into());
let module = field("module").unwrap_or_else(|| "(unknown module)".into());
let short = module.rsplit(['\\', '/']).next().unwrap_or(&module).to_string();
let offset = field("offset").unwrap_or_else(|| "?".into());
let at = format!("{short}+{offset}");
let mut frames = vec![format!("{code} {name}{}", field("detail").map(|d| format!(" ({d})")).unwrap_or_default())];
frames.push(format!("at {module}+{offset} (address {})", field("address").unwrap_or_else(|| "?".into())));
if let Some(t) = native.as_ref().and_then(|n| n.get("thread")).and_then(Value::as_u64) {
frames.push(format!("on thread {t}"));
}
(
format!("SolidCraft {was} (started {when}) crashed with {name} ({code}) in {at} {during}"),
format!("native exception in {at}"),
format!("{code} {short} {}", starting.as_ref().map_or(doing.unwrap_or("idle"), |(p, _)| p.as_str())),
frames,
)
}
None => (
format!("SolidCraft {was} (started {when}) ended without a clean exit {during}"),
match (&starting, doing) {
(Some((p, _)), _) => format!("unclean exit during startup: {p}"),
(None, c) => format!("unclean exit in {}", c.unwrap_or("(between commands)")),
},
match &starting {
Some((p, _)) => format!("startup: {p}"),
None => doing.unwrap_or("idle").to_string(),
},
Vec::new(),
),
};
// A temporary state just for this report (the app's own is not set up yet).
let state = State {
dir: dir.to_path_buf(),
build: build.clone(),
environment: Mutex::new(String::new()),
journal: Mutex::new(Journal { path: None, file: None, bytes: 0, full: true, last_doc: 0, header: String::new(), running_at: None }),
environment: Mutex::new(j.environment.clone().unwrap_or_default()),
journal: Mutex::new(Journal {
path: None,
file: None,
bytes: 0,
full: true,
last_doc: 0,
header: String::new(),
running_at: None,
phase: None,
phase_step: None,
environment: None,
}),
known: Mutex::new(list(dir).into_iter().filter_map(|s| sig_of(&s.id).map(|sig| (sig, (0, s.id)))).collect()),
written: AtomicUsize::new(0),
started: now(),
started_ms: now_ms(),
};
let p = Panic {
message,
location: format!("unclean exit in {}", doing.unwrap_or("(between commands)")),
location,
thread: "(unknown)".into(),
backtrace: Vec::new(),
backtrace,
recovered: false,
signature: signature("unclean exit", doing.unwrap_or("idle")),
signature: signature(if native.is_some() { "native exception" } else { "unclean exit" }, &signature_text),
};
if let Ok(mut k) = state.known.lock()
&& let Some(e) = k.get_mut(&p.signature)
@@ -715,7 +863,13 @@ fn unclean_exit(dir: &Path, build: &Build, journal: &Path) {
bump(dir, &id, n);
return;
}
let extra = from_journal(j);
let mut extra = from_journal(j);
if let Some(n) = native {
extra.fields.insert("native".into(), n);
if dump_file.is_file() {
extra.attach.push(("minidump".into(), dump_file, "minidump.dmp".into()));
}
}
write_report(&state, "unclean_exit", Some(&p), extra);
}
@@ -729,6 +883,10 @@ struct Extra {
recent: Vec<Value>,
base: Value,
notes: Vec<String>,
/// More top-level fields for the report (`startup_phase`, `native`).
fields: serde_json::Map<String, Value>,
/// Files moved into the report's folder: (key in `files`, source, name in the folder).
attach: Vec<(String, PathBuf, String)>,
}
/// Design and replay from a journal: the base design (autosave or saved file) and the commands
@@ -762,7 +920,20 @@ fn from_journal(j: JournalRead) -> Extra {
if j.base.as_str() == Some("unknown") {
notes.push("the design changed outside commands before this; it cannot be rebuilt from the journal".into());
}
Extra { command: j.in_flight, design, replay: replay.then(|| json!({ "commands": commands })), recent: j.recent, base: j.base, notes }
let mut fields = serde_json::Map::new();
if let Some((p, ms)) = j.phase.filter(|(p, _)| p != RUNNING) {
fields.insert("startup_phase".into(), json!({ "step": p, "ms": ms }));
}
Extra {
command: j.in_flight,
design,
replay: replay.then(|| json!({ "commands": commands })),
recent: j.recent,
base: j.base,
notes,
fields,
attach: Vec::new(),
}
}
fn write_from_journal(state: &State, kind: &str, p: Option<&Panic>) -> Option<String> {
@@ -805,13 +976,19 @@ fn write_report(state: &State, kind: &str, p: Option<&Panic>, extra: Extra) -> O
{
files.insert("replay".into(), json!("replay.json"));
}
for (key, from, name) in &extra.attach {
let to = folder.join(name);
if fs::rename(from, &to).is_ok() || fs::copy(from, &to).is_ok() {
files.insert(key.clone(), json!(name));
}
}
let reproduce = match (files.contains_key("replay"), files.contains_key("design")) {
(true, true) => Some(format!("solidcraft-cli crashes replay {id}")),
(true, false) => Some(format!("solidcraft-cli crashes replay {id} (starts from a new design)")),
_ => None,
};
let environment = state.environment.lock().map(|e| e.clone()).unwrap_or_default();
let report = json!({
let mut report = json!({
"format": FORMAT,
"id": id,
"kind": kind,
@@ -833,6 +1010,11 @@ fn write_report(state: &State, kind: &str, p: Option<&Panic>, extra: Extra) -> O
"reproduce": reproduce,
"notes": notes,
});
if let Some(r) = report.as_object_mut() {
for (k, v) in extra.fields {
r.entry(k).or_insert(v);
}
}
let bytes = serde_json::to_vec_pretty(&report).ok()?;
solidcraft_io::write_atomic(&folder.join("report.json"), &bytes, false).ok()?;
prune(&state.dir, t);
@@ -957,7 +1139,7 @@ pub fn read(dir: &Path, id: &str) -> std::result::Result<Value, String> {
let folder = dir.join(id);
let mut v = read_report_file(&folder.join("report.json")).ok_or_else(|| format!("no crash report `{id}`"))?;
v["path"] = json!(folder.to_string_lossy());
for (k, f) in [("design_path", "design.solidcraft"), ("replay_path", "replay.json")] {
for (k, f) in [("design_path", "design.solidcraft"), ("replay_path", "replay.json"), ("minidump_path", "minidump.dmp")] {
if folder.join(f).is_file() {
v[k] = json!(folder.join(f).to_string_lossy());
}
+81
View File
@@ -20,6 +20,24 @@ fn child() {
};
let fresh = install(PathBuf::from(dir), build());
println!("FRESH {}", serde_json::to_string(&fresh).unwrap());
// Dying while starting up, before any command: in a graphics driver, with or without what
// a native crash handler caught.
if mode == "startup" || mode == "native" {
phase("window");
phase("adapter request");
set_environment("Renderer: Vulkan Test GPU (driver 1.2.3)");
phase("device creation");
if mode == "native" {
let (info, dump) = native_paths().unwrap();
fs::write(
info,
r#"{"code":"0xC0000005","name":"EXCEPTION_ACCESS_VIOLATION","detail":"read of 0x0000000000000010","address":"0x00007FFB12345678","module":"C:\\Windows\\System32\\DriverStore\\igvk64.dll","offset":"0x64EA72","thread":4242}"#,
)
.unwrap();
fs::write(dump, b"MDMP fake").unwrap();
}
std::process::abort();
}
set_environment("Renderer: test");
let mut s = Session { crash_journal: true, ..Default::default() };
s.execute("solid.box", &json!({"length": 10, "width": 20, "height": 30})).unwrap();
@@ -187,6 +205,69 @@ fn render_settings_survive_unclean_exit_and_journal_replay() {
let _ = fs::remove_dir_all(&d);
}
#[test]
fn a_crash_while_starting_up_names_the_step_and_the_renderer() {
let d = fresh_dir("startup");
let (_, ok) = run_child(&d, "startup");
assert!(!ok, "the child aborted");
let (_, ok) = run_child(&d, "none");
assert!(ok);
let (s, v) = only_report(&d);
assert_eq!(s.kind, "unclean_exit");
assert!(s.message.contains("during startup (device creation"), "{}", s.message);
assert_eq!(v["location"], "unclean exit during startup: device creation");
assert_eq!(v["startup_phase"]["step"], "device creation");
assert_eq!(v["system"]["environment"], "Renderer: Vulkan Test GPU (driver 1.2.3)", "{v}");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn a_native_crash_is_reported_with_the_module_the_step_and_the_minidump() {
let d = fresh_dir("native");
let (_, ok) = run_child(&d, "native");
assert!(!ok, "the child aborted");
let (_, ok) = run_child(&d, "none");
assert!(ok);
let (s, v) = only_report(&d);
assert_eq!(s.kind, "unclean_exit");
assert!(s.message.contains("EXCEPTION_ACCESS_VIOLATION (0xC0000005) in igvk64.dll+0x64EA72 during startup (device creation"), "{}", s.message);
assert_eq!(v["location"], "native exception in igvk64.dll+0x64EA72");
assert_eq!(v["native"]["thread"], 4242);
let bt: Vec<&str> = v["backtrace"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect();
assert_eq!(bt[0], "0xC0000005 EXCEPTION_ACCESS_VIOLATION (read of 0x0000000000000010)");
assert!(bt[1].contains("igvk64.dll+0x64EA72"), "{bt:?}");
assert_eq!(fs::read(v["minidump_path"].as_str().unwrap()).unwrap(), b"MDMP fake");
assert_eq!(v["files"]["minidump"], "minidump.dmp");
// Nothing is left behind next to the journals.
let left: Vec<_> = fs::read_dir(d.join("sessions")).unwrap().flatten().map(|e| e.file_name()).collect();
assert!(left.iter().all(|n| n.to_string_lossy().ends_with(".jsonl")), "{left:?}");
// The same crash again is counted, not written again.
let _ = run_child(&d, "native");
let _ = run_child(&d, "none");
let (s, _) = only_report(&d);
assert_eq!(s.count, 2);
let _ = fs::remove_dir_all(&d);
}
#[test]
fn startup_steps_survive_a_rebase_and_running_ends_startup() {
let d = fresh_dir("rebase");
fs::create_dir_all(d.join("sessions")).unwrap();
let mut j = Journal::open(&d, &build(), now());
j.phase = Some(json!({"phase": "first frame", "ms": 5}).to_string());
j.environment = Some(json!({"environment": "Renderer: X"}).to_string());
j.rebase(&json!("new"), 1);
let r = read_journal(j.path.as_ref().unwrap());
assert_eq!(r.phase, Some(("first frame".to_string(), 5)));
assert_eq!(r.environment.as_deref(), Some("Renderer: X"));
j.write(&json!({"phase": RUNNING, "ms": 9}).to_string());
let r = read_journal(j.path.as_ref().unwrap());
assert_eq!(r.phase, Some((RUNNING.to_string(), 9)));
assert!(from_journal(r).fields.get("startup_phase").is_none(), "a running app is past startup");
drop(j);
let _ = fs::remove_dir_all(&d);
}
#[test]
fn a_journal_reads_back_its_base_commands_and_the_one_in_flight() {
let d = fresh_dir("journal");
+1 -1
View File
@@ -298,7 +298,7 @@ pub fn crash_draft(report: &serde_json::Value) -> Draft {
let what = match report.get("kind").and_then(serde_json::Value::as_str) {
Some("command_panic") => "An internal error stopped a command (the design was left unchanged)",
Some("kernel_panic_recovered") => "The geometry kernel hit an internal error (caught; the operation failed)",
Some("unclean_exit") => "SolidCraft closed unexpectedly (abort, stack overflow or the process was killed)",
Some("unclean_exit") => "SolidCraft closed unexpectedly (abort, stack overflow, a crash in a graphics driver or the process was killed)",
_ => "SolidCraft hit an internal error",
};
let command = report.get("command").and_then(|c| c.get("command")).and_then(serde_json::Value::as_str);
+17 -1
View File
@@ -58,7 +58,7 @@ A folder `crashes/<YYYYMMDD-HHMMSS>-<signature>/`:
- `command_panic`: a command hit an internal error; it was caught and the design was left unchanged
- `kernel_panic_recovered`: the third-party geometry kernel panicked and was caught. This is harmless on the desktop, but it would crash the web build.
- `panic`: an internal error outside any command
- `unclean_exit`: the app ended without a hook seeing it (an abort, a stack overflow, a segfault, or the process being killed). It is found at the next start.
- `unclean_exit`: the app ended without a hook seeing it (an abort, a stack overflow, a segfault, a crash inside a graphics driver, or the process being killed). It is found at the next start. When the app died while starting up, the message and `location` name the startup step (see below). On Windows, a crash in native code also says the exception, the module it happened in and the offset into it.
- `message`, `location` (file:line), `thread`
- `backtrace`: frames `symbol (file:line)`, without the panic machinery, std internals or the user's home folder
- `app` (version, commit, build date), `system` (OS, architecture, renderer and GPU), `session.uptime_s`
@@ -67,8 +67,24 @@ A folder `crashes/<YYYYMMDD-HHMMSS>-<signature>/`:
- `base`: what the replay starts from (the design before the command, an autosave, a saved file or a new design)
- `count`, `first_seen`, `last_seen`: the same crash repeats only increase `count`
- `notes`: what is missing and why, for example a design too large to copy
- `startup_phase`: `{step, ms}` when the app died before it drew its first frame: the step it
was in (`window`, `graphics instance`, `window surface`, `adapter request`, `adapter chosen`,
`device creation`, `egui renderer (shader compile)`, `surface configure`, `app setup`,
`viewport pipelines (shader compile)`, `first frame`, `first frame (present)`) and how many
milliseconds after launch it began. `system.environment` names the renderer (backend, GPU,
driver) as soon as the adapter is picked, so it is there even when the device never started.
- `native` (Windows): what the exception filter caught: `code`, `name`, `address`, `module`,
`offset` (into the module), `detail` (for an access violation, the address read or written)
and `thread`. The `backtrace` then shows the same in two lines.
- `design.solidcraft`: the design the replay starts from
- `replay.json`: `{"commands": [{command, params}, …]}`
- `minidump.dmp` (Windows, native crashes): the threads' stacks and the loaded modules at the
crash, for WinDbg or Visual Studio. It holds stack memory only, not the design, but it stays
on this computer like the rest of the report; share it only on request.
A crash that ends the process without any exception (a fail-fast, which is how an abort ends on
Windows) still gets the startup step. Windows can also keep its own dumps of every crash: set
`DumpFolder` under `HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps`.
## How it stays cheap and small
+4
View File
@@ -125,6 +125,10 @@ SolidCraft changes:
guarantees 1x and 4x).
- `src/setup.rs`: the default device asks for the adapter's largest 2D texture (at least 8192)
instead of exactly 8192.
- `src/lib.rs`, `src/winit.rs`: `set_startup_hook` is told before each step of getting the
device and the window's surface up (instance, surface, adapter request, the chosen adapter,
device, renderer, surface configure, first present), so the app's crash journal can say which
step a crash inside the graphics driver happened in.
- `src/lib.rs`, `src/winit.rs`: a window larger than the device's largest texture gets a surface
clamped to it (`clamp_surface_extent`, aspect ratio kept) and egui is drawn at the matching
smaller scale, instead of a `Surface::configure` validation panic (#869). The resize path and
+41
View File
@@ -41,6 +41,35 @@ use std::sync::Arc;
use epaint::mutex::RwLock;
/// SolidCraft: what [`set_startup_hook`] is told before each step of getting the graphics
/// device and the window's surface up, so a crash inside the driver (with no Rust panic) can
/// still say which step it died in. `detail` is empty except for [`STEP_ADAPTER_CHOSEN`].
pub type StartupHook = fn(step: &'static str, detail: &str);
static STARTUP_HOOK: std::sync::OnceLock<StartupHook> = std::sync::OnceLock::new();
/// SolidCraft: the steps [`StartupHook`] is told about, in order.
pub const STEP_INSTANCE: &str = "graphics instance";
pub const STEP_SURFACE: &str = "window surface";
pub const STEP_ADAPTER: &str = "adapter request";
/// The adapter was picked; `detail` names it, its backend and driver.
pub const STEP_ADAPTER_CHOSEN: &str = "adapter chosen";
pub const STEP_DEVICE: &str = "device creation";
pub const STEP_RENDERER: &str = "egui renderer (shader compile)";
pub const STEP_SURFACE_CONFIGURE: &str = "surface configure";
pub const STEP_FIRST_PRESENT: &str = "first frame (present)";
/// SolidCraft: install the startup hook (once; later calls are ignored).
pub fn set_startup_hook(hook: StartupHook) {
let _ = STARTUP_HOOK.set(hook);
}
pub(crate) fn startup_step(step: &'static str, detail: &str) {
if let Some(hook) = STARTUP_HOOK.get() {
hook(step, detail);
}
}
/// An error produced by egui-wgpu.
#[derive(thiserror::Error, Debug)]
pub enum WgpuError {
@@ -226,6 +255,7 @@ impl RenderState {
wgpu::Backends::all()
};
startup_step(STEP_ADAPTER, ""); // SolidCraft
instance.enumerate_adapters(backends).await
};
@@ -257,6 +287,16 @@ impl RenderState {
}
}?;
// SolidCraft: name the adapter before the driver is asked for a device.
let info = adapter.get_info();
startup_step(
STEP_ADAPTER_CHOSEN,
&format!(
"{:?} {} ({:?}, driver {} {})",
info.backend, info.name, info.device_type, info.driver, info.driver_info
),
);
startup_step(STEP_DEVICE, "");
let (device, queue) = {
profiling::scope!("request_device");
adapter
@@ -303,6 +343,7 @@ impl RenderState {
msaa_samples,
..options
};
startup_step(STEP_RENDERER, ""); // SolidCraft
let renderer = Renderer::new(&device, target_format, options);
// On wasm, depending on feature flags, wgpu objects may or may not implement sync.
+10
View File
@@ -27,6 +27,9 @@ struct SurfaceState {
needs_recreate: bool,
}
/// SolidCraft: whether a frame was presented yet (`crate::STEP_FIRST_PRESENT`).
static FIRST_PRESENT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Everything you need to paint egui with [`wgpu`] on [`winit`].
///
/// Alternatively you can use [`crate::Renderer`] directly.
@@ -70,6 +73,7 @@ impl Painter {
options: RendererOptions,
) -> Self {
let (capture_tx, capture_rx) = capture_channel();
crate::startup_step(crate::STEP_INSTANCE, ""); // SolidCraft
let instance = config.wgpu_setup.new_instance().await;
Self {
@@ -206,6 +210,7 @@ impl Painter {
if let Some(window) = window {
let size = window.inner_size();
if !self.surfaces.contains_key(&viewport_id) {
crate::startup_step(crate::STEP_SURFACE, ""); // SolidCraft
let surface = self.instance.create_surface(window)?;
self.add_surface(surface, viewport_id, size).await?;
}
@@ -259,6 +264,7 @@ impl Painter {
self.options.msaa_samples = render_state.msaa_samples;
self.render_state = Some(render_state);
}
crate::startup_step(crate::STEP_SURFACE_CONFIGURE, ""); // SolidCraft
self.install_surface(surface, viewport_id, size.width, size.height, false);
Ok(())
}
@@ -628,6 +634,10 @@ impl Painter {
profiling::scope!("get_current_texture");
// This is what vsync-waiting happens on my Mac.
let start = web_time::Instant::now();
// SolidCraft: the first frame is the last startup step.
if !FIRST_PRESENT.swap(true, std::sync::atomic::Ordering::Relaxed) {
crate::startup_step(crate::STEP_FIRST_PRESENT, "");
}
let output_frame = surface_state.surface.get_current_texture();
vsync_sec += start.elapsed().as_secs_f32();
output_frame