Skip to content

fix(test): mock.module() should work with static imports of mocked modules - #27187

Closed
robobun wants to merge 1 commit into
mainfrom
claude/fix-mock-module-hoisting-18358
Closed

robobun wants to merge 1 commit into
mainfrom
claude/fix-mock-module-hoisting-18358

Conversation

@robobun

@robobun robobun commented Feb 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Fixes mock.module() failing when the mocked module is also statically imported in the same test file
  • Detects top-level mock.module("specifier", fn) calls during parsing and pre-registers placeholder virtual modules with the expected export names before the ESM module graph is built
  • This prevents JSC's linker from throwing SyntaxError: Export named 'X' not found when the real module doesn't have the expected exports

Problem

When a test file uses mock.module() alongside a static import of the same module:

mock.module("@apollo/experimental-nextjs-app-support", () => ({
    registerApolloClient: () => ({...}),
}));
import { registerApolloClient } from "@apollo/experimental-nextjs-app-support";

ESM import hoisting causes the static import to be resolved and linked before mock.module() executes. If the real module doesn't export registerApolloClient, JSC's linker throws a SyntaxError.

Solution

During transpilation, the parser now detects top-level mock.module("specifier", callback) calls and records the specifier strings. After transpilation but before the module graph is built, placeholder virtual modules are registered with the expected export names (derived from the file's static imports of the same specifier). When JSC's module loader encounters these specifiers, it finds the placeholder instead of the real module. Later, when mock.module() actually executes, it replaces the placeholder with the real mock via overrideExportValue.

Test plan

  • New regression test test/regression/issue/18358.test.ts passes with debug build
  • New test fails with system bun (verifying it tests the right thing)
  • Existing test/js/bun/test/mock/mock-module.test.ts tests all pass (no regressions)

Closes #18358

🤖 Generated with Claude Code

…ore ESM linking

When `mock.module()` is called at the top level of a test file alongside
static imports of the same module, ESM import hoisting causes the real
module to be linked before `mock.module()` executes. If the real module
doesn't export the expected names, JSC's linker throws a SyntaxError.

This fix detects top-level `mock.module("specifier", fn)` calls during
parsing and registers placeholder virtual modules with the expected
export names before the module graph is built. When `mock.module()`
later runs at evaluation time, it replaces the placeholder with the
real mock via `overrideExportValue`.

Closes #18358

Co-Authored-By: Claude <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Feb 19, 2026

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@robobun has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 3 minutes and 0 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.


Comment @coderabbitai help to get the list of available commands and usage tips.

@robobun

robobun commented Feb 19, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:35 PM PT - Feb 19th, 2026

❌ Your commit c58ed3ea has 100 failures in Build #37620 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 27187

That installs a local version of the PR into your bun-27187 executable, so you can run:

bun-27187 --bun

@claude

claude Bot commented Feb 19, 2026

Copy link
Copy Markdown
Contributor

🔴 c58ed — 1 issue found

Issue Severity File
Thread-safety: registerPendingMockModules called from worker thread 🔴 Critical src/bun.js/RuntimeTranspilerStore.zig

Comment on lines +577 to +580
// Register placeholder virtual modules for mock.module() targets.
if (parse_result.ast.mock_module_specifiers.count() > 0) {
@import("./ModuleLoader.zig").registerPendingMockModules(vm, &parse_result, path);
}

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.

🔴 Critical: Thread-safety bug: registerPendingMockModules is called from a worker thread (inside TranspilerJob.run()), but it creates JSC heap objects and mutates globalObject->onLoadPlugins which are not safe for concurrent access from non-JS threads. This call should be deferred to runFromJSThread() (which already runs on the main JS thread) by storing the mock specifier data on the TranspilerJob and processing it there.

Why this is a problem

Thread-Safety Violation in registerPendingMockModules on Worker Thread

This PR introduces a call to registerPendingMockModules at line 577-580 of RuntimeTranspilerStore.zig, inside the TranspilerJob.run() function. The problem is that run() executes on a worker thread dispatched via jsc.WorkPool (see schedule() at line 280-282 and runFromWorkerThread() at line 285-287), but registerPendingMockModules performs multiple JSC heap allocations and object mutations that are fundamentally unsafe to perform outside the main JavaScript thread.

Specifically, registerPendingMockModules in ModuleLoader.zig accesses jsc_vm.global and calls the C++ function Bun__registerPendingMockModule. This C++ function performs the following JSC heap operations: JSC::constructEmptyObject() (line 718), placeholderExports->putDirect() (line 722/727), JSModuleMock::createStructure() (line 732), JSModuleMock::create() (line 733), and globalObject->onLoadPlugins.addModuleMock() (line 738). Every one of these operations allocates on or mutates the JSC garbage-collected heap, which is not thread-safe for concurrent access from non-JS threads.

The same registerPendingMockModules call exists in ModuleLoader.zig at line 543, where it is safe because transpileSourceCode runs synchronously on the main JS thread. The RuntimeTranspilerStore is the async transpilation path, specifically designed to offload work to worker threads, and the newly added code does not account for this threading model.

Step-by-step proof of the bug

  1. A test file imports a module (e.g., import { foo } from './helper.ts') where helper.ts contains import { mock } from 'bun:test'; mock.module('./dep', ...) along with a static import of ./dep.
  2. The test runner loads the test file via the synchronous ModuleLoader.transpileSourceCode path (since it is is_main).
  3. When the test file's import of ./helper.ts is resolved, and conditions are met for concurrent transpilation (concurrent_transpiler enabled, has_loaded is true, no plugin runner, etc.), a TranspilerJob is created and schedule() dispatches it to the jsc.WorkPool thread pool.
  4. A worker thread picks up the task and calls runFromWorkerThread(), which calls run().
  5. Inside run(), the parser populates parse_result.ast.mock_module_specifiers because helper.ts contains mock.module() calls and inject_jest_globals is set.
  6. At line 577-580, registerPendingMockModules is called on the worker thread.
  7. registerPendingMockModules calls Bun__registerPendingMockModule, which performs JSC heap allocations (constructEmptyObject, JSModuleMock::create) and mutates shared state (globalObject->onLoadPlugins.addModuleMock) without any lock or synchronization.
  8. Concurrently, the main JS thread may be running GC, allocating objects, or accessing onLoadPlugins—causing heap corruption, use-after-free, or crashes.
  9. Only after run() returns does dispatchToMainThread() enqueue the result back to the main thread—but by then, the unsafe JSC operations have already been performed.

Suggested fix

The mock module registration should be deferred to the main JS thread. The TranspilerJob struct should store the mock specifier data (specifier strings and export names) extracted during parsing on the worker thread, and then runFromJSThread() (which already runs on the main JS thread after dispatchToMainThread()) should call registerPendingMockModules with the stored data. This follows the existing pattern where all JSC object creation is deferred to the main thread callback.

@github-actions

Copy link
Copy Markdown
Contributor

Closing this PR because it has been inactive for more than 90 days.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bun test fails on import server component

1 participant