Skip to content

GC hardening: mutable-root mark path is NaN-box-only while rewrite/evacuation accepts raw addresses (green-tests/prod-UAF trap for future raw-slot reps) #6910

Description

@proggeramlug

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)

  1. 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.
  2. 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.
  3. 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)

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