Skip to content

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

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.

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