1use 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#[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 pub fn abort(&self) {
37 self.inner.abort();
38 }
39
40 #[must_use]
42 pub fn is_finished(&self) -> bool {
43 self.inner.is_finished()
44 }
45
46 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#[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#[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
142async 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#[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#[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 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
217pub 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 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 const DEADLINE: Duration = Duration::from_secs(10);
274
275 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 let _ = go_rx.recv();
520 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 #[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 #[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 #[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}