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
Repro files are self-contained; both run unmodified on perry, node and bun.
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.
0c0e850e98c30c57f12a869fd7f7d3a510c782b3(v0.5.1573, merge train 195), release build, Linux x86_64 (perrybuilder, EPYC 9354P)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.
nextedges are not merely dropped — they are redirected into other live chains. AtTOTAL=100000the same program reports more nodes than were ever allocated:Repro 2 — corrupted object / crash
Same shape plus a
Mapretaining thepayloadarrays (full file:repro-crash.ts). The stale addresses now land on reused memory:Reading such a node throws
TypeError: Cannot read properties of undefinedfrom 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=1on repro 1 panics immediately:parent_space=old_pagewithremembered=noanddirty_snapshot=nois 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 atminor=5.What narrows it
payload: [...]array fieldc.tag = "..."post-allocation storeCHAINS= 10000 / 20000 / 30000CHAINS= 40000PERRY_GC_SCAVENGE=0PERRY_GC_SCAVENGE_NURSERY_MB=1Two 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=0does 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
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.old_page_forced_defraganomalies.Repro files are self-contained; both run unmodified on perry, node and bun.