{"id":"5b466e1c377db3f9","repo":"tokio-rs/tokio","slug":"tasklocalfuture-polled-after-completion","errorCode":null,"errorMessage":"`TaskLocalFuture` polled after completion","messagePattern":"`TaskLocalFuture` polled after completion","errorType":"panic","errorClass":null,"httpStatus":null,"severity":"error","filePath":"tokio/src/task/task_local.rs","lineNumber":412,"sourceCode":"        let this = self.project();\n        let mut future_opt = this.future;\n\n        let res = this\n            .local\n            .scope_inner(this.slot, || match future_opt.as_mut().as_pin_mut() {\n                Some(fut) => {\n                    let res = fut.poll(cx);\n                    if res.is_ready() {\n                        future_opt.set(None);\n                    }\n                    Some(res)\n                }\n                None => None,\n            });\n\n        match res {\n            Ok(Some(res)) => res,\n            Ok(None) => panic!(\"`TaskLocalFuture` polled after completion\"),\n            Err(err) => err.panic(),\n        }\n    }\n}\n\nimpl<T: 'static, F> fmt::Debug for TaskLocalFuture<T, F>\nwhere\n    T: fmt::Debug,\n{\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        /// Format the Option without Some.\n        struct TransparentOption<'a, T> {\n            value: &'a Option<T>,\n        }\n        impl<'a, T: fmt::Debug> fmt::Debug for TransparentOption<'a, T> {\n            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n                match self.value.as_ref() {\n                    Some(value) => value.fmt(f),","sourceCodeStart":394,"sourceCodeEnd":430,"githubUrl":"https://github.com/tokio-rs/tokio/blob/108d6d3dc038332af2af83957748333091e35b3f/tokio/src/task/task_local.rs#L394-L430","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before: poll loop does not stop on Ready\nlet mut fut = Box::pin(KEY.scope(42, async { 1 }));\nloop {\n    let waker = noop_waker();\n    let mut cx = Context::from_waker(&waker);\n    if let Poll::Ready(v) = fut.as_mut().poll(&mut cx) {\n        println!(\"done {v}\");\n    }\n    // BUG: keeps polling after Ready -> `TaskLocalFuture polled after completion`\n}\n\n// after: break on Ready, use take_value to recover the slot\nlet mut fut = Box::pin(KEY.scope(42, async { 1 }));\nlet waker = noop_waker();\nlet mut cx = Context::from_waker(&waker);\nloop {\n    match fut.as_mut().poll(&mut cx) {\n        Poll::Ready(v) => {\n            println!(\"done {v}\");\n            let _ = fut.as_mut().take_value();\n            break;\n        }\n        Poll::Pending => {}\n    }\n}","handlingStrategy":"type-guard","validationCode":null,"typeGuard":"// Wrap the TaskLocalFuture in Fuse so extra polls after Ready return\n// Pending instead of panicking with \"polled after completion\".\nuse futures::FutureExt; // brings .fuse() into scope\nuse tokio::task_local;\n\ntask_local! { static KEY: u32; }\n\nlet safe = KEY.scope(42, async {\n    // ... async body ...\n}).fuse();\n// `safe: Fuse<TaskLocalFuture<u32, _>>` -- structurally cannot panic on re-poll.\n// If you must poll manually, drive `safe` (never the raw TaskLocalFuture).","tryCatchPattern":"use std::panic::{catch_unwind, AssertUnwindSafe};\n\n// TaskLocalFuture::poll panics (not a Result), so the only runtime backstop is\n// catch_unwind. Note: after a panic the future is in an invalid state --\n// drop it and rebuild. Prefer the Fuse type-guard above in real code.\nlet res = catch_unwind(AssertUnwindSafe(|| {\n    // poll/await the TaskLocalFuture here\n    futures::executor::block_on(KEY.scope(42, async { 1 }))\n}));\nmatch res {\n    Ok(v) => { /* v */ }\n    Err(payload) => eprintln!(\"TaskLocalFuture polled after completion: {payload:?}\"),\n}","preventionTips":["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."],"tags":["tokio","task-local","future","polling","runtime"],"analyzedSha":"108d6d3dc038332af2af83957748333091e35b3f","analyzedAt":"2026-08-01T08:30:11.576Z","schemaVersion":2}