miniextendr_api/worker.rs
1//! Worker thread infrastructure for safe Rust-R FFI.
2//!
3//! **Deprecated** (#2117): the worker thread (`#[miniextendr(worker)]`, the
4//! `worker-thread` and `worker-default` features) won't be developed further.
5//! It keeps working, but its known problems are not planned: #989, #1425,
6//! #2076, #2109. An explicit `worker` option warns where it's written
7//! (through the hidden `WORKER_OPTION`). [`with_r_thread`] and
8//! [`is_r_main_thread`] are not deprecated; the checked FFI layer uses them
9//! on every build.
10//!
11//! ## Why a worker thread at all?
12//!
13//! R's error handling uses `longjmp`, which skips Rust destructors and leaks
14//! resources. Generated wrappers therefore establish a destructor-safe error
15//! boundary. The default boundary runs inline on R's main thread inside
16//! `R_UnwindProtect`; `#[miniextendr(worker)]` (or the `worker-default`
17//! feature) instead dispatches the Rust body to a dedicated worker.
18//!
19//! On the worker-dispatch path, **user code is off the R main thread**.
20//! Merely enabling the `worker-thread` infrastructure feature does not select
21//! that path. Anything in an opted-in worker body that calls R's C API
22//! (allocating `SEXP`s, walking attributes, accessing `INTEGER(x)`) must cross
23//! back to main via [`with_r_thread`].
24//!
25//! ## Public API
26//!
27//! - [`with_r_thread`] — Execute a closure on R's main thread. This is the
28//! bridge: call it from inside a `#[miniextendr]` body whenever you need
29//! to touch the R FFI.
30//! - [`is_r_main_thread`] — Check if the current thread is R's main thread.
31//! - [`Sendable`] — `#[doc(hidden)]` wrapper used by the macros to ferry
32//! `SEXP` (and other `!Send` types) across the worker channel. The author
33//! asserts the value is only consumed on the main thread.
34//!
35//! ## Tradeoffs
36//!
37//! - **Default to checked FFI variants** (`Rf_allocVector`, `INTEGER`, …) so
38//! an active worker call routes correctly and an arbitrary off-thread call
39//! fails instead of reaching R.
40//! - **Inside a [`with_r_thread`] body, the check is redundant** — the
41//! `*_unchecked` variants in [`crate::sys`] are safe to call there
42//! (recognised by the lint **MXL301**, alongside ALTREP callbacks and
43//! [`crate::unwind_protect::with_r_unwind_protect`] bodies).
44//! - **Don't raise R errors directly** from worker-thread code. `Rf_error`
45//! would longjmp through Rust frames on the wrong thread. Panic instead;
46//! the framework converts the panic into a structured R condition (see
47//! [`crate::error_value`]). The lint **MXL300** enforces this.
48//!
49//! ## Feature gate: `worker-thread`
50//!
51//! Without the `worker-thread` cargo feature, all calls execute inline on
52//! R's main thread:
53//! - `with_r_thread(f)` runs `f()` directly (panics if not on main thread)
54//! - `run_on_worker(f)` runs `f()` directly, returns `Ok(f())`
55//!
56//! With the feature enabled, a dedicated worker thread is spawned at init time.
57//! `with_r_thread` routes calls from the worker back to main, and `run_on_worker`
58//! dispatches closures to the worker with bidirectional communication. The
59//! worker has a 16 MB stack — keep `proptest!` invocations on it modest (see
60//! the project `CLAUDE.md`).
61//!
62//! ## Initialization
63//!
64//! [`miniextendr_runtime_init`] must be called from R's main thread before any
65//! R FFI APIs. Typically done in `R_init_<pkgname>()`.
66//!
67//! ## Cross references
68//!
69//! - [`crate::unwind_protect::with_r_unwind_protect`] — catch R errors with
70//! Rust cleanup; sibling to `with_r_thread`.
71//! - [`crate::sys`] — checked vs `*_unchecked` FFI surface.
72//! - [`crate::ffi_guard`] — guard taxonomy across boundaries.
73
74use std::sync::OnceLock;
75use std::thread;
76
77use crate::SEXP;
78use crate::sys::{self};
79
80static R_MAIN_THREAD_ID: OnceLock<thread::ThreadId> = OnceLock::new();
81
82// region: Public API
83
84/// Named by the code an explicit `#[miniextendr(worker)]` option expands to,
85/// so rustc warns at the option (#2117). `#![allow(deprecated)]` on the
86/// module silences it.
87#[doc(hidden)]
88#[deprecated(
89 note = "the worker thread (`#[miniextendr(worker)]`, the `worker-thread` / `worker-default` features) is deprecated and won't be developed further; its known problems are not planned: #989, #1425, #2076, #2109 (https://github.com/A2-ai/miniextendr/issues/2117)"
90)]
91pub const WORKER_OPTION: () = ();
92
93/// Wrapper to mark values as Send for main-thread routing.
94///
95/// Only safe if the value is not accessed on the worker thread and is
96/// used exclusively on the main thread.
97#[doc(hidden)]
98#[repr(transparent)]
99#[derive(Clone, Copy)]
100pub struct Sendable<T>(pub T);
101
102unsafe impl<T> Send for Sendable<T> {}
103
104/// Check if the current thread is R's main thread.
105///
106/// Returns `true` if called from the main R thread, `false` otherwise.
107/// Before `miniextendr_runtime_init()` is called, always returns `false`.
108#[inline(always)]
109pub fn is_r_main_thread() -> bool {
110 R_MAIN_THREAD_ID
111 .get()
112 .map(|&id| id == std::thread::current().id())
113 .unwrap_or(false)
114}
115
116/// Execute a closure on R's main thread, returning the result.
117///
118/// This function executes successfully in two contexts:
119/// - From the main thread: executes the closure directly (re-entrant)
120/// - From the worker thread (during `run_on_worker`): sends the work to
121/// the main thread and blocks until completion
122///
123/// Calls from arbitrary spawned or Rayon threads panic; there is no active
124/// main-thread event loop to receive their work.
125///
126/// The "main thread" the closure runs on is whichever thread called
127/// [`run_on_worker`]. Per the [`run_on_worker`] contract, that must be
128/// the R main thread (the thread that ran `miniextendr_runtime_init()`);
129/// otherwise R API calls inside the closure happen on the wrong thread.
130///
131/// # Panics
132///
133/// - If `miniextendr_runtime_init()` hasn't been called yet
134/// - If called from a non-main thread without the `worker-thread` feature
135/// - If called from a non-main thread outside of a `run_on_worker` context
136/// (even with the `worker-thread` feature)
137///
138/// # Example
139///
140/// ```ignore
141/// use miniextendr_api::with_r_thread;
142///
143/// // From worker thread, safely call R APIs:
144/// let sexp = with_r_thread(|| {
145/// // This runs on R's main thread
146/// SEXP::nil()
147/// });
148/// ```
149pub fn with_r_thread<F, R>(f: F) -> R
150where
151 F: FnOnce() -> R + 'static,
152 R: Send + 'static,
153{
154 assert_runtime_initialized();
155
156 if is_r_main_thread() {
157 return f();
158 }
159
160 // Not on main thread — need worker-thread routing (absent on wasm, where
161 // there is only the single R thread, so this branch is unreachable there).
162 #[cfg(not(all(feature = "worker-thread", not(target_family = "wasm"))))]
163 {
164 panic!(
165 "with_r_thread called from a non-main thread without the `worker-thread` feature.\n\
166 \n\
167 Without `worker-thread`, R API calls can only happen on the R main thread.\n\
168 Either:\n\
169 - Enable the `worker-thread` cargo feature to route calls from background threads, or\n\
170 - Ensure this code only runs on the R main thread."
171 );
172 }
173
174 #[cfg(all(feature = "worker-thread", not(target_family = "wasm")))]
175 {
176 worker_channel::route_to_main_thread(f)
177 }
178}
179// endregion
180
181// region: #[doc(hidden)] items for macro-generated code
182
183/// Raise an R error from a panic message. Does not return.
184///
185/// If `call` is `Some(sexp)`, uses `Rf_errorcall` to include call context.
186#[doc(hidden)]
187pub fn panic_message_to_r_error(msg: String, call: Option<SEXP>) -> ! {
188 let c_msg = std::ffi::CString::new(msg)
189 .unwrap_or_else(|_| std::ffi::CString::new("Rust panic (invalid message)").unwrap());
190 unsafe {
191 match call {
192 Some(call) => sys::Rf_errorcall_unchecked(call, c"%s".as_ptr(), c_msg.as_ptr()),
193 None => sys::Rf_error_unchecked(c"%s".as_ptr(), c_msg.as_ptr()),
194 }
195 }
196}
197
198/// Run a closure on the worker thread with proper cleanup on panic.
199///
200/// Returns `Ok(T)` on success, `Err(String)` if the closure panicked.
201/// The caller handles the error (either tagged error value or `Rf_errorcall`).
202///
203/// Without the `worker-thread` feature, runs inline on the current thread.
204///
205/// # Precondition: caller must be the R main thread
206///
207/// The main-thread event loop that drives [`with_r_thread`] callbacks
208/// runs on whatever thread invokes `run_on_worker`. R API calls fired
209/// from inside the closure are routed back to *that* thread — so if
210/// the caller isn't the R main thread, the callbacks land on the
211/// wrong thread silently.
212///
213/// In normal usage this contract is satisfied automatically:
214/// `#[miniextendr]` entry points are reached via `.Call`, which is
215/// always on R's main thread. Calling `run_on_worker` from a
216/// Rust-spawned thread is a programming error.
217///
218/// In debug builds the precondition is asserted via `debug_assert!`.
219/// Release builds skip the check (one fewer atomic load per dispatch);
220/// the `.Call` invariant is relied on instead.
221#[doc(hidden)]
222pub fn run_on_worker<F, T>(f: F) -> Result<T, String>
223where
224 F: FnOnce() -> T + Send + 'static,
225 T: Send + 'static,
226{
227 // On wasm there is no worker thread even when the feature is enabled
228 // (R-on-wasm is single-threaded; emscripten has no usable pthreads), so we
229 // run inline — identical to a non-`worker-thread` build. The feature stays
230 // *enabled* on wasm so worker-gated routines still compile and the
231 // pre-generated `wasm_registry.rs` (built with the feature) has no dangling
232 // entries. See `worker_active` reasoning at the top of the worker functions.
233 //
234 // The inline path must uphold the same panic contract as the worker path:
235 // a panicking closure yields `Err(message)`, never an unwind out of
236 // `run_on_worker` (on the worker path the channel boundary guarantees
237 // this). Without the catch, a panic here unwinds out of raw
238 // `extern "C-unwind"` `.Call` entry points that rely on the Err contract —
239 // under webR's wasm-exception unwinding that escapes R entirely and
240 // reaches the JS host as an uncaught `WebAssembly.Exception`, killing the
241 // session (observed in tier-3 via `unsafe_C_test_worker_panic_simple`).
242 // The message rules mirror `worker_channel::fold_panic_message`:
243 // `RCondition` payloads stringify verbatim, generic panics fold in the
244 // recorded `(at file:line)` location — same thread, so the take-once
245 // location slot is valid here too.
246 #[cfg(not(all(feature = "worker-thread", not(target_family = "wasm"))))]
247 {
248 let deferred_mark = crate::deferred_condition::mark();
249 let boundary = crate::unwind_protect::Boundary::enter();
250 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
251 Ok(val) => Ok(val),
252 Err(payload) => {
253 // An R exit carried out of an evaluation in an inline
254 // `with_r_thread` callback (#1835) continues to its R target,
255 // as it does from the worker path's main-thread loop.
256 // SAFETY: inline means R's main thread, inside the `.Call()`.
257 let payload = unsafe {
258 crate::unwind_protect::resume_if_r_unwind(payload, deferred_mark, &boundary)
259 };
260 drop(boundary);
261 let msg = if payload.is::<crate::condition::RCondition>() {
262 crate::unwind_protect::panic_payload_to_string(payload.as_ref()).into_owned()
263 } else {
264 crate::unwind_protect::panic_message_with_location(payload.as_ref())
265 };
266 crate::panic_telemetry::fire(&msg, crate::panic_telemetry::PanicSource::Worker);
267 Err(msg)
268 }
269 }
270 }
271
272 #[cfg(all(feature = "worker-thread", not(target_family = "wasm")))]
273 {
274 let result = worker_channel::dispatch_to_worker(f);
275 if let Err(ref msg) = result {
276 crate::panic_telemetry::fire(msg, crate::panic_telemetry::PanicSource::Worker);
277 }
278 result
279 }
280}
281
282/// Initialize the miniextendr runtime.
283///
284/// Records the main thread ID and (with `worker-thread`) spawns the worker.
285/// Must be called from R's main thread, typically from `R_init_<pkgname>`.
286#[doc(hidden)]
287#[unsafe(no_mangle)]
288pub extern "C-unwind" fn miniextendr_runtime_init() {
289 static RUN_ONCE: std::sync::Once = std::sync::Once::new();
290
291 // wasm: never spawn a worker (single-threaded; spawning traps at load).
292 // Falls through to the inline init path below even with the feature on.
293 #[cfg(all(feature = "worker-thread", not(target_family = "wasm")))]
294 {
295 RUN_ONCE.call_once_force(|x| {
296 if x.is_poisoned() {
297 eprintln!(
298 "warning: miniextendr worker init is retrying after a previous failed attempt"
299 );
300 }
301
302 let current_id = std::thread::current().id();
303 if let Some(&existing_id) = R_MAIN_THREAD_ID.get() {
304 if existing_id != current_id {
305 panic!(
306 "miniextendr_runtime_init called from thread {:?}, but R_MAIN_THREAD_ID \
307 was already set to {:?}. This indicates incorrect initialization order.",
308 current_id, existing_id
309 );
310 }
311 } else {
312 let _ = R_MAIN_THREAD_ID.set(current_id);
313 }
314
315 worker_channel::init_worker();
316
317 // NB: no libc `atexit` registration.
318 //
319 // `atexit` stores a function pointer into the DLL's code. If the
320 // package is unloaded (e.g. via `library.dynam.unload` / dyn.unload
321 // / devtools::load_all(reset = TRUE)) before libc runs its atexit
322 // registry at process exit, the handler jumps to an unmapped
323 // address and tears down the process's SEH state on Windows —
324 // which manifests as "fatal runtime error: failed to initiate
325 // panic, error 5" in the next DLL that tries to unwind. #277.
326 //
327 // The normal path (package unload → `R_unload_<pkg>` →
328 // `miniextendr_runtime_shutdown`) already joins the worker
329 // cleanly. The abnormal path (process exit without unload, e.g.
330 // `q("no")`) relies on the OS to reap the worker thread, which
331 // it does — we don't need graceful shutdown for a dying process.
332 });
333 }
334
335 #[cfg(not(all(feature = "worker-thread", not(target_family = "wasm"))))]
336 {
337 RUN_ONCE.call_once(|| {
338 let _ = R_MAIN_THREAD_ID.set(std::thread::current().id());
339 });
340 }
341}
342
343/// Shut down the miniextendr worker thread synchronously.
344///
345/// Called from `R_unload_<pkg>` (generated by `miniextendr_init!`). Sends a
346/// `Shutdown` message to the worker, drops the sender, and blocks on
347/// `JoinHandle::join()` until the worker thread has fully exited. Must block:
348/// `library.dynam.unload` unmaps the DLL's code pages as soon as this returns,
349/// and a still-live worker would resume execution in freed memory (see #277).
350///
351/// Idempotent. After the first call, the join handle is taken and subsequent
352/// calls are no-ops. Safe to call from any thread, though R only ever calls it
353/// from the main thread.
354///
355/// Additionally uninstalls the process panic hook that this DLL registered
356/// (also in DLL code — see `backtrace::miniextendr_panic_hook_uninstall`).
357///
358/// Without the `worker-thread` feature this is (mostly) a no-op: only the
359/// panic hook uninstall runs.
360#[doc(hidden)]
361#[unsafe(no_mangle)]
362pub extern "C-unwind" fn miniextendr_runtime_shutdown() {
363 // wasm never spawns a worker, so there is nothing to join (and the channel
364 // was never initialised) — skip shutdown there even with the feature on.
365 #[cfg(all(feature = "worker-thread", not(target_family = "wasm")))]
366 {
367 worker_channel::shutdown();
368 }
369 crate::backtrace::miniextendr_panic_hook_uninstall();
370}
371// endregion
372
373// region: pub(crate) internals
374
375/// Check whether the current thread has a worker routing context.
376pub(crate) fn has_worker_context() -> bool {
377 #[cfg(all(feature = "worker-thread", not(target_family = "wasm")))]
378 {
379 worker_channel::has_context()
380 }
381 #[cfg(not(all(feature = "worker-thread", not(target_family = "wasm"))))]
382 {
383 false
384 }
385}
386
387/// Panic if the runtime hasn't been initialized.
388fn assert_runtime_initialized() {
389 if R_MAIN_THREAD_ID.get().is_none() {
390 panic!(
391 "miniextendr_runtime_init() must be called before using R FFI APIs.\n\
392 \n\
393 This is typically done in R_init_<pkgname>() via:\n\
394 \n\
395 void R_init_pkgname(DllInfo *dll) {{\n\
396 miniextendr_runtime_init();\n\
397 R_registerRoutines(dll, NULL, CallEntries, NULL, NULL);\n\
398 }}\n\
399 \n\
400 If you're embedding R in Rust, call miniextendr_runtime_init() from the main thread \
401 before any R API calls."
402 );
403 }
404}
405// endregion
406
407// region: Worker channel infrastructure (only with worker-thread feature)
408//
409// Compiled only on non-wasm worker-thread builds. On wasm the feature stays
410// enabled (so worker-gated routines compile and `wasm_registry.rs` matches),
411// but every caller above takes the inline path, so this module is omitted to
412// avoid dead-code and to keep the single-threaded wasm side-module clean.
413
414#[cfg(all(feature = "worker-thread", not(target_family = "wasm")))]
415mod worker_channel {
416 use std::any::Any;
417 use std::cell::RefCell;
418 use std::panic::{AssertUnwindSafe, catch_unwind};
419 use std::sync::Mutex;
420 use std::sync::mpsc::{self, Receiver, SyncSender};
421 use std::thread;
422
423 use super::Sendable;
424 use crate::sys;
425 use crate::{Rboolean, SEXP};
426
427 type AnyJob = Box<dyn FnOnce() + Send>;
428
429 /// Tagged messages on the main→worker channel.
430 ///
431 /// Plain `Box<AnyJob>` transport with an atomic shutdown flag + `recv_timeout`
432 /// polling used to sit here. That shape left the worker asleep in
433 /// `recv_timeout` when `R_unload_<pkg>` fired, so `library.dynam.unload`
434 /// unmapped the DLL's code pages while the worker was still about to wake
435 /// up inside them — producing the "failed to initiate panic, error 5"
436 /// SEH corruption documented in #277.
437 ///
438 /// `Shutdown` is a proper message instead: the worker blocks on `recv()`
439 /// (no timeout, no polling), `shutdown()` delivers the message, and
440 /// `recv()` returns immediately. Combined with dropping the sender and
441 /// a blocking `JoinHandle::join()`, this makes `R_unload_<pkg>`
442 /// synchronous — the DLL can't unmap until the worker thread has truly
443 /// exited.
444 enum WorkerMsg {
445 Job(AnyJob),
446 Shutdown,
447 }
448
449 /// Single place that owns the worker's lifetime.
450 ///
451 /// `Mutex<Option<...>>` (rather than `OnceLock`) because `shutdown()`
452 /// needs to `.take()` both the sender (to drop it, closing the channel as
453 /// a second-path wake-up) and the join handle (to `.join()` it). After
454 /// `shutdown()`, `dispatch_to_worker` sees `None` and returns a
455 /// structured "worker shut down" error instead of relying on the old
456 /// `send` returning `SendError`.
457 struct WorkerState {
458 tx: SyncSender<WorkerMsg>,
459 handle: thread::JoinHandle<()>,
460 }
461
462 static WORKER: Mutex<Option<WorkerState>> = Mutex::new(None);
463
464 /// Shut the worker down synchronously.
465 ///
466 /// Send `Shutdown`, drop the sender (so `recv()` returns `Err` even if
467 /// the `Shutdown` message is somehow missed — defense in depth), then
468 /// block on `JoinHandle::join()`. No timeout: if the worker wedges, we
469 /// want the hang to surface directly rather than mask it with an
470 /// arbitrary deadline that then races DLL unmap. Idempotent — after the
471 /// first call, `WORKER` is `None` and the function is a no-op.
472 pub(super) fn shutdown() {
473 let Some(state) = WORKER.lock().unwrap().take() else {
474 return;
475 };
476 // If the worker already exited (e.g. it panicked), `send` errors —
477 // the drop below is what matters in that case.
478 let _ = state.tx.send(WorkerMsg::Shutdown);
479 drop(state.tx);
480 // We're being called from `R_unload_<pkg>`, which is `extern
481 // "C-unwind"` — so a panic here would unwind through R's dyn.unload
482 // handler. If the worker itself panicked in a way we can't catch,
483 // logging the payload and continuing is safer than re-raising
484 // across the R FFI boundary. We still join (unwrap the Err) so the
485 // handle is consumed and OS thread resources are released.
486 if let Err(payload) = state.handle.join() {
487 let msg = crate::unwind_protect::panic_payload_to_string(&*payload);
488 eprintln!("miniextendr: worker thread panicked during shutdown: {msg}");
489 }
490 }
491
492 // Type-erased main thread work: closure that returns boxed result
493 type MainThreadWork = Sendable<Box<dyn FnOnce() -> Box<dyn Any + Send> + 'static>>;
494
495 // Response from main thread: Ok(result) or Err(panic_message)
496 type MainThreadResponse = Result<Box<dyn Any + Send>, String>;
497
498 /// Messages from worker to main thread
499 enum WorkerMessage<T> {
500 /// Worker requests main thread to execute some work, then send response back
501 WorkRequest(MainThreadWork),
502 /// Worker is done, here's the final result
503 Done(Result<T, String>),
504 }
505
506 type TypeErasedWorkerMessage = WorkerMessage<Box<dyn Any + Send>>;
507 type WorkerToMainSender = RefCell<Option<SyncSender<TypeErasedWorkerMessage>>>;
508 type MainResponseReceiver = RefCell<Option<Receiver<MainThreadResponse>>>;
509
510 // Thread-local channels for worker -> main communication during run_on_worker
511 thread_local! {
512 static WORKER_TO_MAIN_TX: WorkerToMainSender = const { RefCell::new(None) };
513 static MAIN_RESPONSE_RX: MainResponseReceiver = const { RefCell::new(None) };
514 }
515
516 pub(super) fn has_context() -> bool {
517 WORKER_TO_MAIN_TX.with(|tx_cell| tx_cell.borrow().is_some())
518 }
519
520 /// Route a closure from the worker thread to the main thread.
521 pub(super) fn route_to_main_thread<F, R>(f: F) -> R
522 where
523 F: FnOnce() -> R + 'static,
524 R: Send + 'static,
525 {
526 WORKER_TO_MAIN_TX.with(|tx_cell| {
527 let tx = tx_cell
528 .borrow()
529 .as_ref()
530 .expect("`with_r_thread` called outside of `run_on_worker` context")
531 .clone();
532
533 let work: MainThreadWork =
534 Sendable(Box::new(move || Box::new(f()) as Box<dyn Any + Send>));
535
536 tx.send(WorkerMessage::WorkRequest(work))
537 .expect("main thread channel closed");
538 });
539
540 MAIN_RESPONSE_RX.with(|rx_cell| {
541 let rx = rx_cell.borrow();
542 let rx = rx.as_ref().expect("response channel not set");
543 let response = rx.recv().expect("main thread response channel closed");
544 match response {
545 Ok(boxed) => *boxed
546 .downcast::<R>()
547 .expect("type mismatch in `with_r_thread` response"),
548 Err(panic_msg) => {
549 // `panic_msg` is already final: the main-thread stringify
550 // point in `dispatch_to_worker` (below) folded the *true*
551 // origin location into it before sending it back over the
552 // channel. Re-panicking here (needed to unwind out of
553 // `run_on_worker`) must NOT let this thread's panic hook
554 // fold ITS OWN call site (this line, in `worker.rs`) on
555 // top — wrap in `PreLocatedPanic` so the catch in
556 // `dispatch_to_worker` uses the message verbatim (#1245).
557 std::panic::panic_any(crate::unwind_protect::PreLocatedPanic(format!(
558 "panic in `with_r_thread`: {panic_msg}"
559 )))
560 }
561 }
562 })
563 }
564
565 /// Stringify a caught panic payload for cross-thread transport, folding in
566 /// the *current* thread's recorded panic location for generic panics.
567 ///
568 /// User conditions (`error!`/`warning!`/`message!`/`condition!`) travel as
569 /// `RCondition` payloads and must stay location-free — stringified
570 /// verbatim. Genuine generic panics get the `(at file:line)` suffix via
571 /// [`crate::unwind_protect::panic_message_with_location`], which reads the
572 /// current thread's take-once slot — correct only because every call site
573 /// below runs on the same thread whose hook caught this exact panic
574 /// (main thread for both uses in `dispatch_to_worker`'s main-thread event
575 /// loop, #1245).
576 fn fold_panic_message(payload: &(dyn Any + Send)) -> String {
577 if payload.is::<crate::condition::RCondition>() {
578 crate::unwind_protect::panic_payload_to_string(payload).into_owned()
579 } else {
580 crate::unwind_protect::panic_message_with_location(payload)
581 }
582 }
583
584 /// Dispatch a closure to the worker thread.
585 /// Returns Ok(T) or Err(panic_message).
586 pub(super) fn dispatch_to_worker<F, T>(f: F) -> Result<T, String>
587 where
588 F: FnOnce() -> T + Send + 'static,
589 T: Send + 'static,
590 {
591 /// Marker type for R errors caught by R_UnwindProtect's cleanup handler.
592 struct RErrorMarker;
593
594 // Re-entry guard: if we're already on the worker thread (inside a
595 // run_on_worker job), a nested run_on_worker would deadlock because the
596 // single worker thread can't pick up a new job while running the current one.
597 //
598 // Checked before the main-thread debug_assert so re-entry from the worker
599 // produces its specific message rather than the more general
600 // "must be called from the R main thread" assert (worker thread isn't main).
601 if has_context() {
602 panic!(
603 "run_on_worker called re-entrantly from within a worker context.\n\
604 \n\
605 The single worker thread is already executing a job, so a nested \
606 run_on_worker would deadlock. To call R APIs from worker code, \
607 use with_r_thread() instead."
608 );
609 }
610
611 // Precondition: the caller is the R main thread. `dispatch_to_worker`
612 // runs the main-thread event loop on whatever thread invokes it, so
613 // a non-main caller silently routes `with_r_thread` callbacks to the
614 // wrong thread. `.Call` always lands here on R's main thread, so this
615 // is a programming error rather than a runtime condition — debug-only
616 // assert, no atomic load in release. See #730.
617 debug_assert!(
618 super::is_r_main_thread(),
619 "run_on_worker must be called from the R main thread \
620 (the thread that ran miniextendr_runtime_init); see #730"
621 );
622
623 // Clone the worker's sender while holding the mutex briefly. The
624 // clone outlives the lock, so sends happen without blocking other
625 // callers on the mutex. If `WORKER` is `None`, the package has
626 // already been unloaded (or never initialized) — return a
627 // structured error instead of panicking.
628 let job_tx = {
629 let guard = WORKER.lock().unwrap();
630 match guard.as_ref() {
631 Some(state) => state.tx.clone(),
632 None => {
633 return Err(
634 "miniextendr worker is not running (runtime not initialized, \
635 or package has been unloaded)"
636 .to_string(),
637 );
638 }
639 }
640 };
641
642 // Single channel for worker -> main (work requests + final result).
643 // Capacity 1: each run_on_worker sends exactly one request at a time and blocks
644 // for a response, so no accumulation is possible. The extra slot ensures the
645 // worker's final Done message doesn't block if the main thread longjmped away.
646 let (worker_tx, worker_rx) = mpsc::sync_channel::<TypeErasedWorkerMessage>(1);
647
648 // Channel for main -> worker responses to work requests.
649 // Capacity 1: the worker blocks on recv after each with_r_thread call, so at most
650 // one response is in flight. The extra slot lets the cleanup handler send an error
651 // without blocking (it runs mid-longjmp and cannot wait).
652 let (response_tx, response_rx) = mpsc::sync_channel::<MainThreadResponse>(1);
653
654 let job: AnyJob = Box::new(move || {
655 // Set up thread-local channels for with_r_thread
656 WORKER_TO_MAIN_TX.with(|tx_cell| {
657 *tx_cell.borrow_mut() = Some(worker_tx.clone());
658 });
659 MAIN_RESPONSE_RX.with(|rx_cell| {
660 *rx_cell.borrow_mut() = Some(response_rx);
661 });
662
663 let result = catch_unwind(AssertUnwindSafe(f));
664
665 // Clear thread-locals
666 WORKER_TO_MAIN_TX.with(|tx_cell| {
667 *tx_cell.borrow_mut() = None;
668 });
669 MAIN_RESPONSE_RX.with(|rx_cell| {
670 *rx_cell.borrow_mut() = None;
671 });
672
673 // Send final result back to the main thread's recv loop. The capacity-1
674 // buffer ensures this doesn't block even if the main thread already exited
675 // the loop (e.g., after an R longjmp consumed the last WorkRequest).
676 let to_send: Result<Box<dyn Any + Send>, String> = match result {
677 Ok(val) => Ok(Box::new(val)),
678 Err(payload) => {
679 // Fold the panic location HERE, on the worker (origin)
680 // thread: the hook fired on the worker for this panic, so
681 // its take-once slot holds the real `panic!` site. The main
682 // side then treats the already-final message verbatim
683 // (`make_rust_condition_value(&__panic_msg, PANIC, …)` in the
684 // generated worker wrapper) — no second fold, no clobber.
685 //
686 // Only genuine generic panics get the `(at …)` suffix. User
687 // conditions (error!/warning!/message!/condition!) travel as
688 // `RCondition` payloads and must stay location-free — mirror
689 // the main-thread RCondition branch by stringifying verbatim.
690 //
691 // `PreLocatedPanic` is a THIRD case (#1245): the re-panic in
692 // `route_to_main_thread` (a `with_r_thread` closure that
693 // panicked on the MAIN thread, relayed back here) already
694 // carries a final, correctly-folded message from the true
695 // origin. This thread's panic hook still fired for the
696 // `panic_any` relay call itself, though, and recorded ITS
697 // OWN call site (in `worker.rs`, not the user's) into this
698 // thread's take-once slot — stale. Use the payload's
699 // message verbatim and drain-and-discard that stale slot so
700 // it can't leak into a later, unrelated fold on this same
701 // (reused) worker thread.
702 let msg = if let Some(pre) =
703 payload.downcast_ref::<crate::unwind_protect::PreLocatedPanic>()
704 {
705 let _ = crate::backtrace::take_last_panic_location();
706 pre.0.clone()
707 } else {
708 fold_panic_message(&*payload)
709 };
710 Err(msg)
711 }
712 };
713 let _ = worker_tx.send(WorkerMessage::Done(to_send));
714 });
715
716 job_tx
717 .send(WorkerMsg::Job(job))
718 .expect("worker thread dead");
719
720 // Main thread event loop: processes WorkRequest messages (from with_r_thread)
721 // until a Done message arrives. Invariant: each WorkRequest produces exactly
722 // one response_tx.send, and the worker blocks until it receives that response.
723 loop {
724 match worker_rx
725 .recv()
726 .expect("worker channel closed unexpectedly")
727 {
728 WorkerMessage::WorkRequest(work) => {
729 // Execute work on main thread with R_UnwindProtect so we can:
730 // 1. Catch Rust panics and send them as errors to the worker
731 // 2. Catch R errors (longjmp) via cleanup handler and send error to worker
732 // before R continues unwinding (function never returns in that case)
733
734 struct CallData {
735 work: Option<MainThreadWork>,
736 result: Option<Box<dyn Any + Send>>,
737 panic_payload: Option<Box<dyn Any + Send>>,
738 response_tx_ptr: *const SyncSender<MainThreadResponse>,
739 }
740
741 unsafe extern "C-unwind" fn trampoline(data: *mut std::ffi::c_void) -> SEXP {
742 assert!(!data.is_null(), "trampoline: data pointer is null");
743 let data = unsafe { &mut *data.cast::<CallData>() };
744 let work = data
745 .work
746 .take()
747 .expect("trampoline: work already consumed")
748 .0;
749
750 match catch_unwind(AssertUnwindSafe(work)) {
751 Ok(result) => {
752 data.result = Some(result);
753 SEXP::nil()
754 }
755 Err(payload) => {
756 data.panic_payload = Some(payload);
757 SEXP::nil()
758 }
759 }
760 }
761
762 unsafe extern "C-unwind" fn cleanup_handler(
763 data: *mut std::ffi::c_void,
764 jump: Rboolean,
765 ) {
766 if jump != Rboolean::FALSE {
767 // R is about to longjmp. We MUST send an error response to the worker
768 // before continuing the unwind—the worker is blocked on response_rx.recv()
769 // and would deadlock if we don't send something.
770 assert!(!data.is_null(), "cleanup_handler: data pointer is null");
771 let data = unsafe { &*data.cast::<CallData>() };
772 let response_tx = unsafe { &*data.response_tx_ptr };
773
774 #[cfg(feature = "nonapi")]
775 let error_msg = unsafe {
776 let buf = sys::R_curErrorBuf();
777 if buf.is_null() {
778 "R error occurred".to_string()
779 } else {
780 std::ffi::CStr::from_ptr(buf).to_string_lossy().into_owned()
781 }
782 };
783 #[cfg(not(feature = "nonapi"))]
784 let error_msg = "R error occurred".to_string();
785
786 let _ = response_tx.send(Err(error_msg));
787 std::panic::panic_any(RErrorMarker);
788 }
789 }
790
791 let response: MainThreadResponse = unsafe {
792 let boundary = crate::unwind_protect::Boundary::enter();
793 let token = crate::unwind_protect::get_continuation_token();
794
795 let data = Box::into_raw(Box::new(CallData {
796 work: Some(work),
797 result: None,
798 panic_payload: None,
799 response_tx_ptr: std::ptr::from_ref(&response_tx),
800 }));
801
802 let panic_result = catch_unwind(AssertUnwindSafe(|| {
803 sys::R_UnwindProtect_C_unwind(
804 Some(trampoline),
805 data.cast(),
806 Some(cleanup_handler),
807 data.cast(),
808 token,
809 )
810 }));
811
812 let mut data = Box::from_raw(data);
813
814 match panic_result {
815 Ok(_) => {
816 // Check if trampoline caught a panic
817 if let Some(payload) = data.panic_payload.take() {
818 // An R exit carried out of an evaluation in the
819 // callback (#1835): answer the waiting worker as
820 // the cleanup handler does for an R error, then
821 // let R continue its unwind.
822 let payload = match payload
823 .downcast::<crate::unwind_protect::RUnwind>()
824 {
825 Ok(exit) if exit.belongs_to(&boundary) => {
826 let _ = response_tx.send(Err(
827 "R exited the main-thread callback (an R \
828 error or a condition handler's exit)"
829 .to_string(),
830 ));
831 drop(data);
832 boundary.leave();
833 (*exit).resume()
834 }
835 Ok(exit) => exit.stray(),
836 Err(payload) => payload,
837 };
838 // This IS the real panic origin thread (main) —
839 // fold its location in now (#1245), before the
840 // message crosses back to the worker.
841 Err(fold_panic_message(&*payload))
842 } else {
843 // Normal completion - return the result
844 Ok(data
845 .result
846 .take()
847 .expect("result not set after successful completion"))
848 }
849 }
850 Err(payload) => {
851 // Check if this was an R error (cleanup handler already sent response)
852 if payload.downcast_ref::<RErrorMarker>().is_some() {
853 drop(data);
854 boundary.leave();
855 sys::R_ContinueUnwind(token);
856 }
857 // Rust panic - return as error response. Also
858 // the real origin thread (main) — fold its
859 // location in now (#1245).
860 Err(fold_panic_message(&*payload))
861 }
862 }
863 };
864
865 // Exactly one send per WorkRequest: either here (normal/panic) or
866 // in cleanup_handler (R error). Never both—R error path diverges
867 // via R_ContinueUnwind above and never reaches this line.
868 response_tx
869 .send(response)
870 .expect("worker response channel closed");
871 }
872 WorkerMessage::Done(result) => {
873 return match result {
874 Ok(boxed) => Ok(*boxed
875 .downcast::<T>()
876 .expect("type mismatch in run_on_worker result")),
877 Err(msg) => Err(msg),
878 };
879 }
880 }
881 }
882 }
883
884 /// Spawn the worker thread and install it as the global `WORKER`.
885 ///
886 /// Idempotent — if the worker is already running, this is a no-op. We
887 /// intentionally do NOT call this from a `OnceLock`: after `shutdown()`
888 /// the slot is cleared, and a subsequent `dyn.load` on the same DLL
889 /// (same statics, unchanged addresses) should be able to spawn a fresh
890 /// worker. `std::sync::Once` would forbid that.
891 pub(super) fn init_worker() {
892 let mut guard = WORKER.lock().unwrap();
893 if guard.is_some() {
894 return;
895 }
896 // Capacity 0 (rendezvous): the main thread blocks until the worker picks
897 // up the job, ensuring at most one job is in flight at a time.
898 let (tx, rx) = mpsc::sync_channel::<WorkerMsg>(0);
899 let handle = thread::Builder::new()
900 .name("miniextendr-worker".into())
901 .spawn(move || worker_loop(rx))
902 .expect("failed to spawn worker thread");
903 *guard = Some(WorkerState { tx, handle });
904 }
905
906 /// Worker thread body: blocking `recv()` loop.
907 ///
908 /// `recv()` blocks the thread in the OS until either a message arrives
909 /// or the sender is dropped — no polling, no timeouts, no sleeps. On
910 /// `Job`, run it. On `Shutdown` or `Err` (sender dropped), exit.
911 fn worker_loop(rx: Receiver<WorkerMsg>) {
912 while let Ok(msg) = rx.recv() {
913 match msg {
914 WorkerMsg::Job(job) => job(),
915 WorkerMsg::Shutdown => break,
916 }
917 }
918 }
919}
920// endregion
921
922// region: Tests
923
924#[cfg(test)]
925mod tests {
926 use super::*;
927
928 #[test]
929 fn sendable_is_send() {
930 fn assert_send<T: Send>() {}
931 assert_send::<Sendable<*const u8>>();
932 }
933
934 #[test]
935 fn with_r_thread_panics_before_init() {
936 // If another test already called miniextendr_runtime_init (via Once),
937 // we can't test the pre-init path. Verify at least panics from wrong thread.
938 if R_MAIN_THREAD_ID.get().is_some() {
939 let handle = std::thread::spawn(|| std::panic::catch_unwind(|| with_r_thread(|| 42)));
940 let result = handle.join().expect("thread panicked outside catch_unwind");
941 assert!(
942 result.is_err(),
943 "with_r_thread should panic from non-main thread"
944 );
945 return;
946 }
947 let result = std::panic::catch_unwind(|| {
948 with_r_thread(|| 42);
949 });
950 assert!(result.is_err());
951 let payload = result.unwrap_err();
952 let msg = crate::unwind_protect::panic_payload_to_string(payload.as_ref());
953 assert!(
954 msg.contains("miniextendr_runtime_init"),
955 "expected init error message, got: {msg}"
956 );
957 }
958
959 #[test]
960 fn has_worker_context_false_outside_worker() {
961 assert!(!has_worker_context());
962 }
963
964 // region: Feature-gated tests: worker-thread
965
966 #[cfg(feature = "worker-thread")]
967 mod worker_tests {
968 use super::*;
969
970 /// Calling `run_on_worker` from within worker code (re-entry) must be
971 /// detected and panic, not deadlock.
972 ///
973 /// Dispatches from the current test thread rather than a spawned one.
974 /// `run_on_worker` requires the R main thread (#730), and a spawned
975 /// std::thread would trip the debug_assert before the re-entry check
976 /// gets a chance to run. If this test happens to run on a thread that
977 /// isn't `R_MAIN_THREAD_ID` (another test initialised first), skip —
978 /// the reentry behaviour is checked elsewhere on each invocation of
979 /// the suite.
980 #[test]
981 fn run_on_worker_reentry_panics_not_deadlocks() {
982 miniextendr_runtime_init();
983 if !is_r_main_thread() {
984 return;
985 }
986
987 let result = run_on_worker(|| {
988 // Re-entry: this runs on the worker thread.
989 run_on_worker(|| 42).unwrap();
990 });
991
992 let msg = result.expect_err("re-entry should surface as Err");
993 assert!(
994 msg.contains("re-entr") || msg.contains("Re-entr"),
995 "expected re-entry error, got: {msg}"
996 );
997 }
998
999 /// `run_on_worker` from a non-main thread trips the debug-only
1000 /// precondition assert (#730). Spawning a fresh std::thread guarantees
1001 /// the caller isn't `R_MAIN_THREAD_ID`, regardless of which thread
1002 /// `miniextendr_runtime_init` first ran on.
1003 ///
1004 /// `cfg(debug_assertions)` only — the assert is compiled out in
1005 /// release, by design.
1006 #[cfg(debug_assertions)]
1007 #[test]
1008 fn run_on_worker_from_non_main_thread_asserts_in_debug() {
1009 miniextendr_runtime_init();
1010
1011 let (tx, rx) = std::sync::mpsc::sync_channel::<Result<String, ()>>(1);
1012 std::thread::spawn(move || {
1013 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1014 run_on_worker(|| 0i32)
1015 }));
1016 let report = match outcome {
1017 Err(payload) => Ok(crate::unwind_protect::panic_payload_to_string(
1018 payload.as_ref(),
1019 )
1020 .into_owned()),
1021 Ok(_) => Err(()),
1022 };
1023 let _ = tx.send(report);
1024 });
1025
1026 match rx.recv_timeout(std::time::Duration::from_secs(5)) {
1027 Ok(Ok(msg)) => {
1028 assert!(
1029 msg.contains("R main thread"),
1030 "expected main-thread precondition assert, got: {msg}"
1031 );
1032 }
1033 Ok(Err(())) => {
1034 panic!("debug_assert did not fire when run_on_worker was called from non-main")
1035 }
1036 Err(_) => panic!("test deadlocked waiting for debug_assert panic"),
1037 }
1038 }
1039 }
1040 // endregion
1041
1042 // region: Feature-gated tests: no worker-thread (stubs)
1043
1044 #[cfg(not(feature = "worker-thread"))]
1045 mod stub_tests {
1046 use super::*;
1047
1048 #[test]
1049 fn stub_with_r_thread_inline() {
1050 miniextendr_runtime_init();
1051 // If another parallel test already set R_MAIN_THREAD_ID to a
1052 // different thread (OnceLock), we won't be "main" and with_r_thread
1053 // will rightfully panic. Skip in that case.
1054 if !is_r_main_thread() {
1055 return;
1056 }
1057 let result = with_r_thread(|| 42);
1058 assert_eq!(result, 42);
1059 }
1060
1061 #[test]
1062 fn stub_run_on_worker_inline() {
1063 let result = run_on_worker(|| 123);
1064 assert_eq!(result, Ok(123));
1065 }
1066
1067 /// Without `worker-thread`, `with_r_thread` must panic when called from
1068 /// a non-main thread.
1069 #[test]
1070 fn stub_with_r_thread_panics_on_wrong_thread() {
1071 miniextendr_runtime_init();
1072
1073 let handle = std::thread::spawn(|| {
1074 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| with_r_thread(|| 42)))
1075 });
1076
1077 let result = handle.join().expect("thread panicked outside catch_unwind");
1078 assert!(
1079 result.is_err(),
1080 "with_r_thread should panic when called from a non-main thread \
1081 without the worker-thread feature, but it ran inline silently"
1082 );
1083 }
1084 }
1085 // endregion
1086}
1087// endregion