ScopedJoinSet
A lifetime-aware, &mut-scoped wrapper around [tokio::task::JoinSet] that allows you to spawn non-'static async tasks safely.
ScopedJoinSet provides the ergonomic benefits of Tokio’s JoinSet, but lifts the 'static bound on futures in a sound and predictable way. This enables async tasks that borrow stack-allocated data, while ensuring those tasks never outlive their parent scope.
Why ScopedJoinSet?
Tokio’s default task API requires all spawned futures to be 'static. This makes many patterns impossible without cloning, Arc, or complex restructuring.
ScopedJoinSet enables:
- async tasks that borrow temporary variables,
- task orchestration within a bounded scope,
- predictable task lifetimes,
- deterministic cleanup.
All without unsafe user code.
Key Guarantees
Every task spawned through ScopedJoinSet:
- cannot outlive the data it captures (
'scopeis enforced at compile time), - completes exactly once, and its output is recoverable,
- is cancellation-safe via
join_next,try_join_next, orshutdown, - is aborted and cleaned up when the
ScopedJoinSetis dropped.
This crate does not spawn background tasks.
Tasks always remain within the lifetime of the ScopedJoinSet.
⚠️ Recommendation: Prefer shutdown().await
ScopedJoinSet::shutdown() allows all tasks to finish or abort asynchronously.
If the ScopedJoinSet is dropped without shutdown:
- all tasks are aborted immediately,
- and the drop implementation uses a spin loop (
block_in_place) on multithreaded runtimes to wait for completion.
To avoid blocking executor threads, prefer:
set.shutdown.await;
Features
- Spawn futures with non-
'staticlifetimes. - Lightweight and predictable; no hashmaps or intrusive metadata.
- Cancellation-safe joining.
join_next()andtry_join_next()iterate tasks in completion order.- Graceful or abrupt shutdown.
- Verified using Miri to hunt down UB or memory leakage.
Example
use ScopedJoinSet;
async
Tasks may borrow stack data safely because they cannot escape the scope of the ScopedJoinSet.
How It Works (Briefly)
- Each task’s output is stored in a heap-allocated slot.
- The user future is wrapped in a small pinned future that writes into that slot.
- That wrapper is unsafely cast to
'static, but this cast is sound because:- the allocation outlives the task,
- the task cannot outlive the
ScopedJoinSet, - the wrapper future itself never moves once pinned.
join_next()retrieves the raw pointer returned by the task, reads the output, and frees the allocation.
This design keeps the overhead low — typically within ~5–7% of Tokio’s native JoinSet.
A more detailed explanation lives in the crate docs.
Comparison to Tokio’s JoinSet
| Feature | Tokio JoinSet |
Scoped JoinSet |
|---|---|---|
Requires 'static futures |
✅ Yes | ❌ No |
| Allows stack borrows | ❌ No | ✅ Yes |
| Deterministic scope-bound | ❌ No | ✅ Yes |
Installation
[]
= "0.6"
= { = "1", = ["full"] }
Soundness
ScopedJoinSet relies on unsafe internally, but:
- all pinned wrappers have stable addresses,
- tasks cannot escape the scope through the API,
- all allocations are freed after task completion or abortion,
- Miri runs show no UB.
Still, this crate has not been extensively audited. See the warning below.
⚠️ Warning
This crate has not undergone formal peer review or production-level verification. It is tested for memory safety (including Miri), but use in security-critical or untrusted contexts carries risk.
Please test thoroughly in your environment before deploying.
License
MIT OR Apache-2.0, at your option.