Repository navigation
gc: PERRY_GC_ZEAL + PERRY_GC_VERIFY_EVACUATION reports a stale forwarded pointer in shadow-stack roots (10/10, pre-existing, no CI arm sets the pair) #7254
Description
PERRY_GC_ZEAL=1 and PERRY_GC_VERIFY_EVACUATION=1 are both shipped knobs, and no CI arm sets them together over the representation corpus. Setting them together finds something on the first file that was tried.
Repro
test_gap_repsel_p4a3_ptr_numarray, compiled and run with back-edge safepoint polls, at origin/main = 64c1f56fb:
PERRY_GC_MOVING_LOOP_POLLS=1 perry test-files/test_gap_repsel_p4a3_ptr_numarray.ts -o bin
PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \
PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_GC_ZEAL=1 PERRY_GC_VERIFY_EVACUATION=1 ./bin
thread '<unnamed>' panicked at crates/perry-runtime/src/gc/verify.rs:59:5:
gc evacuation verification failed: stale forwarded pointer in shadow stack roots:
slot=0x16d72ea48 old=0x7ffd0437f5914878 forwarded_to=0x7ffd0437f59148c8
10/10, deterministic. old is a POINTER_TAG NaN-box (0x7ffd); the object moved 0x50 bytes and a shadow-stack slot still holds the pre-move address after the rewrite pass ran.
Adding PERRY_GC_PROTECT_FROMSPACE=1 instead of the verifier gives a hard fault at the use rather than at the check, also 10/10:
[gc-fromspace-protect] FAULT: signal 10 at 0x33a1e140001
block=0x33a1e140000 +1 retired_bytes=144 retired_by_minor=#14072
last-known object: user_ptr=0x33a1e140008 obj_type=1 size=144
Why this is probably a dead slot, and why it still matters
Three measurements say the mutator is not reading it:
- the same binary under the same zeal without the verifier is byte-exact vs node 26.5.1, 10/10, across 14 373 evacuating minors and 9 485 copied objects. A live stale read would not survive that;
[gc-fromspace-scan clean] … missing_rewrites=0 dangling=0 owners=0on every cycle —PERRY_GC_FROMSPACE_SCAN_ABORT=1never fires;- the failure surfaces only when the verifier or the from-space quarantine looks, never in program output.
So the most likely reading is a shadow-stack slot whose value is no longer live but was not rewritten — the verifier is stricter than the collector needs to be. That is still worth resolving, in one of two directions, because the current state makes an instrument unusable:
- if the slot is genuinely dead, the verifier needs to know that (or the frame needs to clear it), otherwise
PERRY_GC_VERIFY_EVACUATION=1cannot be combined with zeal on real code — and zeal is precisely the configuration you reach for when hunting a rooting bug; - if it is not dead, this is a missed rewrite on the shadow stack, which is the highest-severity class this campaign has.
Note the adjacent, opposite-signed finding in #7035 (the verifier reporting clean on a demonstrable missing rewrite because it only self-checks the rewrite pass's own enumeration). A verifier with both a false-negative mode and a false-positive mode is not yet a trustworthy oracle.
Scope, honestly
- Pre-existing, not a regression from the recent GC merges. The identical panic reproduces 3/3 on a build of
c9cd73ba5(before fix(gc): root the ten unrooted runtime-side caches, and the scanner that walked 1 of 3 sibling slots (#7231) #7239/fix(codegen): remove the unproven i64 function specialization (#7238) #7242/fix(gc): a Symbol-typed local is a GC-heap reference, and gets a shadow slot (#7236) #7243/fix(gc): run the globalThis bootstrap in a no-move window (#7217) #7249/perf(gc): stop maintaining a remembered set nothing has read yet (#7187 Phase A) #7250/fix(gc): root every argument of a cross-module direct call (#7240, by @jdalton) #7252). It is not what matrix: test_gap_repsel_p4a3_ptr_numarray red on main across ALL requires=move arms, untriaged — blocks clean gating of every barrier/GC PR (#7016 neighbourhood) #7194 was about — that was an output mismatch fixed by fix(gc): run the globalThis bootstrap in a no-move window (#7217) #7249, and this survives the fix. - Small sample. Four corpus files tried under this arm:
test_gap_repsel_p4a3_ptr_numarrayfails 10/10;test_gap_repsel_canonical_i32,test_gap_repsel_p4a3_numarray_growthandtest_gap_repsel_ptr_shape_localsare clean 0/3. Nobody has swept the other 45. - The knob-pair has no CI arm.
scripts/gc_instrument_smoke.shexercises zeal against its own synthetic fixture;gc_repsel_matrix.shhas no zeal arm at all. Under CLAUDE.md's GC knob kill-policy that is a configuration nobody has verified — which is how a 10/10 deterministic reproducer sat unseen in a corpus file that the matrix runs twenty-one different ways.
Suggested first step: sweep the remaining 45 corpus files under this arm to size the population before deciding between (1) and (2) — the ratio of clean to dirty files is what says whether this is a verifier-strictness bug or a rewrite bug.
Activity
proggeramlug commentedon Aug 2, 2026
Confirmed still open on
main=d4ac65f21. Re-verified in the sweep #7262.3/3
rc=134, each with:Reproduces on
d4ac65f21, i.e. after #7249/#7250/#7252/#7253/#7256, consistent with this issue's finding that it is pre-existing rather than a regression from the recent GC merges.One datum that may help size it, from elsewhere in the same sweep: the zeal+verify pair runs clean on two other corpus files —
test_gap_repsel_ptr_shape_locals(3/3 green, 6 copy-minors / 15 724 copied) andtest_gap_repsel_proven_this_frozen(3/3 green, 5 copy-minors / 15 623 copied), both at substantial relocation volume with the verifier armed. So the verifier is not simply incompatible with zeal in general — it is specific files. That nudges the issue's own fork toward reading (1) (a genuinely dead slot the verifier is stricter about than the collector needs) rather than a blanket verifier-strictness problem, but it is three files out of ~49 and the issue's suggested first step — sweep the rest to size the population — is still the right one.proggeramlug commentedon Aug 3, 2026
Consolidated plan reference —
docs/src/internals/rfc-rooting-by-construction.md("Where this sits in the one GC correctness plan", landed in #7292).This issue is layer 3: hand-written Rust in
perry-runtime— a*mut ObjectHeaderin a Rust local or a cache, held across an allocation.Unlike the codegen-side rooting issues, this class is NOT subsumed by the planned statepoint work. LLVM never sees this code, so stack maps cannot help. It is also the class the static checker is structurally blind to —
scripts/gc_root_dominance_check.pyreads emitted LLVM IR and read0 violationson both sides of #7249, a real bug. See the blind-spot list added in #7289.⇒ Work here is safe to start and will not be thrown away. The long-term direction is making
RuntimeHandleScopenon-optional rather than opt-in — today every bug in this class is a site where someone simply didn't reach for it.Verification note: a 10/10-deterministic failure is diagnostic of this class rather than a stale register; the instruments that see it are
PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800on a dependency-scale workload. #7280 records 25 curated corpus files passing while 20 lines of stock zod fail.gc: exercise PERRY_GC_ZEAL + PERRY_GC_VERIFY_EVACUATION together in CI (
gc: no-move window for lazy intrinsic towers (#7251) + CI arm for ZEA…