tokio-rs/tokio ยท error

cannot enter a task-local scope during or after destruction

Error message

cannot enter a task-local scope during or after destruction of the underlying thread-local

What it means

This panic (ScopeInnerErr::AccessError at task_local.rs:473) is raised when scope_inner cannot even reach the RefCell because the underlying thread-local backing storage is being or has been destroyed. In scope_inner (task_local.rs:209) the call self.inner.try_with(...) returns Err(std::thread::AccessError), which is converted into ScopeInnerErr::AccessError and panic()'d at line 473. std::thread::AccessError is what thread-local accessors return once a thread is in its destructor phase (after the thread-local destructors have started running). It means Tokio is being asked to enter a task-local scope on a thread that is already tearing down its thread-local state.

Source

Thrown at tokio/src/task/task_local.rs:473

impl fmt::Display for AccessError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        fmt::Display::fmt("task-local value not set", f)
    }
}

impl Error for AccessError {}

enum ScopeInnerErr {
    BorrowError,
    AccessError,
}

impl ScopeInnerErr {
    #[track_caller]
    fn panic(&self) -> ! {
        match self {
            Self::BorrowError => panic!("cannot enter a task-local scope while the task-local storage is borrowed"),
            Self::AccessError => panic!("cannot enter a task-local scope during or after destruction of the underlying thread-local"),
        }
    }
}

impl From<std::cell::BorrowMutError> for ScopeInnerErr {
    fn from(_: std::cell::BorrowMutError) -> Self {
        Self::BorrowError
    }
}

impl From<std::thread::AccessError> for ScopeInnerErr {
    fn from(_: std::thread::AccessError) -> Self {
        Self::AccessError
    }
}

View on GitHub (pinned to 108d6d3dc0)

Solutions

  1. Ensure TaskLocalFuture (and any future produced by LocalKey::scope) is fully driven to completion or dropped before the thread that owns its thread-local storage exits.
  2. Drop task-local scope futures inside a runtime context on a live runtime thread; do not move them into std::thread::spawn threads that then terminate.
  3. During shutdown, await/join all spawned tasks (e.g., shutdown_background(), JoinSet::shutdown after join_all) before letting the runtime drop its worker thread-locals.
  4. If the future must outlive its origin thread, store the scoped value outside the task-local (e.g., pass it explicitly) instead of relying on thread-local-backed storage.
  5. Add a drop guard in your code that flushes/cancels task-local futures before thread::JoinHandle::join returns.

Example fix

// before: thread exits while a TaskLocalFuture is still alive on it
std::thread::spawn(|| {
    let fut = Box::pin(KEY.scope(1, async { work().await }));
    fut.as_mut().now_or_never(); // maybe Pending
    // thread returns -> thread-local destructors run
    // a later drop of `fut` (or a wake) hits scope_inner -> AccessError
});

// after: drive the future to completion (or drop it) before the thread exits
std::thread::spawn(|| {
    let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
    rt.block_on(async {
        KEY.scope(1, async { work().await }).await; // fully driven here
    });
    // thread-local storage torn down only after the future is gone
});
Defensive patterns

Strategy: validation

Validate before calling

// This panic only fires when a scope is entered while the underlying
// thread-local is being/has been destroyed (thread or process teardown).
// Guard entry points by refusing to scope without a live runtime handle:
match tokio::runtime::Handle::try_current() {
    Ok(_handle) => KEY.scope(value, fut).await,  // runtime alive -> safe to scope
    Err(_) => {
        // Off-runtime or shutting down: don't touch task-local storage here.
        fut.await
    }
}

Try / catch

use std::panic::{catch_unwind, AssertUnwindSafe};

// Entering a scope during thread-local destruction always panics and there is
// no Result variant, so catch_unwind is the only runtime backstop. If this
// fires, your code is running too late in shutdown -- fix the ordering.
let r = catch_unwind(AssertUnwindSafe(|| KEY.scope(value, fut)));
match r {
    Ok(v) => v,
    Err(p) => eprintln!("scope entered during/after thread-local destruction: {p:?}"),
}

Prevention

When it happens

Trigger: Specifically: (1) a background thread is shutting down and its thread-local destructors are running, while a still-live TaskLocalFuture is being polled or dropped on that thread (the PinnedDrop at task_local.rs:330-342 calls scope_inner during drop); (2) a task whose LocalKey storage lives on a worker thread gets moved or polled after the runtime/worker has started dropping thread-locals; (3) a 'static future spawned on a runtime keeps a TaskLocalFuture alive past the point where the originating thread exited and its thread-locals were freed. The Guard::drop path at task_local.rs:202 (which calls self.local.inner.with) can also surface this during teardown.

Common situations: Mixing std::thread::spawn work that holds a TaskLocalFuture across thread exit; spawning blocking work with spawn_blocking that captures task-local futures and outlives the runtime; runtime shutdown while tasks with task-locals are still pending; pinning a scope() future to a thread that is later joined; upgrading Tokio across a version that changed destruction ordering. Common in shutdown/drain code paths and in tests that spawn threads and join them without awaiting outstanding task-local futures.

Related errors


AI-assisted analysis of tokio-rs/tokio@108d6d3dc0 (2026-08-01). Data as JSON: /data/errors/0566817f8fb3265d.json. Report an issue: GitHub.