Fix crash when constructing a mock function that returns a primitive#31094
Fix crash when constructing a mock function that returns a primitive#31094robobun wants to merge 2 commits into
Conversation
jsMockFunctionCall was registered as both the [[Call]] and [[Construct]] handler for mock functions. When invoked as a constructor it could return primitives (symbols, strings, bigints, undefined) from the mock implementation, which violates the native-constructor contract and trips an asObject() assertion inside Reflect.construct. Split the handler into separate call and construct entry points. The construct path now allocates a fresh `this` object using new.target's prototype and returns it whenever the implementation's result is not an object, matching ordinary JS [[Construct]] semantics.
|
Updated 1:18 PM PT - May 19th, 2026
❌ @robobun, your commit b393c56 has 2 failures in
🧪 To try this PR locally: bunx bun-pr 31094That installs a local version of the PR into your bun-31094 --bun |
|
Caution Review failedPull request was closed or merged during review WalkthroughMock functions can now be used as constructors. The implementation adds a separate JSC call path for construct invocations, derives ChangesMock Function Constructor Support
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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 843-861: The encodeReturn lambda only adjusts the encoded value
seen by callers but does not normalize what gets stored in mock.results or
update mock.instances for constructor calls; update the write sites that push
`"return"` results (the Kind::Call, Kind::ReturnValue, Kind::RejectedValue
branches and the default-undefined branch) to pass the normalized value (use
normalizeReturn(...) instead of the raw value or JSValue::encode(...)) and, when
isConstructCall is true, ensure the constructor path also appends thisValue to
mock.instances so mock.results and mock.instances reflect the actual constructed
object; locate the encodeReturn lambda and the branches handling mock result
pushes in JSMockFunction.cpp and replace the stored values accordingly and add
the thisValue push into the construct path.
🪄 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: 54062884-725b-48fd-b962-e0315a22e0f7
📒 Files selected for processing (2)
src/jsc/bindings/JSMockFunction.cpptest/js/bun/test/mock-fn.test.js
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
| if (isConstructCall) { | ||
| JSValue newTarget = callframe->newTarget(); | ||
| JSObject* prototype = globalObject->objectPrototype(); | ||
| if (newTarget && newTarget.isObject()) { | ||
| JSValue prototypeValue = asObject(newTarget)->get(globalObject, vm.propertyNames->prototype); | ||
| RETURN_IF_EXCEPTION(scope, {}); | ||
| if (prototypeValue.isObject()) | ||
| prototype = asObject(prototypeValue); | ||
| } | ||
| thisValue = JSC::constructEmptyObject(globalObject, prototype); | ||
| RETURN_IF_EXCEPTION(scope, {}); | ||
| } |
There was a problem hiding this comment.
🟣 Pre-existing nit (not blocking): fn->instances is still never populated, so fn.mock.instances will remain [] after new fn() even though Jest records the constructed this there. Since this PR now synthesizes the correct thisValue for the construct path, it would be a natural place to also push it onto fn->instances (mirroring how calls/contexts/results are pushed) — but that can also land separately.
Extended reasoning...
What's missing
JSMockFunction declares mutable JSC::WriteBarrier<JSC::JSArray> instances;, clears it in clear(), GC-visits it, lazily initializes an empty array in getInstances(), and exposes it on the mock object at offset 2 as the instances property. However, nothing in jsMockFunctionCallOrConstruct (or anywhere else) ever pushes to it. The function pushes to fn->calls, fn->contexts, fn->invocationCallOrder, and fn->returnValues on every invocation, but fn->instances is dead storage that always reads back as [].
Why it's relevant to this PR
In Jest, mockFn.mock.instances is documented to record the this that was bound for each invocation — for a constructor call, that's the freshly-allocated instance. Before this PR, new fn() on a mock that returned a primitive would assert in debug builds and return a primitive in release builds, so the fact that mock.instances stayed empty was largely unobservable (you couldn't really use mocks as constructors anyway). After this PR, new fn() works correctly and the construct path explicitly allocates the right thisValue via constructEmptyObject(globalObject, prototype) — so users who can now construct mocks may reasonably reach for fn.mock.instances and find it empty.
Step-by-step
const fn = jest.fn(function () { this.x = 1; });const inst = new fn();→ entersjsMockFunctionConstruct→jsMockFunctionCallOrConstruct(..., true).isConstructCallis true, sothisValue = constructEmptyObject(globalObject, prototype)— this is the instance.- The function pushes
argumentsArraytofn->calls,thisValuetofn->contexts, the invocation id tofn->invocationCallOrder, and the result record tofn->returnValues.fn->instancesis never touched. encodeReturn(returnValue)returnsthisValue(the impl returnedundefined, a non-object), soinstis the constructed object — correct.fn.mock.instances→getInstances()→ lazy-creates and returns an empty array. Expected (Jest):[inst]. Actual:[].
Why nothing prevents it
There's simply no write site. Grepping the file shows instances only at the declaration, clear(), getInstances(), the visitor, and the mock-object structure setup. The existing test suite only asserts fn.mock.instances is empty after mockClear/mockReset, never that it's populated, so no test catches this.
Impact
Low — it's a Jest-compat gap, not a crash or correctness issue in the PR's stated scope (a Fuzzilli-found assertion fix). Users porting Jest tests that assert on mock.instances (e.g. expect(fn.mock.instances[0]).toBe(inst)) will see failures.
Suggested fix (optional, can be a follow-up)
In jsMockFunctionCallOrConstruct, alongside the contexts push, also push thisValue to fn->instances (Jest pushes this for every call, not just constructs — non-construct calls record whatever this was, and plain fn() records undefined):
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);
}Severity
Pre-existing / nit. This gap predates the PR entirely — instances was never populated for any kind of call. The PR is a targeted crash fix and shouldn't be blocked on this; it's flagged only because the PR is restructuring exactly the construct path that now has the right value to record.
What
Fixes a debug assertion (
cell->isObjectSlow()inJSObject.h:1345) when ajest.fn()/mock()is invoked as a constructor and its implementation returns a non-object value.Also affected:
mock(() => "str"),mock(BigInt),mock().mockReturnValue(42), andmock()with no implementation — any construct path whose result isn't an object.Why
jsMockFunctionCallwas registered as both the[[Call]]and[[Construct]]native forJSMockFunction. Native constructors must return an object; the mock was returning whatever its implementation returned (orjsUndefined()).Reflect.constructthen callsasObject()on the result and asserts. In release builds this doesn't crash butnew fn()incorrectly returns primitives.Since
CallFrame::newTarget()is just an alias forthisValue(), a single shared native can't distinguish call from construct, so the handler is split into separatejsMockFunctionCallandjsMockFunctionConstructentry points.The construct path now:
thisobject usingnew.target.prototypethisto the implementationThis matches ordinary JS
[[Construct]]semantics (function F() { return "x"; } new F()→ the created object) and how Jest's mock constructor behaves.Found by Fuzzilli.