Repository navigation
GC: old-page object holds a stale pointer after evacuation (remembered=no) — object graph silently truncated and cross-linked, then corrupted #10348
Description
Summary
An object promoted to the old generation can hold a field pointer to an object that is later evacuated without the parent ever being entered into the remembered set. The slot is never rewritten, so it keeps pointing at the stale from-space address. The result is a silently wrong object graph: linked structures are truncated or cross-linked, and once the stale address is reused the program reads a corrupted object and crashes.
This is silent data corruption with no diagnostic — the default build prints no warning and exits 0 with wrong answers.
Found while benchmarking perry startup vs node/bun on a plain allocation-heavy program; it is not an exotic pattern.
- Version:
0c0e850e98c30c57f12a869fd7f7d3a510c782b3(v0.5.1573, merge train 195), release build, Linux x86_64 (perrybuilder, EPYC 9354P) - Deterministic: identical counts on every run
- node v26.8.1 and bun 1.4.2 are both correct on the same source
Repro 1 — silent graph truncation (no crash)
30 lines, no dependencies. Build a ring of 40 000 retained 8-node chains, then count the nodes actually reachable.
var CHAINS = 40000, CHAIN_LEN = 8, TOTAL = 300000;
function makeChain(seed) {
var head = null;
for (var i = 0; i < CHAIN_LEN; i++) {
head = {
id: seed + i,
payload: [seed, i, (seed ^ i) | 0, (seed + i * 3) | 0],
tag: null,
next: head
};
}
return head;
}
var ring = new Array(CHAINS);
for (var i = 0; i < CHAINS; i++) ring[i] = null;
for (var n = 0; n < TOTAL; n++) {
var c = makeChain(n);
c.tag = "n" + (n % 1024); // post-allocation store of a heap string
ring[n % CHAINS] = c;
}
var nodes = 0, truncated = 0;
for (var i = 0; i < CHAINS; i++) {
var cur = ring[i], d = 0;
while (cur !== null) { nodes++; cur = cur.next; d++; if (d > 50) break; }
if (d !== CHAIN_LEN) truncated++;
}
console.log("nodes=" + nodes + " expected=" + (CHAINS * CHAIN_LEN) + " truncated_chains=" + truncated);perry nodes=308181 expected=320000 truncated_chains=11819 <-- 3.7% of the graph lost
node nodes=320000 expected=320000 truncated_chains=0
bun nodes=320000 expected=320000 truncated_chains=0
next edges are not merely dropped — they are redirected into other live chains. At TOTAL=100000 the same program reports more nodes than were ever allocated:
perry nodes=329754 expected=320000 truncated_chains=9754
Repro 2 — corrupted object / crash
Same shape plus a Map retaining the payload arrays (full file: repro-crash.ts). The stale addresses now land on reused memory:
perry CORRUPT slot=20003 depth=2 id=undefined typeof(tag)=undefined payload_is_array=false
CORRUPT slot=20004 depth=2 id=undefined typeof(tag)=undefined payload_is_array=false
...
node walk complete
bun walk complete
Reading such a node throws TypeError: Cannot read properties of undefined from what is, in source, a plain field access on an object that is provably still reachable.
The collector's own verifier confirms it
PERRY_GC_VERIFY_EVACUATION=1 on repro 1 panics immediately:
gc evacuation verification failed: stale forwarded pointer in heap fields:
surface=heap_fields parent=0x33150e745f8 parent_type=object parent_space=old_page
slot=0x33150e74620 slot_index=4 visitor=ObjectFields
old=0x7ffd033150b7ff70 forwarded_to=0x7ffd03314fd57db8
child_type=object child_space=old_page
remembered=no young_logged=n/a(heap_parent_uses_remembered_set) dirty_snapshot=no
minor=5 trigger=ArenaBytes after_budgeted_step=no
parent_space=old_page with remembered=no and dirty_snapshot=no is the defect: an old-page parent holding a forwarded child that the remembered set never recorded, so the evacuation rewrite never visits the slot. It fires at minor=5.
What narrows it
| variant | result |
|---|---|
| as above | 11819 truncated chains |
drop the payload: [...] array field |
clean |
drop the c.tag = "..." post-allocation store |
clean |
| drop both | clean |
CHAINS = 10000 / 20000 / 30000 |
clean |
CHAINS = 40000 |
broken |
PERRY_GC_SCAVENGE=0 |
still broken (unchanged: 11819) |
PERRY_GC_SCAVENGE_NURSERY_MB=1 |
worse (23617 truncated) |
Two conditions are jointly required: the object must carry a heap-pointer field allocated with it (payload) and receive a later heap-pointer store (c.tag = "n"+...). Either alone is clean.
That PERRY_GC_SCAVENGE=0 does not help points away from the young-generation scavenge and at old-page evacuation; shrinking the nursery (more promotion) makes it strictly worse. The threshold between 30 000 and 40 000 retained chains is where the live set starts forcing old-gen evacuation.
Hypothesis, stated as such: the write barrier for the post-allocation store either never records the parent card, or records it against the wrong generation, so when the parent is promoted and its children are later evacuated the parent is not in the remembered-set root list for that cycle.
Why this matters beyond the repro
It is reachable from ordinary code — an object with an array field that gets a string assigned after construction, held in a long-lived collection — and it fails silently. A program gets wrong results with no crash, no warning, exit code 0.
Possibly related
- GC: a 12-element array persistently holds a pointer at index 10 that the slot enumeration never visits (UNENUMERATED, reserved=0x8020) #9983 — unenumerated pointer slot, but that is the array surface (
verify_array_pointer_slots_enumerated) found via probe on the cc bundle; this one isvisitor=ObjectFieldson old pages with a standalone repro. May share a root cause. - GC: copying minor reports not_attempted on every cycle in the memory-stability gates; old_page_forced_defrag moved > selected #9659 — copying minor /
old_page_forced_defraganomalies.
Repro files are self-contained; both run unmodified on perry, node and bun.
Activity
proggeramlug commentedon Sep 16, 2026
Cross-reference: #10350 profiles this same barrier as a performance problem —
mark_dirty_old_page_uncachedis ~35% of all retired instructions on an ordinary allocation workload, with theDIRTY_OLD_PAGEShash insert adding another 12%.Relevant to the fix here: that issue documents three separate representations of "this page is dirty" that must agree — the 16-way
dirty_page_cache, theDIRTY_OLD_PAGESmodbuf, andpage_meta.dirtyinside theOLD_GEN_PAGE_METAhash map. The verifier output in this issue (remembered=no ... dirty_snapshot=no) is a report of two of those three disagreeing.If the barrier is restructured onto a single card table as proposed in #10350, this failure class goes away with the structures that create it, rather than needing a separate fix. Worth deciding whether to fix this one narrowly or fold it into that work.
proggeramlug commentedon Sep 16, 2026
Root-caused and fixed in #10352. It is not the collector — the write
barrier, the remembered set and the evacuation rewrite are all doing exactly
what they were told. They were told the wrong thing at compile time.
The defect.
{ id, payload, tag, next }lowers tonew __AnonShape_*(…),and the synthesized class's field types become the class's compile-time GC
masks.
js_gc_typed_shape_id_for_keysregisters them against a dedicatedShapeId and every allocation stamps
SIDE_MASK | TYPED_LAYOUT_INTACTfrom thebaked header image (#8405) — no per-object validation, no downgrade. So a
field the pointer mask omits is a field the collector never scans.
Instrumenting the registration on the reproducer:
The live pointer slots are
{payload, next}=0b1010.nextwas excluded,because
var head = nullinfersType::Null,type_is_pointer_bearing(Null)is
false, and the post-lowering widening pass that exists to repair adeclared type "lied about by later assignments" both runs after this class is
minted and did not cover
Null/Voidat all (_ => false).PERRY_GC_FROMSPACE_SCAN=1— layout-blind, so it sees past the mask — names itdirectly on the filed binary:
14,727 live, marked objects each holding a
nextthe minor never evacuated.The
old_page/remembered=noevacuation abort is the same edge onegeneration later.
Correction to the narrowing table in this issue: every "clean" row is a false
green. The mask is equally wrong in all of them. With
payloadpresent andc.tag = …dropped, the reproducer prints the correct node count while thefrom-space scan still reports 15,123 dangling references — the dropped
children simply had not been recycled into anything visible yet. The
conditions in the table select whether the corruption becomes observable,
not whether it happens. Only the two rows that remove the pointer-bearing
payloadfield entirely stop the class from being registered at all.Worth carrying forward:
PERRY_GC_VERIFY_EVACUATION=1was silent on a 2,000-chainversion of this program that
PERRY_GC_FROMSPACE_SCAN_ABORT=1aborts oninstantly. The verifier trusts the same slot enumeration the bug corrupts; the
from-space scan does not. For anything that smells like a dropped edge, run
both.
proggeramlug commentedon Sep 16, 2026
Correction to my earlier comment above: I suggested this was a remembered-set divergence that a card-table restructure would subsume. That was wrong. PR #10352 identifies the actual cause — a synthesized
__AnonShape_*record takes its GC pointer mask from field types, andvar head = nullinfersType::Null, so thenextslot was omitted from the mask and never scanned.remembered=nowas downstream: a slot the mask calls non-pointer never gets barriered in the first place.I have verified the fix against every variant in this issue (all now correct with
dangling=0underPERRY_GC_FROMSPACE_SCAN), and #10350 is closed as invalid — its profile was measuring this bug's consequences.Also worth recording here, since the issue body is misleading on it: the narrowing table in my original report is wrong. Every row I marked "clean" is a false green. On main with
PERRY_GC_FROMSPACE_SCAN=1,v_notagcarries 15 019 dangling references andv_nopaycarries 29 179 — more than the 14 727 in the row that actually printed a wrong answer. The two conditions I claimed were jointly required are not; they only govern whether the dropped children get recycled into something observable before the program looks.fix(hir): a null-typed field is not a proof the GC may skip the slot (#…
perf(gc): borrow the shape record in the slot visitor, stop moving th…
proggeramlug commentedon Sep 17, 2026
Fixed on
mainby #10352, which landed via merge train 203 (v0.5.1580,33690c5635).Closing manually: a merge train cherry-picks its source PRs and lands them as its own PR, so #10352 was closed rather than merged and its
Fixes #10348keyword never evaluated. The train's merge receipt records the landed tree as identical to the validated train, with per-commit patch-id and authorship proofs for every source commit.test(gc): compile the #10348 from-space-scan fixture with the GC inst…