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.
On
b3f14e9cde(currentmainat 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.--helpon the same binary with no GC knobs at all is node-identical, so this is not a general breakage.Repro
Binary:
cli_2.1.112.jscompiled atb3f14e9cdewithperry compile --no-auto-optimize --enable-wasm-runtime(PERRY_DEBUG_SYMBOLS=1, x86-64 Linux).size=1347565393is0x504C5451, i.e. the ASCII bytesQTLP— 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:
A remembered-set dirty-range slot still holds the pre-move address after the rewrite pass has run.
oldandforwarded_toare both NaN-boxed pointers (0x7ffd0prefix), so the slot names a live object that moved and was never rewritten.How reproducible
PERRY_GC_SCHEDULE_RATE=1.safepoints=362 scheduled_collections=362, which suggestsPERRY_GC_SCHEDULE_RATEis not actually varying the collection density here either.PERRY_GC_SCHEDULE_SEED=1withoutPERRY_GC_SCHEDULE_RATEis 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:PERRY_GC_FORCE_EVACUATE=1PERRY_GC_PROTECT_FROMSPACE=1PERRY_GC_VERIFY_EVACUATION=1PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_PROTECT_FROMSPACE=1PERRY_GC_HEAP_LIMIT=64PERRY_GC_HEAP_LIMIT=32 PERRY_GC_FORCE_EVACUATE=1One further configuration fails differently and is probably the same defect reached by another route:
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.shcannot 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
b3f14e9cdebuild 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 againstmain, 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.