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//! ## Guard Modes
8//!
9//! - [`GuardMode::CatchUnwind`]: Wraps the closure in `catch_unwind`. On panic,
10//!   fires telemetry and raises an R error via `Rf_error` (diverges).
11//!   Used by worker and connection trampolines.
12//!
13//! - [`GuardMode::RUnwind`]: Uses `R_UnwindProtect` to catch both Rust panics
14//!   and R longjmps. Used by ALTREP callbacks that call R APIs.
15//!
16//! The ALTREP-specific `Unsafe` mode (no protection at all) stays in
17//! `altrep_bridge.rs` since it has no general applicability.
18
19use std::panic::{AssertUnwindSafe, catch_unwind};
20
21use crate::panic_telemetry::PanicSource;
22use crate::unwind_protect::panic_payload_to_string;
23
24/// FFI guard mode controlling how panics are caught at Rust-R boundaries.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum GuardMode {
27    /// `catch_unwind` only. On panic: fire telemetry, then `Rf_error` (diverges).
28    ///
29    /// Use when R longjmps cannot occur (the closure does not call R APIs).
30    CatchUnwind,
31    /// `R_UnwindProtect`. Catches both Rust panics and R longjmps.
32    ///
33    /// Use when the closure may call R APIs that can error.
34    RUnwind,
35}
36
37/// Execute `f` inside an FFI guard selected by `mode`.
38///
39/// On panic:
40/// - Extracts the panic message from the payload.
41/// - Fires [`crate::panic_telemetry`] with `source`.
42/// - For [`GuardMode::CatchUnwind`]: raises R error via `Rf_error` (diverges — never returns).
43/// - For [`GuardMode::RUnwind`]: delegates to `with_r_unwind_protect_sourced`.
44///
45/// # Parameters
46///
47/// - `f`: The closure to execute.
48/// - `mode`: Which guard strategy to use.
49/// - `source`: Attribution for telemetry if a panic occurs.
50///
51/// # Note on `fallback`
52///
53/// `GuardMode::CatchUnwind` diverges on panic (`Rf_error` never returns), so no
54/// fallback value is needed. If you need a fallback (e.g. connection trampolines
55/// that must return a value on panic without calling R), use
56/// [`guarded_ffi_call_with_fallback`] instead.
57#[inline]
58pub fn guarded_ffi_call<F, R>(f: F, mode: GuardMode, source: PanicSource) -> R
59where
60    F: FnOnce() -> R,
61{
62    match mode {
63        GuardMode::CatchUnwind => match catch_unwind(AssertUnwindSafe(f)) {
64            Ok(val) => val,
65            Err(payload) => {
66                let msg = panic_payload_to_string(payload.as_ref());
67                crate::panic_telemetry::fire(&msg, source);
68                crate::error::r_stop(&msg)
69            }
70        },
71        GuardMode::RUnwind => crate::unwind_protect::with_r_unwind_protect_sourced(f, None, source),
72    }
73}
74
75/// Execute `f` inside a `CatchUnwind` guard, returning `fallback` on panic.
76///
77/// Unlike [`guarded_ffi_call`] with `CatchUnwind` (which diverges via `Rf_error`),
78/// this variant returns the `fallback` value instead of raising an R error.
79/// This is needed for connection trampolines where panicking through R/C frames
80/// is UB but raising an R error is also undesirable (the caller expects a return
81/// value indicating failure).
82///
83/// Telemetry is fired before returning the fallback.
84#[inline]
85pub fn guarded_ffi_call_with_fallback<F, R>(f: F, fallback: R, source: PanicSource) -> R
86where
87    F: FnOnce() -> R,
88{
89    match catch_unwind(AssertUnwindSafe(f)) {
90        Ok(val) => val,
91        Err(payload) => {
92            let msg = panic_payload_to_string(payload.as_ref());
93            crate::panic_telemetry::fire(&msg, source);
94            fallback
95        }
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn catch_unwind_returns_value_on_success() {
105        let result = guarded_ffi_call(|| 42, GuardMode::CatchUnwind, PanicSource::Worker);
106        assert_eq!(result, 42);
107    }
108
109    #[test]
110    fn fallback_returns_value_on_success() {
111        let result = guarded_ffi_call_with_fallback(|| 42, -1, PanicSource::Connection);
112        assert_eq!(result, 42);
113    }
114
115    #[test]
116    fn fallback_returns_fallback_on_panic() {
117        let result = guarded_ffi_call_with_fallback(|| panic!("boom"), -1, PanicSource::Connection);
118        assert_eq!(result, -1);
119    }
120
121    #[test]
122    fn fallback_fires_telemetry_on_panic() {
123        use std::sync::atomic::{AtomicBool, Ordering};
124
125        let fired = std::sync::Arc::new(AtomicBool::new(false));
126        let fired_clone = fired.clone();
127
128        crate::panic_telemetry::set_panic_telemetry_hook(move |report| {
129            assert_eq!(report.source, PanicSource::Connection);
130            assert!(report.message.contains("test panic"));
131            fired_clone.store(true, Ordering::SeqCst);
132        });
133
134        let _ =
135            guarded_ffi_call_with_fallback(|| panic!("test panic"), 0i32, PanicSource::Connection);
136
137        assert!(fired.load(Ordering::SeqCst), "telemetry hook should fire");
138        crate::panic_telemetry::clear_panic_telemetry_hook();
139    }
140}