Skip to main content

miniextendr_api/
pump.rs

1//! [`WorkerPump<T>`] — safe main/worker thread coordination for FFI bodies.
2//!
3//! # Overview
4//!
5//! `WorkerPump` manages the common pattern of running a CPU-bound worker on a
6//! background thread while the main R thread drives a "pump" loop — processing
7//! progress events, rendering output, or any other operation that must happen
8//! on R's main thread (e.g. calls into R's C API).
9//!
10//! It wraps [`std::thread::scope`] so the worker is always joined before
11//! `run` returns, and exposes a builder API for common knobs (channel capacity,
12//! log-drain cadence).
13//!
14//! # Longjmp-safety contract
15//!
16//! **`WorkerPump::run` must be called from inside an `#[miniextendr]` FFI
17//! body** (or any code already wrapped by `with_r_unwind_protect`).
18//!
19//! The reason: the pump closure may call into R's API (e.g. to render a
20//! progress bar), and R can `longjmp` out of those calls at any time — on
21//! interrupt, on allocation failure, etc.  miniextendr's macro layer wraps
22//! every `#[miniextendr]` body in `R_UnwindProtect` via
23//! `run_r_unwind_protect`.
24//!
25//! The `R_UnwindProtect` strategy used by miniextendr converts R longjmps
26//! into Rust panics (via `cleanup_handler` → `std::panic::panic_any`) which
27//! are then caught by an outer `catch_unwind`.  Because the panic travels
28//! through normal Rust stack-unwinding, **all `Drop` glue runs on the way
29//! out** — including `thread::scope`'s `Drop`, which joins the worker before
30//! the scope exits.  The worker sees `tx` dropped (because `rx` dropped as
31//! the scope cleaned up), so any blocked `tx.send` returns `Err` and the
32//! worker can exit gracefully.  The panic is then re-raised as an R error via
33//! `R_ContinueUnwind`.
34//!
35//! If you call `WorkerPump::run` *outside* of an `#[miniextendr]` body and
36//! the pump triggers an R longjmp, the longjmp will bypass Rust destructors
37//! entirely and the worker thread will be leaked.
38//!
39//! # Error type
40//!
41//! `WorkerPump::run` uses `Result<R, Box<dyn Error + Send + Sync>>` so it
42//! composes naturally with both `anyhow::Result` (via `?`) and `std::io::Error`
43//! without requiring a hard dependency on any error-handling crate.
44//!
45//! # Example
46//!
47//! ```rust,ignore
48//! use miniextendr_api::pump::WorkerPump;
49//! use std::sync::mpsc::SyncSender;
50//!
51//! #[miniextendr]
52//! fn compress_files(paths: Vec<String>) -> i64 {
53//!     WorkerPump::new()
54//!         .run(
55//!             // worker: runs off-main-thread
56//!             |tx: SyncSender<u64>| -> Result<i64, Box<dyn std::error::Error + Send + Sync>> {
57//!                 let mut total = 0i64;
58//!                 for path in &paths {
59//!                     let bytes = compress_one(path)?;
60//!                     tx.send(bytes).ok();
61//!                     total += bytes as i64;
62//!                 }
63//!                 Ok(total)
64//!             },
65//!             // pump: runs on main thread, may call R API
66//!             |bytes| render_progress(bytes),
67//!         )
68//!         .expect("compression failed")
69//! }
70//! ```
71
72use std::error::Error;
73use std::marker::PhantomData;
74use std::sync::mpsc::{self, SyncSender};
75use std::thread;
76
77/// Boxed, thread-safe error type used by [`WorkerPump::run`].
78///
79/// Alias for `Box<dyn Error + Send + Sync>`. Compatible with `anyhow::Error`
80/// via `?` and with standard library error types without requiring extra
81/// dependencies.
82pub type WorkerError = Box<dyn Error + Send + Sync>;
83
84/// Runs a worker thread in parallel with a main-thread pump loop.
85///
86/// See [the module documentation][self] for the longjmp-safety contract and a
87/// usage example.
88pub struct WorkerPump<T> {
89    /// Capacity of the bounded MPSC channel between worker and pump.
90    capacity: usize,
91    /// Whether to drain the cross-thread log queue on every pump tick.
92    drain_logs_each_tick: bool,
93    _marker: PhantomData<fn() -> T>,
94}
95
96impl<T: Send + 'static> WorkerPump<T> {
97    /// Create a new `WorkerPump` with default settings.
98    ///
99    /// Defaults:
100    /// - channel capacity: 64
101    /// - `drain_logs_each_tick`: `true`
102    pub fn new() -> Self {
103        Self {
104            capacity: 64,
105            drain_logs_each_tick: true,
106            _marker: PhantomData,
107        }
108    }
109
110    /// Set the capacity of the bounded MPSC channel.
111    ///
112    /// The default is 64.  A larger capacity allows the worker to get further
113    /// ahead of the pump; a capacity of 0 makes every send synchronous
114    /// (rendezvous channel).
115    ///
116    /// When the channel is full the worker blocks on `tx.send` until the pump
117    /// drains a slot.  If the pump panics or a longjmp fires, `rx` is dropped
118    /// as part of scope unwinding, which unblocks `tx.send` with an `Err` and
119    /// lets the worker exit cleanly.
120    pub fn channel_capacity(mut self, n: usize) -> Self {
121        self.capacity = n;
122        self
123    }
124
125    /// Control whether the cross-thread log queue is drained on every pump tick.
126    ///
127    /// Default: `true`.  Set to `false` if the consumer manages its own log
128    /// drain cadence (e.g. it calls `drain_log_queue()` explicitly at
129    /// coarser granularity).
130    ///
131    /// Has no effect when the `log` feature is disabled.
132    pub fn drain_logs_each_tick(mut self, on: bool) -> Self {
133        self.drain_logs_each_tick = on;
134        self
135    }
136
137    /// Run the worker/pump pair and return the worker's result.
138    ///
139    /// - `worker` runs on a scoped background thread.  It receives a
140    ///   [`SyncSender<T>`] and sends messages to the pump.  When `worker`
141    ///   returns (success or error) it should drop `tx`; the pump's receive
142    ///   loop then terminates naturally.
143    /// - `pump` is called on the **current (main R) thread** for every message
144    ///   the worker sends.
145    ///
146    /// `run` returns `Ok(R)` on success, or `Err` if the worker returned an
147    /// error or panicked.
148    ///
149    /// # Panics
150    ///
151    /// If the worker thread panics, `run` returns
152    /// `Err("WorkerPump worker panicked")`.
153    ///
154    /// If the pump closure panics, the panic propagates normally through
155    /// `thread::scope`'s `Drop` (which joins the worker), and then out of
156    /// `run`.  When called from inside an `#[miniextendr]` body the outer
157    /// `R_UnwindProtect` catches it and converts it to an R error.
158    pub fn run<R, W, P>(self, worker: W, mut pump: P) -> Result<R, WorkerError>
159    where
160        R: Send,
161        W: FnOnce(SyncSender<T>) -> Result<R, WorkerError> + Send,
162        P: FnMut(T),
163    {
164        thread::scope(|scope| {
165            let (tx, rx) = mpsc::sync_channel(self.capacity);
166            let handle = scope.spawn(move || worker(tx));
167            for msg in rx {
168                if self.drain_logs_each_tick {
169                    #[cfg(feature = "log")]
170                    crate::optionals::log_impl::drain_log_queue();
171                }
172                pump(msg);
173            }
174            handle
175                .join()
176                .map_err(|_| -> WorkerError { "WorkerPump worker panicked".into() })?
177        })
178    }
179}
180
181impl<T: Send + 'static> Default for WorkerPump<T> {
182    fn default() -> Self {
183        Self::new()
184    }
185}
186
187// region: Tests
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use std::sync::atomic::{AtomicBool, Ordering};
193    use std::sync::{Arc, Mutex};
194
195    // region: happy_path
196
197    /// Worker sends N messages, pump receives all, worker returns Ok.
198    #[test]
199    fn happy_path() {
200        let received = Arc::new(Mutex::new(Vec::<i64>::new()));
201        let received2 = Arc::clone(&received);
202
203        let result = WorkerPump::new()
204            .drain_logs_each_tick(false)
205            .run(
206                |tx| {
207                    for i in 0..5i64 {
208                        tx.send(i).unwrap();
209                    }
210                    Ok(42i64)
211                },
212                |msg| {
213                    received2.lock().unwrap().push(msg);
214                },
215            )
216            .expect("run failed");
217
218        assert_eq!(result, 42);
219        let got = received.lock().unwrap();
220        assert_eq!(*got, vec![0, 1, 2, 3, 4]);
221    }
222    // endregion
223
224    // region: worker_returns_err
225
226    /// Worker returns Err early; pump exits cleanly; run returns the worker's Err.
227    #[test]
228    fn worker_returns_err() {
229        let pump_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
230        let pump_count2 = Arc::clone(&pump_count);
231
232        let result: Result<(), WorkerError> = WorkerPump::new().drain_logs_each_tick(false).run(
233            |_tx| {
234                // drop tx immediately, send no messages
235                Err("deliberate worker error".into())
236            },
237            |_msg: ()| {
238                pump_count2.fetch_add(1, Ordering::Relaxed);
239            },
240        );
241
242        assert!(result.is_err());
243        let msg = format!("{}", result.unwrap_err());
244        assert!(
245            msg.contains("deliberate worker error"),
246            "unexpected error: {msg}"
247        );
248        assert_eq!(
249            pump_count.load(Ordering::Relaxed),
250            0,
251            "pump must not run if worker sends nothing"
252        );
253    }
254    // endregion
255
256    // region: pump_panics
257
258    /// Pump callback panics; worker is joined (no thread leak).
259    ///
260    /// We instrument the worker with a `Drop` guard that flips an `AtomicBool`
261    /// to confirm it ran after the scope unwinds.
262    #[test]
263    fn pump_panics() {
264        // Instrument: flipped to true when the worker's guard is dropped.
265        let worker_dropped = Arc::new(AtomicBool::new(false));
266        let worker_dropped2 = Arc::clone(&worker_dropped);
267
268        struct DropGuard(Arc<AtomicBool>);
269        impl Drop for DropGuard {
270            fn drop(&mut self) {
271                self.0.store(true, Ordering::Relaxed);
272            }
273        }
274
275        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
276            WorkerPump::<u8>::new().drain_logs_each_tick(false).run(
277                move |tx| {
278                    let _guard = DropGuard(worker_dropped2);
279                    // Send one message to ensure pump fires at least once,
280                    // then block until rx is dropped (pump panic).
281                    let _ = tx.send(1u8);
282                    // The second send will return Err when rx drops on pump panic.
283                    let _ = tx.send(2u8);
284                    Ok(())
285                },
286                |_msg: u8| {
287                    panic!("pump panic");
288                },
289            )
290        }));
291
292        // The outer catch_unwind should see the pump panic propagate.
293        assert!(result.is_err(), "expected panic to propagate");
294        // The worker's DropGuard must have run — confirms worker was joined.
295        assert!(
296            worker_dropped.load(Ordering::Relaxed),
297            "worker Drop did not run — possible thread leak"
298        );
299    }
300    // endregion
301
302    // region: bounded_channel_no_deadlock_on_scope_drop
303
304    /// Fill the bounded channel; break out of pump early; assert worker exits.
305    ///
306    /// The worker tries to send more messages than the channel can hold.  The
307    /// pump breaks after the first message (simulating an early exit), which
308    /// drops `rx`.  The worker's blocked `tx.send` must return `Err` (not
309    /// deadlock), and the worker must finish.
310    ///
311    /// We verify no deadlock by asserting `run` completes before the test
312    /// times out (Rust's test harness kills hanging tests).
313    #[test]
314    fn bounded_channel_no_deadlock_on_scope_drop() {
315        // Use a channel capacity of 1 so the worker blocks quickly.
316        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
317            WorkerPump::<u8>::new()
318                .channel_capacity(1)
319                .drain_logs_each_tick(false)
320                .run(
321                    |tx| {
322                        // Try to send many messages — will block after slot 1 fills.
323                        for i in 0u8..=10 {
324                            if tx.send(i).is_err() {
325                                // rx was dropped (pump broke out), exit cleanly.
326                                break;
327                            }
328                        }
329                        Ok(())
330                    },
331                    |_msg: u8| {
332                        // Simulate early pump exit by panicking after first message.
333                        panic!("early pump exit");
334                    },
335                )
336        }));
337
338        // We get a panic from the pump, but no deadlock (test would hang otherwise).
339        assert!(result.is_err(), "expected pump panic");
340    }
341    // endregion
342
343    // region: drain_logs_each_tick_default_on
344
345    /// With the `log` feature enabled: a log record from the worker is flushed
346    /// to the render sink by the time the pump processes the next message.
347    ///
348    /// Without the `log` feature this test reduces to a basic send/recv check.
349    #[test]
350    fn drain_logs_each_tick_default_on() {
351        #[cfg(feature = "log")]
352        {
353            use crate::optionals::log_impl::{
354                LOG_TEST_LOCK, install_r_logger, set_fake_main_thread, set_log_level, take_rendered,
355            };
356
357            // Acquire the same lock that log_impl tests use so we don't race on
358            // the shared QUEUE / DROPPED / TEST_SINK globals.
359            let _guard = LOG_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
360
361            install_r_logger();
362            set_log_level("trace");
363            // Clear residual state from prior tests.
364            take_rendered();
365
366            let rendered_before_pump: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
367            let rendered_before_pump2 = Arc::clone(&rendered_before_pump);
368
369            // The pump loop (and drain_log_queue inside it) runs on *this* thread.
370            // Mark this thread as main so drain_log_queue() actually drains.
371            // The worker is a real scoped thread whose FAKE_IS_MAIN is None,
372            // so is_main() → is_r_main_thread() → false → records go to queue.
373            set_fake_main_thread(Some(true));
374
375            WorkerPump::<u8>::new()
376                .drain_logs_each_tick(true)
377                .run(
378                    |tx| {
379                        // Runs on a real background thread; is_main() == false
380                        // → log record is buffered in QUEUE, not rendered yet.
381                        log::info!("hello from worker");
382                        tx.send(1u8).ok();
383                        Ok(())
384                    },
385                    move |msg: u8| {
386                        // WorkerPump calls drain_log_queue() BEFORE calling us.
387                        // Since is_main() == true on the pump thread, the queue
388                        // was already drained → record is in TEST_SINK.
389                        let rendered = take_rendered();
390                        rendered_before_pump2.lock().unwrap().extend(rendered);
391                        let _ = msg;
392                    },
393                )
394                .expect("run failed");
395
396            // Restore state.
397            set_fake_main_thread(None);
398            take_rendered();
399
400            // The drain happened before pump(msg), so the record must have
401            // appeared in the captured snapshot.
402            let rendered = rendered_before_pump.lock().unwrap();
403            assert!(
404                rendered.iter().any(|m| m.contains("hello from worker")),
405                "log record was not drained before pump tick; got: {rendered:?}"
406            );
407        }
408
409        // When `log` feature is disabled: just verify run completes.
410        #[cfg(not(feature = "log"))]
411        {
412            let result: Result<u8, WorkerError> = WorkerPump::new().drain_logs_each_tick(true).run(
413                |tx| {
414                    tx.send(99u8).ok();
415                    Ok(99u8)
416                },
417                |_| {},
418            );
419            assert_eq!(result.unwrap(), 99u8);
420        }
421    }
422    // endregion
423}
424
425// endregion