Skip to content

Fix crash when constructing a jest.fn() mock#30641

Closed
robobun wants to merge 1 commit into
mainfrom
farm/f4798f68/mock-fn-construct
Closed

Fix crash when constructing a jest.fn() mock#30641
robobun wants to merge 1 commit into
mainfrom
farm/f4798f68/mock-fn-construct

Conversation

@robobun

@robobun robobun commented May 13, 2026

Copy link
Copy Markdown
Collaborator

What

Reflect.construct(jest.fn(), []) (and any [[Construct]] that goes through the C++ Interpreter::executeConstruct path) tripped a debug assertion in JSC:

ASSERTION FAILED: cell->isObjectSlow()
JavaScriptCore/JSObject.h(1345) : JSObject *JSC::asObject(JSCell *)

JSMockFunction registered the same native entry point for [[Call]] and [[Construct]], and that entry point returns primitives in several cases — undefined when there's no implementation, the mockReturnValue, or the implementation's return value. executeConstruct unconditionally calls asObject() on the result of a native construct, so a primitive result aborts.

How

Split the native entry point into separate call and construct variants via a bool template parameter. On construct:

  • Allocate a fresh this object using new.target's prototype (via InternalFunction::createSubclassStructure, falling back to the plain object structure).
  • Record it in mock.instances and use it as the this passed to the implementation and stored in mock.contexts.
  • If the final return value is not an object, return the allocated this instead — standard [[Construct]] semantics.

Regular calls are unchanged.

As a side effect, mock.instances is now populated when a mock is used as a constructor (it was always empty before), matching Jest.

Repro

const { jest } = Bun.jest(import.meta.path);
const fn = jest.fn();
Reflect.construct(fn, []); // previously: assertion failure in debug builds

JSMockFunction used the same native entry point for [[Call]] and
[[Construct]], and that entry point could return primitives (undefined,
the mockReturnValue, or the implementation's return value). When invoked
via Reflect.construct, JSC's Interpreter::executeConstruct unconditionally
does asObject() on the result of a native construct, triggering an
assertion when the result was not an object.

Split the entry point into separate call and construct variants. On
construct, allocate a fresh this object derived from new.target, record it
in mock.instances, pass it as this to the implementation, and return it
whenever the implementation result is not an object. This matches normal
[[Construct]] semantics and Jest's behaviour for mock.instances.
@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@robobun has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 4 minutes and 34 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, 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 the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a30ebc85-937f-4f8e-9bd1-20c2395e9282

📥 Commits

Reviewing files that changed from the base of the PR and between b9c757b and 281711e.

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

Comment @coderabbitai help to get the list of available commands and usage tips.

@robobun

robobun commented May 13, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:24 PM PT - May 13th, 2026

@robobun, your commit 281711e has 1 failures in Build #54100 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30641

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

bun-30641 --bun

@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 jsMockFunctionCall into separate call/construct entry points in JSMockFunction.cpp

🤖 Generated with Claude Code

@robobun

robobun commented May 13, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #30212 which has already been through review.

@robobun robobun closed this May 13, 2026
@robobun robobun deleted the farm/f4798f68/mock-fn-construct branch May 13, 2026 22:23
JSGlobalObject* functionGlobalObject = getFunctionRealm(globalObject, newTargetObject);
RETURN_IF_EXCEPTION(scope, {});
Structure* baseStructure = functionGlobalObject->objectStructureForObjectConstructor();
Structure* structure = newTargetObject == fn ? baseStructure : InternalFunction::createSubclassStructure(globalObject, newTargetObject, baseStructure);

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 newTargetObject == fn fast-path skips createSubclassStructure and hard-codes objectStructureForObjectConstructor() (i.e. Object.prototype), so a user-assigned fn.prototype is ignored when constructing directly via new fn() — yet honored via Reflect.construct(fn, [], Other). Since createSubclassStructure already falls back to baseStructure when newTarget.prototype isn't an object, you can drop the ternary and call it unconditionally.

Extended reasoning...

What the bug is

Line 850 uses the standard JSC fast-path idiom:

Structure* structure = newTargetObject == fn
    ? baseStructure
    : InternalFunction::createSubclassStructure(globalObject, newTargetObject, baseStructure);

This pattern is correct for built-in constructors like Array or Map, where it relies on two invariants: (a) the constructor's .prototype is non-writable/non-configurable, and (b) baseStructure already has that .prototype baked in as its stored prototype. Neither invariant holds for JSMockFunction. As an InternalFunction, it has no default .prototype own-property (so users can freely assign one), and baseStructure here is functionGlobalObject->objectStructureForObjectConstructor(), whose stored prototype is Object.prototype — not anything the user set.

How it manifests

Assigning .prototype to a mock is a documented Jest pattern for mocking ES6 classes (see Jest's ES6 class mocks docs). In Jest, mocks are plain JS functions, so new fn() follows OrdinaryCreateFromConstructor and honors fn.prototype. With this PR's fast-path, Bun ignores it.

Why nothing prevents it

InternalFunction::createSubclassStructure does the right thing — it performs newTarget->get(vm.propertyNames->prototype) and uses the result as the instance's [[Prototype]], falling back to baseStructure only when .prototype is not an object. The bug is precisely that the newTargetObject == fn branch skips this lookup. The new test at mock-fn.test.js:842 ("Reflect.construct respects new.target prototype") only exercises the newTarget !== fn branch, so it passes; there's no test that sets fn.prototype and constructs directly.

Step-by-step proof

const fn = jest.fn();
fn.prototype = { greet() { return "hi"; } };
const inst = new fn();
  1. new fn() enters jsMockFunctionCallImpl<true> with callframe->newTarget() === fn.
  2. newTargetObject == fn is true, so structure = baseStructure = objectStructureForObjectConstructor().
  3. thisValue = constructEmptyObject(vm, structure) → an object whose [[Prototype]] is Object.prototype.
  4. No implementation returns an object, so constructorResult returns thisValue.
  5. Result: Object.getPrototypeOf(inst) === Object.prototype, and inst.greet is undefined.

Contrast with Reflect.construct(fn, [], class C {}): newTargetObject !== fn, so createSubclassStructure reads C.prototype and the instance correctly inherits from it — the asymmetry confirmed by the new test.

Impact

Users mocking classes by assigning Mock.prototype.method = jest.fn() and then doing new Mock() will get instances that don't inherit those methods, diverging from Jest. instanceof checks against fn will also fail (inst instanceof fnfalse even though fn.prototype is set).

Fix

Drop the ternary:

Structure* structure = InternalFunction::createSubclassStructure(globalObject, newTargetObject, baseStructure);

createSubclassStructure already returns baseStructure when newTarget.prototype is not an object (the default case for a fresh jest.fn()), so this is both a correctness fix and a simplification with negligible cost.

Comment on lines +854 to +866
JSC::JSArray* instancesArray = fn->instances.get();
if (instancesArray) {
instancesArray->push(globalObject, thisValue);
RETURN_IF_EXCEPTION(scope, {});
} else {
JSC::ObjectInitializationScope object(vm);
instancesArray = JSC::JSArray::tryCreateUninitializedRestricted(
object,
globalObject->arrayStructureForIndexingTypeDuringAllocation(JSC::ArrayWithContiguous),
1);
instancesArray->initializeIndex(object, 0, thisValue);
fn->instances.set(vm, fn, instancesArray);
}

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.

🟡 In Jest, mock.instances is pushed on every invocation (in lockstep with mock.calls/mock.contexts), not only on [[Construct]] — see jest-mock's wrapper, which runs mockState.instances.push(this) unconditionally. With this change, mixing new fn() and fn() produces arrays where instances[i] no longer corresponds to calls[i], and the new test "calling as a function does not populate instances" will fail when this file is run under Jest (the file header says it's meant to be cross-runtime). Consider pushing thisValue to instances unconditionally to match jest-mock, or at minimum gate that last test with if (isBun).

Extended reasoning...

What the bug is

Jest's mock-function wrapper (in packages/jest-mock/src/index.ts) executes, on every invocation:

mockState.instances.push(this);
mockState.contexts.push(this);
mockState.calls.push(args);

All three arrays therefore grow in lockstep and fn.mock.instances.length === fn.mock.calls.length === fn.mock.contexts.length always holds. After this PR, Bun pushes to instances only inside the if constexpr (isConstructCall) block in jsMockFunctionCallImpl, while calls and contexts are still pushed unconditionally. So a regular (non-construct) call grows calls/contexts but leaves instances untouched.

How it manifests / step-by-step proof

const fn = jest.fn();
new fn();   // call 0: construct
fn();       // call 1: regular
new fn();   // call 2: construct

Jest:

  • fn.mock.calls.length === 3
  • fn.mock.contexts.length === 3
  • fn.mock.instances.length === 3[obj0, undefined, obj2]
  • fn.mock.instances[2] corresponds to fn.mock.calls[2].

Bun after this PR:

  • fn.mock.calls.length === 3
  • fn.mock.contexts.length === 3
  • fn.mock.instances.length === 2[obj0, obj2]
  • fn.mock.instances[2] is undefined and fn.mock.instances[1] is the instance from call 2, not call 1.

Any Jest-compatible test that indexes these arrays in parallel (e.g. expect(fn.mock.instances[i]).toBe(fn.mock.contexts[i])) will see misaligned data.

Why existing code doesn't prevent it

The instances push is guarded by if constexpr (isConstructCall) (JSMockFunction.cpp:845–867), whereas the contexts push a few lines below runs in both branches. Nothing else in the function adds an entry to instances for non-construct calls, so the arrays diverge by design.

Impact

  1. Index misalignment between mock.instances and mock.calls/mock.contexts when construct and regular calls are mixed — a behavioral divergence from Jest that can break ported test suites.
  2. The new test breaks the file's cross-runtime contract. mock-fn.test.js opens with "This file is meant to be runnable in Jest, Vitest, and Bun", but the new test "calling as a function does not populate instances" asserts expect(fn.mock.instances).toHaveLength(0) after a regular call. Under real Jest that array has length 1 (containing undefined), so running this file in Jest will fail.

Note that pre-PR, instances was always empty, which was also divergent from Jest — so the C++ change here is a strict improvement. The issue is that it goes only halfway, and the accompanying test codifies the remaining divergence as expected behavior.

How to fix

Either:

  • Match jest-mock fully: move the instances push out of the if constexpr (isConstructCall) block and push thisValue unconditionally (just like contexts). Then change the last test to expect toHaveLength(1) / [undefined].
  • Or, keep the current C++ but fix the test file: wrap "calling as a function does not populate instances" in if (isBun) { … } so the file remains green when run under Jest/Vitest, consistent with how other Bun-specific behavior in this file is gated.

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