teksilo_async/blocking.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`spawn_blocking`] — run a blocking closure on a throwaway OS thread and
5//! await its result on the main-thread executor, with **zero** async runtime.
6
7use std::any::Any;
8use std::future::Future;
9
10/// Why a [`spawn_blocking`] future resolved to an error instead of a value.
11#[derive(Debug, thiserror::Error)]
12pub enum BlockingError {
13 /// The closure panicked on the worker thread. The string is the panic
14 /// message when one could be extracted from the payload.
15 #[error("spawn_blocking closure panicked: {0}")]
16 Panicked(String),
17 /// The worker thread ended without sending a result. Should not happen in
18 /// practice (the panic path is caught above); kept so the future always
19 /// resolves rather than hanging.
20 #[error("spawn_blocking worker ended without sending a result")]
21 WorkerVanished,
22}
23
24/// Run `f` on a dedicated OS thread and resolve to its return value. Await the
25/// returned future inside a
26/// [`spawn_local`](crate::AsyncRuntimeHandle::spawn_local) body to keep
27/// heavy/blocking work off the UI thread:
28///
29/// ```ignore
30/// ctx.spawn_local(async move {
31/// match teksilo_async::spawn_blocking(move || std::fs::read(path)).await {
32/// Ok(bytes) => loaded.set(bytes.ok()), // back on the UI thread
33/// Err(e) => status.set(format!("load failed: {e}")),
34/// }
35/// })
36/// .detach();
37/// ```
38///
39/// Needs no async runtime: the worker is a plain `std::thread`, and the result
40/// crosses back through a one-shot channel whose waker nudges the executor.
41/// This is the runtime-free path; `teksilo-tokio` / `teksilo-async-std` add the
42/// ability to `.await` native-ecosystem futures (timers, sockets) directly.
43///
44/// A panic in `f` is caught on the worker thread and surfaced as
45/// [`BlockingError::Panicked`] — it does **not** unwind through the UI thread.
46pub fn spawn_blocking<T, F>(f: F) -> impl Future<Output = Result<T, BlockingError>>
47where
48 F: FnOnce() -> T + Send + 'static,
49 T: Send + 'static,
50{
51 let (tx, rx) = async_channel::bounded::<Result<T, BlockingError>>(1);
52 std::thread::Builder::new()
53 .name("teksilo-async-blocking".to_string())
54 .spawn(move || {
55 // Catch a panic in `f` so a misbehaving closure reports an error
56 // instead of unwinding through the UI thread's executor tick.
57 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f))
58 .map_err(|payload| BlockingError::Panicked(panic_message(payload)));
59 // Capacity 1, single send — `try_send` only fails if the receiver
60 // was dropped (the spawning task was cancelled); the value is then
61 // simply discarded.
62 let _ = tx.try_send(outcome);
63 })
64 .expect("teksilo-async: failed to spawn blocking worker thread");
65 async move {
66 rx.recv()
67 .await
68 .unwrap_or(Err(BlockingError::WorkerVanished))
69 }
70}
71
72/// Best-effort extraction of a human-readable message from a panic payload.
73fn panic_message(payload: Box<dyn Any + Send>) -> String {
74 if let Some(s) = payload.downcast_ref::<&'static str>() {
75 (*s).to_string()
76 } else if let Some(s) = payload.downcast_ref::<String>() {
77 s.clone()
78 } else {
79 "<non-string panic payload>".to_string()
80 }
81}