Expand description
Tagged condition value transport.
Rust-origin failures (panics, Result::Err, Option::None) and user-raised
conditions (error!(), warning!(), message!(), condition!()) are
converted to a tagged SEXP value instead of raising an R error immediately.
The generated R wrapper inspects this tagged value and escalates it to a
proper R condition past the Rust boundary, with rust_* class layering.
§Why tagged SEXP instead of Rf_error
The naive way to surface a Rust error in R is to call Rf_error, which
longjmps out of the call frame. That works for C — C has no destructors —
but in Rust it skips every drop on the stack: open files, Mutex guards,
Box::into_raw round-trips, the worker-thread continuation token. Anything
holding a resource leaks or corrupts.
The framework instead catches every Rust panic (and every RCondition
panic_any payload) at the boundary inside
crate::unwind_protect::with_r_unwind_protect, encodes it as the
4-element list described below, and returns that SEXP normally. The
generated R wrapper then re-raises with stop(structure(..., class = c("rust_*", ...))). Destructors run; tryCatch sees the right class.
There is one accepted leak: on the R-longjmp branch inside
with_r_unwind_protect (when an R-origin error is propagated through via
R_ContinueUnwind), the RErrorMarker panic payload — about 8 bytes plus
Box header — escapes Rust drop ordering. This is the price we pay for
routing Rust failures through real R conditions instead of
Rf_error-via-longjmp, and is exactly why lint MXL300 forbids direct
Rf_error / Rf_errorcall in user code: every Rf_error skips Rust
destructors unconditionally, not just on the (rare) R-longjmp path.
§The three error-emission entry points
Authors of #[miniextendr] functions reach for one of:
panic!(msg)— escape hatch. Produceskind = "panic"and R classc("rust_error", "simpleError", "error", "condition"). Use for true bugs / impossible states; the caller has nothing to catch by class.miniextendr_api::error!("msg")— typed condition. Produceskind = "error"and the samerust_errorclass layering. Theclass = "my_class"form prepends a user class, giving R-sidec("rust_my_class", "rust_error", "simpleError", "error", "condition")— exactly what a caller’stryCatch(my_class = …)matches on. The siblingcrate::warning!,crate::message!,crate::condition!macros cover the non-error condition kinds.Result<_, E>whereE: std::error::Error, often viacrate::condition::AsRError— value-style propagation through Rust code. Converts at the boundary usingkind = "result_err".Option::Nonefollows the same path withkind = "none_err".
§error_in_r is the default
For every #[miniextendr] fn / method, the proc macro emits a wrapper that
routes through this tagged-SEXP transport — i.e. error_in_r = true is the
default. The opt-outs are documented on the macro:
#[miniextendr(no_error_in_r)]— bypass the tagged-SEXP path entirely. Useful for trait-ABI vtable shims and benchmarks; Rust panics become classicRf_errorlongjmps. Drops the leak above at the cost of skipping Rust destructors universally.#[miniextendr(unwrap_in_r)]—Result<T, E>returns are unwrapped on the R side rather than encoded askind = "result_err". Orthogonal to the transport: still rides this SEXP path, just changes howErris stringified.
Older comments suggesting Rf_error is the user-facing path predate PR
#344 and are wrong. The wrapper preambles now consistently use this
transport.
§Condition value structure (make_rust_condition_value)
The tagged SEXP is a 5-element named list:
error: error message (character scalar)kind: condition kind string — one of the constants inkindclass: optional user-supplied custom class (character scalar orNULL)call: the R call SEXP,NULLif not available (the R helper then uses the wrapper’s call), orFALSEfor a condition raised without a call (ConditionCall::None: the helper signals it withcall = NULL, like R’scall. = FALSE)data: optional named-list condition-data payload (from the macros’data = ...form), orNULL. The R helper splices these named fields into the condition object so handlers can reade$<name>.- class attribute:
"rust_condition_value" __rust_condition__attribute:TRUE
§PROTECT discipline (read before editing)
make_rust_condition_value allocates SEXPs that must remain live
across subsequent allocations (SET_VECTOR_ELT / SETATTRIB both
trigger old-to-new GC barriers): the list itself, the message scalar
STRSXP, the kind scalar STRSXP, the optional class scalar STRSXP, the
FALSE no-call marker LGLSXP when the condition has no call, the
TRUE marker LGLSXP, and — when a data payload is present — the data
VECSXP, its names STRSXP, and each materialised field value. Each is
added to a single ProtectScope before the next
allocation; the scope releases them all together (UNPROTECT) when it
drops at function exit — on every branch. Field values are
materialised one at a time and rooted into the protected data list
immediately (same shape as List::from_pairs) so an unrooted value SEXP
never survives across the next allocation.
R-devel runs a more aggressive GC than R-release/oldrel and will fire
inside the window between two allocations. PR #344 commit af6b4875
tracked down a recursive gc invocation segfault that lit up only on
R-devel because the pre-existing 3-element version was lucky-not-safe;
adding the class slot crossed the threshold. If you add another fresh
allocation, protect it. A green R-release CI run is not proof of
safety here; run gctorture(TRUE) on R-devel before merging.
Modules§
- kind
- Canonical kind strings for tagged condition values.
Functions§
- arg_
check_ ⚠condition_ value - Build the tagged value for an argument check that failed in Rust after
the conversion:
#[miniextendr(no_na)]on a value its type reads as missing where R’sanyNA()saw none (the text"NA"or a blank string forAsNumeric*, a factorNAlevel forAsCharacter*, …), or on anEither, which has no R guard, anNAthe arm taken refuses. - condition_
parts_ 🔒 ⚠value make_rust_condition_value_with_dataover theErrPartsof a condition.- conversion_
condition_ ⚠value - Build the tagged value for an argument that failed its Rust-side
conversion:
kind = "conversion", the parts probed off the error bycrate::__mx_conversion_err_parts!(class, message and data from anRConditionDataimpl, the R-worded reason for a built-in conversion error, else theDisplaytext), withprefixbefore the message ('x' must be a single integer, orinvalid 'x' argumentwhen the macro has no R-facing expectation for the type), the crate’sconversion_error_class(crate_class, emitted by the macro from[package.metadata.miniextendr]) after the error’s own classes, the parameter’s R name ase$paramand the Rust type ase$rust_type.arg_message, when the error words the whole argument error itself (crate::__mx_conversion_arg_message!: amatch_argchoice error, in the words of the R-sidematch_argcheck, #1767), is the message instead of<prefix>: <reason>. Seecrate::condition::conversion_err_partsfor how they combine. - make_
rust_ ⚠condition_ value - Build a tagged condition value with no structured
datapayload. - make_
rust_ ⚠condition_ value_ with_ data - Build a tagged condition-value SEXP for transport across the Rust→R boundary.
- result_
err_ ⚠condition_ value - Build the tagged value for a
Result<T, E>::Errfrom the parts the generated wrapper probed offe(seecrate::__mx_result_err_parts!):kind = "result_err", the error’s class vector (empty for the plainDebugfallback), its structured fields, and no call when itsRConditionData::callisConditionCall::None(anRError::without_call). - rust_
condition_ 👻 ⚠value - Build the tagged value for an [
RCondition] payload (error!(),warning!(),message!(),condition!(), a queueddefer_*entry, or a payload reconstructed across a package boundary): its kind, message, classes and data, andcallunless the payload saysConditionCall::None. - to_
cstring_ 🔒lossy - Convert a
&strto aCString, falling back tofallbackon interior NUL bytes.