fix(object): resolve the array forwarding chain before reading a header in Object.* (#7548) - #7551
Conversation
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (6)
📝 WalkthroughWalkthroughArray object operations now resolve stale array pointers through GC forwarding chains before reading array headers. Runtime and gap tests cover array property operations after growth, including freeze, seal, length updates, Proxy mutators, enumerability, keys, descriptors, and serialization. ChangesGrown array object operations
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant ArrayObjectOps
participant ForwardingChain
participant CurrentArray
ArrayObjectOps->>ForwardingChain: resolve stale array pointer
ForwardingChain->>CurrentArray: follow forwarding chain
CurrentArray-->>ArrayObjectOps: return current array header
ArrayObjectOps->>CurrentArray: process array properties
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/perry-runtime/src/object/array_object_ops.rs`:
- Around line 84-93: Unify the identity used by array attribute storage and
element-write lookup in the relevant array operation, using the
forwarding-resolved allocation from array_header or clean_arr_ptr consistently
instead of retaining the caller’s stale address. Update the descriptor-flag path
so grown frozen arrays are recognized by indexed writes, and add a regression
covering grow, freeze, then Reflect.set on index "0", asserting it returns false
and leaves the element unchanged.
- Around line 179-181: Update the array length-setting flow around
array_header_mut and js_number_coerce: root the receiver and descriptor before
coercion, then re-resolve the receiver after coercion and call array_header_mut
immediately before reading old_len or mutating the array. Do not retain raw
pointer locals across user-code-invoking operations, and add a regression
covering a length descriptor whose valueOf grows the target array.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: b34cc455-5fd0-47c8-b2b7-326332d9ba43
📒 Files selected for processing (4)
changelog.d/7551-array-forwarding-stub-object-ops.mdcrates/perry-runtime/src/object/array_object_ops.rscrates/perry-runtime/src/object/tests.rstest-files/test_gap_7548_grown_array_object_ops.ts
…er in Object.* (#7548) `js_array_grow` reallocates an array's header+elements as one allocation and leaves a #233 forwarding stub at the old address — and the stub's first 8 bytes are exactly where `length` and `capacity` live, so they read back as the two halves of the forwarding POINTER. The array branches of `Object.*` reinterpreted the caller's pointer with a bare `obj as *ArrayHeader` cast, so any JS binding still holding an array's pre-grow address made `(*arr).length` return a heap address: 615,098,568 instead of 6 in the observed case. `is_array_object` cannot tell a stub apart — it keeps `obj_type == GC_TYPE_ARRAY`, and only the `GC_FLAG_FORWARDED` bit plus the clobbered payload distinguish it — so the bad pointer sailed through every guard. Two loops are driven by that length and became bounded-but-unreachable walks, one `to_string()` plus an attrs side-table probe per index: * `mark_all_array_props` — `Object.freeze` / `Object.seal` of any array that has ever outgrown its dense capacity. `[1,2]; t.push(3); Object.freeze(t)` never returns. * `array_set_length_from_descriptor` — ArraySetLength's shrink walk, reached by the `Set(receiver, "length", n)` tail of an `Array.prototype.splice` that grows a Proxy receiver. This is the reported #7548 timeout in `test_gap_6908_proxy_array_mutators.ts`: the mutator's element writes all completed, and it was the final length write that walked. The hang is NOT infinite — it is a bounded loop over ~6·10^8 iterations, which the harness's 10 s budget cannot distinguish from non-termination. Fix: one `array_header` / `array_header_mut` helper that walks the forwarding chain (via `clean_arr_ptr`) before the cast, applied at all four header casts in `array_object_ops.rs`. It falls back to the raw cast when the chain does not resolve, so no caller loses a pointer it previously accepted. Deliberately NOT changed: the `obj as usize` side-table keys. The array attrs table is keyed inconsistently across the runtime — `getOwnPropertyDescriptor` reads at the caller's (possibly pre-grow) address while the element-write rejection path resolves through `clean_arr_ptr` first. Measured both ways; re-keying only these writers regressed `getOwnPropertyDescriptor` on a grown frozen array without gaining the write rejection. Unifying the readers is a separate change. Root cause predates the gap test: all four bare casts were already present at d255ae6 (#7424), which added `test_gap_6908_proxy_array_mutators.ts` — the test has been timing out since the day it landed. The casts themselves date to #4709 (2026-06-06, ArraySetLength) and #5025 (2026-06-11, freeze/seal on arrays) and were never touched since. Validation - `test_gap_6908_proxy_array_mutators.ts`: byte-identical to node 26.5.1, exit 0 (was exit 124 / 5 of 25 lines). - New `test_gap_7548_grown_array_object_ops.ts`: byte-identical, exit 0; the pristine arm hangs on it with zero output. - New `stale_pre_grow_array_pointer_reads_the_real_length_in_object_ops` unit test is sabotage-tested — reverting `array_header` to the bare cast fails it in 0.00 s with `left: 8913048 right: 17`, and it asserts non-vacuity (the stub's length word must actually differ from the real length). - `cargo test -p perry-runtime --no-fail-fast`: 1798 passed, 0 failed. - Targeted gap sweep (167 tests touching Object.freeze/seal/defineProperty/ getOwnPropertyDescriptor/Reflect/Proxy/splice/push/unshift), A/B'd against a pristine build in its own target dir: no regressions; the only diff, `test_gap_2159_defineproperty_class_prototype`, is identical in both arms and already tracked in gap_snapshot.json + known_failures.json. - Gates: raw_handle_debt 999 (baseline 999), check_file_size OK, addr_class_inventory passed, cargo fmt --all --check clean. Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH
7048c76 to
a184d0f
Compare
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
Fixes #7548.
What was wrong
js_array_growreallocates an array's header+elements as one allocation and leaves a #233 forwarding stub at the old address. The stub's first 8 bytes are exactly wherelengthandcapacitylive, so they read back as the two halves of the forwarding pointer.The array branches of
Object.*reinterpreted the caller's pointer with a bareobj as *ArrayHeadercast. Any JS binding still holding an array's pre-grow address therefore made(*arr).lengthreturn a heap address — 615,098,568 instead of 6 in the observed case (capacityread back as1273, i.e. the two words together were a ~4.9 TB mimalloc address).is_array_objectcannot tell a stub apart: it keepsobj_type == GC_TYPE_ARRAY, and only theGC_FLAG_FORWARDEDbit plus the clobbered payload distinguish it. So the bad pointer sailed through every guard.Two loops are driven by that length and became bounded-but-unreachable walks — one
to_string()plus an attrs side-table probe per index:mark_all_array_propsObject.freeze/Object.sealof any array that has ever outgrown its dense capacity.const t=[1,2]; t.push(3); Object.freeze(t)never returns.array_set_length_from_descriptorSet(receiver,"length",n)tail of anArray.prototype.splicethat grows a Proxy receiver — the reported timeout.The hang is not infinite. It is a bounded loop over ~6·10^8 iterations; the harness's 10 s budget simply cannot distinguish that from non-termination. Getting this right mattered: the fix for "slow" and the fix for "non-terminating" are different, and pattern-matching on "trap re-entering itself" would have been wrong — the mutator's element writes all completed, and it was the final length write that walked.
Narrowing
test_gap_6908_proxy_array_mutators.tsstops aftersort-cmp, i.e. in the firstspliceblock, not the dense/object-like section the issue guessed. Tracingproxy_set_str_keyshowed all five element writes completing and the trailingSet(proxy,"length",6)never returning. That reduced to a repro with nosplice, no mutator, and noProxyat all:and then to the strictly worse
Object.freezecase above, which needs nodefinePropertyeither.Fix
One
array_header/array_header_muthelper that walks the forwarding chain (viaclean_arr_ptr) before the cast, applied at all four header casts inarray_object_ops.rs. It falls back to the raw cast when the chain does not resolve, so no caller loses a pointer it previously accepted.Deliberately not changed: the
obj as usizeside-table keys. The array attrs table is keyed inconsistently across the runtime —getOwnPropertyDescriptorreads at the caller's (possibly pre-grow) address, while the element-write rejection path resolves throughclean_arr_ptrfirst. I measured both ways: re-keying only these writers regressedgetOwnPropertyDescriptoron a grown frozen array without gaining the write rejection. Unifying the readers is a separate change; see "Adjacent findings".Sibling mutators on a Proxy receiver
The discriminator is not the mutator — it is whether the mutator writes an index at or beyond the receiver's dense capacity and then writes
length.push,unshift(always grow)pop,shift(shrink)splicewhen inserts > deletesspliceremove-only / equal-countsplice(len, 0, x)(pure append)reverse,sort,fill,copyWithin(never grow)reverse/fill/copyWithin— the other mutators #7424 touched — are clean in every form, including their.callforms.Which commit
There is no bisectable regression commit — the test never passed. I built
perryatd255ae604(#7424, the PR that addedtest_gap_6908_proxy_array_mutators.ts) from an exported source tree in its own target dir, with the same-pset, and ran the test that commit ships: exit 137 after the identical five lines, last linesort-cmp: 2,4,10,33. It was broken on arrival, and no per-PR job could report it becauseparityis gated to tag pushes.All four bare casts are present verbatim at that commit. They date to #4709 (2026-06-06, ArraySetLength) and #5025 (2026-06-11, freeze/seal on arrays) and were never touched since — so the
Object.freeze-on-a-grown-array hang has been shipping for two months, entirely independent of any Proxy work.Validation (local — CI is 130+ deep and may not run this)
test_gap_6908_proxy_array_mutators.ts: byte-identical to node 26.5.1, exit 0 (was exit 124 after 5 of 25 lines).test_gap_7548_grown_array_object_ops.ts: byte-identical, exit 0. The pristine arm hangs on it with zero output.stale_pre_grow_array_pointer_reads_the_real_length_in_object_opsis sabotage-tested: revertingarray_headerto the bare cast fails it in 0.00 s withleft: 8913048 right: 17. It asserts non-vacuity too (the stub's length word must actually differ from the real length), and asserts the header read before the walks, so a regression fails fast instead of hanging the suite.cargo test -p perry-runtime --no-fail-fast: 1798 passed, 0 failed, 3 ignored.Object.freeze|seal|defineProperty|getOwnPropertyDescriptor|preventExtensions|isFrozen|isSealed,Reflect.defineProperty|set|getOwnPropertyDescriptor,new Proxy,propertyIsEnumerable,.splice(,.push(,.unshift(— A/B'd against a pristine build in its own target dir. 159 PASS, 5 DIFF, 3 NODE_FAIL, 0 TIMEOUT — and every one of the 5 diffs is byte-identical between the two arms, so zero regressions. Two are already tracked (test_gap_2159_defineproperty_class_prototypeingap_snapshot.json+known_failures.json;test_gap_diagchannel_3082_3084_3085_3086inknown_failures.json); the other three are host-local flakes unrelated to this change (test_gap_http_overloads_3226pluspanics identically in both arms atcrates/perry-ext-http/src/server/server.rs:911,test_gap_zlib_3285_paramsandtest_gap_stream_tee_tick_parityproduce identical output in both arms).raw_handle_debt.py→ 999 (baseline 999);check_file_size.shOK;addr_class_inventory.pypassed;cargo fmt --all -- --checkclean.Adjacent findings (not fixed here, reported for triage)
getOwnPropertyDescriptorreads at the caller's address; the element-write rejection path reads at theclean_arr_ptr-resolved address. Consequence: after this PR,Object.freezeon a grown array terminates and reports correct descriptors, but the element write is still not rejected (t[0]=99lands). That was previously invisible because the freeze hung. Strictly better than a hang, still not node-identical.Array.prototype.push.call(proxy, …)and.unshift.call(proxy, …)silently no-op. Verified identical on the pristine arm, so pre-existing and unrelated.getOwnPropertyDescriptorloses an accessor defined at an index past capacity (typeof d.getisundefined, node saysfunction) — the mirror of finding 1, from the accessor branch's existing canonical re-keying. Also identical on the pristine arm.Summary by CodeRabbit
Bug Fixes
Tests