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
- Stop polling the TaskLocalFuture after it returns Poll::Ready: in manual poll loops, check the returned Poll and break/return immediately.
- 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.
- Avoid storing and re-polling the same scope() future in a FuturesUnordered/JoinSet; push each scope() future exactly once and remove it on completion.
- If you must hold the value after completion, use TaskLocalFuture::take_value on the pinned future instead of re-polling it.
- 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
- Drive every TaskLocalFuture with `.await`; the compiler-generated state machine never polls a future after it returns Ready, which is the only normal way this panic occurs.
- If you poll futures manually (custom executor, hand-written combinator, or a loop), always wrap with `.fuse()` from `futures::FutureExt` so post-completion polls yield Pending instead of panicking.
- Treat TaskLocalFuture as single-shot: after it resolves, drop it -- never retain it and re-await/re-poll it in a later loop iteration.
- Avoid combinators that may poll a sub-future more times than the contract allows; prefer fused iterators (e.g. `StreamExt::next` on a fused stream) over raw `poll` calls.
- If you store the future in a struct field, take it out of an `Option` and replace with `None` once it completes so it physically cannot be polled again.
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
- cannot enter a task-local scope while the task-local storage
- cannot enter a task-local scope during or after destruction
- Use AioSource::register_borrowed instead
AI-assisted analysis of tokio-rs/tokio@108d6d3dc0 (2026-08-01).
Data as JSON: /data/errors/5b466e1c377db3f9.json.
Report an issue: GitHub.