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