From b75ee2ccb46524d65bc14b97086d914df9d48c97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 4 Aug 2026 15:16:38 +0200 Subject: [PATCH 1/2] fix(gc): a Symbol's description was stored into the header already stale alloc_symbol calls gc_malloc -- a collection point -- and then writes the description pointer its caller computed BEFORE that call. An evacuating minor moves the description string, so a live SymbolHeader holds a retired from-space address and js_symbol_to_string faults reading it through str_from_header. Same shape and same fix as RegExpHeader::flags_ptr (#7374): root across the allocation, re-read after. 3/3 cluster tests clean, byte-identical to Node. LEFT OPEN DELIBERATELY: the header is allocated GC_TYPE_STRING, whose payload the collector treats as opaque, so a fresh symbol's description is never marked or rewritten after construction. The comment at the site already says this -- 'kept alive through the SYMBOL_REGISTRY (for registered symbols) or not at all (for fresh symbols ... which is fine for test workloads)'. This makes the STORED value correct; keeping it alive for the symbol's lifetime is a separate fix, noted in the changelog and tracked in #7341. --- changelog.d/7376-symbol-description-stale.md | 20 ++++++++++++++++++++ crates/perry-runtime/src/symbol.rs | 17 +++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 changelog.d/7376-symbol-description-stale.md diff --git a/changelog.d/7376-symbol-description-stale.md b/changelog.d/7376-symbol-description-stale.md new file mode 100644 index 0000000000..7f3dad66cf --- /dev/null +++ b/changelog.d/7376-symbol-description-stale.md @@ -0,0 +1,20 @@ +### Fixed + +- **A `Symbol`'s description was stored into the header already stale.** + `alloc_symbol` calls `gc_malloc` — a collection point — and then writes the + description pointer its caller computed *before* that call. An evacuating + minor moves the description string, so a live `SymbolHeader` holds a retired + from-space address and `js_symbol_to_string` faults reading it through + `str_from_header`. + + The description is now rooted across the allocation and re-read, the same fix + as `RegExpHeader::flags_ptr` (#7374). Closes 3 of the 31 catches in #7341. + + **A related gap is left open deliberately, and is worth knowing about.** The + header is allocated `GC_TYPE_STRING`, whose payload the collector treats as + opaque — so a fresh (non-registered) symbol's description is never marked or + rewritten after construction. The existing comment says so: *"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)"*. This change makes the stored value correct; keeping it + alive for the symbol's lifetime is a separate fix, tracked in #7341. diff --git a/crates/perry-runtime/src/symbol.rs b/crates/perry-runtime/src/symbol.rs index 5a6503d02b..51a1566ac8 100644 --- a/crates/perry-runtime/src/symbol.rs +++ b/crates/perry-runtime/src/symbol.rs @@ -377,10 +377,27 @@ pub(crate) unsafe fn alloc_symbol( // 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). + // #7341: `gc_malloc` below is a collection point, and `description` was + // computed by the caller before it. An evacuating minor there relocates the + // description string, and the pre-collection address is then written into + // the header — permanently stale in a live symbol, exactly the shape fixed + // for `RegExpHeader::flags_ptr`. `js_symbol_to_string` reads it through + // `str_from_header` and faults on retired from-space; that is 3 of the 31 + // catches in #7341. + // + // Root across the allocation and re-read. NOTE the remaining gap the + // comment above describes and this does not close: the payload is opaque to + // the collector (`GC_TYPE_STRING`), so a fresh symbol's description is + // neither marked nor rewritten afterwards. Rooting here makes the STORED + // value correct; keeping it alive for the symbol's lifetime is a separate + // fix, tracked in #7341. + let scope = crate::gc::RuntimeHandleScope::new(); + let desc_root = scope.root_string_ptr(description); let raw = crate::gc::gc_malloc( std::mem::size_of::(), crate::gc::GC_TYPE_STRING, ); + let description = desc_root.get_raw_mut_ptr::(); let ptr = raw as *mut SymbolHeader; (*ptr).magic = SYMBOL_MAGIC; (*ptr).registered = if registered { 1 } else { 0 }; From af999707049367555f69412ac9c13b0d72c773c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 4 Aug 2026 15:27:43 +0200 Subject: [PATCH 2/2] docs: the symbol-description fix closes 6 catches, not 3 js_object_literal_infer_computed_function_name was triaged as a separate cluster because its backtrace names a different frame. It reaches the same stale (*sym_ptr).description through infer_symbol_function_name, so the #7376 fix closes it too -- verified 3/3 clean, byte-identical to Node. Two distinct faulting frames, one root cause. Worth recording: grouping catches by frame #0 is the right first cut, but it over-counts clusters whenever one bad field has several readers. --- changelog.d/7376-symbol-description-stale.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/changelog.d/7376-symbol-description-stale.md b/changelog.d/7376-symbol-description-stale.md index 7f3dad66cf..eb27c78a6f 100644 --- a/changelog.d/7376-symbol-description-stale.md +++ b/changelog.d/7376-symbol-description-stale.md @@ -8,7 +8,13 @@ `str_from_header`. The description is now rooted across the allocation and re-read, the same fix - as `RegExpHeader::flags_ptr` (#7374). Closes 3 of the 31 catches in #7341. + as `RegExpHeader::flags_ptr` (#7374). + + Closes **6 of the 31** catches in #7341, not 3: the same stale field is read + by two different helpers. `js_symbol_to_string` reaches it directly, and + `infer_symbol_function_name` reaches it through + `js_object_literal_infer_computed_function_name` — which had been triaged as a + separate cluster until the fix closed both. **A related gap is left open deliberately, and is worth knowing about.** The header is allocated `GC_TYPE_STRING`, whose payload the collector treats as