Skip to content
Open
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
18 changes: 18 additions & 0 deletions src/jsc/bindings/ModuleLoader.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include "JavaScriptCore/Identifier.h"
#include "ZigGlobalObject.h"
#include <JavaScriptCore/JSCInlines.h>
#include <JavaScriptCore/GlobalObjectMethodTable.h>
#include <JavaScriptCore/JSNativeStdFunction.h>
#include <JavaScriptCore/JSCJSValueInlines.h>
#include <JavaScriptCore/JSPromise.h>
Expand Down Expand Up @@ -69,6 +70,14 @@ static JSC::JSPromise* resolvedInternalPromise(JSC::JSGlobalObject* globalObject
return promise;
}

// What attaching a handler does (JSPromise::performPromiseThen): a rejection already reported as unhandled gets rejectionHandled.
static void markRejectionAsHandled(Zig::GlobalObject* globalObject, JSC::JSPromise* promise)
{
if (!promise->isHandled())
Zig::GlobalObject::promiseRejectionTracker(globalObject, promise, JSC::JSPromiseRejectionOperation::Handle);
promise->markAsHandled();
}

// Converts an object from InternalModuleRegistry into { ...obj, default: obj }
static JSC::SyntheticSourceProvider::LazySyntheticSourceGenerator generateInternalModuleSourceCode(JSC::JSGlobalObject* globalObject, InternalModuleRegistry::Field moduleId)
{
Expand Down Expand Up @@ -445,6 +454,11 @@ static JSValue handleVirtualModuleResult(

case OnLoadResultTypePromise: {
JSC::JSPromise* promise = uncheckedDowncast<JSC::JSPromise>(onLoadResult.value.promise);
// require() cannot wait for the chain below to report the rejection: it throws the reason now.
if (commonJSModule && promise->status() == JSC::JSPromise::Status::Rejected) {
markRejectionAsHandled(globalObject, promise);
RELEASE_AND_RETURN(scope, reject(promise->result()));
}
JSFunction* performPromiseThenFunction = globalObject->performPromiseThenFunction();
auto callData = JSC::getCallData(performPromiseThenFunction);
ASSERT(callData.type != CallData::Type::None);
Expand Down Expand Up @@ -696,6 +710,8 @@ JSValue fetchCommonJSModule(
RELEASE_AND_RETURN(scope, JSValue {});
}
case JSPromise::Status::Pending: {
// Nothing else can handle this promise: it rejects when the plugin's promise does, after require() threw.
promise->markAsHandled();
Comment thread
claude[bot] marked this conversation as resolved.
JSC::throwTypeError(globalObject, scope, makeString("require() async module \""_s, specifierWtfString, "\" is unsupported. use \"await import()\" instead."_s));
RELEASE_AND_RETURN(scope, JSValue {});
}
Expand Down Expand Up @@ -765,6 +781,8 @@ JSValue fetchCommonJSModule(
RELEASE_AND_RETURN(scope, JSValue {});
}
case JSPromise::Status::Pending: {
// Nothing else can handle this promise: it rejects when the plugin's promise does, after require() threw.
promise->markAsHandled();
JSC::throwTypeError(globalObject, scope, makeString("require() async module \""_s, specifierWtfString, "\" is unsupported. use \"await import()\" instead."_s));
RELEASE_AND_RETURN(scope, JSValue {});
}
Expand Down
218 changes: 218 additions & 0 deletions test/js/bun/plugin/plugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,224 @@ describe("module", () => {
});
});

// Spawned in a subprocess: the failure is an unhandled rejection, which ends the process or fails the test run.
describe.concurrent("require() of a module whose plugin callback returns a promise", () => {
const asyncModuleError = (id: string) =>
`TypeError: require() async module "${id}" is unsupported. use "await import()" instead.`;

// `body` is the body of an async function that has `log(line)`. Gives the lines it logged.
async function run(mode: "bun" | "bun test", body: string, files: Record<string, string> = {}) {
using dir = tempDir("plugin-require-promise", {
...files,
"scenario.test.cjs": `
const lines = [];
const log = line => lines.push(line);
const tick = () => new Promise(resolve => setImmediate(resolve));
const scenario = (async () => {${body}})().then(() => console.log(JSON.stringify(lines)));
${mode === "bun test" ? `require("bun:test").test("scenario", () => scenario);` : ""}
`,
});
await using proc = Bun.spawn({
cmd: mode === "bun test" ? [bunExe(), "test", "./scenario.test.cjs"] : [bunExe(), "scenario.test.cjs"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
let lines: unknown = stdout;
try {
lines = JSON.parse(stdout.trim().split("\n").at(-1)!);
} catch {}
// `bun test` reports on stderr: the lines of a run that passed are left out, anything else is kept.
// A test that takes 10 microseconds or less is reported without its duration.
const report =
/^(scenario\.test\.cjs:|\(pass\) scenario( \[.+\])?| 1 pass| 0 fail|Ran 1 test across 1 file\. \[.+\])?$/;
const unexpected = stderr
.split(/\r?\n/)
.filter(line => mode === "bun" || !report.test(line))
.join("\n");
return { lines, stderr: unexpected, exitCode };
}

describe.each(["bun", "bun test"] as const)("%s", mode => {
it("throws the async module error for a pending promise, however it settles later", async () => {
const result = await run(
mode,
`
const { createRequire } = require("node:module");
const calls = [];
const pending = () => new Promise((resolve, reject) => calls.push({ resolve, reject }));
Bun.plugin({
name: "p",
setup(b) {
b.module("virt", pending);
b.onLoad({ filter: /\\.late$/ }, pending);
b.onLoad({ filter: /.*/, namespace: "late" }, pending);
},
});
const spellings = [
require,
id => module.require(id),
createRequire(__filename),
require("./meta-require.mjs").metaRequire,
];
for (const id of ["virt", "./x.late", "late:x"]) {
const thrown = new Set();
for (const load of spellings) {
try {
load(id);
thrown.add("did not throw");
} catch (e) {
thrown.add(e.name + ": " + e.message.replace(require.resolve(id), id));
}
}
log([...thrown].join(" | "));
}
log("plugin calls: " + calls.length);
for (const [i, { resolve, reject }] of calls.entries()) {
if (i % 2) reject(new Error("late failure " + i));
else resolve({ loader: "object", exports: 7 });
Comment on lines +383 to +385

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): the "resolve" half of this scenario never exercises a late fulfilment, so maintainers get no coverage of the resolve path the test name promises. exports: 7 is not an object, so handleOnLoadObjectResult at src/jsc/bindings/ModuleLoader.cpp:162 turns every even call into the TypeError '"object" loader must return an "exports" object' and all 12 late settlements reject the internal promise. Fix: resolve with a valid result, e.g. { loader: "object", exports: { n: i } }, so the even calls reach promise->resolve at ModuleLoader.cpp:1350 while the odd calls still cover the rejection.

Why this was flagged

The fixture at test/js/bun/plugin/plugins.test.ts:383-385 settles the 12 pending plugin promises: odd ones with reject(), even ones with resolve({ loader: "object", exports: 7 }). The late resolve runs jsFunctionOnLoadObjectResultResolve (src/jsc/bindings/ModuleLoader.cpp:1318), which calls handleVirtualModuleResult with wasModuleMock false, so the object goes through handleOnLoadResultNotPromise to handleOnLoadObjectResult (ModuleLoader.cpp:141). There exportsValue 7 fails isObject() at ModuleLoader.cpp:156 and the result is OnLoadResultTypeError, so ModuleLoader.cpp:1345 rejects the internal promise with the caught TypeError. Thus every late settlement, resolve or reject, ends as a rejection of the internal promise that the new markAsHandled at ModuleLoader.cpp:714 and :785 silences; the fulfilment branch at ModuleLoader.cpp:1350 is never reached. The test still fails on the base branch and passes with the fix, so the fix is covered; what is missing is the behaviour the title claims for a promise that later fulfils validly. No runtime defect; a test-coverage nit.

Verification: nit. The even-indexed late settlements at test/js/bun/plugin/plugins.test.ts:383-385 (resolve({ loader: "object", exports: 7 })) never fulfil the internal promise. exportsValue.isObject() at ModuleLoader.cpp:156 is false, and :162 throws "object" loader must return an "exports" object. So every even call rejects the internal promise; promise->resolve at :1350 is never exercised.

}
await tick();
`,
{ "x.late": "", "meta-require.mjs": `export const metaRequire = import.meta.require;` },
);
expect(result).toEqual({
lines: [asyncModuleError("virt"), asyncModuleError("./x.late"), asyncModuleError("late:x"), "plugin calls: 12"],
stderr: "",
exitCode: 0,
});
});

it("throws the reason of a promise that is already rejected", async () => {
const result = await run(
mode,
`
const errors = {
"virt": new Error("module failed"),
"./x.rejects": new Error("loader failed"),
"rejects:x": new Error("namespace loader failed"),
};
const caught = Promise.reject(errors.virt);
caught.catch(() => {});
Bun.plugin({
name: "p",
setup(b) {
b.module("virt", () => caught);
b.onLoad({ filter: /\\.rejects$/ }, async () => {
throw errors["./x.rejects"];
});
b.onLoad({ filter: /.*/, namespace: "rejects" }, () => Promise.reject(errors["rejects:x"]));
},
});
for (const id in errors) {
try {
require(id);
log(id + " did not throw");
} catch (e) {
log(id + ": " + (e === errors[id] ? "the plugin's error" : e.name + ": " + e.message));
}
}
for (const id in errors) {
await import(id).then(
() => log("import of " + id + " did not reject"),
e => log("import of " + id + ": " + e.message),
);
}
await tick();
`,
{ "x.rejects": "" },
);
expect(result).toEqual({
lines: [
"virt: the plugin's error",
"./x.rejects: the plugin's error",
"rejects:x: the plugin's error",
"import of virt: module failed",
"import of ./x.rejects: loader failed",
"import of rejects:x: namespace loader failed",
],
stderr: "",
exitCode: 0,
});
});

it("leaves the import() that the error asks for to a promise of its own", async () => {
const result = await run(
mode,
`
const calls = [];
Bun.plugin({
name: "p",
setup(b) {
b.module("virt", () => new Promise((resolve, reject) => calls.push({ resolve, reject })));
},
});
let exports;
try {
exports = require("virt");
} catch {
const imported = import("virt");
calls[1].resolve({ loader: "object", exports: { from: "import()" } });
exports = await imported;
}
log("from: " + exports.from);
calls[0].reject(new Error("the promise that require() was given"));
await tick();
log("plugin calls: " + calls.length);
`,
);
expect(result).toEqual({ lines: ["from: import()", "plugin calls: 2"], stderr: "", exitCode: 0 });
});
});

it("emits rejectionHandled for a rejection that was reported before the module was loaded", async () => {
const result = await run(
"bun",
`
const stored = {
"virt-require": Promise.reject(new Error("for require()")),
"virt-import": Promise.reject(new Error("for import()")),
};
const events = { unhandledRejection: [], rejectionHandled: [] };
const nameOf = promise => Object.keys(stored).find(id => stored[id] === promise) ?? "another promise";
process.on("unhandledRejection", (reason, promise) => events.unhandledRejection.push(nameOf(promise)));
process.on("rejectionHandled", promise => events.rejectionHandled.push(nameOf(promise)));
Bun.plugin({
name: "p",
setup(b) {
for (const id in stored) b.module(id, () => stored[id]);
},
});
await tick();
try {
require("virt-require");
log("require() did not throw");
} catch (e) {
log("require(): " + e.message);
}
await import("virt-import").then(
() => log("import() did not reject"),
e => log("import(): " + e.message),
);
await tick();
for (const name in events) log(name + ": " + events[name].sort().join(", "));
`,
);
expect(result).toEqual({
lines: [
"require(): for require()",
"import(): for import()",
"unhandledRejection: virt-import, virt-require",
"rejectionHandled: virt-import, virt-require",
],
stderr: "",
exitCode: 0,
});
});
});

describe("dynamic import", () => {
it("SSRs `<h1>Hello world!</h1>` with Svelte", async () => {
const { default: App }: any = await import("./hello.svelte");
Expand Down
26 changes: 26 additions & 0 deletions test/js/bun/test/mock/mock-module.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,32 @@ test("a pending factory promise that rejects rejects the promise mock.module ret
).toThrow(error);
});

test("require() of a mocked module takes a factory promise that is settled and does not wait for one that is pending", async () => {
mock.module("mock-module-require-fulfilled", async () => ({ value: "mocked" }));
expect(require("mock-module-require-fulfilled").value).toBe("mocked");

const error = new Error("factory failed");
mock.module("mock-module-require-rejected", async () => {
throw error;
});
let thrown: unknown;
try {
require("mock-module-require-rejected");
} catch (e) {
thrown = e;
}
expect(thrown).toBe(error);

const factory = Promise.withResolvers<object>();
mock.module("mock-module-require-pending", () => factory.promise);
expect(() => require("mock-module-require-pending")).toThrow(
'require() async module "mock-module-require-pending" is unsupported. use "await import()" instead.',
);
// Not reported as an unhandled rejection: this test would fail.
factory.reject(new Error("factory failed after require() threw"));
await new Promise(resolve => setImmediate(resolve));
});

test("a pending factory whose exports throw while being read rejects the returned promise and unregisters the mock", async () => {
using dir = tempDir("mock-module-pending-getter-throws", {
"a.ts": `export function a() { return "real-a"; }`,
Expand Down
Loading