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. 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