Skip to main content

miniextendr_api/
ffi_guard.rs

1//! Unified FFI guard for catching panics at Rust-R boundaries.
2//!
3//! Four modules independently catch panics at FFI boundaries: `worker.rs`,
4//! `altrep_bridge.rs`, `unwind_protect.rs`, and `connection.rs`. This module
5//! extracts the common pattern into a single `guarded_ffi_call` function.
6//!
7//! Most user code never calls anything here. The proc-macro layer
8//! (`#[miniextendr]`) inserts the right guard at every Rust → R boundary it
9//! generates. Reach for these helpers when you're writing a callback or
10//! trampoline that the macros don't already cover (custom connections,
11//! manual ALTREP, raw FFI shims).
12//!
13//! ## Guard Modes
14//!
15//! - [`GuardMode::CatchUnwind`]: Wraps the closure in `catch_unwind`. On panic,
16//!   fires telemetry and raises an R error via `Rf_error` (diverges).
17//!   Used by worker and connection trampolines.
18//!
19//! - [`GuardMode::RUnwind`]: Uses `R_UnwindProtect` to catch both Rust panics
20//!   and R longjmps. Used by ALTREP callbacks that call R APIs. Routes
21//!   through `crate::unwind_protect::with_r_unwind_protect_sourced`
22//!   (crate-private).
23//!
24//! The ALTREP-specific `Unsafe` mode (no protection at all) stays in
25//! `altrep_bridge.rs` since it has no general applicability.
26//!
27//! ## Tradeoffs vs raising R errors directly
28//!
29//! Don't reach for `Rf_error` / `Rf_errorcall` to fail out of a callback —
30//! the longjmp skips Rust destructors and the lint **MXL300** rejects it.
31//! Panic instead; whichever guard mode you pick converts the panic into the
32//! tagged-condition transport ([`crate::error_value`]) or, on the ALTREP
33//! `RUnwind` path, raises a structured `rust_*` condition via the
34//! crate-private `raise_rust_condition_via_stop` helper.
35//!
36//! ## Cross references
37//!
38//! - [`crate::worker::with_r_thread`] — main-thread routing entry point.
39//! - [`crate::unwind_protect::with_r_unwind_protect`] — the user-facing R
40//!   error catcher; consumed by `RUnwind` mode.
41
42use std::panic::{AssertUnwindSafe, catch_unwind};
43
44use crate::panic_telemetry::PanicSource;
45use crate::unwind_protect::panic_payload_to_string;
46
47/// FFI guard mode controlling how panics are caught at Rust-R boundaries.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum GuardMode {
50    /// `catch_unwind` only. On panic: fire telemetry, then `Rf_error` (diverges).
51    ///
52    /// Use when R longjmps cannot occur (the closure does not call R APIs).
53    CatchUnwind,
54    /// `R_UnwindProtect`. Catches both Rust panics and R longjmps.
55    ///
56    /// Use when the closure may call R APIs that can error.
57    RUnwind,
58}
59
60/// Execute `f` inside an FFI guard selected by `mode`.
61///
62/// On panic:
63/// - Extracts the panic message from the payload.
64/// - Fires [`crate::panic_telemetry`] with `source`.
65/// - For [`GuardMode::CatchUnwind`]: raises R error via `Rf_error` (diverges — never returns).
66/// - For [`GuardMode::RUnwind`]: delegates to `with_r_unwind_protect_sourced`.
67///
68/// # Parameters
69///
70/// - `f`: The closure to execute.
71/// - `mode`: Which guard strategy to use.
72/// - `source`: Attribution for telemetry if a panic occurs.
73///
74/// # Note on `fallback`
75///
76/// `GuardMode::CatchUnwind` diverges on panic (`Rf_error` never returns), so no
77/// fallback value is needed. If you need a fallback (e.g. connection trampolines
78/// that must return a value on panic without calling R), use
79/// [`guarded_ffi_call_with_fallback`] instead.
80#[inline]
81pub fn guarded_ffi_call<F, R>(f: F, mode: GuardMode, source: PanicSource) -> R
82where
83    F: FnOnce() -> R,
84{
85    match mode {
86        GuardMode::CatchUnwind => match catch_unwind(AssertUnwindSafe(f)) {
87            Ok(val) => val,
88            Err(payload) => {
89                // Fold the hook-captured `(at file:line)` into the message, same
90                // as the `RUnwind` sibling below (which routes through
91                // `with_r_unwind_protect_sourced`). The panic and hook fired on
92                // this thread (ALTREP `RustUnwind` callbacks run on main), so the
93                // take-once slot holds the real `panic!` site.
94                let msg = crate::unwind_protect::panic_message_with_location(payload.as_ref());
95                crate::panic_telemetry::fire(&msg, source);
96                crate::error::r_stop(&msg)
97            }
98        },
99        GuardMode::RUnwind => crate::unwind_protect::with_r_unwind_protect_sourced(f, None, source),
100    }
101}
102
103/// Execute `f` inside a `CatchUnwind` guard, returning `fallback` on panic.
104///
105/// Unlike [`guarded_ffi_call`] with `CatchUnwind` (which diverges via `Rf_error`),
106/// this variant returns the `fallback` value instead of raising an R error.
107/// This is needed for connection trampolines where panicking through R/C frames
108/// is UB but raising an R error is also undesirable (the caller expects a return
109/// value indicating failure).
110///
111/// Telemetry is fired before returning the fallback.
112#[inline]
113pub fn guarded_ffi_call_with_fallback<F, R>(f: F, fallback: R, source: PanicSource) -> R
114where
115    F: FnOnce() -> R,
116{
117    match catch_unwind(AssertUnwindSafe(f)) {
118        Ok(val) => val,
119        Err(payload) => {
120            let msg = panic_payload_to_string(payload.as_ref());
121            crate::panic_telemetry::fire(&msg, source);
122            fallback
123        }
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn catch_unwind_returns_value_on_success() {
133        let result = guarded_ffi_call(|| 42, GuardMode::CatchUnwind, PanicSource::Worker);
134        assert_eq!(result, 42);
135    }
136
137    #[test]
138    fn fallback_returns_value_on_success() {
139        let result = guarded_ffi_call_with_fallback(|| 42, -1, PanicSource::Connection);
140        assert_eq!(result, 42);
141    }
142
143    #[test]
144    fn fallback_returns_fallback_on_panic() {
145        let result = guarded_ffi_call_with_fallback(|| panic!("boom"), -1, PanicSource::Connection);
146        assert_eq!(result, -1);
147    }
148
149    #[test]
150    fn fallback_fires_telemetry_on_panic() {
151        use std::sync::atomic::{AtomicBool, Ordering};
152
153        let fired = std::sync::Arc::new(AtomicBool::new(false));
154        let fired_clone = fired.clone();
155
156        crate::panic_telemetry::set_panic_telemetry_hook(move |report| {
157            assert_eq!(report.source, PanicSource::Connection);
158            assert!(report.message.contains("test panic"));
159            fired_clone.store(true, Ordering::SeqCst);
160        });
161
162        let _ =
163            guarded_ffi_call_with_fallback(|| panic!("test panic"), 0i32, PanicSource::Connection);
164
165        assert!(fired.load(Ordering::SeqCst), "telemetry hook should fire");
166        crate::panic_telemetry::clear_panic_telemetry_hook();
167    }
168}