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//! # Relationship to the three emission paths
8//!
9//! This module is **observability, not emission**. It does not raise R
10//! conditions and does not change how panics become errors; it only lets a
11//! caller observe them. The three actual emission paths are documented on
12//! [`crate::error_value`]:
13//!
14//! - `panic!(msg)` — escape hatch; tagged-SEXP with `kind = "panic"`.
15//! - [`crate::error!`] / [`crate::warning!`] / [`crate::message!`] /
16//! [`crate::condition!`] — typed conditions via `RCondition` panic payloads.
17//! - `Result<_, E: std::error::Error>` with [`crate::condition::AsRError`] —
18//! value-style propagation; tagged-SEXP with `kind = "result_err"`.
19//!
20//! `fire` is invoked from each catch site *before* the panic message is encoded
21//! into a tagged condition value, so a registered hook sees every Rust-origin
22//! failure regardless of which of the three paths produced it. Typical uses:
23//! routing to `tracing` / `log`, capturing stack snapshots for crash reports,
24//! or surfacing the panic message in test harnesses where R's stderr is
25//! buffered. The hook must not raise R conditions itself (it runs inside the
26//! panic-handling path) and must not panic — secondary panics from the hook
27//! are caught and silently suppressed by `fire` (this crate, `pub(crate)`).
28//!
29//! # Usage
30//!
31//! ```ignore
32//! use miniextendr_api::panic_telemetry::{set_panic_telemetry_hook, PanicReport, PanicSource};
33//!
34//! set_panic_telemetry_hook(|report| {
35//! eprintln!("[{:?}] panic: {}", report.source, report.message);
36//! });
37//! ```
38//!
39//! # Performance
40//!
41//! `fire()` takes a read lock (uncontended in normal use). The hook only fires
42//! on panic paths, never on hot paths.
43
44use std::panic::{AssertUnwindSafe, catch_unwind};
45use std::sync::{Arc, RwLock};
46
47/// Describes where a panic originated before being converted to an R error.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum PanicSource {
50 /// Panic on the worker thread (caught by `run_on_worker`).
51 Worker,
52 /// Panic inside an ALTREP trampoline (caught by `catch_altrep_panic`).
53 Altrep,
54 /// Panic inside `with_r_unwind_protect` (caught by `with_r_unwind_protect_sourced`).
55 UnwindProtect,
56 /// Panic inside a connection callback trampoline.
57 Connection,
58}
59
60/// A structured panic report passed to the telemetry hook.
61pub struct PanicReport<'a> {
62 /// The panic message (extracted from the panic payload).
63 pub message: &'a str,
64 /// Which panic→R-error boundary caught this panic.
65 pub source: PanicSource,
66}
67
68type Hook = Arc<dyn Fn(&PanicReport) + Send + Sync>;
69
70static HOOK: RwLock<Option<Hook>> = RwLock::new(None);
71
72/// Register a panic telemetry hook.
73///
74/// The hook is called with a [`PanicReport`] each time a Rust panic is about
75/// to be converted into an R error. Only one hook can be active at a time;
76/// calling this again replaces (and drops) the previous hook.
77///
78/// # Thread Safety
79///
80/// The hook may be called from any thread (worker thread, main R thread, etc.).
81/// Ensure your closure is safe to call concurrently.
82///
83/// It is safe to call `set_panic_telemetry_hook` or `clear_panic_telemetry_hook`
84/// from within a hook — the lock is released before the hook is invoked.
85pub fn set_panic_telemetry_hook(f: impl Fn(&PanicReport) + Send + Sync + 'static) {
86 let mut guard = HOOK.write().unwrap_or_else(|e| e.into_inner());
87 *guard = Some(Arc::new(f));
88}
89
90/// Remove the current panic telemetry hook, if any.
91pub fn clear_panic_telemetry_hook() {
92 let mut guard = HOOK.write().unwrap_or_else(|e| e.into_inner());
93 *guard = None;
94}
95
96/// Fire the telemetry hook if one is set.
97///
98/// Called internally at each panic→R-error conversion site.
99///
100/// The hook is cloned (as `Arc`) and the lock is dropped before invocation,
101/// so the hook can safely call `set_panic_telemetry_hook` or
102/// `clear_panic_telemetry_hook` without deadlocking. Secondary panics from
103/// the hook are caught and silently suppressed.
104pub(crate) fn fire(message: &str, source: PanicSource) {
105 // Clone the Arc while holding the read lock, then drop the lock
106 // before invoking. This prevents deadlock if the hook calls
107 // set/clear_panic_telemetry_hook (which take a write lock).
108 let hook = {
109 let guard = HOOK.read().unwrap_or_else(|e| e.into_inner());
110 guard.as_ref().cloned()
111 };
112
113 if let Some(hook) = hook {
114 let report = PanicReport { message, source };
115 // Suppress secondary panics from the hook — we're already on a
116 // panic→R-error path and a double-panic would abort.
117 let _ = catch_unwind(AssertUnwindSafe(|| hook(&report)));
118 }
119}