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}