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
12 changes: 6 additions & 6 deletions src/bun.js/bindings/BunPlugin.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,12 @@
return {};
}

JSC::JSValue callbackValue = callframe->argument(1);
if (!callbackValue.isCell() || !callbackValue.isCallable()) {
scope.throwException(lexicalGlobalObject, JSC::createTypeError(lexicalGlobalObject, "mock(module, fn) requires a function"_s));
return {};
}

auto resolveSpecifier = [&]() -> void {
JSC::SourceOrigin sourceOrigin = callframe->callerSourceOrigin(vm);
if (sourceOrigin.isNull())
Expand Down Expand Up @@ -578,18 +584,12 @@
}
};

resolveSpecifier();
RETURN_IF_EXCEPTION(scope, {});

JSC::JSValue callbackValue = callframe->argument(1);
if (!callbackValue.isCell() || !callbackValue.isCallable()) {
scope.throwException(lexicalGlobalObject, JSC::createTypeError(lexicalGlobalObject, "mock(module, fn) requires a function"_s));
return {};
}

JSC::JSObject* callback = callbackValue.getObject();

JSModuleMock* mock = JSModuleMock::create(vm, globalObject->mockModule.mockModuleStructure.getInitializedOnMainThread(globalObject), callback);

Check notice on line 592 in src/bun.js/bindings/BunPlugin.cpp

View check run for this annotation

Claude / Claude Code Review

Infinite spin loop in getJSValue when mock factory returns a pending Promise

Pre-existing bug in the `getJSValue` lambda (same function this PR modifies): the `while` loop that unwraps Promise chains has no escape path for a Pending promise — `default: { break; }` exits only the `switch`, not the `while`, so when `executeOnce` returns a pending Promise the loop busy-spins forever, permanently hanging the JS event loop thread. This is triggered by calling `mock.module('already-imported-module', async () => ({ default: 42 }))` — the async factory returns a pending Promise
Comment on lines 587 to 592

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.

🟣 Pre-existing bug in the getJSValue lambda (same function this PR modifies): the while loop that unwraps Promise chains has no escape path for a Pending promise — default: { break; } exits only the switch, not the while, so when executeOnce returns a pending Promise the loop busy-spins forever, permanently hanging the JS event loop thread. This is triggered by calling mock.module('already-imported-module', async () => ({ default: 42 })) — the async factory returns a pending Promise synchronously, and the loop never exits.

Extended reasoning...

The bug: In the getJSValue lambda (lines 603–620 of BunPlugin.cpp), a while loop unwraps Promise chains returned by executeOnce. The loop condition is:

while (JSC::JSPromise* promise = jsDynamicCast<JSC::JSPromise*>(result))

The three cases are:

  • Rejected → throws and returns (correctly exits the loop)
  • Fulfilled → updates result with the resolved value (loop exits on next iteration once result is no longer a Promise)
  • Pending (default) → break; which in C++ exits only the enclosing switch statement, not the while loop

Why the loop spins forever: Because result is never updated in the Pending branch, jsDynamicCast<JSC::JSPromise*>(result) evaluates to the same non-null pointer on the very next iteration. The loop has no sleep, no yield, and no way to drain microtasks between iterations — so a Pending promise can never transition to Fulfilled during iteration. The thread spins at 100% CPU until the process is killed.

Concrete trigger sequence:

  1. import 'my-module' — module is now in the ESM registry
  2. mock.module('my-module', async () => ({ default: 42 }))
  3. Inside JSMock__jsModuleMock, esm->get returns the existing entry, so getJSValue() is called
  4. executeOnce calls the async factory; an async function always returns a Promise in Pending state synchronously (the microtask that resolves it hasn't run yet)
  5. The while loop receives a Pending JSPromise, enters default: { break; }, exits the switch, immediately re-evaluates the while condition with the same unchanged result — Pending again
  6. Infinite loop — the JS event loop thread is permanently hung

Why existing code doesn't prevent it: The TODO comment // TODO: blocking wait for promise explicitly acknowledges that blocking on the promise is unimplemented. But the implementation is actively incorrect: instead of returning an error or the pending promise to the caller (as runVirtualModule correctly does at line ~930), it re-spins endlessly.

Contrast with runVirtualModule: That function handles Pending by return promise; — returning the pending promise to the caller for proper async resolution. getJSValue has no equivalent escape.

Fix: In the default/Pending case, either goto / break out of the while loop (e.g., using a labeled break or a flag variable), throw a descriptive error, or return the pending promise to the caller and handle it asynchronously as runVirtualModule does. The minimal safe fix is to add goto done; or restructure to break out of the outer loop when the status is Pending.


auto* esm = globalObject->esmRegistryMap();
RETURN_IF_EXCEPTION(scope, {});
Expand Down
17 changes: 17 additions & 0 deletions test/js/bun/test/mock/mock-module-non-string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,20 @@ test("mock.module still works with valid string argument", async () => {
const m = await import("mock-module-non-string-test-fixture");
expect(m.default).toBe(42);
});

test("mock.module throws TypeError without resolving when callback is missing", () => {
// Running the resolver on an arbitrary user-provided string can enter the
// package-manager auto-install code path and crash. When the caller didn't
// pass a callable second argument, we must fail fast before touching the
// resolver.
const specifiers = [
"function f3() {}",
"function foo(a, b) { return a + b; }",
"() => 1",
"some bogus package name {with braces}",
];
for (const specifier of specifiers) {
// @ts-expect-error missing callback on purpose
expect(() => mock.module(specifier)).toThrow("mock(module, fn) requires a function");
}
});
Loading