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

  1. Track which pending external ids the host has already answered and skip duplicates
  2. Remove the id from pending_externals / mark it answered atomically on first resolution
  3. Do not retry a resolution after a timeout unless the pool session was discarded
  4. 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

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


AI-assisted analysis of pydantic/monty@adc986b362 (2026-09-13). Data as JSON: /api/errors/36a88c583727983c. Report an issue: GitHub.