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