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