{"record":{"id":"e3c023d53faa918e","repo":"pydantic/monty","slug":"fail-for-call-future-was-already-resolved","errorCode":null,"errorMessage":"fail_for_call: future was already resolved","messagePattern":"fail_for_call: future was already resolved","errorType":"panic","errorClass":null,"httpStatus":null,"severity":"critical","filePath":"crates/monty/src/bytecode/vm/scheduler.rs","lineNumber":486,"sourceCode":"    /// so the caller's chain walk picks up at the right level.\n    ///\n    /// The returned `Awaiter` is owned (callers must walk it via\n    /// `deliver_awaiter_failure`, which drops every link).\n    ///\n    /// Returns `None` when there's nothing to propagate (unknown CallId,\n    /// already-resolved future, or the future had no awaiter — the failure\n    /// is simply cached on the future for replay).\n    #[must_use]\n    pub fn fail_for_call(&mut self, call_id: CallId, error: &RunError, heap: &mut HeapReader<'_>) -> Option<Awaiter> {\n        let future_id = self.pending_externals.remove(&call_id)?;\n\n        let HeapReadOutput::ExternalFuture(mut fut) = heap.read(future_id) else {\n            panic!(\"pending_externals entry doesn't point to an ExternalFuture\")\n        };\n        let awaiter = match mem::replace(&mut fut.get_mut(heap).state, ExternalFutureState::Failed(error.clone())) {\n            ExternalFutureState::Pending { awaiter } => awaiter,\n            ExternalFutureState::Resolved(_) | ExternalFutureState::Failed(_) => {\n                panic!(\"fail_for_call: future was already resolved\")\n            }\n        };\n        drop(fut);\n        heap.dec_ref(future_id);\n\n        match awaiter {\n            // Nothing is waiting on this call — it was never awaited. The\n            // failure stays cached on the future for a later await to replay.\n            None => None,\n            Some(Awaiter::Task(task_id)) => {\n                // A task's own awaiter is the `GatherSlot` its gather gave it;\n                // borrow that gather's id without taking the task's ref, which\n                // stays until the task is cancelled.\n                let gather_id = match self.tasks.get(&task_id).and_then(|t| t.awaiter.as_ref()) {\n                    Some(Awaiter::GatherSlot { gather, .. }) => Some(*gather),\n                    Some(Awaiter::Task(_)) | None => None,\n                };\n                match gather_id {","sourceCodeStart":468,"sourceCodeEnd":504,"githubUrl":"https://github.com/pydantic/monty/blob/adc986b362e3961f407868cb118a99fe831b9e61/crates/monty/src/bytecode/vm/scheduler.rs#L468-L504","documentation":"`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.","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"// before: host times out AND delivers the error\nif timed_out { scheduler.fail_for_call(id, &err, heap); }\nscheduler.fail_for_call(id, &err, heap); // second call panics\n// after\nif timed_out {\n    scheduler.fail_for_call(id, &err, heap);\n} else {\n    scheduler.fail_for_call(id, &err, heap);\n}","handlingStrategy":"validation","validationCode":"// Host-side: track settled calls before failing\nlet mut settled: std::collections::HashSet<CallId> = HashSet::new();\nfn fail_once(scheduler: &mut Scheduler, id: CallId, err: &RunError, heap: &mut Heap) {\n    if settled.insert(id) {\n        scheduler.fail_for_call(id, err, heap);\n    }\n}","typeGuard":null,"tryCatchPattern":"// Panic is by design on double-settle; guard on the host side instead:\nif !settled.contains(&call_id) {\n    scheduler.fail_for_call(call_id, &error, heap);\n    settled.insert(call_id);\n}","preventionTips":["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"],"tags":["panic","async","scheduler","double-resolution","futures"],"backgroundTag":"invalid-state-transition","analyzedSha":"adc986b362e3961f407868cb118a99fe831b9e61","analyzedAt":"2026-09-13T19:19:18.698Z","contentChangedAt":"2026-09-13T19:19:18.698Z","schemaVersion":2},"datasetVersion":"2026-09-14T11:17:12.474Z"}