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//!
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}