This guide covers error handling patterns, panic safety, and best practices for robust R-Rust interop.

πŸ”—Overview

miniextendr handles three types of errors:

  1. Rust panics β†’ Converted to R errors
  2. R errors (Rf_error) β†’ Rust destructors run, then R unwinds
  3. Result errors β†’ Can be returned as R values or converted to R errors

πŸ”—Panics

πŸ”—Basic Panic Handling

Rust panics in #[miniextendr] functions are automatically caught and converted to R errors:

#[miniextendr]
pub fn divide(a: i32, b: i32) -> i32 {
    if b == 0 {
        panic!("Division by zero!");
    }
    a / b
}
divide(10L, 0L)
# Error: Division by zero!

πŸ”—Panic with Destructors

RAII resources are properly cleaned up when panics occur:

#[miniextendr]
pub fn process_file(path: &str) -> i32 {
    let _file = File::open(path)?;  // RAII - closed on drop
    let _lock = mutex.lock();        // RAII - released on drop

    if some_condition {
        panic!("Something went wrong!");
        // _file and _lock are STILL dropped before R sees the error
    }
    42
}

πŸ”—How It Works

miniextendr uses R_UnwindProtect to ensure proper cleanup:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  #[miniextendr] function called from R                  β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  1. Enter R_UnwindProtect context                       β”‚
β”‚  2. Create Rust resources (RAII)                        β”‚
β”‚  3. Execute user code                                   β”‚
β”‚     β”œβ”€β”€ Success: return result                          β”‚
β”‚     β”œβ”€β”€ Panic: catch_unwind catches it                  β”‚
β”‚     β”‚   └── Drop all Rust resources                     β”‚
β”‚     β”‚   └── Convert panic message to R error            β”‚
β”‚     └── R error (longjmp): cleanup handler triggers     β”‚
β”‚         └── Drop all Rust resources                     β”‚
β”‚         └── Continue R's unwind                         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸ”—Result Types

πŸ”—Automatic Error Conversion

Return Result<T, E> for automatic error conversion. The R message is E’s Debug text, so a String error arrives quoted. With the serde feature on, an E: Serialize + Display (String included) uses its Display text instead:

#[miniextendr]
pub fn parse_number(s: &str) -> Result<i32, String> {
    s.parse().map_err(|e| format!("Parse error: {}", e))
}
parse_number("42")    # 42
parse_number("abc")   # Error: "Parse error: invalid digit found in string"
                      # (no quotes with the serde feature)

πŸ”—Classed errors for R handlers

By default an Err is a bare rust_error whose message is the error’s Debug rendering. Implement RConditionData on the error type (or return RError) to raise a custom class vector plus structured fields (e$value) that tryCatch() can dispatch on, while keeping ? composition in Rust. See CONDITIONS.md.

πŸ”—Custom Error Types

Any Debug error type works, and the message is its Debug text: validate(101) below raises OutOfRange { min: 0, max: 100, got: 101 }, not the Display text (only an E: Serialize + Display under the serde feature uses Display). To raise the Display text, return an RError (.map_err(|e| RError::new(e.to_string()))) or implement RConditionData (see Classed errors above):

use std::fmt;

#[derive(Debug)]
pub enum MyError {
    InvalidInput(String),
    OutOfRange { min: i32, max: i32, got: i32 },
}

impl fmt::Display for MyError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            MyError::InvalidInput(s) => write!(f, "Invalid input: {}", s),
            MyError::OutOfRange { min, max, got } =>
                write!(f, "Value {} out of range [{}, {}]", got, min, max),
        }
    }
}

#[miniextendr]
pub fn validate(x: i32) -> Result<i32, MyError> {
    if x < 0 || x > 100 {
        Err(MyError::OutOfRange { min: 0, max: 100, got: x })
    } else {
        Ok(x)
    }
}

πŸ”—Using anyhow or thiserror

For complex error handling, use standard error crates:

// With thiserror
use thiserror::Error;

#[derive(Error, Debug)]
pub enum DataError {
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),
    #[error("Parse error at line {line}: {message}")]
    Parse { line: usize, message: String },
}

#[miniextendr]
pub fn load_data(path: &str) -> Result<Vec<f64>, DataError> {
    let contents = std::fs::read_to_string(path)?;  // Auto-converts io::Error
    // ...
}
// With anyhow
use anyhow::{Context, Result};

#[miniextendr]
pub fn process(path: &str) -> Result<i32> {
    let data = std::fs::read_to_string(path)
        .context("Failed to read input file")?;
    // ...
}

πŸ”—R Error API

πŸ”—panic!() for Errors

Use panic!() for unrecoverable errors. The #[miniextendr] framework catches panics and converts them to structured R error conditions via the tagged-condition transport:

#[miniextendr]
pub fn validate_positive(x: i32) -> i32 {
    if x < 0 {
        panic!("x must be non-negative, got {}", x);
    }
    x
}

For recoverable errors, return Result<T, E>:

#[miniextendr]
pub fn check_input(x: i32) -> Result<i32, String> {
    if x < 0 {
        return Err("x must be positive".to_string());
    }
    Ok(x)
}

Note: r_stop() exists internally but is not part of the public API. The r_error! macro has been removed. Always use panic!() or Err(...) instead.


πŸ”—Never Call Rf_error / Rf_errorcall Directly

Calling Rf_error() or Rf_errorcall() from Rust is forbidden.

πŸ”—Why it is forbidden

Rf_error and Rf_errorcall raise an R error by executing a longjmp. That jump unwinds the C call stack directly, bypassing every Rust stack frame between your code and the setjmp point in R’s evaluator. The consequence is that Rust destructors never run – any heap allocations, file handles, locks, or Box<T> values held by those frames leak or remain poisoned forever.

The R_UnwindProtect wrapper that miniextendr uses internally is designed specifically to avoid this: it runs a cleanup handler before continuing R’s unwind, which lets Rust drop everything properly. But that protection only exists when the Rust code enters R_UnwindProtect. Code that calls Rf_error directly skips the protection entirely.

πŸ”—Use panic!() or Err(…) instead

Both alternatives route through the safe wrapper:

// Good: framework catches this and raises a simpleError on the R side
#[miniextendr]
pub fn check(x: i32) -> i32 {
    if x < 0 {
        panic!("x must be non-negative");
    }
    x
}

// Also good: Err is transported as a value, raised as simpleError in the R wrapper
#[miniextendr]
pub fn parse(s: &str) -> Result<i32, String> {
    s.parse().map_err(|e| format!("parse failed: {e}"))
}

Both produce an R condition with class c("rust_error", "simpleError", "error", "condition") – the same hierarchy as stop(). R code can catch them at any level:

tryCatch(check(-1L), error = function(e) cat("caught:", conditionMessage(e), "\n"))
# caught: x must be non-negative

πŸ”—MXL300 lint

miniextendr-lint (MXL300) scans your crate for direct Rf_error() and Rf_errorcall() call sites and emits a build-time warning. If you see MXL300, replace the call with panic!() or Err(...).

To suppress a false positive on a single line (e.g., inside the framework itself):

// miniextendr-lint: suppress MXL300
unsafe { Rf_error(...) }

πŸ”—The ~8-byte leak on R_ContinueUnwind

There is one documented, accepted cost in the unwind path: when an R error propagates through with_r_unwind_protect, the R_ContinueUnwind call that hands control back to R’s evaluator leaks approximately 8 bytes (the RErrorMarker struct plus its Box header). This is not a bug – it is a deliberate trade-off to keep the unwind path free of further R API calls that could themselves error.

This cost does not apply to the normal Rust panic path, which returns to R via a tagged SEXP value without any longjmp. If you see this leak in valgrind, it is expected and bounded: one allocation per R error that crosses a with_r_unwind_protect boundary, not per call.

πŸ”—Warnings

Issue warnings without stopping execution:

use miniextendr_api::error::r_warning;

#[miniextendr]
pub fn risky_operation(x: i32) -> i32 {
    if x > 1000 {
        r_warning("Large value may cause performance issues");
    }
    expensive_computation(x)
}

πŸ”—Console Output

Print to R’s console (not stderr):

use miniextendr_api::{r_print, r_println};

#[miniextendr]
pub fn verbose_function() {
    r_println!("Starting computation...");
    // ... work ...
    r_println!("Done!");
}

πŸ”—Tagged-Condition Transport

Rust-origin failures (panics, Result::Err, Option::None) are transported as tagged SEXP values back to R, and the generated R wrapper inspects the value and raises a structured R condition. This ensures all Rust destructors have fully completed before R sees the error, and gives R code a typed condition class to catch.

This is the only transport for #[miniextendr] functions and methods. The unwrap_in_r attribute (orthogonal to the transport) instead returns Result<T, E> to R as a list with an $error slot β€” Err never traverses the condition pipeline.

πŸ”—What Gets Caught

The transport intercepts three failure modes:

FailureError kindExample
Rust panic"panic"panic!("oops")
Result::Err"result_err"Err("bad input".to_string())
Option::None"none_err"Returning None from -> Option<()> or -> Option<SEXP> (any other Option<T>, a method’s Option<Self> included, converts None to NULL / NA)

Not affected: Result<T, ()> (unit error type) always returns NULL on Err(()), since a unit error is a deliberate sentinel, not a failure.

πŸ”—The Error Value (Rust Side)

When a failure occurs, the generated C wrapper calls make_rust_condition_value(message, kind, None, call) from miniextendr_api::error_value. This builds a tagged SEXP – a named list with:

  • $error: the error message (character scalar)
  • $kind: machine-readable kind ("panic", "result_err", "none_err", "conversion")
  • $class: optional user-supplied class name (or NULL)
  • $call: the R call SEXP (or NULL)
  • class attribute: "rust_condition_value"
  • __rust_condition__ attribute: TRUE

This value is returned as a normal SEXP from the .Call() interface. No R error is raised at this point – all Rust stack frames have already unwound.

πŸ”—The R Wrapper (R Side)

The generated R wrapper checks the return value:

my_function <- function(x) {
  .val <- .Call(C_mypkg_my_function, .call = sys.call(), x)
  if (inherits(.val, "rust_error_value") && isTRUE(attr(.val, "__rust_error__"))) {
    stop(structure(
      class = c("rust_error", "simpleError", "error", "condition"),
      list(message = .val$error, call = sys.call(), kind = .val$kind)
    ))
  }
  .val
}

The R condition has class c("rust_error", "simpleError", "error", "condition"), so it can be caught at any level of the hierarchy.

πŸ”—Catching Errors in R

Catch by the specific rust_error class:

tryCatch(
  parse_data("not a number"),
  rust_error = function(e) {
    message("Rust error (", e$kind, "): ", e$message)
  }
)

Or with tryCatch/withCallingHandlers using the generic error class:

tryCatch(
  parse_data("bad"),
  error = function(e) {
    if (inherits(e, "rust_error")) {
      # Structured Rust error with e$kind
    } else {
      # Other R error
    }
  }
)

The condition object has these fields:

FieldTypeDescription
messagecharacterHuman-readable error message
callcallThe R call that triggered the error
kindcharacter"panic", "result_err", or "none_err"

πŸ”—Works with All Class Systems

The tagged-condition transport applies to methods in any class system; no extra attribute is needed:

#[miniextendr]            // env class (default)
impl Counter {
    fn get(&self) -> i32 { self.value }
}

#[miniextendr(r6)]        // R6 class
impl Widget {
    pub fn name(&self) -> String { self.name.clone() }
}

#[miniextendr(s7)]        // S7 class
impl Gauge {
    pub fn read(&self) -> f64 { self.level }
}

It also works on trait impl methods:

#[miniextendr]
impl Fallible for MyType {
    fn get_value(&self) -> i32 { self.value }
}

πŸ”—Object Survival After Errors

Objects remain valid after an error. Since the error is transported as a value (not a longjmp), the object’s internal state is never corrupted:

counter <- Counter$new()
counter$inc()

# Error does not corrupt the object
tryCatch(counter$panic_method(), error = function(e) NULL)

counter$get()  # Still returns 1

πŸ”—unwrap_in_r β€” Result-as-value opt-out

The default transport treats Result::Err as a Rust-origin failure (kind "result_err", class rust_error). The unwrap_in_r attribute instead returns the Result<T, E> to R as a list with an $error slot, leaving the caller to decide what to do:

defaultunwrap_in_r
Err(e) reaches Ras a rust_error condition (via stop())as list(value = NULL, error = e)
Catchable by classYes (tryCatch(..., rust_error = ...))n/a β€” no error is raised
Use caseMost code; cleanly maps Rust errors to R errorsReturning structured error data alongside values

πŸ”—Panic Hook and Backtraces

πŸ”—The Panic Hook

miniextendr installs a custom panic hook at package load time via miniextendr_panic_hook(), which is called from R_init_<pkg>() in the C entrypoint. The hook is registered once using std::sync::Once and controls whether Rust backtraces are printed to stderr on panic.

By default, backtraces are suppressed. Panic messages are still captured and forwarded to R as error messages, but the noisy default Rust backtrace output is hidden. This keeps R console output clean for end users.

πŸ”—Enabling Backtraces

Set the MINIEXTENDR_BACKTRACE environment variable to see full Rust backtraces on panic:

Sys.setenv(MINIEXTENDR_BACKTRACE = "1")
my_function_that_panics()
# thread 'main' panicked at 'something went wrong', src/lib.rs:42:5
# stack backtrace:
#    0: std::panicking::begin_panic
#    1: mypackage::my_function
#    ...

Accepted values: "1" or "true" (case-insensitive). Any other value or unset = backtraces suppressed.

To disable again:

Sys.unsetenv("MINIEXTENDR_BACKTRACE")

This variable is read at panic time, not at hook registration time, so it can be toggled during a session without restarting R.

πŸ”—How It Works

The hook replaces Rust’s default panic hook with a wrapper that checks MINIEXTENDR_BACKTRACE on every panic:

Panic occurs
  -> Custom hook runs
  -> Reads MINIEXTENDR_BACKTRACE env var
  -> If "true" or "1": delegates to default hook (prints backtrace to stderr)
  -> Otherwise: suppresses output (panic message still captured for R error)

The panic message itself is always captured by catch_unwind in the generated C wrapper and forwarded to R – the hook only controls whether the backtrace is additionally printed to stderr.

πŸ”—Poisoning Recovery

If the panic hook installation itself panics (e.g., take_hook fails), Once marks the state as poisoned. On the next call, the hook retries via call_once_force and prints a warning:

warning: miniextendr panic hook is retrying after a previous failed attempt

This is a defensive measure – in practice, hook installation should not fail.


πŸ”—Thread Safety

πŸ”—Main Thread Requirement

R API calls must happen on R’s main thread. Checked FFI wrappers route calls from an active miniextendr worker context back to that thread and panic for arbitrary off-main callers in every build mode. Some lower-level SEXP accessors also retain debug assertions:

#[miniextendr]
pub fn my_function() {
    // Runs directly here because default exports execute on R's main thread.
    r_println!("Hello from R!");
}

πŸ”—Thread Detection

Explicitly check thread context:

use miniextendr_api::worker::is_r_main_thread;

pub fn internal_function() {
    if is_r_main_thread() {
        r_println!("On main thread");
    } else {
        eprintln!("On worker thread");
    }
}

πŸ”—Type Conversion Errors

A bad argument is caught in one of two places: by an R-side check in the generated wrapper (the type and length checks most built-in argument types get, no_na, inherits, not_inherits, and the match_arg / choices validation), or by the Rust conversion (TryFromSexp) for what only Rust can judge, such as a non-numeric string given to AsNumeric. The arguments without an R-side type check convert in Rust directly: every custom TryFromSexp type, Either<L, R>, the AsFromStr family, and every argument whose type checks are dropped (Unchecked<T>, no_preconditions, the crate’s preconditions = false, or no-preconditions-default).

Both places raise the same error condition (#1591):

  • the class vector c("rust_error", "simpleError", "error", "condition"), preceded by the crate’s conversion_error_class when set (below), and, for a conversion whose error type implements RConditionData, by that type’s own classes first (below);
  • e$kind == "conversion";
  • e$param, the R name of the failing parameter;
  • a message in R terms. An R-side check says the one requirement that failed ('x' must have length 1, 'x' must not contain NA, 'mode' should be one of "fast", "slow"), or gives the package author’s own message for an inherits check (which also covers that parameter’s type checks), a not_inherits check or a no_na check that sets one (MINIEXTENDR_ATTRIBUTE.md). An inherits check can also pick its message by the refused value’s class: a when(class = "data.frame", message = "...") hint (MINIEXTENDR_ATTRIBUTE.md). A conversion says what the argument must be and why it is not: '<p>' must be <expected>: <reason>, or invalid '<p>' argument: <reason> for an opaque custom type without an R-facing expectation;
  • on a conversion only, e$rust_type, the Rust type as written in the signature, for the package author.

So one handler, rust_error or the crate’s class, catches every argument error, whichever side found it:

#[miniextendr(internal)]
pub fn ratio(num: AsNumeric, den: AsNumeric) -> Option<f64> { Some(num.0? / den.0?) }

#[miniextendr(internal)]
pub fn peak(dv: AsNumericVec) -> Option<f64> { dv.0.into_iter().flatten().reduce(f64::max) }
e1 <- tryCatch(ratio(c(1, 2), 3), error = identity)   # R-side length check
class(e1)
#> [1] "rust_error" "simpleError" "error" "condition"
conditionMessage(e1)
#> [1] "'num' must have length 1"
e1$param
#> [1] "num"

e2 <- tryCatch(peak(c("1", "BLQ")), error = identity)  # Rust conversion
class(e2)
#> [1] "rust_error" "simpleError" "error" "condition"
conditionMessage(e2)
#> [1] "'dv' must be numeric: non-numeric value(s): \"BLQ\" (element 2)"
e2$param
#> [1] "dv"
e2$rust_type
#> [1] "AsNumericVec"

conditionCall(e) is the same call whichever side refused the argument: the wrapper’s call as written (what stopifnot() reports), or under call = caller the caller’s call, or under call_arg the call passed as .call (CALL_ATTRIBUTION.md).

no_na on a reading marker (AsNumeric*, AsCharacter*) runs on both sides: the R guard refuses R’s NA, and the C wrapper refuses what the marker reads as missing after the conversion ("NA", a blank string, a factor NA level). On an Either, only the C wrapper checks, for the arm the value converted to (MINIEXTENDR_ATTRIBUTE.md). A handler sees one condition either way, with the same classes, e$param, message and call:

peak_no_na <- function(dv) ...   # #[miniextendr(no_na)] dv: AsNumericVec
e_r <- tryCatch(peak_no_na(c("1", NA)), error = identity)     # R guard
e_rust <- tryCatch(peak_no_na(c("1", "NA")), error = identity) # after the conversion
identical(class(e_r), class(e_rust))
#> [1] TRUE
identical(conditionCall(e_r)[[1]], conditionCall(e_rust)[[1]])
#> [1] TRUE
conditionMessage(e_rust)
#> [1] "'dv' must not contain NA"
e_rust$rust_type
#> NULL

The R-side checks are one guard per check, if (!isTRUE(<check>)) .miniextendr_arg_error("<p>", "<requirement>"), calling a helper in the generated wrappers file only when the check fails; a passing argument costs one isTRUE() test per check. A check with the author’s message passes it instead of the requirement, .miniextendr_arg_error("<p>", message = "<message>"), and the helper uses it as the condition message unchanged. The receiver check of an S7 method registered for S7::class_any (s7(fallback)) raises the same condition on x: 'x' must be an S7 object, got integer.

πŸ”—Conversion wording

The <expected> part comes from the same type table as the R-side checks (with coerce widening it the same way), and the built-in conversion errors (SexpError and its parts) give the <reason> in R terms rather than as SEXPTYPE names:

Argument type<expected>
i32 / f64a single integer / a single double (a single whole number / a single number under coerce)
f32, i8, i16, i64, isize, AsNumerica single number
u16, u32, u64, usizea single non-negative number
boolTRUE or FALSE
String, &str, char, PathBufa single string
u8 / Rcomplexa single raw value / a single complex value
Vec<T> / &[T]the R type: integer, double, numeric, logical, character, raw, complex, or integer or whole-number numeric
AsNumericVecnumeric
AsCharacter / AsCharacterVeccoercible to a single string / coercible to character
AsFromStr<T> / AsFromStrVec<T>a single string / character (what T is parsed from)
Either<L, R><expected of L> or <expected of R> (a single integer or a single string), the same whichever arm got further. An arm with no entry here is named by what its type declares (a #[derive(TryFromSexp)] newtype), else by its own error at run time (numeric for a type error, the choices of a match_arg enum: one of "fast", "slow", or a single double), else by its @param noun (a `DocId` ) when the other arm is named. An Option<T> or Result<T, ()> arm reads NULL or <expected of T>
(A, B, ...)a list of length N
DataFramea data frame
a #[derive(TryFromSexp)] newtypethe inner type’s (struct Dose(AsNumeric): a single number), declared by the derive; none for an inner type that has none
a match_arg enumnone: a choice error is worded whole, as the R-side match_arg check words it ('mode' should be one of "fast", "slow", 'mode' must be of length 1, 'mode' must be NULL or a character vector), with or without #[miniextendr(match_arg)] (#1767). As an Either arm it is named one of "fast", "slow"
HashMap, BTreeMap, NamedList, Lista list
Option<T>NULL or <expected of T>
i64, u64, isize, usize under stricta single whole number / a single non-negative whole number
an opaque custom TryFromSexp typenone: invalid '<p>' argument: <reason>
Built-in error<reason>
typegot character (expected integer, got character without an <expected>)
class (a DataFrame given a non-list, or a list that is not a data frame)got list (expected a data frame, got list without an <expected>)
lengthgot length 2 (expected length 1, got length 2 without an <expected>)
NANA is not allowed
invalid valuethe value’s own text, e.g. non-numeric value(s): "BLQ" (element 2)
Either (both branches failed)the reason of the branch that took the kind of value (got length 2, data.frame has no column names), else one for the value’s type (got numeric)
match_arg enum, as an Either armgot "zzz", got numeric, got length 2, NA is not allowed

A vector conversion checks every element and reports them together, each reason once with the 1-based positions that failed with it, as R numbers them: 'xs' must be integer or whole-number numeric: NA is not allowed (element 1); value out of range (elements 2, 4). The first 10 failing elements are listed, the rest counted (; and 5 more). The Rust type is in e$rust_type, not in the message.

The examples below are for arguments without the R-side checks (Unchecked<T>, no_preconditions, the crate’s preconditions = false, no-preconditions-default), where these values reach Rust.

πŸ”—Type mismatch

#[miniextendr(no_preconditions)]
pub fn needs_integer(x: i32) -> i32 {
    x * 2
}
needs_integer("abc")
# Error: 'x' must be a single integer: got character

e <- tryCatch(needs_integer("abc"), error = identity)
e$kind       # "conversion"
e$param      # "x"
e$rust_type  # "i32"

An Either<L, R> names both sides, and its reason is the one of the branch that got furthest; the full Rust type is in e$rust_type:

either_int_or_str(3.5)   # value: Either<i32, String>
# Error: 'value' must be a single integer or a single string: got numeric
either_int_or_str(1:2)
# Error: 'value' must be a single integer or a single string: got length 2

A list that is not a data frame is not a data frame at all, so a DataFrame arm (or parameter) refuses it by its type: 'x' must be double or a data frame: got list. An arm is refused by its kind when it fails with a type or class error, or when a match_arg enum is given a value that is not a string or factor. When both arms are refused that way, the message names both, even when the macro knows neither arm’s expectation (an opaque type, or a match_arg enum without #[miniextendr(match_arg)]):

either_route_or_number(TRUE)   # x: Either<Route, f64>
# Error: 'x' must be one of "oral", "bolus", "infusion", or numeric: got logical

A match_arg / choices parameter on Either<T, R> is decoded differently (β€œChoice or Another Value” in ENUMS_AND_FACTORS.md): the R prelude checks character or factor input against the choices, and anything else is converted to R alone. A value R refuses is refused against the whole parameter, in the words of its @param line, and R’s error gives the reason:

choices_either_level(TRUE)   # level: Either<String, f64>, choices("low", "mid", "high")
# Error: 'level' must be one of "low", "mid", "high", or a number: got logical

When one arm took the kind of value and failed later, its reason is the message’s: either_route_or_number("zzz") reads 'x' must be one of "oral", "bolus", "infusion", or a single double: got "zzz".

invalid '<p>' argument is left for a type whose expectation neither the macro nor the error knows, such as an opaque custom type, with the error’s own message (its Display text, or its RConditionData message) as the reason.

πŸ”—NA Handling

NA-rejecting scalar types such as i32 and bool report conversion errors; their Option forms preserve NA as None:

#[miniextendr(no_preconditions)]
pub fn needs_value(x: i32) -> i32 { x }

#[miniextendr]
pub fn handles_na(x: Option<i32>) -> i32 {
    x.unwrap_or(-1)
}
needs_value(NA_integer_)
# Error: 'x' must be a single integer: NA is not allowed
handles_na(NA_integer_)   # -1

πŸ”—Coercion Errors

When coercion fails:

#[miniextendr(coerce)]
pub fn needs_int(x: i32) -> i32 { x }
needs_int(1.5)   # Error: 'x' must be integer or whole-number numeric   (R-side check)
needs_int(1e20)  # Error: 'x' must be a single whole number: value out of range

πŸ”—Classed conversion errors

When the TryFromSexp::Error of an argument type implements RConditionData, a failed conversion takes that error’s class vector, message and data, the same way a Result<T, E: RConditionData> return does. kind stays "conversion", the message keeps the parameter context, and e$param and e$rust_type are added, so a package can put the conversion failures of its own argument types in its condition family:

use miniextendr_api::RCondition;
use miniextendr_api::named_vector::NamedVector;
use miniextendr_api::{SEXP, TryFromSexp};
use std::collections::BTreeMap;

pub struct Hyperparams(BTreeMap<String, f64>);

#[derive(Debug, RCondition)]
#[condition(class = "pkg_error")]
pub enum HyperparamError {
    #[condition(class = "pkg_error_bad_arg", message = "expected a named numeric vector ({reason})")]
    NotNamedNumeric { reason: String },
    #[condition(class = "pkg_error_negative", message = "hyperparameter '{param}' must be non-negative, got {value}")]
    Negative { param: String, value: f64 },
}

impl TryFromSexp for Hyperparams {
    type Error = HyperparamError;

    fn try_from_sexp(sexp: SEXP) -> Result<Self, Self::Error> {
        let NamedVector(map) = NamedVector::<BTreeMap<String, f64>>::try_from_sexp(sexp)
            .map_err(|e| HyperparamError::NotNamedNumeric { reason: e.to_string() })?;
        if let Some((param, value)) = map.iter().find(|(_, v)| **v < 0.0) {
            return Err(HyperparamError::Negative { param: param.clone(), value: *value });
        }
        Ok(Hyperparams(map))
    }
}

#[miniextendr]
pub fn hyperparams_total(hyper: Hyperparams) -> f64 {
    hyper.0.values().sum()
}
e <- tryCatch(hyperparams_total(1:3), error = identity)
class(e)
# [1] "pkg_error_bad_arg" "pkg_error" "rust_error" "simpleError" "error" "condition"
e$kind       # "conversion"
e$param      # "hyper"
e$rust_type  # "Hyperparams"
e$reason     # "invalid value: NamedVector requires a names attribute on the input vector"
conditionMessage(e)
# invalid 'hyper' argument: expected a named numeric vector (invalid value: ...)

tryCatch(hyperparams_total("a"), pkg_error = function(e) "caught by the family handler")

Rules:

  • param / rust_type precedence. When the error type’s own data() already has a field named param (or rust_type), the type’s value is kept and the framework adds none: the type author owns the fields they chose, and the parameter’s name stays in the message. Above, hyperparams_total(c(alpha = 1, beta = -2)) gives e$param == "beta" (the hyperparameter) and the message invalid 'hyper' argument: hyperparameter 'beta' must be non-negative, got -2.
  • Reserved names. data() fields must not be message, call or kind; a reserved name raises a plain rust_error explaining the clash, as for Result errors.
  • Fallback. An error type without the impl keeps the plain class vector. The built-in SexpError family and MatchArgError are reworded as in the table above; any other type is rendered with Display. The error type must implement RConditionData or Display.
  • A newtype’s refusal. The Option / Vec / Vec<Option> conversions of a #[derive(TryFromSexp)] newtype never call its own try_from_sexp, so a refusal written there would not hold for them. A newtype refuses values its inner type accepts with #[try_from_sexp(validate = path)], a check on the R value that every shape runs. It returns an RError (or any RConditionData type), which becomes SexpError::Condition: the SexpError arm above then carries its classes and fields, with its message as the reason after the prefix, also from an Either arm whose other arm refused the kind of value. For a Vec of a newtype over List (a list of R objects) the check runs on each element, and the error reports every refused element with its position, (element 2), with the classes of every refusal.
  • The whole message. An error whose RConditionData::argument_message is Some (RError::argument_message(...), #[condition(argument_message = "…")]) gives the argument error’s whole message, as an inherits(..., message = ) guard does: no prefix, the classes, kind, e$param, e$rust_type and the call as above. It holds for a type’s own TryFromSexp error and for a validate refusal under Option / Vec; inside an Either the Either words the message. In a list of objects (Vec of a newtype over List) it is the reason of the element it refused, before that element’s position, after the prefix. See EXTENDING_MINIEXTENDR.md.
  • Raising instead of returning. rust_error!(class = ...) inside a try_from_sexp raises the function’s error: its class and the call as written, but kind = "error", no e$param / e$rust_type, no crate class and no parameter in the message. Return the error, or refuse in a newtype’s check, for an argument error.
  • Worker and call = caller. The conversion runs on the main thread in both cases, so the class, data and e$param are the same; conditionCall(e) follows the function’s call attribution.
  • Sidecar setters (Type_set_<field>, and the R6 / S7 bindings over them) raise the same condition on their value formal, naming the field: '<field>' must be <expected>: <reason> ('count' must be a number: got "abc", 'tags' must be character: got integer), with e$param == "value" and the field’s Rust type as e$rust_type. The field is in the message because an R6 active binding’s or S7 property’s condition call is the binding’s own anonymous function, which does not name it.
  • Strict mode (#[miniextendr(strict)] on lossy integer types) rejects a logical, raw, fractional, out-of-range or NA input with the same condition ('n' must be a single whole number: got logical), every failing element of a vector at once. A strict return value that does not fit an R integer is still a panic (kind = "panic"): that is the function’s fault, not the argument’s.

πŸ”—A crate-level class for every conversion error

To put every argument error of a package in its condition family without an RConditionData impl on each type, name the classes once in the crate’s Cargo.toml:

[package.metadata.miniextendr]
conversion_error_class = ["pkg_error_argument", "pkg_error"]   # or one string: "pkg_error"

Every argument error of the crate then carries those classes before the rust_error layering: the Rust conversions (free functions, methods, trait methods and sidecar setters) and the R-side checks alike, so a package can document one argument-error class for its users. An error type with its own RConditionData classes keeps them first; the crate classes follow, skipping any the error already names:

# A plain SexpError (e.g. an `i32` argument without a precondition), or any
# R-side check (a length check, `no_na`, `inherits`, `not_inherits`, a bad choice):
class(tryCatch(f("a"), error = identity))
# [1] "pkg_error_argument" "pkg_error" "rust_error" "simpleError" "error" "condition"

# An error type classed c("pkg_error_negative", "pkg_error"):
class(tryCatch(g(-1L), error = identity))
# [1] "pkg_error_negative" "pkg_error" "pkg_error_argument" "rust_error" "simpleError" "error" "condition"

Without the key nothing is added. The value is a string or a single-line array of strings, set once; the entries must be non-empty and distinct and must not name rust_error, simpleError, error or condition, which every argument error already carries. The macro emits the classes into each Rust conversion arm, and miniextendr_init! registers them for the generated wrappers file, which binds them as .miniextendr_conversion_error_class for the R-side checks (NULL without the key). A conversion arm reads the manifest of the crate that defines its item, the wrappers-file binding that of the crate calling miniextendr_init!; they agree whenever the package’s #[miniextendr] items live in that crate, the usual layout. tests/cross-package/producer.pkg sets the key and tests it in test-conversion-error-class.R.

A function body raises the same argument error, with these classes, through arg_error!(param = "mode", "..."), and match_arg_param matches a raw choice argument with the wrapper’s own check and message (#1740, #1741). The Rust side sends a marker class that the wrappers file replaces with .miniextendr_conversion_error_class; see CONDITIONS.md.


πŸ”—Best Practices

πŸ”—1. Prefer Result Over Panic

// Good: Clear error handling
#[miniextendr]
pub fn parse_config(json: &str) -> Result<Config, String> {
    serde_json::from_str(json)
        .map_err(|e| format!("Invalid JSON: {}", e))
}

// Avoid: Panics hide the problem
#[miniextendr]
pub fn parse_config(json: &str) -> Config {
    serde_json::from_str(json).unwrap()  // Panic message is cryptic
}

πŸ”—2. Use Option for NA-Safe Operations

// Good: Explicit NA handling
#[miniextendr]
pub fn safe_divide(a: Option<f64>, b: Option<f64>) -> Option<f64> {
    match (a, b) {
        (Some(x), Some(y)) if y != 0.0 => Some(x / y),
        _ => None,  // Returns NA
    }
}

// Avoid: Will panic on NA
#[miniextendr]
pub fn unsafe_divide(a: f64, b: f64) -> f64 {
    a / b
}

πŸ”—3. Validate Early

#[miniextendr]
pub fn process_matrix(data: Vec<f64>, rows: i32, cols: i32) -> Vec<f64> {
    // Validate at entry point
    if rows <= 0 || cols <= 0 {
        panic!("rows and cols must be positive");
    }
    let expected_len = (rows * cols) as usize;
    if data.len() != expected_len {
        panic!("data length {} doesn't match {}x{}", data.len(), rows, cols);
    }

    // Now process with confidence
    compute(data, rows as usize, cols as usize)
}

πŸ”—4. Context in Error Messages

#[miniextendr]
pub fn load_model(path: &str) -> Result<Model, String> {
    let bytes = std::fs::read(path)
        .map_err(|e| format!("Failed to read '{}': {}", path, e))?;

    Model::from_bytes(&bytes)
        .map_err(|e| format!("Invalid model format in '{}': {}", path, e))
}

πŸ”—5. Don’t Swallow Errors

// Bad: Silent failure
#[miniextendr]
pub fn maybe_compute(x: i32) -> i32 {
    risky_operation(x).unwrap_or(0)  // Error silently becomes 0
}

// Good: Return Option to signal failure
#[miniextendr]
pub fn maybe_compute(x: i32) -> Option<i32> {
    risky_operation(x).ok()  // Caller sees NA on failure
}

// Also good: Explicit error
#[miniextendr]
pub fn compute(x: i32) -> Result<i32, String> {
    risky_operation(x).map_err(|e| e.to_string())
}

πŸ”—6. Document Error Conditions

/// Compute factorial.
///
/// @param n Non-negative integer (max 20 to avoid overflow)
/// @return n! as integer
/// @examples
/// factorial(5L)   # 120
/// factorial(-1L)  # Error
#[miniextendr]
pub fn factorial(n: i32) -> Result<i64, String> {
    if n < 0 {
        return Err("n must be non-negative".into());
    }
    if n > 20 {
        return Err("n > 20 would overflow i64".into());
    }
    Ok((1..=n as i64).product())
}

πŸ”—Error Recovery Patterns

πŸ”—Fallback Values

#[miniextendr]
pub fn safe_log(x: f64) -> f64 {
    if x <= 0.0 {
        f64::NEG_INFINITY  // Mathematical convention
    } else {
        x.ln()
    }
}

πŸ”—Partial Results

#[miniextendr]
pub fn parse_numbers(strings: Vec<String>) -> Vec<Option<f64>> {
    strings.iter()
        .map(|s| s.parse().ok())  // Failed parses become None (NA in R)
        .collect()
}

πŸ”—Cleanup on Error

#[miniextendr]
pub fn transactional_write(path: &str, data: &[u8]) -> Result<(), String> {
    let temp_path = format!("{}.tmp", path);

    // Write to temp file first
    std::fs::write(&temp_path, data)
        .map_err(|e| format!("Write failed: {}", e))?;

    // Atomic rename - if this fails, temp file is left for debugging
    std::fs::rename(&temp_path, path)
        .map_err(|e| {
            // Clean up temp file on rename failure
            let _ = std::fs::remove_file(&temp_path);
            format!("Rename failed: {}", e)
        })
}

πŸ”—Debugging Tips

πŸ”—Enable Backtraces

See Panic Hook and Backtraces above for full details.

Sys.setenv(MINIEXTENDR_BACKTRACE = "1")
my_function_that_panics()

πŸ”—Nested Panic Location

Panics preserve the original location:

fn inner_function() {
    panic!("Inner error");  // This location is reported
}

fn middle_function() {
    inner_function();
}

#[miniextendr]
pub fn outer_function() {
    middle_function();  // Error shows inner_function location
}

Use R’s console for visibility:

#[miniextendr]
pub fn debug_function(x: i32) -> i32 {
    r_println!("Input: {}", x);
    let result = complex_computation(x);
    r_println!("Result: {}", result);
    result
}

Or Rust’s stderr (visible in R console):

#[miniextendr]
pub fn debug_function(x: i32) -> i32 {
    eprintln!("[Rust] Input: {}", x);
    // ...
}

πŸ”—Common Pitfalls

πŸ”—1. Panicking in Drop

Avoid panicking in destructors - it can cause double-panic abort:

// Bad
impl Drop for MyResource {
    fn drop(&mut self) {
        if self.is_invalid() {
            panic!("Resource in bad state!");  // Don't do this
        }
    }
}

// Good
impl Drop for MyResource {
    fn drop(&mut self) {
        if self.is_invalid() {
            eprintln!("Warning: dropping invalid resource");
        }
    }
}

πŸ”—2. Calling R from Wrong Thread

// Bad: Crashes or causes undefined behavior
std::thread::spawn(|| {
    r_println!("Hello!");  // R API call from wrong thread
});

// Good: keep arbitrary spawned threads Rust-only
std::thread::spawn(|| {
    eprintln!("Hello!");  // Rust-only output is safe
});

// For miniextendr's opt-in worker, route R work to the main thread:
#[miniextendr(worker)]
fn routed_output() {
    with_r_thread(|| r_println!("Hello!"));
}

πŸ”—3. Ignoring Result

// Bad: Error silently ignored
#[miniextendr]
pub fn risky() {
    let _ = fallible_operation();  // What if this failed?
}

// Good: Handle or propagate
#[miniextendr]
pub fn risky() -> Result<(), String> {
    fallible_operation()?;
    Ok(())
}

πŸ”—Known Limitations

  • Spawned-thread panics cannot be cleanly propagated through extern "C-unwind" boundaries. Convert thread errors to Result instead of using resume_unwind. See GAPS.md.
  • Thread safety debug assertions for direct SEXP access only run in debug builds. Checked FFI wrappers route through with_r_thread in all build modes. See GAPS.md.

See GAPS.md for the full catalog of known limitations.


πŸ”—See Also