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
- Await each JoinHandle exactly once, then drop it; treat the first Ready as final.
- Never await a handle after calling abort() - abort() is terminal, just drop the handle.
- 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.
- When writing a manual poll fn around the handle, follow the Future contract: never emit Ready twice and stop polling after Ready.
- 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
- Await each slint::spawn_local JoinHandle exactly once, then drop it
- Treat abort() as terminal - drop the handle, never await it afterwards
- Do not store JoinHandle in wrappers that may be polled after Ready; share the result (OnceCell, channel) instead
- In hand-written poll functions, honor the Future contract: Ready is emitted at most once
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
- SystemTrayIcon must be created on the main thread on macOS
- unable to show popup window
- fatal: swash is unable to parse truetype font
AI-assisted analysis of slint-ui/slint@3fd8f2ec03 (2026-08-19).
Data as JSON: /api/errors/1fee63609f3d1cb7.
Report an issue: GitHub.