Skip to content

Fix crash when constructing a mock function whose implementation returns a primitive#31375

Closed
robobun wants to merge 2 commits into
mainfrom
farm/4e0236f3/mock-construct-return-object
Closed

Fix crash when constructing a mock function whose implementation returns a primitive#31375
robobun wants to merge 2 commits into
mainfrom
farm/4e0236f3/mock-construct-return-object

Conversation

@robobun

@robobun robobun commented May 25, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a crash found by fuzzing (assertion isCell() in JSC::JSValue::asCell, JSCJSValue.h:1043).

JSMockFunction registered jsMockFunctionCall as both its call and construct callback, so constructing a mock returned whatever the mock implementation produced. Native constructors must always return an object: Interpreter::executeConstruct does asObject(result) on the returned value, so a primitive result asserts in debug builds and is a type confusion in release builds. It was also observable from JS — new (mock())() evaluated to undefined, which should be impossible for a new expression.

Repro before this change (crashes debug builds, Reflect.construct path):

import { mock } from "bun:test";
const fn = mock(); // or spyOn(obj, "someNonFunctionProperty")
Reflect.construct(fn, []);

This PR gives JSMockFunction a dedicated construct callback that follows ordinary JS constructor semantics (and matches Jest, where mocks are plain JS functions):

  • creates the this object from newTarget.prototype (falling back to Object.prototype)
  • runs the existing mock call logic with that this, so mock.contexts/mock.results keep recording as before and implementations that assign to this work
  • returns the implementation's result when it is an object, otherwise the newly created this

How did you verify your code works?

  • Added tests in test/js/bun/test/mock-fn.test.js covering new mock() with no implementation, primitive-returning, object-returning, this-mutating and throwing implementations, Reflect.construct with a custom newTarget, and constructing a spy on a missing property (the fuzzer scenario). The new tests fail with the isCell() assertion on a build without the fix and pass with it.
  • bun bd test test/js/bun/test/mock-fn.test.js (also with BUN_JSC_validateExceptionChecks=1), plus mock-disposable.test.ts, spyMatchers.test.ts, and mock/mock-module-non-string.test.ts — all pass.
  • The original fuzzer reproducer no longer crashes.

Mock functions registered the call callback as their construct callback,
so `new mockFn()` (or Reflect.construct) returned whatever the mock
implementation produced. When that value was a primitive, the native
construct contract was violated: Interpreter::executeConstruct treats
the result as a JSObject*, which asserts in debug builds and is a type
confusion in release builds.

Give JSMockFunction a dedicated construct callback that creates the
`this` object from newTarget.prototype, runs the mock with it, and
returns the mock's result only when it is an object, matching ordinary
JS constructor semantics.
@robobun

robobun commented May 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:29 PM PT - May 24th, 2026

@robobun, your commit 85f9f8b has 1 failures in Build #57860 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31375

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

bun-31375 --bun

@coderabbitai

coderabbitai Bot commented May 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, we couldn't start this review because you've used your available PR reviews for now.

Your plan includes 5 reviews of capacity. Refill in 45 minutes and 42 seconds.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more review capacity refills, 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 trial, open-source, and free plans. In all cases, review capacity refills continuously over time.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7368b116-a334-4a27-a77c-efcdbaa2dae9

📥 Commits

Reviewing files that changed from the base of the PR and between 39786a7 and 85f9f8b.

📒 Files selected for processing (1)
  • test/js/bun/test/mock-fn.test.js

Walkthrough

This PR adds support for using new on Jest-style mock functions in Bun's JSC binding. A new jsMockFunctionConstruct host function is implemented to handle construction semantics, wired into JSMockFunction, and validated with test coverage for both mock() and spyOn() constructor scenarios.

Changes

Mock function constructor support

Layer / File(s) Summary
Mock constructor handler implementation
src/jsc/bindings/JSMockFunction.cpp
New jsMockFunctionConstruct function declared and implemented to construct objects like standard JS constructors: creates a this object from newTarget prototype when available, delegates call logic to obtain the return value, and returns that value if it is an object; otherwise returns the constructed this.
JSMockFunction constructor wiring
src/jsc/bindings/JSMockFunction.cpp
JSMockFunction base initialization updated to use jsMockFunctionConstruct for the construct path instead of reusing jsMockFunctionCall.
Constructor behavior test coverage
test/js/bun/test/mock-fn.test.js
Tests added for mock() with new across multiple scenarios (no implementation, primitive return, this mutation, object return, exceptions, newTarget and prototype handling) and for spyOn on missing properties to verify constructibility via both new and Reflect.construct.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and specifically summarizes the main change: fixing a crash when constructing a mock function with a primitive return value.
Description check ✅ Passed The pull request description covers both required sections: 'What does this PR do?' provides detailed context and implementation approach, and 'How did you verify your code works?' documents testing and verification thoroughly.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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.

Comment thread test/js/bun/test/mock-fn.test.js
@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Fix crash constructing jest.fn() via Reflect.construct #30212 - Fixes the same isCell() crash on mock construction by adding an identical jsMockFunctionConstruct callback with the same semantics

🤖 Generated with Claude Code

Comment on lines +1008 to +1010
callframe->setThisValue(thisObject);
JSValue returnValue = JSValue::decode(jsMockFunctionCall(lexicalGlobalObject, callframe));
RETURN_IF_EXCEPTION(scope, {});

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.

🟣 Not a regression, but worth noting while we're here: mock.instances is still never populated — fn->instances is declared, cleared, GC-visited and exposed via getInstances(), but nothing ever pushes to it. In Jest, new mockFn() pushes the constructed this onto mock.instances (it's the canonical API for tracking instances), and the new construct path now has thisObject in hand, so this would be the natural place to wire it up. The new test asserts on noImpl.mock.contexts[0] rather than noImpl.mock.instances[0], which sidesteps the gap.

Extended reasoning...

What the gap is. In Jest, mockFn.mock.instances is the documented API for tracking objects created by calling a mock with new — each new mockFn() pushes the constructed this onto that array. Bun declares the backing storage for this (mutable JSC::WriteBarrier<JSC::JSArray> instances), clears it in clear(), visits it in visitAdditionalChildrenInGCThread, lazily creates it in getInstances(), and wires it into the mock object structure at offset 2 — but nothing in the file ever pushes a value into it. Grepping the whole of JSMockFunction.cpp for instances shows only declarations, clears, visits, and reads; there is no push / initializeIndex / putDirectIndex targeting fn->instances.

Code path. jsMockFunctionCall records each invocation by pushing to fn->calls, fn->contexts, fn->invocationCallOrder, and fn->returnValues, but not fn->instances. The new jsMockFunctionConstruct creates thisObject (exactly the value Jest pushes onto mock.instances), then delegates to jsMockFunctionCall via callframe->setThisValue(thisObject). So after construction, mock.contexts contains the new instance but mock.instances remains empty.

Why nothing prevents it. The only thing that could populate mock.instances is an explicit push, and there isn't one anywhere in the file. The new test in mock-fn.test.js asserts expect(noImpl.mock.contexts[0]).toBe(instance) rather than expect(noImpl.mock.instances[0]).toBe(instance), so the test suite doesn't catch the omission. Under real Jest (which this test file is documented to be runnable against), mock.instances[0] would equal instance — the cross-runner contract is satisfied only because the test avoids checking it.

Step-by-step proof.

  1. const Fn = jest.fn(); const inst = new Fn();
  2. jsMockFunctionConstruct runs, allocates thisObject, sets it as the call frame's this, and calls jsMockFunctionCall.
  3. jsMockFunctionCall pushes thisObject onto fn->contexts (so Fn.mock.contexts === [inst]), pushes [] onto fn->calls, pushes a result onto fn->returnValues, pushes an id onto fn->invocationCallOrder — and returns.
  4. Nothing has touched fn->instances. Fn.mock.instances lazily resolves via getInstances() to a fresh empty array.
  5. Result: Fn.mock.instances[] in Bun, [inst] in Jest.

Impact. This is a pre-existing Jest-compat gap, not a regression — mock.instances was never populated before this PR either, and the PR's stated purpose (fixing the isCell() crash) is achieved correctly without it. But the PR explicitly adds the construct path "to match Jest, where mocks are plain JS functions", and mock.instances is the Jest API specifically for construct calls, so it's directly adjacent. User code that follows the Jest docs (expect(MockCtor.mock.instances[0]).toBe(...)) will still see an empty array under Bun.

Fix. In jsMockFunctionConstruct, after creating thisObject (and before or after delegating to jsMockFunctionCall), push thisObject onto fn->instances using the same pattern used for contexts in jsMockFunctionCall (push if the array exists, otherwise create a 1-element array and fn->instances.set(...)). Then the new test could assert noImpl.mock.instances[0] instead of (or in addition to) noImpl.mock.contexts[0]. Non-blocking — fine as a follow-up.

@robobun

robobun commented May 25, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as a duplicate of #30212, which fixes the same root cause (the mock/spy construct path returning a non-object from a native constructor) by giving JSMockFunction a dedicated construct callback, and already includes a regression test for the exact scenario from this fuzzer report (Reflect.construct/new on a spy created over a missing property). That PR also has a full green CI run apart from two infra-canceled Windows build jobs, so consolidating there rather than keeping two copies of the same fix open.

The non-blocking review note here about populating mock.instances on construct is a pre-existing gap that applies equally to #30212 and can be handled as a follow-up.

@robobun robobun closed this May 25, 2026
@robobun robobun deleted the farm/4e0236f3/mock-construct-return-object branch May 25, 2026 04:27
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.

1 participant