Skip to content

mock: return an object when a mock function is constructed - #31621

Closed
robobun wants to merge 1 commit into
mainfrom
farm/30cd0850/mock-construct-returns-object
Closed

robobun wants to merge 1 commit into
mainfrom
farm/30cd0850/mock-construct-returns-object

Conversation

@robobun

@robobun robobun commented May 31, 2026

Copy link
Copy Markdown
Collaborator

What

Constructing a jest.fn() / Bun.jest().mock() mock function via Reflect.construct (or any code path that goes through the C++ JSC::construct helper) could crash the process.

Minimal repro:

const m = Bun.jest().mock();
Reflect.construct(m, []);

In a debug/ASAN build this trips ASSERTION FAILED: cell->isObjectSlow() in JSC::asObject. It was originally surfaced by a fuzzer as a flaky SIGSEGV.

Why

A native [[Construct]] implementation in JavaScriptCore must return a JSObject. The mock reused its call handler (jsMockFunctionCall) for construct, so constructing a mock returned the implementation's result — which is frequently a primitive or undefined (e.g. jest.fn() with no implementation returns undefined; jest.fn(() => 5) returns 5).

  • The op_construct bytecode path (new m()) tolerated a non-object return, so new m() merely returned the raw (often primitive) value — already not spec-correct.
  • Reflect.construct / JSC::construct go through Interpreter::executeConstruct, which ends in return asObject(JSValue::decode(result)). With a non-object result this dereferences a non-cell value and crashes.

Fix

Give the mock a dedicated construct handler. It runs the same recording + implementation logic, then enforces the native [[Construct]] contract: if the implementation's result is not an object, it returns a freshly constructed instance derived from the new.target's prototype — matching ordinary new aFunctionReturningAPrimitive() semantics. When the implementation returns an object, that object is passed through unchanged.

This also makes new mockFn() spec-correct (it now returns an object instance instead of a primitive).

Test

Added a portable (Jest/Vitest/Bun) regression test in test/js/bun/test/mock-fn.test.js covering new, Reflect.construct(m, []), an explicit newTarget, and the primitive-vs-object implementation-return cases. It fails on the released build (typeof new jest.fn() was "undefined") and passes with this change. The existing mock/spyOn suite (73 tests) still passes.

A native [[Construct]] implementation must return a JSObject. The mock
function reused its call handler for construct, so `new mockFn()` returned
the implementation's result — which may be a primitive or undefined. The
op_construct bytecode path tolerated that, but constructing through
`Reflect.construct` / the C++ `JSC::construct` path hit `asObject()` on a
non-object in `Interpreter::executeConstruct` and crashed.

Give the mock a dedicated construct handler that enforces the contract:
if the implementation returns a non-object, return a freshly constructed
instance derived from the new.target, matching ordinary
`new aFunctionReturningAPrimitive()` semantics.
@coderabbitai

coderabbitai Bot commented May 31, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 20 minutes and 58 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, 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 include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8639d1be-b075-4be9-9ed1-2cbc05869dd2

📥 Commits

Reviewing files that changed from the base of the PR and between 61bd997 and 2afae3b.

📒 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 31, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:38 PM PT - May 30th, 2026

❌ @robobun, your commit 2afae3b has 2 failures in Build #59411 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31621

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

bun-31621 --bun

@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 - Same fix: adds a dedicated jsMockFunctionConstruct handler to prevent crash when constructing mock functions via new or Reflect.construct

🤖 Generated with Claude Code

@robobun

robobun commented May 31, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as a duplicate of #31386, which is the same fix but more complete: it also records the constructed instance in mock.instances and runs the implementation with the freshly-created this (so mock.contexts[0] is the new instance). #31386 already covers the Reflect.construct(mockFn, []) crash this PR addresses.

@robobun robobun closed this May 31, 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; the two inline nits are valid follow-ups but don't block.

Extended reasoning...

Overview

This PR fixes a SIGSEGV / assertion failure when a jest.fn() mock is constructed via Reflect.construct (or any path through JSC::construct). The mock previously reused its call handler for construct, which could return a primitive; JSC's native [[Construct]] contract requires an object, and Interpreter::executeConstruct ends in asObject(result), which crashes on non-cells. The fix adds a small dedicated construct handler (~20 lines) that delegates to the existing call/record logic and, if the result is not an object, allocates a fresh instance derived from newTarget. A portable regression test is added.

Files touched: src/jsc/bindings/JSMockFunction.cpp (declare + wire + define one host function) and test/js/bun/test/mock-fn.test.js (one new test).

Security risks

None. This is test-utility code (jest.fn() / bun:test mocks), not exposed to untrusted input in any meaningful sense. The new path uses the same JSC primitives (createSubclassStructure, constructEmptyObject) as a dozen other construct handlers in src/jsc/bindings/. Exception scope handling is correct (RETURN_IF_EXCEPTION after the delegated call and after createSubclassStructure). No new heap-stored fields, so no GC visitor changes needed.

Level of scrutiny

Low-to-medium. It's C++ in JSC bindings, but the change is tiny, mechanical, and a strict improvement over the prior behavior (crash / returning undefined from new). It's not in a CODEOWNER-covered path, and the existing 73-test mock suite plus the new regression test cover it.

Other factors

The bug-hunter raised two nits, both of which I agree are non-blocking:

  • getFunctionRealm(newTarget) for the fallback base structure — a cross-realm-newTarget-with-non-object-.prototype edge case in a test utility; worth matching the rest of the directory but trivially deferrable.
  • Instance is allocated after the impl runs, so this.x = … mutations are lost and mock.instances stays empty. This is a real Jest-compat gap in the new path, but not a regression (construct never worked at all before), and threading a pre-allocated this through jsMockFunctionCall is a larger refactor that's reasonable as a follow-up.

Neither changes the verdict: the PR's stated goal is to stop the crash and satisfy the native [[Construct]] contract, and it does that correctly.

Comment on lines +1002 to +1005
JSObject* newTarget = asObject(callframe->newTarget());
Structure* structure = JSC::InternalFunction::createSubclassStructure(globalObject, newTarget, globalObject->objectStructureForObjectConstructor());
RETURN_IF_EXCEPTION(scope, {});
return 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.

🟡 The fallback base structure passed to createSubclassStructure comes from the lexical globalObject directly; per OrdinaryCreateFromConstructor it should come from getFunctionRealm(globalObject, newTarget) instead. Every other construct handler in src/jsc/bindings/ (e.g. NapiClass.cpp:52-55, JSX509Certificate.cpp, NodeVMScript.cpp, JSHash.cpp, JSDOMFile.cpp) follows that pattern — it only matters for the cross-realm-newTarget-with-non-object-.prototype edge case, but it's a two-line change to stay consistent.

Extended reasoning...

What's diverging

jsMockFunctionConstruct builds the fallback instance like this:

JSObject* newTarget = asObject(callframe->newTarget());
Structure* structure = JSC::InternalFunction::createSubclassStructure(globalObject, newTarget, globalObject->objectStructureForObjectConstructor());

The third argument — the base structure — is taken from the lexical globalObject. createSubclassStructure only consults this base when newTarget.prototype is not an object; in that case the constructed instance's [[Prototype]] falls back to the base structure's prototype. ECMA-262 OrdinaryCreateFromConstructor step 2 / GetPrototypeFromConstructor says that fallback prototype must come from GetFunctionRealm(newTarget), not the caller's realm.

Established codebase pattern

Every other native construct handler in src/jsc/bindings/ that calls createSubclassStructure resolves the realm first. NapiClass.cpp:52-55 (which uses the exact same objectStructureForObjectConstructor() base and explicitly cites JSC's ObjectConstructor.cpp as the reference):

JSGlobalObject* functionGlobalObject = getFunctionRealm(globalObject, asObject(newTarget));
RETURN_IF_EXCEPTION(scope, {});
Structure* baseStructure = functionGlobalObject->objectStructureForObjectConstructor();
Structure* objectStructure = InternalFunction::createSubclassStructure(globalObject, asObject(newTarget), baseStructure);

The same shape appears in JSX509Certificate.cpp:188-190, NodeVMScript.cpp:122-124, JSDOMFile.cpp:78-80, NodeFSStatBinding.cpp:830-832, JSBakeResponse.cpp:207-209, JSSign.cpp:504-506, JSHash.cpp:303-305, JSHmac.cpp:249-251, NodeFSStatFSBinding.cpp:374-376, JSDiffieHellmanGroupConstructor.cpp:58-60, and JSDOMWrapperCache.h:222-226. This PR's new handler is the only one that skips it.

Step-by-step trigger

  1. Create a mock in realm A: const m = jest.fn();
  2. Obtain a constructor C from realm B (e.g. via node:vm / ShadowRealm) and clobber its prototype: C.prototype = null;
  3. const inst = Reflect.construct(m, [], C);
  4. jsMockFunctionConstruct runs with lexical globalObject = realm A and newTarget = C (realm B).
  5. The mock has no implementation → returns undefined → not an object → fallback path runs.
  6. createSubclassStructure reads C.prototype, finds it's not an object, and uses the supplied base structure — realm A's objectStructureForObjectConstructor().
  7. inst therefore inherits from realm A's Object.prototype. Spec (and the rest of the codebase) says it should inherit from realm B's Object.prototype.

Why existing code doesn't prevent it

createSubclassStructure itself does not call getFunctionRealm; it trusts the base structure handed in. When newTarget.prototype is an object (the overwhelmingly common case) the base is ignored entirely, so this only surfaces in the non-object-prototype branch — which is exactly the branch the spec defines the realm rule for.

Impact / addressing the objection

One verifier argued this is not worth flagging because the trigger — cross-realm Reflect.construct of a jest.fn() mock with a newTarget whose .prototype is not an object — is implausible inside a unit test. That's fair: this is a test utility, not a web-platform object with cross-realm conformance requirements, and no user is likely to ever hit it. That's why this is filed as a nit, not a blocker. The reason it's still worth the two-line change is consistency: this is brand-new code that diverges from a pattern applied uniformly across a dozen other construct handlers in the same directory, and matching it costs effectively nothing.

Suggested fix

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

Comment on lines +985 to +1006
JSC_DEFINE_HOST_FUNCTION(jsMockFunctionConstruct, (JSGlobalObject * globalObject, CallFrame* callframe))
{
auto& vm = JSC::getVM(globalObject);
auto scope = DECLARE_THROW_SCOPE(vm);

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

// A native [[Construct]] implementation must return an object. The mock's
// implementation may return a primitive (or undefined), so fall back to a
// freshly constructed instance in that case, matching ordinary
// `new aFunctionReturningAPrimitive()` semantics and avoiding a crash when
// invoked via Reflect.construct / the C++ construct path.
JSValue result = JSValue::decode(encodedResult);
if (result.isObject())
return encodedResult;

JSObject* newTarget = asObject(callframe->newTarget());
Structure* structure = JSC::InternalFunction::createSubclassStructure(globalObject, newTarget, globalObject->objectStructureForObjectConstructor());
RETURN_IF_EXCEPTION(scope, {});
return 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.

🟡 The new construct handler creates the fresh instance after running the implementation, so this-mutations are lost: new (jest.fn(function(){ this.x = 5 }))().x is undefined here but 5 in Jest. Allocating the instance up front and passing it as thisValue would fix this — and would also be the natural place to push it onto fn->instances (which is wired up via getInstances() but never written to, so mock.instances is always []). Not a regression — the construct path never worked before — so feel free to defer to a follow-up, but the comment's claim of "ordinary new aFunctionReturningAPrimitive() semantics" isn't quite accurate as-is.

Extended reasoning...

What the bug is

jsMockFunctionConstruct invokes the implementation first (by delegating to jsMockFunctionCall, which calls Bun::call(globalObject, result, callData, thisValue, args) with thisValue = callframe->thisValue()), and only afterward — if the result is not an object — allocates a fresh instance via constructEmptyObject(vm, structure). For a native InternalFunction constructor in JSC, no this object is pre-allocated by the engine; callframe->thisValue() is not the freshly-constructed instance. So any this.x = … assignments in the implementation land on whatever thisValue() happened to be, and the brand-new object returned at the end never sees them.

The same root cause means the constructed instance is never pushed to fn->instances. The instances array is fully wired up — declared as a WriteBarrier<JSArray>, exposed via getInstances() at mockObjectStructure offset 2, cleared in clear(), visited in visitAdditionalChildrenInGCThread — but nothing in jsMockFunctionCall or jsMockFunctionConstruct ever writes to it, so mock.instances is always [].

Why existing code doesn't prevent it

The comment at lines 993–997 says this matches "ordinary new aFunctionReturningAPrimitive() semantics", but that's only half of the contract. In spec [[Construct]] (and in Jest, where the mock is a plain JS function that does mockImpl.apply(this, arguments)), the engine creates the this object before the body runs, the body mutates it, and that same object is what gets returned when the body's return value is non-object. Here the order is inverted: call → check → allocate. The crash is fixed, but the semantics diverge from both the spec step the comment cites and from Jest.

Step-by-step proof

const m = jest.fn(function () { this.x = 5; });
const inst = new m();
  1. jsMockFunctionConstruct is entered; callframe->thisValue() is whatever JSC passes for a native construct (not a fresh instance derived from newTarget.prototype).
  2. It calls jsMockFunctionCall, which reaches the JSMockImplementation::Kind::Call branch and does Bun::call(..., thisValue, args). The implementation runs this.x = 5 on that unrelated thisValue and returns undefined.
  3. Back in jsMockFunctionConstruct, result.isObject() is false, so it allocates constructEmptyObject(vm, structure) and returns it.
  4. inst is the empty object from step 3 — inst.x is undefined. In Jest, inst.x === 5.
  5. Nothing pushed to fn->instances, so m.mock.instances is []. In Jest it is [inst].

Impact

This is not a regression — before this PR new m() returned undefined (so .x threw) and Reflect.construct crashed outright; the PR strictly improves things. But it is a real Jest-compat gap in the code path this PR introduces, and the inline comment overstates the spec match. Tests that mock a constructor with jest.fn(function(){ this.field = … }) and inspect the returned instance, or that assert on mock.instances, will still fail under Bun.

The mock.instances half is pre-existing — fn->instances was never populated before this PR either (and in Jest it's actually pushed on every invocation, not just construct). It's mentioned here only because the fix for the this-mutation issue is exactly where it would be addressed.

How to fix

Allocate the instance up front and pass it through to the implementation, e.g.:

JSObject* newTarget = asObject(callframe->newTarget());
Structure* structure = InternalFunction::createSubclassStructure(globalObject, newTarget,
    globalObject->objectStructureForObjectConstructor());
RETURN_IF_EXCEPTION(scope, {});
JSObject* instance = constructEmptyObject(vm, structure);

// push to fn->instances here (and ideally also record it in mock.contexts as 'this')

// invoke the recording/implementation logic with 'instance' as thisValue
// (either thread an explicit thisValue into jsMockFunctionCall, or inline the
// relevant parts here)

JSValue result = JSValue::decode(encodedResult);
return JSValue::encode(result.isObject() ? result.getObject() : instance);

This requires threading an explicit thisValue into the shared call/record path, so it's a slightly larger change than the current crash fix — entirely reasonable to do as a follow-up.

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