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