miniextendr_api/panic_telemetry.rs
1//! Structured panic telemetry for debugging Rust panics that become R errors.
2//!
3//! Three separate panic→R-error paths exist in miniextendr (worker thread, ALTREP
4//! trampolines, and unwind_protect). This module provides a unified hook point
5//! that fires before each panic is converted to an R error.
6//!
7//! # Usage
8//!
9//! ```ignore
10//! use miniextendr_api::panic_telemetry::{set_panic_telemetry_hook, PanicReport, PanicSource};
11//!
12//! set_panic_telemetry_hook(|report| {
13//! eprintln!("[{:?}] panic: {}", report.source, report.message);
14//! });
15//! ```
16//!
17//! # Performance
18//!
19//! `fire()` takes a read lock (uncontended in normal use). The hook only fires
20//! on panic paths, never on hot paths.
21
22use std::panic::{AssertUnwindSafe, catch_unwind};
23use std::sync::{Arc, RwLock};
24
25/// Describes where a panic originated before being converted to an R error.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum PanicSource {
28 /// Panic on the worker thread (caught by `run_on_worker`).
29 Worker,
30 /// Panic inside an ALTREP trampoline (caught by `catch_altrep_panic`).
31 Altrep,
32 /// Panic inside `with_r_unwind_protect` (caught by `panic_payload_to_r_error`).
33 UnwindProtect,
34 /// Panic inside a connection callback trampoline.
35 Connection,
36}
37
38/// A structured panic report passed to the telemetry hook.
39pub struct PanicReport<'a> {
40 /// The panic message (extracted from the panic payload).
41 pub message: &'a str,
42 /// Which panic→R-error boundary caught this panic.
43 pub source: PanicSource,
44}
45
46type Hook = Arc<dyn Fn(&PanicReport) + Send + Sync>;
47
48static HOOK: RwLock<Option<Hook>> = RwLock::new(None);
49
50/// Register a panic telemetry hook.
51///
52/// The hook is called with a [`PanicReport`] each time a Rust panic is about
53/// to be converted into an R error. Only one hook can be active at a time;
54/// calling this again replaces (and drops) the previous hook.
55///
56/// # Thread Safety
57///
58/// The hook may be called from any thread (worker thread, main R thread, etc.).
59/// Ensure your closure is safe to call concurrently.
60///
61/// It is safe to call `set_panic_telemetry_hook` or `clear_panic_telemetry_hook`
62/// from within a hook — the lock is released before the hook is invoked.
63pub fn set_panic_telemetry_hook(f: impl Fn(&PanicReport) + Send + Sync + 'static) {
64 let mut guard = HOOK.write().unwrap_or_else(|e| e.into_inner());
65 *guard = Some(Arc::new(f));
66}
67
68/// Remove the current panic telemetry hook, if any.
69pub fn clear_panic_telemetry_hook() {
70 let mut guard = HOOK.write().unwrap_or_else(|e| e.into_inner());
71 *guard = None;
72}
73
74/// Fire the telemetry hook if one is set.
75///
76/// Called internally at each panic→R-error conversion site.
77///
78/// The hook is cloned (as `Arc`) and the lock is dropped before invocation,
79/// so the hook can safely call `set_panic_telemetry_hook` or
80/// `clear_panic_telemetry_hook` without deadlocking. Secondary panics from
81/// the hook are caught and silently suppressed.
82pub(crate) fn fire(message: &str, source: PanicSource) {
83 // Clone the Arc while holding the read lock, then drop the lock
84 // before invoking. This prevents deadlock if the hook calls
85 // set/clear_panic_telemetry_hook (which take a write lock).
86 let hook = {
87 let guard = HOOK.read().unwrap_or_else(|e| e.into_inner());
88 guard.as_ref().cloned()
89 };
90
91 if let Some(hook) = hook {
92 let report = PanicReport { message, source };
93 // Suppress secondary panics from the hook — we're already on a
94 // panic→R-error path and a double-panic would abort.
95 let _ = catch_unwind(AssertUnwindSafe(|| hook(&report)));
96 }
97}