Wrap mock in GetterSetter for indexed-accessor spyOn#31619
Conversation
|
Updated 4:51 PM PT - May 30th, 2026
❌ @robobun, your commit b1d114b has 1 failures in 🧪 To try this PR locally: bunx bun-pr 31619That installs a local version of the PR into your bun-31619 --bun |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
WalkthroughModified ChangesIndexed Property Spy Installation
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
LGTM — brings the indexed-accessor branch into parity with the non-indexed branch right below it.
Extended reasoning...
Overview
This PR changes one line in src/jsc/bindings/JSMockFunction.cpp (the spyOn implementation) and adds a regression test to test/js/bun/test/mock-fn.test.js. When spyOn(obj, <numeric index>) hits the non-callable / missing-value branch, the code sets PropertyAttribute::Accessor and stores the property via putDirectIndex. Previously it stored the raw mock function; now it stores JSC::GetterSetter::create(vm, globalObject, mock, mock) — exactly what the adjacent non-indexed else branch already does via putDirectAccessor.
The fix is mechanically obvious: JSC requires that any sparse-array entry carrying the Accessor attribute hold a GetterSetter cell (since SparseArrayEntry::get/put unchecked-downcast it). The old code violated that invariant and tripped debug assertions on any subsequent read/write of the spied index. The new code matches the established pattern one branch down.
Security risks
None. This is test-runner mocking infrastructure (jest.spyOn / bun:test spyOn), not a production runtime path. There's no auth, crypto, permissions, or untrusted-input handling involved. The change strictly tightens an invariant that was previously violated.
Level of scrutiny
Low. The diff is a single-line substitution that mirrors the existing, correct code path immediately below it (putDirectAccessor(..., GetterSetter::create(...), attributes)). The function/data-property indexed branch above is intentionally left unchanged (it doesn't set Accessor, so storing the mock directly is correct there). The blast radius is limited to spyOn on numeric indices where the existing value is missing or non-callable — an edge case that previously crashed in debug builds.
Other factors
- Found by the fuzzer; the PR description includes a clear repro and explanation of the JSC invariant.
- A regression test is added covering read, sloppy write,
Reflect.set, andmockRestoreon the spied index. - The multi-agent bug hunting system found no issues.
- No CODEOWNERS entries cover this path.
- No outstanding reviewer comments.
spyOn(target, index) on a missing or non-callable indexed property stored the mock directly via putDirectIndex with the Accessor attribute bit set, without wrapping it in a JSC::GetterSetter. JSC's indexed-property read/write paths (SparseArrayEntry::get/put) treat an entry with the Accessor attribute as a GetterSetter and uncheckedDowncast it, so a subsequent read or write of the spied index cast a non-GetterSetter cell to a GetterSetter, tripping the asObject() isObjectSlow() assertion. Wrap the mock in a GetterSetter for the indexed path, matching the non-indexed putDirectAccessor path.
6a98cbc to
b1d114b
Compare
There was a problem hiding this comment.
LGTM — one-line fix that makes the indexed-accessor branch match the non-indexed branch directly below it, with a regression test.
Extended reasoning...
Overview
This PR changes a single line in src/jsc/bindings/JSMockFunction.cpp (the spyOn implementation) and adds a 15-line regression test to test/js/bun/test/mock-fn.test.js. When spyOn(obj, <numeric index>) is called on a property that has no callable value, the code installs the spy as an accessor property. Previously, the indexed-property branch passed the mock function directly to putDirectIndex while setting PropertyAttribute::Accessor, violating JSC's invariant that an accessor-flagged slot must hold a GetterSetter cell. The fix wraps the mock in JSC::GetterSetter::create(vm, globalObject, mock, mock) — exactly what the adjacent non-indexed else branch already does via putDirectAccessor.
Security risks
None. This is test-runner mock/spy infrastructure (bun:test's spyOn), not auth, crypto, network, or filesystem code. The change replaces an invalid downcast/assertion failure with the correct cell type; it cannot expose data or grant capabilities.
Level of scrutiny
Low-to-moderate. While the file is C++ JSC bindings, the change is mechanical: it makes one branch consistent with the branch immediately following it. The PR description clearly explains the JSC invariant being violated (SparseArrayEntry::get/put do uncheckedDowncast<GetterSetter> when the Accessor bit is set), and the fix is the obvious one. I also verified that mockRestore()/clearSpy() is unaffected: for missing properties spyAttributes is saved as 0 (no Accessor bit), so restoration writes a plain data property and doesn't need a GetterSetter.
Other factors
- No CODEOWNERS apply to these paths.
- No bugs were found by the bug hunting system.
- A regression test covers read, sloppy write,
Reflect.set, andmockRestore. - The duplicate-PR bot flagged #29481 which fixes the same assertion via a different strategy (stripping the
Accessorattribute). This PR's approach is preferable because it preserves the existing spy-on-non-callable-property semantics (spy fires on read), matching the established behavior for non-indexed properties tested elsewhere in the suite. Maintainers may want to close one of the two, but that's a coordination matter, not a correctness concern with this change.
What
spyOn(target, index)where the target has no callable value at that numericindex (e.g.
spyOn(obj, 1002)on a plain object, or a missing array index)stored the mock directly via
putDirectIndexwith thePropertyAttribute::Accessorbit set, instead of wrapping it in aJSC::GetterSetter:This violates a JSC invariant: an indexed property entry carrying the
Accessorattribute must hold aGetterSettercell.SparseArrayEntry::getand
SparseArrayEntry::putuncheckedDowncast<GetterSetter>the stored valuewhenever the
Accessorbit is set. So any later read or write of the spiedindex downcasts the non-
GetterSettermock, tripping theasObject()cell->isObjectSlow()assertion (JSObject.h:1346) — or, on the read path,the
PropertySlot::setValueassertion.The non-indexed branch immediately below already does the right thing:
putDirectAccessor(..., GetterSetter::create(vm, globalObject, mock, mock), attributes).Fix
Wrap the mock in a
GetterSetteron the indexed-accessor path too, so thestored value matches the
Accessorattribute. The value/function indexedbranch (non-accessor, plain data property) is unchanged.
Repro (before fix, debug build)
Found by the fuzzer.
Test
Added a regression test in
test/js/bun/test/mock-fn.test.jscovering read,write (sloppy +
Reflect.set), andmockRestoreof a spied missing indexedproperty. Verified it SIGABRTs on the pre-fix debug build and passes after.
Full
mock-fn.test.jssuite (73 tests) passes.