Condition system: error!, warning!, message!, condition!
miniextendr provides four macros for raising structured R conditions from Rust. They ride the tagged-condition transport that every #[miniextendr] function uses. A deferred form signals a warning, message or condition and still returns the call's value.
miniextendr provides four macros for raising structured R conditions from Rust.
They ride the tagged-condition transport that every #[miniextendr] function
uses. A deferred form signals a
warning, message or condition and still returns the call’s value.
🔗Quick reference
| Macro | R equivalent | Default class | Unhandled behaviour |
|---|---|---|---|
error!(...) | stop() | rust_error | terminates execution |
warning!(...) | warning() | rust_warning | prints, continues |
message!(...) | message() | rust_message | prints, continues |
condition!(...) | signalCondition() | rust_condition | silent no-op |
All four support an optional class = ... argument to prepend custom classes
for programmatic catching (one string or a vector, most specific first), and an
optional data = ... argument to attach structured named fields readable as
e$<name> in handlers. error!, warning! and condition! also take a
leading call = none, which raises the condition without a call, like R’s
call. = FALSE (see Conditions without a call).
Result<T, E> returns get the same treatment through the
RConditionData trait
(hand-written or derived), or, for error enums
that already derive serde::Serialize, through
the serde shape. An R error
caught by RCall::eval, r_eval_str or try_eval_with_handlers (an
REvalError) is raised again the same ways, keeping R’s message and classes (see
Raising a caught R error as your own).
A fifth macro, arg_error!(param = "x", …), raises the condition of the
wrapper’s own argument checks from a body (see
Argument errors from a body).
Import note.
error!andcondition!are shadowed by the crate-root moduleserror/condition, souse miniextendr_api::*;(or a directuse miniextendr_api::error;) resolves to the module, not the macro. Use the collision-free aliasesrust_error!/rust_condition!(identical expansions), or invoke the bare names fully qualified.warning!/message!have no such clash.
🔗How it works
Each macro calls std::panic::panic_any(RCondition::...). The panic is caught by
with_r_unwind_protect before Rust destructors have unwound, which recognises
the RCondition payload and converts it to a tagged SEXP (5-element list:
error, kind, class, call, data). The generated R wrapper reads the
SEXP and dispatches to the appropriate R signal function.
The class slot carries the optional user-supplied class. When non-NULL it is
prepended to the standard layered vector. The data slot carries the optional
named-list payload; the R helper splices its fields into the condition object
alongside message / call / kind. The call is the one the wrapper
passed to .Call(): its own call as written; for an S3 method the generic’s
call, summary(x) for a method R called as summary.mx_rec(x) and
x$f <- value for a replacement method; and no call at all under
#[miniextendr(call = none)] (see
CALL_ATTRIBUTION.md).
Deferred conditions take a shorter route: defer_warning & co. push the same
payload onto a queue instead of panicking, and the generated C wrapper drains
the queue once the body has returned, handing each entry (as the same tagged
SEXP) to the same R helper from Rust before the value reaches R.
🔗Class layering
class(e)
# error!("...") → c("rust_error", "simpleError", "error", "condition")
# warning!("...") → c("rust_warning", "simpleWarning", "warning", "condition")
# message!("...") → c("rust_message", "simpleMessage", "message", "condition")
# condition!("...") → c("rust_condition", "simpleCondition", "condition")
# With class = "my_err":
class(e)
# error!(class = "my_err", "...") → c("my_err", "rust_error", "simpleError", "error", "condition")
# With a class vector (member first, family second), so handlers can catch either:
# error!(class = ["pkg_error_missing_field", "pkg_error"], "...")
# → c("pkg_error_missing_field", "pkg_error", "rust_error", "simpleError", "error", "condition")
class = accepts anything implementing ConditionClass: &str, String,
[&str; N], Vec<String>, slices. The same vector form is available on
warning! and condition!.
The framework layers are part of the contract. Every condition raised from
Rust carries the user classes first, then the framework’s layers: rust_error
(so a handler can catch anything raised from Rust), simpleError (so code
written for stop() errors keeps working), then error and condition. They
cannot be left out. A Rust method that replaces an R one raising
errorCondition(class = "subscriptOutOfBoundsError") therefore raises
c("subscriptOutOfBoundsError", "rust_error", "simpleError", "error", "condition")
where R raised c("subscriptOutOfBoundsError", "error", "condition").
tryCatch(subscriptOutOfBoundsError = ), inherits() and expect_s3_class()
without exact treat the two alike; only a comparison of the whole class
vector sees the difference, so compare the leading classes (below).
🔗Testing the class vector
testthat’s expect_s3_class(e, c("pkg_error_missing_field", "rust_error", "error"))
is inherits(e, class): it passes when any one of the listed classes is
present. A test written that way keeps passing when a layer disappears, such as
the custom class dropped, rust_error replaced by a bare simpleError, or a
family class lost at a version bump. To pin the layering, assert the whole
vector with exact = TRUE:
e <- tryCatch(check(150), error = identity)
testthat::expect_s3_class(
e,
c("pkg_error_out_of_range", "pkg_error", "rust_error", "simpleError", "error", "condition"),
exact = TRUE
)
When only the leading layers are yours to assert, or a test should pass for an R and a Rust implementation of the same method, compare the prefix instead:
cls <- c("pkg_error_out_of_range", "pkg_error", "rust_error")
testthat::expect_identical(class(e)[seq_along(cls)], cls)
A small helper in tests/testthat/helper-conditions.R makes the exact form the
easy one to reach for:
expect_condition_classes <- function(x, classes) {
testthat::expect_identical(class(x)[seq_along(classes)], classes)
}🔗Runnable examples
🔗error!()
library(miniextendr)
# Raised by: error!("something went wrong: {x}")
e <- tryCatch(demo_error("oops"), error = function(e) e)
class(e)
# [1] "rust_error" "simpleError" "error" "condition"
conditionMessage(e)
# [1] "oops"
# Specific handler:
tryCatch(demo_error("x"), rust_error = function(e) "caught by rust_error handler")
# [1] "caught by rust_error handler"🔗error!() with custom class
# Raised by: error!(class = "my_error", "missing field: {name}")
tryCatch(
demo_error_custom_class("my_error", "missing field: x"),
my_error = function(e) paste("custom:", conditionMessage(e)),
rust_error = function(e) paste("rust:", conditionMessage(e))
)
# [1] "custom: missing field: x"🔗error!() with structured data payloads
Rust-side, the macros accept data = ("name", value) for a single field or
data = [("a", v1), ("b", v2)] for several (rlang abort(data = list(...))
style). Argument order is fixed: call = none (optional, see
Conditions without a call), then class = ...
(optional), then data = ... (optional), then the format message:
// Single field:
error!(class = "range_error", data = ("value", value), "value {value} out of range");
// Multiple fields:
error!(
class = "validation_error",
data = [("value", value), ("code", code), ("label", label), ("fatal", true)],
"validation failed for {label}"
);
R-side, handlers read the fields directly from the condition object:
# Raised by: error!(class = "range_error", data = ("value", value), "value {value} out of range")
e <- tryCatch(demo_error_data_scalar(150L), range_error = function(e) e)
e$value
# [1] 150
# Programmatic recovery — clamp instead of parsing the message:
tryCatch(
demo_error_data_scalar(150L),
range_error = function(e) min(max(e$value, 0L), 100L)
)
# [1] 100🔗Supported data value types
| Rust value | R field type |
|---|---|
i32 | integer(1) |
f64 | double(1) |
bool | logical(1) |
&str / String | character(1) |
Vec<i32> | integer(n) |
Vec<f64> | double(n) |
Vec<bool> | logical(n) |
Vec<String> / Vec<&str> | character(n) |
Option<T> / Vec<Option<T>> for the scalar families above | typed NA |
i64 / u32 | integer when it fits; otherwise double |
(A, B, ...) tuples (1–12 fields) of these values | unnamed list, one element per field |
RValue::Null | NULL |
RValue::Complex, RValue::Raw | complex / raw vector |
RValue::List | recursively nested, optionally named list |
RValue is the owned, Send, R-native value tree used by the condition
transport. Build it directly for nested or heterogeneous values, or use
RValue::debug(value) to attach an eager Debug rendering when no native R
mapping is appropriate.
🔗Worker-thread note
The payload travels through panic_any, which requires Send — and the macro
may fire on the worker thread, where a live SEXP is illegal to carry. Each
field is therefore converted at the call site into RValue, and a multi-field
payload becomes ConditionData (Vec<(String, RValue)>). The actual R objects
are materialised on R’s main thread at the unwind boundary. Consequently,
data = ... works identically from worker-thread and main-thread code, but a
live SEXP or arbitrary IntoR value cannot ride along.
🔗Reserved names
message, call and kind are the condition’s own slots (the R helper
splices data over them with utils::modifyList, on every transport,
the raising guards included, #1315), and .mx_parent carries a cause
attached with with_parent, so they are
rejected as field names: at compile time when the name is a literal or a
bare identifier (data = ("kind", 1), data = { kind = 1 }), at runtime
otherwise (a plain rust_error explaining the clash, instead of the former
silent overwrite).
There is no escape hatch, deliberately: message and call are what R’s
conditionMessage() / conditionCall() read, and kind is the framework’s
transport tag: it records which path raised the condition ("error" for
error!, "result_err" for a Result’s Err, "panic", "conversion",
…), not what the condition is, so one condition class raised from two
paths has two kinds; match conditions on their class. A field with one of
those names is therefore a naming clash to fix where the payload is built (a different key, #[serde(rename)] on a derived
struct, a rename in your RConditionData::data() impl). Renaming every field
with a prefix to rescue one would make e$<name> access inconsistent across
conditions. When the payload is an error type’s own serde shape that other
consumers read, do the renaming at the condition boundary instead:
#[miniextendr(serde_error(skip(..), rename(..)))] drops or renames payload
fields, and a message field that merely echoes Display is dropped with no
option (see
Payload fields named message). The same
attribute gives fields their R types (as_character(..) & co.), and a shape
declared once for the error type with serde_error_shape! holds all of it for
every function that returns the type (see
Field types and shared shapes).
error!(
class = "pkg_sparse_rule",
data = { rule = kind, column = column },
"sparse rule on `{column}`"
);e <- tryCatch(f(), pkg_sparse_rule = function(e) e)
e$rule # the field
e$kind # still "error" (the transport tag)🔗warning!()
# Raised by: warning!("x is large: {x}")
# tryCatch absorbs the warning and returns the handler result:
tryCatch(demo_warning("watch out"), rust_warning = function(w) "caught!")
# [1] "caught!"
# withCallingHandlers resumes execution after the handler:
result <- withCallingHandlers(
{
demo_warning("note")
42L
},
warning = function(w) {
cat("saw:", conditionMessage(w), "\n")
invokeRestart("muffleWarning")
}
)
# saw: note
result
# [1] 42🔗message!()
# Raised by: message!("step {n} complete")
demo_message("hello")
# hello
suppressMessages(demo_message("silenced"))
# (no output)
# withCallingHandlers — muffleMessage restart stops the default printing:
withCallingHandlers(
demo_message("intercepted"),
message = function(m) {
cat("caught:", conditionMessage(m))
invokeRestart("muffleMessage")
}
)
# caught: intercepted🔗condition!()
# Raised by: condition!("step 1 of 10")
# Without a handler, signalCondition returns NULL invisibly.
demo_condition("silent signal")
# NULL
# With a handler:
withCallingHandlers(
demo_condition("progress event"),
condition = function(c) cat("progress:", conditionMessage(c), "\n")
)
# progress: progress event
# NULL
# With a custom class:
withCallingHandlers(
demo_condition_custom_class("my_progress", "step 3"),
my_progress = function(c) cat("progress:", conditionMessage(c), "\n")
)
# progress: step 3
# NULL🔗Deferred conditions: a warning and a value
warning!(), message!() and condition!() unwind the Rust function: R sees
the signal and the call returns invisible(NULL). When the result is still
worth returning (“here are the rows, two were dropped”), queue the condition
instead of raising it. The generated wrapper signals everything queued during
the call after the Rust body has returned, in queue order, and then hands the
value to R:
use miniextendr_api::RCondition;
use miniextendr_api::{defer_warning, miniextendr};
#[derive(Debug, RCondition)]
#[condition(class = "pkg_warning")]
pub enum PkgWarning {
#[condition(message = "dropped {dropped} of {total} rows")]
Truncated { dropped: i32, total: i32 },
}
#[miniextendr]
pub fn trim(n: i32) -> i32 {
defer_warning(PkgWarning::Truncated { dropped: 2, total: n });
n - 2
}trim(5L)
# [1] 3
# Warning message:
# In trim(5L) : dropped 2 of 5 rows
w <- tryCatch(trim(5L), pkg_warning_truncated = function(w) w)
class(w)
# [1] "pkg_warning_truncated" "pkg_warning" "rust_warning" "simpleWarning" "warning" "condition"
w$dropped
# [1] 2
suppressWarnings(trim(5L))
# [1] 3
withCallingHandlers(trim(5L), warning = function(w) invokeRestart("muffleWarning"))
# [1] 3
What R sees is what warning() called from an R function would produce,
because that is what runs: the same R helper that re-raises the panicking
macros’ conditions signals each queued one. Class layering, w$<name> data
fields and conditionCall() (the wrapper’s call, or NULL for a condition
queued without a call) are identical to the
immediate forms. tryCatch(warning = ) exits before the value is returned;
calling handlers that muffle keep it.
| Function | Macro | R signal |
|---|---|---|
defer_warning(payload) | defer_warning!(class = …, data = …, "fmt") | warning() |
defer_message(payload) | defer_message!(…) | message() |
defer_condition(payload) | defer_condition!(…) | signalCondition() |
The functions take any RConditionData payload: a
derived or hand-written type, or an RError
built on the spot. The macros take the warning! grammar (call = none,
then class = …, then data = …, then the format message). defer_message honours the payload’s
classes, layered in front of rust_message, which the message! macro has
no syntax for.
Ordering. Queued conditions are signalled after the Rust body finished and
before R sees its outcome. A deferred warning followed by rust_error! or an
Err return is signalled first and the error second; several deferred
conditions keep their queue order; nested calls (R code evaluated from Rust
that calls another #[miniextendr] function) flush only what they queued
themselves.
Warning placement. Conditions queued before a failure still signal. If a
body must not warn on failure, collect warning payloads locally and queue them only after its last
fallible step (including any ?). Moving a warning before a later ? changes
that behavior: returning Err does not cancel the queued warning.
use miniextendr_api::{defer_warning, miniextendr};
#[miniextendr]
pub fn parse_trimmed(text: String) -> Result<i32, std::num::ParseIntError> {
let trimmed = text.trim();
let value = trimmed.parse::<i32>()?;
if trimmed != text {
defer_warning!(class = "pkg_trimmed", "removed surrounding whitespace");
}
Ok(value)
}
Here, parse_trimmed(" bad ") errors without warning;
parse_trimmed(" 42 ") warns about the whitespace and returns 42L.
Capturing a block’s conditions. deferred_condition::capture(f) runs f
and returns what it queued, oldest first, instead of signalling it: for code
whose diagnostics the caller already has, such as replaying the steps that
built a saved object. capture(f).0 discards them; each RCondition can also
be inspected, queued again with .defer() or raised with .raise().
use miniextendr_api::deferred_condition::capture;
let (rebuilt, _replayed) = capture(|| rebuild(&snapshot));
Conditions queued before the block, or by enclosing calls, stay queued, and a
nested #[miniextendr] call still signals its own when it returns. If f
unwinds, its conditions stay queued and follow the rules below (a Rust failure
signals them, an R-origin error discards them); to keep them on failure too,
catch the failure inside the block with
catch_condition:
capture(|| catch_condition(|| rebuild(&snapshot))).
Where it works. Every #[miniextendr] function and method, including
#[miniextendr(worker)] bodies (the queue is a Mutex: the push happens on
the worker, the signal on R’s main thread once the result is back), rng
wrappers and trait-ABI vtable shims. ALTREP RUnwind and RustUnwind callbacks,
with_r_unwind_protect_or_raise, and connection I/O callbacks flush their own
conditions before returning. ALTREP and connection conditions have
conditionCall() = NULL; the raising guard uses its explicit call argument
(none for a condition raised with call = none).
For example, an ALTREP elt() implementation can queue defer_warning!
and return the element: suppressWarnings(x[1L]) keeps that element, while
tryCatch(x[1L], warning = identity) exits with the warning. The constructor’s
.Call has already returned; a later unrelated call does not own that warning.
Callback results remain alive while handlers run. ALTREP’s SEXP trampolines
root their results automatically. Generic low-level guards cannot inspect R
objects hidden in arbitrary Rust values: return OwnedProtect (or another
rooted handle) for an allocated SEXP and extract it after the guard returns.
Calling handlers may allocate or re-enter Rust. Every queued entry is signalled
in order, including identical entries; callbacks that run repeatedly should
queue only the diagnostics their API intends to emit.
A Rust failure signals queued conditions first. An R-origin error or exiting
handler abandons the callback and discards its remaining queue, rather than
trying to signal while R is already unwinding. options(warn = 2) and warning
handlers that exit follow the same rule.
Finalization. External-pointer finalizers and connection close/destroy/Drop
callbacks suppress deferred conditions, including nested guarded work. R’s
finalizer context clears the caller’s condition handlers; these conditions are
neither signalled there nor retained for a future call. The same rule applies
to destructors run through drop_catching_panic. Use explicit methods for
user-visible diagnostics before releasing an object. Raw unguarded FFI and
AltrepGuard::Unsafe callbacks must not queue conditions; select a guarded
callback when deferred signalling is needed.
🔗Classed Result errors with RConditionData and RError
A #[miniextendr] function or method returning Result<T, E> raises Err(e)
as an R error whose kind is "result_err". The Err arm picks the class
vector and data in three steps, the first that applies winning:
Eimplementsminiextendr_api::condition::RConditionData(this section).- The API crate’s
serdefeature is on andE: serde::Serialize + Display(derived from the serde shape). - Otherwise a bare
rust_errorwhose message isformat!("{e:?}").
To give R handlers something to dispatch on without giving up ? composition,
implement RConditionData for the error type:
use miniextendr_api::condition::{ConditionData, RConditionData};
#[derive(Debug, thiserror::Error)]
pub enum PkgError {
#[error("field `{field}` is missing")]
MissingField { field: String },
#[error("{value} exceeds the maximum {max}")]
OutOfRange { value: f64, max: f64 },
}
impl RConditionData for PkgError {
fn message(&self) -> String { self.to_string() }
fn class(&self) -> Vec<String> {
let member = match self {
PkgError::MissingField { .. } => "pkg_error_missing_field",
PkgError::OutOfRange { .. } => "pkg_error_out_of_range",
};
vec![member.into(), "pkg_error".into()]
}
fn data(&self) -> Option<ConditionData> {
Some(match self {
PkgError::MissingField { field } => vec![("field".into(), field.as_str().into())],
PkgError::OutOfRange { value, max } =>
vec![("value".into(), (*value).into()), ("max".into(), (*max).into())],
})
}
}
#[miniextendr]
pub fn check(value: f64) -> Result<f64, PkgError> {
if value > 100.0 { return Err(PkgError::OutOfRange { value, max: 100.0 }); }
Ok(value)
}e <- tryCatch(check(150), error = function(e) e)
class(e)
# [1] "pkg_error_out_of_range" "pkg_error" "rust_error" "simpleError" "error" "condition"
e$value; e$max
# [1] 150
# [1] 100
tryCatch(check(150), pkg_error = function(e) "family handler")
# [1] "family handler"
In tests, assert this vector exactly (expect_s3_class(e, classes, exact = TRUE)
or a prefix comparison, see Testing the class vector);
the any-of default of expect_s3_class() cannot tell a dropped layer from a
present one.
Detection is by trait, not by attribute: the generated Err arm probes
E: RConditionData first and falls back to the Debug rendering otherwise,
so existing Result<T, String> functions behave exactly as before. kind
stays "result_err". Field names follow the reserved-name rule above; a
reserved name from data() raises a rust_error describing the clash.
The same trait classes argument-conversion failures: when a parameter type’s
TryFromSexp::Error implements RConditionData, a failed conversion raises
its classes and fields with kind = "conversion", the parameter’s name as
e$param and its Rust type as e$rust_type. The R-side argument checks raise
the same condition (#1591). See
ERROR_HANDLING.md.
For one-off cases, RError is a ready-made classed value. Any
std::error::Error converts into it with ? or From (the message keeps the
caused by: chain), and builders add the R-facing parts:
use miniextendr_api::condition::RError;
#[miniextendr]
pub fn parse_port(s: &str) -> Result<i32, RError> {
let port: i32 = s
.parse()
.map_err(|e| RError::from(e).class(["pkg_bad_port", "pkg_error"]).data("input", s))?;
Ok(port)
}
The .without_call() builder raises the error with no call
(Conditions without a call). RError implements
Display (the message) but not std::error::Error, which keeps the blanket
From<E: Error> coherent; it works with #[miniextendr(unwrap_in_r)] too.
🔗Deriving RCondition
#[derive(RCondition)] writes the impl above from the type’s shape, and a
conversion into an owned RCondition (below):
use miniextendr_api::RCondition;
#[derive(Debug, RCondition)]
#[condition(class = "pkg_error")]
pub enum PkgError {
#[condition(message = "field `{field}` is missing")]
MissingField { field: String },
#[condition(message = "{value} exceeds the maximum {max}")]
OutOfRange { value: f64, max: f64 },
}
-
Classes. The family is
#[condition(class = "…")]on the type, else the type name in snake_case (PkgError→pkg_error). Each variant reportsc(<member>, <family>); the member is the same attribute on the variant, else<family>_<variant in snake_case>(pkg_error_out_of_range). A struct reportsc(<family>). The list form,#[condition(class("…", "…"))], most specific first, is a struct’s whole class vector, and on a variant it replaces the member, with the family still last:#[derive(RCondition)] #[condition(kind = message, class("pkg_message_selection_dropped", "pkg_message"))] struct SelectionDropped { missing: Vec<String> } // class: pkg_message_selection_dropped, pkg_message, rust_message, ...On an enum the type takes
class = "…"only: it names the family. -
Message.
#[condition(message = "…")]on a struct or variant is aformat!string with the fields in scope (tuple fields as_0,_1, …). Without it the message is the type’sDisplayrendering, so a thiserror#[error("…")]stays the single source of the text. -
Data. Every field becomes a
dataentry under its own name, converted withRValue::from(field.clone())(the supported value types).#[condition(rename = "…")]changes the name,#[condition(skip)]leaves the field out, and#[condition(debug)]attaches theDebugrendering (RValue::debug) for a type without an R mapping.#[condition(skip_none)]on anOptionfield leaves its entry out when it isNone, instead of a typedNA.#[condition(data(name = value, …))]on the type or a variant adds entries after the fields’ (the type’s, then the variant’s), eachRValue::from(value):data(param = "unit_table")gives every such conditione$param. Tuple fields needrenameorskip. The reserved slotsmessage,callandkindare compile errors, for fields anddata(...)entries alike, as are duplicate names and type or const parameters. Lifetime parameters are fine: a payload can borrow, since every entry is converted to an ownedRValue. -
Call.
#[condition(call = none)]on the type (every variant) or on a variant raises the condition without a call (Conditions without a call); the others keep the wrapper’s call. -
Argument message.
#[condition(argument_message = "…")]on a struct or variant, aformat!string likemessage, is the whole message when the error refuses an argument in a conversion (a type’sTryFromSexpor itsvalidatecheck), in place of'<p>' must be …: <message>. A variant without it keeps the prefixed message. In a list of objects (aVecof a newtype overList) it words the refused element, before its position. See EXTENDING_MINIEXTENDR.md.
The same derive serves
deferred conditions and
Result<T, E> returns.
🔗Typed warnings, messages and mixed condition enums
The derive also converts the type into an owned RCondition. Its kind is
#[condition(kind = …)]: on the type it sets the default, and an enum
variant can override it. The choices are error (the default), warning,
message and condition.
use miniextendr_api::RCondition;
#[derive(RCondition)]
#[condition(kind = warning, class = "pkg_warning", message = "dropped {n} rows")]
struct DroppedRows {
n: i32,
}
// Queue a warning and keep computing the return value.
RCondition::from(DroppedRows { n: 2 }).defer();
#[derive(RCondition)]
#[condition(kind = warning, class = "pkg_diagnostic")]
enum Diagnostic {
#[condition(message = "dropped {n} rows")]
Dropped { n: i32 },
#[condition(kind = message, message = "finished {n} rows")]
Finished { n: i32 },
#[condition(kind = error, message = "input is empty", call = none)]
Empty,
}
RCondition::from(Diagnostic::Finished { n: 10 }).defer();
.defer() queues the declared kind until the current guarded call returns.
.raise() uses panic_any(RCondition) to leave the Rust body immediately;
muffling a warning in R does not resume that body. Both routes preserve the
classes, fields and call policy. Plain panic_any(DroppedRows { n: 2 })
does not perform this conversion; convert into RCondition first.
The kind is read only through RCondition::from. A value returned as
Err(x) from a Result<T, E> is always an R error, whatever kind it
declares: Err(Diagnostic::Finished { n }) stops the call instead of printing
a message. defer_warning(x) / defer_message(x) / defer_condition(x) also
signal the kind they are named after. Convert with RCondition::from(x) when
the declared kind should decide.
🔗Deriving the classes from a serde error type
When the error type already derives serde::Serialize (for logging, JSON
transport, or a downstream client), the same information drives the R
condition without an RConditionData impl and without any attribute: with the
API crate’s serde feature enabled, every Result<T, E> whose
E: Serialize + Display serializes the Err value. The enum variant becomes
the member class <prefix>_<variant>, the variant’s fields become e$<name>,
and the message comes from Display. #[miniextendr(serde_error(..))] exists
only to carry options (tag, prefix, skip, rename, the as_* field
types, or a shared shape); the bare flag is a compile error, since it would
switch nothing on.
#[derive(Debug, thiserror::Error, serde::Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum EngineError {
#[error("field `{field}` is missing")]
MissingField { field: String },
#[error("{value} exceeds the maximum {max}")]
OutOfRange { value: f64, max: f64 },
}
#[miniextendr]
pub fn check(value: f64) -> Result<f64, EngineError> {
if value > 100.0 { return Err(EngineError::OutOfRange { value, max: 100.0 }); }
Ok(value)
}e <- tryCatch(check(150), error = function(e) e)
class(e)
# [1] "mypkg_error_out_of_range" "mypkg_error" "rust_error" "simpleError" "error" "condition"
e$value; e$max
# [1] 150
# [1] 100
- The family class defaults to
<crate>_error, from the Rust crate name at compile time. Override it withserde_error(prefix = "engine"). - Internally tagged enums (
#[serde(tag = "kind")]) have the tag field consumed as the variant, so it never collides with the framework’s owne$kind("result_err"). Name a different tag withserde_error(tag = "type"). Externally tagged enums (serde’s default) report the variant name verbatim (mypkg_error_OutOfRange; add#[serde(rename_all = "snake_case")]for snake_case). - Struct variants contribute their fields. A newtype variant with a struct
payload contributes that struct’s fields; any other newtype or tuple payload
lands under
e$value. Unit variants carry classes only. A value that serializes without variant information (a plain struct, a string) gets just the family class.Result<T, String>andResult<T, &str>therefore raisec("mypkg_error", "rust_error", …)with the text as the message, no data. - Values follow the
RValuemapping (RValue::from_serde, see SERDE_R.md): scalars, non-empty homogeneousVec<T>as atomic vectors, nested structs as named lists,NoneasNULL. An emptyVec<T>arrives aslist(), since serde gives no element type for an empty sequence, soe$columnsislist()when empty and a character vector otherwise; aVec<Option<String>>with aNoneis a list with aNULLhole. Give such a field its R type with#[serde(serialize_with = "miniextendr_api::serde::as_character")](oras_integer,as_double,as_logical): it is then always that vector type,character(0)when empty, aNoneelementNA, the same fielderror!(data = ...)gives for the data (#2164). Other serializers see the field unchanged. For an error type you can’t annotate (its crate doesn’t depend on miniextendr), choose the same types at the condition boundary, once for the type withserde_error_shape!(EngineError = ENGINE), see Field types and shared shapes. DerivingRConditionData(above) instead converts each field withRValue::from. The reserved names above apply to the payload fields; a clash raises arust_errordescribing it, exactly as fordata(), with the exceptions below. serde_error(..)needs aResultreturn type and cannot be combined withunwrap_in_r; both are compile errors, as is the bareserde_errorflag.
🔗Payload fields named message
A wrapped parser or foreign error is usually Variant { message: String },
and renaming that field at the source changes a serialization other consumers
read. Three rules keep the zero-boilerplate path open for that shape:
#[derive(Debug, thiserror::Error, serde::Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ParserError {
#[error("line {line}: {message}")]
Parse { message: String, line: u32 },
#[error("{message}")]
Wrapped { message: String },
}
#[miniextendr(serde_error(skip("message")))]
pub fn parse(text: String) -> Result<f64, ParserError> { /* ... */ }
#[miniextendr(serde_error(rename(message = "detail")))]
pub fn parse_keep(text: String) -> Result<f64, ParserError> { /* ... */ }
serde_error(skip("message", ...))drops the named payload fields before the reserved-name check.Parsereaches R asmypkg_error_parsewithe$lineand theDisplaytext asconditionMessage(e); the wrapped text survives only whereDisplayincludes it.serde_error(rename(message = "detail", ...))splices the field under the new name (e$detail). The source may be an identifier or, for a serde-renamed field whose name is not one, a string literal:rename("kebab-name" = "kebab"). A target may not bemessage,callorkind, and two pairs may not share a target; both are compile errors. A target the variant already carries (rename(message = "line")onParse) would give the condition twolinefields of whiche$linereads only the first, so it raises arust_errornaming the field and the rename at raise time (#1459).- With no option, a
messagefield whose value is exactly theDisplaytext is dropped as redundant with the condition’s ownmessageslot.Wrappedabove therefore needs nothing;Parse(whoseDisplayadds the line) still raises the reserved-name error unless skipped or renamed.
Both options name the field as it serializes and are a no-op for variants
that lack it, so one attribute covers an enum whose variants differ. The macro
cannot see the error type’s fields at expansion time; only the option grammar
is checked there. A field named call or kind has no equality rule and
must be skipped or renamed.
- Choose
RConditionDatawhen the message or class vector needs to differ from the serde shape (it takes precedence, unless the R-facing crate declares a shape for the type; see below); the serde path is the zero-boilerplate default for enums that are already serde-tagged.
🔗Field types and shared shapes
A payload field’s R type follows its serialization, so an empty Vec<String>
arrives as list() and a u32 as an integer. The error type can fix that
itself with #[serde(serialize_with = "miniextendr_api::serde::as_character")]
(above), but not when its crate can’t depend on miniextendr, or when its
Serialize is hand-written for other consumers. The R-facing crate then picks
the types at the condition boundary, once for the error type:
use miniextendr_api::condition::SerdeErrorShape;
pub const ENGINE: SerdeErrorShape = SerdeErrorShape::new()
.prefix("engine")
.skip(&["message"])
.as_character(&["rule", "column"])
.as_integer(&["n"]);
miniextendr_api::serde_error_shape!(engine::Error = ENGINE);
#[miniextendr]
pub fn check(x: f64) -> Result<f64, engine::Error> { /* ... */ }
#[miniextendr]
pub fn rate(x: f64) -> Result<f64, engine::Error> { /* ... */ }
Every #[miniextendr] function and method of the crate that returns
engine::Error raises with ENGINE, with or without a serde_error(..)
attribute, so a function added later can’t give the error’s fields other
types than its siblings by leaving a reference out.
as_character(..),as_integer(..),as_double(..),as_logical(..)give each named field exactly what the matchingserde::as_*helper gives it: its usual serialization as one vector of that type,character(0)for an empty sequence,NAfor aNone(element). A value that doesn’t fit (a string underas_integer, an integer outside R’s range) raises arust_errornaming the field and the option, with the original error’s message.- The shape holds the whole option set:
tag,prefix,skip,renameand the fouras_*lists, asconst fnbuilder methods. The keys are the field names as the error serializes them, before anyrename:.rename(&[("n", "count")]).as_integer(&["n"])givese$countas an integer. A field a variant lacks is a no-op for that variant.prefixdefaults to the function’s crate,<crate>_error.SerdeErrorShapeis also re-exported asminiextendr_api::serde::SerdeErrorShapeand from the prelude, as isserde_error_shape!. - Contradictions inside a shape (a field in two
as_*lists, a field skipped and typed or renamed, a reserved or repeated rename target, an option set twice) panic during const evaluation, so theconstdoes not compile: error E0080, naming the field. - The right-hand side of
serde_error_shape!is any constant expression of typeSerdeErrorShape, so the builder chain can also be written inline. The error type must beSerialize + Display, which the declaration checks. - The declaration is a trait impl keyed by a marker type that
miniextendr_init!defines at the crate root. That is what lets it name a type from another crate, including one that can’t depend on miniextendr, and it scopes the declaration to the declaring crate: another package returning the same type declares its own. A type gets one declaration per crate; a second is the compiler’s conflicting-implementations error (E0119). With theserdefeature, a crate whose#[miniextendr]functions return aResultmust callminiextendr_init!at its crate root, as every R package does; see MACRO_ERRORS.md. - A declared shape wins over an
RConditionDataimpl of the type: the declaration is the R-facing crate’s choice for its own functions, where the impl is the error crate’s general one. - All of this runs only when the function returns
Err: the shape is plain data theErrarm reads, at no cost to a successful call.
A function overrides the declared shape for itself with options of its own,
next to skip and rename, or with another shape named by path:
#[miniextendr(serde_error(as_double("n")))]
pub fn rate(x: f64) -> Result<f64, engine::Error> { /* ... */ }
#[miniextendr(serde_error(shape = STRICT, as_double("n")))]
pub fn strict_rate(x: f64) -> Result<f64, engine::Error> { /* ... */ }
- Shapes apply in order: the one declared for the type, then
shape =, then the function’s own options. For a field set by more than one, the later wins:rategivese$na double. A laterskipdrops a field an earlier shape types or renames, a lateras_*orrenamekeeps a field an earlier shape skips, and a type and a new name compose. A latertag/prefixreplaces an earlier one. shape =takes a path resolved from the function’s module (STRICT,crate::errors::STRICT), or on a methodSelf::SHAPEfor an associated const of the impl’s type; a misspelt one is a compile error.- The function’s options work the same with no shape at all:
serde_error(as_character("rule", "column"), as_integer("n")). A field takes oneas_*option and can’t be both skipped and typed; both are compile errors, every conflict reported at once.
🔗Catching a condition in Rust: catch_condition
condition::catch_condition(f) runs f and returns a condition raised inside
it as Err(RCondition) instead of letting it reach R. Use it when a body turns
another function’s failure into its own result: a refusal that names the
cause, a fallback, a retry.
use miniextendr_api::condition::{RCondition, catch_condition};
use miniextendr_api::{miniextendr, rust_error};
#[miniextendr]
pub fn restore(path: &str) -> Restored {
match catch_condition(|| rebuild(path)) {
Ok(object) => object,
Err(RCondition::Error { message, class, .. }) => rust_error!(
class = "pkg_error_restore",
data = ("cause_class", class.join(" ")),
"cannot restore {path}: {message}"
),
Err(other) => other.raise(),
}
}Raised inside f | catch_condition |
|---|---|
error! / rust_error!, warning!, message!, condition!, RCondition::raise() | Err, with the kind (the variant), classes, data and call choice |
an argument or conversion error (arg_error!, RCondition::arg_error), an ExternalPtr type error | Err (RCondition::Conversion / Error) |
an R error, interrupt or other R exit crossing the Rust frames (R code run through eval_with_handlers, with_r_unwind_protect, …) | resumed: R carries on to where it was jumping |
a plain panic!() or any other payload | resumed: it becomes an R error at the boundary, with its source location |
- An
Errcan be raised again with.raise()or queued with.defer(). - To get an R error back as a value, evaluate the R code with
try_eval_with_handlers(orRCall::try_eval_with_handlers), which returnsErr(REvalError);REvalError::reraise_classgives the classes to raise it again as your own (next section). - Deferred conditions queued inside
fstay queued;capturetakes them. - A caught raise still runs the panic hook: with
MINIEXTENDR_BACKTRACE=trueit prints the panic to stderr.
🔗Raising a caught R error as your own
RCall::eval, eval_base and r_eval_str catch an R error as
expression::REvalError, and so do RCall::try_eval_with_handlers and
expression::try_eval_with_handlers, which leave the call’s warnings and
messages to the caller’s handlers
(QUOTED_ARGUMENTS.md).
REvalError holds R’s condition: message() (R’s
conditionMessage(), without the Error in <call> : prefix and the Calls:
line R prints), call(), classes() and the condition object
(EXPRESSION_EVAL.md). Three ways raise it
again from a #[miniextendr] function, each keeping R’s message as the
message:
use miniextendr_api::expression::{RCall, REvalError};
use miniextendr_api::condition::RError;
use miniextendr_api::{miniextendr, rust_error, SEXP};
// 1. The package's class first, the caught classes after it.
#[miniextendr]
pub fn summarise(df: SEXP) -> SEXP {
match unsafe { RCall::new("summary").arg(df).eval_base() } {
Ok(value) => value,
Err(e) => rust_error!(class = e.reraise_class("pkg_summary_error"), "{e}"),
}
}
// 2. Returned as is: the caught classes, then the `rust_error` layers.
#[miniextendr]
pub fn summarise_plain(df: SEXP) -> Result<SEXP, REvalError> {
unsafe { RCall::new("summary").arg(df).eval_base() }
}
// 3. Through `RError`, for data fields or to carry it off R's main thread.
#[miniextendr]
pub fn summarise_with_data(df: SEXP) -> Result<SEXP, RError> {
unsafe { RCall::new("summary").arg(df).eval_base() }.map_err(|e| {
let class = e.reraise_class("pkg_summary_error");
RError::from(e).class(class).data("step", "summary")
})
}
For an error R raised as errorCondition("…", class = "vctrs_error_x"), the
first form gives
e <- tryCatch(summarise(x), error = function(e) e)
class(e)
# [1] "pkg_summary_error" "vctrs_error_x" "rust_error" "simpleError" "error" "condition"
reraise_class(own) puts own (one class or several, most specific first)
ahead of the caught specific_classes(): R’s classes without the trailing
rust_error, simpleError, error and condition layers, which the macro
adds back once. A plain stop("…") has no specific class, so the result is
c(own, "rust_error", "simpleError", "error", "condition"). A handler for the
package’s class, or for the class R raised, catches it either way.
The raised condition’s call is the #[miniextendr] function’s own, as for
any condition the function raises; e.call() is the call R recorded, there
for a message that wants it. Fields of the caught condition other than its
message (rlang’s body or parent, say) are not copied; read them from
e.condition() and attach the ones a handler needs with data =. A data =
value can’t be an R object, so the caught R condition itself can’t be kept as
the new one’s parent yet (#1874); a condition raised in Rust can, with
with_parent.
To raise the caught condition itself, unchanged (R’s classes and fields, no
rust_error class, R’s call), use unsafe { e.reraise() } inside the
#[miniextendr] call: it signals the condition again through base
stop(cond), and the Rust frames drop their values on the way out (#2163,
QUOTED_ARGUMENTS.md).
REvalError is !Send: dropping it releases the condition’s root, which
must happen on R’s main thread. Convert it to an RError (the third form), or
keep the String from message(), to carry the failure to a worker thread.
🔗Chaining a cause: with_parent
RCondition::with_parent(cause) attaches another RCondition as the cause,
the way rlang::abort(parent = ) chains conditions. R gets the cause as the
field e$parent: a condition object with its own classes, message and fields,
built like the raised condition but never signalled. A cause can have its own
parent, so the chain is e$parent$parent and so on.
use miniextendr_api::condition::{catch_condition, ConditionCall, RCondition};
use miniextendr_api::{error, miniextendr};
fn parse_line(line: &str) -> i32 {
line.trim().parse().unwrap_or_else(|_| {
error!(class = "pkg_error_parse", data = ("line", line), "not a number: {line}")
})
}
#[miniextendr]
pub fn load_setting(line: &str) -> i32 {
match catch_condition(|| parse_line(line)) {
Ok(value) => value,
Err(cause) => RCondition::Error {
message: "could not load the setting".into(),
class: vec!["pkg_error_load".into()],
data: None,
call: ConditionCall::Inherit,
}
.with_parent(cause)
.raise(),
}
}e <- tryCatch(load_setting("x"), pkg_error_load = function(e) e)
conditionMessage(e)
# [1] "could not load the setting"
class(e$parent)
# [1] "pkg_error_parse" "rust_error" "simpleError" "error" "condition"
e$parent$line
# [1] "x"
- Classes stay apart. The cause’s classes are not added to the raised
condition’s, so a handler for
pkg_error_parsedoes not catchload_setting()’s error. Code that cares about the cause tests it:inherits(e$parent, "pkg_error_parse"). - Call. The cause reports the same call as the raised condition, or none
when the cause was raised with
call = none. - Any kind. The raised condition and its cause can each be an error, warning, message or plain condition.
- One parent. A second
with_parentreplaces the first, and the cause replaces adatafield the condition namesparent. - Transport. The cause travels in the reserved data field
.mx_parent(see Reserved names), so it goes wherever the condition’s otherdatafields go. - Only Rust conditions. The cause is an
RCondition. An R condition caught asREvalErrorcan’t be a parent yet (#1874).
A #[externalptr(rebuild = ..)] hook’s error reaches R this way: a failed
rebuild is refused with the hook’s error as e$parent
(EXTERNALPTR.md).
🔗Conditions without a call
R’s warning(..., call. = FALSE) and stop(..., call. = FALSE) raise a
condition that names no call. That suits a condition about the data rather
than about the call: a warning that row 5 overrides an earlier row says nothing
about read_overrides(path), and repeating the call on every such warning is
noise. The same opt-out exists per condition:
| Form | Spelling |
|---|---|
| Macros | error!(call = none, …) / rust_error!, warning!, condition! / rust_condition!, defer_warning!, defer_condition! |
RError | RError::new("…").without_call() |
| Derive | #[condition(call = none)] on a #[derive(RCondition)] type (every variant) or on one variant |
Hand-written RConditionData | fn call(&self) -> ConditionCall { ConditionCall::None } |
In the macros call = none comes first, before class = … and data = ….
none is the only value: the default (the wrapper’s call) needs no spelling,
and any other value is a compile error, in the macros and in the derive.
use miniextendr_api::RCondition;
use miniextendr_api::{defer_warning, miniextendr};
#[derive(Debug, RCondition)]
#[condition(class = "pkg_override")]
pub enum OverrideWarning {
#[condition(call = none, message = "row {row} overrides an earlier row for profile {profile}")]
Overridden { row: i32, profile: i32 },
#[condition(message = "{n} rows settled")]
Settled { n: i32 },
}
#[miniextendr]
pub fn read_overrides(n: i32) -> i32 {
defer_warning(OverrideWarning::Overridden { row: 5, profile: 3 });
defer_warning(OverrideWarning::Settled { n });
n
}read_overrides(3L)
# [1] 3
# Warning messages:
# 1: row 5 overrides an earlier row for profile 3
# 2: In read_overrides(3L) : 3 rows settled
w <- tryCatch(read_overrides(3L), pkg_override_overridden = function(w) w)
conditionCall(w)
# NULL
w$row
# [1] 5
w
# <pkg_override_overridden: row 5 overrides an earlier row for profile 3>
Only the call changes. Classes, data fields, kind and handler matching
(tryCatch(pkg_override = ), rust_warning = , withCallingHandlers with
muffleWarning) are the same as with the call; an error prints as
Error: <message> instead of Error in f(...) : <message>.
How it travels. The marker is FALSE in the call slot of the tagged
condition value (no new slot, no new kind). The R helper reads it as “no call”:
.call <- if (isFALSE(.val$call)) NULL else if (is.null(.val$call)) .call_default else .val$call
Every transport carries it:
- immediate macros and
Resulterrors, through the generated wrapper; - deferred conditions, signalled from Rust with the same helper;
#[miniextendr(worker)]bodies: the worker wrapper catches the condition on the worker and builds its tagged value on the main thread, so an immediatewarning!/error!there keeps its kind, classes, data and call choice;- trait-ABI vtable shims: the marker survives the re-panic into the consumer, so a producer’s call-less warning is call-less in the consumer’s wrapper too;
with_r_unwind_protect_or_raiseand ALTREPRUnwindguards, where a call-less error drops the guard’scallargument.
Messages. A message from miniextendr carries no call (conditionCall(m)
is NULL), so message! has no call = form (it is a compile error), and
defer_message!(call = none, …) is accepted for the shared grammar and changes
nothing.
Argument-conversion errors keep the call. They are about an argument of
the call, and the R-side argument checks they mirror name the call too, so
#[condition(call = none)] on a type used as a TryFromSexp::Error has no
effect on a failed conversion. The one exception is a function that reports
no call at all, below.
🔗A function without a call: call = none
The per-condition opt-out is a decision per warning! or per variant. A
function whose conditions are all about the data makes it once:
#[miniextendr(call = none)] drops the call from every condition the
function raises, however it was reached, the argument errors included
(#1851):
#[miniextendr(call = none)]
pub fn no_call_verb(x: i32, #[miniextendr(match_arg)] mode: Mode) -> Result<i32, String> {
if x < 0 {
return Err(format!("x must be non-negative, got {x}"));
}
if x == 0 {
defer_warning!("x is zero");
}
Ok(x)
}no_call_verb(-1L, "Fast")
# Error: x must be non-negative, got -1
no_call_verb(1.5, "Fast")
# Error: 'x' must be integer
no_call_verb(0L, "Fast")
# Warning message:
# x is zero
The wrapper passes .call = FALSE, the marker above, into .Call(), so an
Err, a panic, a classed error! and a deferred warning or message all
arrive call-less through the transport that already exists, and it hands
NULL to its own checks (type and length guards, no_na, inherits,
not_inherits, match_arg / choices), which a failed Rust conversion
shares. The option applies to standalone functions, S3 methods
(s3(generic = ..., class = ...)) included; it has no marker type, so a
Call / CallerCall parameter next to it is a compile error, as are
call_arg and an extern "C-unwind" function. A crate default,
call_attribution = "none" in Cargo.toml, applies it to every free
function, and a per-item call = wrapper restores the call on one. Details:
CALL_ATTRIBUTION.md.
🔗Argument errors from a body
The generated wrapper’s argument checks (match_arg, inherits and its
when(...) hints, not_inherits, no_na, the type preconditions) and a
failed argument conversion all raise one condition:
kind = "conversion", e$param naming the argument, and the crate’s
conversion_error_class before the rust_error layering
(ERROR_HANDLING.md).
arg_error! raises that condition from a function body, for a check the body
makes itself: an argument handed on to another function’s body (which skips
that function’s wrapper and its checks), or a rule that depends on another
argument (#1740).
use miniextendr_api::{arg_error, miniextendr};
#[miniextendr]
pub fn in_metres(x: f64, unit: &str) -> f64 {
match unit {
"m" => x,
"cm" => x / 100.0,
_ => arg_error!(param = "unit", "'unit' should be one of \"m\", \"cm\""),
}
}e <- tryCatch(in_metres(1, "km"), error = identity)
class(e)
# [1] "pkg_error_argument" "pkg_error" "rust_error" "simpleError" "error" "condition"
e$kind
# [1] "conversion"
e$param
# [1] "unit"
conditionCall(e)
# in_metres(1, "km")
(The first two classes are the crate’s conversion_error_class; without the
key the vector starts at rust_error.) The condition is the one the wrapper’s
own check raises: same classes, same fields (message, call, kind,
param) and the same call, which is the wrapper’s call as written or, under
#[miniextendr(call = caller)], its caller’s (under call_arg, the call
passed as .call; for an S3 method, the generic’s call; under
call = none, no call). One handler
(tryCatch(pkg_error_argument = …)) therefore catches the wrapper’s checks and
the body’s alike.
The ... check of dots = empty | forward(allow = [..]) raises this argument
error too, with param = "...", the class miniextendr_error_unused_argument
(after the attribute’s own class) in front of the crate’s, and two more
fields: argument (each refused argument, ..<i> for an unnamed one) and
suggestion (the name each one most likely meant, or NA)
(DOTS_TYPED_LIST.md).
- Grammar:
arg_error!([call = none,] param = <expr>, <format>, <args>…).paramis the argument’s R name (anythingInto<String>); a leadingcall = noneraises it without a call, as for the other macros. There is noclass =ordata =: the classes are the crate’s and the only field isparam. Acallafterparam, a missingparam,class =anddata =are compile errors. - Control flow: it diverges like
error!(the samepanic_anytransport), so it fits any expression position. Conditions queued withdefer_warning!& co. are signalled before it, as before any error. - Without the macro:
ArgError::new(param, message).raise()raises the same condition from a value.match_arg_paramreturns one for a choice argument the body matches itself (match_arg_param_with_defaultfor one forwarded from adefault = "..."parameter) (ENUMS_AND_FACTORS.md).ArgErroris a plain value and does not implementRConditionData: returned as a body’sErrit takes theResultpath (kind = "result_err"), so raise it with.raise(). - Where it works: immediate calls,
#[miniextendr(worker)]bodies (the worker wrapper builds the condition on the main thread) and trait-ABI calls, where the consumer package’s wrapper raises it with that crate’s classes.
How the crate’s classes get there. A macro_rules! macro cannot read
[package.metadata.miniextendr], so the tagged condition value carries the
marker class .miniextendr_conversion_error_class in its class slot (no new
slot, no new kind). The wrapper file’s .miniextendr_raise_condition replaces
the marker with the value of the binding of that name, the classes its own
checks use (NULL without the key). The generated no_na check after a
conversion builds the same condition with arg_check_condition_value, which
receives the classes the #[miniextendr] macro read at expansion time; the
two are the generated-code and hand-written spellings of one condition.
A raising guard without a generated R wrapper (an ALTREP r_unwind callback,
with_r_unwind_protect_or_raise) resolves the marker in Rust, against the
classes miniextendr_init! registered for the package (on wasm32, the copy
the wasm_registry.rs snapshot carries), and raises the same condition: the
crate classes, kind = "conversion", e$param and the rust_error layering
(#1768). A connection callback returns its fallback value, as for any panic.
🔗Trait-ABI and ALTREP error class layering
Cross-package trait method panics and ALTREP r_unwind callback panics
do receive rust_* class layering, even though there is no R wrapper
to inspect a tagged SEXP. Two different mechanisms cover the two contexts:
-
Trait-ABI shims: the vtable shim returns a tagged SEXP on panic; the generated View method wrapper inspects the result and re-panics with the reconstructed [
RCondition]. The consumer’s outerwith_r_unwind_protectguard (every#[miniextendr]fn has one) catches the re-panic and produces the tagged SEXP for the consumer’s R wrapper. End-to-end behavior is identical to a same-package call:tryCatch(rust_error = h, ...)matches; user classes fromerror!(class = "...", ...)match beforerust_error. Structureddata =fields (tagged SEXP slot [4]) are also preserved across the re-panic boundary —e$field_nameis accessible in R handlers even when the error crossed a package boundary (see #996 path-1). -
ALTREP
r_unwindcallbacks (andwith_r_unwind_protect_or_raise): the guard builds the tagged value a wrapper would receive and raises it with the wrappers’ own.miniextendr_raise_conditionhelper (no R wrapper required), so the condition is the one a wrapper raises for the same payload (#1768):tryCatch(rust_error = h, ...)matches, user classes match beforerust_error,e$kindis"error"forerror!(),"conversion"forarg_error!()and"panic"for a panic, and structureddata =fields followmessage,callandkind, soe$field_nameworks from ALTREP-raised errors too (see #996 path-2). The one difference is the call: the guard reports thecallit was given,NULLin an ALTREP callback.
🔗Remaining limitations
Two narrow cases still degrade:
-
warning!()/message!()/condition!()from an ALTREPr_unwindcallback or a custom connection’s I/O callback. There is no mechanism to suspend execution to deliver a non-fatal signal from inside R’s vector-dispatch or connection machinery. These produce an R error (kind = "panic", no user classes or data) with the message: “warning!/message!/condition! from an ALTREP or connection callback context cannot be raised as non-fatal signals; use error!() instead”. -
A trait View method (
view.method()) called from Rust code that is not wrapped inwith_r_unwind_protect(e.g., a manual call from a test harness or init callback). The re-panic from the View has no outer guard to catch it, so the worker thread’scatch_unwindboundary converts it to an R error withoutrust_*class layering. In practice, every#[miniextendr]fn already provides the outer guard, so this only affects unusual call sites.
Functions that explicitly opt out via #[miniextendr(unwrap_in_r)] deliver
Result<T, E> to R as a list with an $error slot rather than treating Err
as a Rust-origin failure — Err never traverses the condition pipeline.
🔗AsRError — wrapping std::error::Error
For functions that return Result<T, E> where E: std::error::Error,
AsRError<E> wraps the error and formats its full cause chain into the
message:
use miniextendr_api::condition::AsRError;
use miniextendr_api::miniextendr;
#[miniextendr]
fn parse_number(s: &str) -> Result<i32, AsRError<std::num::ParseIntError>> {
s.parse::<i32>().map_err(AsRError)
}tryCatch(parse_number("abc"), error = function(e) e$message)
# [1] "invalid digit found in string"
For errors with a source chain, all causes appear in the message separated by
\n caused by: ....