Skip to main content

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}