Skip to content

Fix crash when using Reflect.construct on a mock function#29790

Closed
robobun wants to merge 2 commits into
mainfrom
farm/b694b77c/mock-fn-reflect-construct
Closed

Fix crash when using Reflect.construct on a mock function#29790
robobun wants to merge 2 commits into
mainfrom
farm/b694b77c/mock-fn-reflect-construct

Conversation

@robobun

@robobun robobun commented Apr 27, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes an assertion failure / crash when a jest.fn() / spyOn() mock is invoked via Reflect.construct() and its implementation would return a non-object (e.g. no implementation, mockReturnValue(42), or a spy on a non-function property).

const m = jest.fn();
Reflect.construct(m, []); // ASSERTION FAILED: isCell()

JSC requires native constructors to return an object — Interpreter::executeConstruct passes the result straight through asObject(). JSMockFunction was using the same host function for both [[Call]] and [[Construct]], which can return jsUndefined() or other primitives.

This adds a dedicated construct handler that falls back to a fresh empty object when the mock would otherwise produce a non-object, matching ordinary [[Construct]] semantics (and Jest's behavior).

How did you verify your code works?

  • Added a regression test in test/js/bun/test/mock-fn.test.js covering empty mocks, mockReturnValue with a primitive, an implementation that returns an object, and spyOn of a non-function property.
  • bun bd test test/js/bun/test/mock-fn.test.js — 73 pass, 0 fail
  • Verified the test fails on main (assertion failure on debug, wrong return type on release).
  • Original fuzzer repro no longer crashes.

Found by Fuzzilli. Fingerprint: JSCJSValue.h(1044)

JSC requires native constructors to return an object. jsMockFunctionCall
was used for both [[Call]] and [[Construct]], but it returns jsUndefined()
(or whatever primitive the mock implementation returns). When invoked via
Reflect.construct, the interpreter's executeConstruct() passes the result
through asObject(), which asserts isCell() on debug builds.

Split out a dedicated construct handler that falls back to a fresh object
when the mock would otherwise produce a non-object, matching ordinary
[[Construct]] semantics.
@robobun

robobun commented Apr 27, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:06 PM PT - Apr 27th, 2026

@robobun, your commit 2877eb3 has 1 failures in Build #48363 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29790

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

bun-29790 --bun

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Fix crash when Reflect.construct is used on mock functions returning non-objects #28532 - Fixes the same Reflect.construct crash on mock functions returning non-objects, with the same approach in JSMockFunction.cpp

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

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: 10597b85-9084-4158-87ff-595f7ad1e1be

📥 Commits

Reviewing files that changed from the base of the PR and between cdac378 and 2877eb3.

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

Walkthrough

Adds a dedicated construct handler jsMockFunctionConstruct for JSMockFunction, wires it as the construct callback, reuses jsMockFunctionCall for invocation, returns the call result if it is an object, and otherwise derives the realm from newTarget and returns a constructed empty object. Adds tests covering Reflect.construct with mock functions and spies.

Changes

Cohort / File(s) Summary
JSMockFunction Implementation
src/bun.js/bindings/JSMockFunction.cpp
Adds jsMockFunctionConstruct and wires it as the construct callback for JSMockFunction. The handler delegates to jsMockFunctionCall, returns the call result when it evaluates to an object, and otherwise derives the realm from newTarget to create and return an empty object.
Mock Function Tests
test/js/bun/test/mock-fn.test.js
Adds tests verifying Reflect.construct with jest.fn() across cases (no return, primitive return, object return) and ensures call counts and proper newTarget/prototype behavior; includes a Bun-specific branch testing Reflect.construct with a spyOn-wrapped non-function property.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: fixing a crash when Reflect.construct is used on mock functions.
Description check ✅ Passed The PR description comprehensively covers both required template sections with clear explanations of the problem, solution, and verification methods.
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.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/bun.js/bindings/JSMockFunction.cpp`:
- Around line 985-998: The construct fallback in jsMockFunctionConstruct
currently returns a plain object via constructEmptyObject which ignores
newTarget.prototype; update the fallback to create an instance using the target
function's subclass structure so subclassing and Reflect.construct(...,
newTarget) work correctly: after decoding result from jsMockFunctionCall in
jsMockFunctionConstruct, if the result is not an object obtain the appropriate
structure using InternalFunction::createSubclassStructure with the newTarget
(from callframe->newTarget or equivalent) and then allocate/initialize the
object from that structure instead of calling constructEmptyObject; ensure
exceptions and the existing RETURN_IF_EXCEPTION/RELEASE_AND_RETURN scope
handling remain intact.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: cb59c3ff-579d-4029-82a5-e247b3d1c5ab

📥 Commits

Reviewing files that changed from the base of the PR and between 2755641 and cdac378.

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

Comment thread src/bun.js/bindings/JSMockFunction.cpp

@claude claude Bot left a comment

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.

LGTM — straightforward crash fix that wraps the construct path to guarantee an object return; the two inline nits are non-blocking.

Extended reasoning...

Overview

This PR fixes a fuzzer-found assertion failure when Reflect.construct is used on a jest.fn() / spyOn() mock. JSMockFunction previously passed jsMockFunctionCall as both the call and construct native functions, but jsMockFunctionCall can return primitives (e.g. jsUndefined() for an empty mock), which violates JSC's invariant that native constructors must return an object — Interpreter::executeConstruct runs the result straight through asObject() and asserts. The fix adds a thin jsMockFunctionConstruct wrapper (~13 lines) that delegates to jsMockFunctionCall, propagates exceptions, and falls back to constructEmptyObject(globalObject) when the result is not an object. A regression test in mock-fn.test.js covers empty mocks, mockReturnValue(primitive), an object-returning implementation, and a property spy.

Security risks

None. This is test-runner mock infrastructure; no auth, crypto, network, or untrusted-input handling is involved. The change strictly tightens behavior (crash → defined object return) and uses standard JSC scope/exception macros (DECLARE_THROW_SCOPE, RETURN_IF_EXCEPTION, RELEASE_AND_RETURN).

Level of scrutiny

Low-to-moderate. The C++ change is small, mechanical, and follows the exact pattern used throughout the file and JSC host functions. It only adds a new code path for [[Construct]]; the existing [[Call]] path is untouched. The result-is-object check plus empty-object fallback is the canonical way native constructors satisfy the object-return invariant.

Other factors

Two inline nits were filed and I agree they are non-blocking: (1) the fallback ignores newTarget.prototype, so Reflect.construct(jest.fn(), [], SomeClass) won't be instanceof SomeClass — a parity gap, but a strict improvement over crashing and easy to refine later via InternalFunction::createSubclassStructure; (2) the new test's spyOn({foo:42}, "foo") line isn't guarded by if (isBun), which breaks the file's stated Jest/Vitest cross-runner compatibility convention — test-only, doesn't affect Bun CI. Neither warrants holding up a crash fix. Tests pass per the PR description (73 pass / 0 fail), and the diff is self-contained with no design decisions a human needs to weigh in on.

Comment thread src/bun.js/bindings/JSMockFunction.cpp Outdated
Comment thread test/js/bun/test/mock-fn.test.js Outdated
Use InternalFunction::createSubclassStructure so that
Reflect.construct(mock, [], SomeCtor) produces an object that inherits
from SomeCtor.prototype, matching ordinary [[Construct]] semantics.

Also guard the spyOn-on-non-function assertion behind isBun since Jest
and Vitest reject that form.
Comment on lines +985 to +1003
JSC_DEFINE_HOST_FUNCTION(jsMockFunctionConstruct, (JSGlobalObject * lexicalGlobalObject, CallFrame* callframe))
{
auto& vm = JSC::getVM(lexicalGlobalObject);
auto scope = DECLARE_THROW_SCOPE(vm);

EncodedJSValue encodedResult = jsMockFunctionCall(lexicalGlobalObject, callframe);
RETURN_IF_EXCEPTION(scope, {});

JSValue result = JSValue::decode(encodedResult);
if (result.isObject()) [[likely]]
return encodedResult;

JSObject* newTarget = asObject(callframe->newTarget());
JSGlobalObject* functionGlobalObject = getFunctionRealm(lexicalGlobalObject, newTarget);
RETURN_IF_EXCEPTION(scope, {});
Structure* structure = InternalFunction::createSubclassStructure(lexicalGlobalObject, newTarget, functionGlobalObject->objectStructureForObjectConstructor());
RETURN_IF_EXCEPTION(scope, {});
RELEASE_AND_RETURN(scope, JSValue::encode(JSC::constructEmptyObject(vm, structure)));
}

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: the fallback object is allocated after the implementation runs (which is invoked as a plain [[Call]] with callframe->thisValue()), so for new (jest.fn(function(){ this.x = 42 }))() the this.x = 42 lands on the wrong object and the returned instance is an empty {} — Jest yields {x: 42}. Not blocking — this is still a strict improvement over the crash — but the "matching ordinary [[Construct]] semantics" claim doesn't hold for the constructor‑assigns‑to‑this pattern; a fuller fix would create the receiver from newTarget.prototype first, pass it as this when invoking the impl, and return it on a non‑object result.

Extended reasoning...

What the bug is

jsMockFunctionConstruct delegates to jsMockFunctionCall with the original callframe, and only after that call returns does it allocate the fallback object via constructEmptyObject(vm, structure). Inside jsMockFunctionCall, the implementation is invoked as

JSValue returnValue = Bun::call(globalObject, result, callData, thisValue, args);
// where thisValue = callframe->thisValue()

i.e. a plain [[Call]] with whatever the native construct frame's thisValue happens to be — not a freshly‑allocated receiver derived from newTarget.prototype. So when the mock body assigns to this, those mutations land on the wrong object, and the brand‑new fallback object created at line 1002 has none of them.

In ordinary ECMAScript [[Construct]] (and in Jest, where jest.fn() is a real JS function), the engine performs OrdinaryCreateFromConstructor(newTarget, "%Object.prototype%") before the body runs, the body executes with that object as this, and that same object is returned when the body's completion value is not an object.

Step‑by‑step proof

const m = jest.fn(function () { this.x = 42; });
const inst = new m();
Step Jest (ordinary JS function) Bun after this PR
1. Create receiver from newTarget.prototype {} (proto = m.prototype) — (skipped)
2. Run body with that receiver as this sets receiver.x = 42 runs Bun::call(impl, callframe->thisValue(), …); this.x = 42 lands on the construct frame's thisValue, not the eventual return value
3. Body returns undefined (non‑object) return the receiver result.isObject() is false → allocate a new constructEmptyObject(vm, structure)
4. inst.x 42 undefined

A secondary consequence of the same ordering: createSubclassStructure reads newTarget.prototype after jsMockFunctionCall has already consumed any mockReturnValueOnce queue entry and pushed into mock.calls/mock.results. If newTarget.prototype is a throwing getter, the once‑queue is consumed and the call is recorded even though the construct ultimately throws — whereas spec/Jest read the prototype first, before the body ever runs.

Why nothing prevents it

jsMockFunctionConstruct never sets up a receiver before delegating; it just forwards the same callframe. jsMockFunctionCall has no construct‑aware path — it always passes callframe->thisValue() straight through to Bun::call. The createSubclassStructure work added in 2877eb3 fixed the prototype chain of the fallback (so Reflect.construct(empty, [], Base) instanceof Base), but didn't change when the object is created or what this the body sees.

Impact

Low. Before this PR the same path crashed (asObject(undefined) assertion), so any behavior here is a strict improvement, not a regression. The observable divergence affects the recognizable "mock a constructor that assigns to this" pattern (jest.fn(function(){ this.foo = … }) used with new), where Bun returns an empty object instead of the populated one Jest returns. This is distinct from the already‑addressed CodeRabbit/Claude comments on this PR, which only covered the newTarget.prototype chain.

How to fix (follow‑up)

Allocate the receiver up front and thread it through the implementation call, roughly:

JSObject* newTarget = asObject(callframe->newTarget());
auto* fnGlobal = getFunctionRealm(lexicalGlobalObject, newTarget);
RETURN_IF_EXCEPTION(scope, {});
Structure* structure = InternalFunction::createSubclassStructure(
    lexicalGlobalObject, newTarget, fnGlobal->objectStructureForObjectConstructor());
RETURN_IF_EXCEPTION(scope, {});
JSObject* receiver = constructEmptyObject(vm, structure);

// invoke the mock with `receiver` as thisValue (requires plumbing into jsMockFunctionCall
// or factoring out a helper that takes an explicit thisValue)

return result.isObject() ? encodedResult : JSValue::encode(receiver);

That's a larger refactor than this crash fix targets, so flagging as a known limitation / follow‑up rather than a blocker.

JSObject* newTarget = asObject(callframe->newTarget());
JSGlobalObject* functionGlobalObject = getFunctionRealm(lexicalGlobalObject, newTarget);
RETURN_IF_EXCEPTION(scope, {});
Structure* structure = InternalFunction::createSubclassStructure(lexicalGlobalObject, newTarget, functionGlobalObject->objectStructureForObjectConstructor());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

is that createSubclassStructure correct? shouldn't it be based on the mock functions structure or no?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes, I believe so — the third argument to createSubclassStructure is the base structure for the instances this constructor produces, not the structure of the callee. It's used as:

  1. The fallback when newTarget.prototype isn't an object
  2. The class-info/shape template even when newTarget.prototype is an object (the result is a structure with that prototype but the base's cell type)

A mock function acting as a constructor is emulating an ordinary user-defined function constructor, so its instances should be plain JSFinalObjects — there's no "mock instance" cell type. objectStructureForObjectConstructor() is the plain-object structure, which matches:

JSMockFunction also doesn't expose a .prototype property (jest.fn().prototype === undefined), so when newTarget is the mock itself, createSubclassStructure falls back to the base structure → plain {} with Object.prototype, which is the OrdinaryCreateFromConstructor default. When newTarget is something else (Reflect.construct(mock, [], SomeClass)), it correctly picks up SomeClass.prototype.

@robobun

robobun commented May 4, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #30212, which additionally creates the this object before invoking the implementation so this.x = ... and mock.contexts behave like Jest.

@robobun robobun closed this May 4, 2026
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