Skip to content

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

Closed
robobun wants to merge 4 commits into
mainfrom
farm/7a85c992/fix-mock-construct-iscell
Closed

Fix crash constructing jest.fn() via Reflect.construct#30212
robobun wants to merge 4 commits into
mainfrom
farm/7a85c992/fix-mock-construct-iscell

Conversation

@robobun

@robobun robobun commented May 4, 2026

Copy link
Copy Markdown
Collaborator

What

Fixes an ASSERTION FAILED: isCell() crash when a jest.fn() mock is invoked as a constructor through Reflect.construct (or any path that goes through Interpreter::executeConstruct).

const { mock } = Bun.jest();
const m = mock();
Reflect.construct(m, []); // ASSERTION FAILED: isCell()

Why

JSMockFunction registered the same host function for both [[Call]] and [[Construct]]. When invoked as a constructor with no implementation (or an implementation/mockReturnValue that produces a primitive), it returned jsUndefined(). Native constructors must return an object — Interpreter::executeConstruct calls asObject() on the result, which trips the isCell() assertion in debug builds.

Because CallFrame::newTarget() is an alias for thisValue() (only meaningful in a construct context), the shared host function had no reliable way to tell call apart from construct.

How

  • Split the host function into separate jsMockFunctionCall / jsMockFunctionConstruct entry points that forward to a shared implementation with an explicit isConstructCall flag.
  • On construct, allocate a fresh this object — using InternalFunction::createSubclassStructure to honor new.target's prototype when it differs from the mock itself — and pass it as this to the implementation.
  • Whenever the mock would return a non-object from a construct call, return the allocated this instead, matching ordinary [[Construct]] semantics and Jest's behavior.

Found by Fuzzilli.

@coderabbitai

coderabbitai Bot commented May 4, 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 12 minutes and 4 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: 61dbabce-0e66-4fe0-83ca-a78c2d1fb3d7

📥 Commits

Reviewing files that changed from the base of the PR and between 5b8f855 and 17566f2.

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

Walkthrough

A shared host-function helper unifies mock call and construct behavior; a dedicated jsMockFunctionConstruct entrypoint handles newTarget semantics, constructs appropriate this for construct calls, and uses construct-aware return encoding. Tests covering Reflect.construct/new behaviors were added.

Changes

Mock Function Construct Support

Layer / File(s) Summary
Data / API
src/jsc/bindings/JSMockFunction.cpp
Adds declaration JSC_DECLARE_HOST_FUNCTION(jsMockFunctionConstruct); and a new host entry JSC_DEFINE_HOST_FUNCTION(jsMockFunctionConstruct, ...).
Core Implementation
src/jsc/bindings/JSMockFunction.cpp (lines ~830–1003)
Introduces static EncodedJSValue jsMockFunctionCallOrConstruct(..., bool isConstructCall) implementing shared call/construct logic, handling newTarget subclass construction and computing encodedReturnValue(...) for construct semantics. Replaces direct JSValue::encode(...) return sites with encodedReturnValue(...).
Host Function Binding
src/jsc/bindings/JSMockFunction.cpp (lines ~1005–1013)
jsMockFunctionCall and new jsMockFunctionConstruct are thin wrappers delegating to the shared helper with isConstructCall=false / true.
Integration / Wiring
src/jsc/bindings/JSMockFunction.cpp (line ~466)
JSMockFunction base initialization now passes jsMockFunctionConstruct (instead of duplicating jsMockFunctionCall) for the construct entrypoint.
Tests
test/js/bun/test/mock-fn.test.js (lines ~241–285)
Adds tests validating construct/new behavior: constructed results are objects, primitives are coerced to objects, returned objects preserved, newTarget prototype used, fn.prototype honored for new, and constructed instance is passed as this (accessible via fn.mock.contexts[0]).
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately captures the main fix: addressing an assertion crash when constructing jest.fn() via Reflect.construct.
Description check ✅ Passed The PR description exceeds template requirements with comprehensive What/Why/How sections. It clearly explains the problem, root cause, solution approach, and includes reproduction steps and discovery attribution.
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.

@robobun

robobun commented May 4, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:41 PM PT - May 24th, 2026

@robobun, your commit daaa8d94a5f7a42bd7e395fd4d9c6b21b106afe3 passed in Build #57842! 🎉


🧪   To try this PR locally:

bunx bun-pr 30212

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

bun-30212 --bun

@github-actions github-actions Bot added the claude label May 4, 2026
@github-actions

github-actions Bot commented May 4, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix mock function crash when Reflect.construct returns non-object #28315 - Fixes the same Reflect.construct crash on mock functions returning non-objects by adding a dedicated construct handler
  2. Fix crash when Reflect.construct is used on mock functions returning non-objects #28532 - Fixes the same isCell() assertion crash by splitting call/construct paths in JSMockFunction
  3. Fix crash when using Reflect.construct on a mock function #29790 - Fixes the same Fuzzilli-found Reflect.construct mock crash with a dedicated construct handler fallback

🤖 Generated with Claude Code

Comment thread src/bun.js/bindings/JSMockFunction.cpp Outdated
Comment on lines +844 to +856
if (isConstructCall) {
JSValue newTarget = callframe->newTarget();
JSObject* newTargetObject = newTarget.getObject();
if (newTargetObject && newTargetObject != fn) {
JSGlobalObject* functionGlobalObject = getFunctionRealm(globalObject, newTargetObject);
RETURN_IF_EXCEPTION(scope, {});
Structure* structure = InternalFunction::createSubclassStructure(globalObject, newTargetObject, functionGlobalObject->objectStructureForObjectConstructor());
RETURN_IF_EXCEPTION(scope, {});
thisValue = constructEmptyObject(vm, structure);
} else {
thisValue = 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 nit (not introduced by this PR): now that the construct path allocates a fresh thisValue, this would be the natural place to also push it onto fn->instances so that mock.instances is populated on new fn() like Jest does — currently instances is exposed but never written to anywhere, so it always stays empty. Fine to leave for a follow-up since this PR is scoped to the crash fix.

Extended reasoning...

What the gap is

JSMockFunction declares a mutable JSC::WriteBarrier<JSC::JSArray> instances field, lazily creates it in getInstances(), clears it in clear(), visits it for GC, and exposes it as mock.instances on the mock object structure. However, nothing in jsMockFunctionCallOrConstruct (or anywhere else in the file) ever pushes into it. Grepping the file confirms the only references to instances are the declaration, clear(), getInstances(), the GC visitor, and the mockObjectStructure slot — none of them are writes during invocation. So mock.instances is always an empty array.

In Jest, mockFn.mock.instances is "an array that contains all the object instances that have been instantiated from this mock function using new" — every new fn() pushes the constructed this onto it.

Code path

In the new construct branch:

if (isConstructCall) {
    ...
    thisValue = constructEmptyObject(...);
}

the freshly allocated thisValue is later pushed onto fn->contexts (alongside calls, invocationCallOrder, and returnValues), but there is no corresponding push onto fn->instances. The fix would be to add the same lazy-create-then-push pattern for fn->instances inside the isConstructCall block (or right after contexts is pushed, gated on isConstructCall), pushing thisValue.

Why nothing currently prevents it

There simply is no write site. The infrastructure (storage, getter, GC visiting, mock-object slot) is all wired up, but the population step was never implemented.

Step-by-step proof

  1. const fn = jest.fn();
  2. new fn(); → enters jsMockFunctionConstructjsMockFunctionCallOrConstruct(..., true).
  3. isConstructCall is true, so thisValue = constructEmptyObject(globalObject).
  4. calls, contexts, invocationCallOrder are pushed; returnValues is pushed via setReturnValue. instances is not touched.
  5. fn.mock.instancesgetInstances() lazily creates an empty array and returns it.
  6. expect(fn.mock.instances).toHaveLength(1) fails in Bun, passes in Jest.

Why this is pre-existing

Before this PR, instances was already never populated, and new (jest.fn(() => ({})))() already worked without crashing (the impl returned an object, so asObject() succeeded), so the empty mock.instances was already observable. This PR is a narrow Fuzzilli crash fix and doesn't change instances behavior at all — it just adds the construct branch where the missing push would naturally live, which is why it's worth flagging here as a follow-up rather than a blocker.

@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 — thanks for dropping the != fn guard and adding the fn.prototype test.

Extended reasoning...

Overview

Splits JSMockFunction's host function into separate jsMockFunctionCall / jsMockFunctionConstruct entry points that share a common body via an explicit isConstructCall flag. On construct, a fresh this is allocated through getFunctionRealm + InternalFunction::createSubclassStructure (honoring new.target.prototype), and any non-object return is replaced with that this so Interpreter::executeConstruct always receives an object. Six new tests in mock-fn.test.js cover no-impl, primitive-returning impl, mockReturnValue, object-returning impl, new.target prototype, user-assigned fn.prototype, and this propagation into the implementation.

Security risks

None. This is test-runner mock infrastructure; no auth, crypto, filesystem, or network surface. The new code paths only run when a jest.fn() mock is invoked via new/Reflect.construct, which previously crashed in debug builds.

Level of scrutiny

Moderate — C++ JSC bindings, but the change is small and idiomatic: the getFunctionRealmcreateSubclassStructureconstructEmptyObject(vm, structure) sequence with RETURN_IF_EXCEPTION after each step is the standard pattern used throughout Bun's native constructors. The encodedReturnValue lambda enforcing object-or-this on construct is a textbook [[Construct]] substitution rule. The non-construct path is byte-identical to before (just routed through the shared helper with isConstructCall=false).

Other factors

I previously left two comments: a nit about the newTargetObject != fn fast-path skipping user-assigned fn.prototype, and a pre-existing observation about mock.instances never being populated. The author addressed the first in 6e3244f (the guard is gone and there's a covering test); the second was explicitly flagged as out-of-scope follow-up and is unchanged behavior. The bug-hunting pass on the latest revision found nothing. Strictly an improvement over the prior crash.

@Jarred-Sumner Jarred-Sumner force-pushed the farm/7a85c992/fix-mock-construct-iscell branch from 6e3244f to 1df4eed Compare May 4, 2026 10:23

@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 — the != fn guard was dropped per the earlier nit and the construct path now follows the standard JSC createSubclassStructure idiom with good test coverage.

Extended reasoning...

Overview

Splits JSMockFunction's host function into separate [[Call]] and [[Construct]] entry points that share a common body via an isConstructCall flag. On construct, it allocates a fresh this via getFunctionRealmInternalFunction::createSubclassStructureconstructEmptyObject, passes it to the implementation, and substitutes it for any non-object return value — standard ordinary-[[Construct]] semantics. The [[Call]] path is byte-for-byte unchanged aside from being routed through a thin wrapper. Six new tests in mock-fn.test.js cover no-impl construct, primitive returns, object passthrough, newTarget.prototype, user-assigned fn.prototype, and this propagation to the implementation.

Security risks

None. This is bun:test mock-function infrastructure; no auth, crypto, filesystem, or network surface is touched. The new code only allocates plain JS objects and reads newTarget.prototype via the standard JSC helper.

Level of scrutiny

Moderate-low. It's native JSC bindings C++, but the change is ~40 lines, confined to one function whose construct path previously crashed (so regression risk on that path is effectively zero), and follows the exact createSubclassStructure pattern used across Bun's other native constructors. Exception scopes are correctly threaded with RETURN_IF_EXCEPTION after both getFunctionRealm and createSubclassStructure.

Other factors

  • My earlier 🟡 nit (the newTargetObject != fn fast-path skipping user-assigned fn.prototype) was addressed in 6e3244f / 1df4eed and is now covered by the "new on mock uses assigned prototype" test.
  • The remaining 🟣 inline comment is an explicitly pre-existing mock.instances gap flagged as a follow-up, not a blocker.
  • No CODEOWNERS for the touched paths.
  • The duplicate-PR bot flagged three prior attempts at the same fix; that's a triage decision for maintainers and doesn't affect correctness here.

@robobun robobun force-pushed the farm/7a85c992/fix-mock-construct-iscell branch from 1df4eed to 1204fe7 Compare May 6, 2026 01:56

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/jsc/bindings/JSMockFunction.cpp (1)

844-901: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Record constructor-created objects in mock.instances.

Line 844 allocates a constructor thisValue, and Line 891 stores it in contexts, but this construct path never appends to fn->instances. That leaves constructor metadata incomplete for consumers reading mock.instances.

Suggested fix
@@
     JSC::JSArray* contexts = fn->contexts.get();
@@
         fn->contexts.set(vm, fn, contexts);
     }

+    if (isConstructCall) {
+        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);
+        }
+    }
+
     auto invocationId = JSMockModule::nextInvocationId();
🤖 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 844 - 901, The constructor
path creates thisValue but never records it in fn->instances, leaving
mock.instances incomplete; after the contexts handling add code that if
isConstructCall then fetch fn->instances (like fn->contexts/get pattern), push
thisValue with RETURN_IF_EXCEPTION(scope, {}) if the array exists, otherwise
create a new JSArray via JSArray::tryCreateUninitializedRestricted, initialize
index 0 with thisValue, and set it with fn->instances.set(vm, fn, instances).
🤖 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.

Outside diff comments:
In `@src/jsc/bindings/JSMockFunction.cpp`:
- Around line 844-901: The constructor path creates thisValue but never records
it in fn->instances, leaving mock.instances incomplete; after the contexts
handling add code that if isConstructCall then fetch fn->instances (like
fn->contexts/get pattern), push thisValue with RETURN_IF_EXCEPTION(scope, {}) if
the array exists, otherwise create a new JSArray via
JSArray::tryCreateUninitializedRestricted, initialize index 0 with thisValue,
and set it with fn->instances.set(vm, fn, instances).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4db13abc-f7d1-4476-ba38-bd2a03a85fb1

📥 Commits

Reviewing files that changed from the base of the PR and between 1df4eed and 1204fe7.

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

@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 — thanks for addressing the fn.prototype nit; the remaining mock.instances note is pre-existing and fine as a follow-up.

Extended reasoning...

Overview

This PR fixes a debug-build assertion crash (ASSERTION FAILED: isCell()) when a jest.fn() mock is invoked as a constructor via Reflect.construct or new. It touches src/jsc/bindings/JSMockFunction.cpp (splitting the shared host function into separate [[Call]]/[[Construct]] entry points, allocating a fresh this via InternalFunction::createSubclassStructure, and ensuring construct calls always return an object) plus six new test cases in test/js/bun/test/mock-fn.test.js.

Security risks

None. This is test-runner mock-function dispatch; no untrusted input parsing, auth, crypto, or filesystem/network surface. The change strictly tightens behavior (crash → spec-compliant [[Construct]] semantics) and follows the same getFunctionRealm / createSubclassStructure idiom used throughout Bun's other native constructors.

Level of scrutiny

Moderate. JSC bindings C++ warrants care, but the change is small (~50 lines), mechanical, and mirrors the standard JSC pattern for native constructors (allocate this from newTarget's prototype, substitute it for any non-object return). The encodedReturnValue lambda is applied consistently to every return site, exception checks (RETURN_IF_EXCEPTION) are placed after each fallible JSC call, and the call path (isConstructCall=false) is byte-for-byte unchanged in behavior.

Other factors

  • I previously left two nits on this PR. The actionable one (honor a user-assigned fn.prototype on new fn() by always going through createSubclassStructure) was addressed in 6e3244f and now has a dedicated test. The other was an explicitly pre-existing gap (mock.instances is never populated) flagged as a follow-up, not a blocker.
  • The bug-hunting system found no issues on the current revision.
  • Test coverage is thorough: no-impl construct, primitive-returning impl, object-returning impl, newTarget prototype, assigned fn.prototype, and this propagation to the impl via mock.contexts.
  • No CODEOWNERS cover these paths.
  • Pre-PR this code path crashed, so the change is strictly an improvement with no regression surface on the call path.

@robobun robobun force-pushed the farm/7a85c992/fix-mock-construct-iscell branch from 1204fe7 to 5b8f855 Compare May 12, 2026 04:12

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/jsc/bindings/JSMockFunction.cpp (1)

844-901: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Track constructed receivers in mock.instances.

When isConstructCall is true we allocate thisValue and record it in mock.contexts, but this path never appends that receiver to fn->instances. new fn() / Reflect.construct(fn, ...) will therefore still leave fn.mock.instances empty even though constructor calls are now otherwise supported. Push the constructed receiver to instances only for constructor calls, mirroring the contexts tracking pattern.

Proposed fix
     JSC::JSArray* contexts = fn->contexts.get();
     if (contexts) {
         contexts->push(globalObject, thisValue);
         RETURN_IF_EXCEPTION(scope, {});
     } else {
         JSC::ObjectInitializationScope object(vm);
         contexts = JSC::JSArray::tryCreateUninitializedRestricted(
             object,
             globalObject->arrayStructureForIndexingTypeDuringAllocation(JSC::ArrayWithContiguous),
             1);
         contexts->initializeIndex(object, 0, thisValue);
         fn->contexts.set(vm, fn, contexts);
     }
+
+    if (isConstructCall) {
+        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);
+        }
+    }
🤖 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 844 - 901, The constructor
call path (when isConstructCall is true) creates thisValue and records it in
fn->contexts but never appends the constructed receiver to fn->instances; update
the construct branch to push thisValue into fn->instances (mirror how contexts
is handled): obtain or create JSC::JSArray* instances = fn->instances.get(),
push(thisValue) if instances exists with RETURN_IF_EXCEPTION(scope, {}),
otherwise create a new uninitialized restricted JSArray of length 1,
initializeIndex(..., 0, thisValue) and set it via fn->instances.set(vm, fn,
instances), matching the pattern used for fn->contexts and fn->calls so
constructed receivers appear in mock.instances.
🤖 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.

Outside diff comments:
In `@src/jsc/bindings/JSMockFunction.cpp`:
- Around line 844-901: The constructor call path (when isConstructCall is true)
creates thisValue and records it in fn->contexts but never appends the
constructed receiver to fn->instances; update the construct branch to push
thisValue into fn->instances (mirror how contexts is handled): obtain or create
JSC::JSArray* instances = fn->instances.get(), push(thisValue) if instances
exists with RETURN_IF_EXCEPTION(scope, {}), otherwise create a new uninitialized
restricted JSArray of length 1, initializeIndex(..., 0, thisValue) and set it
via fn->instances.set(vm, fn, instances), matching the pattern used for
fn->contexts and fn->calls so constructed receivers appear in mock.instances.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 98685672-b142-42a3-a07b-7ecca435f8a6

📥 Commits

Reviewing files that changed from the base of the PR and between 1204fe7 and 5b8f855.

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

@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 — the earlier nit was addressed in 6e3244f/5b8f855, and the remaining mock.instances note is a pre-existing follow-up.

Extended reasoning...

Overview

This PR fixes a Fuzzilli-found ASSERTION FAILED: isCell() crash when a jest.fn() mock is invoked as a constructor via Reflect.construct / new. It touches two files: src/jsc/bindings/JSMockFunction.cpp (splitting the shared host function into separate [[Call]] / [[Construct]] entry points that delegate to a common helper with an isConstructCall flag, allocating a proper this via InternalFunction::createSubclassStructure, and substituting this for non-object return values on construct) and test/js/bun/test/mock-fn.test.js (six new tests covering no-impl construct, primitive returns, object returns, newTarget.prototype, assigned fn.prototype, and this propagation to the impl).

Security risks

None. This is bun:test mock-function infrastructure — no auth, crypto, permissions, filesystem, or network surface. The change only affects how jest.fn() behaves when invoked as a constructor, and the pre-PR behavior on this path was a debug-build crash.

Level of scrutiny

Moderate. While this is C++ JSC-bindings code, the scope is narrow and the implementation follows the established JSC idiom (getFunctionRealmcreateSubclassStructureconstructEmptyObject, with RETURN_IF_EXCEPTION after each fallible step) used elsewhere in Bun's native constructors. The encodedReturnValue lambda correctly implements ordinary [[Construct]] semantics (return the impl's result if it's an object, else the allocated this). The isConstructCall=false path is byte-for-byte equivalent to the old code, so existing call behavior is unchanged.

Other factors

  • My earlier 🟡 nit (the newTargetObject != fn fast-path ignoring user-assigned fn.prototype) was addressed: the guard was dropped so createSubclassStructure always runs, and a new on mock uses assigned prototype test was added. That inline thread is resolved.
  • My 🟣 comment about mock.instances never being populated is explicitly pre-existing and flagged as a follow-up; it does not block this crash fix.
  • The bug-hunting system found no issues on the current revision.
  • The duplicate-PR bot flagged three earlier PRs targeting the same crash; that's a process/triage matter for maintainers and doesn't bear on the correctness of this diff.
  • Test coverage is solid: the six added cases exercise every new branch (no impl, primitive impl, mockReturnValue, object impl, explicit newTarget, assigned prototype, and this capture via mock.contexts).

@robobun

robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the only failure on build #53591 so far is windows-2019-x64-test-bun, which is the same test-http-should-emit-close-when-connection-is-aborted.ts timeout that's currently failing on unrelated open PRs #30524, #30526, and #30529 — a pre-existing Windows flake, not related to this change.

Across four CI builds (#50719, #50963, #51966, #53591) on successive rebases of this branch, mock-fn.test.js has passed on every platform where tests ran. All failures have been pre-existing main flakes (the Windows HTTP timeout, 26225.test.ts, hot.test.ts, fetch-http2-client.test.ts, the since-fixed src/js/internal/sql/errors.ts lint error) or infra "Expired" jobs — none touch src/jsc/bindings/JSMockFunction.cpp or test/js/bun/test/mock-fn.test.js.

Both code reviews approved. The fix has been verified against 30+ Fuzzilli reproductions of this fingerprint across 6 different main commits. Ready to merge once maintainers are satisfied the remaining CI noise is unrelated.

@robobun

robobun commented May 24, 2026

Copy link
Copy Markdown
Collaborator Author

Fuzzilli hit this same assertion again via a different entry point — a spy instead of jest.fn():

const spy = Bun.jest(1002).spyOn(Date, 1002).mockRestore();
Reflect.construct(spy, []); // ASSERTION FAILED: isCell() in JSValue::asCell()

Same root cause: the spy is a JSMockFunction that's left with no implementation, so the construct path returns undefined and Interpreter::executeConstruct's asObject() trips the assertion. The construct handling in this PR covers that variant as well (non-object result → the allocated this is returned).

@robobun

robobun commented May 24, 2026

Copy link
Copy Markdown
Collaborator Author

Fuzzilli reported this assertion again via the spy entry point (constructing a spy created by spyOn), which led to the duplicate #31368. Closed that PR in favor of this one and pushed its extra regression test here (spyOn > constructing a spy works in 17566f2): it constructs a spy that wraps an existing function and checks the instance, the implementation's this, and call recording. Full mock-fn.test.js suite passes with this branch's fix rebased-checked against current main.

Comment on lines +844 to +855
if (isConstructCall) {
JSValue newTarget = callframe->newTarget();
if (JSObject* newTargetObject = newTarget.getObject()) {
JSGlobalObject* functionGlobalObject = getFunctionRealm(globalObject, newTargetObject);
RETURN_IF_EXCEPTION(scope, {});
Structure* structure = InternalFunction::createSubclassStructure(globalObject, newTargetObject, functionGlobalObject->objectStructureForObjectConstructor());
RETURN_IF_EXCEPTION(scope, {});
thisValue = constructEmptyObject(vm, structure);
} else {
thisValue = 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 (not introduced by this PR): even with isConstructCall true, the Kind::Call branch still invokes the implementation via Bun::call(...) — a regular [[Call]] — so spying on / mocking an ES class constructor and then new-ing it throws Class constructor Foo cannot be invoked without 'new', whereas Jest uses new impl(...args) on construct. The new "constructing a spy works" test only passes because it uses a plain function() {} rather than a class. Since this PR adds the isConstructCall flag and a proper thisValue right at this site, it'd be the natural place to branch to JSC::construct(globalObject, impl, args, newTarget) when isConstructCall and the impl is constructible — fine to leave for a follow-up alongside the mock.instances note since this PR is scoped to the crash fix.

Extended reasoning...

What the gap is

In the JSMockImplementation::Kind::Call branch of jsMockFunctionCallOrConstruct, the implementation is always invoked as:

JSC::CallData callData = JSC::getCallData(result);
...
JSValue returnValue = Bun::call(globalObject, result, callData, thisValue, args);

Bun::call performs an ordinary [[Call]], never [[Construct]], regardless of isConstructCall. ES class constructors have valid CallData (so the Type::None guard doesn't trip), but per spec they throw TypeError: Class constructor X cannot be invoked without 'new' when invoked via [[Call]]. So a mock or spy whose implementation is an ES class can be called fine, but throws as soon as it's used with new / Reflect.construct.

In Jest, mock functions are ordinary JS functions whose body checks new.target and does new impl(...args) when invoked as a constructor, so spying on a class and then new-ing it works.

Concrete trigger / step-by-step

class Foo { constructor() { this.x = 1 } }
const obj = { Foo };
jest.spyOn(obj, 'Foo');
new obj.Foo();
  1. spyOn(obj, 'Foo') creates a JSMockFunction and pushes a Kind::Call implementation whose underlyingValue is the original class Foo.
  2. new obj.Foo() enters jsMockFunctionConstructjsMockFunctionCallOrConstruct(..., /* isConstructCall */ true).
  3. isConstructCall is true, so a fresh thisValue is allocated via createSubclassStructure — good so far.
  4. The Kind::Call branch fetches result = class Foo, then getCallData(result) returns a JS call data (classes are callable in the getCallData sense), so the Type::None check passes.
  5. Bun::call(globalObject, result, callData, thisValue, args) performs [[Call]] on class Foo → throws TypeError: Class constructor Foo cannot be invoked without 'new'.
  6. The exception is recorded in mock.results as a throw and re-thrown.

The same applies to jest.fn(class Foo {}) followed by new fn().

Why nothing currently prevents it

The new isConstructCall flag is consulted only for allocating thisValue and for the encodedReturnValue non-object fixup; it does not influence how the implementation is invoked. There is no JSC::getConstructData / JSC::construct path anywhere in this function. The "constructing a spy works" test added in this PR uses function () { this.ok = true }, which is happy to be [[Call]]ed with the allocated thisValue, so it passes — but swapping it for class { constructor() { this.ok = true } } would fail.

Why this is pre-existing

Before this PR the construct entry point was the same jsMockFunctionCall host function and hit the exact same Bun::call line, so new obj.Foo() on a class spy already threw the same TypeError (when it didn't crash earlier for the unrelated isCell() reason this PR fixes). The PR does not change this behavior; it refactors the surrounding code and adds the isConstructCall flag right next to where the fix would go, which is why it's worth flagging here as a follow-up rather than a regression.

Suggested fix

Inside Kind::Call, when isConstructCall and JSC::getConstructData(result).type != JSC::CallData::Type::None, use JSC::construct(globalObject, result, args, callframe->newTarget()) instead of Bun::call(...), then run the result through encodedReturnValue. Optionally, when the impl is constructible, prefer the constructor-allocated instance (the value construct returns) over the pre-allocated thisValue so fn.prototype of the implementation class is honored. This pairs naturally with the existing pre-existing note about populating mock.instances.

Severity

Pre-existing, follow-up. The PR is scoped to the Fuzzilli isCell() crash and doesn't claim to fix class-constructor spying, so this should not block merge — filing alongside the existing mock.instances pre-existing note.

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.

Agreed — good catch. Invoking the implementation with [[Construct]] (via JSC::getConstructData/JSC::construct) when isConstructCall and the impl is constructible is the right follow-up, and this is the natural spot for it now that the flag and thisValue exist here. Keeping this PR scoped to the isCell() crash fix; the class-constructor spying gap and the mock.instances population can go together in a follow-up.

JSMockFunction registered the same host function for both [[Call]] and
[[Construct]]. When invoked as a constructor with no implementation (or
one returning a primitive), it returned jsUndefined(). Native
constructors must return an object; Interpreter::executeConstruct calls
asObject() on the result, tripping the isCell() assertion.

Split the host function into call/construct entry points. On construct,
allocate a fresh this object (honoring new.target's prototype) and
return it whenever the mock would otherwise produce a non-object.
@robobun robobun force-pushed the farm/7a85c992/fix-mock-construct-iscell branch from 17566f2 to 0fb8200 Compare May 24, 2026 22:36
@robobun

robobun commented May 24, 2026

Copy link
Copy Markdown
Collaborator Author

CI status for build #57723 (17566f2d): the only failures were the two Windows test-bun jobs, and in both the sole cause was test/js/bun/test/parallel/test-http-should-emit-close-when-connection-is-aborted.ts timing out on all 4 attempts — unrelated to this PR. mock-fn.test.js passed, and the expect-assertions.test.ts "fail" lines in the log are that file's intentional inner-runner output (the file itself reports 1 pass / 0 fail).

That HTTP-close timeout has been consistently red on Windows at this branch's old base (same failure as build #53591), while current main's Windows jobs are green (build #57709 passed). Rebased onto main 2fbfcb98 — clean rebase, the PR diff is unchanged (src/jsc/bindings/JSMockFunction.cpp + test/js/bun/test/mock-fn.test.js) — to pick that up and re-run CI.

@robobun

robobun commented May 24, 2026

Copy link
Copy Markdown
Collaborator Author

CI status for build #57745 (0fb82003, rebased on main): no job has failed — 261 jobs have passed (including every Linux/macOS test-bun shard), 0 failed, and the only thing keeping the build red is that two Windows build jobs (:windows: x64 - build-rust and :windows: x64-baseline - build-cpp) were canceled by CI infrastructure ~10 minutes after becoming runnable without ever being assigned an agent (started_at is null), which put their downstream Windows build/test steps into waiting_failed. That's agent-provisioning noise, unrelated to this PR (the change only touches src/jsc/bindings/JSMockFunction.cpp and test/js/bun/test/mock-fn.test.js).

Retrying those two canceled Windows jobs from the Buildkite UI is all that's needed to complete the Windows lane — I've already used a re-run on this PR (the rebase), so leaving that to a maintainer rather than retriggering again.

@robobun

robobun commented May 25, 2026

Copy link
Copy Markdown
Collaborator Author

Fuzzilli hit this assertion again through another entry point (constructing a spy created by spyOn on a missing property), which produced the duplicate #31373. Closed that PR in favor of this one and pushed its extra regression test here (daaa8d9). The fix in this PR already covers that case — the new test passes on this branch and fails on main.

@robobun

robobun commented May 25, 2026

Copy link
Copy Markdown
Collaborator Author

CI status for build #57842 (daaa8d94): 282 jobs passed, 0 failed. The only red GitHub status is darwin-14-aarch64-test-bun showing "Expired" — that job timed out waiting for a macOS agent and was automatically retried (the retry is scheduled); the two darwin-26-aarch64-test-bun shards are still running. No test or build failures, nothing related to this PR's change.

@robobun

robobun commented May 25, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #31386, which carries the same fix rebased onto current main and folds in this PR's test coverage (spy construction, explicit newTarget prototype). Closing to keep a single open PR for this crash.

@robobun robobun closed this May 25, 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.

1 participant