Skip to main content

pebble/threading/
mod.rs

1//! A small, fixed-size worker pool for offloading CPU-bound work off the
2//! main thread — mip/image processing, physics steps, any one-off or
3//! recurring task that shouldn't block a frame.
4//!
5//! Deliberately NOT a full async runtime: no cancellation, no priorities,
6//! no work-stealing. Just a bounded number of OS threads pulling jobs off
7//! one shared, lock-free MPMC queue, with results delivered back via a
8//! channel you poll from an ordinary system. If you outgrow this — need
9//! cancellation, need priority scheduling — that's real, separate
10//! infrastructure to build once you have a concrete case for it, not
11//! something to guess at now.
12//!
13//! Requires the `crossbeam-channel` crate (lock-free MPMC), since
14//! `std::sync::mpsc` only supports a single consumer and would otherwise
15//! force a `Mutex` around the receiver for multiple worker threads.
16
17use crossbeam_channel::{Receiver as CbReceiver, Sender as CbSender, unbounded};
18use std::sync::mpsc::{Receiver, TryRecvError, channel};
19
20use crate::ecs::plugin::Plugin;
21
22type Job = Box<dyn FnOnce() + Send + 'static>;
23
24/// Extracts a human-readable message from a `catch_unwind` payload — panics
25/// via `panic!("...")`/`.unwrap()`/`.expect("...")` all land in one of these
26/// two downcasts; anything else (a panic with a non-`&str`/`String` payload,
27/// via `std::panic::panic_any`) falls back to a generic label rather than
28/// failing to report anything at all.
29fn panic_message(payload: Box<dyn std::any::Any + Send>) -> String {
30    if let Some(s) = payload.downcast_ref::<&str>() {
31        (*s).to_string()
32    } else if let Some(s) = payload.downcast_ref::<String>() {
33        s.clone()
34    } else {
35        "<panic payload was not a string>".to_string()
36    }
37}
38
39/// The bound a future must satisfy to be handed to
40/// [`BackgroundTasks::spawn_async`] — mirrored as a trait (rather than
41/// written out at every call site) so both `spawn_async` itself and the
42/// scheduler's [`AsyncExt::detach`](crate::ecs::system::AsyncExt::detach)/
43/// [`AsyncEventWriter`](crate::ecs::events::AsyncEventWriter) share one definition
44/// instead of duplicating the native/web split.
45///
46/// Native futures cross to a worker thread, so they must be `Send`; web
47/// futures run on the browser's microtask queue on the same thread, so they
48/// don't need to be — which is what lets a web future capture `!Send`
49/// browser-bound types (`JsValue`, a `web_sys` handle, ...).
50#[cfg(not(target_arch = "wasm32"))]
51pub trait SpawnableFuture<T>: std::future::Future<Output = T> + Send + 'static {}
52#[cfg(not(target_arch = "wasm32"))]
53impl<T, F: std::future::Future<Output = T> + Send + 'static> SpawnableFuture<T> for F {}
54
55#[cfg(target_arch = "wasm32")]
56pub trait SpawnableFuture<T>: std::future::Future<Output = T> + 'static {}
57#[cfg(target_arch = "wasm32")]
58impl<T, F: std::future::Future<Output = T> + 'static> SpawnableFuture<T> for F {}
59
60/// The worker pool itself. Insert as a resource once, at startup; every
61/// system that needs to offload work reaches for `Res<BackgroundTasks>`
62/// and calls `spawn`.
63///
64/// Cheap to clone (an internal channel sender) — clone it out of a `Res`
65/// borrow to move an owned handle into a detached future, e.g. from a
66/// system registered with [`AsyncExt::detach`](crate::ecs::system::AsyncExt::detach).
67#[derive(Clone)]
68pub struct BackgroundTasks {
69    job_tx: CbSender<Job>,
70}
71
72impl BackgroundTasks {
73    /// Spawns `worker_count` OS threads, each pulling jobs off one shared,
74    /// lock-free queue until the pool itself is dropped. A worker count
75    /// around your CPU's core count (minus one, to leave room for the
76    /// main thread) is a reasonable default; tune based on actual
77    /// measured load.
78    ///
79    /// On `wasm32` there are no OS threads to spawn, so `worker_count` is
80    /// ignored and [`spawn_blocking`](Self::spawn_blocking) queues jobs that never run —
81    /// [`spawn_async`](Self::spawn_async) is the one that's web-compatible,
82    /// since it drives the browser's microtask queue via
83    /// `wasm_bindgen_futures::spawn_local` instead of a worker thread.
84    pub fn new(worker_count: usize) -> Self {
85        let (job_tx, job_rx): (CbSender<Job>, CbReceiver<Job>) = unbounded();
86
87        #[cfg(not(target_arch = "wasm32"))]
88        for _ in 0..worker_count.max(1) {
89            let job_rx = job_rx.clone(); // cheap — crossbeam receivers are natively Clone, no Mutex needed
90            std::thread::spawn(move || {
91                // `recv()` blocks this worker thread only, until a job
92                // arrives or every sender (the pool, plus any clones) is
93                // dropped — no lock contention between workers picking up
94                // jobs concurrently.
95                while let Ok(job) = job_rx.recv() {
96                    // `spawn_blocking`/`spawn_async` already catch_unwind
97                    // around the caller's closure/future, so a panic
98                    // reaching here at all means something upstream failed
99                    // to report it through its own TaskHandle — this is a
100                    // last-resort net so *that* doesn't also cost the pool
101                    // a worker thread permanently. Every worker dying one
102                    // panic at a time, with nothing ever telling the app
103                    // its background work silently stopped happening, is
104                    // exactly the failure mode this whole module exists to
105                    // avoid.
106                    if let Err(payload) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(job)) {
107                        tracing::error!(
108                            "BackgroundTasks: a job panicked without going through its own \
109                             error reporting — the worker thread survived regardless: {}",
110                            panic_message(payload)
111                        );
112                    }
113                }
114            });
115        }
116        #[cfg(target_arch = "wasm32")]
117        let _ = (worker_count, &job_rx);
118
119        Self { job_tx }
120    }
121
122    /// Queue `work` to run on the pool. Returns a [`TaskHandle`] you can
123    /// poll (non-blocking) from any system to check whether it's done.
124    ///
125    /// `work` runs on whichever worker thread picks it up next — don't
126    /// assume anything about timing or ordering relative to other spawned
127    /// tasks unless you build that coordination yourself.
128    ///
129    /// **Native-only.** There are no OS threads to block on in a browser
130    /// tab, so on `wasm32` this queues a job that never runs — use
131    /// [`spawn_async`](Self::spawn_async) instead, which works on both.
132    /// The `_blocking` suffix names what makes this one platform-specific:
133    /// it occupies its worker thread for as long as `work` runs, same as
134    /// `std::thread::spawn` would.
135    pub fn spawn_blocking<T: Send + 'static>(
136        &self,
137        work: impl FnOnce() -> T + Send + 'static,
138    ) -> TaskHandle<T> {
139        let (result_tx, result_rx) = channel::<Result<T, String>>();
140        let job: Job = Box::new(move || {
141            let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(work)).map_err(|payload| {
142                let message = panic_message(payload);
143                tracing::error!("BackgroundTasks: a spawned task panicked: {message}");
144                message
145            });
146            // ignore: receiver may have been dropped (TaskHandle discarded
147            // by the caller, e.g. `.detach()`), that's fine either way.
148            let _ = result_tx.send(outcome);
149        });
150        // If this fails, every worker thread has panicked and the pool is
151        // effectively dead — surfaced through `TaskHandle::poll` as
152        // `Panicked` (the disconnected-channel case), same as any other
153        // task whose sender never got to send, rather than panicking here
154        // and crashing an unrelated caller trying to queue new work.
155        let _ = self.job_tx.send(job);
156        TaskHandle { rx: result_rx }
157    }
158
159    /// Queue an already-constructed `future` to run to completion, and
160    /// return a [`TaskHandle`] you can poll for its result.
161    ///
162    /// This is the primitive [`AsyncExt::detach`](crate::ecs::system::AsyncExt::detach)
163    /// uses under the hood; call it directly instead when you want the
164    /// `TaskHandle` back to poll for a result, rather than firing the
165    /// future off and forgetting it.
166    ///
167    /// - **Native**: blocks whichever worker thread picks it up (via
168    ///   [`pollster::block_on`]) for as long as the future takes to
169    ///   resolve — there's no cooperative multitasking between futures
170    ///   sharing a worker, so a slow future occupies that worker
171    ///   exclusively, same as a slow [`spawn_blocking`](Self::spawn_blocking)
172    ///   closure would. The future must be `Send` to cross to that worker thread.
173    /// - **Web**: runs on the browser's microtask queue via
174    ///   `wasm_bindgen_futures::spawn_local`, on the same (only) thread —
175    ///   so it does *not* need to be `Send`, which is what lets it capture
176    ///   `!Send` browser-bound types (`JsValue`, a `web_sys` handle, ...).
177    #[cfg(not(target_arch = "wasm32"))]
178    pub fn spawn_async<T: Send + 'static>(
179        &self,
180        future: impl SpawnableFuture<T>,
181    ) -> TaskHandle<T> {
182        self.spawn_blocking(move || pollster::block_on(future))
183    }
184
185    /// See the native [`spawn_async`](Self::spawn_async) docs above for the
186    /// full contract — this is the web counterpart, driven by the
187    /// browser's microtask queue instead of a worker thread.
188    #[cfg(target_arch = "wasm32")]
189    pub fn spawn_async<T: 'static>(&self, future: impl SpawnableFuture<T>) -> TaskHandle<T> {
190        let (result_tx, result_rx) = channel::<Result<T, String>>();
191        wasm_bindgen_futures::spawn_local(async move {
192            // No native `catch_unwind` wrapper here — catching a panic
193            // across an `.await` point needs a polling combinator this
194            // module doesn't currently pull in a dependency for (native's
195            // `spawn_blocking`/`spawn_async` can wrap synchronously instead,
196            // which is why only this platform lacks it). If `future` panics,
197            // `result_tx` is simply never sent to; `TaskHandle::poll` still
198            // reports that as `Panicked` once the disconnected channel is
199            // observed, just without a captured message — install a wasm
200            // panic hook (`console_error_panic_hook`) to see the message
201            // itself in the browser console instead.
202            let result = future.await;
203            let _ = result_tx.send(Ok(result)); // ignore: receiver may have been dropped, that's fine
204        });
205        TaskHandle { rx: result_rx }
206    }
207}
208
209/// The outcome of polling a [`TaskHandle`].
210pub enum TaskStatus<T> {
211    /// Not finished yet — poll again next tick.
212    Pending,
213    /// Finished successfully.
214    Ready(T),
215    /// The task panicked (or, for a [`spawn_blocking`](BackgroundTasks::spawn_blocking)/
216    /// [`spawn_async`](BackgroundTasks::spawn_async) task specifically, the
217    /// whole worker pool has died) before producing a result — it never
218    /// will now. The message is the panic payload where one could be
219    /// captured; native tasks always get one, since `spawn_blocking` wraps
220    /// the closure in `catch_unwind` directly. A web [`spawn_async`](BackgroundTasks::spawn_async)
221    /// task can't be wrapped the same way (see that method's docs), so its
222    /// message is a generic placeholder — the real panic message goes to
223    /// the browser console instead, via whatever wasm panic hook is
224    /// installed.
225    Panicked(String),
226}
227
228/// A handle to a single in-flight (or already-finished) task.
229pub struct TaskHandle<T> {
230    rx: Receiver<Result<T, String>>,
231}
232
233impl<T> TaskHandle<T> {
234    /// Poll for this task's outcome. Never blocks — safe to call every tick.
235    ///
236    /// Distinguishes "still running" from "panicked and will never produce
237    /// a result" — the two states [`try_recv`](Self::try_recv) alone can't
238    /// tell apart, since both look like `None` there. Reach for this
239    /// instead of `try_recv` whenever a task not finishing is something
240    /// your own code should react to, rather than silently wait on forever.
241    pub fn poll(&mut self) -> TaskStatus<T> {
242        match self.rx.try_recv() {
243            Ok(Ok(value)) => TaskStatus::Ready(value),
244            Ok(Err(message)) => TaskStatus::Panicked(message),
245            Err(TryRecvError::Empty) => TaskStatus::Pending,
246            Err(TryRecvError::Disconnected) => TaskStatus::Panicked(
247                "the task's sender was dropped without ever sending a result — on native, this \
248                 also means the panic that caused it was already logged via tracing::error! at \
249                 the time it happened"
250                    .to_string(),
251            ),
252        }
253    }
254
255    /// Returns `Some(result)` once the task has finished successfully,
256    /// `None` otherwise — whether it's still running or it panicked. Kept
257    /// for the common case where you only care *whether* a result showed
258    /// up, not why one hasn't; see [`poll`](Self::poll) to tell a panic
259    /// apart from ordinary pending.
260    pub fn try_recv(&mut self) -> Option<T> {
261        match self.poll() {
262            TaskStatus::Ready(value) => Some(value),
263            TaskStatus::Pending | TaskStatus::Panicked(_) => None,
264        }
265    }
266}
267
268/// Registers `BackgroundTasks` as a resource with the given worker count.
269///
270/// ```ignore
271/// app.add_plugin(BackgroundTasksPlugin::new(4));
272/// ```
273pub struct BackgroundTasksPlugin {
274    worker_count: usize,
275}
276
277impl BackgroundTasksPlugin {
278    pub fn new(worker_count: usize) -> Self {
279        Self { worker_count }
280    }
281}
282
283impl Plugin for BackgroundTasksPlugin {
284    fn build(&self, app: &mut crate::prelude::App) {
285        app.add_resource(BackgroundTasks::new(self.worker_count));
286    }
287}
288
289#[cfg(all(test, not(target_arch = "wasm32")))]
290mod tests {
291    use super::*;
292    use std::time::{Duration, Instant};
293
294    fn poll_until<T>(handle: &mut TaskHandle<T>, timeout: Duration) -> TaskStatus<T> {
295        let deadline = Instant::now() + timeout;
296        loop {
297            match handle.poll() {
298                TaskStatus::Pending => {
299                    assert!(Instant::now() < deadline, "task did not resolve within {timeout:?}");
300                    std::thread::sleep(Duration::from_millis(5));
301                }
302                status => return status,
303            }
304        }
305    }
306
307    #[test]
308    fn a_panicking_task_reports_panicked_instead_of_hanging_forever() {
309        // Silence the default panic hook for this test only — we're
310        // deliberately panicking a worker thread and don't want a scary
311        // backtrace in otherwise-passing test output; catch_unwind doesn't
312        // suppress the hook on its own.
313        let previous_hook = std::panic::take_hook();
314        std::panic::set_hook(Box::new(|_| {}));
315
316        let pool = BackgroundTasks::new(1);
317        let mut handle = pool.spawn_blocking(|| -> u32 { panic!("deliberate test panic") });
318        let status = poll_until(&mut handle, Duration::from_secs(2));
319
320        std::panic::set_hook(previous_hook);
321
322        match status {
323            TaskStatus::Panicked(message) => assert!(message.contains("deliberate test panic")),
324            _ => panic!("expected TaskStatus::Panicked"),
325        }
326    }
327
328    #[test]
329    fn the_worker_pool_survives_a_panic_and_keeps_processing_later_tasks() {
330        let previous_hook = std::panic::take_hook();
331        std::panic::set_hook(Box::new(|_| {}));
332
333        // Exactly one worker — proves that specific thread survived the
334        // panic and picked the next job back up, rather than a second
335        // worker happening to cover for a dead first one.
336        let pool = BackgroundTasks::new(1);
337        let mut doomed = pool.spawn_blocking(|| -> u32 { panic!("first task panics") });
338        poll_until(&mut doomed, Duration::from_secs(2));
339
340        let mut healthy = pool.spawn_blocking(|| 42u32);
341        let status = poll_until(&mut healthy, Duration::from_secs(2));
342
343        std::panic::set_hook(previous_hook);
344
345        match status {
346            TaskStatus::Ready(value) => assert_eq!(value, 42),
347            _ => panic!("expected the pool's sole worker thread to still be alive and processing"),
348        }
349    }
350}