Skip to main content

miniextendr_api/
unwind_protect.rs

1//! Safe API for R's `R_UnwindProtect`
2//!
3//! This module provides [`with_r_unwind_protect`] for handling R errors with Rust cleanup.
4//! It automatically runs Rust destructors when R errors occur.
5//!
6//! **Important**: R uses `longjmp` for error handling, which normally bypasses Rust destructors.
7//! Use this API to ensure cleanup happens even when R errors occur.
8//!
9use std::{
10    any::Any,
11    ffi::c_void,
12    panic::{AssertUnwindSafe, catch_unwind},
13    sync::OnceLock,
14};
15
16use crate::ffi::{self, R_ContinueUnwind, R_UnwindProtect_C_unwind, Rboolean, SEXP};
17
18/// Global continuation token for R_UnwindProtect.
19///
20/// Using a single global token instead of thread-local tokens avoids leaking
21/// one token per thread that uses `with_r_unwind_protect`.
22///
23/// # Safety
24///
25/// The token is created and preserved once during first use. It remains valid
26/// for the entire R session.
27static R_CONTINUATION_TOKEN: OnceLock<SEXP> = OnceLock::new();
28
29/// Get or create the global continuation token.
30///
31/// This is public for use by the worker module.
32pub(crate) fn get_continuation_token() -> SEXP {
33    *R_CONTINUATION_TOKEN.get_or_init(|| {
34        // The continuation token must be created on R's main thread
35        // (R_MakeUnwindCont is an R API call). OnceLock ensures it is
36        // only created once and safely shared.
37        unsafe {
38            let token = ffi::R_MakeUnwindCont();
39            ffi::R_PreserveObject(token);
40            token
41        }
42    })
43}
44
45/// Extract a message from a panic payload.
46///
47/// Handles `&str`, `String`, and `&String` payloads consistently.
48/// Returns a descriptive fallback for unrecognised payload types.
49pub fn panic_payload_to_string(payload: &(dyn Any + Send)) -> String {
50    if let Some(&s) = payload.downcast_ref::<&str>() {
51        s.to_string()
52    } else if let Some(s) = payload.downcast_ref::<String>() {
53        s.clone()
54    } else if let Some(s) = payload.downcast_ref::<&String>() {
55        (*s).clone()
56    } else {
57        "unknown panic".to_string()
58    }
59}
60
61/// Convert a Rust panic payload into an R error and continue unwinding on the R side.
62pub(crate) unsafe fn panic_payload_to_r_error(
63    payload: Box<dyn Any + Send>,
64    call: Option<SEXP>,
65    source: crate::panic_telemetry::PanicSource,
66) -> ! {
67    let error_message = panic_payload_to_string(payload.as_ref());
68
69    crate::panic_telemetry::fire(&error_message, source);
70
71    let c_error_message = std::ffi::CString::new(error_message)
72        .unwrap_or_else(|_| std::ffi::CString::new("<invalid panic message>").unwrap());
73
74    unsafe {
75        if let Some(call) = call {
76            ::miniextendr_api::ffi::Rf_errorcall_unchecked(
77                call,
78                c"%s".as_ptr(),
79                c_error_message.as_ptr(),
80            );
81        } else {
82            ::miniextendr_api::ffi::Rf_error_unchecked(c"%s".as_ptr(), c_error_message.as_ptr());
83        }
84    }
85}
86
87/// Core R_UnwindProtect wrapper. Returns `Ok(result)` on success,
88/// `Err(payload)` on Rust panic, or diverges via `R_ContinueUnwind` on R longjmp.
89///
90/// Handles: CallData boxing, trampoline, cleanup handler, continuation token,
91/// `Box::from_raw` reclamation on all non-diverging paths.
92fn run_r_unwind_protect<F, R>(f: F) -> Result<R, Box<dyn Any + Send>>
93where
94    F: FnOnce() -> R,
95{
96    /// Marker type for R errors caught by R_UnwindProtect's cleanup handler.
97    struct RErrorMarker;
98
99    struct CallData<F, R> {
100        f: Option<F>,
101        result: Option<R>,
102        panic_payload: Option<Box<dyn Any + Send>>,
103    }
104
105    unsafe extern "C-unwind" fn trampoline<F, R>(data: *mut c_void) -> SEXP
106    where
107        F: FnOnce() -> R,
108    {
109        assert!(!data.is_null(), "trampoline: data pointer is null");
110        let data = unsafe { &mut *data.cast::<CallData<F, R>>() };
111        let f = data.f.take().expect("trampoline: closure already consumed");
112
113        match catch_unwind(AssertUnwindSafe(f)) {
114            Ok(result) => {
115                data.result = Some(result);
116                crate::ffi::SEXP::nil()
117            }
118            Err(payload) => {
119                data.panic_payload = Some(payload);
120                crate::ffi::SEXP::nil()
121            }
122        }
123    }
124
125    unsafe extern "C-unwind" fn cleanup_handler(_data: *mut c_void, jump: Rboolean) {
126        if jump != Rboolean::FALSE {
127            // R is about to longjmp - trigger a Rust panic so we can unwind properly
128            std::panic::panic_any(RErrorMarker);
129        }
130    }
131
132    unsafe {
133        let token = get_continuation_token();
134
135        let data = Box::into_raw(Box::new(CallData::<F, R> {
136            f: Some(f),
137            result: None,
138            panic_payload: None,
139        }));
140
141        let panic_result = catch_unwind(AssertUnwindSafe(|| {
142            R_UnwindProtect_C_unwind(
143                Some(trampoline::<F, R>),
144                data.cast(),
145                Some(cleanup_handler),
146                std::ptr::null_mut(),
147                token,
148            )
149        }));
150
151        let mut data = Box::from_raw(data);
152
153        match panic_result {
154            Ok(_) => {
155                // Check if trampoline caught a panic
156                if let Some(payload) = data.panic_payload.take() {
157                    drop(data);
158                    Err(payload)
159                } else {
160                    // Normal completion - return the result
161                    Ok(data
162                        .result
163                        .take()
164                        .expect("result not set after successful completion"))
165                }
166            }
167            Err(payload) => {
168                // Drop data first to run destructors
169                drop(data);
170                // Check if this was an R error or a Rust panic
171                if payload.downcast_ref::<RErrorMarker>().is_some() {
172                    // R error - continue R's unwind (diverges, never returns)
173                    R_ContinueUnwind(token);
174                } else {
175                    // Rust panic
176                    Err(payload)
177                }
178            }
179        }
180    }
181}
182
183/// Execute a closure with R unwind protection.
184///
185/// If the closure panics, the panic is caught and converted to an R error.
186/// If R raises an error (longjmp), all Rust RAII resources are properly dropped
187/// before R continues unwinding.
188///
189/// # Arguments
190///
191/// * `f` - The closure to execute
192/// * `call` - Optional R call SEXP for better error messages
193///
194/// # Example
195///
196/// ```ignore
197/// let result: i32 = with_r_unwind_protect(|| {
198///     // Code that might call R APIs that can error
199///     42
200/// }, None);
201/// ```
202pub fn with_r_unwind_protect<F, R>(f: F, call: Option<SEXP>) -> R
203where
204    F: FnOnce() -> R,
205{
206    with_r_unwind_protect_sourced(f, call, crate::panic_telemetry::PanicSource::UnwindProtect)
207}
208
209/// Like [`with_r_unwind_protect`], but reports panics with a custom [`PanicSource`].
210///
211/// Used by `guarded_altrep_call` so that panics inside ALTREP callbacks with
212/// `AltrepGuard::RUnwind` are still attributed to `PanicSource::Altrep`.
213pub(crate) fn with_r_unwind_protect_sourced<F, R>(
214    f: F,
215    call: Option<SEXP>,
216    source: crate::panic_telemetry::PanicSource,
217) -> R
218where
219    F: FnOnce() -> R,
220{
221    match run_r_unwind_protect(f) {
222        Ok(result) => result,
223        Err(payload) => unsafe { panic_payload_to_r_error(payload, call, source) },
224    }
225}
226
227/// Like [`with_r_unwind_protect`], but returns a tagged error SEXP on Rust panics
228/// instead of raising an R error via `Rf_errorcall`.
229///
230/// Used by `#[miniextendr(error_in_r)]` mode for the main thread strategy.
231/// The error SEXP is inspected by the generated R wrapper which raises a proper
232/// R error condition past the Rust boundary.
233///
234/// R-origin errors (longjmp) still pass through via `R_ContinueUnwind`.
235pub fn with_r_unwind_protect_error_in_r<F>(f: F, call: Option<SEXP>) -> SEXP
236where
237    F: FnOnce() -> SEXP,
238{
239    match run_r_unwind_protect(f) {
240        Ok(result) => result,
241        Err(payload) => {
242            let msg = panic_payload_to_string(payload.as_ref());
243            crate::panic_telemetry::fire(&msg, crate::panic_telemetry::PanicSource::UnwindProtect);
244            crate::error_value::make_rust_error_value(&msg, "panic", call)
245        }
246    }
247}