retroglyph_recorder/panic_hook.rs
1//! [`install_panic_recorder`]: auto-save an in-progress recording on panic.
2
3use crate::RecorderHandle;
4use std::path::PathBuf;
5
6/// Installs a panic hook that saves whatever `handle`'s recorder has captured so far to `path`
7/// when the process panics, then chains to whatever hook was already installed.
8///
9/// The saved recording is exactly what a bug report needs: the input stream leading up to the
10/// crash, ready to hand to [`TestHarness::replay`](retroglyph_core::testing::TestHarness::replay)
11/// or [`replay_live`](crate::replay_live) as a regression test, with no hand-transcription.
12///
13/// Chaining to the previous hook (rather than replacing it) is deliberate: this must never
14/// suppress the panic message itself, or any other diagnostic (e.g. `color-backtrace`,
15/// `console_error_panic_hook`) a consumer already installed. The save happens first, so a panic
16/// *while* saving (a full disk, a bad `path`) still lets the original panic's own output reach
17/// the terminal via the chained hook, rather than replacing it with a save failure.
18///
19/// This installs no hotkey and has no opinion on what triggers a save beyond an actual panic:
20/// deliberate, so it can never collide with a consumer's own keybindings. Call
21/// [`RecorderHandle::save`] directly wherever a consumer wants a non-panic trigger (a dedicated
22/// debug key, a signal handler, an explicit "report a bug" menu item).
23///
24/// # Examples
25///
26/// ```no_run
27/// use retroglyph_core::backend::Headless;
28/// use retroglyph_recorder::{InputRecorder, install_panic_recorder};
29///
30/// let recorder = InputRecorder::new(Headless::new(80, 24));
31/// install_panic_recorder(recorder.handle(), "crash.rgrec");
32/// ```
33pub fn install_panic_recorder(handle: RecorderHandle, path: impl Into<PathBuf>) {
34 let path = path.into();
35 let previous = std::panic::take_hook();
36 std::panic::set_hook(Box::new(move |info| {
37 // Best-effort: a save failure here (a full disk, an unwritable path) must not stop the
38 // original panic's own output from reaching the terminal via `previous` below.
39 let _ = handle.save(&path);
40 previous(info);
41 }));
42}
43
44#[cfg(test)]
45mod tests {
46 use super::*;
47 use crate::InputRecorder;
48 use retroglyph_core::backend::{Headless, Input};
49 use retroglyph_core::event::{Event, KeyCode, KeyEvent, KeyModifiers};
50
51 /// Runs `body` with `install_panic_recorder` active, catching the panic so the test process
52 /// itself survives, and returns the path the recording was saved to.
53 ///
54 /// Panic hooks are process-global, so this test (and any other exercising a panic hook in
55 /// this crate) must not run concurrently with one that installs its own; `#[test]`s in one
56 /// binary share a process but not a thread, and `std::panic::set_hook` applies process-wide.
57 /// Serializing isn't done here explicitly (this module has only the one hook-installing
58 /// test), but a future addition should keep that in mind.
59 #[test]
60 fn saves_the_recording_on_panic_without_suppressing_the_original_panic() {
61 let dir =
62 std::env::temp_dir().join(format!("rgrec-panic-hook-test-{}", std::process::id()));
63 std::fs::create_dir_all(&dir).expect("create temp dir");
64 let path = dir.join("crash.rgrec");
65 std::fs::remove_file(&path).ok();
66
67 let mut recorder = InputRecorder::new(Headless::new(4, 1));
68 recorder.push_event(Event::Key(KeyEvent::new(
69 KeyCode::Char('a'),
70 KeyModifiers::NONE,
71 )));
72 recorder.poll_event(std::time::Duration::ZERO);
73
74 let previous_hook_ran = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
75 let flag = previous_hook_ran.clone();
76 std::panic::set_hook(Box::new(move |_| {
77 flag.store(true, std::sync::atomic::Ordering::SeqCst);
78 }));
79 install_panic_recorder(recorder.handle(), path.clone());
80
81 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
82 panic!("boom");
83 }));
84 assert!(result.is_err());
85
86 // Restore Rust's default hook so later tests in this binary aren't affected by the one
87 // this test just installed.
88 let _ = std::panic::take_hook();
89
90 assert!(
91 previous_hook_ran.load(std::sync::atomic::Ordering::SeqCst),
92 "the previously installed hook must still run, so the original panic is never suppressed"
93 );
94 let saved = crate::read(&path).expect("saved recording should be readable");
95 assert_eq!(saved.len(), 1);
96
97 std::fs::remove_file(&path).ok();
98 }
99}