-
Notifications
You must be signed in to change notification settings - Fork 4.7k
Fix crash constructing jest.fn() via Reflect.construct #30212
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
920bc96
512bebd
0fb8200
daaa8d9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -86,6 +86,7 @@ inline To tryJSDynamicCast(JSC::WriteBarrier<WriteBarrierT>& from) | |
| } | ||
|
|
||
| JSC_DECLARE_HOST_FUNCTION(jsMockFunctionCall); | ||
| JSC_DECLARE_HOST_FUNCTION(jsMockFunctionConstruct); | ||
| JSC_DECLARE_CUSTOM_GETTER(jsMockFunctionGetter_protoImpl); | ||
| JSC_DECLARE_CUSTOM_GETTER(jsMockFunctionGetter_mock); | ||
| JSC_DECLARE_HOST_FUNCTION(jsMockFunctionGetter_mockGetLastCall); | ||
|
|
@@ -462,7 +463,7 @@ class JSMockFunction : public JSC::InternalFunction { | |
| } | ||
|
|
||
| JSMockFunction(JSC::VM& vm, JSC::Structure* structure, CallbackKind wrapKind) | ||
| : Base(vm, structure, jsMockFunctionCall, jsMockFunctionCall) | ||
| : Base(vm, structure, jsMockFunctionCall, jsMockFunctionConstruct) | ||
| { | ||
| initMock(); | ||
| } | ||
|
|
@@ -826,7 +827,7 @@ static JSValue createMockResult(JSC::VM& vm, Zig::GlobalObject* globalObject, co | |
| return result; | ||
| } | ||
|
|
||
| JSC_DEFINE_HOST_FUNCTION(jsMockFunctionCall, (JSGlobalObject * lexicalGlobalObject, CallFrame* callframe)) | ||
| static EncodedJSValue jsMockFunctionCallOrConstruct(JSGlobalObject* lexicalGlobalObject, CallFrame* callframe, bool isConstructCall) | ||
| { | ||
| Zig::GlobalObject* globalObject = uncheckedDowncast<Zig::GlobalObject>(lexicalGlobalObject); | ||
| auto& vm = JSC::getVM(globalObject); | ||
|
|
@@ -839,6 +840,26 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionCall, (JSGlobalObject * lexicalGlobalObje | |
|
|
||
| JSC::ArgList args = JSC::ArgList(callframe); | ||
| JSValue thisValue = callframe->thisValue(); | ||
|
|
||
| if (isConstructCall) { | ||
| JSValue newTarget = callframe->newTarget(); | ||
| if (JSObject* newTargetObject = newTarget.getObject()) { | ||
| JSGlobalObject* functionGlobalObject = getFunctionRealm(globalObject, newTargetObject); | ||
| RETURN_IF_EXCEPTION(scope, {}); | ||
| Structure* structure = InternalFunction::createSubclassStructure(globalObject, newTargetObject, functionGlobalObject->objectStructureForObjectConstructor()); | ||
| RETURN_IF_EXCEPTION(scope, {}); | ||
| thisValue = constructEmptyObject(vm, structure); | ||
| } else { | ||
| thisValue = constructEmptyObject(globalObject); | ||
| } | ||
| } | ||
|
Comment on lines
+844
to
+855
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟣 Pre-existing (not introduced by this PR): even with Extended reasoning...What the gap isIn the JSC::CallData callData = JSC::getCallData(result);
...
JSValue returnValue = Bun::call(globalObject, result, callData, thisValue, args);
In Jest, mock functions are ordinary JS functions whose body checks Concrete trigger / step-by-stepclass Foo { constructor() { this.x = 1 } }
const obj = { Foo };
jest.spyOn(obj, 'Foo');
new obj.Foo();
The same applies to Why nothing currently prevents itThe new Why this is pre-existingBefore this PR the construct entry point was the same Suggested fixInside SeverityPre-existing, follow-up. The PR is scoped to the Fuzzilli
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed — good catch. Invoking the implementation with [[Construct]] (via JSC::getConstructData/JSC::construct) when isConstructCall and the impl is constructible is the right follow-up, and this is the natural spot for it now that the flag and thisValue exist here. Keeping this PR scoped to the isCell() crash fix; the class-constructor spying gap and the mock.instances population can go together in a follow-up. |
||
|
|
||
| auto encodedReturnValue = [&](JSValue value) -> EncodedJSValue { | ||
| if (isConstructCall && !value.isObject()) | ||
| return JSValue::encode(thisValue); | ||
| return JSValue::encode(value); | ||
| }; | ||
|
|
||
| JSC::JSArray* argumentsArray = nullptr; | ||
| { | ||
| JSC::ObjectInitializationScope object(vm); | ||
|
|
@@ -954,22 +975,22 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionCall, (JSGlobalObject * lexicalGlobalObje | |
| fn->returnValues.set(vm, fn, returnValuesArray); | ||
| } | ||
|
|
||
| return JSValue::encode(returnValue); | ||
| return encodedReturnValue(returnValue); | ||
| } | ||
| case JSMockImplementation::Kind::ReturnValue: { | ||
| JSValue returnValue = impl->underlyingValue.get(); | ||
| setReturnValue(createMockResult(vm, globalObject, "return"_s, returnValue)); | ||
| return JSValue::encode(returnValue); | ||
| return encodedReturnValue(returnValue); | ||
| } | ||
| case JSMockImplementation::Kind::ReturnThis: { | ||
| setReturnValue(createMockResult(vm, globalObject, "return"_s, thisValue)); | ||
| return JSValue::encode(thisValue); | ||
| return encodedReturnValue(thisValue); | ||
| } | ||
| case JSMockImplementation::Kind::RejectedValue: { | ||
| JSValue rejectedPromise = JSC::JSPromise::rejectedPromise(globalObject, impl->underlyingValue.get()); | ||
| RETURN_IF_EXCEPTION(scope, {}); | ||
| setReturnValue(createMockResult(vm, globalObject, "return"_s, rejectedPromise)); | ||
| return JSValue::encode(rejectedPromise); | ||
| return encodedReturnValue(rejectedPromise); | ||
| } | ||
| default: { | ||
| RELEASE_ASSERT_NOT_REACHED(); | ||
|
|
@@ -978,7 +999,17 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionCall, (JSGlobalObject * lexicalGlobalObje | |
| } | ||
|
|
||
| setReturnValue(createMockResult(vm, globalObject, "return"_s, jsUndefined())); | ||
| return JSValue::encode(jsUndefined()); | ||
| return encodedReturnValue(jsUndefined()); | ||
| } | ||
|
|
||
| JSC_DEFINE_HOST_FUNCTION(jsMockFunctionCall, (JSGlobalObject * lexicalGlobalObject, CallFrame* callframe)) | ||
| { | ||
| return jsMockFunctionCallOrConstruct(lexicalGlobalObject, callframe, false); | ||
| } | ||
|
|
||
| JSC_DEFINE_HOST_FUNCTION(jsMockFunctionConstruct, (JSGlobalObject * lexicalGlobalObject, CallFrame* callframe)) | ||
| { | ||
| return jsMockFunctionCallOrConstruct(lexicalGlobalObject, callframe, true); | ||
| } | ||
|
|
||
| void JSMockFunctionPrototype::finishCreation(JSC::VM& vm, JSC::JSGlobalObject* globalObject) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟣 Pre-existing nit (not introduced by this PR): now that the construct path allocates a fresh
thisValue, this would be the natural place to also push it ontofn->instancesso thatmock.instancesis populated onnew fn()like Jest does — currentlyinstancesis exposed but never written to anywhere, so it always stays empty. Fine to leave for a follow-up since this PR is scoped to the crash fix.Extended reasoning...
What the gap is
JSMockFunctiondeclares amutable JSC::WriteBarrier<JSC::JSArray> instancesfield, lazily creates it ingetInstances(), clears it inclear(), visits it for GC, and exposes it asmock.instanceson the mock object structure. However, nothing injsMockFunctionCallOrConstruct(or anywhere else in the file) ever pushes into it. Grepping the file confirms the only references toinstancesare the declaration,clear(),getInstances(), the GC visitor, and themockObjectStructureslot — none of them are writes during invocation. Somock.instancesis always an empty array.In Jest,
mockFn.mock.instancesis "an array that contains all the object instances that have been instantiated from this mock function usingnew" — everynew fn()pushes the constructedthisonto it.Code path
In the new construct branch:
the freshly allocated
thisValueis later pushed ontofn->contexts(alongsidecalls,invocationCallOrder, andreturnValues), but there is no corresponding push ontofn->instances. The fix would be to add the same lazy-create-then-push pattern forfn->instancesinside theisConstructCallblock (or right aftercontextsis pushed, gated onisConstructCall), pushingthisValue.Why nothing currently prevents it
There simply is no write site. The infrastructure (storage, getter, GC visiting, mock-object slot) is all wired up, but the population step was never implemented.
Step-by-step proof
const fn = jest.fn();new fn();→ entersjsMockFunctionConstruct→jsMockFunctionCallOrConstruct(..., true).isConstructCallis true, sothisValue = constructEmptyObject(globalObject).calls,contexts,invocationCallOrderare pushed;returnValuesis pushed viasetReturnValue.instancesis not touched.fn.mock.instances→getInstances()lazily creates an empty array and returns it.expect(fn.mock.instances).toHaveLength(1)fails in Bun, passes in Jest.Why this is pre-existing
Before this PR,
instanceswas already never populated, andnew (jest.fn(() => ({})))()already worked without crashing (the impl returned an object, soasObject()succeeded), so the emptymock.instanceswas already observable. This PR is a narrow Fuzzilli crash fix and doesn't changeinstancesbehavior at all — it just adds the construct branch where the missing push would naturally live, which is why it's worth flagging here as a follow-up rather than a blocker.