From 88c52fc79ddf9b203a2d508800a28770e2139a25 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Sat, 21 Feb 2026 08:47:45 +0000 Subject: [PATCH] fix: prevent double-fulfill of fetch promise when import() and require() race on the same ESM module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `import()` starts fetching an ESM module, JSC's `requestFetch` creates `entry.fetch` as a `.then()` chained promise with a pending microtask reaction. If `require()` then loads the same module, `fetchCommonJSModuleNonBuiltin` called `provideFetch` which fulfilled that same promise directly via `fulfillFetch`. When the pending microtask reaction later fired during microtask draining, it attempted to resolve the already-fulfilled promise, triggering `ASSERT(status() == Status::Pending)` in `JSPromise::fulfillPromise`. This was exposed by commit 21c3439bb4 which added `Loader.registry.$delete(id)` to the require() error path. The underlying issue is that Bun was calling `provideFetch` on a module whose fetch pipeline was already owned by import(). The fix has two parts: 1. **ModuleLoader.cpp**: Extend the existing `hasAlreadyLoadedESMVersionSoWeShouldntTranspileItTwice` guard to also detect when `import()` has a fetch in progress (entry exists with a fetch promise but state is still `Fetch`). This prevents `fetchCommonJSModuleNonBuiltin` from calling `provideFetch` on that entry. 2. **CommonJS.ts**: In `loadEsmIntoCjs`, clear `entry.fetch` before calling `$fulfillModuleSync`, so that `provideFetch` → `fulfillFetch` creates a fresh promise instead of fulfilling import()'s pending chained promise. Closes #12910 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/bun.js/bindings/ModuleLoader.cpp | 12 +++++++++++- src/js/builtins/CommonJS.ts | 9 +++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/bun.js/bindings/ModuleLoader.cpp b/src/bun.js/bindings/ModuleLoader.cpp index a647e3d52a..bc8f3b9ba4 100644 --- a/src/bun.js/bindings/ModuleLoader.cpp +++ b/src/bun.js/bindings/ModuleLoader.cpp @@ -768,7 +768,17 @@ JSValue fetchCommonJSModule( RETURN_IF_EXCEPTION(scope, false); int status = entry.getObject()->getDirect(vm, WebCore::clientData(vm)->builtinNames().statePublicName()).asInt32(); - return status > JSModuleLoader::Status::Fetch; + if (status > JSModuleLoader::Status::Fetch) + return true; + + // Also skip when import() has already started fetching this module + // (entry exists with a fetch promise). Calling provideFetch in + // fetchCommonJSModuleNonBuiltin would fulfill that promise directly, + // conflicting with the pending microtask reaction from import()'s + // fetch chain and causing a double-fulfill assertion. + // https://github.com/oven-sh/bun/issues/12910 + JSValue fetchValue = entry.getObject()->getDirect(vm, JSC::Identifier::fromString(vm, "fetch"_s)); + return fetchValue && !fetchValue.isUndefined(); }(); RETURN_IF_EXCEPTION(scope, {}); diff --git a/src/js/builtins/CommonJS.ts b/src/js/builtins/CommonJS.ts index 466be81507..5990bd183f 100644 --- a/src/js/builtins/CommonJS.ts +++ b/src/js/builtins/CommonJS.ts @@ -205,6 +205,15 @@ export function loadEsmIntoCjs(resolvedSpecifier: string) { (!$isPromise(fetch) || ($getPromiseInternalField(fetch, $promiseFieldFlags) & $promiseStateMask) === $promiseStatePending)) ) { + // If import() already started fetching this module, entry.fetch is a + // chained promise with a pending microtask reaction from the raw fetch + // promise. Clear it so $fulfillModuleSync → provideFetch → fulfillFetch + // creates a fresh promise instead of fulfilling the old one. + // https://github.com/oven-sh/bun/issues/12910 + if (entry) { + entry.fetch = undefined; + } + // force it to be no longer pending $fulfillModuleSync(key);