Skip to content

mock.module: validate callback before running resolver#29485

Merged
Jarred-Sumner merged 2 commits intomainfrom
farm/9591bc2d/mock-module-callback-before-resolve
Apr 21, 2026
Merged

mock.module: validate callback before running resolver#29485
Jarred-Sumner merged 2 commits intomainfrom
farm/9591bc2d/mock-module-callback-before-resolve

Conversation

@robobun
Copy link
Copy Markdown
Collaborator

@robobun robobun commented Apr 19, 2026

Crash

Fuzzilli hit a flaky use-after-poison (fingerprint Address:use-after-poison:bun-debug+0x8f2ee1e) from:

const v3 = Bun.jest().vi;
try { v3.mock("PbQ"); } catch (e) {}
Bun.gc(true);

JSMock__jsModuleMock runs resolveSpecifier() before checking whether the second argument is callable. "PbQ" is a valid npm package name, so the isNPMPackageName gate added in #29255 lets it through to the auto-install path:

PackageManager.enqueueDependencyToRoot
-> PackageManager.sleepUntil
-> EventLoop.tick()      // re-entry while still inside JSMock__jsModuleMock

That re-entry (plus the ResolveMessage thrown by the failed resolution, whose referrer field borrows from a stack-temporary WTF::String) leaves the process in a state where a later GC can read freed mimalloc memory. The crash reproduces only under fuzzilli's REPRL with specific prior state, which is why it's flaky.

Fix

Move the callable check for the second argument in front of resolveSpecifier(). When the caller omits the callback (or passes something non-callable), mock.module / vi.mock throw TypeError: mock(module, fn) requires a function without ever entering the resolver — no auto-install, no event-loop re-entry, no ResolveMessage.

The observable error is unchanged; only its timing moves earlier.

This is the same reorder as the previously-closed #28946. That PR was closed in favour of #29255, which gated auto-install on isNPMPackageName; but valid package names like "PbQ" pass that gate, so the 1-arg misuse still reaches the resolver on main today.

Test

test/js/bun/test/mock/mock-module-non-string.test.ts gains two cases:

  • A direct assertion that mock.module(specifier) / mock.module(specifier, 123) throw the expected TypeError for several specifiers including valid npm package names.
  • A subprocess test that runs vi.mock("PbQ") under --install=force with a local registry. On main the resolver blocks on the registry (the test races against the first request and fails fast with a clear message); with this change the process throws immediately, makes zero registry requests, and exits 0.
test/js/bun/test/mock/mock-module-non-string.test.ts:
(pass) mock.module throws TypeError for non-string first argument
(pass) mock.module still works with valid string argument
(pass) mock.module does not crash on specifiers that are not valid npm package names
(pass) mock.module throws TypeError without resolving when callback is missing
(pass) mock.module does not run the resolver when callback is missing

Also verified mock-module.test.ts, mock-module-resolve-log.test.ts, and resolve-autoinstall-invalid-name.test.ts still pass.

Fingerprint: Address:use-after-poison:bun-debug+0x8f2ee1e

@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented Apr 19, 2026

Updated 9:51 AM PT - Apr 19th, 2026

@autofix-ci[bot], your commit 7484608 has 5 failures in Build #46487 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29485

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

bun-29485 --bun

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 19, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b80ecdb4-2da2-4215-bdac-1846d2e44de0

📥 Commits

Reviewing files that changed from the base of the PR and between 4bb69e7be064bcca1eae75534cbf4dbcd9dc86ec and 7484608.

📒 Files selected for processing (3)
  • docs/runtime/bunfig.mdx
  • src/bun.js/bindings/BunPlugin.cpp
  • test/js/bun/test/mock/mock-module-non-string.test.ts

Walkthrough

Reordered validation in JSMock__jsModuleMock to check the second argument is callable before invoking resolveSpecifier. Added tests ensuring a missing or non-callable callback throws TypeError immediately and does not trigger resolver/registry network activity.

Changes

Cohort / File(s) Summary
Argument validation reordering
src/bun.js/bindings/BunPlugin.cpp
Moved the callback (second-argument) callable check to before resolveSpecifier() and removed the duplicated later check so resolver is not invoked for invalid callbacks.
Mock module callback validation tests
test/js/bun/test/mock/mock-module-non-string.test.ts
Added tests: (1) assert mock.module(specifier) and mock.module(specifier, 123) throw TypeError: mock(module, fn) requires a function for several non-resolvable/specifier inputs; (2) spawn-isolated process test verifying the error is thrown and no registry HTTP requests occur when callback is omitted.
Docs table formatting
docs/runtime/bunfig.mdx
Adjusted Markdown table column separator spacing under install.prefer → “Valid values” (formatting-only change).
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'mock.module: validate callback before running resolver' clearly and concisely summarizes the main change: moving callback validation before the resolver execution.
Description check ✅ Passed The description thoroughly explains the crash, the fix, and testing approach. It covers the root cause, the solution, and verification steps, matching the template's intent.
Linked Issues check ✅ Passed The changes fully address the objectives from #28946: validating the callback before resolver execution, preventing event-loop re-entry, and adding regression tests.
Out of Scope Changes check ✅ Passed All changes are within scope: BunPlugin.cpp refactors callback validation order, new test cases validate the fix, and documentation markdown formatting is a minor maintenance update.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@github-actions
Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. bun test panics after tests pass when using mock.module() with bun:sqlite and repeated SQLite create/close cycles on macOS #28557 - Reports a panic/crash when using mock.module() with bun:sqlite. This PR fix to validate the callback before running the resolver in JSMock__jsModuleMock addresses the same crash-prone code path

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #28557

Generated with Claude Code

@github-actions
Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. mock.module: skip resolve for specifiers with control characters #28945 - Also prevents the use-after-poison crash in JSMock__jsModuleMock by skipping resolveSpecifier() for specifiers with control characters (different guard, same root cause)
  2. mock.module: reject specifiers with whitespace or bracket characters #28956 - Same crash fix with a broader specifier-validation filter (rejects whitespace/bracket characters before resolving)

🤖 Generated with Claude Code

@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented Apr 19, 2026

Not a duplicate of #28945 / #28956. Those gate on the specifier (control chars / whitespace / brackets) and would let "PbQ" through — it's a perfectly valid npm package name. This PR gates on the callback argument, so vi.mock("PbQ") with no second argument throws before the resolver runs regardless of how clean the specifier looks. They're complementary, not overlapping.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

LGTM — straightforward reorder of the callable check before resolveSpecifier() to fix the use-after-poison crash.

Extended reasoning...

Overview

The PR moves the callbackValue.isCallable() check in JSMock__jsModuleMock (BunPlugin.cpp) from after resolveSpecifier() to before it. Two test cases are added to mock-module-non-string.test.ts covering the TypeError and the subprocess registry-race scenario.

Security Risks

None. This is a defensive validation reorder — no new attack surface, no auth/crypto/permissions code touched.

Level of Scrutiny

Low. The change is a 6-line block move with no logic modifications. The error message, call path, and observable behavior for valid calls are unchanged. The only difference is that invalid calls now fail earlier (before the resolver runs).

Other Factors

  • No bugs found by the bug hunting system.
  • The root cause (use-after-poison from event-loop re-entry during resolver) is well-documented in the PR description.
  • Tests are well-structured and cover both the fast-path TypeError and the regression scenario.

JSMock__jsModuleMock previously ran resolveSpecifier() before checking
whether the second argument was callable. When the specifier is a valid
npm package name (e.g. "PbQ") and auto-install is enabled, the resolver
reaches PackageManager.enqueueDependencyToRoot -> sleepUntil -> event
loop re-entry while still inside the host function. Fuzzilli hit a
use-after-poison on this path; #29255 closed the door for invalid
package names but valid names still reach it.

Move the callable check in front of resolveSpecifier() so that
mock.module/vi.mock throws TypeError: mock(module, fn) requires a
function without ever touching the resolver when no callback was
supplied. The observable error is unchanged for callers.

The new regression test stands up a local registry and runs
vi.mock("PbQ") with --install=force in a subprocess. Before this
change the resolver hits the registry and blocks; after, zero requests
are made and the process exits cleanly.
@robobun robobun force-pushed the farm/9591bc2d/mock-module-callback-before-resolve branch from 4bb69e7 to 74649b3 Compare April 19, 2026 15:45
Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

LGTM — clean reorder of existing validation logic with solid regression tests.

Extended reasoning...

Overview

Moves the callback-callable check (6 lines) from after resolveSpecifier() to before it in JSMock__jsModuleMock in BunPlugin.cpp. Two regression tests are added to mock-module-non-string.test.ts: one unit test asserting TypeError is thrown before the resolver runs, and one subprocess test confirming zero registry requests are made when the callback is missing.

Security risks

None. This is an argument validation reorder in the mock testing API — no auth, crypto, or permission surfaces touched.

Level of scrutiny

Low. The diff is a mechanical reorder of a single block of existing code with no logic changes. The error message and observable behavior are identical; only the timing of the check moves earlier. The risk of regression is minimal.

Other factors

No outstanding reviewer comments. coderabbitai raised no actionable concerns. The prior related PRs (#28945, #28956) addressed different guard conditions (specifier characters), so this is genuinely complementary, not a duplicate. The subprocess test design (racing process exit against the first registry request) is a robust way to detect regression without flakiness.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

LGTM — surgical fix for the use-after-poison crash.

Extended reasoning...

Overview

This PR touches (reordering 6 lines in ), (two new test cases), and (whitespace-only table formatting fix).

Security risks

None. This is a defensive validation reorder — it causes / to throw before entering the resolver when the callback argument is missing or non-callable, preventing reentrant event-loop ticks and the resulting use-after-poison on the stack-temporary .

Level of scrutiny

Low. The C++ change is literally moving the existing callable-check block from after to before it. The check itself is unchanged; only its position in the function moved. The ordering doesn't affect any valid call (a valid call has a callable second argument regardless of when we check). The fix is the same reorder as the previously-closed #28946, now correctly covering the case where the specifier is a valid npm package name that bypasses the guard.

Other factors

No bugs were found by the automated hunting system. The two new tests directly cover the regression: one inline (multiple specifiers, both missing and non-callable callbacks) and one subprocess test that races process exit against a registry HTTP request to catch any future regression that would reach the auto-install path. The CI failures in the timeline reference commit ; the autofix.ci follow-up () addressed formatting, and the libuv/zig failures appear pre-existing and unrelated to this PR's changes.

@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented Apr 19, 2026

Build #46487 failures are all pre-existing on main / unrelated to this change:

test/js/bun/test/mock/mock-module-non-string.test.ts (the test this PR adds) passed on every platform.

@Jarred-Sumner Jarred-Sumner merged commit c8e6aa1 into main Apr 21, 2026
52 of 61 checks passed
@Jarred-Sumner Jarred-Sumner deleted the farm/9591bc2d/mock-module-callback-before-resolve branch April 21, 2026 04:41
structwafel pushed a commit to structwafel/bun that referenced this pull request Apr 25, 2026
## Crash

Fuzzilli hit a flaky use-after-poison (fingerprint
`Address:use-after-poison:bun-debug+0x8f2ee1e`) from:

```js
const v3 = Bun.jest().vi;
try { v3.mock("PbQ"); } catch (e) {}
Bun.gc(true);
```

`JSMock__jsModuleMock` runs `resolveSpecifier()` before checking whether
the second argument is callable. `"PbQ"` is a valid npm package name, so
the `isNPMPackageName` gate added in oven-sh#29255 lets it through to the
auto-install path:

```
PackageManager.enqueueDependencyToRoot
-> PackageManager.sleepUntil
-> EventLoop.tick()      // re-entry while still inside JSMock__jsModuleMock
```

That re-entry (plus the `ResolveMessage` thrown by the failed
resolution, whose `referrer` field borrows from a stack-temporary
`WTF::String`) leaves the process in a state where a later GC can read
freed mimalloc memory. The crash reproduces only under fuzzilli's REPRL
with specific prior state, which is why it's flaky.

## Fix

Move the callable check for the second argument in front of
`resolveSpecifier()`. When the caller omits the callback (or passes
something non-callable), `mock.module` / `vi.mock` throw `TypeError:
mock(module, fn) requires a function` without ever entering the resolver
— no auto-install, no event-loop re-entry, no `ResolveMessage`.

The observable error is unchanged; only its timing moves earlier.

This is the same reorder as the previously-closed oven-sh#28946. That PR was
closed in favour of oven-sh#29255, which gated auto-install on
`isNPMPackageName`; but valid package names like `"PbQ"` pass that gate,
so the 1-arg misuse still reaches the resolver on main today.

## Test

`test/js/bun/test/mock/mock-module-non-string.test.ts` gains two cases:

- A direct assertion that `mock.module(specifier)` /
`mock.module(specifier, 123)` throw the expected `TypeError` for several
specifiers including valid npm package names.
- A subprocess test that runs `vi.mock("PbQ")` under `--install=force`
with a local registry. On main the resolver blocks on the registry (the
test races against the first request and fails fast with a clear
message); with this change the process throws immediately, makes
**zero** registry requests, and exits 0.

```
test/js/bun/test/mock/mock-module-non-string.test.ts:
(pass) mock.module throws TypeError for non-string first argument
(pass) mock.module still works with valid string argument
(pass) mock.module does not crash on specifiers that are not valid npm package names
(pass) mock.module throws TypeError without resolving when callback is missing
(pass) mock.module does not run the resolver when callback is missing
```

Also verified `mock-module.test.ts`, `mock-module-resolve-log.test.ts`,
and `resolve-autoinstall-invalid-name.test.ts` still pass.

Fingerprint: `Address:use-after-poison:bun-debug+0x8f2ee1e`

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
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.

2 participants