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//! ## When to reach for this
10//!
11//! - **Calling R APIs that can error from a body you wrote yourself**
12//! (custom ALTREP, custom connection trampoline, hand-rolled FFI shim).
13//! Wrap the R-calling section in [`with_r_unwind_protect`] so Rust
14//! destructors run if R longjmps.
15//! - **Inside a [`with_r_unwind_protect`] body it is safe to use `*_unchecked`
16//! variants of the R FFI** — see the [`crate::sys`] module doc. The lint
17//! **MXL301** recognises this as one of the three contexts where bypassing
18//! the main-thread assertion is valid (the other two being ALTREP callbacks
19//! and [`crate::worker::with_r_thread`] bodies).
20//!
21//! ## You probably don't need this from a `#[miniextendr]` body
22//!
23//! The proc-macro already wraps every function and method in a guard that
24//! converts panics into the tagged-condition transport ([`crate::error_value`]).
25//! Returning `Result::Err`, `Option::None`, or calling `panic!()` /
26//! [`crate::error!`] / [`crate::warning!`] / [`crate::message!`] is the
27//! idiomatic path. Direct [`with_r_unwind_protect`] use inside that body is
28//! almost always wrong — you'd be nesting an `R_UnwindProtect` inside another
29//! `R_UnwindProtect`, paying the longjmp-leak cost twice (see "Leaks" below).
30//!
31//! ## Don't use `Rf_error`
32//!
33//! `Rf_error` and `Rf_errorcall` longjmp directly, skipping every Rust
34//! destructor on the stack. The lint **MXL300** forbids them in user code.
35//! Panic instead (or call [`crate::error!`]) and the framework raises the
36//! corresponding R condition for you.
37//!
38//! ## Leaks
39//!
40//! On the R longjmp path (when R unwinds out of the protected body),
41//! `with_r_unwind_protect` leaks ~8 bytes (an `RErrorMarker` + `Box` header)
42//! because the cleanup handler can't reclaim them via
43//! `Box::from_raw`. Regular Rust panics from inside the body don't leak.
44//! This is the cost MXL300 is buying off: every direct `Rf_error()` would
45//! incur the same leak with no observability.
46//!
47//! ## Log drain
48//!
49//! Every call to `with_r_unwind_protect` (and its variants) drains the
50//! cross-thread log queue via the crate-private `drain_log_queue_if_available`
51//! helper before returning or re-raising an R error. This ensures that records
52//! buffered by worker threads are flushed to R's console on every FFI exit —
53//! including error paths.
54//!
55//! ## Cross references
56//!
57//! - [`crate::worker::with_r_thread`] — routes a closure to R's main thread.
58//! - [`crate::ffi_guard`] — unified panic-catching trampoline that consumes
59//! `with_r_unwind_protect_sourced` for ALTREP `RUnwind` mode.
60//! - [`crate::error_value`] / [`mod@crate::condition`] — panic → R condition
61//! transport.
62use std::{
63 any::Any,
64 borrow::Cow,
65 ffi::c_void,
66 panic::{AssertUnwindSafe, catch_unwind},
67 sync::OnceLock,
68};
69
70// region: raise_rust_condition_via_stop — Approach 3 for ALTREP RUnwind path
71
72/// Cached `stop` symbol (permanently interned via `Rf_install`).
73fn stop_sym() -> crate::SEXP {
74 static CACHE: OnceLock<crate::SEXP> = OnceLock::new();
75 *CACHE.get_or_init(|| unsafe { crate::sys::Rf_install(c"stop".as_ptr()) })
76}
77
78/// Raise an R condition with `rust_*` class layering by evaluating
79/// `stop(structure(list(message = msg, call = call, ...data), class = c(...)))`.
80///
81/// This is **Approach 3** from the issue-345 plan: the `Rf_eval(stop(...))` pattern
82/// that works in any context where there is no outer R wrapper to inspect a tagged SEXP.
83/// It is the only viable option for ALTREP callbacks, which are invoked directly by
84/// R's runtime (no `.Call` frame, no R wrapper).
85///
86/// The `stop()` call longjmps, so this function never returns — declared `-> !`.
87///
88/// ## Class layering
89///
90/// - If `class` is `Some("my_class")`, the resulting R condition has class:
91/// `c("my_class", "rust_error", "simpleError", "error", "condition")`.
92/// - Without a custom class: `c("rust_error", "simpleError", "error", "condition")`.
93///
94/// ## Structured `data` (issue #996 path 2)
95///
96/// When `data` is `Some`, each `(name, value)` pair is spliced directly into
97/// the condition list *after* `message`/`call` — mirroring how the
98/// tagged-transport path's `.miniextendr_raise_condition` R helper
99/// (`utils::modifyList`) layers the macros' `data = ...` payload onto the base
100/// condition fields (see `crate::error_value`). `message`/`call` are kept
101/// first so `$`'s first-match semantics protect `conditionMessage()` /
102/// `conditionCall()` even if a data field happens to share one of those names.
103/// `None` produces the original 2-element `(message, call)` list.
104///
105/// ## MXL300 compliance
106///
107/// This function raises an R error via `Rf_eval(stop(...))`, not via direct
108/// `Rf_error`/`Rf_errorcall`. MXL300 does not flag `Rf_eval`.
109///
110/// # Safety
111///
112/// Must be called from R's main thread inside an `R_UnwindProtect` cleanup
113/// or equivalent context where R longjmps are safe. In practice, always called
114/// from `with_r_unwind_protect_sourced` on the ALTREP guard path.
115pub(crate) unsafe fn raise_rust_condition_via_stop(
116 message: &str,
117 class: Option<&str>,
118 call: Option<crate::SEXP>,
119 data: Option<crate::condition::ConditionData>,
120) -> ! {
121 use crate::sexp_types::CE_UTF8;
122 use crate::sys::{R_BaseEnv, Rf_allocVector, Rf_eval, Rf_lang2, Rf_mkCharCE, Rf_protect};
123 use crate::{IntoR, SEXP, SEXPTYPE, SexpExt};
124
125 unsafe {
126 // Build the class vector: c([custom_class,] "rust_error", "simpleError", "error", "condition")
127 let base_classes: &[&std::ffi::CStr] =
128 &[c"rust_error", c"simpleError", c"error", c"condition"];
129 let class_count = if class.is_some() {
130 base_classes.len() + 1
131 } else {
132 base_classes.len()
133 };
134
135 let class_vec = Rf_allocVector(SEXPTYPE::STRSXP, class_count as isize);
136 Rf_protect(class_vec);
137
138 let mut idx = 0isize;
139 if let Some(custom) = class {
140 let custom_cstr = std::ffi::CString::new(custom)
141 .unwrap_or_else(|_| std::ffi::CString::new("rust_error").unwrap());
142 let custom_charsxp = Rf_mkCharCE(custom_cstr.as_ptr(), CE_UTF8);
143 class_vec.set_string_elt(idx, custom_charsxp);
144 idx += 1;
145 }
146 for base in base_classes {
147 let charsxp = crate::cached_class::permanent_charsxp(base);
148 class_vec.set_string_elt(idx, charsxp);
149 idx += 1;
150 }
151
152 // Build the message SEXP
153 let msg_cstr = std::ffi::CString::new(message)
154 .unwrap_or_else(|_| std::ffi::CString::new("<invalid error message>").unwrap());
155 let msg_charsxp = Rf_mkCharCE(msg_cstr.as_ptr(), CE_UTF8);
156 let msg_sexp = SEXP::scalar_string(msg_charsxp);
157 Rf_protect(msg_sexp);
158
159 let call_sexp = call.unwrap_or(SEXP::nil());
160
161 // Build a named list: list(message = msg, call = call_sexp, ...data).
162 // `data_len` extra slots hold the spliced `data =` fields (issue #996
163 // path 2); with no data this is the original 2-element list.
164 let data_len = data.as_ref().map_or(0, |fields| fields.len());
165 let total_len = 2 + data_len;
166
167 let err_list = Rf_allocVector(SEXPTYPE::VECSXP, total_len as isize);
168 Rf_protect(err_list);
169 err_list.set_vector_elt(0, msg_sexp);
170 err_list.set_vector_elt(1, call_sexp);
171
172 // Set names: c("message", "call", <data field names>...)
173 let names_vec = Rf_allocVector(SEXPTYPE::STRSXP, total_len as isize);
174 Rf_protect(names_vec);
175 names_vec.set_string_elt(0, crate::cached_class::permanent_charsxp(c"message"));
176 names_vec.set_string_elt(1, crate::cached_class::permanent_charsxp(c"call"));
177
178 // PROTECT discipline: err_list and names_vec are already protected
179 // above. Each data field's materialised value is stored into the
180 // protected err_list immediately (rooting it) before the next
181 // allocation (its name CHARSXP) — same discipline as
182 // `make_rust_condition_value_with_data` in `crate::error_value`.
183 if let Some(fields) = data {
184 for (i, (name, value)) in fields.into_iter().enumerate() {
185 let idx = (2 + i) as isize;
186 let value_sexp = value.into_sexp();
187 err_list.set_vector_elt(idx, value_sexp);
188 let name_cstr = std::ffi::CString::new(name)
189 .unwrap_or_else(|_| std::ffi::CString::new("<invalid name>").unwrap());
190 let name_charsxp = Rf_mkCharCE(name_cstr.as_ptr(), CE_UTF8);
191 names_vec.set_string_elt(idx, name_charsxp);
192 }
193 }
194 err_list.set_names(names_vec);
195
196 // Set the class attribute directly (no structure() call needed)
197 err_list.set_class(class_vec);
198
199 // Build stop(err_list) as a language object: lang2(stop_sym, err_list)
200 // stop() accepts a condition object directly
201 let stop_call = Rf_lang2(stop_sym(), err_list);
202 Rf_protect(stop_call);
203
204 // Rf_eval(stop_call, R_BaseEnv) longjmps — never returns
205 // The protect stack is cleaned up by R's longjmp unwind
206 Rf_eval(stop_call, R_BaseEnv);
207
208 // Never reached — Rf_eval(stop(...), ...) always longjmps
209 std::hint::unreachable_unchecked()
210 }
211}
212
213// endregion
214
215use crate::sys::{self, R_ContinueUnwind, R_UnwindProtect_C_unwind};
216use crate::{Rboolean, SEXP};
217
218/// Global continuation token for R_UnwindProtect.
219///
220/// Using a single global token instead of thread-local tokens avoids leaking
221/// one token per thread that uses `with_r_unwind_protect`.
222///
223/// # Safety
224///
225/// The token is created and preserved once during first use. It remains valid
226/// for the entire R session.
227static R_CONTINUATION_TOKEN: OnceLock<SEXP> = OnceLock::new();
228
229/// Get or create the global continuation token.
230///
231/// This is public for use by the worker module.
232pub(crate) fn get_continuation_token() -> SEXP {
233 *R_CONTINUATION_TOKEN.get_or_init(|| {
234 // The continuation token must be created on R's main thread
235 // (R_MakeUnwindCont is an R API call). OnceLock ensures it is
236 // only created once and safely shared.
237 unsafe {
238 let token = sys::R_MakeUnwindCont();
239 sys::R_PreserveObject(token);
240 token
241 }
242 })
243}
244
245/// Panic payload whose message is already final (location folded, or
246/// deliberately location-free): downstream folds must use it verbatim and
247/// must NOT append the current thread's recorded panic location (#1245).
248///
249/// Produced by `worker::route_to_main_thread`'s re-panic when a `with_r_thread`
250/// closure panics on the main thread: the main-thread stringify point already
251/// folded the *true* origin location into the message before it crossed back
252/// to the worker, so the worker's own re-panic (needed to unwind out of
253/// `run_on_worker`) must carry that message forward untouched rather than
254/// re-fold its own relay call site on top.
255pub(crate) struct PreLocatedPanic(pub(crate) String);
256
257/// Extract a message from a panic payload.
258///
259/// Handles `&str`, `String`, `&String`, and `PreLocatedPanic` payloads
260/// consistently. The borrowed variants are returned as `Cow::Borrowed`, so the
261/// common `panic!("literal")` case avoids the heap allocation that a `String`
262/// return would force. Unrecognised payload types fall back to a
263/// `Cow::Borrowed` static string.
264///
265/// Call `.into_owned()` (or `.to_string()`) at sites that need an owned
266/// `String`.
267pub fn panic_payload_to_string(payload: &(dyn Any + Send)) -> Cow<'_, str> {
268 if let Some(&s) = payload.downcast_ref::<&str>() {
269 Cow::Borrowed(s)
270 } else if let Some(s) = payload.downcast_ref::<String>() {
271 Cow::Borrowed(s.as_str())
272 } else if let Some(s) = payload.downcast_ref::<&String>() {
273 Cow::Borrowed(s.as_str())
274 } else if let Some(pre) = payload.downcast_ref::<PreLocatedPanic>() {
275 Cow::Borrowed(pre.0.as_str())
276 } else {
277 Cow::Borrowed("unknown panic")
278 }
279}
280
281/// Stringify a panic payload and fold in the Rust source location the panic
282/// hook recorded on the *current* thread, producing the final R-facing message.
283///
284/// Returns `panic_payload_to_string(payload)` with a `\n(at file:line)` suffix
285/// when [`crate::backtrace::take_last_panic_location`] has a location for this
286/// thread, otherwise the bare message. The location is captured in the process
287/// panic hook (`backtrace.rs`), which fires on the panicking thread — so this
288/// **must be called on the same thread that ran the panicking closure** (main
289/// for main-thread `#[miniextendr]` fns; the worker for worker-dispatched fns).
290///
291/// Only the *generic panic* path uses this. `error!`/`warning!`/`message!`/
292/// `condition!` (and `Result::Err` / `Option::None`) travel the typed
293/// `RCondition` / tagged-value branches and are deliberately left byte-for-byte
294/// unchanged — they carry no location suffix.
295pub(crate) fn panic_message_with_location(payload: &(dyn Any + Send)) -> String {
296 let msg = panic_payload_to_string(payload);
297 match crate::backtrace::take_last_panic_location() {
298 Some((file, line)) => format!("{msg}\n(at {file}:{line})"),
299 None => msg.into_owned(),
300 }
301}
302
303// region: Log drain integration
304
305/// Drain the cross-thread log queue if the `log` feature is enabled.
306///
307/// This is called at every exit point of `run_r_unwind_protect` (normal
308/// return, Rust panic, and immediately before `R_ContinueUnwind`) so that
309/// worker-thread log records always reach R's console before the FFI call
310/// returns or re-raises an R error.
311///
312/// When the `log` feature is disabled this compiles to a no-op; there is
313/// no runtime overhead.
314#[inline]
315fn drain_log_queue_if_available() {
316 #[cfg(feature = "log")]
317 crate::optionals::log_impl::drain_log_queue();
318}
319
320// endregion
321
322/// Core R_UnwindProtect wrapper. Returns `Ok(result)` on success,
323/// `Err(payload)` on Rust panic, or diverges via `R_ContinueUnwind` on R longjmp.
324///
325/// Handles: CallData boxing, trampoline, cleanup handler, continuation token,
326/// `Box::from_raw` reclamation on all non-diverging paths.
327///
328/// Drains the cross-thread log queue (when the `log` feature is enabled) at
329/// each exit point so worker-thread records reach R's console before the FFI
330/// boundary is crossed.
331fn run_r_unwind_protect<F, R>(f: F) -> Result<R, Box<dyn Any + Send>>
332where
333 F: FnOnce() -> R,
334{
335 /// Marker type for R errors caught by R_UnwindProtect's cleanup handler.
336 struct RErrorMarker;
337
338 struct CallData<F, R> {
339 f: Option<F>,
340 result: Option<R>,
341 panic_payload: Option<Box<dyn Any + Send>>,
342 }
343
344 unsafe extern "C-unwind" fn trampoline<F, R>(data: *mut c_void) -> SEXP
345 where
346 F: FnOnce() -> R,
347 {
348 assert!(!data.is_null(), "trampoline: data pointer is null");
349 let data = unsafe { &mut *data.cast::<CallData<F, R>>() };
350 let f = data.f.take().expect("trampoline: closure already consumed");
351
352 match catch_unwind(AssertUnwindSafe(f)) {
353 Ok(result) => {
354 data.result = Some(result);
355 crate::SEXP::nil()
356 }
357 Err(payload) => {
358 data.panic_payload = Some(payload);
359 crate::SEXP::nil()
360 }
361 }
362 }
363
364 unsafe extern "C-unwind" fn cleanup_handler(_data: *mut c_void, jump: Rboolean) {
365 if jump != Rboolean::FALSE {
366 // R is about to longjmp - trigger a Rust panic so we can unwind properly
367 std::panic::panic_any(RErrorMarker);
368 }
369 }
370
371 unsafe {
372 let token = get_continuation_token();
373
374 let data = Box::into_raw(Box::new(CallData::<F, R> {
375 f: Some(f),
376 result: None,
377 panic_payload: None,
378 }));
379
380 let panic_result = catch_unwind(AssertUnwindSafe(|| {
381 R_UnwindProtect_C_unwind(
382 Some(trampoline::<F, R>),
383 data.cast(),
384 Some(cleanup_handler),
385 std::ptr::null_mut(),
386 token,
387 )
388 }));
389
390 let mut data = Box::from_raw(data);
391
392 match panic_result {
393 Ok(_) => {
394 // Check if trampoline caught a panic
395 if let Some(payload) = data.panic_payload.take() {
396 drop(data);
397 // Drain worker-thread log records before returning the panic
398 // payload to the caller (which will convert it to an R error).
399 drain_log_queue_if_available();
400 Err(payload)
401 } else {
402 // Normal completion - return the result
403 let result = data
404 .result
405 .take()
406 .expect("result not set after successful completion");
407 drop(data);
408 // Drain worker-thread log records on the normal success path.
409 drain_log_queue_if_available();
410 Ok(result)
411 }
412 }
413 Err(payload) => {
414 // Drop data first to run destructors
415 drop(data);
416 // Check if this was an R error or a Rust panic
417 if payload.downcast_ref::<RErrorMarker>().is_some() {
418 // R error - drain log records before re-raising so worker
419 // thread output is not lost even on error exits.
420 drain_log_queue_if_available();
421 // Continue R's unwind (diverges, never returns)
422 R_ContinueUnwind(token);
423 } else {
424 // Rust panic — drain before returning the payload.
425 drain_log_queue_if_available();
426 Err(payload)
427 }
428 }
429 }
430 }
431}
432
433/// Execute a closure with R unwind protection, raising any Rust panic as an R
434/// error via `Rf_eval(stop(structure(...)))`.
435///
436/// If the closure panics, the panic is caught and converted to an R error
437/// (longjmp) with `rust_*` class layering. If R raises an error (longjmp), all
438/// Rust RAII resources are properly dropped before R continues unwinding.
439///
440/// **This is NOT the user-facing path for `#[miniextendr]` functions.** That
441/// path is [`with_r_unwind_protect`], which returns a tagged SEXP instead of
442/// longjmping (the macro-generated R wrapper raises the structured condition).
443///
444/// This raising-variant exists for guard sites that have no R wrapper between
445/// them and R's runtime:
446/// - ALTREP `RUnwind` guard callbacks (via the crate-private
447/// `with_r_unwind_protect_sourced`)
448/// - FFI guard tests / benchmarks exercising the raw `R_UnwindProtect` mechanism
449///
450/// In those contexts there is no consumer-side R wrapper to inspect a tagged
451/// SEXP. Panics are routed through `raise_rust_condition_via_stop` so they
452/// still receive `rust_*` class layering (issue #345). Trait-ABI shims use a
453/// separate SEXP-returning variant ([`with_r_unwind_protect_shim`]) that
454/// re-panics at the View boundary.
455///
456/// # Arguments
457///
458/// * `f` - The closure to execute
459/// * `call` - Optional R call SEXP for better error messages
460pub fn with_r_unwind_protect_or_raise<F, R>(f: F, call: Option<SEXP>) -> R
461where
462 F: FnOnce() -> R,
463{
464 with_r_unwind_protect_sourced(f, call, crate::panic_telemetry::PanicSource::UnwindProtect)
465}
466
467/// Like [`with_r_unwind_protect_or_raise`], but reports panics with a custom
468/// `PanicSource`.
469///
470/// Used by `guarded_altrep_call` so that panics inside ALTREP callbacks with
471/// `AltrepGuard::RUnwind` are still attributed to `PanicSource::Altrep`.
472///
473/// Handles [`crate::condition::RCondition`] payloads:
474///
475/// - `RCondition::Error` — routes through [`raise_rust_condition_via_stop`] which
476/// `Rf_eval`s `stop(structure(..., class = c("rust_error", ...)))`. This gives
477/// full `rust_*` class layering even in ALTREP callback context where there is
478/// no R wrapper to inspect a tagged SEXP (Approach 3 from the issue-345 plan).
479/// Custom `class = "..."` from `error!()` is preserved in the class vector.
480///
481/// - `Warning`, `Message`, `Condition` — convert to a plain R error with a
482/// diagnostic message. `warning!()`/`message!()` from ALTREP context cannot
483/// suspend execution for non-fatal signals; documented limitation.
484pub(crate) fn with_r_unwind_protect_sourced<F, R>(
485 f: F,
486 call: Option<SEXP>,
487 source: crate::panic_telemetry::PanicSource,
488) -> R
489where
490 F: FnOnce() -> R,
491{
492 match run_r_unwind_protect(f) {
493 Ok(result) => result,
494 Err(payload) => {
495 // region: RCondition recognition for the raising-variant path
496 if payload.is::<crate::condition::RCondition>() {
497 // Take ownership so `data` (issue #996 path 2) can be moved into
498 // `raise_rust_condition_via_stop` without cloning — same idiom as
499 // `with_r_unwind_protect_shim`.
500 let cond = *payload
501 .downcast::<crate::condition::RCondition>()
502 .expect("checked is::<RCondition> above");
503 match cond {
504 crate::condition::RCondition::Error {
505 message,
506 class,
507 data,
508 } => {
509 // Approach 3 (issue-345): raise via Rf_eval(stop(structure(...)))
510 // so tryCatch(rust_error = h, ...) and tryCatch(my_class = h, ...)
511 // both match. No R wrapper needed. `data` fields are spliced in
512 // too (issue #996 path 2) — previously silently dropped here.
513 crate::panic_telemetry::fire(&message, source);
514 unsafe {
515 raise_rust_condition_via_stop(&message, class.as_deref(), call, data)
516 }
517 }
518 crate::condition::RCondition::Warning { .. }
519 | crate::condition::RCondition::Message { .. }
520 | crate::condition::RCondition::Condition { .. } => {
521 // warning!/message!/condition! cannot be cleanly raised from ALTREP
522 // context (no mechanism to suspend execution for non-fatal signals).
523 // Documented degradation: convert to a plain R error with a fixed
524 // diagnostic, but route through `raise_rust_condition_via_stop` so
525 // the resulting error gets `rust_error` class layering — consistent
526 // with the generic-panic branch a few lines below (issue #366).
527 // The data fields (if any) are dropped along with everything else
528 // about the original kind — this branch already discards message.
529 let msg = "warning!/message!/condition! from ALTREP callback context \
530 cannot be raised as non-fatal signals; use error!() instead. \
531 This context has no R wrapper to handle signal restart.";
532 crate::panic_telemetry::fire(msg, source);
533 unsafe { raise_rust_condition_via_stop(msg, None, call, None) }
534 }
535 }
536 } else {
537 // Generic panic — no class layering, plain error string plus the
538 // `(at file:line)` suffix folded from the panic hook (this branch
539 // runs on the panicking thread for the ALTREP/FFI-guard path).
540 // Fire telemetry and raise via Approach 3 with rust_error class so
541 // tryCatch(rust_error = h, ...) matches even for plain panics.
542 let msg = panic_message_with_location(payload.as_ref());
543 crate::panic_telemetry::fire(&msg, source);
544 unsafe { raise_rust_condition_via_stop(&msg, None, call, None) }
545 }
546 // endregion
547 }
548 }
549}
550
551/// Like [`with_r_unwind_protect`], but tailored for trait-ABI vtable shims.
552///
553/// Same tagged-SEXP behaviour as [`with_r_unwind_protect`], but intended for
554/// shim functions that have no R wrapper of their own. The tagged SEXP is
555/// returned to the View method wrapper, which calls
556/// [`crate::condition::repanic_if_rust_error`] to re-panic with the
557/// reconstructed [`crate::condition::RCondition`]. The outer
558/// `with_r_unwind_protect` in the consumer's C entry point then catches the
559/// re-panic and builds the final tagged SEXP for the consumer's R wrapper.
560///
561/// R-origin errors (longjmp) still pass through via `R_ContinueUnwind` — the
562/// outer guard will catch them.
563///
564/// # PROTECT note
565///
566/// The returned SEXP is unprotected. The View method wrapper must not call any
567/// R API functions between receiving it and passing it to
568/// `repanic_if_rust_error`. `repanic_if_rust_error` reads the message/kind/class
569/// strings immediately and then panics (or returns), so the SEXP does not need
570/// protection beyond that window.
571pub fn with_r_unwind_protect_shim<F>(f: F) -> SEXP
572where
573 F: FnOnce() -> SEXP,
574{
575 match run_r_unwind_protect(f) {
576 Ok(result) => result,
577 Err(payload) => {
578 // region: RCondition recognition — same as the tagged-SEXP path
579 if payload.is::<crate::condition::RCondition>() {
580 use crate::error_value::kind;
581 // Take ownership of the payload so the `data` Vec can be moved
582 // into `make_rust_condition_value` (consumed when materialised).
583 let cond = *payload
584 .downcast::<crate::condition::RCondition>()
585 .expect("checked is::<RCondition> above");
586 let (kind, message, class, data) = match cond {
587 crate::condition::RCondition::Error {
588 message,
589 class,
590 data,
591 } => (kind::ERROR, message, class, data),
592 crate::condition::RCondition::Warning {
593 message,
594 class,
595 data,
596 } => (kind::WARNING, message, class, data),
597 crate::condition::RCondition::Message { message, data } => {
598 (kind::MESSAGE, message, None, data)
599 }
600 crate::condition::RCondition::Condition {
601 message,
602 class,
603 data,
604 } => (kind::CONDITION, message, class, data),
605 };
606 // SAFETY: on the R main thread inside R_UnwindProtect.
607 return unsafe {
608 crate::error_value::make_rust_condition_value_with_data(
609 &message,
610 kind,
611 class.as_deref(),
612 None,
613 data,
614 )
615 };
616 }
617 // endregion
618
619 // Generic panic path — fold the hook-captured `(at file:line)` into
620 // the message (this shim runs on the panicking thread).
621 let msg = panic_message_with_location(payload.as_ref());
622 crate::panic_telemetry::fire(&msg, crate::panic_telemetry::PanicSource::UnwindProtect);
623 // SAFETY: on the R main thread inside R_UnwindProtect.
624 unsafe {
625 crate::error_value::make_rust_condition_value(
626 &msg,
627 crate::error_value::kind::PANIC,
628 None,
629 None,
630 )
631 }
632 }
633 }
634}
635
636/// Run a closure under `R_UnwindProtect`, returning a tagged condition SEXP on
637/// Rust panics instead of raising an R error.
638///
639/// This is **the** transport for all `#[miniextendr]` functions and methods.
640/// The returned error/condition SEXP is inspected by the generated R wrapper
641/// which raises a proper R condition past the Rust boundary, with `rust_*`
642/// class layering.
643///
644/// Recognises [`crate::condition::RCondition`] payloads (from `error!()`,
645/// `warning!()`, `message!()`, `condition!()`) before falling through to the
646/// generic panic→string path.
647///
648/// R-origin errors (longjmp) still pass through via `R_ContinueUnwind`.
649///
650/// For guard sites that have no R wrapper to inspect a tagged SEXP (ALTREP
651/// `RUnwind` callbacks, FFI guard tests) see [`with_r_unwind_protect_or_raise`];
652/// for trait-ABI vtable shims see [`with_r_unwind_protect_shim`].
653pub fn with_r_unwind_protect<F>(f: F, call: Option<SEXP>) -> SEXP
654where
655 F: FnOnce() -> SEXP,
656{
657 match run_r_unwind_protect(f) {
658 Ok(result) => result,
659 Err(payload) => {
660 // region: RCondition recognition — must come before generic panic path
661 if payload.is::<crate::condition::RCondition>() {
662 use crate::error_value::kind;
663 // Take ownership so the `data` payload can be moved into
664 // `make_rust_condition_value` (consumed during materialisation).
665 let cond = *payload
666 .downcast::<crate::condition::RCondition>()
667 .expect("checked is::<RCondition> above");
668 let (kind, message, class, data) = match cond {
669 crate::condition::RCondition::Error {
670 message,
671 class,
672 data,
673 } => (kind::ERROR, message, class, data),
674 crate::condition::RCondition::Warning {
675 message,
676 class,
677 data,
678 } => (kind::WARNING, message, class, data),
679 crate::condition::RCondition::Message { message, data } => {
680 (kind::MESSAGE, message, None, data)
681 }
682 crate::condition::RCondition::Condition {
683 message,
684 class,
685 data,
686 } => (kind::CONDITION, message, class, data),
687 };
688 // No panic telemetry for user-raised conditions — they are intentional.
689 // SAFETY: on the R main thread inside R_UnwindProtect.
690 return unsafe {
691 crate::error_value::make_rust_condition_value_with_data(
692 &message,
693 kind,
694 class.as_deref(),
695 call,
696 data,
697 )
698 };
699 }
700 // endregion
701
702 // Generic panic path — the primary user-facing route for
703 // `#[miniextendr]` fns running on the main thread. Fold the
704 // hook-captured `(at file:line)` into the message. (The RCondition
705 // branch above is deliberately untouched: error!/warning!/message!/
706 // condition! and Err/None carry no location suffix.)
707 let msg = panic_message_with_location(payload.as_ref());
708 crate::panic_telemetry::fire(&msg, crate::panic_telemetry::PanicSource::UnwindProtect);
709 // SAFETY: on the R main thread inside R_UnwindProtect.
710 unsafe {
711 crate::error_value::make_rust_condition_value(
712 &msg,
713 crate::error_value::kind::PANIC,
714 None,
715 call,
716 )
717 }
718 }
719 }
720}