Skip to content

test: make mock functions construct a proper instance#31891

Closed
robobun wants to merge 1 commit into
mainfrom
farm/6b0428f9/mock-construct-return-object
Closed

test: make mock functions construct a proper instance#31891
robobun wants to merge 1 commit into
mainfrom
farm/6b0428f9/mock-construct-return-object

Conversation

@robobun

@robobun robobun commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a Fuzzilli-found assertion failure (fingerprint JSObject.h(1346), ASSERTION FAILED: cell->isObjectSlow()) reached by constructing a bun:test mock function:

const fn = Bun.jest().mock(); // or jest.fn(), mock(), vi.fn()
Reflect.construct(fn, []);

Root cause: JSMockFunction passed jsMockFunctionCall as both the call and the construct handler:

: Base(vm, structure, jsMockFunctionCall, jsMockFunctionCall)

so [[Construct]] returned whatever the mock implementation returned, undefined by default. JSC requires a native [[Construct]] to return an object: Reflect.construct goes through Interpreter::executeConstruct, which does asObject(result) on the return value. On undefined that asserts in debug builds and is type confusion in release builds. The bytecode path doesn't assert, so new fn() silently evaluated to undefined, which also violates the spec (a new expression always produces an object).

Fix: add a dedicated jsMockFunctionConstruct that follows ordinary function [[Construct]] semantics, which is exactly what jest's mockConstructor (a plain JS function) does:

  1. Create the instance via OrdinaryCreateFromConstructor(newTarget, "%Object.prototype%") (the same getFunctionRealm + InternalFunction::createSubclassStructure pattern JSC's ObjectConstructor uses), so an assigned fn.prototype is honored.
  2. Record the instance in mock.instances (the array already existed, was exposed on fn.mock, and was cleared by mockClear/mockReset, but nothing ever wrote to it).
  3. Run the shared mock logic with the instance as this, so mock.contexts, mock.calls, and mock.results record the same things jest records for new.
  4. Return the implementation's return value if it's an object, otherwise the instance.

The load-bearing change is the constructor list at JSMockFunction(...): jsMockFunctionCall, jsMockFunctionCall becomes jsMockFunctionCall, jsMockFunctionConstruct. The old handler body is unchanged, just extracted into jsMockFunctionCallImpl taking thisValue as a parameter (during native construct the callframe's this slot holds newTarget, not a this value).

How did you verify your code works?

  • The original Fuzzilli crash script and the minimized repro run cleanly on the debug+ASAN build (previously: ASSERTION FAILED: cell->isObjectSlow() / isCell()).
  • New tests in test/js/bun/test/mock-fn.test.js under mock() > called as a constructor: 5 of the 7 fail on the unfixed build (new fn() is undefined, mock.instances stays empty); the other 2 pin the "implementation returned an object wins" contract that already worked and must keep working. This file is written to also run under real Jest and Vitest, and the assertions match jest's documented mock.instances behavior for new.
  • Full mock-fn.test.js (79 tests), mock-disposable.test.ts, mock/mock-module.test.ts, spyMatchers.test.ts, expect-toHaveReturnedWith.test.js, and jest-extended.test.js pass with the debug build.
  • BUN_JSC_validateExceptionChecks=1 is clean on the new path, including a newTarget.prototype proxy trap that throws.
  • GC stress: 5000 new fn() iterations with Bun.gc(true) keeps all recorded instances alive.

Known remaining divergence from jest, unchanged by this PR: a bare mock function has no own prototype property (jest's mocks are plain functions, so new jest.fn() instanceof fn works there without assigning fn.prototype first).

JSMockFunction registered its call handler as the [[Construct]]
implementation too, so constructing a mock returned whatever the
implementation returned, undefined by default. Native constructors must
return an object: Reflect.construct(mockFn, []) tripped the asObject()
assertion in debug builds and is type confusion in release builds, and
new mockFn() evaluated to undefined.

Construct now follows ordinary function semantics, matching jest's
mockConstructor: create the instance from newTarget's prototype, run
the mock with it as this, record it in mock.instances, and return it
unless the implementation returned an object.
@github-actions github-actions Bot added the claude label Jun 5, 2026
@robobun

robobun commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:11 AM PT - Jun 5th, 2026

@robobun, your commit 0d8f0e6 has 1 failures in Build #60772 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31891

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

bun-31891 --bun

@coderabbitai

coderabbitai Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 04763b11-1756-418a-b17d-3f209a4f44bc

📥 Commits

Reviewing files that changed from the base of the PR and between 55f6c89 and 0d8f0e6.

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

Walkthrough

This PR adds constructor semantics to mock functions in Bun's JSC bindings. The jsMockFunctionCall path is refactored into a reusable jsMockFunctionCallImpl helper, a new jsMockFunctionConstruct handler is added to implement Jest-style constructor behavior (instance creation and recording), and the internal function wiring directs construction to that new handler. Tests validate instance creation, return value handling, and prototype behavior.

Changes

Mock function constructor support

Layer / File(s) Summary
Call implementation refactor and constructor declaration
src/jsc/bindings/JSMockFunction.cpp
jsMockFunctionConstruct is declared and jsMockFunctionCall is refactored to delegate to jsMockFunctionCallImpl, enabling both call and construct paths to share the same mock invocation logic.
Constructor implementation and internal wiring
src/jsc/bindings/JSMockFunction.cpp
jsMockFunctionConstruct creates instances from newTarget, records them in fn->instances, invokes the mock call with the instance as this, and returns object results or the created instance; JSMockFunction base constructor wiring is updated to route construction through this handler.
Constructor behavior tests
test/js/bun/test/mock-fn.test.js
Test suite validates mock function construction: instance creation and tracking, this binding, return value semantics for objects and primitives, mockReturnValue handling, prototype usage, and exception recording.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title directly reflects the main change: adding proper constructor instance behavior for mock functions, which is the primary fix in this PR.
Description check ✅ Passed The description fully follows the template with detailed 'What does this PR do?' and 'How did you verify your code works?' sections covering root cause, fix, and comprehensive verification methods.
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.

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 Infer (1.2.0)
src/jsc/bindings/JSMockFunction.cpp

In file included from src/jsc/bindings/JSMockFunction.cpp:1:
src/jsc/bindings/root.h:42:10: fatal error: 'cmakeconfig.h' file not found
42 | #include "cmakeconfig.h"
| ^~~~~~~~~~~~~~~
1 error generated.
src/jsc/bindings/JSMockFunction.cpp:297:5-321:5: ERROR translating statement 'CompoundStmt'
Aborting translation of method 'Bun::JSMockFunction::copyNameAndLength' in file 'src/jsc/bindings/JSMockFunction.cpp': "Assert_failure src/clang/cAst_utils.ml:249:53"
Uncaught Internal Error: "Assert_failure src/clang/cAst_utils.ml:249:53"
Error backtrace:
Raised at ClangFrontend__CAst_utils.get_decl_from_typ_ptr in file "src/clang/cAst_utils.ml", line 249, characters 53-65
Called from ClangFrontend__CTrans.CTrans_funct.get_destructor_decl_ref in file "src/clang/cTrans.ml", line 658, characters 12-59
Called from ClangFrontend__CTrans.CTrans_funct.destructor_calls.(fun) in file "src/clang/cTrans.ml", line 2048, characters 12-69
Called from Base__List.rev_filter_map.loop in file "

... [truncated 2200 characters] ...

tend_decl.CFrontend_decl_funct.add_method in file "src/clang/cFrontend_decl.ml" (inlined), line 54, characters 4-52
Called from ClangFrontend__CFrontend_decl.CFrontend_decl_funct.process_method_decl.add_method_if_create_procdesc in file "src/clang/cFrontend_decl.ml" (inlined), line 123, characters 16-158
Called from ClangFrontend__CFrontend_decl.CFrontend_decl_funct.process_method_decl in file "src/clang/cFrontend_decl.ml", line 126, characters 17-97
Called from ClangFrontend__CFrontend_decl.CFrontend_decl_funct.process_methods in file "src/clang/cFrontend_decl.ml" (inlined), line 270, characters 8-122
Called from Stdlib__List.iter in file "list.ml" (inlined), line 110, characters 12-15
Called from Stdlib__List.iter in file "list.ml" (inlined), line 108, characters 13-64
Called from Base__


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

@github-actions

github-actions Bot commented Jun 5, 2026

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 - Also adds a dedicated jsMockFunctionConstruct handler to fix new mockFn() returning undefined instead of a proper object, and fixes mock.instances being always empty

🤖 Generated with Claude Code

@robobun

robobun commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as a duplicate of #31386, which I opened earlier for the same fuzzer-found crash and didn't notice before opening this one.

#31386 takes the same approach (a dedicated jsMockFunctionConstruct handler so native [[Construct]] always returns an object) and goes further: it installs a default .prototype with a constructor back-reference so new fn() instanceof fn works without assigning one, and it populates mock.instances on every invocation to match jest's runtime behavior. It already went through review iterations, its CI is green, and it's mergeable.

This report (fingerprint JSObject.h(1346), asObject on the construct result) reproduces on current main at 55f6c89 and is the same bug, so #31386 covers it.

@robobun robobun closed this Jun 5, 2026
@robobun robobun deleted the farm/6b0428f9/mock-construct-return-object branch June 5, 2026 18:10

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

I didn't find any issues — the construct handler follows the standard JSC pattern and the instances-array bookkeeping mirrors the existing calls/contexts code — but ~50 lines of new JSC binding C++ that fixes a release-build type confusion is worth a human glance.

Extended reasoning...

Overview

This PR fixes a Fuzzilli-found assertion failure / release-build type confusion reached by constructing a bun:test mock function (Reflect.construct(jest.fn(), [])). The root cause is that JSMockFunction registered jsMockFunctionCall as both the call and construct handler, so [[Construct]] could return undefined, which JSC's Interpreter::executeConstruct then passes to asObject(). The fix extracts the call body into jsMockFunctionCallImpl(... thisValue), adds a dedicated jsMockFunctionConstruct that creates an instance via getFunctionRealm + InternalFunction::createSubclassStructure, records it in mock.instances, runs the shared impl with the instance as this, and returns the instance unless the impl returned an object. Two files touched: src/jsc/bindings/JSMockFunction.cpp (~60 lines net) and 7 new tests in test/js/bun/test/mock-fn.test.js.

Security risks

The PR fixes a security-adjacent issue (type confusion on asObject(undefined) in release builds). The new code itself does asObject(callframe->newTarget()), which is safe because newTarget is always an object during a construct call. Exception checks (RETURN_IF_EXCEPTION) are placed after getFunctionRealm, createSubclassStructure, push, and the impl call. The instances array is already visited in visitAdditionalChildrenInGCThread. tryCreateUninitializedRestricted is unchecked for null-on-OOM, but that exactly matches the existing pattern for calls/contexts/returnValues in the same file. I don't see new risks introduced.

Level of scrutiny

Moderate-to-high. This is hand-written JSC binding C++ touching object construction, GC-tracked arrays, and exception scopes in a code path exercised by every jest.fn() / mock() user who uses new. The change is well-scoped and follows established JSC idioms (same pattern as ObjectConstructor; instances bookkeeping is byte-for-byte parallel to the existing calls bookkeeping), and the PR description documents debug+ASAN, exception-validation, and GC-stress verification. Still, native engine binding changes that resolve a type-confusion crash deserve a human reviewer's eyes rather than bot-only approval.

Other factors

The bug hunting system found nothing. Test coverage is solid: 7 new cases covering the default instance, Reflect.construct, instance-as-this, object-return override, primitive-return ignored, mockReturnValue with object, prototype honoring, and throw-still-records. The test file is designed to also run under real Jest/Vitest, so the asserted semantics are cross-checked against the reference implementations.

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