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