fix: prevent double-fulfill of fetch promise when import() and require() race on the same ESM module

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) <noreply@anthropic.com>
This commit is contained in:
Dylan Conway
2026-02-21 08:47:45 +00:00
parent 06f26e5f01
commit 88c52fc79d
2 changed files with 20 additions and 1 deletions

View File

@@ -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, {});

View File

@@ -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);