pydantic/monty · critical
resolve_future: future was already resolved
Error message
resolve_future: future was already resolved
What it means
resolve_future asserts the target ExternalFuture is still Pending before delivering the value; if it is already Resolved or Failed the future is being resolved twice, so it panics. Double resolution means the host side resolved the same pending external twice, which would otherwise deliver two values to one awaiter.
Source
Thrown at crates/monty/src/bytecode/vm/async_exec.rs:786
return;
};
// Ensure future cleaned up on all paths
let fut_val = Value::Ref(future_id);
let this = self;
defer_drop!(fut_val, this);
let mut value_guard = DropGuard::new(value, this);
let (value, this) = value_guard.as_parts_mut();
let HeapReadOutput::ExternalFuture(mut fut) = this.heap.read(future_id) else {
panic!("pending_externals entry doesn't point to an ExternalFuture")
};
let awaiter_and_value = match &mut fut.get_mut(this.heap).state {
ExternalFutureState::Pending { awaiter } => awaiter.take().map(|a| (a, value.clone_with_heap(this.heap))),
ExternalFutureState::Resolved(_) | ExternalFutureState::Failed(_) => {
panic!("resolve_future: future was already resolved")
}
};
let (value, this) = value_guard.into_parts();
fut.get_mut(this.heap).state = ExternalFutureState::Resolved(value);
if let Some((awaiter, value)) = awaiter_and_value {
this.deliver_awaiter_success(awaiter, value);
}
}
/// Pushes `value` onto `task_id`'s stack and marks it ready. If the task
/// has already been cancelled (no longer in the scheduler) or failed,
/// drops `value` instead — the resolution still gets cached on the future,
/// but the (now-gone) awaiter doesn't receive it.
///
fn deliver_value_to_task(&mut self, task_id: TaskId, value: Value) {
if !self.scheduler.has_task(task_id) || self.scheduler.is_task_failed(task_id) {View on GitHub (pinned to adc986b362)
Solutions
- Track which pending external ids the host has already answered and skip duplicates
- Remove the id from pending_externals / mark it answered atomically on first resolution
- Do not retry a resolution after a timeout unless the pool session was discarded
- Check for double-delivery when wiring timeout fallbacks alongside normal completion
Example fix
// before
async function answer(fut, value) {
await pool.resolve(fut, value);
}
// after
const answered = new Set();
async function answer(fut, value) {
if (answered.has(fut)) return;
answered.add(fut);
await pool.resolve(fut, value);
} Defensive patterns
Strategy: try-catch
Try / catch
// host side: make resolution idempotent if (answered.has(futureId)) return; answered.add(futureId); await session.resolveFuture(futureId, value);
Prevention
- Make host future resolution idempotent (track answered ids)
- Never resolve the same suspension twice, including from timeout/retry paths
- Discard the session after a timeout instead of racing a late answer
When it happens
Trigger: The host calls the resolve path for the same pending external future twice — e.g. the host callback answer was delivered once automatically and again explicitly, or resume_with_resolved_futures processed a duplicated resolution for the same future id.
Common situations: Host/binding code (PyO3/napi/wasm driver) answering an external-function suspension more than once — often from a retry path or an event handler that re-fires after a timeout fallback.
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
- fail_for_call: future was already resolved
- pending_externals entry doesn't point to an ExternalFuture
- pending_externals entry doesn't point to an ExternalFuture
- gather commit frame id is not a GatherFuture
- gather item is not a Coroutine, ExternalFuture, or GatherFut
AI-assisted analysis of pydantic/monty@adc986b362 (2026-09-13).
Data as JSON: /api/errors/36a88c583727983c.
Report an issue: GitHub.