tokio-rs/tokio ยท error

`TaskLocalFuture` polled after completion

Error message

`TaskLocalFuture` polled after completion

What it means

This panic is raised inside TaskLocalFuture::poll (the future produced by LocalKey::scope) when the underlying inner future is already None. The poll implementation (task_local.rs:393-414) sets future to None once the inner future returns Poll::Ready, then returns Ready itself; if poll is invoked again after that, the closure passed to scope_inner returns None and the match arm at line 412 panics. In other words, Tokio is defending against a contract violation: a Future must never be polled after it has resolved to Ready. It usually surfaces a bug in the driving code (manual poll, a buggy select!/FuturesUnordered, or a runtime mis-polling the task) rather than in the task-local logic itself.

Source

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

        let this = self.project();
        let mut future_opt = this.future;

        let res = this
            .local
            .scope_inner(this.slot, || match future_opt.as_mut().as_pin_mut() {
                Some(fut) => {
                    let res = fut.poll(cx);
                    if res.is_ready() {
                        future_opt.set(None);
                    }
                    Some(res)
                }
                None => None,
            });

        match res {
            Ok(Some(res)) => res,
            Ok(None) => panic!("`TaskLocalFuture` polled after completion"),
            Err(err) => err.panic(),
        }
    }
}

impl<T: 'static, F> fmt::Debug for TaskLocalFuture<T, F>
where
    T: fmt::Debug,
{
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        /// Format the Option without Some.
        struct TransparentOption<'a, T> {
            value: &'a Option<T>,
        }
        impl<'a, T: fmt::Debug> fmt::Debug for TransparentOption<'a, T> {
            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
                match self.value.as_ref() {
                    Some(value) => value.fmt(f),

View on GitHub (pinned to 108d6d3dc0)

Solutions

  1. Stop polling the TaskLocalFuture after it returns Poll::Ready: in manual poll loops, check the returned Poll and break/return immediately.
  2. If using select!, replace the resolved branch future with a fresh one each iteration (or use a guard that marks it pending=disabled) so the completed future is never polled again.
  3. Avoid storing and re-polling the same scope() future in a FuturesUnordered/JoinSet; push each scope() future exactly once and remove it on completion.
  4. If you must hold the value after completion, use TaskLocalFuture::take_value on the pinned future instead of re-polling it.
  5. Audit custom executors/wakers to ensure they only wake/poll tasks whose last poll did not return Ready.

Example fix

// before: poll loop does not stop on Ready
let mut fut = Box::pin(KEY.scope(42, async { 1 }));
loop {
    let waker = noop_waker();
    let mut cx = Context::from_waker(&waker);
    if let Poll::Ready(v) = fut.as_mut().poll(&mut cx) {
        println!("done {v}");
    }
    // BUG: keeps polling after Ready -> `TaskLocalFuture polled after completion`
}

// after: break on Ready, use take_value to recover the slot
let mut fut = Box::pin(KEY.scope(42, async { 1 }));
let waker = noop_waker();
let mut cx = Context::from_waker(&waker);
loop {
    match fut.as_mut().poll(&mut cx) {
        Poll::Ready(v) => {
            println!("done {v}");
            let _ = fut.as_mut().take_value();
            break;
        }
        Poll::Pending => {}
    }
}
Defensive patterns

Strategy: type-guard

Type guard

// Wrap the TaskLocalFuture in Fuse so extra polls after Ready return
// Pending instead of panicking with "polled after completion".
use futures::FutureExt; // brings .fuse() into scope
use tokio::task_local;

task_local! { static KEY: u32; }

let safe = KEY.scope(42, async {
    // ... async body ...
}).fuse();
// `safe: Fuse<TaskLocalFuture<u32, _>>` -- structurally cannot panic on re-poll.
// If you must poll manually, drive `safe` (never the raw TaskLocalFuture).

Try / catch

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

// TaskLocalFuture::poll panics (not a Result), so the only runtime backstop is
// catch_unwind. Note: after a panic the future is in an invalid state --
// drop it and rebuild. Prefer the Fuse type-guard above in real code.
let res = catch_unwind(AssertUnwindSafe(|| {
    // poll/await the TaskLocalFuture here
    futures::executor::block_on(KEY.scope(42, async { 1 }))
}));
match res {
    Ok(v) => { /* v */ }
    Err(payload) => eprintln!("TaskLocalFuture polled after completion: {payload:?}"),
}

Prevention

When it happens

Trigger: Specifically: (1) calling Pin::as_mut().poll() on a TaskLocalFuture by hand after it has already returned Poll::Ready; (2) using futures::stream::FuturesUnordered / select! where the same TaskLocalFuture handle can be polled again after completion; (3) feeding a completed LocalKey::scope(...).await future into a combinator that does not unregister resolved futures; (4) a custom executor or task waker that spuriously re-polls after Ready. The match at task_local.rs:411 detects Ok(None) (scope_inner ran but future_opt was None) and panics.

Common situations: Hand-rolled executors or unsafe pin projections that forget to track completion; misuse of select! where the same branch future is reused across iterations without being replaced; re-polling a Box::pin(...).as_mut() future in a loop without checking the previous Poll result; upgrade of tokio where TaskLocalFuture enforces this invariant more strictly; reentrant task-local access during drop combined with an already-completed future.

Related errors


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