fix(gc): keep live array elements traced and initialize unused array capacity - #7138
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
🚧 Files skipped from review as they are similar to previous changes (3)
📝 WalkthroughWalkthroughThis change hardens array GC behavior. New capacity slots are initialized with ChangesGC array soundness hardening
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related issues
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 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 |
…k desync Two tests for the invariant PerryTS#7138 restores: * `test_pointer_store_into_retained_mask_restores_side_mask` -- the invariant itself: recording a pointer into an EXISTING element mask must restore SIDE_MASK, or the mask bit is written into a table `heap_payload_slot_selection` will never read. * `test_retained_mask_pointer_elements_survive_copying_minor` -- the same store driven end to end through a real evacuating young-gen minor. It asserts liveness first (`copied_objects > 0`, array relocated) so an inert collection cannot produce a green cell, then asserts both masked children were evacuated and their slots rewritten, then reads a field back off each relocated child. The fixture builds the desynced state with one `set_layout_state` call. That is what the collector observes and the only input the fix reads; every in-crate writer that sets POINTER_FREE today also drops the mask entry, so no single call sequence in this crate reproduces it -- the production occurrences (202 within-length offenders under PERRY_GC_FROMSPACE_SCAN) come from address-keyed side-table reuse. New file rather than an addition to gc/tests/layout_trace.rs, which is already 2081 lines and over the 2000-line CI cap. Claude-Session: https://claude.ai/code/session_018ZFER8EEg8K7ez2n6oDrT9
…elog fragment test-files/test_gap_gc_array_truncate_append.ts is the TypeScript-level shape behind PerryTS#7138: an array holding pointer elements is truncated or overwritten down to a numeric/empty prefix -- which flips its GC layout to "pointer free" -- and then has pointers appended back into it. Five functions cover `length = 0`, truncation to a numeric prefix, pop/shift/splice, callable elements (the "value is not a function" failure by name), and a long-lived array that survives many collections. Each one re-reads and USES the element after further allocation churn, so a collection can land in the window. Registered in test-parity/gc_repsel_corpus.txt so it runs on every arm of scripts/gc_repsel_matrix.sh, including the arms that relocate survivors. Sized to be LIVE (~1.5M escaping churn allocations, same budget as test_gap_repsel_gc_stress) rather than inert -- a corpus row that drives zero collections certifies nothing (PerryTS#6942/PerryTS#6946/PerryTS#6950). Also renames the fragment to the PR-keyed convention documented in changelog.d/README.md (`<PR>-<slug>.md`). Content unchanged. Claude-Session: https://claude.ai/code/session_018ZFER8EEg8K7ez2n6oDrT9
proggeramlug
left a comment
There was a problem hiding this comment.
Thanks — this is a real soundness fix and it lands as-is. I've pushed the missing regression coverage on top of your commit (your commit is untouched) and I'm merging.
On the fix itself
Correct, and correct for the reason you give: recording a pointer proves the object is not pointer-free, and heap_payload_slot_selection skips the whole payload on POINTER_FREE without ever consulting the mask, so a populated mask under that state is not a cosmetic desync — it is an invisible payload. The sibling branch (no mask yet, state POINTER_FREE) already restored SIDE_MASK; this one just didn't. Strictly conservative, as you say: it can only add tracing.
I also checked the one interaction that could have made it non-orthogonal — your new set_layout_state(header, GC_LAYOUT_SIDE_MASK) against the #6893 typed-layout eviction machinery (layout_set_typed_unknown is one-way, GC_OBJ_TYPED_LAYOUT_INTACT is bit 12 of GcHeader._reserved). It is orthogonal, three ways: set_layout_state masks only GC_LAYOUT_STATE_MASK | GC_LAYOUT_ALL_POINTERS (0xE000), so bit 12 is preserved and can never be set by it; an evicted object has state GC_LAYOUT_UNKNOWN, which layout_note_slot early-returns on well before your branch; and eviction also removes the object's LAYOUT_SLOT_MASKS entry, so the masks.get_mut(..) guard your code sits behind is None for exactly the objects eviction has claimed. Lock discipline is likewise unchanged: your call sits inside the same LAYOUT_SLOT_MASKS.with(|m| { let mut masks = m.borrow_mut(); … }) borrow as the pre-existing set_layout_state in the sibling else if branch, and set_layout_state is a plain _reserved bit write that takes no lock and touches no thread-local, so there is no re-entrant borrow and no double-take of the global side-table lock.
Your beyond-length finding — you reproduced an established result
This is the part worth your time, because it's context you couldn't have had from the issue tracker alone.
Your second finding — 21,888 remaining scan offenders at element indices ≥ length, and the observation that a diagnostic which evacuated them turned the crash into a SIGSEGV — independently reproduces the fromspace-scan instrument's own validation from #7050. Measured there by extending the scan with two extra axes (is the offender's owner itself GC_FLAG_FORWARDED, and is the offending slot inside visit_gc_rewrite_slots for that owner), on every cycle of every run:
[gc-fromspace-scan OFFENDERS] missing_rewrites=2054 dangling=0 owners=3 | never_dirty=6 lost_dirty=0 dirty_but_missed=2048
[... split] live_owner_refs=6 (enumerated=0 not_enumerated=6) live_owners=2 stub_owner_refs=2048 stub_owners=1 stub_forwarding_words=0
enumerated=0 throughout. 99%+ of those offenders are (1) payload words of GC_FLAG_FORWARDED array growth stubs (#233) — dead storage by construction, only word 0 is live — and (2) uninitialized bytes inside an array's unused capacity: arena blocks are recycled rather than zeroed and move_young copies the whole payload, so a len=1 cap=16 array carries 15 slots of the previous occupant's bytes into to-space, complete with plausible-looking GcHeaders and forwarding addresses. That is exactly why evacuating them SIGSEGVs — they aren't references, they're garbage that happens to decode. The consequence recorded in #7050 is that "PERRY_GC_FROMSPACE_SCAN → zero offenders" is not an attainable acceptance criterion and never was; what is zero on every cycle is the population that would be a genuine collector defect (live owner, enumerated slot).
So your conclusion is the same one that campaign reached, arrived at independently: fix it at the producers, never by tracing more in the collector. Keeping length in sync with the filled/live extent and clearing vacated tail slots in pop / shift / unshift is the right shape — js_array_pop_f64 today just decrements length and leaves the slot bits in place, which is precisely the producer half of what you measured. That follow-up is real, wanted, and yours if you want it.
What I added on top
Your PR shipped fix + changelog only, so on top of your commit:
-
crates/perry-runtime/src/gc/tests/layout_pointer_free_mask.rs— two tests.test_pointer_store_into_retained_mask_restores_side_maskpins the invariant;test_retained_mask_pointer_elements_survive_copying_minordrives the same store end to end through a real evacuating young-gen minor, asserts liveness first (copied_objects > 0, array actually relocated) so an inert collection can't produce a green cell, then asserts both masked children were evacuated and their slots rewritten, then reads a field back off each relocated child.Verified red against the unfixed runtime — your commit reverted locally, separate
CARGO_TARGET_DIRper arm, test binaries hashed and confirmed different:test_pointer_store_into_retained_mask_restores_side_mask ... FAILED left: Some(0) right: Some(2) test_retained_mask_pointer_elements_survive_copying_minor ... FAILED slot 0 still points at the from-space child: the minor skipped this array's payload, so the live child was never evacuated (#7138) left: 2199037411368 right: 2199037411368Both pass with your fix. Note the second one reaches the collector assertion on the unfixed arm — i.e. the copying minor ran and moved objects in both arms, so the red is the payload being skipped, not an inert collector.
-
test-files/test_gap_gc_array_truncate_append.ts, registered intest-parity/gc_repsel_corpus.txt— the TypeScript-level truncate-then-append shape (length = 0, truncation to a numeric prefix,pop/shift/splice, callable elements, and a long-lived array), sized to be GC-live so the relocating arms ofscripts/gc_repsel_matrix.sharen't inert against it. -
Renamed the changelog fragment to the PR-keyed convention (
changelog.d/7138-gc-pointer-free-mask-invariant.md); content is yours, unchanged.
Gates (run locally on the pinned macOS arm64 host — the CI runner backlog is hours deep)
scripts/gc_repsel_matrix.sh --arms all --pressure 8, 24 corpus files × 20 arms = 480 cells:
byte-exact vs node 26.5.1: 479/480 cells
summary: PASS=379 UNVER=100 XFAIL=1 FAIL=0
FAIL=0, and the single XFAIL is the pre-existing repsel_ptr_shape_locals × rep_ptr_shape_off entry (#6976). Liveness holds — every requires=move arm reports copy-minor 23/24, every requires=scavenge arm 14/24. The new row is PASS on all 20 arms, UNVER on none, and it is the most GC-live member of the corpus:
default cycles=196 scavenged=8 756 303
evac_minor cycles=149 scavenged=8 682 359
cons_scan_off cycles=148 scavenged=8 682 359
shipped_default cycles=274 scavenged=12 766 892 <- no pressure knob, no GC env at all
main moved three corpus rows underneath this PR while it was open, so the matrix was re-run on the merged state (current main + this PR, 27 files × 21 arms = 567 cells):
byte-exact vs node 26.5.1: 566/567 cells
summary: PASS=447 UNVER=119 XFAIL=1 FAIL=0
Still FAIL=0, same single pre-existing XFAIL, new row PASS on all 21 arms, and main's three new rows — including gc_string_literal_operand_rooting (#7114), the other evacuation-sensitive one — are unaffected by the fix.
gc-ratchet (measure --repeats 7 then check), against the pinned darwin-arm64 baseline 88dcee83b, on the same host it was captured on:
--profile shared_ci→ OK, exit 0--profile pinned_host→ OK, exit 0 (this profile additionally gates RSS and wall time)
Gated columns (heap_used/heap_total/minor_cycles/step_cycles/copied_/promoted_/freed_bytes, 72 rows) are +0.00% on retention and cycles and within ±0.06% on the evacuation counters. That is the right answer, but bit-identical gated counters are also exactly what a vacuous measurement looks like, so the ungated columns are the proof a fresh binary was actually measured: rss_bytes −0.28%…+1.48%, wall_ms −13.17%…+3.61% across the 8 probes. Runtime archives rebuilt via -p perry -p perry-runtime-static -p perry-stdlib-static with .a mtimes confirmed to have moved (the crates are rlib-only, so building them alone links a stale archive and makes the whole measurement vacuous).
cargo test --release -p perry-runtime --lib -- --test-threads=1: 1576 passed, 0 failed, 3 ignored — including both new tests. (Worth knowing if you run the suite locally: in the dev profile it aborts on gc::tests::shadow_stack_ops::out_of_range_frame_pop_is_ignored, because js_shadow_frame_pop's corrupted-handle guard uses debug_assert!(false, …) inside an extern "C" fn — a non-unwinding panic, so SIGABRT. Pre-existing on main, invisible to CI because CI runs --release, where debug_assert! compiles out. Nothing to do with this PR; use --release.)
cargo fmt --all -- --check clean. lint's other gates (check_file_size.sh, gc_store_site_inventory.py, addr_class_inventory.py) are red — but they are red with identical offender counts on pristine origin/main (16 oversize files, 17 store sites, 4 addr-class sites), and none of the offenders is in a file this PR touches. Pre-existing repo debt, not introduced here.
One note on the fixture: it builds the desynced state with a single set_layout_state call rather than replaying a producer sequence. That's deliberate and documented in the module header — every in-crate writer that sets POINTER_FREE today also drops the LAYOUT_SLOT_MASKS entry, so no single call sequence in this crate reproduces it; the masks table is keyed by address, so the 202 occurrences you measured come from side-table reuse. The invariant under test ("a populated mask entry must never be left under POINTER_FREE") is address-local and doesn't depend on how the state got there, and pinning the test to one speculative producer route would have made it weaker.
Thanks again — good find, good writeup, and the draft framing (partial fix, named remaining cause, explicit "do not fix this in the collector") is exactly the right way to hand off a partially-understood soundness bug.
48a0a74 to
a24e70b
Compare
…xisting element mask layout_note_slot's pointer branch set the mask bit but left a stale POINTER_FREE layout state in place. heap_payload_slot_selection treats POINTER_FREE as pointer-free and skips the ENTIRE payload without ever consulting the mask, so the evacuating young-gen minor never traced the recorded pointer elements and reclaimed/relocated the live child out from under the slot -> 'TypeError: value is not a function' when later called. Same class as PerryTS#6831. Recording a pointer proves the object is not pointer-free, so restore SIDE_MASK. Adds a regression test and a changelog fragment. Verified: PERRY_GC_FROMSPACE_SCAN within-length stale array->young edges drop 202 -> 0; cargo test -p perry-runtime green.
…ray_grow The [length, capacity) slack was left as raw arena bytes; stale pointer-shaped bits there are followed as live pointers by any beyond-length scan/trace (SIGSEGV if evacuated) and flood PERRY_GC_FROMSPACE_SCAN with false positives (~4000/cycle). HOLE-initialize the slack so it is a non-pointer sentinel, matching js_array_alloc_with_length.
a24e70b to
e047966
Compare
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
crates/perry-runtime/src/array/alloc.rs (1)
116-137: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winInitialize
js_array_alloc_with_lengthcapacity slack.For
capacity < MIN_ARRAY_CAPACITY, this function allocatesactual_capacityslots but only writes0..capacity. A smallnew Array(n)withn < 16, smallArray.frommaterialization targets, smallspliceremoved arrays, and small sort scratch buffers can therefore leave raw arena bytes in[length, actual_capacity)while the constructor path marks the array as raw-f64 holes. Matchjs_array_allocand writeTAG_HOLEthroughactual_capacity.🤖 Prompt for 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. In `@crates/perry-runtime/src/array/alloc.rs` around lines 116 - 137, Update js_array_alloc_with_length to initialize every allocated element slot through actual_capacity, not just the requested capacity. Keep length set to capacity while writing TAG_HOLE across the full slack range, matching js_array_alloc and preserving the existing layout initialization.
🤖 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.
Outside diff comments:
In `@crates/perry-runtime/src/array/alloc.rs`:
- Around line 116-137: Update js_array_alloc_with_length to initialize every
allocated element slot through actual_capacity, not just the requested capacity.
Keep length set to capacity while writing TAG_HOLE across the full slack range,
matching js_array_alloc and preserving the existing layout initialization.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 77001956-d8b1-436e-b01e-2bf3221cb704
📒 Files selected for processing (5)
changelog.d/gc-pointer-free-mask-invariant.mdcrates/perry-runtime/src/array/alloc.rscrates/perry-runtime/src/array/push_pop.rscrates/perry-runtime/src/gc/layout.rscrates/perry-runtime/src/gc/tests/layout_trace.rs
Two ways the garbage collector can mishandle arrays, both fixed here. The first one loses data: an array holding object references can have those references silently dropped during a collection, and the next read of that element gets whatever now sits at that address. In practice the program dies later with
TypeError: value is not a function, at a place that has nothing to do with the array. The second one reads memory that was never written: the unused slack at the end of an array was left as raw uninitialized bytes, which the collector could mistake for pointers and follow.Both were found while investigating a crash in a downstream command-line application. Each fix stands on its own, is independently correct, and is tested. Neither one fixes that crash; its root cause is a separate bug, described at the bottom.
Background: how the collector decides what to scan in an array
Perry's collector is generational and moving. Newly allocated objects live in a young generation, and a minor collection walks only that generation, copying whatever is still reachable somewhere else and reclaiming the rest. Copying is what makes a missed reference fatal rather than merely wasteful: an object that the collector never visits does not get its new address recorded, so any reference to it becomes a pointer to memory that has been reused.
To avoid scanning every slot of every array, each object carries a layout state describing where its pointers are.
POINTER_FREEmeans the payload holds no pointers at all, so the collector can skip it entirely.SIDE_MASKmeans the object has a side table, a bitmask marking which element indices hold pointers, and the collector should consult that mask. Those two states are the ones that matter below.Fix 1: recording a pointer restores the SIDE_MASK layout state
An array could end up in the
POINTER_FREElayout state while still carrying a populated element pointer mask. One way to get there is a numeric-layout rebuild that observed a short or empty prefix and setPOINTER_FREE, while the previously populated mask lingered behind it.The two disagree, and the code that reads them does not notice.
heap_payload_slot_selectionshort-circuits onPOINTER_FREEand skips the entire payload without ever consulting the mask. So the evacuating young-generation minor collection dropped live pointer elements that sit within the array'slength, and a later read of one of those elements returned a garbage pointer, which surfaces asTypeError: value is not a function.The fix is to close the disagreement at the point it is created.
layout_note_slotnow restoresSIDE_MASKwhenever it records a pointer into an array's existing element mask, because recording a pointer is itself proof that the object is not pointer-free.This is the same class of bug as #6831.
The diagnostic scan
PERRY_GC_FROMSPACE_SCANcounts stale references from an old object into the young generation. Within-length stale array-to-young edges go from 202 to 0 with this fix. The regression test istest_pointer_store_restores_side_mask_from_stale_pointer_free.Fix 2: initializing the unused capacity of an array
An array allocates room for more elements than it currently holds, so that appending does not reallocate every time. The region between
lengthandcapacityis slack, and it was being left as raw arena bytes, meaning whatever the allocator happened to hand back.Raw bytes can look like a pointer by coincidence. Any scan or trace that reads beyond
lengthfollows those bits as if they were a live reference, which segfaults if the address it lands on has been evacuated. Short of a crash, it floodsPERRY_GC_FROMSPACE_SCANwith false positives, roughly 4000 per collection cycle.js_array_allocandjs_array_grownow HOLE-initialize the[length, capacity)slack. HOLE is a non-pointer sentinel value, so the slack can no longer be mistaken for a reference. This matches whatjs_array_alloc_with_lengthalready did, and it is the same class as issue #323.Uninitialized-slack false-positive edges go from roughly 4000 per cycle to roughly 0.
Test runs
cargo test -p perry-runtimepasses the array suite 73 to 0 and the layout suite 78 to 0, rebased onorigin/main.The
gc::tests::teardown::map_set_*failures are pre-existing and order-dependent, and are unrelated to this change.The crash this does not fix
The
value is not a functioncrash that prompted this investigation has a separate root cause, and it is not addressed here.There, the evacuating minor collection drops an old object's
field[1]reference to a shared young function across a collection-cycle boundary. That is a remembered-set snapshot drop, in the same area as #5029, rather than an array-layout problem. The remembered set is the collector's record of which old objects point into the young generation, and an edge missing from it means the minor collection never scans that reference at all.The full hand-off, with evidence and the exact next diagnostic step, is pinned at #7154.
Summary by CodeRabbit
Bug Fixes
Tests