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

  1. Await each JoinHandle exactly once; if you need shared results, use Arc on the output or a oneshot/watch channel to fan out
  2. Check for double-await logic: remove the JoinHandle from your collection when polled to completion, or use a JoinSet
  3. 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

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


AI-assisted analysis of dbt-labs/dbt-core@0267ce9170 (2026-09-07). Data as JSON: /api/errors/470aec6b2a6397e4. Report an issue: GitHub.