diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index adb5ed6ecdda..7372ac109695 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -766,6 +766,35 @@ JSC_DEFINE_HOST_FUNCTION(functionEsmLoadSync, (JSC::JSGlobalObject * lexicalGlob RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(ns); } + + // A concurrent import may leave this entry Fetching with no record; drive + // the fetch synchronously so a sync-completable module reaches Fetched + // before loadModuleSync links it. Async fetches (TLA, plugin) stay pending. + if (entry->status() == JSC::ModuleRegistryEntry::Status::Fetching && !entry->record()) { + JSPromise* fetchPromise = entry->ensureFetchPromise(globalObject); + JSPromise* modulePromise = entry->ensureModulePromise(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + if (modulePromise->status() == JSPromise::Status::Pending && fetchPromise->status() == JSPromise::Status::Pending) { + JSC::VM::SynchronousModuleQueue queue; + queue.prev = vm.m_synchronousModuleQueue; + vm.m_synchronousModuleQueue = &queue; + JSPromise* src = loader->fetch(globalObject, keyValue, nullptr, nullptr); + bool settled = false; + if (!scope.exception()) { + if (src->status() == JSPromise::Status::Fulfilled) { + fetchPromise->fulfillPromise(vm, src->result()); + settled = true; + } else if (src->status() == JSPromise::Status::Rejected) { + fetchPromise->rejectPromise(vm, src->result()); + settled = true; + } + if (settled && !scope.exception()) + JSC::JSModuleLoader::drainSynchronousModuleQueue(globalObject); + } + vm.m_synchronousModuleQueue = queue.prev; + RETURN_IF_EXCEPTION(scope, {}); + } + } } JSPromise* promise = loader->loadModuleSync(globalObject, key, nullptr, nullptr); diff --git a/test/regression/issue/33180.test.ts b/test/regression/issue/33180.test.ts new file mode 100644 index 000000000000..83e5d3beba00 --- /dev/null +++ b/test/regression/issue/33180.test.ts @@ -0,0 +1,56 @@ +// https://github.com/oven-sh/bun/issues/33180 +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +// A plain (no top-level await) ESM module must stay require()-able while a +// concurrent static import is still fetching it off-thread. The target is large +// so its transpile reliably overlaps the tiny CommonJS sibling's require(). +function makeTarget() { + let src = ""; + for (let i = 0; i < 1000; i++) src += `export const v${i} = ${i};\n`; + src += `export const val = 42;\n`; + src += `export function getVal() { return val; }\n`; + return src; +} + +const files = { + "target.mjs": makeTarget(), + "cjs-req.cjs": ` + const t = require("./target.mjs"); + if (t.val !== 42 || t.getVal() !== 42) { + console.error("half-initialized namespace: val=" + t.val); + process.exit(3); + } + console.log("required-ok"); + module.exports = {}; + `, + "entry.mjs": ` + import "./target.mjs"; + import "./cjs-req.cjs"; + console.log("entry-done"); + `, +}; + +// Independent attempts (separate temp dirs + child processes, nothing shared); +// without the fix the race throws the false "async module" error on essentially +// every debug run, so any attempt suffices. +for (let attempt = 0; attempt < 4; attempt++) { + test.concurrent( + `require(esm) racing a concurrent static import loads synchronously (attempt ${attempt})`, + async () => { + using dir = tempDir("issue-33180", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.mjs"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("async module"); + expect(stdout).toContain("required-ok"); + expect(stdout).toContain("entry-done"); + expect(exitCode).toBe(0); + }, + ); +}