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//!
9//! ## Log drain
10//!
11//! Every call to `with_r_unwind_protect` (and its variants) drains the
12//! cross-thread log queue via [`drain_log_queue_if_available`] before
13//! returning or re-raising an R error. This ensures that records buffered by
14//! worker threads are flushed to R's console on every FFI exit — including
15//! error paths.
16use std::{
17 any::Any,
18 borrow::Cow,
19 ffi::c_void,
20 panic::{AssertUnwindSafe, catch_unwind},
21 sync::OnceLock,
22};
23
24// region: raise_rust_condition_via_stop — Approach 3 for ALTREP RUnwind path
25
26/// Cached `stop` symbol (permanently interned via `Rf_install`).
27fn stop_sym() -> crate::ffi::SEXP {
28 static CACHE: OnceLock<crate::ffi::SEXP> = OnceLock::new();
29 *CACHE.get_or_init(|| unsafe { crate::ffi::Rf_install(c"stop".as_ptr()) })
30}
31
32/// Raise an R condition with `rust_*` class layering by evaluating
33/// `stop(structure(list(message = msg, call = call), class = c(...)))`.
34///
35/// This is **Approach 3** from the issue-345 plan: the `Rf_eval(stop(...))` pattern
36/// that works in any context where there is no outer R wrapper to inspect a tagged SEXP.
37/// It is the only viable option for ALTREP callbacks, which are invoked directly by
38/// R's runtime (no `.Call` frame, no R wrapper).
39///
40/// The `stop()` call longjmps, so this function never returns — declared `-> !`.
41///
42/// ## Class layering
43///
44/// - If `class` is `Some("my_class")`, the resulting R condition has class:
45/// `c("my_class", "rust_error", "simpleError", "error", "condition")`.
46/// - Without a custom class: `c("rust_error", "simpleError", "error", "condition")`.
47///
48/// ## MXL300 compliance
49///
50/// This function raises an R error via `Rf_eval(stop(...))`, not via direct
51/// `Rf_error`/`Rf_errorcall`. MXL300 does not flag `Rf_eval`.
52///
53/// # Safety
54///
55/// Must be called from R's main thread inside an `R_UnwindProtect` cleanup
56/// or equivalent context where R longjmps are safe. In practice, always called
57/// from `with_r_unwind_protect_sourced` on the ALTREP guard path.
58pub(crate) unsafe fn raise_rust_condition_via_stop(
59 message: &str,
60 class: Option<&str>,
61 call: Option<crate::ffi::SEXP>,
62) -> ! {
63 use crate::ffi::{
64 CE_UTF8, R_BaseEnv, Rf_allocVector, Rf_eval, Rf_lang2, Rf_mkCharCE, Rf_protect, SEXP,
65 SEXPTYPE, SexpExt,
66 };
67
68 unsafe {
69 // Build the class vector: c([custom_class,] "rust_error", "simpleError", "error", "condition")
70 let base_classes: &[&std::ffi::CStr] =
71 &[c"rust_error", c"simpleError", c"error", c"condition"];
72 let class_count = if class.is_some() {
73 base_classes.len() + 1
74 } else {
75 base_classes.len()
76 };
77
78 let class_vec = Rf_allocVector(SEXPTYPE::STRSXP, class_count as isize);
79 Rf_protect(class_vec);
80
81 let mut idx = 0isize;
82 if let Some(custom) = class {
83 let custom_cstr = std::ffi::CString::new(custom)
84 .unwrap_or_else(|_| std::ffi::CString::new("rust_error").unwrap());
85 let custom_charsxp = Rf_mkCharCE(custom_cstr.as_ptr(), CE_UTF8);
86 class_vec.set_string_elt(idx, custom_charsxp);
87 idx += 1;
88 }
89 for base in base_classes {
90 let charsxp = crate::cached_class::permanent_charsxp(base);
91 class_vec.set_string_elt(idx, charsxp);
92 idx += 1;
93 }
94
95 // Build the message SEXP
96 let msg_cstr = std::ffi::CString::new(message)
97 .unwrap_or_else(|_| std::ffi::CString::new("<invalid error message>").unwrap());
98 let msg_charsxp = Rf_mkCharCE(msg_cstr.as_ptr(), CE_UTF8);
99 let msg_sexp = SEXP::scalar_string(msg_charsxp);
100 Rf_protect(msg_sexp);
101
102 let call_sexp = call.unwrap_or(SEXP::nil());
103
104 // Build a 2-element named list: list(message = msg, call = call_sexp)
105 let err_list = Rf_allocVector(SEXPTYPE::VECSXP, 2);
106 Rf_protect(err_list);
107 err_list.set_vector_elt(0, msg_sexp);
108 err_list.set_vector_elt(1, call_sexp);
109
110 // Set names: c("message", "call")
111 let names_vec = Rf_allocVector(SEXPTYPE::STRSXP, 2);
112 Rf_protect(names_vec);
113 names_vec.set_string_elt(0, crate::cached_class::permanent_charsxp(c"message"));
114 names_vec.set_string_elt(1, crate::cached_class::permanent_charsxp(c"call"));
115 err_list.set_names(names_vec);
116
117 // Set the class attribute directly (no structure() call needed)
118 err_list.set_class(class_vec);
119
120 // Build stop(err_list) as a language object: lang2(stop_sym, err_list)
121 // stop() accepts a condition object directly
122 let stop_call = Rf_lang2(stop_sym(), err_list);
123 Rf_protect(stop_call);
124
125 // Rf_eval(stop_call, R_BaseEnv) longjmps — never returns
126 // The protect stack is cleaned up by R's longjmp unwind
127 Rf_eval(stop_call, R_BaseEnv);
128
129 // Never reached — Rf_eval(stop(...), ...) always longjmps
130 std::hint::unreachable_unchecked()
131 }
132}
133
134// endregion
135
136use crate::ffi::{self, R_ContinueUnwind, R_UnwindProtect_C_unwind, Rboolean, SEXP};
137
138/// Global continuation token for R_UnwindProtect.
139///
140/// Using a single global token instead of thread-local tokens avoids leaking
141/// one token per thread that uses `with_r_unwind_protect`.
142///
143/// # Safety
144///
145/// The token is created and preserved once during first use. It remains valid
146/// for the entire R session.
147static R_CONTINUATION_TOKEN: OnceLock<SEXP> = OnceLock::new();
148
149/// Get or create the global continuation token.
150///
151/// This is public for use by the worker module.
152pub(crate) fn get_continuation_token() -> SEXP {
153 *R_CONTINUATION_TOKEN.get_or_init(|| {
154 // The continuation token must be created on R's main thread
155 // (R_MakeUnwindCont is an R API call). OnceLock ensures it is
156 // only created once and safely shared.
157 unsafe {
158 let token = ffi::R_MakeUnwindCont();
159 ffi::R_PreserveObject(token);
160 token
161 }
162 })
163}
164
165/// Extract a message from a panic payload.
166///
167/// Handles `&str`, `String`, and `&String` payloads consistently. The borrowed
168/// variants are returned as `Cow::Borrowed`, so the common `panic!("literal")`
169/// case avoids the heap allocation that a `String` return would force.
170/// Unrecognised payload types fall back to a `Cow::Borrowed` static string.
171///
172/// Call `.into_owned()` (or `.to_string()`) at sites that need an owned
173/// `String`.
174pub fn panic_payload_to_string(payload: &(dyn Any + Send)) -> Cow<'_, str> {
175 if let Some(&s) = payload.downcast_ref::<&str>() {
176 Cow::Borrowed(s)
177 } else if let Some(s) = payload.downcast_ref::<String>() {
178 Cow::Borrowed(s.as_str())
179 } else if let Some(s) = payload.downcast_ref::<&String>() {
180 Cow::Borrowed(s.as_str())
181 } else {
182 Cow::Borrowed("unknown panic")
183 }
184}
185
186// region: Log drain integration
187
188/// Drain the cross-thread log queue if the `log` feature is enabled.
189///
190/// This is called at every exit point of `run_r_unwind_protect` (normal
191/// return, Rust panic, and immediately before `R_ContinueUnwind`) so that
192/// worker-thread log records always reach R's console before the FFI call
193/// returns or re-raises an R error.
194///
195/// When the `log` feature is disabled this compiles to a no-op; there is
196/// no runtime overhead.
197#[inline]
198fn drain_log_queue_if_available() {
199 #[cfg(feature = "log")]
200 crate::optionals::log_impl::drain_log_queue();
201}
202
203// endregion
204
205/// Core R_UnwindProtect wrapper. Returns `Ok(result)` on success,
206/// `Err(payload)` on Rust panic, or diverges via `R_ContinueUnwind` on R longjmp.
207///
208/// Handles: CallData boxing, trampoline, cleanup handler, continuation token,
209/// `Box::from_raw` reclamation on all non-diverging paths.
210///
211/// Drains the cross-thread log queue (when the `log` feature is enabled) at
212/// each exit point so worker-thread records reach R's console before the FFI
213/// boundary is crossed.
214fn run_r_unwind_protect<F, R>(f: F) -> Result<R, Box<dyn Any + Send>>
215where
216 F: FnOnce() -> R,
217{
218 /// Marker type for R errors caught by R_UnwindProtect's cleanup handler.
219 struct RErrorMarker;
220
221 struct CallData<F, R> {
222 f: Option<F>,
223 result: Option<R>,
224 panic_payload: Option<Box<dyn Any + Send>>,
225 }
226
227 unsafe extern "C-unwind" fn trampoline<F, R>(data: *mut c_void) -> SEXP
228 where
229 F: FnOnce() -> R,
230 {
231 assert!(!data.is_null(), "trampoline: data pointer is null");
232 let data = unsafe { &mut *data.cast::<CallData<F, R>>() };
233 let f = data.f.take().expect("trampoline: closure already consumed");
234
235 match catch_unwind(AssertUnwindSafe(f)) {
236 Ok(result) => {
237 data.result = Some(result);
238 crate::ffi::SEXP::nil()
239 }
240 Err(payload) => {
241 data.panic_payload = Some(payload);
242 crate::ffi::SEXP::nil()
243 }
244 }
245 }
246
247 unsafe extern "C-unwind" fn cleanup_handler(_data: *mut c_void, jump: Rboolean) {
248 if jump != Rboolean::FALSE {
249 // R is about to longjmp - trigger a Rust panic so we can unwind properly
250 std::panic::panic_any(RErrorMarker);
251 }
252 }
253
254 unsafe {
255 let token = get_continuation_token();
256
257 let data = Box::into_raw(Box::new(CallData::<F, R> {
258 f: Some(f),
259 result: None,
260 panic_payload: None,
261 }));
262
263 let panic_result = catch_unwind(AssertUnwindSafe(|| {
264 R_UnwindProtect_C_unwind(
265 Some(trampoline::<F, R>),
266 data.cast(),
267 Some(cleanup_handler),
268 std::ptr::null_mut(),
269 token,
270 )
271 }));
272
273 let mut data = Box::from_raw(data);
274
275 match panic_result {
276 Ok(_) => {
277 // Check if trampoline caught a panic
278 if let Some(payload) = data.panic_payload.take() {
279 drop(data);
280 // Drain worker-thread log records before returning the panic
281 // payload to the caller (which will convert it to an R error).
282 drain_log_queue_if_available();
283 Err(payload)
284 } else {
285 // Normal completion - return the result
286 let result = data
287 .result
288 .take()
289 .expect("result not set after successful completion");
290 drop(data);
291 // Drain worker-thread log records on the normal success path.
292 drain_log_queue_if_available();
293 Ok(result)
294 }
295 }
296 Err(payload) => {
297 // Drop data first to run destructors
298 drop(data);
299 // Check if this was an R error or a Rust panic
300 if payload.downcast_ref::<RErrorMarker>().is_some() {
301 // R error - drain log records before re-raising so worker
302 // thread output is not lost even on error exits.
303 drain_log_queue_if_available();
304 // Continue R's unwind (diverges, never returns)
305 R_ContinueUnwind(token);
306 } else {
307 // Rust panic — drain before returning the payload.
308 drain_log_queue_if_available();
309 Err(payload)
310 }
311 }
312 }
313 }
314}
315
316/// Execute a closure with R unwind protection, raising any Rust panic as an R
317/// error via `Rf_eval(stop(structure(...)))`.
318///
319/// If the closure panics, the panic is caught and converted to an R error
320/// (longjmp) with `rust_*` class layering. If R raises an error (longjmp), all
321/// Rust RAII resources are properly dropped before R continues unwinding.
322///
323/// **This is NOT the user-facing path for `#[miniextendr]` functions.** That
324/// path is [`with_r_unwind_protect`], which returns a tagged SEXP instead of
325/// longjmping (the macro-generated R wrapper raises the structured condition).
326///
327/// This raising-variant exists for guard sites that have no R wrapper between
328/// them and R's runtime:
329/// - ALTREP `RUnwind` guard callbacks (via [`with_r_unwind_protect_sourced`])
330/// - FFI guard tests / benchmarks exercising the raw `R_UnwindProtect` mechanism
331///
332/// In those contexts there is no consumer-side R wrapper to inspect a tagged
333/// SEXP. Panics are routed through `raise_rust_condition_via_stop` so they
334/// still receive `rust_*` class layering (issue #345). Trait-ABI shims use a
335/// separate SEXP-returning variant ([`with_r_unwind_protect_shim`]) that
336/// re-panics at the View boundary.
337///
338/// # Arguments
339///
340/// * `f` - The closure to execute
341/// * `call` - Optional R call SEXP for better error messages
342pub fn with_r_unwind_protect_or_raise<F, R>(f: F, call: Option<SEXP>) -> R
343where
344 F: FnOnce() -> R,
345{
346 with_r_unwind_protect_sourced(f, call, crate::panic_telemetry::PanicSource::UnwindProtect)
347}
348
349/// Like [`with_r_unwind_protect_or_raise`], but reports panics with a custom
350/// [`PanicSource`].
351///
352/// Used by `guarded_altrep_call` so that panics inside ALTREP callbacks with
353/// `AltrepGuard::RUnwind` are still attributed to `PanicSource::Altrep`.
354///
355/// Handles [`crate::condition::RCondition`] payloads:
356///
357/// - `RCondition::Error` — routes through [`raise_rust_condition_via_stop`] which
358/// `Rf_eval`s `stop(structure(..., class = c("rust_error", ...)))`. This gives
359/// full `rust_*` class layering even in ALTREP callback context where there is
360/// no R wrapper to inspect a tagged SEXP (Approach 3 from the issue-345 plan).
361/// Custom `class = "..."` from `error!()` is preserved in the class vector.
362///
363/// - `Warning`, `Message`, `Condition` — convert to a plain R error with a
364/// diagnostic message. `warning!()`/`message!()` from ALTREP context cannot
365/// suspend execution for non-fatal signals; documented limitation.
366pub(crate) fn with_r_unwind_protect_sourced<F, R>(
367 f: F,
368 call: Option<SEXP>,
369 source: crate::panic_telemetry::PanicSource,
370) -> R
371where
372 F: FnOnce() -> R,
373{
374 match run_r_unwind_protect(f) {
375 Ok(result) => result,
376 Err(payload) => {
377 // region: RCondition recognition for the raising-variant path
378 if let Some(cond) = payload.downcast_ref::<crate::condition::RCondition>() {
379 match cond {
380 crate::condition::RCondition::Error { message, class } => {
381 // Approach 3 (issue-345): raise via Rf_eval(stop(structure(...)))
382 // so tryCatch(rust_error = h, ...) and tryCatch(my_class = h, ...)
383 // both match. No R wrapper needed.
384 crate::panic_telemetry::fire(message, source);
385 unsafe { raise_rust_condition_via_stop(message, class.as_deref(), call) }
386 }
387 crate::condition::RCondition::Warning { .. }
388 | crate::condition::RCondition::Message { .. }
389 | crate::condition::RCondition::Condition { .. } => {
390 // warning!/message!/condition! cannot be cleanly raised from ALTREP
391 // context (no mechanism to suspend execution for non-fatal signals).
392 // Documented degradation: convert to a plain R error with a fixed
393 // diagnostic, but route through `raise_rust_condition_via_stop` so
394 // the resulting error gets `rust_error` class layering — consistent
395 // with the generic-panic branch a few lines below (issue #366).
396 let msg = "warning!/message!/condition! from ALTREP callback context \
397 cannot be raised as non-fatal signals; use error!() instead. \
398 This context has no R wrapper to handle signal restart.";
399 crate::panic_telemetry::fire(msg, source);
400 unsafe { raise_rust_condition_via_stop(msg, None, call) }
401 }
402 }
403 } else {
404 // Generic panic — no class layering, plain error string.
405 // Fire telemetry and raise via Approach 3 with rust_error class so
406 // tryCatch(rust_error = h, ...) matches even for plain panics.
407 let msg = panic_payload_to_string(payload.as_ref());
408 crate::panic_telemetry::fire(&msg, source);
409 unsafe { raise_rust_condition_via_stop(&msg, None, call) }
410 }
411 // endregion
412 }
413 }
414}
415
416/// Like [`with_r_unwind_protect`], but tailored for trait-ABI vtable shims.
417///
418/// Same tagged-SEXP behaviour as [`with_r_unwind_protect`], but intended for
419/// shim functions that have no R wrapper of their own. The tagged SEXP is
420/// returned to the View method wrapper, which calls
421/// [`crate::condition::repanic_if_rust_error`] to re-panic with the
422/// reconstructed [`crate::condition::RCondition`]. The outer
423/// `with_r_unwind_protect` in the consumer's C entry point then catches the
424/// re-panic and builds the final tagged SEXP for the consumer's R wrapper.
425///
426/// R-origin errors (longjmp) still pass through via `R_ContinueUnwind` — the
427/// outer guard will catch them.
428///
429/// # PROTECT note
430///
431/// The returned SEXP is unprotected. The View method wrapper must not call any
432/// R API functions between receiving it and passing it to
433/// `repanic_if_rust_error`. `repanic_if_rust_error` reads the message/kind/class
434/// strings immediately and then panics (or returns), so the SEXP does not need
435/// protection beyond that window.
436pub fn with_r_unwind_protect_shim<F>(f: F) -> SEXP
437where
438 F: FnOnce() -> SEXP,
439{
440 match run_r_unwind_protect(f) {
441 Ok(result) => result,
442 Err(payload) => {
443 // region: RCondition recognition — same as the tagged-SEXP path
444 if let Some(cond) = payload.downcast_ref::<crate::condition::RCondition>() {
445 use crate::error_value::kind;
446 let (kind, msg, class) = match cond {
447 crate::condition::RCondition::Error { message, class } => {
448 (kind::ERROR, message.as_str(), class.as_deref())
449 }
450 crate::condition::RCondition::Warning { message, class } => {
451 (kind::WARNING, message.as_str(), class.as_deref())
452 }
453 crate::condition::RCondition::Message { message } => {
454 (kind::MESSAGE, message.as_str(), None)
455 }
456 crate::condition::RCondition::Condition { message, class } => {
457 (kind::CONDITION, message.as_str(), class.as_deref())
458 }
459 };
460 return crate::error_value::make_rust_condition_value(msg, kind, class, None);
461 }
462 // endregion
463
464 // Generic panic path
465 let msg = panic_payload_to_string(payload.as_ref());
466 crate::panic_telemetry::fire(&msg, crate::panic_telemetry::PanicSource::UnwindProtect);
467 crate::error_value::make_rust_condition_value(
468 &msg,
469 crate::error_value::kind::PANIC,
470 None,
471 None,
472 )
473 }
474 }
475}
476
477/// Run a closure under `R_UnwindProtect`, returning a tagged condition SEXP on
478/// Rust panics instead of raising an R error.
479///
480/// This is **the** transport for all `#[miniextendr]` functions and methods.
481/// The returned error/condition SEXP is inspected by the generated R wrapper
482/// which raises a proper R condition past the Rust boundary, with `rust_*`
483/// class layering.
484///
485/// Recognises [`crate::condition::RCondition`] payloads (from `error!()`,
486/// `warning!()`, `message!()`, `condition!()`) before falling through to the
487/// generic panic→string path.
488///
489/// R-origin errors (longjmp) still pass through via `R_ContinueUnwind`.
490///
491/// For guard sites that have no R wrapper to inspect a tagged SEXP (ALTREP
492/// `RUnwind` callbacks, FFI guard tests) see [`with_r_unwind_protect_or_raise`];
493/// for trait-ABI vtable shims see [`with_r_unwind_protect_shim`].
494pub fn with_r_unwind_protect<F>(f: F, call: Option<SEXP>) -> SEXP
495where
496 F: FnOnce() -> SEXP,
497{
498 match run_r_unwind_protect(f) {
499 Ok(result) => result,
500 Err(payload) => {
501 // region: RCondition recognition — must come before generic panic path
502 if let Some(cond) = payload.downcast_ref::<crate::condition::RCondition>() {
503 use crate::error_value::kind;
504 let (kind, msg, class) = match cond {
505 crate::condition::RCondition::Error { message, class } => {
506 (kind::ERROR, message.as_str(), class.as_deref())
507 }
508 crate::condition::RCondition::Warning { message, class } => {
509 (kind::WARNING, message.as_str(), class.as_deref())
510 }
511 crate::condition::RCondition::Message { message } => {
512 (kind::MESSAGE, message.as_str(), None)
513 }
514 crate::condition::RCondition::Condition { message, class } => {
515 (kind::CONDITION, message.as_str(), class.as_deref())
516 }
517 };
518 // No panic telemetry for user-raised conditions — they are intentional.
519 return crate::error_value::make_rust_condition_value(msg, kind, class, call);
520 }
521 // endregion
522
523 // Generic panic path — unchanged
524 let msg = panic_payload_to_string(payload.as_ref());
525 crate::panic_telemetry::fire(&msg, crate::panic_telemetry::PanicSource::UnwindProtect);
526 crate::error_value::make_rust_condition_value(
527 &msg,
528 crate::error_value::kind::PANIC,
529 None,
530 call,
531 )
532 }
533 }
534}