{"record":{"id":"36a88c583727983c","repo":"pydantic/monty","slug":"resolve-future-future-was-already-resolved","errorCode":null,"errorMessage":"resolve_future: future was already resolved","messagePattern":"resolve_future: future was already resolved","errorType":"panic","errorClass":null,"httpStatus":null,"severity":"critical","filePath":"crates/monty/src/bytecode/vm/async_exec.rs","lineNumber":786,"sourceCode":"            return;\n        };\n\n        // Ensure future cleaned up on all paths\n        let fut_val = Value::Ref(future_id);\n        let this = self;\n        defer_drop!(fut_val, this);\n\n        let mut value_guard = DropGuard::new(value, this);\n        let (value, this) = value_guard.as_parts_mut();\n\n        let HeapReadOutput::ExternalFuture(mut fut) = this.heap.read(future_id) else {\n            panic!(\"pending_externals entry doesn't point to an ExternalFuture\")\n        };\n\n        let awaiter_and_value = match &mut fut.get_mut(this.heap).state {\n            ExternalFutureState::Pending { awaiter } => awaiter.take().map(|a| (a, value.clone_with_heap(this.heap))),\n            ExternalFutureState::Resolved(_) | ExternalFutureState::Failed(_) => {\n                panic!(\"resolve_future: future was already resolved\")\n            }\n        };\n\n        let (value, this) = value_guard.into_parts();\n        fut.get_mut(this.heap).state = ExternalFutureState::Resolved(value);\n\n        if let Some((awaiter, value)) = awaiter_and_value {\n            this.deliver_awaiter_success(awaiter, value);\n        }\n    }\n\n    /// Pushes `value` onto `task_id`'s stack and marks it ready. If the task\n    /// has already been cancelled (no longer in the scheduler) or failed,\n    /// drops `value` instead — the resolution still gets cached on the future,\n    /// but the (now-gone) awaiter doesn't receive it.\n    ///\n    fn deliver_value_to_task(&mut self, task_id: TaskId, value: Value) {\n        if !self.scheduler.has_task(task_id) || self.scheduler.is_task_failed(task_id) {","sourceCodeStart":768,"sourceCodeEnd":804,"githubUrl":"https://github.com/pydantic/monty/blob/adc986b362e3961f407868cb118a99fe831b9e61/crates/monty/src/bytecode/vm/async_exec.rs#L768-L804","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"// before\nasync function answer(fut, value) {\n  await pool.resolve(fut, value);\n}\n// after\nconst answered = new Set();\nasync function answer(fut, value) {\n  if (answered.has(fut)) return;\n  answered.add(fut);\n  await pool.resolve(fut, value);\n}","handlingStrategy":"try-catch","validationCode":null,"typeGuard":null,"tryCatchPattern":"// host side: make resolution idempotent\nif (answered.has(futureId)) return;\nanswered.add(futureId);\nawait session.resolveFuture(futureId, value);","preventionTips":["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"],"tags":["async","futures","double-resolution"],"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-14T16:17:12.679Z"}