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                let msg = panic_payload_to_string(payload.as_ref());
90                crate::panic_telemetry::fire(&msg, source);
91                crate::error::r_stop(&msg)
92            }
93        },
94        GuardMode::RUnwind => crate::unwind_protect::with_r_unwind_protect_sourced(f, None, source),
95    }
96}
97
98/// Execute `f` inside a `CatchUnwind` guard, returning `fallback` on panic.
99///
100/// Unlike [`guarded_ffi_call`] with `CatchUnwind` (which diverges via `Rf_error`),
101/// this variant returns the `fallback` value instead of raising an R error.
102/// This is needed for connection trampolines where panicking through R/C frames
103/// is UB but raising an R error is also undesirable (the caller expects a return
104/// value indicating failure).
105///
106/// Telemetry is fired before returning the fallback.
107#[inline]
108pub fn guarded_ffi_call_with_fallback<F, R>(f: F, fallback: R, source: PanicSource) -> R
109where
110    F: FnOnce() -> R,
111{
112    match catch_unwind(AssertUnwindSafe(f)) {
113        Ok(val) => val,
114        Err(payload) => {
115            let msg = panic_payload_to_string(payload.as_ref());
116            crate::panic_telemetry::fire(&msg, source);
117            fallback
118        }
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn catch_unwind_returns_value_on_success() {
128        let result = guarded_ffi_call(|| 42, GuardMode::CatchUnwind, PanicSource::Worker);
129        assert_eq!(result, 42);
130    }
131
132    #[test]
133    fn fallback_returns_value_on_success() {
134        let result = guarded_ffi_call_with_fallback(|| 42, -1, PanicSource::Connection);
135        assert_eq!(result, 42);
136    }
137
138    #[test]
139    fn fallback_returns_fallback_on_panic() {
140        let result = guarded_ffi_call_with_fallback(|| panic!("boom"), -1, PanicSource::Connection);
141        assert_eq!(result, -1);
142    }
143
144    #[test]
145    fn fallback_fires_telemetry_on_panic() {
146        use std::sync::atomic::{AtomicBool, Ordering};
147
148        let fired = std::sync::Arc::new(AtomicBool::new(false));
149        let fired_clone = fired.clone();
150
151        crate::panic_telemetry::set_panic_telemetry_hook(move |report| {
152            assert_eq!(report.source, PanicSource::Connection);
153            assert!(report.message.contains("test panic"));
154            fired_clone.store(true, Ordering::SeqCst);
155        });
156
157        let _ =
158            guarded_ffi_call_with_fallback(|| panic!("test panic"), 0i32, PanicSource::Connection);
159
160        assert!(fired.load(Ordering::SeqCst), "telemetry hook should fire");
161        crate::panic_telemetry::clear_panic_telemetry_hook();
162    }
163}