tokio-rs/tokio ยท error

cannot enter a task-local scope while the task-local storage

Error message

cannot enter a task-local scope while the task-local storage is borrowed

What it means

This panic (ScopeInnerErr::BorrowError at task_local.rs:472) is raised when scope_inner fails to enter the task-local scope because the RefCell that backs the task-local's thread-local storage is already borrowed. Concretely, in scope_inner (task_local.rs:209-213) the call self.inner.try_with(|inner| inner.try_borrow_mut()) returns Err(BorrowMutError), which is converted into ScopeInnerErr::BorrowError and eventually panic()'d. Under normal usage this should be impossible: the comment at task_local.rs:192-201 notes user code never receives the RefCell guards, so there is no way for it to leave one outstanding. When it does occur it points to reentrant or concurrent re-borrow of the same LocalKey's storage from within the scope closure.

Source

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

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. Audit drop implementations inside the future passed to LocalKey::scope for any re-entrant access to the same LocalKey (e.g., calling KEY.scope(...) or KEY.with(...) from a Drop body) and remove the recursion.
  2. Ensure only one scope_inner is active on a given LocalKey at a time per thread; serialize nested scope() calls on the same key or use a different task-local key for the inner scope.
  3. Avoid panicking while a task-local borrow is live; if the closure may panic, isolate the borrow so the guard is released before the panic propagates.
  4. If the recursion is intentional, switch to thread::scope-style nesting by storing a stack of values in the task-local instead of re-entering scope().
  5. Reproduce with RUST_BACKTRACE=1 to confirm the reentrant borrow source, then refactor the offending Drop/closure.

Example fix

// before: Drop of an inner future re-enters the same task-local scope
struct ReentrantDrop;
impl Future for ReentrantDrop { type Output = (); /* ... */ }
impl Drop for ReentrantDrop {
    fn drop(&mut self) {
        // BUG: scope_inner is already borrowing KEY's RefCell here
        let _ = KEY.scope(0, async {}).now_or_never();
    }
}

// after: do not re-enter the same LocalKey from Drop; read the value if needed
impl Drop for ReentrantDrop {
    fn drop(&mut self) {
        // only read, do not open a new scope on the same key
        let _ = KEY.try_with(|v| *v);
    }
}
Defensive patterns

Strategy: validation

Validate before calling

// BAD -- enters a new scope while the same key is immutably borrowed -> panic:
//   KEY.with(|v| { block_on(KEY.scope(*v, async { /*..*/ })); })
//
// GOOD -- release the borrow BEFORE entering any (possibly nested) scope:
let value: u32 = KEY.with(|v| *v);            // borrow dropped at the `;`
KEY.scope(value, async { /* .. */ }).await;   // safe: no outstanding borrow

Try / catch

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

// scope()/sync_scope() have NO Result-returning variant -- a borrow conflict
// always panics. catch_unwind is the only runtime backstop; restructuring the
// call (see validationCode) is the real fix.
let r = catch_unwind(AssertUnwindSafe(|| KEY.scope(7, async { 7 })));
if r.is_err() {
    // A task-local borrow was outstanding when we tried to enter the scope.
    // Don't retry in-place: move the scope call outside the `with`/`try_with`.
}

Prevention

When it happens

Trigger: Triggered when scope_inner's try_borrow_mut() at task_local.rs:211 fails because the same task-local RefCell is already borrowed. Realistic causes: (1) a panic during scope execution that unwinds while a borrow guard is still live and re-enters scope on the same key; (2) a Drop guard (Guard at task_local.rs:188) running its own scope_inner on the same LocalKey reentrantly while a previous borrow is still active (e.g., the inner future's drop recursively accesses the same task-local via scope()); (3) misuse via unsafe transmute/pin-projection that aliases the slot so two scope_inner calls overlap on the same key. Standard LocalKey::scope/with/try_with usage from a single task will not hit this.

Common situations: Custom AioSource-like code or destructors that re-enter LocalKey::scope during drop of the inner future inside the same scope; downgrading/combining Tokio versions where the borrow bookkeeping differs; unsafe pin projection tricks on TaskLocalFuture; bugs in a runtime that re-uses a LocalKey across logically distinct task-local slots. Rarely seen in idiomatic async code.

Related errors


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