Skip to content

GC: old-page object holds a stale pointer after evacuation (remembered=no) — object graph silently truncated and cross-linked, then corrupted #10348

Description

@proggeramlug

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions