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
- 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
- Check the host's timeout/error handling so timeouts do not race with delivering the real result for the same call
- 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
- Fail or resolve each external call exactly once
- Don't deliver both a timeout failure and the real result for the same call
- Remove the call from pending state as soon as it is answered
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
- pending_externals entry doesn't point to an ExternalFuture
- resolve_future: future was already resolved
- gather_id doesn't point to a GatherFuture
- task coroutine_id doesn't point to a Coroutine
- task has no frames and no coroutine_id
AI-assisted analysis of pydantic/monty@adc986b362 (2026-09-13).
Data as JSON: /api/errors/e3c023d53faa918e.
Report an issue: GitHub.