Summary
The GC's mutable-root-slot mark path and its rewrite/evacuation path disagree about raw (untagged) pointers:
- Mark is NaN-box-only.
gc/roots.rs shadow-slot scanning (~:1566 incremental cursor walk, ~:1714 full walk) feeds slot bits to try_mark_value (gc/trace.rs:498-508), which hard-rejects any bit pattern whose top-16 tag is not POINTER_TAG / STRING_TAG / BIGINT_TAG. A RAW pointer stored in a mutable root slot is invisible to marking.
- Rewrite accepts raw. The same slot visitors' rewrite modes (
gc/roots.rs:1094-1126: RuntimeRootVisitMode::Rewrite -> try_rewrite_raw_addr, CopyingRewrite -> collector.rewrite_raw_addr) happily translate raw forwarded addresses.
Why this is a trap, not a bug (today)
All shipped codegen keeps pointer-holding slots tagged at rest (NaN-boxed bits in the shadow-bound alloca; raw pointers only as region-local SSA between safepoints), so the asymmetry is currently unobservable. But if any future lowering ever stores a bare pointer in a registered slot:
- the object would be rewritten correctly on evacuation (moving-GC stress tests look green),
- yet never marked through that slot — if the slot is the only reference, the object is swept while live.
Worse, the test suites force the conservative stack scan to Full, which would rescue exactly this case — while production resolves ConservativeStackScanMode::Auto -> SkipDisabled (gc/roots.rs:251-259). So the failure mode is: green tests, prod-only use-after-free.
This was audited during representation-selection Phase 3b (shape-proven Ptr<Shape> object locals, RFC docs/representation-selection-rfc.md §5.6): Phase 3b deliberately keeps slots tagged-at-rest and relies on no GC change, but the asymmetry should be hardened rather than silently relied upon.
Proposed hardening (any of, cheapest first)
- Debug assertion parity: in debug/
PERRY_GC_VERIFY_EVACUATION builds, when a rewrite-mode visitor accepts a RAW (untagged) slot value in a MutableRootSlotKind::ShadowStack slot, panic (or log-once) — the mark path would not have seen it, so accepting it in rewrite is masking a liveness hole.
- Symmetric rejection: make the rewrite path reject untagged bits in shadow-stack slots outright (global roots that legitimately hold raw addresses keep their current handling), so both paths enforce the same tagged-at-rest contract.
- Contract note: document the tagged-at-rest invariant at
js_shadow_slot_bind / MutableRootSlotKind::ShadowStack so future rep-selection phases (raw Ptr slots, Phase 4 typed heap) know a raw-slot design REQUIRES teaching try_mark_value a raw-accepting sibling first (try_mark_value_or_raw exists for the conservative scan and is the natural building block).
Refs
crates/perry-runtime/src/gc/trace.rs:498 (try_mark_value tag rejection)
crates/perry-runtime/src/gc/roots.rs:1566/:1714 (shadow-slot mark feeds)
crates/perry-runtime/src/gc/roots.rs:1094-1126 (rewrite raw acceptance)
crates/perry-runtime/src/gc/roots.rs:251-259 (prod Auto -> SkipDisabled)
- Representation-selection RFC §5.6 (rebase-after-safepoint / rooted unboxed pointers)
Summary
The GC's mutable-root-slot mark path and its rewrite/evacuation path disagree about raw (untagged) pointers:
gc/roots.rsshadow-slot scanning (~:1566incremental cursor walk,~:1714full walk) feeds slot bits totry_mark_value(gc/trace.rs:498-508), which hard-rejects any bit pattern whose top-16 tag is notPOINTER_TAG/STRING_TAG/BIGINT_TAG. A RAW pointer stored in a mutable root slot is invisible to marking.gc/roots.rs:1094-1126:RuntimeRootVisitMode::Rewrite->try_rewrite_raw_addr,CopyingRewrite->collector.rewrite_raw_addr) happily translate raw forwarded addresses.Why this is a trap, not a bug (today)
All shipped codegen keeps pointer-holding slots tagged at rest (NaN-boxed bits in the shadow-bound alloca; raw pointers only as region-local SSA between safepoints), so the asymmetry is currently unobservable. But if any future lowering ever stores a bare pointer in a registered slot:
Worse, the test suites force the conservative stack scan to
Full, which would rescue exactly this case — while production resolvesConservativeStackScanMode::Auto -> SkipDisabled(gc/roots.rs:251-259). So the failure mode is: green tests, prod-only use-after-free.This was audited during representation-selection Phase 3b (shape-proven
Ptr<Shape>object locals, RFCdocs/representation-selection-rfc.md§5.6): Phase 3b deliberately keeps slots tagged-at-rest and relies on no GC change, but the asymmetry should be hardened rather than silently relied upon.Proposed hardening (any of, cheapest first)
PERRY_GC_VERIFY_EVACUATIONbuilds, when a rewrite-mode visitor accepts a RAW (untagged) slot value in aMutableRootSlotKind::ShadowStackslot, panic (or log-once) — the mark path would not have seen it, so accepting it in rewrite is masking a liveness hole.js_shadow_slot_bind/MutableRootSlotKind::ShadowStackso future rep-selection phases (rawPtrslots, Phase 4 typed heap) know a raw-slot design REQUIRES teachingtry_mark_valuea raw-accepting sibling first (try_mark_value_or_rawexists for the conservative scan and is the natural building block).Refs
crates/perry-runtime/src/gc/trace.rs:498(try_mark_valuetag rejection)crates/perry-runtime/src/gc/roots.rs:1566/:1714(shadow-slot mark feeds)crates/perry-runtime/src/gc/roots.rs:1094-1126(rewrite raw acceptance)crates/perry-runtime/src/gc/roots.rs:251-259(prodAuto -> SkipDisabled)