PERRY_GC_VERIFY_EVACUATION=1 aborts on any PERRY_JSON_TAPE=1 workload that
walks a lazy array's sparse element cache. Found while validating #7499
(reparse-on-materialize); A/B'd and confirmed pre-existing on main, so it
is filed separately rather than folded into that PR.
Repro
// probe_gc2.ts — 60 rounds x 300 records
const N = 300;
const items: any[] = [];
for (let i = 0; i < N; i++) {
items.push({ id: i, name: "item_" + i, value: i * 3.14159,
tags: ["tag_" + (i % 10), "tag_" + (i % 5)],
nested: { x: i, y: i * 2 } });
}
const blob = JSON.stringify(items);
let acc = 0;
for (let round = 0; round < 60; round++) {
const a = JSON.parse(blob);
const mapped = a.map((r: any) => r.nested.x);
for (let i = 0; i < mapped.length; i++) acc += mapped[i];
const b = JSON.parse(blob);
for (let i = 0; i < b.length; i += 20) { b[i].name = "MUT_" + i; b[i].nested.x = 900 + i; }
const bcopy = [...b];
for (let i = 0; i < bcopy.length; i++) acc += bcopy[i].nested.x;
acc += JSON.stringify(b).length;
const c = JSON.parse(blob);
for (let i = 0; i < c.length; i++) acc += c[i].id;
acc += JSON.stringify(c).length;
}
console.log("acc:" + acc);
Compile with PERRY_GC_MOVING_LOOP_POLLS=1, then run:
PERRY_JSON_TAPE=1 PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 \
PERRY_GC_PROTECT_FROMSPACE_DEPTH=800 PERRY_GC_VERIFY_EVACUATION=1 \
PERRY_GC_DIAG=1 ./probe_gc2
Exit 134 (abort):
thread '<unnamed>' panicked at crates/perry-runtime/src/gc/verify.rs:524:5:
old-young-edge-verifier failed: checked_old_objects=128 checked_remembered_pages=165
checked_old_to_young_edges=8445 missing_edges=8205 malloc_parents=0 unmarked_parents=8205
first_missing: parent=0x2d63b498e80 type=lazy_array(9) old_arena=true marked=false
slot=0x2d63ed03630 child=0x2d63b2dc8d0 child_type=object(2)
slot_page_ever_dirty=false
missing_by_parent_type: lazy_array(9)=8205
missing_by_child_type: object(2)=8205
Without the flag the same binary exits 0 and prints the node-identical result, so
this is only observable through the verifier.
Where the edge comes from
Every missing edge has parent_type = lazy_array(9) and child_type = object(2), and the slot address is tens of MB away from the parent — it is not a
field of LazyArrayHeader itself but the sparse per-element cache,
materialized_elements, which alloc_lazy_array allocates as a separate arena
block (with GC_TYPE_STRING) and attributes to the header. lazy_get writes it
and does call the barrier:
// crates/perry-runtime/src/json_tape.rs, lazy_get
*cache.add(i as usize) = JSValue::from_bits(value_bits);
crate::gc::runtime_write_barrier_slot(hdr as usize, cache.add(i as usize) as usize, value_bits);
but the verifier reports slot_page_ever_dirty=false for those slots — the page
holding the cache was never dirtied. The barrier is passed parent = hdr while
the slot lives in a different allocation, so whatever generation test the
barrier makes is made about the header's page, not the cache's.
unmarked_parents == missing_edges on both arms, i.e. every flagged parent was
itself unmarked at verification time. That may make some or all of these
verifier false positives on dead lazy headers rather than live missing barriers —
worth settling first, because the two conclusions have opposite fixes (fix the
barrier vs. fix the verifier's parent filter).
Pre-existing: the A/B
Same probe, same compiler, same flags, two runtimes built in the same pipeline —
one with #7499's json_tape.rs + test file, one with only those two files
reverted to the merge-base:
| arm |
result |
missing_edges |
first_missing shape |
| merge-base (no #7499) |
abort 134 |
8205 |
lazy_array(9) → object(2), slot_page_ever_dirty=false |
| with #7499 |
abort 134 |
10095 |
identical shape |
Same defect on both; the count differs only because the two arms generate
different amounts of lazy-cache traffic and promotion.
Why it matters beyond the flag
An old→young edge missing from the remembered set is exactly the shape CLAUDE.md
records as "JSON.parse filled born-old arrays through this helper, the children
were swept live on a later minor → value is not a function". If these parents
are genuinely live, this is a live-object loss waiting for the right timing; if
they are not, PERRY_GC_VERIFY_EVACUATION=1 is unusable on any tape workload,
which costs us the instrument.
Either way PERRY_GC_VERIFY_EVACUATION=1 currently cannot be run over
PERRY_JSON_TAPE=1 code, which is a gap in the GC instrument coverage for the
whole lazy-tape subsystem.
PERRY_GC_VERIFY_EVACUATION=1aborts on anyPERRY_JSON_TAPE=1workload thatwalks a lazy array's sparse element cache. Found while validating #7499
(reparse-on-materialize); A/B'd and confirmed pre-existing on
main, so itis filed separately rather than folded into that PR.
Repro
Compile with
PERRY_GC_MOVING_LOOP_POLLS=1, then run:Exit 134 (abort):
Without the flag the same binary exits 0 and prints the node-identical result, so
this is only observable through the verifier.
Where the edge comes from
Every missing edge has
parent_type = lazy_array(9)andchild_type = object(2), and the slot address is tens of MB away from the parent — it is not afield of
LazyArrayHeaderitself but the sparse per-element cache,materialized_elements, whichalloc_lazy_arrayallocates as a separate arenablock (with
GC_TYPE_STRING) and attributes to the header.lazy_getwrites itand does call the barrier:
but the verifier reports
slot_page_ever_dirty=falsefor those slots — the pageholding the cache was never dirtied. The barrier is passed
parent = hdrwhilethe slot lives in a different allocation, so whatever generation test the
barrier makes is made about the header's page, not the cache's.
unmarked_parents == missing_edgeson both arms, i.e. every flagged parent wasitself unmarked at verification time. That may make some or all of these
verifier false positives on dead lazy headers rather than live missing barriers —
worth settling first, because the two conclusions have opposite fixes (fix the
barrier vs. fix the verifier's parent filter).
Pre-existing: the A/B
Same probe, same compiler, same flags, two runtimes built in the same pipeline —
one with #7499's
json_tape.rs+ test file, one with only those two filesreverted to the merge-base:
lazy_array(9)→object(2),slot_page_ever_dirty=falseSame defect on both; the count differs only because the two arms generate
different amounts of lazy-cache traffic and promotion.
Why it matters beyond the flag
An old→young edge missing from the remembered set is exactly the shape CLAUDE.md
records as "JSON.parse filled born-old arrays through this helper, the children
were swept live on a later minor →
value is not a function". If these parentsare genuinely live, this is a live-object loss waiting for the right timing; if
they are not,
PERRY_GC_VERIFY_EVACUATION=1is unusable on any tape workload,which costs us the instrument.
Either way
PERRY_GC_VERIFY_EVACUATION=1currently cannot be run overPERRY_JSON_TAPE=1code, which is a gap in the GC instrument coverage for thewhole lazy-tape subsystem.