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