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}