Skip to content

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

Closed

Description

@proggeramlug

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=0 on every cycle — PERRY_GC_FROMSPACE_SCAN_ABORT=1 never 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:

  1. if the slot is genuinely dead, the verifier needs to know that (or the frame needs to clear it), otherwise PERRY_GC_VERIFY_EVACUATION=1 cannot be combined with zeal on real code — and zeal is precisely the configuration you reach for when hunting a rooting bug;
  2. 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

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

  1. proggeramlug commented on Aug 2, 2026

    @proggeramlug
    ContributorAuthor

    Confirmed still open on main = d4ac65f21. Re-verified in the sweep #7262.

    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
    

    3/3 rc=134, each with:

    gc evacuation verification failed: stale forwarded pointer in shadow stack roots
    

    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) and test_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.

  2. proggeramlug commented on Aug 3, 2026

    @proggeramlug
    ContributorAuthor

    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 ObjectHeader in 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.py reads emitted LLVM IR and read 0 violations on 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 RuntimeHandleScope non-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=800 on a dependency-scale workload. #7280 records 25 curated corpus files passing while 20 lines of stock zod fail.

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