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:
- 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).
- 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.
- 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.
Summary
A
Symbol'sdescriptionis a*mut StringHeaderstored insideSymbolHeader, 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, andString(sym)/sym.description/console.log(sym)then read recycled memory.alloc_symbolstates both halves as a design choice and as a known gap:GC_TYPE_STRING's type-info entry (gc/types.rs) ispointer_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_POINTERSdoes not close it either:scan_symbol_pointer_metadata_roots_mut(symbol/gc_roots.rs) visits the set withvisit_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::Symboltreated as an immediate, so aSymbollocal 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:evac_minor,force_evac,force_verify,loop_polls,rep_*(11 arms — every arm withPERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off)default,verify_evac,cons_scan_off,cons_scan_off_force,gen_gc_off,wb_off,shipped_default, …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 17withtypeof 0. After #7243 the symbol object survives — and the residual is stillstring 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 ofSymbol("k")— no description, therefore no untraced pointer — takes the same file to 0 FAIL / 21 of 21 byte-exact on every arm. That is whytest-files/test_gap_gc_symbol_local_rooting.tsships with a descriptionless symbol and says so in a header comment: it gates one defect, and this is the other one.Reproducer
Compile and run with
PERRY_GC_MOVING_LOOP_POLLS=1; run additionally withPERRY_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_duegates the malloc sweep on aGcTriggerKind::MallocCountcollection 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:
GC_TYPE_SYMBOLwith a descriptor that traces the onedescriptionslot. Principled and complete. Touches the GC type table and its verification contract, plus everything that assumes a symbol isGC_TYPE_STRING(is_symbol_pointer,heap_snapshot.rs,dead_owner.rs).scan_symbol_side_table_roots_mutalready enumerates every symbol pointer; visiting(*ptr).descriptionas 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.REGISTERED_SYMBOL_DESCRIPTIONSalready does exactly this for well-known and registered symbols, with readers materializing a freshStringHeaderon demand. Extending it to fresh symbols removes the pointer entirely — at the cost of retaining a RustStringper 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=offarms, at which pointtest_gap_gc_symbol_local_rooting.tscan drop its descriptionless-symbol carve-out and the header comment explaining it.Refs #7236, #7243, #7235, #7230, #7226.