Skip to content

GC: seeded schedule on the claude-code bundle leaves a stale forwarded pointer in the remembered dirty ranges (pin-latch FATAL / verify.rs:74), 12/12 seeds on main #9261

Description

@proggeramlug

On b3f14e9cde (current main at the time of writing), the claude-code bundle under a seeded GC schedule trips the pin latch with a garbage object header — and, with the evacuation verifier turned on, trips it one step earlier with a stale forwarded pointer in the remembered dirty ranges, which is very likely the cause.

--help on the same binary with no GC knobs at all is node-identical, so this is not a general breakage.

Read this part first, whatever you do with the rest. Because every seed aborts,
scripts/gc_schedule_fuzz.sh currently has zero coverage of the claude-code bundle.
Not weak coverage — none. The harness exists specifically to find rooting bugs by varying
when collections fire, and on our largest corpus it dies before it explores a single
schedule. A gate that aborts on startup looks from the outside exactly like a gate that
ran and found nothing, which is how this can persist unnoticed. Whatever the fix here
turns out to be, that is the property to restore.

Repro

Binary: cli_2.1.112.js compiled at b3f14e9cde with perry compile --no-auto-optimize --enable-wasm-runtime (PERRY_DEBUG_SYMBOLS=1, x86-64 Linux).

HOME=/tmp/cc_home PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 ./app --help
rc=134
[gc-pin-latch] FATAL: copying minor is about to relocate a PINNED young object on a
preflight-skipped cycle. header=0x20d7ee22440 obj_type=10 (buffer) size=1347565393
flags=0x76 (ARENA|PINNED|INTERNED|TENURED|HAS_SURVIVED)
[gc-schedule] FAILURE (signal 6) under seed=1
[gc-schedule]   safepoints=362 scheduled_collections=362

size=1347565393 is 0x504C5451, i.e. the ASCII bytes QTLP — the "header" is being read out of payload bytes, which is what a stale pointer looks like by the time the latch sees it.

Add the evacuation verifier and it fires first, on a surface that names itself:

HOME=/tmp/cc_home PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 \
  PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_VERIFY_EVACUATION=1 \
  ./app --help
rc=134
thread '<unnamed>' panicked at crates/perry-runtime/src/gc/verify.rs:74:5:
gc evacuation verification failed: stale forwarded pointer in remembered dirty ranges:
slot=0x57f1230ec68 old=0x7ffd057f1230f470 forwarded_to=0x7ffd057f123119f0

A remembered-set dirty-range slot still holds the pre-move address after the rewrite pass has run. old and forwarded_to are both NaN-boxed pointers (0x7ffd0 prefix), so the slot names a live object that moved and was never rewritten.

How reproducible

  • 3/3 runs each for the two commands above.
  • 12/12 seeds (1..12) at PERRY_GC_SCHEDULE_RATE=1.
  • Every rate tried: 1, 2, 4, 8, 16, 64, 256, 1024 — all abort, all reporting safepoints=362 scheduled_collections=362, which suggests PERRY_GC_SCHEDULE_RATE is not actually varying the collection density here either.
  • PERRY_GC_SCHEDULE_SEED=1 without PERRY_GC_SCHEDULE_RATE is clean, 3/3. The rate knob is required.

What is NOT affected

All of these exit 0 with output byte-identical to node cli_2.1.112.js --help (9,175 bytes) on the same binary:

  • no GC knobs
  • PERRY_GC_FORCE_EVACUATE=1
  • PERRY_GC_PROTECT_FROMSPACE=1
  • PERRY_GC_VERIFY_EVACUATION=1
  • PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1
  • PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_PROTECT_FROMSPACE=1
  • PERRY_GC_HEAP_LIMIT=64
  • PERRY_GC_HEAP_LIMIT=32 PERRY_GC_FORCE_EVACUATE=1

One further configuration fails differently and is probably the same defect reached by another route:

PERRY_GC_HEAP_LIMIT=16 PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_PROTECT_FROMSPACE=1 ./app --help
rc=139   (SIGSEGV)

Why this matters more than the abort

The abort is the diagnostic working. The defect underneath it is an un-rewritten slot in the remembered set, and that is the family where a wrong answer frees live objects silently: in a build without the verifier, that slot is followed, the collector latches payload bytes as a header, and what happens next depends on what those bytes say.

It also costs the project the fuzz coverage described at the top: gc_schedule_fuzz.sh cannot explore a single schedule on this corpus while this reproduces.

Not caused by any local change

Found while validating an unrelated GC-map-loading change. It reproduces identically on the unmodified b3f14e9cde build and on the patched build — same signal, same message, same code path, only the addresses differ — and the patch in question does not touch the collector, the remembered set, or the barrier. I am filing it against main, not against that work.

The [gc-pin-latch] diagnostic prints its own candidate list, and candidate 1 ("a slot that was not rooted across a collection point handed the copier a stale header") is consistent with the verifier's finding, but the verifier is more specific: it names the surface as the remembered dirty ranges rather than a mutator frame.

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