Skip to content

Fix crash when constructing jest.fn() mock that returns a non-object#29940

Closed
robobun wants to merge 1 commit into
mainfrom
farm/f4798f68/fix-mock-construct-non-object
Closed

Fix crash when constructing jest.fn() mock that returns a non-object#29940
robobun wants to merge 1 commit into
mainfrom
farm/f4798f68/fix-mock-construct-non-object

Conversation

@robobun

@robobun robobun commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator

What

JSMockFunction registered jsMockFunctionCall as both its [[Call]] and [[Construct]] handler. When the mock's implementation returns a primitive (e.g. jest.fn(Symbol)), constructing the mock via Reflect.construct returned that primitive to Interpreter::executeConstruct, which then called asObject() on it and hit:

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

Native [[Construct]] handlers must return an object.

Fix

Add a dedicated jsMockFunctionConstruct that:

  • allocates a fresh empty object to use as this,
  • delegates to jsMockFunctionCall with that this (so mock.contexts, mockReturnThis, and implementations that read this all see the new instance),
  • returns the new instance when the implementation's return value is not an object.

This matches ordinary JS new semantics (and Jest, where mock functions are plain JS functions and non-object returns are replaced by this).

Repro

const fn = Bun.jest().mock(Symbol);
Reflect.construct(fn, []); // previously: assertion failure in debug builds

Found by Fuzzilli.

JSMockFunction used the same native function for both [[Call]] and
[[Construct]]. When the mock implementation returned a primitive (e.g.
jest.fn(Symbol)), Reflect.construct() would receive that primitive and
hit an asObject() assertion in Interpreter::executeConstruct.

Add a dedicated construct handler that creates a fresh 'this' object,
runs the mock with it, and substitutes it for any non-object return
value — matching ordinary JS 'new' semantics.
@robobun

robobun commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:38 AM PT - Apr 29th, 2026

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


🧪   To try this PR locally:

bunx bun-pr 29940

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

bun-29940 --bun

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Fix crash when using Reflect.construct on a mock function #29790 - Fix crash when using Reflect.construct on a mock function
  2. Fix crash when Reflect.construct is used on mock functions returning non-objects #28532 - Fix crash when Reflect.construct is used on mock functions returning non-objects
  3. fix mock function crash when Reflect.construct returns non-object #28315 - Fix mock function crash when Reflect.construct returns non-object

🤖 Generated with Claude Code

@robobun

robobun commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #29790 (and #28532, #28315). Closing in favor of #29790 which already has CI green.

One difference worth noting: this PR additionally set thisValue to a fresh instance object before delegating to jsMockFunctionCall, so the mock implementation / mock.contexts / mockReturnThis see the new instance rather than the newTarget constructor — matching Jest's behavior where this inside the mock body is the newly allocated object. May be worth folding into #29790.

@robobun robobun closed this Apr 29, 2026
@robobun robobun deleted the farm/f4798f68/fix-mock-construct-non-object branch April 29, 2026 14:37
@coderabbitai

coderabbitai Bot commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

Caution

Review failed

Pull request was closed or merged during review

Walkthrough

Implements a dedicated construct handler for JSMockFunction to support proper constructor behavior when mocking functions. The new handler creates an empty this object, invokes the mock implementation, and returns the result appropriately. Includes comprehensive tests validating constructor calls via Reflect.construct and new keyword.

Changes

Cohort / File(s) Summary
Mock Function Constructor Support
src/bun.js/bindings/JSMockFunction.cpp, test/js/bun/test/mock-fn.test.js
Introduces dedicated jsMockFunctionConstruct handler to properly handle constructor invocations on mocked functions. The handler creates an empty this object, invokes the mock implementation, and returns appropriately based on result type. Adds comprehensive test suite validating Reflect.construct() and new keyword paths with various return behaviors.
🚥 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() mocks that return non-objects, which directly matches the changeset's primary purpose.
Description check ✅ Passed The description comprehensively addresses both required template sections: 'What' explains the problem, root cause, and fix; 'How did you verify' is implicitly covered through the provided repro case with clear instructions.
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.


Review rate limit: 1/5 review remaining, refill in 38 minutes and 21 seconds.

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

Comment on lines +991 to +992
JSObject* thisObject = JSC::constructEmptyObject(globalObject);
callframe->setThisValue(thisObject);

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 new instance is created via constructEmptyObject(globalObject), which always uses Object.prototype and ignores callframe->newTarget(). This means Reflect.construct(fn, [], CustomCtor) and a user-assigned fn.prototype = {...} are silently ignored, so the PR description's claim of "matches ordinary JS new semantics" doesn't hold for the prototype chain. Consider reading newTarget().prototype and using it (e.g. via InternalFunction::createSubclassStructure as in NapiClass.cpp) to derive the instance's [[Prototype]].

Extended reasoning...

What the bug is

jsMockFunctionConstruct allocates the new this with:

JSObject* thisObject = JSC::constructEmptyObject(globalObject);

This overload always builds a plain object whose [[Prototype]] is Object.prototype. It never consults callframe->newTarget(), so the standard ECMA‑262 OrdinaryCreateFromConstructor step — "derive the new object's [[Prototype]] from newTarget.prototype" — is skipped. In Jest, mock functions are ordinary JS functions, so the engine performs that step automatically; here we are reimplementing [[Construct]] natively and have to do it ourselves.

Code path that triggers it

  1. const fn = jest.fn(function(){});
  2. class C {} (or any function with a .prototype)
  3. Reflect.construct(fn, [], C) → JSC dispatches to jsMockFunctionConstruct with newTarget === C.
  4. constructEmptyObject(globalObject) ignores newTarget, producing {} with Object.prototype.
  5. The implementation returns undefined, so thisObject is returned. Object.getPrototypeOf(result) === Object.prototype, not C.prototype, and result instanceof C is false.

The same applies if the user explicitly sets fn.prototype = { foo: 'bar' } and then does new fn()new fn().foo is undefined here but 'bar' in Jest. (Note: because JSMockFunction is an InternalFunction without an own .prototype, the variant fn.prototype.foo = ... would throw before reaching this code; that's a separate pre‑existing limitation. The Reflect.construct and direct‑assignment cases are the observable ones.)

Why nothing prevents it

The handler reads callframe->thisValue() indirectly via jsMockFunctionCall, but never reads callframe->newTarget(). There is no fallback path that inspects the constructor's prototype. Other native constructors in this repo do handle this — e.g. NapiClass.cpp reads callFrame->newTarget() and calls InternalFunction::createSubclassStructure before constructEmptyObject(vm, structure); JSX509Certificate.cpp, JSDOMFile.cpp, and NodeDirent.cpp follow the same pattern.

Impact

Any test that mocks a class/constructor and relies on the prototype chain of constructed instances will diverge from Jest. Concretely, Reflect.construct(mock, args, RealClass) produces an object that is not instanceof RealClass, and methods inherited via the mock's prototype are unreachable on the instance. The PR description explicitly says this "matches ordinary JS new semantics", which is misleading for the prototype chain.

Step‑by‑step proof

class Base { greet() { return 'hi'; } }
const fn = jest.fn(function() {});
const inst = Reflect.construct(fn, [], Base);

// ECMA‑262 §10.1.13: prototype should be Base.prototype
Object.getPrototypeOf(inst) === Base.prototype;  // Jest: true   | this PR: false
inst instanceof Base;                            // Jest: true   | this PR: false
inst.greet();                                    // Jest: 'hi'   | this PR: TypeError

In jsMockFunctionConstruct, callframe->newTarget() is Base, but constructEmptyObject(globalObject) returns an object with globalObject->objectPrototype() as its prototype, so all three assertions fail.

How to fix

Read newTarget and derive the structure from its .prototype, mirroring the existing pattern:

JSValue newTarget = callframe->newTarget();
JSObject* thisObject;
if (newTarget.isObject()) {
    auto* targetFn = jsCast<JSObject*>(newTarget);
    auto* base = globalObject->objectStructureForObjectConstructor();
    auto* structure = InternalFunction::createSubclassStructure(globalObject, targetFn, base);
    RETURN_IF_EXCEPTION(scope, {});
    thisObject = JSC::constructEmptyObject(vm, structure);
} else {
    thisObject = JSC::constructEmptyObject(globalObject);
}

(or equivalently, look up newTarget.get(globalObject, vm.propertyNames->prototype) and pass it to constructEmptyObject(globalObject, proto) when it's an object). This keeps the crash fix intact while making the prototype chain match ordinary new semantics.

Comment on lines +991 to +994
JSObject* thisObject = JSC::constructEmptyObject(globalObject);
callframe->setThisValue(thisObject);

JSC::EncodedJSValue encodedResult = jsMockFunctionCall(lexicalGlobalObject, callframe);

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 (pre-existing): now that there's a dedicated [[Construct]] path that allocates thisObject, this would be the natural place to push it into fn->instances so mock.instances actually gets populated. Currently fn->instances is declared, exposed via mock.instances, cleared in clear(), and GC-visited, but nothing in this file ever pushes to it — so new fn(); fn.mock.instances is still [] (Jest gives [instance]). Not blocking since the array was already always empty before this PR and the scope here is the crash fix, but worth a follow-up.

Extended reasoning...

What's missing

JSMockFunction has an instances field (WriteBarrier<JSC::JSArray>), a getInstances() accessor, and exposes it as mock.instances (slot 2 of the mock object structure). It's cleared in clear() and visited in visitAdditionalChildrenInGCThread. But searching the entire file shows nothing ever calls instances->push(...) or otherwise populates it — unlike calls, contexts, invocationCallOrder, and returnValues, which are all pushed in jsMockFunctionCall.

This PR adds jsMockFunctionConstruct, which allocates a fresh thisObject via constructEmptyObject and uses it as the new instance — exactly the value Jest records in mock.instances for a new call. But the new handler doesn't push thisObject into fn->instances, so the array remains empty.

Step-by-step

  1. const fn = jest.fn(); const inst = new fn();
  2. jsMockFunctionConstruct runs, creates thisObject, sets it as callframe->thisValue(), delegates to jsMockFunctionCall.
  3. jsMockFunctionCall pushes to fn->calls, fn->contexts (with thisObject), fn->invocationCallOrder, and fn->returnValues. It never touches fn->instances.
  4. jsMockFunctionConstruct returns thisObject (since the implementation returned undefined).
  5. fn.mock.instancesgetInstances() lazily creates an empty array → [].

In Jest the same code yields fn.mock.instances[0] === inst, so assertions like expect(fn.mock.instances[0]).toBe(inst) (a common pattern when mocking constructors) still fail under Bun after this PR.

Why nothing currently prevents it

There's simply no code path that writes to instances. Before this PR the [[Construct]] handler was jsMockFunctionCall itself, which also never populated it, so this is pre-existingmock.instances has always been an empty array in Bun. Note that in Jest, mock.instances is actually pushed on every invocation (it records this, which for non-new calls is the receiver and for new calls is the new instance), so a fully correct fix would push in jsMockFunctionCall rather than only in the construct path.

Impact

Low / compat-only. No crash, no incorrect return value — just a Jest API surface (mock.instances) that continues to be a no-op. The PR's stated goal (fix the asObject() assertion crash) is unaffected.

Suggested fix (follow-up, non-blocking)

Either:

  • In jsMockFunctionCall, after pushing contexts, also push thisValue into fn->instances (matches Jest exactly — instances mirrors contexts for plain calls and holds the new object for new), or
  • At minimum, in jsMockFunctionConstruct, after creating thisObject, retrieve fn from callframe->jsCallee() and push thisObject into fn->instances so the construct case works.

Filed as a nit since it's a pre-existing gap and orthogonal to the crash fix, but the new construct handler is the obvious place to start recording instances.

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