embassy-rs/embassy · error

CallFuture polled after completion

Error message

CallFuture polled after completion

What it means

CallFuture is an in-flight RPC call future with an explicit Phase state machine. Once it reaches Phase::Done it must never be polled again; polling a completed CallFuture panics. This is an internal-use invariant — futures must not be polled after returning Ready.

Solutions

  1. Await the CallFuture with .await and let Drop run at completion; never poll manually after Ready.
  2. If manual polling is required, stop polling as soon as Poll::Ready is returned.
  3. Fuse the future (wrap so polls after Ready return Pending/Ready(None)) or track completion yourself before re-polling.

Example fix

// before
if matches!(fut.poll(cx), Poll::Ready(r)) { done = true; }
fut.poll(cx); // polled again after done
// after
match fut.poll(cx) {
    Poll::Ready(r) => return Poll::Ready(r),
    Poll::Pending => return Poll::Pending,
}
Defensive patterns

Strategy: validation

Validate before calling

// Stop polling once Ready is observed:
enum State { Running(CallFuture<..>), Done }
fn poll_once(state: &mut State, cx: &mut Context) -> Poll<R> {
    match state {
        State::Running(f) => match f.poll_unpin(cx) {
            Poll::Ready(r) => { *state = State::Done; Poll::Ready(r) }
            Poll::Pending => Poll::Pending,
        },
        State::Done => panic!("do not re-poll"),
    }
}

Type guard

fn is_done<T>(r: &Poll<T>) -> bool { matches!(r, Poll::Ready(_)) }

Prevention

When it happens

Trigger: Continuing to poll a CallFuture after it has returned Poll::Ready — e.g. manual poll() loops that ignore Ready, storing the future and polling it after completion, or a select that polls a stale future.

Common situations: Hand-rolled poll loops instead of .await; keeping the CallFuture in a registry after completion and re-polling; misuse of the future inside a custom executor.

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 embassy-rs/embassy@463a07b963 (2026-09-10). Data as JSON: /api/errors/e6d7a471df44f178. Report an issue: GitHub.

Appendix: source

Thrown at embassy-sync/src/rpc_service.rs:620

                Phase::Acquiring => match self.svc.slot.poll_acquire(cx) {
                    Poll::Pending => return Poll::Pending,
                    Poll::Ready(()) => {
                        let f = self.f.take().unwrap();
                        // SAFETY: we just acquired the slot, F and R fit (compile-time check in call())
                        unsafe { self.svc.slot.submit::<R, F>(f) };
                        self.phase = Phase::Submitted;
                    }
                },
                Phase::Submitted => {
                    return match self.svc.slot.poll_result::<R>(cx) {
                        Poll::Pending => Poll::Pending,
                        Poll::Ready(result) => {
                            self.phase = Phase::Done;
                            Poll::Ready(result)
                        }
                    };
                }
                Phase::Done => panic!("CallFuture polled after completion"),
            }
        }
    }
}

impl<M: RawMutex, T, R, F, const S: usize> Drop for CallFuture<'_, M, T, R, F, S> {
    fn drop(&mut self) {
        if matches!(self.phase, Phase::Submitted) {
            // Future dropped after the closure was written to the slot.
            // The runner will still finish executing the closure when polled, but we cannot
            // touch the slot (because the runner may still be using it). We also must not
            // block, so we signal ack so the runner can cleanup and accept new work.
            // The two ack paths are mutually exclusive. Either the poll completes,
            // so ack is sent in poll_result and phase moves to Done synchronously – or we're
            // dropped while still in Submitted and the ack is sent here. Never both.
            self.svc.slot.ack.signal(());
        }
    }

View on GitHub (pinned to 463a07b963)