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
- 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.
- 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.
- 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.
- 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.
- 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
- Fully `.await` (or explicitly drop) every TaskLocalFuture before dropping the runtime or letting its worker thread exit; in-flight scopes must finish while the thread-local still exists.
- Never enter task-local scopes (`scope`/`sync_scope`) or await task-local futures from `Drop` impls that may execute during thread/program shutdown -- destructors run during thread-local destruction.
- Shut the runtime down with `shutdown_background`/`shutdown_timeout` only after joining all tasks that hold task-locals; don't drop the runtime while such futures are still pending.
- Don't spawn or construct task-local futures from destructors of `static`/global objects; those run during the thread-local teardown phase that triggers this panic.
- In any code that might run late in shutdown, prefer `try_with`/`try_get`, which return `Err(AccessError)` instead of panicking when storage is gone.
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
- `TaskLocalFuture` polled after completion
- cannot enter a task-local scope while the task-local storage
- Use AioSource::register_borrowed instead
AI-assisted analysis of tokio-rs/tokio@108d6d3dc0 (2026-08-01).
Data as JSON: /data/errors/0566817f8fb3265d.json.
Report an issue: GitHub.