Skip to main content

miniextendr_api/
worker.rs

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