Skip to main content

spate_core/
task.rs

1//! Spawning tokio tasks and OS threads through handles that own them.
2//!
3//! [`spawn`] and [`spawn_on`] start a tokio task and return a [`TaskHandle`],
4//! which aborts the task when dropped. [`spawn_thread`] starts a named OS
5//! thread and returns a [`ThreadHandle`], which joins the thread when dropped.
6//! Each spawn takes a name, and a panic in the task or thread is logged at
7//! `ERROR` with that name. The panic still reaches a caller that awaits or
8//! joins the handle.
9
10use std::any::Any;
11use std::future::{Future, poll_fn};
12use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind};
13use std::pin::Pin;
14use std::task::{Context, Poll};
15
16use tokio::task::{JoinError, JoinHandle};
17
18/// An owned tokio task, aborted when the handle drops.
19///
20/// Dropping the handle requests cancellation and returns at once: the task's
21/// future is dropped at its next yield point, possibly after the drop
22/// returns. A task blocking its thread, never yielding, is not stopped.
23///
24/// Awaiting the handle yields the task's output, or a [`JoinError`] when the
25/// task panicked or was aborted. The await is cancel safe: polling stops
26/// without losing the output, and the handle can be awaited again.
27#[must_use = "dropping a TaskHandle aborts its task"]
28pub struct TaskHandle<T> {
29    name: &'static str,
30    inner: JoinHandle<T>,
31    abort_on_drop: bool,
32}
33
34impl<T> TaskHandle<T> {
35    /// Request cancellation of the task, as dropping the handle does.
36    pub fn abort(&self) {
37        self.inner.abort();
38    }
39
40    /// Whether the task has ended, by returning, panicking or being aborted.
41    #[must_use]
42    pub fn is_finished(&self) -> bool {
43        self.inner.is_finished()
44    }
45
46    /// Let the task run on with no owner.
47    ///
48    /// It then ends only when its future returns or its runtime shuts down. A
49    /// panic in it is still logged.
50    pub fn detach(mut self) {
51        self.abort_on_drop = false;
52    }
53}
54
55impl<T> Drop for TaskHandle<T> {
56    fn drop(&mut self) {
57        if self.abort_on_drop {
58            self.inner.abort();
59        }
60    }
61}
62
63impl<T> Future for TaskHandle<T> {
64    type Output = Result<T, JoinError>;
65
66    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
67        Pin::new(&mut self.inner).poll(cx)
68    }
69}
70
71impl<T> std::fmt::Debug for TaskHandle<T> {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        f.debug_struct("TaskHandle")
74            .field("name", &self.name)
75            .field("id", &self.inner.id())
76            .finish_non_exhaustive()
77    }
78}
79
80/// Spawn `future` as a task named `name` on the current tokio runtime.
81///
82/// Returns without awaiting or waiting on the task, so a non-blocking path may
83/// call it; tokio takes a brief internal lock to queue the task. A panic in
84/// the task is logged at `ERROR` with `name`.
85///
86/// # Panics
87///
88/// When called outside a tokio runtime, as [`tokio::spawn`] does.
89///
90/// # Examples
91///
92/// ```
93/// # #[tokio::main(flavor = "current_thread")]
94/// # async fn main() {
95/// let handle = spate_core::task::spawn("answer", async { 6 * 7 });
96/// assert_eq!(handle.await.unwrap(), 42);
97///
98/// // Dropping a handle aborts its task.
99/// drop(spate_core::task::spawn("forever", std::future::pending::<()>()));
100/// # }
101/// ```
102#[expect(
103    clippy::disallowed_methods,
104    reason = "the returned `TaskHandle` aborts the task when dropped"
105)]
106pub fn spawn<F>(name: &'static str, future: F) -> TaskHandle<F::Output>
107where
108    F: Future + Send + 'static,
109    F::Output: Send + 'static,
110{
111    owned(name, tokio::spawn(logged(name, future)))
112}
113
114/// Spawn `future` as a task named `name` on the runtime behind `handle`.
115///
116/// Returns without awaiting or waiting on the task, as [`spawn`] does, and may
117/// be called from outside any runtime.
118#[expect(
119    clippy::disallowed_methods,
120    reason = "the returned `TaskHandle` aborts the task when dropped"
121)]
122pub fn spawn_on<F>(
123    name: &'static str,
124    future: F,
125    handle: &tokio::runtime::Handle,
126) -> TaskHandle<F::Output>
127where
128    F: Future + Send + 'static,
129    F::Output: Send + 'static,
130{
131    owned(name, handle.spawn(logged(name, future)))
132}
133
134fn owned<T>(name: &'static str, inner: JoinHandle<T>) -> TaskHandle<T> {
135    TaskHandle {
136        name,
137        inner,
138        abort_on_drop: true,
139    }
140}
141
142/// `future`, logging a panic in any poll before the panic resumes.
143///
144/// The resumed panic keeps `JoinError::is_panic` for a caller awaiting the
145/// task. An abort drops the future without unwinding, so it logs nothing.
146async fn logged<F: Future>(name: &'static str, future: F) -> F::Output {
147    let mut future = std::pin::pin!(future);
148    poll_fn(
149        |cx| match catch_unwind(AssertUnwindSafe(|| future.as_mut().poll(cx))) {
150            Ok(poll) => poll,
151            Err(payload) => {
152                tracing::error!(
153                    task = name,
154                    panic = panic_text(&*payload),
155                    "background task panicked"
156                );
157                resume_unwind(payload)
158            }
159        },
160    )
161    .await
162}
163
164/// The text of a panic payload: the message of a `&str` or `String` payload,
165/// and a fixed placeholder for any other type.
166#[must_use]
167pub fn panic_text(payload: &(dyn Any + Send)) -> &str {
168    if let Some(text) = payload.downcast_ref::<&'static str>() {
169        text
170    } else if let Some(text) = payload.downcast_ref::<String>() {
171        text
172    } else {
173        "non-string panic payload"
174    }
175}
176
177/// An owned OS thread, joined when the handle drops.
178///
179/// Dropping the handle blocks until the thread ends, so only an owner whose
180/// thread ends on a bounded signal holds one. A panic in the thread was
181/// logged when it happened; the drop discards it.
182#[must_use = "dropping a ThreadHandle blocks until its thread ends"]
183pub struct ThreadHandle<T> {
184    inner: Option<std::thread::JoinHandle<T>>,
185}
186
187impl<T> ThreadHandle<T> {
188    /// Wait for the thread to end and return its result.
189    ///
190    /// # Errors
191    ///
192    /// The panic payload when the thread panicked.
193    pub fn join(mut self) -> std::thread::Result<T> {
194        match self.inner.take() {
195            Some(inner) => inner.join(),
196            None => unreachable!("only `join` and `drop` take the handle, each once"),
197        }
198    }
199}
200
201impl<T> Drop for ThreadHandle<T> {
202    fn drop(&mut self) {
203        if let Some(inner) = self.inner.take() {
204            let _ = inner.join();
205        }
206    }
207}
208
209impl<T> std::fmt::Debug for ThreadHandle<T> {
210    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211        f.debug_struct("ThreadHandle")
212            .field("name", &self.inner.as_ref().and_then(|h| h.thread().name()))
213            .finish_non_exhaustive()
214    }
215}
216
217/// Spawn `f` on a new OS thread named `name`.
218///
219/// The thread logs to the `tracing` subscriber that is the default where
220/// `spawn_thread` is called, or to the global default when none is set yet.
221/// A panic in `f` is logged at `ERROR` with `name`
222/// and returned by [`ThreadHandle::join`].
223///
224/// # Errors
225///
226/// The OS error when the thread cannot be created.
227pub fn spawn_thread<F, T>(name: &'static str, f: F) -> std::io::Result<ThreadHandle<T>>
228where
229    F: FnOnce() -> T + Send + 'static,
230    T: Send + 'static,
231{
232    // With no subscriber set, the thread looks the default up when it logs,
233    // so a global subscriber installed after the spawn still applies.
234    let dispatch = tracing::dispatcher::get_default(|d| {
235        (!d.is::<tracing::subscriber::NoSubscriber>()).then(|| d.clone())
236    });
237    #[expect(
238        clippy::disallowed_methods,
239        reason = "the returned `ThreadHandle` joins the thread when dropped"
240    )]
241    let inner = std::thread::Builder::new()
242        .name(name.to_owned())
243        .spawn(move || {
244            let run = move || match catch_unwind(AssertUnwindSafe(f)) {
245                Ok(output) => output,
246                Err(payload) => {
247                    tracing::error!(
248                        thread = name,
249                        panic = panic_text(&*payload),
250                        "background thread panicked"
251                    );
252                    resume_unwind(payload)
253                }
254            };
255            match dispatch {
256                Some(dispatch) => tracing::dispatcher::with_default(&dispatch, run),
257                None => run(),
258            }
259        })?;
260    Ok(ThreadHandle { inner: Some(inner) })
261}
262
263#[cfg(all(test, not(loom)))]
264mod tests {
265    use super::*;
266    use std::sync::atomic::{AtomicBool, Ordering};
267    use std::sync::{Arc, Mutex, mpsc};
268    use std::time::Duration;
269    use tokio::sync::oneshot;
270
271    /// How long a test waits on a signal. Time is paused, so a signal that
272    /// never comes fails the test at once.
273    const DEADLINE: Duration = Duration::from_secs(10);
274
275    /// Sends on its channel when dropped, which shows the future holding it
276    /// was dropped.
277    struct DropSignal(Option<oneshot::Sender<()>>);
278
279    impl Drop for DropSignal {
280        fn drop(&mut self) {
281            if let Some(tx) = self.0.take() {
282                let _ = tx.send(());
283            }
284        }
285    }
286
287    fn drop_signal() -> (DropSignal, oneshot::Receiver<()>) {
288        let (tx, rx) = oneshot::channel();
289        (DropSignal(Some(tx)), rx)
290    }
291
292    async fn signalled(rx: oneshot::Receiver<()>) {
293        tokio::time::timeout(DEADLINE, rx)
294            .await
295            .expect("signal within the deadline")
296            .expect("signal sent");
297    }
298
299    /// The `ERROR` lines logged while `f` runs, without time or target.
300    fn error_lines<R>(f: impl FnOnce() -> R) -> (R, Vec<String>) {
301        #[derive(Clone, Default)]
302        struct Buf(Arc<Mutex<Vec<u8>>>);
303        impl std::io::Write for Buf {
304            fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
305                self.0.lock().unwrap().extend_from_slice(bytes);
306                Ok(bytes.len())
307            }
308            fn flush(&mut self) -> std::io::Result<()> {
309                Ok(())
310            }
311        }
312        let buf = Buf::default();
313        let writer = buf.clone();
314        let subscriber = tracing_subscriber::fmt()
315            .with_writer(move || writer.clone())
316            .with_ansi(false)
317            .without_time()
318            .with_target(false)
319            .finish();
320        let out = tracing::subscriber::with_default(subscriber, f);
321        let text = String::from_utf8(buf.0.lock().unwrap().clone()).unwrap();
322        let lines = text
323            .lines()
324            .filter(|l| l.trim_start().starts_with("ERROR"))
325            .map(|l| l.trim().to_owned())
326            .collect();
327        (out, lines)
328    }
329
330    /// Runs `f` on a paused current-thread runtime on this thread, so its
331    /// tasks log to the subscriber [`error_lines`] installs.
332    fn paused<F: Future>(f: F) -> F::Output {
333        tokio::runtime::Builder::new_current_thread()
334            .enable_time()
335            .start_paused(true)
336            .build()
337            .unwrap()
338            .block_on(f)
339    }
340
341    /// Pins that dropping a handle aborts its task: the task's future is
342    /// dropped although it never completes.
343    #[test]
344    fn dropping_the_handle_aborts_the_task() {
345        paused(async {
346            let (guard, dropped) = drop_signal();
347            let handle = spawn("pending", async move {
348                let _guard = guard;
349                std::future::pending::<()>().await;
350            });
351            tokio::task::yield_now().await;
352            assert!(!handle.is_finished());
353            drop(handle);
354            signalled(dropped).await;
355        });
356    }
357
358    /// Pins that a handle dropped on a thread outside the runtime still
359    /// aborts its task.
360    #[test]
361    fn dropping_the_handle_off_the_runtime_aborts_the_task() {
362        paused(async {
363            let (guard, dropped) = drop_signal();
364            let handle = spawn("pending", async move {
365                let _guard = guard;
366                std::future::pending::<()>().await;
367            });
368            tokio::task::yield_now().await;
369            std::thread::scope(|s| {
370                s.spawn(move || drop(handle));
371            });
372            signalled(dropped).await;
373        });
374    }
375
376    /// Pins that dropping the handle of a task that returned logs nothing.
377    #[test]
378    fn dropping_a_finished_handle_logs_nothing() {
379        let ((), errors) = error_lines(|| {
380            paused(async {
381                let mut handle = spawn("finished", async { 1 });
382                assert_eq!((&mut handle).await.unwrap(), 1);
383                assert!(handle.is_finished());
384                drop(handle);
385            });
386        });
387        assert_eq!(errors, Vec::<String>::new());
388    }
389
390    /// Pins that a handle dropped after its runtime shut down neither panics
391    /// nor logs.
392    #[test]
393    fn a_handle_outliving_its_runtime_drops_quietly() {
394        let ((), errors) = error_lines(|| {
395            let runtime = tokio::runtime::Builder::new_current_thread()
396                .build()
397                .unwrap();
398            let handle = spawn_on("orphan", std::future::pending::<()>(), runtime.handle());
399            drop(runtime);
400            drop(handle);
401        });
402        assert_eq!(errors, Vec::<String>::new());
403    }
404
405    /// Pins that awaiting a handle yields the task's output.
406    #[test]
407    fn awaiting_the_handle_yields_the_output() {
408        let output = paused(async { spawn("answer", async { 6 * 7 }).await.unwrap() });
409        assert_eq!(output, 42);
410    }
411
412    /// Pins that a detached task keeps running after its handle is gone.
413    #[test]
414    fn a_detached_task_runs_to_completion() {
415        paused(async {
416            let (go_tx, go_rx) = oneshot::channel::<()>();
417            let (done_tx, done_rx) = oneshot::channel::<()>();
418            spawn("detached", async move {
419                go_rx.await.unwrap();
420                done_tx.send(()).unwrap();
421            })
422            .detach();
423            tokio::task::yield_now().await;
424            go_tx.send(()).unwrap();
425            signalled(done_rx).await;
426        });
427    }
428
429    /// Pins that a panic in a task nobody awaits is logged once at `ERROR`,
430    /// with the task's name and the panic's text, for a `&str` and a
431    /// `String` payload.
432    #[test]
433    fn a_dropped_handles_panic_is_logged_with_its_name() {
434        let ((), errors) = error_lines(|| {
435            paused(async {
436                let (guard, dropped) = drop_signal();
437                let handle = spawn("static-panic", async move {
438                    let _guard = guard;
439                    panic!("boom");
440                });
441                signalled(dropped).await;
442                drop(handle);
443
444                let (guard, dropped) = drop_signal();
445                let handle = spawn("formatted-panic", async move {
446                    let _guard = guard;
447                    panic!("boom {}", 1);
448                });
449                signalled(dropped).await;
450                drop(handle);
451            });
452        });
453        assert_eq!(
454            errors,
455            vec![
456                r#"ERROR background task panicked task="static-panic" panic="boom""#.to_owned(),
457                r#"ERROR background task panicked task="formatted-panic" panic="boom 1""#
458                    .to_owned(),
459            ]
460        );
461    }
462
463    /// Pins that aborting a task is not reported as a panic.
464    #[test]
465    fn an_aborted_task_logs_no_panic() {
466        let ((), errors) = error_lines(|| {
467            paused(async {
468                let (guard, dropped) = drop_signal();
469                let handle = spawn("aborted", async move {
470                    let _guard = guard;
471                    std::future::pending::<()>().await;
472                });
473                tokio::task::yield_now().await;
474                handle.abort();
475                signalled(dropped).await;
476                assert!(handle.await.unwrap_err().is_cancelled());
477            });
478        });
479        assert_eq!(errors, Vec::<String>::new());
480    }
481
482    /// Pins that an awaited panic reaches the awaiting caller as a panic
483    /// `JoinError`, and is logged once, by the helper.
484    #[test]
485    fn an_awaited_panic_is_returned_and_logged_once() {
486        let (joined, errors) = error_lines(|| {
487            paused(async {
488                spawn("awaited-panic", async {
489                    panic!("awaited boom");
490                })
491                .await
492            })
493        });
494        let err = joined.unwrap_err();
495        assert!(err.is_panic());
496        assert_eq!(panic_text(&*err.into_panic()), "awaited boom");
497        assert_eq!(
498            errors,
499            vec![
500                r#"ERROR background task panicked task="awaited-panic" panic="awaited boom""#
501                    .to_owned()
502            ]
503        );
504    }
505
506    /// Pins that a spawned thread carries its name and that dropping its
507    /// handle returns only once the thread has ended.
508    #[test]
509    fn a_spawned_thread_is_named_and_joined_on_drop() {
510        let name = Arc::new(Mutex::new(None));
511        let ended = Arc::new(AtomicBool::new(false));
512        let (go_tx, go_rx) = mpsc::channel::<()>();
513        let handle = {
514            let name = Arc::clone(&name);
515            let ended = Arc::clone(&ended);
516            spawn_thread("spate-test-thread", move || {
517                *name.lock().unwrap() = std::thread::current().name().map(str::to_owned);
518                // Returns once the sender drops.
519                let _ = go_rx.recv();
520                // Widens the window in which a drop that does not join
521                // would see the flag unset.
522                for _ in 0..1000 {
523                    std::thread::yield_now();
524                }
525                ended.store(true, Ordering::SeqCst);
526            })
527            .unwrap()
528        };
529        drop(go_tx);
530        drop(handle);
531        assert!(
532            ended.load(Ordering::SeqCst),
533            "drop returned before the thread ended"
534        );
535        assert_eq!(name.lock().unwrap().as_deref(), Some("spate-test-thread"));
536    }
537
538    /// Pins that each handle's `Debug` names what it owns.
539    #[test]
540    fn a_handle_debugs_as_its_name() {
541        let task = paused(async {
542            let handle = spawn("debugged-task", std::future::pending::<()>());
543            format!("{handle:?}")
544        });
545        assert!(
546            task.starts_with(r#"TaskHandle { name: "debugged-task", id: "#),
547            "{task}"
548        );
549        let thread = format!("{:?}", spawn_thread("debugged-thread", || ()).unwrap());
550        assert_eq!(
551            thread,
552            r#"ThreadHandle { name: Some("debugged-thread"), .. }"#
553        );
554    }
555
556    /// Pins that a thread's panic is logged once at `ERROR` with its name,
557    /// and returned by `join`.
558    #[test]
559    fn a_panicked_thread_is_logged_once() {
560        let (joined, errors) = error_lines(|| {
561            spawn_thread("panicking-thread", || -> () { panic!("thread boom") })
562                .unwrap()
563                .join()
564        });
565        let payload = joined.unwrap_err();
566        assert_eq!(panic_text(&*payload), "thread boom");
567        assert_eq!(
568            errors,
569            vec![
570                r#"ERROR background thread panicked thread="panicking-thread" panic="thread boom""#
571                    .to_owned()
572            ]
573        );
574    }
575
576    /// Pins that each spawn path `clippy.toml` bans with no call site of its
577    /// own resolves: clippy reports a banned call only at a path it resolves,
578    /// so an entry naming no item leaves its `expect` here unfulfilled.
579    #[test]
580    fn banned_spawn_paths_resolve() {
581        let runtime = tokio::runtime::Builder::new_current_thread()
582            .build()
583            .unwrap();
584        #[expect(
585            clippy::disallowed_methods,
586            reason = "proves the `Runtime::spawn_blocking` entry resolves; awaited below"
587        )]
588        let on_runtime = runtime.spawn_blocking(|| 1);
589        let total = runtime.block_on(async {
590            let handle = tokio::runtime::Handle::current();
591            #[expect(
592                clippy::disallowed_methods,
593                reason = "proves the `Handle::spawn_blocking` entry resolves; awaited below"
594            )]
595            let on_handle = handle.spawn_blocking(|| 2);
596            let mut set = tokio::task::JoinSet::new();
597            #[expect(
598                clippy::disallowed_methods,
599                reason = "proves the `JoinSet::spawn_on` entry resolves; joined below"
600            )]
601            set.spawn_on(async { 3 }, &handle);
602            #[expect(
603                clippy::disallowed_methods,
604                reason = "proves the `JoinSet::spawn_blocking` entry resolves; joined below"
605            )]
606            set.spawn_blocking(|| 4);
607            #[expect(
608                clippy::disallowed_methods,
609                reason = "proves the `JoinSet::spawn_blocking_on` entry resolves; joined below"
610            )]
611            set.spawn_blocking_on(|| 5, &handle);
612            let mut total = on_runtime.await.unwrap() + on_handle.await.unwrap();
613            while let Some(joined) = set.join_next().await {
614                total += joined.unwrap();
615            }
616            total
617        });
618        assert_eq!(total, 15);
619    }
620}