Make mock functions return an object when used as a constructor - #33301
Make mock functions return an object when used as a constructor#33301robobun wants to merge 1 commit into
Conversation
JSMockFunction installed jsMockFunctionCall as both its call and its construct function, but that function returns whatever the mock implementation returns. A native [[Construct]] must always return an object: Interpreter::executeConstruct ends with asObject() and does not check the type, so Reflect.construct(mockFn, []) on a mock whose implementation returns a primitive asserted in debug builds and reinterpreted the primitive as a JSObject in release builds. Give JSMockFunction its own construct function. It builds `this` from new.target, runs the mock with it, and returns the implementation's result only when that result is an object, which is what an ordinary JS constructor does and what jest's mock functions do.
|
Warning Review limit reached
Next review available in: 7 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
No related open issues found. 🤖 Generated with Claude Code |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Confirming the bot above: there is no linked issue. This came out of the Fuzzilli crash corpus rather than a bug report, so there is no issue number to reference. That is also why the regression test went into One scope boundary worth calling out, since a reviewer will probably wonder: mock functions still have no own |
|
The bot is right, and #31955 is the one that should land. Closing this as a duplicate. #31955 fixes the same $ bun-debug -e 'const {isUtf8} = require("node:buffer"); Reflect.construct(isUtf8, [new Uint8Array([1])]);'
ASSERTION FAILED: isCell()
JavaScriptCore/JSCJSValue.h(1043) : JSCell *JSC::JSValue::asCell() const
$ bun-debug /tmp/ffi.js # Reflect.construct(ccCompiledFn, [1, 2])
call: 3
ASSERTION FAILED: isCell()
JavaScriptCore/JSCJSValue.h(1043) : JSCell *JSC::JSValue::asCell() constSame root cause, different spelling: Worth being straight about why I missed them: when I swept for sibling sites I only grepped the #31955's tests are broader than this PR's too (it also covers a throwing implementation, constructing a spy, and Nothing here is worth salvaging over #31955. Closing. |
There was a problem hiding this comment.
No bugs found and the fix looks correct, but this is native JSC [[Construct]] code with a small observable behavior change to mock.contexts, so worth a maintainer's eyes.
Extended reasoning...
Overview
This PR fixes a Fuzzilli-found assertion failure / release-build UB where JSMockFunction reused its call host function as its construct host function. Since a native [[Construct]] must return an object and the call path returns whatever the mock implementation returns (often a primitive), Reflect.construct(mock, []) hit asObject() on a non-object. The fix adds a dedicated jsMockFunctionConstruct that allocates the receiver via InternalFunction::createSubclassStructure, runs the mock with it as this, and returns the implementation's result only when it is an object — standard JS constructor semantics. The old body is factored into invokeMockFunction(..., thisValue) so the call path is byte-identical modulo thisValue sourcing. Comprehensive tests are added covering every primitive return type, object passthrough, this binding, mockReturnValue, and Reflect.construct with an explicit newTarget.
Security risks
None. This is test-runner-only surface (jest.fn / mock()), not reachable from untrusted network input. The change removes a reachable UB path rather than adding one.
Level of scrutiny
Moderate. The diff is small (~25 net new C++ lines) and follows the established createSubclassStructure + RETURN_IF_EXCEPTION pattern used elsewhere in src/jsc/bindings/. However, it is native JSC bindings code where GC and exception-scope mistakes are memory-safety bugs, and it intentionally changes observable behavior: mock.contexts now records the constructed instance for new calls instead of new.target. That aligns with jest, but it is still a user-visible semantic change to a public API that a maintainer should sign off on.
Other factors
The exception handling looks correct (RETURN_IF_EXCEPTION after createSubclassStructure and after invokeMockFunction), asObject(callframe->newTarget()) is safe since newTarget is always an object in a construct call, and result.isObject() correctly excludes null. The PR author already audited other InternalFunction subclasses for the same bug shape. Test coverage is thorough and the file is designed to run under jest/vitest too, which cross-checks the semantics. I'm deferring only because native [[Construct]] implementations plus a behavior change warrant a human maintainer's confirmation, not because I found anything wrong.
Fuzzilli found this crash:
Minimal reproduction:
What was wrong
JSMockFunctionis aJSC::InternalFunction, and it passedjsMockFunctionCallas both its call function and its construct function:jsMockFunctionCallreturns whatever the mock implementation returns, which is very often not an object (undefinedfor a freshmock(), the spied-on value forspyOn, whatevermockReturnValuewas given).A native
[[Construct]]must always return an object.JSC::Interpreter::executeConstruct(reached fromReflect.construct,JSC::construct(), proxy construct traps) ends with:return asObject(JSValue::decode(result));There is no type check there, so a primitive return was an assertion failure in debug and a non-object cell reinterpreted as a
JSObject*in release. On the released build you could see it directly:The bytecode
newpath did not assert, but it was wrong too:new mock()returned the primitive instead of the new instance, sonew (mock(() => 42))()evaluated to42.The fix
JSMockFunctiongets its own construct function. It derivesthisfromnew.target(viaInternalFunction::createSubclassStructure, the same helper JSC's own constructors use), runs the mock with that as the receiver, and returns the implementation's result only when it is an object. Otherwise it returns the newly created object.That is what an ordinary JS constructor does, and it matches jest, whose mocks are plain JS functions:
Reflect.construct(mock(), [])new (mock())()undefinednew (mock(() => 42))()42new (mock(() => ({x:1})))(){x:1}{x:1}new (mock(function(){this.x=1}))()undefined{x:1}mock.contextsnow records the constructed instance fornewcalls rather thannew.target, which is also what jest records.I checked the other
InternalFunctionsubclasses in the tree for the same "call function doubles as the construct function" shape.JSBufferListConstructorandJSStringDecoderConstructordo reuse one function for both, but theirs always return an object, andv8::shim::ObjectTemplate's isASSERT_NOT_REACHED. None of the separateconstructXhost functions return a primitive, so this was the only instance.Tests
Added a
used as a constructorblock totest/js/bun/test/mock-fn.test.js, covering every primitive an implementation can return (includingundefined, symbols and bigints, which are the cells that tripped the original assertion), the object passthrough case,thisbinding,mockReturnValue, andReflect.constructwith an explicitnewTarget. That file is written to run under jest and vitest too, so the assertions are the cross-runtime behavior.10 of the 11 new assertions fail on the current release build and all pass with this change. The original Fuzzilli sample now exits cleanly, and a 2000-iteration construct +
Bun.gc(true)stress loop runs clean under the debug ASAN build.