Reference page
Safety Documentation
This document explains the thread safety invariants and FFI safety requirements for miniextendr. Read this before contributing unsafe code or modifying the worker, thread, or unwind_protect modules.
This document explains the thread safety invariants and FFI safety requirements for miniextendr. Read this before contributing unsafe code or modifying the worker, thread, or unwind_protect modules.
πOverview
miniextendr interfaces with Rβs C API, which has several constraints:
- R is single-threaded - Most R APIs must be called from the main thread
- R uses longjmp - R errors bypass Rust destructors unless handled
- R has its own GC - SEXP objects can be collected if not protected
miniextendr provides abstractions to handle all three safely.
πThread Model
πDefault: Main Thread with R_UnwindProtect
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β R Main Thread β
β βββ R_init_<pkgname>() calls miniextendr_runtime_init() β
β βββ .Call() entry points run on this thread β
β βββ User Rust code runs inline via with_r_unwind_protect β
β βββ catch_unwind catches Rust panics β
β βββ R_UnwindProtect catches R longjmps β
β βββ All R API calls happen here (no thread hop) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββπOptional: Worker Thread (with worker-thread feature)
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β R Main Thread β
β βββ .Call() entry points run on this thread β
β βββ All R API calls must happen here β
β β
β Worker Thread (spawned by miniextendr_runtime_init) β
β βββ User Rust code runs here via run_on_worker() β
β βββ Panics are caught, converted to R errors β
β βββ Uses with_r_thread() to call R APIs β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββπHow Panics Are Caught
Rβs longjmp-based error handling bypasses Rust destructors. miniextendr uses
R_UnwindProtect on the main thread to catch both:
catch_unwindcatches Rust panics, allowing destructors to runR_UnwindProtectcatches R longjmps (e.g.,Rf_error), runs cleanup- Errors are converted to R errors after Rust cleanup completes
With the worker-thread feature, the same safety is achieved via bidirectional
channels: user code runs on the worker, catch_unwind catches panics, and
with_r_thread routes R API calls to the main thread inside R_UnwindProtect.
πThread Identification
// worker.rs
static R_MAIN_THREAD_ID: OnceLock<thread::ThreadId> = OnceLock::new();
pub fn is_r_main_thread() -> bool {
R_MAIN_THREAD_ID
.get()
.map(|&id| id == std::thread::current().id())
.unwrap_or(false) // Safe default: assume NOT main thread
}
Invariant: R_MAIN_THREAD_ID is set exactly once, from the main thread,
during miniextendr_runtime_init(). Any call before initialization returns
false (safe default - prevents R API calls from wrong thread).
πSendable Wrappers
πSendable<T>
// worker.rs
#[repr(transparent)]
pub struct Sendable<T>(pub T);
unsafe impl<T> Send for Sendable<T> {}
Why itβs safe: Sendable is used to transfer owned data between threads.
The type system ensures:
- The value is moved into
Sendableon one thread - Transmitted to another thread via channels
- Extracted and used exclusively on the destination thread
The data is never accessed concurrently - ownership transfers completely.
Use cases:
- Sending raw pointers for R API calls (
SendablePtr<T>) - Sending allocation results back to callers (
SendableDataPtr) - With
worker-thread: sending closures to the main thread (MainThreadWork)
πSendablePtr<T> (externalptr.rs)
type SendablePtr<T> = Sendable<NonNull<T>>;
Used to send pointer addresses between threads. The pointed-to data is only accessed on the main thread after the pointer arrives.
πSendableDataPtr (allocator.rs)
type SendableDataPtr = Sendable<*mut u8>;
Similar to SendablePtr but allows null (for allocation failures).
πExternalPtr Thread Safety
ExternalPtr<T> is not Send or Sync because:
- The underlying SEXP is an R object that should only be accessed on the main thread
- Rβs finalizer registration (
R_RegisterCFinalizerEx) must happen on main thread - The data pointer can become invalid if R garbage collects the SEXP
Safe pattern: Create ExternalPtr on main thread, return to R. Access only
via .Call entry points (which run on main thread).
πR_UnwindProtect
R errors use longjmp, which bypasses Rust destructors. R_UnwindProtect
provides a cleanup callback that runs before the longjmp:
// unwind_protect.rs
R_UnwindProtect_C_unwind(
Some(trampoline), // Code to run
data.cast(), // Data for trampoline
Some(cleanup_handler), // Cleanup on longjmp
data.cast(), // Data for cleanup
token, // Continuation token
)
miniextendrβs approach (main thread, default):
- Wrap user code in
catch_unwind(catches Rust panics) - Run via
R_UnwindProtect(catches R longjmps) - If R error: cleanup handler runs, then
R_ContinueUnwindcompletes Rβs error handling - If Rust panic: error message is extracted and converted to an R error
With worker-thread feature (in run_on_worker):
- Wrap user code in
catch_unwindon the worker thread - R API calls route through
with_r_threadβR_UnwindProtecton main thread - If R error: cleanup handler sends error message to worker, then panics
- Worker thread catches the panic, drops resources, sends
Done(Err(...)) - Main thread calls
R_ContinueUnwindto let R complete its error handling
Key invariant: The cleanup handler must not block. It sends an error message
and panics immediately so catch_unwind can catch it.
πContinuation Token
// unwind_protect.rs
static R_CONTINUATION_TOKEN: OnceLock<SEXP> = OnceLock::new();
A single global token (created via R_MakeUnwindCont, preserved with
R_PreserveObject) is used for all unwind operations. This avoids leaking
one token per thread.
Invariant: The token is created on first use on the main thread and remains valid for the entire R session.
πStack Checking (nonapi feature)
R tracks stack bounds to detect overflow:
R_CStackStart- top of main threadβs stackR_CStackLimit- stack size limitR_CStackDir- growth direction
On non-main threads, these values are invalid. StackCheckGuard disables
checking by setting R_CStackLimit = usize::MAX:
// thread.rs
impl StackCheckGuard {
pub fn disable() -> Self {
let prev_count = STACK_GUARD_COUNT.fetch_add(1, Ordering::SeqCst);
if prev_count == 0 {
let original = get_r_cstack_limit();
ORIGINAL_STACK_LIMIT.store(original, Ordering::SeqCst);
unsafe { set_r_cstack_limit(usize::MAX); }
}
Self { _private: () }
}
}
Invariant: Uses atomic refcounting so multiple concurrent guards work correctly. Only the last guard to drop restores the original limit.
πAllocator Safety
The R-backed allocator (allocator.rs) has special requirements:
- Main thread only: Calls
Rf_allocVectorwhich must run on main thread - Thread routing: Uses
with_r_thread_or_inlineβ runs inline on main thread, routes viawith_r_threadif worker context exists, panics otherwise - No fallback: Panics if called from arbitrary thread without worker context
fn with_r_thread_or_inline<R, F>(f: F) -> R {
if is_r_main_thread() {
f()
} else if has_worker_context() {
with_r_thread(f)
} else {
panic!("R allocator called from non-main thread without worker context");
}
}
longjmp warning: Rf_allocVector can longjmp on allocation failure. The
allocator is safe when used inside run_on_worker (which has unwind protection)
but can cause issues in other contexts.
πFFI Function Categories
All non-variadic functions in ffi.rs marked with #[r_ffi_checked] behave
identically: they are routed to the main thread via with_r_thread when called
from the worker thread. The return value is wrapped in Sendable and sent back
to the caller. This applies to both value-returning functions
(Rf_ScalarInteger, Rf_allocVector) and pointer-returning functions
(INTEGER, REAL, DATAPTR).
Pointer-returning functions are safe to route because the underlying SEXP must
be GC-protected by the caller, and Rβs GC only runs during R API calls which
are serialized through with_r_thread.
By default (main thread execution), all checked wrappers run inline. With the
worker-thread feature, they route through with_r_thread.
Without the worker-thread feature, calling checked wrappers from a non-main
thread panics (there is no routing infrastructure to fall back on).
πInitialization Requirements
miniextendr_runtime_init() must be called before any R API use:
void R_init_pkgname(DllInfo *dll) {
miniextendr_runtime_init(); // First!
R_registerRoutines(dll, NULL, CallEntries, NULL, NULL);
}
This function:
- Records
R_MAIN_THREAD_IDfor thread checks - With
worker-threadfeature: spawns the worker thread and sets up channels - Without
worker-thread: only records the thread ID (no thread spawned)
Invariant: Must be called from the main thread. Calling from another thread will cause all subsequent thread checks to be incorrect.
πSummary of Invariants
| Component | Invariant |
|---|---|
R_MAIN_THREAD_ID | Set once, from main thread, during init |
Sendable<T> | Value moved, not shared; accessed only at destination |
ExternalPtr<T> | Not Send/Sync; main thread only |
AltrepSexp | !Send + !Sync; materialization on main thread only |
SEXP (via TryFromSexp) | ALTREP auto-materialized before function body runs |
R_CONTINUATION_TOKEN | Created once, preserved for session lifetime |
StackCheckGuard | Atomic refcount; last drop restores limit |
| Allocator | Main thread or worker context only |
| Pointer APIs | Main thread only; panic otherwise |
πReporting Safety Issues
If you discover a soundness issue in miniextendr, please report it via
GitHub Issues with the
[SAFETY] tag.