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}