pydantic/monty · critical

fail_for_call: future was already resolved

Error message

fail_for_call: future was already resolved

What it means

`fail_for_call` panics if the ExternalFuture being failed is already in `Resolved` or `Failed` state, meaning the same CallId was failed twice. Each external call resolves or fails exactly once; a second failure means the host answered the same call twice or the scheduler failed to remove the pending entry — a state-machine invariant violation.

Source

Thrown at crates/monty/src/bytecode/vm/scheduler.rs:486

    /// so the caller's chain walk picks up at the right level.
    ///
    /// The returned `Awaiter` is owned (callers must walk it via
    /// `deliver_awaiter_failure`, which drops every link).
    ///
    /// Returns `None` when there's nothing to propagate (unknown CallId,
    /// already-resolved future, or the future had no awaiter — the failure
    /// is simply cached on the future for replay).
    #[must_use]
    pub fn fail_for_call(&mut self, call_id: CallId, error: &RunError, heap: &mut HeapReader<'_>) -> Option<Awaiter> {
        let future_id = self.pending_externals.remove(&call_id)?;

        let HeapReadOutput::ExternalFuture(mut fut) = heap.read(future_id) else {
            panic!("pending_externals entry doesn't point to an ExternalFuture")
        };
        let awaiter = match mem::replace(&mut fut.get_mut(heap).state, ExternalFutureState::Failed(error.clone())) {
            ExternalFutureState::Pending { awaiter } => awaiter,
            ExternalFutureState::Resolved(_) | ExternalFutureState::Failed(_) => {
                panic!("fail_for_call: future was already resolved")
            }
        };
        drop(fut);
        heap.dec_ref(future_id);

        match awaiter {
            // Nothing is waiting on this call — it was never awaited. The
            // failure stays cached on the future for a later await to replay.
            None => None,
            Some(Awaiter::Task(task_id)) => {
                // A task's own awaiter is the `GatherSlot` its gather gave it;
                // borrow that gather's id without taking the task's ref, which
                // stays until the task is cancelled.
                let gather_id = match self.tasks.get(&task_id).and_then(|t| t.awaiter.as_ref()) {
                    Some(Awaiter::GatherSlot { gather, .. }) => Some(*gather),
                    Some(Awaiter::Task(_)) | None => None,
                };
                match gather_id {

View on GitHub (pinned to adc986b362)

Solutions

  1. Ensure the host fails or resolves each external call exactly once — after any fail/resume for a call_id, never call `fail_for_call` again for it
  2. Check the host's timeout/error handling so timeouts do not race with delivering the real result for the same call
  3. If writing a driver, track settled call_ids on the host side and drop duplicates

Example fix

// before: host times out AND delivers the error
if timed_out { scheduler.fail_for_call(id, &err, heap); }
scheduler.fail_for_call(id, &err, heap); // second call panics
// after
if timed_out {
    scheduler.fail_for_call(id, &err, heap);
} else {
    scheduler.fail_for_call(id, &err, heap);
}
Defensive patterns

Strategy: validation

Validate before calling

// Host-side: track settled calls before failing
let mut settled: std::collections::HashSet<CallId> = HashSet::new();
fn fail_once(scheduler: &mut Scheduler, id: CallId, err: &RunError, heap: &mut Heap) {
    if settled.insert(id) {
        scheduler.fail_for_call(id, err, heap);
    }
}

Try / catch

// Panic is by design on double-settle; guard on the host side instead:
if !settled.contains(&call_id) {
    scheduler.fail_for_call(call_id, &error, heap);
    settled.insert(call_id);
}

Prevention

When it happens

Trigger: Calling `fail_for_call` with a call_id whose future already settled via an earlier `fail_for_call` or resolution; typically a host driving the scheduler that reports failure after already handling the call, or double-dispatch of the same error.

Common situations: Writing a custom host event loop that resumes/fails a call and then also reports an error for it; mixing timeout handling with result handling for the same call; a bug in the resume path replaying a settled call.

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 pydantic/monty@adc986b362 (2026-09-13). Data as JSON: /api/errors/e3c023d53faa918e. Report an issue: GitHub.