Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/jsc/bindings/ZigGlobalObject.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
56 changes: 56 additions & 0 deletions test/regression/issue/33180.test.ts
Original file line number Diff line number Diff line change
@@ -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);
},
);
}
Loading