scoped-join-set 0.8.0

A scoped variant of tokio::task::JoinSet for spawning non-'static futures.
Documentation

ScopedJoinSet

A lifetime-aware, &mut-scoped wrapper around [tokio::task::JoinSet] that allows you to spawn non-'static futures safely.

ScopedJoinSet is a drop-in, scope-bounded alternative to Tokio’s JoinSet. It enables async tasks that borrow data from the stack—without requiring cloning, Arc, or complicated ownership gymnastics—while guaranteeing the tasks cannot outlive the scope in which they were spawned.


🚨 Explicit Shutdown Required

A ScopedJoinSet must not be dropped implicitly.
You must terminate it with one of:

  • set.shutdown().await, or
  • set.join_all().await.

If dropped without calling either method:

The process aborts.

This rule is essential for soundness: spawned tasks may borrow stack data, so implicit drop is forbidden to prevent them from outliving their scope.


Why ScopedJoinSet?

Tokio requires all spawned futures to be 'static.
This crate lifts that restriction:

  • spawn tasks that borrow local variables,
  • maintain predictable scope-bound lifetime,
  • avoid unnecessary Arc or cloning,
  • work entirely within safe Rust on the user side.

Scoped tasks behave like normal async tasks—just with lifetime guarantees.


Key Guarantees

  • Tasks may borrow from the stack ('scope is enforced by the API).
  • Each task is executed by Tokio but cannot escape its scope.
  • Result values are safely recovered in completion order.
  • Tasks are automatically cleaned up during shutdown or join_all.
  • Implicit drop is prohibited and enforced by an abort.

No background tasks. No 'static requirement. No unsafe user code.


Quick Example

use scoped_join_set::ScopedJoinSet;

#[tokio::main]
async fn main() {
    let value = Box::new(5);
    let borrowed = &value;

    let mut set = ScopedJoinSet::<u32>::new();

    set.spawn(async move {
        println!("Borrowed: {}", borrowed);
        **borrowed
    });

    while let Some(result) = set.join_next().await {
        assert_eq!(result.unwrap(), *value);
    }

    // Required to avoid abort:
    set.shutdown().await;
}

Features

  • Spawn non-'static futures.
  • Deterministic scope-bounded lifetime.
  • join_next() + try_join_next() yield tasks in completion order.
  • Low overhead (no maps, indices, or intrusive metadata).
  • Soundness validated with Miri.

How It Works (Overview)

  • Every task output lives in a unique heap allocation.
  • The user future is wrapped in a pinned future that writes to that slot.
  • The wrapper is unsafely promoted to 'static, but soundly:
    • the allocation outlives the task,
    • tasks cannot outlive the ScopedJoinSet,
    • the wrapper future never moves after being pinned.
  • The completed task returns a raw pointer identifying its output slot.
  • join_next() reads the value and frees the allocation.

Simple, fast, and predictable.


Comparison with Tokio’s JoinSet

Feature Tokio JoinSet ScopedJoinSet
Spawn non-'static tasks ❌ No ✅ Yes
Borrows from stack allowed ❌ No ✅ Yes
Scoped lifetime enforced ❌ No ✅ Yes
Implicit drop safe ✅ Yes ❌ Aborts

Installation

[dependencies]
scoped-join-set = "0.7"
tokio = { version = "1", features = ["full"] }

Soundness Notes

This crate uses unsafe internally but:

  • all wrapper futures are pinned and immovable,
  • no task can escape the scope (API prevents it),
  • all allocations are freed once tasks finish or abort,
  • extensive testing + Miri runs have found no UB.

However:

The crate has not undergone formal third-party audit.

Use in critical contexts at your discretion.


License

MIT OR Apache-2.0, at your option.