slint-ui/slint · critical

Polling completed or aborted JoinHandle

Error message

Polling completed or aborted JoinHandle

What it means

JoinHandle<T> is the future returned by slint's spawn_local() (SlintContext::spawn_local). When the spawned task finishes, the inner state becomes FutureState::Finished(Some(val)) and the first poll consumes the value with Option::take(); a second poll, or any poll after abort() (which finishes with None), finds an empty slot and panics via .expect. The struct's own doc warns: 'Polling it after it finished or aborted may result in a panic.'

Source

Thrown at internal/core/future.rs:110

///
/// This trait implements future. Polling it after it finished or aborted may result in a panic.
pub struct JoinHandle<T>(alloc::sync::Arc<FutureRunner<T>>);

impl<T> Future for JoinHandle<T> {
    type Output = T;

    fn poll(self: Pin<&mut Self>, cx: &mut core::task::Context<'_>) -> Poll<Self::Output> {
        let mut inner = self.0.inner();
        match &mut inner.fut {
            FutureState::Running(_) => {
                let waker = cx.waker();
                if !inner.wakers.iter().any(|w| w.will_wake(waker)) {
                    inner.wakers.push(waker.clone());
                }
                Poll::Pending
            }
            FutureState::Finished(x) => {
                Poll::Ready(x.take().expect("Polling completed or aborted JoinHandle"))
            }
        }
    }
}

impl<T> JoinHandle<T> {
    /// If the future hasn't completed yet, this will make the event loop stop polling the corresponding future and it will be dropped
    ///
    /// Once this handle has been aborted, it can no longer be polled
    pub fn abort(self) {
        self.0.aborted.store(true, atomic::Ordering::Relaxed);
    }
    /// Checks if the task associated with this `JoinHandle` has finished.
    pub fn is_finished(&self) -> bool {
        matches!(self.0.inner().fut, FutureState::Finished(_))
    }
}

View on GitHub (pinned to 3fd8f2ec03)

Solutions

  1. Await each JoinHandle exactly once, then drop it; treat the first Ready as final.
  2. Never await a handle after calling abort() - abort() is terminal, just drop the handle.
  3. If the result is needed in several places, share the value (Rc<OnceCell>, a channel, or futures::future::Shared on a compatible handle), not the handle itself.
  4. When writing a manual poll fn around the handle, follow the Future contract: never emit Ready twice and stop polling after Ready.
  5. Use handle.is_finished() only to skip/inspect state, never as a license to poll again.

Example fix

// before: the same handle is awaited twice
let handle = slint::spawn_local(async { 42 })?;
let a = handle.await;
let b = handle.await; // PANIC: 'Polling completed or aborted JoinHandle'

// after: await once, share the value instead of the handle
let handle = slint::spawn_local(async { 42 })?;
let value = handle.await; // first and only consuming poll
let b = value;            // reuse `value` everywhere else
Defensive patterns

Strategy: validation

Validate before calling

// Treat a finished handle as spent: never await/poll it again.
// is_finished() == true means the value was already consumed
// (or the task was aborted and Finished(None)).
if !handle.is_finished() {
    let value = handle.await; // first and only consuming poll
    // use `value` from here on
} else {
    // handle already awaited or aborted: read the stored result instead
}

Try / catch

// Last resort if a third-party combinator may re-poll the handle:
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
    futures::executor::block_on(handle)
}));
// Fix the double-poll at the call site; this only contains the blast radius.

Prevention

When it happens

Trigger: Awaiting or manually polling the same JoinHandle twice (e.g. a combinator or hand-written Future that keeps polling after Ready); awaiting a handle after calling handle.abort(); storing the handle in a wrapper future that an executor re-polls after completion.

Common situations: Wrapping slint::spawn_local handles in futures::select!/timeout combinators that may re-poll; racing abort() against an .await; sharing the handle across code paths where each tries to await it; porting code from tokio::task::JoinHandle which permits extra polls.

Related errors


AI-assisted analysis of slint-ui/slint@3fd8f2ec03 (2026-08-19). Data as JSON: /api/errors/1fee63609f3d1cb7. Report an issue: GitHub.