Skip to content

Fix crash when constructing jest.fn() via Reflect.construct#31164

Closed
robobun wants to merge 2 commits into
mainfrom
farm/f4798f68/mock-fn-construct-crash
Closed

Fix crash when constructing jest.fn() via Reflect.construct#31164
robobun wants to merge 2 commits into
mainfrom
farm/f4798f68/mock-fn-construct-crash

Conversation

@robobun

@robobun robobun commented May 21, 2026

Copy link
Copy Markdown
Collaborator

What

Fixes an assertion failure (cell->isObjectSlow() in JSObject.h) when a mock function is invoked as a constructor through Reflect.construct (or any native-construct path).

const fn = jest.fn();
Reflect.construct(fn, []); // crashed in debug builds

Why

JSMockFunction registered the same native function for both [[Call]] and [[Construct]]. When invoked as a native constructor, JSC's Interpreter::executeConstruct unconditionally wraps the result with asObject(), which asserts the value is an object. A mock with no implementation (or one returning a primitive / mockReturnValue(primitive)) returned undefined or a primitive, tripping the assertion.

The new fn() bytecode path happened not to assert, but still returned undefined, which is also wrong per [[Construct]] semantics.

How

Split the call/construct entry points. In the construct path, allocate a fresh this object up front, pass it to the implementation as this, and return it when the mock's result is not an object — matching ordinary [[Construct]] semantics and Jest's behavior.

Found by Fuzzilli.

JSMockFunction used the same native function for both [[Call]] and
[[Construct]]. When used as a constructor, JSC's Interpreter::executeConstruct
asserts the result is an object via asObject(). A mock with no implementation
(or one that returns a primitive) returned a non-object, tripping the
assertion.

Split the call/construct entry points. In the construct path, allocate a
fresh object for 'this' and return it when the mock result is not an object,
matching ordinary [[Construct]] semantics.
@robobun

robobun commented May 21, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:08 PM PT - May 20th, 2026

@robobun, your commit 271bf5b has 2 failures in Build #56553 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31164

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

bun-31164 --bun

@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

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: 9e3ce182-3cbf-45d4-9c4a-866bbfe54769

📥 Commits

Reviewing files that changed from the base of the PR and between 753f1c4 and ea72bc3.

📒 Files selected for processing (2)
  • src/jsc/bindings/JSMockFunction.cpp
  • test/js/bun/test/mock-fn-construct.test.ts

Walkthrough

This PR adds constructor support to Jest mock functions in JSC. Mock functions now correctly handle the Reflect.construct operator and new keyword by implementing a unified call/construct helper that enforces JS constructor semantics: allocating fresh this for construction, ensuring non-object returns resolve to the constructed object, and dispatching both paths through shared mock execution logic.

Changes

Jest mock function constructor support in JSC

Layer / File(s) Summary
Constructor declaration and wiring
src/jsc/bindings/JSMockFunction.cpp
Forward declaration of jsMockFunctionConstruct is added, and the JSMockFunction base internal-function wrapper is updated to route construct operations to the new handler instead of reusing the call handler.
Unified helper with constructor semantics
src/jsc/bindings/JSMockFunction.cpp
New jsMockFunctionCallOrConstruct helper centralizes call and construct execution: it allocates fresh this and defines return encoding for construction, applies that encoding to all mock result branches (call/return/returnThis), and exposes both jsMockFunctionCall and jsMockFunctionConstruct as thin wrappers that delegate with the appropriate isConstruct flag.
Constructor behavior tests
test/js/bun/test/mock-fn-construct.test.ts
Test suite covering Reflect.construct with jest.fn(), mockReturnValue, mockReturnThis, explicit newTarget, spyOn with undefined properties, and this mutation during construction, plus control tests confirming normal calls preserve this semantics.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: fixing a crash when constructing jest.fn() via Reflect.construct.
Description check ✅ Passed The description fully covers the template requirements with comprehensive 'What', 'Why', and 'How' sections explaining the issue, root cause, and solution.
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.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Fix crash constructing jest.fn() via Reflect.construct #30212 - Also fixes the crash when constructing jest.fn() via Reflect.construct by splitting call/construct entry points in JSMockFunction.cpp

🤖 Generated with Claude Code

Comment on lines +843 to +845
if (isConstruct) {
thisValue = JSC::constructEmptyObject(globalObject);
}

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.

🔴 The synthesized this is created with constructEmptyObject(globalObject), which always uses Object.prototype and ignores callframe->newTarget(). As a result new (jest.fn())() instanceof fn and Reflect.construct(jest.fn(), [], Foo) instanceof Foo are both false, whereas in Jest (and per ordinary [[Construct]]) they are true. Consider deriving the prototype from newTarget (e.g. via InternalFunction::createSubclassStructure / getFunctionRealm, as done in NodeVMScript.cpp and other native constructors here).

Extended reasoning...

What the bug is

In the new construct path, the freshly-allocated this is created with the single-argument overload JSC::constructEmptyObject(globalObject). That overload returns a JSFinalObject whose [[Prototype]] is globalObject->objectPrototype() — i.e. plain Object.prototype. It never consults callframe->newTarget().

Per the ECMAScript [[Construct]] internal method (step OrdinaryCreateFromConstructor), the new object's [[Prototype]] must come from newTarget.prototype. The PR description explicitly says the goal is to "match ordinary [[Construct]] semantics and Jest's behavior", but for the prototype chain it matches neither: in Jest, mock functions are real JS functions, so new fn() goes through ordinary [[Construct]] and the instance inherits from fn.prototype.

Code path that triggers it

if (isConstruct) {
    thisValue = JSC::constructEmptyObject(globalObject);   // <-- always Object.prototype
}

callframe->newTarget() is available here (this is the native-construct entry point) but is never read. Whether the call comes from new fn(), Reflect.construct(fn, []), or Reflect.construct(fn, [], Foo), the resulting object's prototype is the same: Object.prototype.

Why nothing else prevents it

The PR's own "with explicit newTarget" test only asserts typeof result === 'object', which a plain {} satisfies. It would fail if it asserted result instanceof newTarget — which is the observable contract newTarget exists to provide. None of the other added tests check the prototype chain either.

Step-by-step proof

const fn = jest.fn();
const inst = new fn();
Object.getPrototypeOf(inst) === Object.prototype  // true (bug) — should be fn.prototype
inst instanceof fn                                 // false (bug) — true in Jest

class Foo {}
const r = Reflect.construct(jest.fn(), [], Foo);
Object.getPrototypeOf(r) === Foo.prototype         // false (bug) — should be true
r instanceof Foo                                   // false (bug) — true in Jest

fn.prototype.hello = () => 1;
new fn().hello                                     // undefined (bug) — defined in Jest

In each case, constructEmptyObject(globalObject) produced { __proto__: Object.prototype }, so OrdinaryHasInstance walks the chain, never finds fn.prototype / Foo.prototype, and returns false.

Impact

Not a crash and strictly better than the pre-PR behavior (which crashed/returned undefined), but it's an observable correctness divergence in the exact lines this PR adds. Tests that mock a constructor and then assert expect(new Mock()).toBeInstanceOf(Mock) — which pass under Jest — will fail. Methods placed on fn.prototype are unreachable from constructed instances.

How to fix

Use the same pattern this codebase already uses in ~15 native constructors (e.g. NodeVMScript.cpp, JSX509Certificate.cpp, NapiClass.cpp): read callframe->newTarget(), resolve its function realm, derive a structure via InternalFunction::createSubclassStructure (falling back to a default if newTarget.prototype isn't an object), and pass that structure to constructEmptyObject. Then new fn() instanceof fn and Reflect.construct(fn, [], Foo) instanceof Foo will both hold, matching Jest.

@@ -0,0 +1,72 @@
import { describe, expect, jest, spyOn, test } from "bun:test";

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: Per CLAUDE.md (root and test/CLAUDE.md), tests should be added to the existing file rather than creating a new one. test/js/bun/test/mock-fn.test.js already covers jest.fn() behavior — these construct tests would fit there as a new describe block (and that file is wired up via test-interop.js to run under real Jest/Vitest, which would also validate the parity claim).

Extended reasoning...

The repo's contributor guidelines explicitly call out test placement. Root CLAUDE.md (line 55) says "Default: add your test to the existing test file for the code you're changing. Do not create a new file," and test/CLAUDE.md (line 152) repeats the same rule. This PR creates a brand-new test/js/bun/test/mock-fn-construct.test.ts instead of extending the existing test suite for mock functions.

The existing canonical file is test/js/bun/test/mock-fn.test.js (~1000 lines), which already exercises jest.fn(), mock(), spyOn(), mockReturnValue, mockReturnThis, and friends. The new Reflect.construct cases are squarely in that file's domain — a new describe("Reflect.construct on mock functions") block would slot in naturally alongside the existing coverage.

There's also a concrete benefit beyond convention: mock-fn.test.js is intentionally written to run under Bun, real Jest, and Vitest via test-interop.js. The PR description claims the new construct semantics "match ordinary [[Construct]] semantics and Jest's behavior." Putting the tests in the cross-runner file would actually verify that claim against real Jest, whereas the new standalone file imports from bun:test directly and only ever runs under Bun.

Step-by-step:

  1. PR adds tests for jest.fn() construct behavior → falls under "mock function behavior."
  2. test/js/bun/test/mock-fn.test.js exists and is the designated home for that surface area.
  3. CLAUDE.md says: existing file → add there, don't create a new one.
  4. New file was created instead → convention violation.

The only minor friction is that the new file uses TypeScript annotations (this: { x: number }, as never, Record<string, unknown>) while the existing file is .js. These are trivial to strip — function () { this.x = 1 } works fine without the this annotation, and the casts can be dropped or replaced with // @ts-ignore / plain {}. Not enough to justify a separate file.

Fix: Move the describe("Reflect.construct on mock functions") block into test/js/bun/test/mock-fn.test.js, drop the TS-only syntax, and delete mock-fn-construct.test.ts. Non-blocking — purely organizational.

Comment on lines +843 to +845
if (isConstruct) {
thisValue = JSC::constructEmptyObject(globalObject);
}

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 (non-blocking): mock.instances is still never populated — fn->instances is declared, GC-visited, cleared, and exposed on the mock object, but nothing ever pushes to it. Now that there's a dedicated construct branch with the freshly-allocated thisValue in hand, this would be the natural place to also push it onto fn->instances to match Jest's mock.instances semantics. Fine as a follow-up.

Extended reasoning...

What the issue is

In Jest, mockFn.mock.instances is the array of this values created when the mock is invoked as a constructor (via new or Reflect.construct). In Bun's implementation, the storage for this exists end-to-end — fn->instances is declared as a WriteBarrier<JSArray> on JSMockFunction, lazily created in getInstances(), cleared in clear(), visited by the GC, and wired into the mock object structure at offset 2 — but nothing in JSMockFunction.cpp ever pushes a value into it. As a result, fn.mock.instances is always [] regardless of how the mock is invoked.

Code path

In jsMockFunctionCallOrConstruct, every invocation pushes into fn->calls, fn->contexts, fn->invocationCallOrder, and fn->returnValues. There is no corresponding push into fn->instances. With this PR, the isConstruct branch now explicitly synthesizes thisValue = JSC::constructEmptyObject(globalObject) — which is exactly the value Jest records in mock.instances — but the value is only used as the call receiver and the fallback return; it's never recorded in instances.

Why nothing currently catches this

Existing tests in test/js/bun/test/mock-fn.test.js only assert that mock.instances is empty after mockClear() / mockReset(), which trivially passes since the array is never populated in the first place. No test asserts that constructing a mock adds an entry, so the gap is silent.

Step-by-step proof

  1. const Fn = jest.fn(); const inst = new Fn();
  2. JSC dispatches to jsMockFunctionConstructjsMockFunctionCallOrConstruct(..., /*isConstruct*/ true).
  3. thisValue is set to a fresh empty object.
  4. The function pushes to calls, contexts, invocationCallOrder, and returnValues, then returns encodeReturn(jsUndefined())thisValue.
  5. inst is the fresh object, but Fn.mock.instances is still [] — in Jest it would be [inst].

Why this is pre-existing

This gap predates the PR entirely. Before this change, jsMockFunctionCall was used for both [[Call]] and [[Construct]] and likewise never touched fn->instances. The PR neither introduces nor worsens the behavior; its scope is fixing a debug-build assertion crash, and it succeeds at that. This comment is raised only because the new explicit construct branch is the obvious and correct place to add the missing push, and the PR description references matching "Jest's behavior" for construct.

Suggested fix (follow-up)

In the if (isConstruct) block (or alongside the other array pushes), push thisValue onto fn->instances, mirroring the pattern used for contexts:

if (isConstruct) {
    thisValue = JSC::constructEmptyObject(globalObject);
    JSC::JSArray* instances = fn->instances.get();
    if (instances) {
        instances->push(globalObject, thisValue);
        RETURN_IF_EXCEPTION(scope, {});
    } else {
        JSC::ObjectInitializationScope object(vm);
        instances = JSC::JSArray::tryCreateUninitializedRestricted(
            object,
            globalObject->arrayStructureForIndexingTypeDuringAllocation(JSC::ArrayWithContiguous),
            1);
        instances->initializeIndex(object, 0, thisValue);
        fn->instances.set(vm, fn, instances);
    }
}

(Note: Jest actually pushes this for every invocation, not just construct calls, so a fuller fix might push unconditionally — but that's a larger compat decision and definitely follow-up territory.)

@robobun

robobun commented May 21, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #30212 which has a more complete fix (honors newTarget.prototype via createSubclassStructure) and is already passing CI.

@robobun robobun closed this May 21, 2026
@robobun robobun deleted the farm/f4798f68/mock-fn-construct-crash branch May 21, 2026 05:07
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