Skip to content

bun:test: return an object when a mock is constructed - #36497

Closed
robobun wants to merge 3 commits into
mainfrom
farm/4e0236f3/mock-fn-construct-object
Closed

robobun wants to merge 3 commits into
mainfrom
farm/4e0236f3/mock-fn-construct-object

Conversation

@robobun

@robobun robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Fuzzer hit the isCell() assertion in JSCJSValue.h with:

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

JSMockFunction registered jsMockFunctionCall for both call and construct. With no implementation that function returns jsUndefined(), and JSC::Interpreter::executeConstruct then does asObject() on the result (native constructors must return an object). In release builds this surfaced as Reflect.construct(jest.fn(), []) evaluating to undefined.

This adds a dedicated construct handler that allocates the instance (using newTarget.prototype so Reflect.construct(mock, args, Sub) produces a Sub instance), records it in mock.instances, hands it to the shared call path as this, and returns it whenever the implementation does not produce an object. As a side effect mock.instances now actually tracks constructed objects like Jest does (it was always empty before).

Supersedes the closed #34656. #34575 is the broader fix (also covers JSFFIFunction and native module helpers) but has merge conflicts; this PR covers the JSMockFunction part and is cleanly mergeable.

robobun added 2 commits July 31, 2026 02:32
JSMockFunction used the same native function for both call and
construct. When invoked via Reflect.construct with no implementation,
it returned undefined, and JSC::Interpreter::executeConstruct then
called asObject() on that value, tripping the isCell() assertion.

Give JSMockFunction its own construct handler that allocates an
instance, records it in mock.instances, passes it as this to the
shared call path, and returns it whenever the implementation does
not produce an object. This also makes mock.instances actually
track constructed objects like Jest does.
@coderabbitai

coderabbitai Bot commented Jul 31, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

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

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

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.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c36cf403-4d2e-4e2c-aa92-b98229cd1d45

📥 Commits

Reviewing files that changed from the base of the PR and between 454922a and f32c31e.

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

Walkthrough

Changes

Mock functions now have a dedicated construct callback. Construction creates and records an instance, invokes the mock with that instance as this, and applies JavaScript constructor return rules. Tests cover new, Reflect.construct, recorded instances, contexts, arguments, return objects, and custom prototypes.

Mock Function Construction

Layer / File(s) Summary
Register the construct callback
src/jsc/bindings/JSMockFunction.cpp
The mock function declaration and constructor wiring now use jsMockFunctionConstruct for construction.
Implement and validate construction
src/jsc/bindings/JSMockFunction.cpp, test/js/bun/test/mock-fn.test.js
The construct path resolves the prototype, records the instance, invokes the mock call handler, returns object results, and otherwise returns the created instance. Tests cover construction behavior and custom newTarget prototypes.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: constructed mocks now return an object.
Description check ✅ Passed The description clearly explains the bug, implementation, and expected behavior, although it does not include a separate verification section.
Linked Issues check ✅ Passed The changes implement the linked issue requirements for construction handling, instance creation, prototype use, recording, context, and object returns.
Out of Scope Changes check ✅ Passed The implementation and tests remain within the linked issue scope for JSMockFunction construction behavior.

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

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

Actionable comments posted: 2

🤖 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 980-1021: Add a brief comment in jsMockFunctionConstruct
immediately before callframe->setThisValue(instance), documenting that newTarget
was captured earlier because setThisValue repurposes the constructor CallFrame’s
this slot for jsMockFunctionCall, consistent with CallFrame.rs.

In `@test/js/bun/test/mock-fn.test.js`:
- Around line 806-833: Add a construction-error case to the existing
“Reflect.construct on a mock” test using a mock implementation that throws, and
assert both new fn() and Reflect.construct(fn, []) synchronously throw the same
Error. Keep the assertions focused on propagating the implementation exception
rather than returning an allocated instance.
🪄 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: 7991438c-900d-4ae3-b262-6cb5093897cb

📥 Commits

Reviewing files that changed from the base of the PR and between bbe3f6a and 454922a.

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

Comment on lines +980 to +1021
JSC_DEFINE_HOST_FUNCTION(jsMockFunctionConstruct, (JSGlobalObject * lexicalGlobalObject, CallFrame* callframe))
{
Zig::GlobalObject* globalObject = uncheckedDowncast<Zig::GlobalObject>(lexicalGlobalObject);
auto& vm = JSC::getVM(globalObject);
auto scope = DECLARE_THROW_SCOPE(vm);
JSMockFunction* fn = dynamicDowncast<JSMockFunction>(callframe->jsCallee());
if (!fn) [[unlikely]] {
throwTypeError(globalObject, scope, "Expected callee to be mock function"_s);
return {};
}

JSValue newTarget = callframe->newTarget();
JSObject* newTargetObject = newTarget.isObject() ? asObject(newTarget) : fn;
JSValue prototype = newTargetObject->get(globalObject, vm.propertyNames->prototype);
RETURN_IF_EXCEPTION(scope, {});
JSObject* instance = prototype.isObject()
? JSC::constructEmptyObject(globalObject, asObject(prototype))
: JSC::constructEmptyObject(globalObject);
RETURN_IF_EXCEPTION(scope, {});

if (JSC::JSArray* instances = fn->instances.get()) {
instances->push(globalObject, instance);
RETURN_IF_EXCEPTION(scope, {});
} else {
JSC::ObjectInitializationScope object(vm);
instances = JSC::JSArray::tryCreateUninitializedRestricted(
object,
globalObject->arrayStructureForIndexingTypeDuringAllocation(JSC::ArrayWithContiguous),
1);
instances->initializeIndex(object, 0, instance);
fn->instances.set(vm, fn, instances);
}

callframe->setThisValue(instance);
EncodedJSValue encodedResult = jsMockFunctionCall(lexicalGlobalObject, callframe);
RETURN_IF_EXCEPTION(scope, {});
JSValue result = JSValue::decode(encodedResult);
if (result.isObject())
return encodedResult;
return JSValue::encode(instance);
}

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.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Confirm the vendored JSC CallFrame this-slot/newTarget behavior used by this construct callback.
fd -a CallFrame.h | xargs -I{} rg -n -B2 -A2 'setThisValue|newTarget\(\)' {}

Repository: oven-sh/bun

Length of output: 149


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- CallFrame.h candidates ---'
fd -a -i 'CallFrame\.h$' . | head -20

printf '%s\n' '--- construct callback and callframe access ---'
rg -n -B4 -A8 'jsMockFunctionConstruct|setThisValue|newTarget\(\)|thisValue\(\)' \
  src/jsc/bindings src 2>/dev/null | head -240

Repository: oven-sh/bun

Length of output: 19972


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- tracked CallFrame files and JSC source roots ---'
git ls-files | rg -i '(^|/)(CallFrame\.h|JavaScriptCore|jsc|webkit)(/|$)' | head -160

printf '%s\n' '--- CallFrame API references ---'
rg -n -S 'class CallFrame|newTarget\(\)|setThisValue\(|thisValue\(\) const|thisValue\(\)' \
  --glob '*.{h,hpp,cpp,cc,mm,inc}' . 2>/dev/null | head -240

printf '%s\n' '--- exact mock constructor context ---'
sed -n '960,1030p' src/jsc/bindings/JSMockFunction.cpp

Repository: oven-sh/bun

Length of output: 38257


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- CallFrame.rs slot definitions ---'
rg -n -B8 -A14 'pub fn (new_target|this_value|set_this_value)|newTarget|setThisValue|thisValue' \
  src/jsc/CallFrame.rs | head -180

printf '%s\n' '--- analogous host-constructor slot reuse ---'
sed -n '1,95p' src/jsc/bindings/NapiClass.cpp
sed -n '1025,1065p' src/jsc/bindings/napi.h

printf '%s\n' '--- constructor implementations using both slots ---'
sed -n '320,350p' src/runtime/webview/JSWebViewConstructor.cpp
sed -n '165,195p' src/jsc/bindings/NodeDirent.cpp

Repository: oven-sh/bun

Length of output: 8808


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- generated CallFrame.rs contents ---'
wc -l src/jsc/CallFrame.rs
cat -n src/jsc/CallFrame.rs | head -220

printf '%s\n' '--- CallFrame generation and WebKit source references ---'
rg -n -S 'CallFrame\.rs|CallFrame|WebKit|oven-sh/WebKit|397dafc9721b8f8046f9448abb6dbc14efe096d3' \
  build.zig build.zig.zon src package.json README.md 2>/dev/null | head -220

Repository: oven-sh/bun

Length of output: 31334


Document the intentional CallFrame slot reuse.

At Line 1013, add a short comment that callframe->newTarget() was captured before setThisValue(instance) repurposes the constructor frame’s this slot for jsMockFunctionCall. src/jsc/CallFrame.rs documents this slot-sharing behavior.

🤖 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 980 - 1021, Add a brief
comment in jsMockFunctionConstruct immediately before
callframe->setThisValue(instance), documenting that newTarget was captured
earlier because setThisValue repurposes the constructor CallFrame’s this slot
for jsMockFunctionCall, consistent with CallFrame.rs.

Source: Coding guidelines

Comment thread test/js/bun/test/mock-fn.test.js
@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Don't register native functions as their own constructors #34575 - Both PRs add an identical jsMockFunctionConstruct handler to JSMockFunction.cpp: they both declare JSC_DECLARE_HOST_FUNCTION(jsMockFunctionConstruct), refactor jsMockFunctionCall into a shared jsMockFunctionCallImpl, wire the new construct handler into JSMockFunction's constructor, and implement the same newTarget → structure → thisObject logic. PR Don't register native functions as their own constructors #34575 is a broader fix that also covers JSFFIFunction and other native module helpers, while bun:test: return an object when a mock is constructed #36497 focuses solely on the JSMockFunction part — but the JSMockFunction changes themselves are the same fix.

🤖 Generated with Claude Code

@robobun

robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Added the throw-during-construct coverage. Skipping the inline comment on setThisValue: capturing newTarget before mutating the this slot is the standard pattern across the codebase (NapiClass.cpp, NodeDirent.cpp, etc. all do it without a note), and the ordering in the code is already self-documenting.

On the duplicate flag: yes, #34575 is the superset fix but it currently has merge conflicts (mergeStateStatus: DIRTY). This PR covers the JSMockFunction part and is cleanly mergeable now; happy to close it if #34575 gets rebased first.

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

No bugs found, but deferring to a human reviewer — this adds a new native JSC construct callback that mutates the CallFrame's this-slot and changes user-visible mock.instances behavior, and it overlaps with the broader open #34575, so a maintainer should confirm the scope split.

What was reviewed:

  • Exception-scope discipline in jsMockFunctionConstruct: every fallible call (get(prototype), constructEmptyObject, push, delegated jsMockFunctionCall) is followed by RETURN_IF_EXCEPTION.
  • newTarget is captured before setThisValue(instance) overwrites the shared slot, matching the NapiClass/NodeDirent pattern.
  • instances array creation vs. push interacts correctly with the lazy mock object (getInstances() reuses the array either way).
  • Test covers no-impl, object-returning impl, primitive-returning impl, custom newTarget, and throwing impl.
Extended reasoning...

Overview

The PR adds jsMockFunctionConstruct in src/jsc/bindings/JSMockFunction.cpp and wires it as the construct callback for JSMockFunction (previously both call and construct used jsMockFunctionCall). The new handler allocates an instance from newTarget.prototype, records it in fn->instances, sets it as this on the CallFrame, delegates to the existing call path, and applies JS constructor return semantics (object result wins, otherwise return the allocated instance). A new test in test/js/bun/test/mock-fn.test.js covers Reflect.construct, new, custom newTarget, primitive/object-returning implementations, and a throwing implementation.

Security risks

None identified. This is test-runner mock machinery; no auth, crypto, filesystem, or untrusted-input parsing is involved. The construct path only handles values already reachable from the test's own code.

Level of scrutiny

Medium-high. This is hand-written C++ against JavaScriptCore internals, in the category REVIEW.md flags as most-blocked (native memory safety / exception checks). The change is small (~40 LOC) and follows established patterns from other host constructors in the repo, and the exception-scope handling looks complete. However, it is not a mechanical change: it mutates a live CallFrame (setThisValue) to reuse the call handler as a construct body, relies on the constructor-frame this/newTarget slot aliasing, and changes an observable Jest-compat behavior (mock.instances is now populated).

Other factors

  • There is an open overlapping PR (#34575) that fixes the same JSMockFunction path plus JSFFIFunction and other native constructors. The author acknowledged it has merge conflicts and positioned this as the mergeable subset; a maintainer should decide whether to land this now or wait for the rebase of the broader fix.
  • CodeRabbit's two nits were handled: the throw-during-construct test was added in f32c31e, and the author gave a reasonable justification for skipping the setThisValue comment (matches uncommented precedent in NapiClass.cpp / NodeDirent.cpp).
  • I did not identify any missing RETURN_IF_EXCEPTION, GC-rooting gaps (the instance is on the conservatively-scanned stack and immediately stored through a WriteBarrier), or paths where the constructor could still return a non-object.

Given the native-bindings surface, the observable behavior change, and the pending scope decision vs. #34575, this warrants a human sign-off rather than auto-approval.

@robobun

robobun commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Closing this as a duplicate of #34575, which has been open since July 18 and covers a superset of this change.

Same root cause and the same core fix (a dedicated jsMockFunctionConstruct instead of reusing the call handler), but #34575 also:

  • resolves the fallback prototype through getFunctionRealm + InternalFunction::createSubclassStructure, which CodeRabbit review required there: the manual newTarget.prototype lookup in this PR falls back to the lexical realm's Object.prototype for cross-realm constructors with a primitive .prototype (covered by a node:vm test in Don't register native functions as their own constructors #34575)
  • fixes the five sibling sites with the same defect (bun:ffi compiled functions, node:buffer's isUtf8/isAscii/resolveObjectURL, the putNativeFn helper behind ~80 node:util/types/node:tty/bun:jsc/node:sqlite exports, utf-8-validate, and node:sqlite's backup), with a balanced-paren sweep in the PR body showing that set is complete
  • records mock.instances on every invocation, matching jest, with tests for both call and construct paths
  • has all CodeRabbit and claude findings addressed and green CI modulo one pre-existing main break

Fuzzilli has found this assertion seven times now; splitting review attention across two PRs is part of why it stayed unfixed, so keeping one.

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