netget/panic_log.rs
1//! Write every panic to the log before the process or the task loses it.
2//!
3//! **A panic inside `tokio::spawn` is swallowed by the task**, and almost everything NetGet does
4//! on behalf of a peer runs inside one. `JoinHandle` carries the `JoinError`, but nothing here
5//! awaits those handles — a connection task is spawned and forgotten — so the panic went
6//! nowhere at all. The observable result was the shape recorded three times in the root
7//! `CLAUDE.md`: the task dies, the server stays `Running`, the log shows the operation
8//! succeeding, and the peer hangs until its own timeout. `block_on` inside
9//! `UsbInterfaceHandler::handle_urb`, `blocking_lock()` in SMB's connection task and the
10//! `.unwrap()`s inside spawned client tasks were all found by reading code, because there was
11//! nothing to grep for.
12//!
13//! The only `set_hook` in the tree before this one lives in the dashboard's event loop and
14//! restores the terminal. It is installed by the TUI alone, so `--mcp`, `--mcp-http` and
15//! non-interactive runs had no hook at all.
16//!
17//! This hook is installed from `cli::setup::init_logging`, which every entry point calls
18//! before doing any work, and it **chains to whatever hook it replaced** — so the dashboard's
19//! terminal-restore hook (installed later, over this one, via `take_hook`) still runs first and
20//! this one still logs.
21
22use std::sync::Once;
23
24static INSTALLED: Once = Once::new();
25
26/// Install the logging panic hook. Idempotent; safe to call from every entry point.
27///
28/// Call this *after* the tracing subscriber is initialised — a panic logged before there is a
29/// subscriber goes to the same place the panic would have gone, which is nowhere.
30pub fn install() {
31 INSTALLED.call_once(|| {
32 let previous = std::panic::take_hook();
33 std::panic::set_hook(Box::new(move |info| {
34 // Build the record before touching `tracing`: if the subscriber itself is what
35 // panicked, the `error!` below is lost and this is all that survives.
36 let location = info
37 .location()
38 .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
39 .unwrap_or_else(|| "unknown location".to_string());
40
41 // `PanicHookInfo::payload` is `&dyn Any`; the message is a `&str` for `panic!("..")`
42 // and a `String` for `panic!("{}", x)`. Anything else is a payload no formatter can
43 // read, and saying so is more useful than printing `Any { .. }`.
44 let message = payload_of(info.payload());
45
46 let thread = std::thread::current();
47 let thread_name = thread.name().unwrap_or("<unnamed>").to_string();
48
49 // `Backtrace::capture` honours RUST_BACKTRACE and is `Disabled` when unset, so this
50 // costs nothing in the default case and is there when someone asks for it.
51 let backtrace = std::backtrace::Backtrace::capture();
52 let backtrace = match backtrace.status() {
53 std::backtrace::BacktraceStatus::Captured => format!("\n{backtrace}"),
54 _ => String::new(),
55 };
56
57 tracing::error!(
58 panic.location = %location,
59 panic.thread = %thread_name,
60 "PANIC: {message}{backtrace}"
61 );
62
63 previous(info);
64 }));
65 });
66}
67
68/// Extract a panic payload's message.
69///
70/// Public because `tests/panic_is_logged_test.rs` asserts the `String` arm: `panic!("{x}")`
71/// produces a `String` payload rather than a `&str`, and a hook that only downcasts to `&str`
72/// reports every formatted panic — which is most of them — as unreadable.
73pub fn payload_of(payload: &(dyn std::any::Any + Send)) -> String {
74 if let Some(s) = payload.downcast_ref::<&'static str>() {
75 (*s).to_string()
76 } else if let Some(s) = payload.downcast_ref::<String>() {
77 s.clone()
78 } else {
79 "<non-string panic payload>".to_string()
80 }
81}