Skip to content

fix(bun:test): constructing a mock function must return an object#31381

Closed
robobun wants to merge 1 commit into
mainfrom
farm/4e0236f3/fix-mock-construct
Closed

fix(bun:test): constructing a mock function must return an object#31381
robobun wants to merge 1 commit into
mainfrom
farm/4e0236f3/fix-mock-construct

Conversation

@robobun

@robobun robobun commented May 25, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a crash found by fuzzing (fingerprint JSCJSValue.h(1043), ASSERTION FAILED: isCell()).

JSMockFunction (the object behind jest.fn() / mock() / spyOn() in bun:test) registered its call callback as its construct callback too. When a mock was constructed and its implementation produced a non-object (e.g. no implementation at all, or mockReturnValue(5)), the construct callback returned that primitive. A [[Construct]] call must always produce an object, so paths that go through JSC::construct()Reflect.construct(mockFn, []), super() on a mocked class, proxies, etc. — ended up calling asObject() on a non-cell value. That trips the isCell() assertion in debug builds and produces a bogus "object" pointer in release builds. Plain new mockFn() was also wrong: it evaluated to undefined.

const { jest } = Bun.jest("/tmp/fake.test.js");
const fn = jest.fn();
new fn();                  // previously: undefined (spec violation)
Reflect.construct(fn, []); // previously: ASSERTION FAILED: isCell()

This PR gives JSMockFunction a dedicated construct callback that:

  • creates a new instance using newTarget.prototype (falling back to Object.prototype),
  • runs the existing mock call logic with that instance as this (so calls/contexts/results are still recorded and mockReturnThis() returns the instance),
  • returns the implementation's result when it is an object, otherwise returns the newly created instance — matching ordinary JS constructor semantics and Jest's behavior.

How did you verify your code works?

  • The fuzzer repro no longer crashes; it now runs to completion.
  • Added a mock() > are constructable test to test/js/bun/test/mock-fn.test.js covering new mockFn() / Reflect.construct with no implementation, primitive return values, object return values, and throwing implementations. The file keeps its Jest/Vitest interop, and these assertions match Jest's behavior.
  • bun bd test test/js/bun/test/mock-fn.test.js, mock-disposable.test.ts, spyMatchers.test.ts, expect-toHaveReturnedWith.test.js, expect/toHaveReturnedWith.test.ts, and test/regression/issue/issue-1825-jest-mock-functions.test.ts all pass.

Mock functions registered the call callback as their construct callback,
so `new mockFn()` / `Reflect.construct(mockFn, [])` could produce a
non-object value (e.g. undefined), violating the [[Construct]] contract
and failing the isCell assertion in JSC when the result was used.

Add a dedicated construct callback that creates an instance from
newTarget's prototype, runs the mock implementation with it as this, and
returns the implementation result only when it is an object.
@coderabbitai

coderabbitai Bot commented May 25, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, we couldn't start this review because you've used your available PR reviews for now.

Your plan includes 5 reviews of capacity. Refill in 30 minutes and 25 seconds.

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

⌛ How to resolve this issue?

After more review capacity refills, 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 have higher rate limits than trial, open-source, and free plans. In all cases, review capacity refills continuously over time.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 76dd544e-3695-450d-a330-4a7d1750a01f

📥 Commits

Reviewing files that changed from the base of the PR and between a49b308 and 6fbf94d.

📒 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 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:37 AM PT - May 25th, 2026

@robobun, your commit 6fbf94d has 2 failures in Build #57894 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31381

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

bun-31381 --bun

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Fix crash constructing jest.fn() via Reflect.construct #30212 - Fixes the same ASSERTION FAILED: isCell() crash when constructing jest.fn() via Reflect.construct, by implementing separate Call/Construct handlers in JSMockFunction

🤖 Generated with Claude Code

@robobun

robobun commented May 25, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as a duplicate of #30212, which fixes this same assertion the same way: separate [[Call]]/[[Construct]] entry points for JSMockFunction, with the construct path allocating a this object (honoring new.target's prototype via createSubclassStructure) and returning it whenever the implementation produces a non-object.

#30212 is further along — it has been through review, its latest CI run is green, and it already carries regression coverage for this exact entry point (constructing a spy created by spyOn on a missing property) along with newTarget/prototype and this-binding tests. Nothing in this PR adds coverage beyond what's already there, so consolidating on #30212.

@robobun robobun closed this May 25, 2026
@robobun robobun deleted the farm/4e0236f3/fix-mock-construct branch May 25, 2026 09:36
Comment on lines +1003 to +1015
JSObject* thisObject = prototype
? JSC::constructEmptyObject(lexicalGlobalObject, prototype)
: JSC::constructEmptyObject(lexicalGlobalObject);

JSValue result = JSValue::decode(jsMockFunctionCallImpl(lexicalGlobalObject, callframe, thisObject));
RETURN_IF_EXCEPTION(scope, {});

// A [[Construct]] call must always produce an object. If the mock implementation returned a
// primitive, return the newly created instance instead, matching ordinary constructor semantics.
if (result && result.isObject())
return JSValue::encode(result);

return JSValue::encode(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.

🟣 Not blocking, and pre-existing, but worth a follow-up: mock.instances is still never populated — fn->instances is only declared, cleared, lazily created empty, and GC-visited, never written to — so even with the new construct path, new fn(); fn.mock.instances is [] where Jest gives [<instance>]. Since this PR now creates the very thisObject that mock.instances is meant to record, it'd be a natural place to push it (and, to fully match Jest, push thisValue on every call alongside the contexts push in jsMockFunctionCallImpl).

Extended reasoning...

What this is

JSMockFunction has an instances field (line 271) that backs mockFn.mock.instances, but nothing in the file ever writes to it. Grepping confirms it appears only at:

  • line 271 — the WriteBarrier<JSArray> declaration,
  • line 350 — clear() clears it,
  • lines 420–425 — getInstances() lazily creates an empty array,
  • line 483 — GC visit,
  • line 757 — the "instances" slot in the mock object structure.

There is no instances->push(...) or initializeIndex call anywhere, so fn.mock.instances is always an empty array — both before and after this PR. In Jest, mock.instances records the this value for every invocation (parallel to mock.contexts), and is most useful exactly for the new fn() case this PR is adding.

Why mention it on this PR

This is a pre-existing gap — instances has never been populated in Bun, and this PR does not regress it. The refutation correctly notes that the PR's stated scope is the isCell() crash fix and that "matching Jest's behavior" in the description refers to the primitive-return-replaced-by-this semantics, not full mock.instances parity. That's all fair, and this should not block the PR.

It is still worth a non-blocking note because the new jsMockFunctionConstruct is the first place in this file that actually creates the instance object that mock.instances is supposed to hold. Before this PR there was no obvious hook; now there is, and it's two lines away from where the value would be pushed. Flagging it as a "while you're here" follow-up is reasonable; landing it separately is equally fine.

Step-by-step proof

  1. const fn = jest.fn();
  2. const inst = new fn(); — enters jsMockFunctionConstruct (line 989), allocates thisObject (lines 1003–1005), calls jsMockFunctionCallImpl with thisValue = thisObject.
  3. jsMockFunctionCallImpl pushes onto fn->calls, fn->contexts, fn->invocationCallOrder, and fn->returnValues (lines 854–910). It never touches fn->instances.
  4. jsMockFunctionConstruct returns thisObject (line 1015). fn->instances is still unset.
  5. fn.mock.instances triggers getInstances() (line 420), which lazily creates an empty array. Result: [].
  6. In Jest the same code yields [inst].

Suggested follow-up fix

In jsMockFunctionCallImpl, right next to the existing contexts push (lines 868–880), add an analogous block that pushes thisValue onto fn->instances. That matches Jest exactly: Jest records this into mock.instances on every call (for plain calls it's the receiver, for new it's the freshly created instance), so doing it only in jsMockFunctionConstruct would still diverge for non-construct calls. Doing it in the shared impl covers both, and the new construct path already threads the right thisObject through as thisValue.

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