scoped_join_set/
lib.rs

1//! A scoped wrapper around [`tokio::task::JoinSet`] allowing non-`'static`
2//! futures to be spawned safely.
3//!
4//! # What `ScopedJoinSet` provides
5//!
6//! Tokio requires all spawned tasks to be `'static`. `ScopedJoinSet` lifts
7//! this restriction by:
8//!
9//! - allocating each user future and its output slot on the heap,
10//! - pinning the wrapper future so it never moves,
11//! - unsafely casting it to `'static` (sound because the allocation is kept
12//!   alive until the task completes),
13//! - storing each task’s output in `Box<Option<T>>`,
14//! - retrieving and deallocating that output when the task completes.
15//!
16//! This lets users write:
17//!
18//! # Example
19//!
20//! ```rust
21//! use scoped_join_set::ScopedJoinSet;
22//!
23//! #[tokio::main]
24//! async fn main() {
25//!     let temporary_value = Box::new(5);
26//!     let value_ref = &temporary_value;
27//!
28//!     let mut set = ScopedJoinSet::<u32>::new();
29//!
30//!     set.spawn(async {
31//!         println!("I have a {value_ref}");
32//!         **value_ref
33//!     });
34//!
35//!     while let Some(result) = set.join_next().await {
36//!         assert_eq!(result.unwrap(), *temporary_value);
37//!     }
38//!     set.shutdown().await;
39//! }
40//! ```
41//!
42//! without requiring the future to be `'static`.
43//!
44//! # How it works internally
45//!
46//! 1. `spawn()` allocates a `Box<MaybeUninit<T>>` for the result and leaks it.
47//! 2. The user future is wrapped in `WriteOutput`, which writes its output
48//!    into this slot.
49//! 3. The wrapper future is pinned and unsafely converted to `'static`.
50//! 4. Tokio polls the wrapper and returns a raw pointer identifying the
51//!    output slot.
52//! 5. `join_next()` converts that pointer back, reads the output, and frees
53//!    the allocation.
54//!
55//! No hashmaps or external bookkeeping structures are used—the pointer
56//! returned by each completed task uniquely identifies its output slot.  
57//! There is *no* self-referential struct and no dependence on pinning beyond
58//! ensuring that the wrapped future remains immovable.
59//!
60//! # Important: Explicit Shutdown Required
61//!
62//! A `ScopedJoinSet` **must be terminated explicitly** using either
63//!
64//! - [`ScopedJoinSet::shutdown()`], or
65//! - [`ScopedJoinSet::join_all()`],
66//!
67//! before it is dropped.
68//!
69//! Dropping a `ScopedJoinSet` implicitly (e.g., letting it go out of scope
70//! without calling one of the methods above) is considered a logic error.
71//!
72//! To prevent accidental misuse — which could otherwise cause tasks holding
73//! references into the stack to outlive their scope — the destructor prints an
74//! error message and **aborts the process**.
75
76mod future_wrappers;
77
78use crate::future_wrappers::{SendPtr, WriteOutput};
79use std::{
80    any::Any, error::Error, fmt, future::Future, marker::PhantomData, mem::MaybeUninit,
81    process::abort, ptr::NonNull,
82};
83use tokio::task::JoinSet;
84
85type TokioJoinError = tokio::task::JoinError;
86
87/// A scoped task set analogous to [`tokio::task::JoinSet`] but accepting
88/// non-`'static` futures.
89///
90/// `ScopedJoinSet` allows spawning futures that borrow data with lifetime
91/// `'scope`, while still being executed by Tokio, which normally requires
92/// `'static` futures.  
93///
94/// Each task’s result is written into a dedicated heap allocation and later
95/// recovered by `join_next()`. No maps, arenas, or auxiliary structures are
96/// used—the completed task returns a raw pointer identifying its output slot.
97///
98/// Results are yielded in completion order.
99/// non-`'static` futures.
100///
101/// # Mandatory explicit shutdown
102///
103/// A `ScopedJoinSet` **must** be terminated using either
104/// [`ScopedJoinSet::shutdown()`] or [`ScopedJoinSet::join_all()`].
105///
106/// If dropped without having called one of these methods, the destructor
107/// prints an error message and **aborts the process** to prevent tasks from
108/// escaping the scope.
109///
110/// This rule ensures that tasks which borrow stack data never outlive that
111/// data.
112#[derive(Default)]
113pub struct ScopedJoinSet<'scope, T>
114where
115    T: 'scope + Send,
116{
117    join_set: JoinSet<SendPtr>,
118    _marker: PhantomData<&'scope T>,
119    _safe_to_drop: bool,
120}
121
122impl<'scope, T> ScopedJoinSet<'scope, T>
123where
124    T: 'scope + Send,
125{
126    /// Create an empty `ScopedJoinSet`.
127    pub fn new() -> Self {
128        Self {
129            join_set: JoinSet::new(),
130            _marker: PhantomData,
131            _safe_to_drop: false,
132        }
133    }
134
135    /// Spawns a non-`'static` future into the set.
136    ///
137    /// The future's output is written into a heap-allocated slot owned by the
138    /// set. The future itself is wrapped and safely promoted to `'static`
139    /// because `ScopedJoinSet` guarantees that its allocation outlives the task.
140    pub fn spawn<F>(&mut self, task: F)
141    where
142        F: Future<Output = T> + Send + 'scope,
143        T: Send,
144    {
145        // Allocate output slot.
146        let output: NonNull<MaybeUninit<T>> = Box::leak(Box::new(MaybeUninit::uninit())).into();
147
148        // Wrap + convert to 'static future.
149        let task = WriteOutput::new(task, output.cast());
150        let task = unsafe { task.into_static() };
151
152        self.join_set.spawn(task);
153    }
154
155    /// Returns `true` if no tasks remain.
156    pub fn is_empty(&self) -> bool {
157        self.join_set.is_empty()
158    }
159
160    /// Returns the number of active tasks.
161    pub fn len(&self) -> usize {
162        self.join_set.len()
163    }
164
165    /// Waits for the next task to complete and returns its result.
166    ///
167    /// On success, returns the output value produced by the task.  
168    /// On failure, returns a [`JoinError`].  
169    ///
170    /// The associated output allocation is reclaimed automatically.
171    pub async fn join_next(&mut self) -> Option<Result<T, JoinError>> {
172        match self.join_set.join_next().await? {
173            Ok(output) => {
174                let output: NonNull<T> = output.cast();
175                let output = unsafe { *Box::from_raw(output.as_ptr()) };
176                Some(Ok(output))
177            }
178            Err(error) => Some(Err(error.into())),
179        }
180    }
181
182    /// Non-async version of [`join_next`].
183    ///
184    /// Returns immediately if no task has finished polling.
185    pub fn try_join_next(&mut self) -> Option<Result<T, JoinError>> {
186        match self.join_set.try_join_next()? {
187            Ok(output) => {
188                let output: NonNull<T> = output.cast();
189                let output = unsafe { *Box::from_raw(output.as_ptr()) };
190                Some(Ok(output))
191            }
192            Err(error) => Some(Err(error.into())),
193        }
194    }
195
196    /// Waits for the completion of all tasks and returns their results in
197    /// completion order.
198    pub async fn join_all(mut self) -> Vec<Result<T, JoinError>> {
199        let mut results = Vec::with_capacity(self.len());
200        while let Some(r) = self.join_next().await {
201            results.push(r);
202        }
203        self._safe_to_drop = true;
204        results
205    }
206
207    /// Aborts all running tasks.
208    ///
209    /// Any output slots associated with incomplete tasks are cleaned up
210    /// when the task wrapper is dropped.
211    pub fn abort_all(&mut self) {
212        self.join_set.abort_all();
213    }
214
215    /// Aborts all tasks and then waits for them to finish.
216    ///
217    /// Returns results for tasks that completed before the abort took effect.
218    pub async fn shutdown(mut self) -> Vec<Result<T, JoinError>> {
219        self.abort_all();
220        self.join_all().await
221    }
222}
223
224unsafe impl<'scope, T: Send> Send for ScopedJoinSet<'scope, T> {}
225
226impl<'scope, T> Drop for ScopedJoinSet<'scope, T>
227where
228    T: Send,
229{
230    /// Drops the set.
231    ///
232    /// # Panics / Aborts
233    ///
234    /// A `ScopedJoinSet` **cannot** be dropped implicitly.  
235    /// If `.join_all()` or `.shutdown()` has not been called beforehand,
236    /// this destructor prints a diagnostic and **aborts the process**.
237    ///
238    /// This is a safety guarantee: tasks may borrow stack data, and allowing
239    /// implicit drop would risk those tasks outliving their scope.
240    fn drop(&mut self) {
241        if !self._safe_to_drop {
242            eprintln!("ScopedJoinSet cannot be dropped implicitly. Use `.join_all().await` or `.shutdown().await`.");
243            abort();
244        }
245    }
246}
247
248/// Errors returned by scoped tasks.
249///
250/// A task may be cancelled (e.g., via `abort_all`) or may panic.
251/// Both cases are detected and surfaced by `ScopedJoinSet`.
252#[derive(Debug)]
253pub enum JoinError {
254    Cancelled,
255    Panicked(Box<dyn Any + Send + 'static>),
256}
257
258impl fmt::Display for JoinError {
259    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
260        match self {
261            JoinError::Cancelled => write!(f, "task was cancelled"),
262            JoinError::Panicked(_) => write!(f, "task panicked"),
263        }
264    }
265}
266
267impl Error for JoinError {}
268
269impl JoinError {
270    pub fn is_cancelled(&self) -> bool {
271        matches!(self, JoinError::Cancelled)
272    }
273
274    pub fn is_panic(&self) -> bool {
275        matches!(self, JoinError::Panicked(_))
276    }
277}
278
279impl From<TokioJoinError> for JoinError {
280    fn from(err: TokioJoinError) -> Self {
281        if err.is_cancelled() {
282            JoinError::Cancelled
283        } else if err.is_panic() {
284            JoinError::Panicked(err.into_panic())
285        } else {
286            JoinError::Cancelled
287        }
288    }
289}