Skip to main content

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}