Skip to main content

shuyi_app/
crash.rs

1//! Crash reports (ADR-0079): a panic hook that writes a
2//! `shuyi_platform::crash::CrashReport` through the game's storage, which
3//! [`run`](crate::run) installs before its first frame
4//! ([`AppConfig::crash`](crate::AppConfig::crash)).
5//!
6//! The hook takes the one before it and calls it after writing, so the
7//! standard library's message in a terminal and miniquad's console line on
8//! the web stay. It reads the frame and the top screen [`run`](crate::run)
9//! set ([`set_frame`]) and the last lines of the log ([`note_log`]), from
10//! statics, so a panic on another thread (the music's worker) gets them too.
11//! A panic caught on purpose runs in [`quiet`] and writes nothing.
12//!
13//! A report is [`fatal`](CrashReport::fatal) when the panic is on the
14//! thread that runs the game, the one `run` installs the hook from: on
15//! native `run` catches it and ends the process, or it is outside that
16//! catch and ends it itself; on the web every panic aborts. A panic on
17//! another thread (quad-snd's own, on a machine with no sound device) is
18//! kept, and not offered to the game as a crash.
19//!
20//! On native, `run` catches a panic of the game's frame: it stops the sound,
21//! adds the game's record of its play to the report ([`add_record`]) and
22//! ends the process with exit code 101, as an uncaught panic does, without
23//! calling the game's system hook, so a game in a broken state never saves
24//! over a good save. On the web a panic aborts, so a web report has no
25//! record.
26//!
27//! `SHUYI_PANIC_AT_FRAME=<n>`, read once by `run` on native in any build,
28//! panics at frame `n` on purpose ([`PANIC_ON_PURPOSE`]), for the tests.
29
30use std::cell::Cell;
31use std::collections::VecDeque;
32use std::panic::PanicHookInfo;
33use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
34use std::sync::{Arc, Mutex, MutexGuard, TryLockError};
35use std::thread::ThreadId;
36
37use shuyi_platform::crash::{CrashReport, LOG_LINES, crash_key, keep_newest};
38use shuyi_ui::storage::Storage;
39
40/// Whether `run` writes crash reports, and with what version of the game.
41/// Build it with [`CrashReports::new`] or [`CrashReports::off`]; a game
42/// sets [`AppConfig::crash`](crate::AppConfig::crash) to it.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44#[non_exhaustive]
45pub struct CrashReports {
46    /// The game's version, in each report: empty if the game gave none.
47    pub game_version: &'static str,
48    /// How many reports are kept, the newest: 0 installs no hook.
49    pub keep: u32,
50}
51
52impl CrashReports {
53    /// Reports naming `game_version`: the newest 5 kept on native, and 1 on
54    /// the web, where `localStorage` holds about 5 MB for the whole site.
55    #[must_use]
56    pub const fn new(game_version: &'static str) -> Self {
57        Self {
58            game_version,
59            keep: if cfg!(target_arch = "wasm32") { 1 } else { 5 },
60        }
61    }
62
63    /// No reports: no hook is installed.
64    #[must_use]
65    pub const fn off() -> Self {
66        Self {
67            game_version: "",
68            keep: 0,
69        }
70    }
71}
72
73impl Default for CrashReports {
74    fn default() -> Self {
75        Self::new("")
76    }
77}
78
79/// The message of the panic `SHUYI_PANIC_AT_FRAME` asks for.
80pub const PANIC_ON_PURPOSE: &str = "a panic on purpose (SHUYI_PANIC_AT_FRAME)";
81
82/// A new storage for each report.
83type StorageMaker = Arc<dyn Fn() -> Box<dyn Storage> + Send + Sync>;
84
85/// What [`install`] was given last.
86struct Installed {
87    reports: CrashReports,
88    storage: StorageMaker,
89    /// The thread [`install`] ran on: the one that runs the game.
90    game_thread: ThreadId,
91}
92
93/// The report the hook wrote last, and its key, for [`add_record`].
94struct Written {
95    key: String,
96    report: CrashReport,
97}
98
99/// Whether the hook is installed: once a process.
100static HOOKED: AtomicBool = AtomicBool::new(false);
101/// What [`install`] was given last.
102static INSTALLED: Mutex<Option<Installed>> = Mutex::new(None);
103/// The frame about to run ([`set_frame`]).
104static FRAME: AtomicU32 = AtomicU32::new(0);
105/// The screen on top as it starts ([`set_frame`]).
106static TOP_SCREEN: Mutex<Option<&'static str>> = Mutex::new(None);
107/// The last lines of the log ([`note_log`]).
108static LOG: Mutex<VecDeque<String>> = Mutex::new(VecDeque::new());
109/// The report the hook wrote last.
110static WRITTEN: Mutex<Option<Written>> = Mutex::new(None);
111
112thread_local! {
113    /// Whether this thread's panics are caught on purpose ([`quiet`]).
114    static QUIET: Cell<bool> = const { Cell::new(false) };
115}
116
117/// `mutex`'s guard if no one holds it, poisoned or not: a hook never waits.
118fn try_lock<T>(mutex: &Mutex<T>) -> Option<MutexGuard<'_, T>> {
119    match mutex.try_lock() {
120        Ok(guard) => Some(guard),
121        Err(TryLockError::Poisoned(poisoned)) => Some(poisoned.into_inner()),
122        Err(TryLockError::WouldBlock) => None,
123    }
124}
125
126/// `mutex`'s guard, poisoned or not.
127fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
128    mutex
129        .lock()
130        .unwrap_or_else(std::sync::PoisonError::into_inner)
131}
132
133/// Writes reports of every panic from now on, as `reports` says, each
134/// through a new storage from `storage`, then calls the hook that was there
135/// before. The thread it is called on is the game's: a panic there is
136/// [`fatal`](CrashReport::fatal), and one on any other thread isn't.
137/// Installs nothing if `reports` keeps none. Called again, it changes what
138/// the hook does, and installs no second hook.
139pub fn install(
140    reports: CrashReports,
141    storage: impl Fn() -> Box<dyn Storage> + Send + Sync + 'static,
142) {
143    if reports.keep == 0 {
144        *lock(&INSTALLED) = None;
145        return;
146    }
147    *lock(&INSTALLED) = Some(Installed {
148        reports,
149        storage: Arc::new(storage),
150        game_thread: std::thread::current().id(),
151    });
152    if HOOKED.swap(true, Ordering::SeqCst) {
153        return;
154    }
155    let before = std::panic::take_hook();
156    std::panic::set_hook(Box::new(move |info| {
157        if !QUIET.with(Cell::get) {
158            report(info);
159        }
160        before(info);
161    }));
162}
163
164/// The frame about to run (`App::frame_number() + 1`) and the screen on
165/// top as it starts, for a report.
166pub fn set_frame(frame: u32, top_screen: Option<&'static str>) {
167    FRAME.store(frame, Ordering::Relaxed);
168    if let Some(mut top) = try_lock(&TOP_SCREEN) {
169        *top = top_screen;
170    }
171}
172
173/// Keeps `line` of the log among the last [`LOG_LINES`], for a report.
174pub fn note_log(line: String) {
175    let mut log = lock(&LOG);
176    if log.len() == LOG_LINES {
177        log.pop_front();
178    }
179    log.push_back(line);
180}
181
182/// Runs `f` with reports off on this thread: a panic it catches on purpose
183/// is not a crash.
184pub fn quiet<R>(f: impl FnOnce() -> R) -> R {
185    /// Puts the flag back, even if `f` unwinds.
186    struct Restore(bool);
187    impl Drop for Restore {
188        fn drop(&mut self) {
189            QUIET.with(|quiet| quiet.set(self.0));
190        }
191    }
192    let _restore = Restore(QUIET.with(|quiet| quiet.replace(true)));
193    f()
194}
195
196/// Adds the game's record of its play to the report the hook wrote last,
197/// and writes it again under the same key. A record over
198/// `shuyi_platform::crash::RECORD_BYTES` is left out, and the report's log
199/// says so. Does nothing if no report was written.
200pub fn add_record(record: Option<String>) {
201    let Some(Written { key, mut report }) = lock(&WRITTEN).take() else {
202        return;
203    };
204    let Some(storage) = lock(&INSTALLED).as_ref().map(|i| Arc::clone(&i.storage)) else {
205        return;
206    };
207    report.set_record(record);
208    let _ = storage().write(&key, &report.to_text());
209}
210
211/// The frame `SHUYI_PANIC_AT_FRAME` names, on native; never on the web.
212pub(crate) fn panic_at_frame() -> Option<u32> {
213    #[cfg(not(target_arch = "wasm32"))]
214    {
215        std::env::var("SHUYI_PANIC_AT_FRAME")
216            .ok()?
217            .trim()
218            .parse()
219            .ok()
220    }
221    #[cfg(target_arch = "wasm32")]
222    {
223        None
224    }
225}
226
227/// Milliseconds since 1970 on the system's clock.
228fn now_ms() -> u64 {
229    #[cfg(not(target_arch = "wasm32"))]
230    {
231        let since = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH);
232        since.map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
233    }
234    #[cfg(target_arch = "wasm32")]
235    {
236        shuyi_platform::Millis::from_secs_f64(macroquad::miniquad::date::now()).0
237    }
238}
239
240/// The panic's message: its text, if it has one.
241fn message(info: &PanicHookInfo<'_>) -> String {
242    let payload = info.payload();
243    if let Some(text) = payload.downcast_ref::<&str>() {
244        (*text).to_owned()
245    } else if let Some(text) = payload.downcast_ref::<String>() {
246        text.clone()
247    } else {
248        "a panic with no text".to_owned()
249    }
250}
251
252/// The report of the panic `info` describes, as of now, on the game's
253/// thread or not.
254fn build(info: &PanicHookInfo<'_>, reports: CrashReports, on_game_thread: bool) -> CrashReport {
255    let mut report = CrashReport::new(now_ms(), message(info));
256    report.fatal = on_game_thread;
257    report.location = info
258        .location()
259        .map(|at| format!("{}:{}:{}", at.file(), at.line(), at.column()));
260    report.thread = std::thread::current().name().map(ToOwned::to_owned);
261    report.backtrace = std::backtrace::Backtrace::force_capture().to_string();
262    reports.game_version.clone_into(&mut report.game);
263    let build = if cfg!(debug_assertions) {
264        "debug"
265    } else {
266        "release"
267    };
268    let (os, arch) = (std::env::consts::OS, std::env::consts::ARCH);
269    report.target = format!("{os} {arch} {build}");
270    report.frame = FRAME.load(Ordering::Relaxed);
271    report.top_screen = try_lock(&TOP_SCREEN)
272        .and_then(|top| *top)
273        .map(ToOwned::to_owned);
274    report.log = try_lock(&LOG)
275        .map(|log| log.iter().cloned().collect())
276        .unwrap_or_default();
277    report.limit();
278    report
279}
280
281/// Writes the report of `info` through a new storage, at the first free
282/// `crash-<when>` from its time, and keeps the newest. A write that fails
283/// is dropped.
284fn report(info: &PanicHookInfo<'_>) {
285    let Some((reports, storage, game_thread)) = try_lock(&INSTALLED).and_then(|i| {
286        i.as_ref()
287            .map(|i| (i.reports, Arc::clone(&i.storage), i.game_thread))
288    }) else {
289        return;
290    };
291    let on_game_thread = std::thread::current().id() == game_thread;
292    let mut report = build(info, reports, on_game_thread);
293    let mut storage = storage();
294    while matches!(storage.read(&crash_key(report.when)), Ok(Some(_))) {
295        report.when = report.when.saturating_add(1);
296    }
297    let key = crash_key(report.when);
298    if storage.write(&key, &report.to_text()).is_err() {
299        return;
300    }
301    let keep = usize::try_from(reports.keep).unwrap_or(usize::MAX);
302    let _ = keep_newest(storage.as_mut(), keep);
303    if let Some(mut written) = try_lock(&WRITTEN) {
304        *written = Some(Written { key, report });
305    }
306}