use std::{
io::Write,
marker::PhantomData,
panic::{self, PanicHookInfo},
rc::Rc,
sync::{Arc, Mutex},
thread,
};
type PanicHook = dyn Fn(&PanicHookInfo<'_>) + Send + Sync + 'static;
#[derive(Default)]
struct BackgroundPanicReport {
count: usize,
first_location: Option<String>,
}
pub(in crate::tui) struct TerminalPanicHook {
previous: Arc<PanicHook>,
background_panics: Arc<Mutex<Option<BackgroundPanicReport>>>,
_owner_only: PhantomData<Rc<()>>,
}
impl TerminalPanicHook {
pub(in crate::tui) fn install() -> Self {
Self::install_with_restore(super::restore_terminal_best_effort)
}
fn install_with_restore(restore: impl Fn() + Send + Sync + 'static) -> Self {
let owner = thread::current().id();
let previous: Arc<PanicHook> = panic::take_hook().into();
let background_panics = Arc::new(Mutex::new(Some(BackgroundPanicReport::default())));
let hook_previous = Arc::clone(&previous);
let hook_panics = Arc::clone(&background_panics);
panic::set_hook(Box::new(move |info| {
let active = {
let mut panics = hook_panics.lock().unwrap_or_else(|e| e.into_inner());
if let Some(report) = panics.as_mut() {
if cfg!(panic = "unwind") && thread::current().id() != owner {
report.count = report.count.saturating_add(1);
if report.first_location.is_none() {
report.first_location = info.location().map(|location| {
let file: String = location.file().chars().take(256).collect();
format!("{file}:{}", location.line())
});
}
return;
}
true
} else {
false
}
};
if active {
restore();
}
hook_previous(info);
}));
Self {
previous,
background_panics,
_owner_only: PhantomData,
}
}
}
impl Drop for TerminalPanicHook {
fn drop(&mut self) {
let report = self
.background_panics
.lock()
.unwrap_or_else(|e| e.into_inner())
.take()
.unwrap_or_default();
if !thread::panicking() {
let previous = Arc::clone(&self.previous);
panic::set_hook(Box::new(move |info| previous(info)));
}
if report.count > 0 {
let location = report
.first_location
.as_deref()
.unwrap_or("unknown location");
let _ = writeln!(
std::io::stderr(),
"Mission Control: {} background worker panic(s) occurred; first at {location}.",
report.count
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::terminal::{
RestoreProbeOps, TerminalOp, TerminalOps, test_guard_with_restore_probe,
};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
#[test]
fn terminal_panic_lifecycle_subprocess() {
const CHILD: &str = "MAGI_TEST_TERMINAL_PANIC_LIFECYCLE";
let Ok(case) = std::env::var(CHILD) else {
for case in ["worker", "owner", "error"] {
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"tui::terminal::panic_hook::tests::terminal_panic_lifecycle_subprocess",
"--nocapture",
])
.env(CHILD, case)
.output()
.unwrap();
assert!(output.status.success(), "{case}: {output:?}");
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(
stderr.contains("1 background worker panic(s)"),
case == "worker",
"{stderr}"
);
if case == "worker" {
assert!(stderr.contains("panic_hook.rs:"), "{stderr}");
assert!(!stderr.contains("worker failure"), "{stderr}");
}
}
return;
};
let calls = Arc::new(Mutex::new(Vec::new()));
let reports = Arc::new(AtomicUsize::new(0));
let reported_before_restore = Arc::new(AtomicBool::new(false));
let hook_reported_before_restore = Arc::clone(&reported_before_restore);
let previous_reports = Arc::clone(&reports);
let previous_calls = Arc::clone(&calls);
panic::set_hook(Box::new(move |_| {
let calls = previous_calls.lock().unwrap();
previous_reports.fetch_add(1, Ordering::SeqCst);
if calls.last() != Some(&"terminal restored") {
hook_reported_before_restore.store(true, Ordering::SeqCst);
}
}));
let restore_calls = Arc::clone(&calls);
let result = panic::catch_unwind(|| -> Result<(), ()> {
let _hook = TerminalPanicHook::install_with_restore(move || {
let ops = RestoreProbeOps {
calls: restore_calls.clone(),
};
for op in [
TerminalOp::DisableEnhancedKeyboard,
TerminalOp::DisableInputAndLeaveAlternateScreen,
TerminalOp::DisableRawMode,
] {
ops.run(op).unwrap();
}
});
let terminal = test_guard_with_restore_probe(Arc::clone(&calls)).unwrap();
match case.as_str() {
"worker" => {
let failure = thread::spawn(|| panic!("worker failure")).join();
assert!(
failure.is_err(),
"worker failure must remain available for reconciliation"
);
assert_eq!(reports.load(Ordering::SeqCst), 0);
assert_eq!(
*calls.lock().unwrap(),
[
"enable raw mode",
"enter alternate screen",
"enable enhanced keyboard"
]
);
drop(terminal);
}
"owner" => panic!("owner failure"),
"error" => return Err(()),
_ => unreachable!(),
}
Ok(())
});
assert_eq!(result.is_err(), case == "owner");
if case == "error" {
assert_eq!(result.unwrap(), Err(()));
}
assert_eq!(calls.lock().unwrap().last(), Some(&"terminal restored"));
assert_eq!(reports.load(Ordering::SeqCst), usize::from(case == "owner"));
let restored_calls = calls.lock().unwrap().len();
let _ = thread::spawn(|| panic!("after TUI exit")).join();
assert_eq!(
reports.load(Ordering::SeqCst),
1 + usize::from(case == "owner")
);
assert_eq!(calls.lock().unwrap().len(), restored_calls);
assert!(!reported_before_restore.load(Ordering::SeqCst));
}
}