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

Closed

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

  1. proggeramlug commented on Sep 16, 2026

    @proggeramlug
    ContributorAuthor

    Cross-reference: #10350 profiles this same barrier as a performance problem — mark_dirty_old_page_uncached is ~35% of all retired instructions on an ordinary allocation workload, with the DIRTY_OLD_PAGES hash 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, the DIRTY_OLD_PAGES modbuf, and page_meta.dirty inside the OLD_GEN_PAGE_META hash 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.

  2. proggeramlug commented on Sep 16, 2026

    @proggeramlug
    ContributorAuthor

    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 to new __AnonShape_*(…),
    and the synthesized class's field types become the class's compile-time GC
    masks. js_gc_typed_shape_id_for_keys registers them against a dedicated
    ShapeId and every allocation stamps SIDE_MASK | TYPED_LAYOUT_INTACT from the
    baked 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:

    [probe10348-register] class_id=1 slot_count=4 raw_mask=[] ptr_mask=[3]   // 0b0011 = {id, payload}
    

    The live pointer slots are {payload, next} = 0b1010. next was excluded,
    because var head = null infers Type::Null, type_is_pointer_bearing(Null)
    is false, and the post-lowering widening pass that exists to repair a
    declared type "lied about by later assignments" both runs after this class is
    minted and did not cover Null/Void at all (_ => false).

    PERRY_GC_FROMSPACE_SCAN=1 — layout-blind, so it sees past the mask — names it
    directly on the filed binary:

    [gc-fromspace-scan OFFENDERS] … missing_rewrites=0 dangling=14727 … never_dirty=14727
      owner=0x…5f8 type=2 space=Survivor1 +40 nanbox -> 0x… (type=2 NurseryEden) DANGLING (target not evacuated)
          [layout] state=SIDE_MASK intact=true live_inline=4 per_object_mask=none shared_mask=11000000
    

    14,727 live, marked objects each holding a next the minor never evacuated.
    The old_page / remembered=no evacuation abort is the same edge one
    generation 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 payload present and
    c.tag = … dropped, the reproducer prints the correct node count while the
    from-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
    payload field entirely stop the class from being registered at all.

    Worth carrying forward: PERRY_GC_VERIFY_EVACUATION=1 was silent on a 2,000-chain
    version of this program that PERRY_GC_FROMSPACE_SCAN_ABORT=1 aborts on
    instantly. 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.

  3. proggeramlug commented on Sep 16, 2026

    @proggeramlug
    ContributorAuthor

    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, and var head = null infers Type::Null, so the next slot was omitted from the mask and never scanned. remembered=no was 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=0 under PERRY_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_notag carries 15 019 dangling references and v_nopay carries 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.

  4. proggeramlug commented on Sep 17, 2026

    @proggeramlug
    ContributorAuthor

    Fixed on main by #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 #10348 keyword 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.

  5. added a commit that references this issue on Sep 27, 2026
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