test(repsel): drive the #7480 element-shape revocation matrix through real mutators - #7608
Conversation
The shipped invariant's bulk-mutator test calls `rebuild_array_layout` directly, so it asserts the proxy rather than the subject: every mutator could stop reaching that funnel and the suite would stay green. That is CLAUDE.md's fourth way a gate cannot fail. Adds an end-to-end matrix that drives each mutation family through its real FFI entry point and asserts the documented verdict, plus the epoch move a consumer's hoisted guard actually reads. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
Three additions after sabotage-testing the first cut: - `reverse`/`sort`/`copyWithin` now assert the returned receiver IS the array that was proven. Without it, an implementation returning a fresh (trivially unproven) array would satisfy the revoke assertion while revoking nothing. - Builder cases: a spread clone must never inherit the source's proof identity, and `Array.from`-style builders may leave a result unproven but must never leave it proven at the wrong class. - The rebuild-regains-it case #7480 names as acceptance. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
|
Warning Review limit reached
Next review available in: 13 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. 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: defaults Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (3)
📝 WalkthroughWalkthroughThis test-only change adds an end-to-end element-shape revocation matrix. It exercises real array FFI mutators and builders, and verifies proof maintenance, epoch changes, fresh identities, structural revocation, and mixed-value rejection. ChangesElement-shape validation
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related PRs
🚥 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 |
…vokes through it Established by #7608's sabotage: removing the revoke here leaves the sort matrix test green -- sort's default path writes its rank permutation back through RootedArrayElems::set and revokes through the store funnel.
Audit before merge — verified, merged as v0.5.1347The unique-guard proof reproduces exactly: removing Full element-shape family 52/52 green, fmt/file-size/raw-handle (998) clean. Folded in at merge: the one-line doc fix the report flagged and correctly On the scope call: finding the assigned work already merged (#7496) and |
The first consumer of the per-array homogeneous element-shape invariant (#7496, matrix #7608), which landed with no consumer on purpose. `for (let j = 0; j < n; j++) sum += keep[j].v` gets a specialized clone behind a preheader guard on "this array holds the element-shape invariant at class C". The element read becomes a bare gep+load off a cached elements base and the field read a bare raw-f64 slot load; the generic body survives unchanged as the cold arm. Measured 41ms -> 13ms (3.15x), now at parity with node, at +0 bytes on a program with no qualifying loop. Revocation mechanism: restrict-the-body, enforced twice — by shape in the matcher (a single store-free `acc = <pure numeric>` statement) and by construction in the lowering, which scans every emitted block of the fast clone for a GC-unsafe call and branches unconditionally to the slow clone if one survived. Call-freeness is exactly the right property: every way to revoke the invariant (element store, length change, delete, defineProperty, prototype surgery) is a runtime call, and so is every allocation that could move the array. Failure mode: conservative, never unsound. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
The first consumer of the per-array homogeneous element-shape invariant (#7496, matrix #7608), which landed with no consumer on purpose. `for (let j = 0; j < n; j++) sum += keep[j].v` gets a specialized clone behind a preheader guard on "this array holds the element-shape invariant at class C". The element read becomes a bare gep+load off a cached elements base and the field read a bare raw-f64 slot load; the generic body survives unchanged as the cold arm. Measured 41ms -> 13ms (3.15x), now at parity with node, at +0 bytes on a program with no qualifying loop. Revocation mechanism: restrict-the-body, enforced twice — by shape in the matcher (a single store-free `acc = <pure numeric>` statement) and by construction in the lowering, which scans every emitted block of the fast clone for a GC-unsafe call and branches unconditionally to the slow clone if one survived. Call-freeness is exactly the right property: every way to revoke the invariant (element store, length change, delete, defineProperty, prototype surgery) is a runtime call, and so is every allocation that could move the array. Failure mode: conservative, never unsound. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
Closes the acceptance gap left by #7496: the element-shape invariant shipped with a revocation matrix that never drove a single mutator.
The hole
element_shape_tests.rshas one test for the whole bulk-mutator family:It calls
rebuild_array_layoutdirectly. The comment names seven mutators; the test exercises none of them. Every one could stop reaching that funnel and the suite would stay green — CLAUDE.md's fourth way a gate cannot fail, "the gate runs but its subject never did".That matters more here than for a normal invariant. The consumers #7480 sequences next (the #5093 versioned-loop clone, then element
Ptr<Shape>) hoist a guard out of the loop and emit unguarded reads inside it. A mutation family that silently stops revoking is not a slow path — it is a miscompile that reads aBthrough anA's layout.What this adds
crates/perry-runtime/src/array/element_shape_matrix_tests.rs— 25 tests, each driving a real FFI entry point against a proven array and asserting the documented verdict. Contract stated once at the top of the file: MAINTAIN / REVOKE / STRUCTURAL REVOKE.Beyond "the proof is gone", each revoke also asserts the global epoch advanced — that is the word a consumer's hoisted guard re-reads, so a revoke the epoch does not advertise is a revoke the consumer misses. And each family that is a permutation or same-class rewrite asserts it re-proves with a fresh identity, which separates a conservative revoke from genuine heterogeneity and stops a future "optimisation" from replacing a revoke with a no-op.
Two vacuity guards, both added after sabotage-testing the first cut caught nothing:
shaped()asserts the fixture is proven before every op, so a step that found nothing to revoke fails loudly instead of passing.reverse/sort/copyWithinassert the returned receiver is the array that was proven. Without this, returning a fresh (trivially unproven) array satisfies the revoke assertion while revoking nothing.Audit result: the invariant is sound
Every element-writing site was traced to a funnel. No unhooked path found — the shipped design holds.
js_array_set_f64js_array_push_f64js_array_set_f64_uncheckedjs_array_set_f64delete arr[i]js_array_deleteTAG_HOLEstorearr.length = n(both directions)js_array_set_lengthpopjs_array_pop_f64verified_lenmismatchshift/unshiftjs_array_{shift,unshift}_f64reversejs_array_reverserebuild_array_layoutsortjs_array_sort_defaultcopyWithinjs_array_copy_withinrebuild_array_layoutfill(whole)js_array_fillrebuild_array_layoutfill(range)js_array_fill_rangerebuild_array_layoutsplice, equal-length replacementjs_array_splicerebuild_array_layoutalonesplice(pure delete)js_array_splicejs_array_clone_for_spreadrebuild_array_layout_exactArray.fromfamilyjs_array_from_valuesTwo findings worth recording:
sort's default path does not userebuild_array_layout. It is a rank permutation written back throughRootedArrayElems::set, so it revokes via the store funnel. Verified by sabotage, not by reading: removing the revoke fromrebuild_array_layoutleaves the sort test green; removing it fromlayout_note_slotturns it red. Defence in depth, not redundancy — and not what the shipped comment claims.Equal-length
spliceis guarded by exactly one thing.arr.splice(1, 1, otherClassInstance)leaveslengthunchanged, so the structuralverified_lencheck cannot see it, and the inserted item is written with a bareptr::writethat never reaches the store funnel. Only splice's ownrebuild_array_layoutcatches it.Sabotage evidence
Each funnel was removed in turn and the suite re-run.
rebuild_array_layoutlayout_note_slotjs_array_spliceonlymatrix_splice_equal_length_replacement_revokesThe third is the load-bearing one: it shows that test is the unique guard for a case no other mechanism can catch. All sabotages reverted; the tree is clean.
Scope and cost
Test-only. The diff is one new
#[cfg(test)]file plus its#[cfg(test)] moddeclaration. No runtime code, no emitted code, no bookkeeping added — so standing cost onpush_cls/churn_alloc/churn_readis zero by construction, not by measurement, and there is no behaviour to A/B against the gap suite. The invariant's own maintenance cost was #7496's to account for.GC: unchanged, and the argument holds without new work. The record holds four plain integers and no heap pointer —
class_idis a registry index that is compared, never dereferenced — so it is correctly absent fromgc_register_mutable_root_scanner. Elementclass_ids live inObjectHeaders that move with their contents, and the record's address key is moved bytransfer_element_shapefrom insidelayout_transfer. The two shipped copying-minor tests ingc/tests/layout_trace/element_shape.rspass.Validation
Local, and stated plainly as such — CI's backlog is deep.
cargo test -p perry-runtime --no-fail-fast— 1880 passed, 0 failedcargo test -p perry-codegen --lib— greencargo fmt --all -- --check— cleanscripts/raw_handle_debt.py— 998 (baseline 998)scripts/addr_class_inventory.py— passedscripts/class_id_collisions.py— passedscripts/check_file_size.sh— passedRoot-dominance corpus not run: no codegen changes.
Follow-up worth filing separately:
rebuild_array_layout's doc comment listssortamong the mutators that revoke through it, which the sabotage disproves.Summary by CodeRabbit
Tests
Documentation