Skip to content

GC: a Symbol's description pointer is never traced — GC_TYPE_STRING is a pointer-free Leaf #7246

Closed

Description

@proggeramlug

Summary

A Symbol's description is a *mut StringHeader stored inside SymbolHeader, and the collector never traces or rewrites it. So a symbol that is itself perfectly rooted — shadow slot, side table, everything — can have its description reaped out from under it, and String(sym) / sym.description / console.log(sym) then read recycled memory.

alloc_symbol states both halves as a design choice and as a known gap:

// crates/perry-runtime/src/symbol.rs:370
pub(crate) unsafe fn alloc_symbol(description: *mut StringHeader, registered: bool)
    -> *mut SymbolHeader {
    // Allocate via gc_malloc as a leaf (GC_TYPE_STRING treats payload as
    // opaque, which is what we want — the GC won't try to scan internal
    // pointers). The description pointer is kept alive through the
    // SYMBOL_REGISTRY (for registered symbols) or not at all (for fresh
    // symbols — in practice they live for the duration of the program,
    // which is fine for test workloads).
    let raw = crate::gc::gc_malloc(
        std::mem::size_of::<SymbolHeader>(),
        crate::gc::GC_TYPE_STRING,
    );

GC_TYPE_STRING's type-info entry (gc/types.rs) is pointer_free: true, GcRewriteDescriptorKind::Leaf, GcLayoutSlotKind::None, GcMoveHookKind::None — so nothing walks into the payload. That is correct for a string, whose payload is bytes. It is wrong for a symbol, whose payload's third word is a heap pointer. Symbols and strings share one GC type, so no descriptor can distinguish them today.

SYMBOL_POINTERS does not close it either: scan_symbol_pointer_metadata_roots_mut (symbol/gc_roots.rs) visits the set with visit_metadata_usize_slot, which rewrites a recorded address without marking. It keeps the registry's own addresses honest; it does not keep anything alive, and it never looks at (*ptr).description.

Why this is being filed now

Found while building #7236's runtime witness — and it is a different defect from #7236, which is a codegen classification (Type::Symbol treated as an immediate, so a Symbol local got no shadow slot). #7236 is fixed in #7243. This one survives that fix, because it is about the pointer inside a symbol rather than the pointer to it.

Measured on #7243's branch, release, --arms all --pressure 8, oracle node 26.5.1, with a witness whose probe symbol carries a description:

arm result
evac_minor, force_evac, force_verify, loop_polls, rep_* (11 arms — every arm with PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off) red, 2 of 20 iterations, deterministic
default, verify_evac, cons_scan_off, cons_scan_off_force, gen_gc_off, wb_off, shipped_default, … green

Per-probe instrumentation isolates it precisely. At base (before #7243) the symbol object is freed and its block recycled into another symbol, so the failures are string 17 desc 17 with typeof 0. After #7243 the symbol object survives — and the residual is still string 2 desc 2, same probes, typeof 0, read 0 count 0. The key resolves, it is still a symbol, its description is wrong.

The discriminator is one character: making the probe symbol Symbol() instead of Symbol("k") — no description, therefore no untraced pointer — takes the same file to 0 FAIL / 21 of 21 byte-exact on every arm. That is why test-files/test_gap_gc_symbol_local_rooting.ts ships with a descriptionless symbol and says so in a header comment: it gates one defect, and this is the other one.

Reproducer

function symChurn(n: number): number {
  let k = 0;
  for (let i = 0; i < n; i++) {
    const t = Symbol("t");
    if (typeof t === "symbol") { k++; }
  }
  return k;
}
function run(): number {
  let bad = 0;
  for (let r = 0; r < 20; r++) {
    const s = Symbol("k");            // description: an untraced StringHeader*
    if (symChurn(50000) !== 50000) { bad++; }
    const o: any = {};
    o[s] = r;
    if (o[s] !== r) { bad++; }        // green — the symbol itself is fine
    const keys = Object.getOwnPropertySymbols(o);
    if (keys.length !== 1) { bad++; } // green
    if (String(keys[0]) !== "Symbol(k)") { bad++; }   // RED 2/20
  }
  return bad;
}
console.log("B", run());

Compile and run with PERRY_GC_MOVING_LOOP_POLLS=1; run additionally with
PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off PERRY_GC_FORCE_EVACUATE=1.
Node prints B 0.

Symbol churn is load-bearing: copied_minor_malloc_sweep_due gates the malloc sweep on a GcTriggerKind::MallocCount collection or the malloc-object threshold, and object/array churn reaches the arena trigger instead.

Why it has mostly not bitten

The quoted comment's own answer — "in practice they live for the duration of the program" — plus the conservative stack scan, which pins whatever happens to be in registers. Both are properties the moving-GC work keeps eroding, which is the same "closed by accident" argument #7226 recorded for #7213.

Fix shape (not decided here)

Three candidates, in increasing order of blast radius:

  1. Give symbols their own GC type. GC_TYPE_SYMBOL with a descriptor that traces the one description slot. Principled and complete. Touches the GC type table and its verification contract, plus everything that assumes a symbol is GC_TYPE_STRING (is_symbol_pointer, heap_snapshot.rs, dead_owner.rs).
  2. Trace the description from the symbol side table. scan_symbol_side_table_roots_mut already enumerates every symbol pointer; visiting (*ptr).description as a real (marking, rewriting) root would close it — but only if the visit is conditional on the symbol itself being live, or every symbol ever created retains its description string forever.
  3. Intern descriptions off the GC heap. REGISTERED_SYMBOL_DESCRIPTIONS already does exactly this for well-known and registered symbols, with readers materializing a fresh StringHeader on demand. Extending it to fresh symbols removes the pointer entirely — at the cost of retaining a Rust String per symbol, which a workload that makes millions of symbols would feel.

Whichever is chosen, the acceptance test is the reproducer above going green on the eleven PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off arms, at which point test_gap_gc_symbol_local_rooting.ts can drop its descriptionless-symbol carve-out and the header comment explaining it.

Refs #7236, #7243, #7235, #7230, #7226.

Activity

  1. proggeramlug commented on Aug 2, 2026

    @proggeramlug
    ContributorAuthor

    Confirmed still open on main = d4ac65f21, as this issue predicted it would be. Re-verified in the sweep #7262.

    Your reproducer, compiled with no compile-time GC env (i.e. not relying on PERRY_GC_MOVING_LOOP_POLLS=1), on the allocation-point arm:

    PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off ./sym
    → B 2      10/10        (node 26.5.1: B 0)
    
    + PERRY_GC_FORCE_EVACUATE=1
    → B 2      10/10
    

    Movement confirmed: 88 copying minors, 15 504 objects copied — this is not a masked collection, and the symbol churn is doing what the issue says it must (reaching the MallocCount trigger rather than the arena one).

    Structurally unchanged by #7243, exactly as argued here:

    • symbol.rs:370 alloc_symbol still allocates gc_malloc(size_of::<SymbolHeader>(), GC_TYPE_STRING), with the quoted comment intact;
    • there is no GC_TYPE_SYMBOL anywhere in the tree — grep -rn GC_TYPE_SYMBOL crates/perry-runtime/src/ returns nothing — so no descriptor can distinguish a symbol's payload from a string's, and nothing can trace (*ptr).description.

    #7243 is verified fixed separately (the Type::Symbol local classification, test_gap_gc_symbol_local_rooting 10/10 green here). This is the other defect, and the descriptionless-symbol design of that gap file remains the correct choice.

  2. proggeramlug commented on Aug 3, 2026

    @proggeramlug
    ContributorAuthor

    Heads-up before anyone invests here — this issue is in a class that a planned architectural change would make unrepresentable, and I don't want someone building new machinery that gets deleted.

    See docs/src/internals/rfc-rooting-by-construction.md ("Where this sits in the one GC correctness plan", landed in #7292). Short version: this is layer 2 — a GC-managed pointer live in emitted machine code across a point where the collector can run. Statepoints/stack maps (#7108, #7174, enabled by #7241) would have LLVM track that liveness precisely, so the whole shape stops being expressible.

    This is not "don't fix it". The architectural change is weeks away, the bug is real now, and correctness comes first — please do fix it.

    It is "fix the instance, don't build new mechanism." Specifically:

    • A targeted root/RuntimeHandleScope/GcSuppressScope at the site: good, lands now, survives.
    • A new predicate, a new shadow-slot allocation scheme, or a new hand-maintained table deciding what to root: please raise it first. codegen: ClassExprFresh leaves the class object unrooted across its own js_object_set_field_by_name stores #7211 is the cautionary case — a four-clause predicate written by someone actively thinking about rooting, still wrong, because it asked about the user's expressions rather than the lowering's own emitted calls. Statepoints delete that entire category of judgement call.

    Also worth knowing when you verify: PERRY_GC_MOVING_LOOP_POLLS=1 set at run time against a default-compiled binary is clean 10/10 and proves nothing — the poll codegen isn't in the binary. Compile-time and run-time env must both be set (#7280). And a green run that performed zero copying minors proves nothing either (#7255) — assert copied_objects > 0.

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