Skip to main content

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