dbt-labs/dbt-core · error
JoinHandle polled after completion
Error message
JoinHandle polled after completion
What it means
A JoinHandle's output can be consumed only once. take_output replaces the internal stage with Stage::Consumed; if the stage is not Finished(output) at that moment (already consumed or still pending), the library panics that the JoinHandle was polled after completion/consumption. This guards the single-consumption contract of the task result.
Source
Thrown at crates/dbt-runtime/src/task/core.rs:375
// Safety: the caller ensures mutual exclusion to the field.
unsafe {
self.set_stage(Stage::Finished(output));
}
}
/// Takes the task output.
///
/// # Safety
///
/// The caller must ensure it is safe to mutate the `stage` field.
pub(super) fn take_output(&self) -> super::Result<T::Output> {
use std::mem;
self.stage.stage.with_mut(|ptr| {
// Safety:: the caller ensures mutual exclusion to the field.
match mem::replace(unsafe { &mut *ptr }, Stage::Consumed) {
Stage::Finished(output) => output,
_ => panic!("JoinHandle polled after completion"),
}
})
}
unsafe fn set_stage(&self, stage: Stage<T>) {
let _guard = TaskIdGuard::enter(self.task_id);
self.stage.stage.with_mut(|ptr| *ptr = stage);
}
}
impl Header {
/// Gets a pointer to the `Trailer` of the task containing this `Header`.
///
/// # Safety
///
/// The provided raw pointer must point at the header of a task.
pub(super) unsafe fn get_trailer(me: NonNull<Header>) -> NonNull<Trailer> {
let offset = me.as_ref().vtable.trailer_offset;View on GitHub (pinned to 0267ce9170)
Solutions
- Await each JoinHandle exactly once; if you need shared results, use Arc on the output or a oneshot/watch channel to fan out
- Check for double-await logic: remove the JoinHandle from your collection when polled to completion, or use a JoinSet
- If polling manually, stop polling once Poll::Ready is returned and drop the handle
Example fix
// before let out1 = (&mut jh).await; let out2 = jh.await; // panic: polled after completion // after let out = jh.await; // consume once; clone/Arc the result if needed elsewhere
Defensive patterns
Strategy: type-guard
Validate before calling
// Track consumption yourself before awaiting twice
struct OnceJoin<T>(Option<tokio::task::JoinHandle<T>>);
impl<T> OnceJoin<T> { fn take(&mut self) -> tokio::task::JoinHandle<T> { self.0.take().expect("JoinHandle already consumed") } } Type guard
fn is_joinable<T>(jh: &Option<tokio::task::JoinHandle<T>>) -> bool { jh.is_some() } Try / catch
let out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| jh.clone())); // better: remove the handle from storage upon first await so re-await is a compile-time/logic error
Prevention
- Await each JoinHandle exactly once; use JoinSet for many tasks
- Remove handles from collections as soon as they are polled to Ready
- Use Arc<Output> or channels for shared results instead of re-awaiting
- Avoid manual polling of JoinHandles; rely on .await
When it happens
Trigger: Polling or awaiting a JoinHandle after it already resolved and its output was taken — e.g. awaiting the same JoinHandle twice, polling it after an earlier poll returned Ready, or calling an internal API that takes the output more than once.
Common situations: Storing a JoinHandle in a collection and awaiting it from two places; re-polling after join() inside a retry loop; manually pinning and polling a JoinHandle in a custom executor and not dropping it after Ready.
Understand the failure class
Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.
Related errors
- {e} {:?}
- {e}
- Cannot drop a runtime in a context where blocking is not all
- waker missing
- Schema not found for canonical FQN: {}
AI-assisted analysis of dbt-labs/dbt-core@0267ce9170 (2026-09-07).
Data as JSON: /api/errors/470aec6b2a6397e4.
Report an issue: GitHub.