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 dir (`crashes/` under the state root). 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::sync::Mutex;
25
26/// Install the process-wide crash hooks: a panic hook that writes a report
27/// and chains to the previously installed hook, plus (macOS/Windows) a native
28/// fault handler that writes a minidump. Binaries call this first thing at
29/// startup; later calls are no-ops.
30pub fn install() {
31    hook::install();
32    #[cfg(any(target_os = "macos", target_os = "windows"))]
33    native::install();
34    #[cfg(backend_metal)]
35    note("backend", "metal");
36    #[cfg(backend_dx)]
37    note("backend", "directx");
38    #[cfg(backend_vk)]
39    note("backend", "vulkan");
40    #[cfg(not(any(backend_metal, backend_dx, backend_vk)))]
41    note("backend", "none");
42}
43
44const MAX_NOTES: usize = 16;
45const MAX_NOTE_KEY_BYTES: usize = 32;
46const MAX_NOTE_VALUE_BYTES: usize = 128;
47
48// Bounded key-value context stamped into every report (backend, GPU, world
49// identity). Bounded so the crash path never carries unbounded state.
50static NOTES: Mutex<Vec<(String, String)>> = Mutex::new(Vec::new());
51
52/// Record a context note included in subsequent crash reports, replacing any
53/// previous value for `key`. Bounded: at most 16 keys, keys truncate at 32
54/// bytes and values at 128; further keys are dropped.
55pub fn note(key: &str, value: &str) {
56    let key = clamp(key, MAX_NOTE_KEY_BYTES);
57    let value = clamp(value, MAX_NOTE_VALUE_BYTES);
58    let mut notes = NOTES.lock().unwrap_or_else(|p| p.into_inner());
59    if let Some(slot) = notes.iter_mut().find(|(k, _)| *k == key) {
60        slot.1 = value;
61    } else if notes.len() < MAX_NOTES {
62        notes.push((key, value));
63    }
64}
65
66// Write a crash-style report for a lost GPU device. The process itself is
67// healthy, so no minidump is captured; the report exists so a device loss on
68// another machine leaves the same local evidence as a crash.
69pub(crate) fn report_device_lost(detail: &str) {
70    let report = report::CrashReport::gather(report::ReportKind::DeviceLost, detail.to_string());
71    if write::emit(&report).is_none() {
72        tracing::warn!("crash report for device loss could not be written");
73    }
74}
75
76pub(crate) fn notes_snapshot() -> Vec<(String, String)> {
77    NOTES.lock().unwrap_or_else(|p| p.into_inner()).clone()
78}
79
80// Snapshot that gives up instead of blocking, for the native fault path.
81#[cfg(any(target_os = "macos", target_os = "windows"))]
82pub(crate) fn try_notes_snapshot() -> Vec<(String, String)> {
83    match NOTES.try_lock() {
84        Ok(notes) => notes.clone(),
85        Err(_) => Vec::new(),
86    }
87}
88
89fn clamp(s: &str, cap: usize) -> String {
90    let mut end = s.len().min(cap);
91    while !s.is_char_boundary(end) {
92        end -= 1;
93    }
94    s[..end].to_string()
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    // The notes map is process-global; a single test drives it so mutations
102    // never race another test.
103    #[test]
104    fn notes_replace_by_key_and_stay_bounded() {
105        note("probe-key", "first");
106        note("probe-key", "second");
107        let snap = notes_snapshot();
108        assert_eq!(snap.iter().filter(|(k, _)| k == "probe-key").count(), 1);
109        assert!(snap.contains(&("probe-key".to_string(), "second".to_string())));
110
111        note("probe-long", &"v".repeat(1000));
112        let snap = notes_snapshot();
113        let long = snap.iter().find(|(k, _)| k == "probe-long").unwrap();
114        assert_eq!(long.1.len(), MAX_NOTE_VALUE_BYTES);
115
116        for i in 0..2 * MAX_NOTES {
117            note(&format!("probe-fill-{i}"), "x");
118        }
119        assert!(notes_snapshot().len() <= MAX_NOTES);
120    }
121}