scoped_join_set/
lib.rs

1//! A scoped, lifetime-aware wrapper around [`tokio::task::JoinSet`].
2//!
3//! `ScopedJoinSet` enables dynamically spawning async tasks whose futures are
4//! tied to a specific Rust lifetime `'scope`. This provides a safe alternative
5//! to hand-managed `unsafe` approaches while enabling advanced async task
6//! orchestration where futures do **not** need to be `'static`.
7//!
8//! Each spawned task is split into:
9//!
10//! - A [`FutureHolder`], owning the actual boxed future and ensuring proper drop
11//!   ordering.
12//! - A [`WeakFuture`] (inside the `JoinSet`), which the Tokio runtime polls.
13//!
14//! When the [`ScopedJoinSet`] is dropped, all tasks are aborted and all future
15//! memory is reclaimed safely.
16//!
17//! # Guarantees
18//!
19//! - Futures spawned via [`ScopedJoinSet::spawn`] cannot outlive `'scope`.
20//! - `join_next()` is cancellation-safe.
21//! - Dropping the entire set aborts all tasks.
22//!
23//! # Example
24//!
25//! ```
26//! use scoped_join_set::ScopedJoinSet;
27//!
28//! #[tokio::main]
29//! async fn main() {
30//!     let temporary_value = Box::new(5);
31//!     let value_ref = &temporary_value;
32//!
33//!     let mut set = ScopedJoinSet::<u32>::new();
34//!
35//!     set.spawn(async {
36//!         println!("I have a {value_ref}");
37//!         **value_ref
38//!     });
39//!
40//!     while let Some(result) = set.join_next().await {
41//!         assert_eq!(result.unwrap(), *temporary_value);
42//!     }
43//! }
44//! ```
45
46mod future_wrappers;
47mod lifetime_obfuscation;
48
49use std::{any::Any, collections::HashMap, error::Error, fmt, future::Future, marker::PhantomData};
50
51use tokio::task::{Id, JoinSet};
52
53use crate::{
54    future_wrappers::{split_spawn_task, FutureHolder},
55    lifetime_obfuscation::{from_bytes_unchecked, obfuscate_lifetime},
56};
57
58type TokioJoinError = tokio::task::JoinError;
59
60/// A scoped wrapper around [`tokio::task::JoinSet`] for dynamically spawning
61/// futures with a lifetime `'scope`.
62///
63/// Unlike normal Tokio tasks requiring `'static` futures, this set accepts
64/// futures tied to a temporary stack lifetime, as long as the
65/// `ScopedJoinSet` lives long enough.
66///
67/// Each call to [`spawn`](Self::spawn) places a non-`'static` future into the
68/// runtime via a controlled internal mechanism (`FutureHolder` + `WeakFuture`).
69///
70/// # Type Parameters
71/// - `'scope`: The lifetime during which spawned futures must live.
72/// - `T`: The output of each spawned future.
73#[derive(Default)]
74pub struct ScopedJoinSet<'scope, T>
75where
76    T: 'scope + Send,
77{
78    join_set: JoinSet<Option<Box<[u8]>>>,
79    holders: HashMap<Id, FutureHolder<'scope, Box<[u8]>>>,
80    _marker: PhantomData<T>,
81}
82
83impl<'scope, T> ScopedJoinSet<'scope, T>
84where
85    T: 'scope + Send,
86{
87    /// Creates an empty `ScopedJoinSet`.
88    pub fn new() -> Self {
89        Self {
90            join_set: JoinSet::new(),
91            holders: HashMap::new(),
92            _marker: PhantomData,
93        }
94    }
95
96    /// Spawns a new scoped future into the set.
97    ///
98    /// This function is analogous to [`tokio::spawn`], but accepts futures with
99    /// the given scope lifetime.
100    ///
101    /// # Parameters
102    /// - `task`: The user future to spawn.
103    ///
104    /// # Requirements
105    /// - The returned value `T` must be `Send`.
106    /// - The future must be `Send` and live at least `'scope`.
107    pub fn spawn<F>(&mut self, task: F)
108    where
109        F: Future<Output = T> + Send + 'scope,
110        T: Send,
111    {
112        let task = obfuscate_lifetime(task);
113        let (id, holder) = split_spawn_task(&mut self.join_set, task);
114        self.holders.insert(id, holder);
115    }
116
117    /// Returns whether no tasks remain.
118    ///
119    /// Equivalent to [`JoinSet::is_empty`].
120    pub fn is_empty(&self) -> bool {
121        self.join_set.is_empty()
122    }
123
124    /// Returns the number of tasks currently stored in the set.
125    ///
126    /// Equivalent to [`JoinSet::len`].
127    pub fn len(&self) -> usize {
128        self.holders.len()
129    }
130
131    /// Waits for the next completed task in the set.
132    ///
133    /// Returns:
134    /// - `Some(Ok(T))` on success.
135    /// - `Some(Err(JoinError))` on cancellation or panic.
136    /// - `None` when the set is empty.
137    ///
138    /// This method is **cancellation-safe**: dropping the future returned by
139    /// `.await` does not remove the task from the set.
140    pub async fn join_next(&mut self) -> Option<Result<T, JoinError>> {
141        match self.join_set.join_next_with_id().await? {
142            Ok((id, Some(bytes))) => {
143                self.holders.remove(&id);
144
145                // SAFETY: By having `T` as the type argument, reconstruction needs to be successful.
146                let value = unsafe { from_bytes_unchecked(&bytes) };
147
148                Some(Ok(value))
149            }
150            Ok((id, None)) => {
151                self.holders.remove(&id);
152                Some(Err(JoinError::Cancelled))
153            }
154            Err(error) => {
155                self.holders.remove(&error.id());
156                Some(Err(error.into()))
157            }
158        }
159    }
160
161    /// Attempts to join one completed task without awaiting.
162    ///
163    /// This method is **non-blocking** and returns immediately.
164    ///
165    /// Returns:
166    /// - `Some(Ok(T))` if a task has already completed successfully,
167    /// - `Some(Err(JoinError))` if a completed task was cancelled or panicked,
168    /// - `None` if **no** tasks have completed yet **or** the set is empty.
169    ///
170    /// This differs from [`join_next`](Self::join_next):
171    /// - `join_next()` **awaits** the next completed task.
172    /// - `try_join_next()` returns immediately without awaiting.
173    ///
174    /// # Cancellation Safety
175    ///
176    /// This method is always cancellation-safe, because it does not perform
177    /// any async operations and does not remove tasks unless they have
178    /// already finished.
179    pub fn try_join_next(&mut self) -> Option<Result<T, JoinError>> {
180        match self.join_set.try_join_next_with_id()? {
181            Ok((id, Some(bytes))) => {
182                // Remove the holder before reconstructing the value.
183                self.holders.remove(&id);
184
185                // SAFETY: The bytes originated from a valid instance of T.
186                let value = unsafe { from_bytes_unchecked(&bytes) };
187                Some(Ok(value))
188            }
189            Ok((id, None)) => {
190                self.holders.remove(&id);
191                Some(Err(JoinError::Cancelled))
192            }
193            Err(error) => {
194                self.holders.remove(&error.id());
195                Some(Err(error.into()))
196            }
197        }
198    }
199
200    /// Waits for all tasks in the set to complete, consuming the set and
201    /// returning a list of results.
202    ///
203    /// The returned results are in **completion order**, not spawn order.
204    ///
205    /// # Cancellation Safety
206    /// If this future is dropped, remaining tasks stay inside the set.
207    pub async fn join_all(mut self) -> Vec<Result<T, JoinError>> {
208        let mut results = Vec::with_capacity(self.len());
209        while let Some(res) = self.join_next().await {
210            results.push(res);
211        }
212        results
213    }
214
215    /// Aborts all currently-running tasks.
216    ///
217    /// This does **not** await task completion; it merely issues abort signals.
218    /// To also wait for them, call [`join_remaining`](#method.join_remaining)
219    /// or drop the entire set.
220    ///
221    /// After calling `abort_all`, the set will still contain all tasks until
222    /// they complete their aborts and are polled again.
223    pub fn abort_all(&mut self) {
224        for holder in self.holders.values_mut() {
225            holder.abort();
226        }
227    }
228
229    /// Aborts all tasks *and then waits for them to finish*.
230    ///
231    /// Unlike dropping the set, `shutdown` consumes it gracefully.
232    pub async fn shutdown(mut self) -> Vec<Result<T, JoinError>> {
233        self.abort_all();
234        self.join_all().await
235    }
236}
237
238unsafe impl<'scope, T: Send> Send for ScopedJoinSet<'scope, T> {}
239
240/// Errors returned when joining scoped tasks.
241#[derive(Debug)]
242pub enum JoinError {
243    /// The task was cancelled or aborted.
244    Cancelled,
245    /// The task panicked and its panic payload is returned.
246    Panicked(Box<dyn Any + Send + 'static>),
247}
248
249impl fmt::Display for JoinError {
250    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
251        match self {
252            JoinError::Cancelled => write!(f, "task was cancelled"),
253            JoinError::Panicked(_) => write!(f, "task panicked"),
254        }
255    }
256}
257
258impl Error for JoinError {}
259
260impl JoinError {
261    /// Returns `true` if the task was cancelled.
262    pub fn is_cancelled(&self) -> bool {
263        matches!(self, JoinError::Cancelled)
264    }
265
266    /// Returns `true` if the task panicked.
267    pub fn is_panic(&self) -> bool {
268        matches!(self, JoinError::Panicked(_))
269    }
270}
271
272impl From<TokioJoinError> for JoinError {
273    fn from(err: TokioJoinError) -> Self {
274        if err.is_cancelled() {
275            JoinError::Cancelled
276        } else if err.is_panic() {
277            JoinError::Panicked(err.into_panic())
278        } else {
279            JoinError::Cancelled
280        }
281    }
282}