Skip to content

Fix assertion failure constructing jest.fn() via Reflect.construct - #32667

Closed
robobun wants to merge 1 commit into
mainfrom
farm/3b8f6538/mock-construct-object
Closed

Fix assertion failure constructing jest.fn() via Reflect.construct#32667
robobun wants to merge 1 commit into
mainfrom
farm/3b8f6538/mock-construct-object

Conversation

@robobun

@robobun robobun commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

What

JSMockFunction used jsMockFunctionCall as both its call and construct native function. When the mock has no implementation (or the implementation returns a primitive), the function returns jsUndefined(). JSC's Interpreter::executeConstruct then calls asObject() on that result and hits ASSERTION FAILED: cell->isObjectSlow() in debug builds (the Reflect.construct path goes through executeConstruct).

Minimal repro:

const fn = Bun.jest("a.js").mock();
Reflect.construct(fn, []);
Bun.gc(true);

Fix

Add a dedicated jsMockFunctionConstruct that:

  1. Allocates a this object based on newTarget via InternalFunction::createSubclassStructure
  2. Sets it on the call frame and runs the shared call logic
  3. Returns the implementation's result if it's an object, otherwise returns the allocated this

This matches standard [[Construct]] semantics (and what Jest does) where a constructor that returns a non-object yields the newly created instance.

This also fixes new jest.fn()() and new jest.fn(() => 42)() previously returning undefined/42 instead of an object on the bytecode path.

Found by Fuzzilli.

JSMockFunction used the same native function for both call and
construct. When constructed with no implementation (or one that
returns a primitive), it returned jsUndefined(), which trips the
asObject() assertion in Interpreter::executeConstruct.

Add a dedicated construct path that allocates a this object up front
and returns it when the implementation does not produce an object,
matching standard [[Construct]] semantics.
@robobun

robobun commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:09 AM PT - Jun 24th, 2026

@robobun, your commit c593043 has 1 failures in Build #64498 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32667

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

bun-32667 --bun

@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

JSMockFunction gains construct (new) support by introducing jsMockFunctionConstruct, which creates a subclass instance, delegates to jsMockFunctionCall, and returns the object result or the constructed thisObject. The construct callback replaces jsMockFunctionCall in the base initialization wiring, and new tests verify the behavior.

Changes

JSMockFunction construct support

Layer / File(s) Summary
jsMockFunctionConstruct implementation and wiring
src/jsc/bindings/JSMockFunction.cpp
Declares jsMockFunctionConstruct, replaces jsMockFunctionCall with it in JSMockFunction's base initialization, and implements the body: allocates a subclass instance via InternalFunction::createSubclassStructure, runs jsMockFunctionCall, and returns the encoded object result or the constructed thisObject when the result is a primitive.
Reflect.construct / new behavior tests
test/js/bun/test/mock-fn.test.js
Adds a test asserting jest.fn() with no implementation, a primitive-returning implementation, mockReturnValue(42), and an object-returning implementation all behave correctly under Reflect.construct and new, and that direct invocation of the no-impl mock still returns undefined.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main fix for constructing jest.fn() via Reflect.construct.
Description check ✅ Passed The description covers the bug, root cause, fix, and repro, though it does not explicitly include verification steps.
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.

@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: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/jsc/bindings/JSMockFunction.cpp`:
- Around line 995-999: The construct path in JSMockFunction::construct currently
creates thisObject and passes it through jsMockFunctionCall, but does not record
the constructed instance in fn->instances, leaving mock.mock.instances stale for
new mock() calls. Update the construct flow to append thisObject to the mock
instances collection before delegating to the shared call logic, keeping the
existing jsMockFunctionCall and callframe setup intact.

In `@test/js/bun/test/mock-fn.test.js`:
- Around line 800-809: The construction checks in the mock function tests only
verify typeof on the results from Reflect.construct and new calls, which still
passes for null. Update the assertions around noImpl, primitiveImpl, and
returnsPrimitive to first capture the constructed values, then assert each value
is not null before confirming it is an object/has the expected prototype shape;
use the existing jest.fn mock names to locate the affected cases.
🪄 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: a4eb4ade-fd40-4521-85d2-0735a5bbf72d

📥 Commits

Reviewing files that changed from the base of the PR and between 942c222 and c593043.

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

Comment on lines +995 to +999
JSObject* thisObject = constructEmptyObject(vm, structure);
callframe->setThisValue(thisObject);

EncodedJSValue encodedResult = 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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Record constructed mocks in mock.instances.

The new construct path creates thisObject and records it as the call context via jsMockFunctionCall, but never appends it to fn->instances, so new mock() still leaves mock.mock.instances stale. Add the instance update before delegating to the shared call logic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/bindings/JSMockFunction.cpp` around lines 995 - 999, The construct
path in JSMockFunction::construct currently creates thisObject and passes it
through jsMockFunctionCall, but does not record the constructed instance in
fn->instances, leaving mock.mock.instances stale for new mock() calls. Update
the construct flow to append thisObject to the mock instances collection before
delegating to the shared call logic, keeping the existing jsMockFunctionCall and
callframe setup intact.

Comment on lines +800 to +809
expect(typeof Reflect.construct(noImpl, [])).toBe("object");
expect(typeof new noImpl()).toBe("object");

const primitiveImpl = jest.fn(() => 42);
expect(typeof Reflect.construct(primitiveImpl, [])).toBe("object");
expect(typeof new primitiveImpl()).toBe("object");

const returnsPrimitive = jest.fn().mockReturnValue(42);
expect(typeof Reflect.construct(returnsPrimitive, [])).toBe("object");
expect(typeof new returnsPrimitive()).toBe("object");

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert a non-null constructed object.

typeof ... === "object" also passes for null, which is still an invalid construct result. Capture each constructed value and assert it is not null before checking the object shape/prototype.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/bun/test/mock-fn.test.js` around lines 800 - 809, The construction
checks in the mock function tests only verify typeof on the results from
Reflect.construct and new calls, which still passes for null. Update the
assertions around noImpl, primitiveImpl, and returnsPrimitive to first capture
the constructed values, then assert each value is not null before confirming it
is an object/has the expected prototype shape; use the existing jest.fn mock
names to locate the affected cases.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(bun:test): return an object when a mock function is constructed with new #31386 - Also adds a jsMockFunctionConstruct handler in JSMockFunction.cpp with createSubclassStructure to fix new jest.fn() / Reflect.construct assertion failure
  2. Fix non-object returns from native construct handlers #31955 - Superset fix that includes the identical jsMockFunctionConstruct change plus additional construct handler fixes for JSFFIFunction and node:buffer

🤖 Generated with Claude Code

@robobun

robobun commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #31386 (and the broader #31955), both of which already implement this fix with additional coverage for .prototype and mock.instances. Closing in favor of those.

@robobun robobun closed this Jun 24, 2026

@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 — focused crash fix following the same construct pattern as NapiClass, with test coverage.

Extended reasoning...

Overview

This PR fixes a debug-build assertion failure (ASSERTION FAILED: cell->isObjectSlow()) when a jest.fn() mock is invoked as a constructor via Reflect.construct or new. Previously JSMockFunction registered jsMockFunctionCall as both its call and construct entrypoint; when the call path returns undefined (no implementation) or a primitive, JSC's executeConstruct calls asObject() on the result and asserts. The fix adds a dedicated jsMockFunctionConstruct (~20 lines) that allocates a this object via InternalFunction::createSubclassStructure, sets it on the callframe, delegates to the existing call logic, and returns the result if it's an object or the allocated this otherwise — standard [[Construct]] semantics. A new test in mock-fn.test.js exercises Reflect.construct and new against mocks with no impl, primitive-returning impls, mockReturnValue, and object-returning impls, plus verifies plain-call behavior is unchanged.

Security risks

None. This is test-runner-only code (jest.fn() / Bun.jest), not reachable from production app code paths, and touches no auth, crypto, network, or filesystem surfaces. The change strictly tightens behavior (returns an object instead of asserting/returning a primitive).

Level of scrutiny

Low-to-medium. The new function is a near-verbatim copy of the established pattern already used in NapiClass.cpp (getFunctionRealmcreateSubclassStructure(..., objectStructureForObjectConstructor())constructEmptyObjectcallFrame->setThisValue), so the JSC interaction has prior art in this codebase. Exception handling is correct (RETURN_IF_EXCEPTION after each fallible step), and asObject(callframe->newTarget()) is safe because newTarget is guaranteed to be an object on the construct path.

Other factors

The one inline note is explicitly flagged pre-existing and non-blocking: JSMockFunction lacks an own .prototype, so Object.getPrototypeOf(new fn()) falls back to Object.prototype — a Jest-compat gap that predates this PR and is orthogonal to the crash being fixed. The fix was found by Fuzzilli, is small and self-contained, and includes regression tests, so I'm comfortable approving without human review.

Comment on lines +993 to +995
Structure* structure = InternalFunction::createSubclassStructure(lexicalGlobalObject, newTarget, functionGlobalObject->objectStructureForObjectConstructor());
RETURN_IF_EXCEPTION(scope, {});
JSObject* thisObject = 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.

🟣 Note (pre-existing, not blocking): JSMockFunction never installs an own .prototype property, so when newTarget is the mock itself createSubclassStructure reads fn.prototype as undefined and falls back to Object.prototype. The constructed instance therefore has Object.getPrototypeOf(new fn()) === Object.prototype (and new fn() instanceof fn throws), diverging from Jest where mocks are real JS functions. A follow-up adding a per-instance .prototype in JSMockFunction::create() would close the gap; the construct logic here would then work unchanged.

Extended reasoning...

Summary

jsMockFunctionConstruct derives the new instance's structure via InternalFunction::createSubclassStructure(lexicalGlobalObject, newTarget, functionGlobalObject->objectStructureForObjectConstructor()). That helper reads newTarget.prototype and only falls back to the supplied base structure when the property is not an object. JSMockFunction is an InternalFunction and never defines an own .prototype data property — there is no putDirect(vm.propertyNames->prototype, ...) anywhere in this file, no entry for it in JSMockFunctionPrototypeTableValues, and InternalFunction::finishCreation does not auto-create one (only JSFunction does that lazily). So in the common new fn() case where newTarget === fn, fn.prototype is undefined, the fallback path is taken, and the freshly allocated thisObject has %Object.prototype% as its [[Prototype]].

Step-by-step

  1. const fn = jest.fn(); — creates a JSMockFunction. No own prototype is installed, so fn.prototype === undefined.
  2. new fn() enters jsMockFunctionConstruct with newTarget = fn.
  3. createSubclassStructure calls newTarget->get(globalObject, vm.propertyNames->prototype)undefined. Since that's not an object, it returns the base structure (objectStructureForObjectConstructor()).
  4. constructEmptyObject(vm, structure) produces a plain object whose [[Prototype]] is Object.prototype.
  5. The mock has no implementation, so jsMockFunctionCall returns undefined, result.isObject() is false, and thisObject is returned.
  6. Result: Object.getPrototypeOf(new fn()) === Object.prototype, and (new fn()) instanceof fn throws TypeError: instanceof called on an object with an invalid prototype property because fn.prototype is not an object.

In Jest, mock functions are ordinary JS functions, so each mock has its own .prototype object, Object.getPrototypeOf(new fn()) === fn.prototype, and (new fn()) instanceof fn === true. The new test in this PR only asserts typeof === "object", so it does not exercise this divergence.

Why this is pre-existing / a nit

The construct code added here is spec-correct: OrdinaryCreateFromConstructor mandates falling back to the realm's %Object.prototype% when newTarget.prototype is not an object. The root cause is the pre-existing lack of a per-instance .prototype on JSMockFunction — e.g. ({}) instanceof jest.fn() already threw TypeError before this PR, and jest.fn().prototype was already undefined. This PR simply makes the constructed instance observable for the first time (previously construction asserted/returned undefined), so the prototype-chain divergence is now newly visible, but it was not introduced here. The Reflect.construct(fn, [], SomeClass) case where newTarget is a real class with a .prototype already works correctly with this implementation.

Suggested follow-up

In JSMockFunction::create(), allocate a fresh prototype object and install it:

JSObject* prototype = constructEmptyObject(globalObject);
prototype->putDirect(vm, vm.propertyNames->constructor, function, PropertyAttribute::DontEnum);
function->putDirect(vm, vm.propertyNames->prototype, prototype, PropertyAttribute::DontEnum | PropertyAttribute::DontDelete);

Once that exists, createSubclassStructure will pick it up automatically and jsMockFunctionConstruct needs no changes. This is a Jest-compat improvement worth tracking separately; it should not block this crash fix.

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