Skip to main content

concinnity_engine/crash/
mod.rs

1// src/crash/mod.rs
2//
3// Crash reporting: a panic hook, native fault capture, and local report files
4// under the crashes directory a host names at install. Reports
5// are plain text written section by section, so a partial report still leads
6// with what matters; macOS and Windows also write a minidump beside the
7// report. The directory is pruned to the newest reports after each write.
8// Local files only: nothing is uploaded, and no hostname or username is
9// recorded.
10
11mod hook;
12mod memory;
13mod report;
14mod ring;
15mod write;
16
17#[cfg(any(target_os = "macos", target_os = "windows"))]
18mod minidump;
19#[cfg(any(target_os = "macos", target_os = "windows"))]
20mod native;
21
22pub use ring::RingLayer;
23
24use std::path::{Path, PathBuf};
25use std::sync::Mutex;
26
27// Where reports land. Process state, and it has to be: a fault handler runs on
28// a compromised stack with no caller to take a path from, so the directory is
29// resolved once, up front, by whoever installs the hooks.
30static REPORT_DIR: Mutex<Option<PathBuf>> = Mutex::new(None);
31
32/// Install the process-wide crash hooks writing to `dir`: a panic hook that
33/// writes a report and chains to the previously installed hook, plus
34/// (macOS/Windows) a native fault handler that writes a minidump. Binaries call
35/// this first thing at startup, naming the `crashes/` of the state tree they
36/// resolved; later calls re-point the directory but install nothing twice.
37///
38/// Without a directory (`None`) the hooks still chain and the notes still
39/// accumulate, but nothing is written: an embedder that has chosen no place for
40/// them gets no files.
41pub fn install(dir: Option<&Path>) {
42    *REPORT_DIR.lock().unwrap_or_else(|p| p.into_inner()) = dir.map(Path::to_path_buf);
43    hook::install();
44    #[cfg(any(target_os = "macos", target_os = "windows"))]
45    native::install();
46    #[cfg(backend_metal)]
47    note("backend", "metal");
48    #[cfg(backend_dx)]
49    note("backend", "directx");
50    #[cfg(backend_vk)]
51    note("backend", "vulkan");
52    #[cfg(not(any(backend_metal, backend_dx, backend_vk)))]
53    note("backend", "none");
54}
55
56const MAX_NOTES: usize = 16;
57const MAX_NOTE_KEY_BYTES: usize = 32;
58const MAX_NOTE_VALUE_BYTES: usize = 128;
59
60// Bounded key-value context stamped into every report (backend, GPU, world
61// identity). Bounded so the crash path never carries unbounded state.
62static NOTES: Mutex<Vec<(String, String)>> = Mutex::new(Vec::new());
63
64/// Record a context note included in subsequent crash reports, replacing any
65/// previous value for `key`. Bounded: at most 16 keys, keys truncate at 32
66/// bytes and values at 128; further keys are dropped.
67pub fn note(key: &str, value: &str) {
68    let key = clamp(key, MAX_NOTE_KEY_BYTES);
69    let value = clamp(value, MAX_NOTE_VALUE_BYTES);
70    let mut notes = NOTES.lock().unwrap_or_else(|p| p.into_inner());
71    if let Some(slot) = notes.iter_mut().find(|(k, _)| *k == key) {
72        slot.1 = value;
73    } else if notes.len() < MAX_NOTES {
74        notes.push((key, value));
75    }
76}
77
78// Write a crash-style report for a lost GPU device. The process itself is
79// healthy, so no minidump is captured; the report exists so a device loss on
80// another machine leaves the same local evidence as a crash.
81pub(crate) fn report_device_lost(detail: &str) {
82    let report = report::CrashReport::gather(report::ReportKind::DeviceLost, detail.to_string());
83    if write::emit(&report).is_none() {
84        tracing::warn!("crash report for device loss could not be written");
85    }
86}
87
88// The directory reports are written to, or `None` when no host named one.
89pub(crate) fn report_dir() -> Option<PathBuf> {
90    REPORT_DIR.lock().ok()?.clone()
91}
92
93pub(crate) fn notes_snapshot() -> Vec<(String, String)> {
94    NOTES.lock().unwrap_or_else(|p| p.into_inner()).clone()
95}
96
97// Snapshot that gives up instead of blocking, for the native fault path.
98#[cfg(any(target_os = "macos", target_os = "windows"))]
99pub(crate) fn try_notes_snapshot() -> Vec<(String, String)> {
100    match NOTES.try_lock() {
101        Ok(notes) => notes.clone(),
102        Err(_) => Vec::new(),
103    }
104}
105
106fn clamp(s: &str, cap: usize) -> String {
107    let mut end = s.len().min(cap);
108    while !s.is_char_boundary(end) {
109        end -= 1;
110    }
111    s[..end].to_string()
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    // The notes map is process-global; a single test drives it so mutations
119    // never race another test.
120    #[test]
121    fn notes_replace_by_key_and_stay_bounded() {
122        note("probe-key", "first");
123        note("probe-key", "second");
124        let snap = notes_snapshot();
125        assert_eq!(snap.iter().filter(|(k, _)| k == "probe-key").count(), 1);
126        assert!(snap.contains(&("probe-key".to_string(), "second".to_string())));
127
128        note("probe-long", &"v".repeat(1000));
129        let snap = notes_snapshot();
130        let long = snap.iter().find(|(k, _)| k == "probe-long").unwrap();
131        assert_eq!(long.1.len(), MAX_NOTE_VALUE_BYTES);
132
133        for i in 0..2 * MAX_NOTES {
134            note(&format!("probe-fill-{i}"), "x");
135        }
136        assert!(notes_snapshot().len() <= MAX_NOTES);
137    }
138}