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}