pydantic/monty · critical

pending_externals entry doesn't point to an ExternalFuture

Error message

pending_externals entry doesn't point to an ExternalFuture

What it means

In the async scheduler, `fail_for_call` removes the ExternalFuture registered for a failing external call; this panic fires if the heap entry for that future's HeapId is not an `ExternalFuture`. The `pending_externals` map is only populated with future HeapIds the scheduler itself allocated, so this indicates heap corruption or a scheduler bug — it cannot be triggered by host callback code alone.

Source

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

    /// with a clone of the error, and yields the awaiter that owned the
    /// future's `Pending` slot — except for `Awaiter::Task(t)` where `t` is a
    /// child of a still-running gather: in that case we settle the gather here
    /// (rather than leaving the parked task `Failed` for a sibling's
    /// resolution to discover later) and return the gather's awaiter instead,
    /// 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.

View on GitHub (pinned to adc986b362)

Solutions

  1. File a bug including the failing external-call sequence
  2. Audit ExternalFuture drop/cancel paths for frees that leave a stale call_id in `pending_externals`
  3. Run async test suites with memory-model-checks to catch premature frees

Example fix

// not applicable — internal scheduler invariant
Defensive patterns

Strategy: fallback

Try / catch

// Internal panic — no user-side catch; isolate and report.
match scheduler.fail_for_call(call_id, &err, heap) {
    Some(awaiter) => chain(awaiter),
    None => (),
} // if it panics, file a scheduler bug with the call sequence

Prevention

When it happens

Trigger: Calling `fail_for_call(call_id, error, heap)` for a call_id whose `pending_externals` entry points at a heap entry that is not an ExternalFuture; only via heap corruption or a scheduler/allocation bug.

Common situations: Developing or fuzzing the async/external-call scheduler; patches that free an ExternalFuture while its call_id is still pending.

Understand the failure class

Background: "This is a bug, please report it": internal invariant violations, unreachable panics, and SNH errors explained — this error's family across 47 libraries.

Related errors


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