Skip to main content

scoped_join_set/
lib.rs

1#![feature(async_fn_traits, unboxed_closures)]
2
3//! # Scoped Async Tasks
4//!
5//! This module provides a mechanism for spawning scoped asynchronous tasks.
6//! Unlike standard `tokio::spawn`, these tasks can borrow from the surrounding
7//! environment (non-`'static` futures) because they are guaranteed to complete
8//! before the scope exits.
9
10/// Internal module containing helper types for type-erasing futures and handling output pointers.
11mod future_wrappers;
12
13use crate::future_wrappers::{SendPtr, WriteOutput};
14use std::{
15    any::Any, error::Error, fmt, future::Future, marker::PhantomData, mem::MaybeUninit,
16    ptr::NonNull,
17};
18use tokio::task::JoinSet;
19
20/// Alias for the specific JoinError type returned by Tokio.
21type TokioJoinError = tokio::task::JoinError;
22
23/// A scoped task set analogous to [`tokio::task::JoinSet`] but accepting
24/// non-`'static` futures.
25///
26/// This structure manages the lifetime of the tasks and ensures their outputs
27/// are correctly typed and returned.
28#[derive(Default)]
29struct ScopedJoinSet<'env, TaskResult>
30where
31    TaskResult: 'env + Send,
32{
33    /// The underlying Tokio JoinSet handling the actual execution.
34    /// It stores `SendPtr` to type-erase the task output while maintaining `Send`.
35    join_set: JoinSet<SendPtr>,
36    /// Marker to ensure the struct respects the lifetime of the environment
37    /// and the task result type.
38    _marker: PhantomData<&'env TaskResult>,
39}
40
41impl<'env, TaskResult> ScopedJoinSet<'env, TaskResult>
42where
43    TaskResult: 'env + Send,
44{
45    /// Creates a new, empty `ScopedJoinSet`.
46    unsafe fn new() -> Self {
47        Self {
48            join_set: JoinSet::new(),
49            _marker: PhantomData,
50        }
51    }
52
53    /// Spawns a new task into the set.
54    ///
55    /// The provided future must be `Send` and live for at least `'env`.
56    /// The output of the task is stored in a heap-allocated slot to be retrieved later.
57    fn spawn<F>(&mut self, task: F)
58    where
59        F: Future<Output = TaskResult> + Send + 'env,
60        TaskResult: Send,
61    {
62        // Allocate a slot for the result. We leak the box to ensure the memory stays
63        // valid until the task completes. The pointer is passed to the task wrapper.
64        // It is reclaimed in `join_next` (or cleaned up by Tokio if aborted).
65        let output_slot: NonNull<MaybeUninit<TaskResult>> =
66            Box::leak(Box::new(MaybeUninit::uninit())).into();
67
68        // Wrap the user's task to write the output to the allocated slot upon completion.
69        // This is unsafe because we are creating the WriteOutput wrapper.
70        let wrapped_task = unsafe { WriteOutput::new(task, output_slot.cast()) };
71
72        // Safety: We are casting lifetimes to 'static to satisfy Tokio's API.
73        // The safety is guaranteed by `scope` awaiting all tasks before returning,
74        // preventing any use-after-free of the environment.
75        let static_task = unsafe { wrapped_task.into_static() };
76
77        self.join_set.spawn(static_task);
78    }
79
80    /// Returns `true` if the set contains no tasks.
81    pub fn is_empty(&self) -> bool {
82        self.join_set.is_empty()
83    }
84
85    /// Returns the number of tasks currently in the set.
86    fn len(&self) -> usize {
87        self.join_set.len()
88    }
89
90    /// Waits for the next task to complete and returns its result.
91    ///
92    /// Returns `None` if the set is empty.
93    async fn join_next(&mut self) -> Option<Result<TaskResult, JoinError>> {
94        match self.join_set.join_next().await? {
95            Ok(output_ptr) => {
96                // Reconstruct the typed pointer from the raw SendPtr.
97                let typed_ptr: NonNull<TaskResult> = output_ptr.cast();
98                // Safety: We know this pointer was created in `spawn` via `Box::leak`.
99                // We take ownership back to drop the box and retrieve the value.
100                let output_value = unsafe { *Box::from_raw(typed_ptr.as_ptr()) };
101                Some(Ok(output_value))
102            }
103            Err(join_error) => Some(Err(join_error.into())),
104        }
105    }
106
107    /// Tries to join the next task without blocking.
108    ///
109    /// Returns `None` if no tasks have completed or the set is empty.
110    fn try_join_next(&mut self) -> Option<Result<TaskResult, JoinError>> {
111        match self.join_set.try_join_next()? {
112            Ok(output_ptr) => {
113                let typed_ptr: NonNull<TaskResult> = output_ptr.cast();
114                let output_value = unsafe { *Box::from_raw(typed_ptr.as_ptr()) };
115                Some(Ok(output_value))
116            }
117            Err(join_error) => Some(Err(join_error.into())),
118        }
119    }
120
121    /// Aborts all running tasks in the set.
122    fn abort_all(&mut self) {
123        self.join_set.abort_all();
124    }
125
126    /// Drains all tasks, waiting for each to complete.
127    ///
128    /// Returns a vector containing the results of all tasks.
129    async fn join_all(&mut self) -> Vec<Result<TaskResult, JoinError>> {
130        let mut results = Vec::with_capacity(self.len());
131        while let Some(result) = self.join_next().await {
132            results.push(result);
133        }
134        results
135    }
136}
137
138/// Safety: The `ScopedJoinSet` is `Send` if the `TaskResult` is `Send`.
139/// This allows the set to be moved between threads (though tasks themselves
140/// run on the Tokio runtime).
141unsafe impl<'env, TaskResult: Send> Send for ScopedJoinSet<'env, TaskResult> {}
142
143/// Errors that can occur when joining a scoped task.
144#[derive(Debug)]
145pub enum JoinError {
146    /// The task was cancelled before it could complete.
147    Cancelled,
148    /// The task panicked during execution.
149    Panicked(Box<dyn Any + Send + 'static>),
150}
151
152impl fmt::Display for JoinError {
153    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
154        match self {
155            JoinError::Cancelled => write!(formatter, "task was cancelled"),
156            JoinError::Panicked(_) => write!(formatter, "task panicked"),
157        }
158    }
159}
160
161impl Error for JoinError {}
162
163impl From<TokioJoinError> for JoinError {
164    fn from(error: TokioJoinError) -> Self {
165        if error.is_cancelled() {
166            JoinError::Cancelled
167        } else if error.is_panic() {
168            JoinError::Panicked(error.into_panic())
169        } else {
170            JoinError::Cancelled
171        }
172    }
173}
174
175/// A handle passed to the closure in [`scope`], allowing the spawning of
176/// scoped tasks and interaction with the set.
177///
178/// This handle wraps a mutable reference to the `ScopedJoinSet`, ensuring
179/// exclusive access to the set for operations like spawning and joining.
180pub struct Scope<'scope, 'env, TaskResult>
181where
182    TaskResult: Send + 'env,
183{
184    /// Mutable reference to the underlying task set.
185    task_set: &'scope mut ScopedJoinSet<'env, TaskResult>,
186}
187
188impl<'scope, 'env, TaskResult> Scope<'scope, 'env, TaskResult>
189where
190    TaskResult: Send + 'env,
191{
192    /// Spawns a future into the scope.
193    ///
194    /// This method is synchronous and non-blocking.
195    pub fn spawn<F>(&mut self, future: F)
196    where
197        F: Future<Output = TaskResult> + Send + 'env,
198    {
199        self.task_set.spawn(future);
200    }
201
202    /// Waits for the next task to complete.
203    pub async fn join_next(&mut self) -> Option<Result<TaskResult, JoinError>> {
204        self.task_set.join_next().await
205    }
206
207    /// Tries to join the next task without blocking.
208    pub fn try_join_next(&mut self) -> Option<Result<TaskResult, JoinError>> {
209        self.task_set.try_join_next()
210    }
211
212    /// Returns `true` if the set is empty.
213    pub fn is_empty(&self) -> bool {
214        self.task_set.is_empty()
215    }
216
217    /// Returns the number of tasks in the set.
218    pub fn len(&self) -> usize {
219        self.task_set.len()
220    }
221
222    /// Aborts all tasks.
223    pub fn abort_all(&mut self) {
224        self.task_set.abort_all();
225    }
226
227    /// Joins all tasks and returns their results.
228    pub async fn join_all(&mut self) -> Vec<Result<TaskResult, JoinError>> {
229        self.task_set.join_all().await
230    }
231}
232
233/// Guard structure that ensures the process aborts if the scope is dropped unsafely.
234///
235/// If the `scope` future is cancelled (dropped) before the internal `ScopedJoinSet`
236/// has finished joining all tasks, this guard's `Drop` implementation will trigger.
237/// This prevents undefined behavior where background tasks might access the
238/// stack frame of the cancelled scope.
239struct AbortOnDrop;
240
241impl Drop for AbortOnDrop {
242    /// Aborts the process immediately to ensure memory safety.
243    fn drop(&mut self) {
244        eprintln!("Tried to cancel task scope");
245        std::process::abort();
246    }
247}
248
249/// Creates a scope for spawning non-`'static` futures.
250///
251/// The provided `closure` receives a `Scope` handle which can be used to spawn
252/// tasks. The scope guarantees that all spawned tasks will be joined or aborted
253/// before the function returns.
254///
255/// # Cancellation Safety
256///
257/// This future is **not** cancellation safe. If the future returned by this function
258/// is dropped before it completes (e.g., by wrapping it in `tokio::time::timeout`
259/// or `tokio::select!`), the process will be **aborted** (via [`std::process::abort`]).
260pub async fn scope<'env, TaskResult, Closure, ClosureResult>(closure: Closure) -> ClosureResult
261where
262    TaskResult: Send + 'env,
263    // The closure must accept a mutable reference to Scope with the lifetime 's.
264    Closure: for<'s> AsyncFnOnce(&'s mut Scope<'s, 'env, TaskResult>) -> ClosureResult,
265{
266    let guard = AbortOnDrop;
267
268    // Safety: Creating the `ScopedJoinSet` is safe here because this function
269    // establishes the necessary runtime guarantees for non-`'static` tasks.
270    // Specifically:
271    // 1. We ensure all tasks are joined or aborted before this function returns.
272    // 2. The `AbortOnDrop` guard ensures that if this scope is cancelled (dropped)
273    //    prematurely, the process aborts. This prevents background tasks from
274    //    accessing the stack frame after it has been deallocated.
275    let mut task_set = unsafe { ScopedJoinSet::<TaskResult>::new() };
276
277    let mut scope_handle = Scope {
278        task_set: &mut task_set,
279    };
280
281    let result = closure(&mut scope_handle).await;
282
283    // Ensure all tasks are cleaned up before returning.
284    task_set.abort_all();
285    task_set.join_all().await;
286
287    drop(task_set);
288
289    std::mem::forget(guard);
290
291    result
292}