From b67b83176eb1712a8b49f0b1a4e8e0d0b56d5317 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 12:58:46 +0200 Subject: [PATCH 1/5] fix(gc): a Symbol's description no longer lives in an untraced payload slot (#7246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SymbolHeader::description` was a `*mut StringHeader` inside a payload the collector treats as opaque bytes. `alloc_symbol` gc_malloc's the header as `GC_TYPE_STRING`, whose type info is `pointer_free: true` / `GcRewriteDescriptorKind::Leaf` / `GcLayoutSlotKind::None` — correct for a string, whose payload IS bytes, and wrong for a symbol, whose payload's third word was a heap pointer. Symbols and strings share one GC type, so no descriptor could distinguish them. A symbol that was itself perfectly rooted could have its description reaped or relocated out from under it, and `String(sym)` / `sym.description` then read recycled memory. `SYMBOL_POINTERS` did not close it: `scan_symbol_pointer_metadata_roots_mut` uses `visit_metadata_usize_slot`, which rewrites a recorded address WITHOUT marking, and never looks at `(*ptr).description` at all. The pointer is REMOVED rather than traced. `alloc_symbol` copies the description text off the GC heap before it allocates and leaves the field null; `FRESH_SYMBOL_DESCRIPTIONS` holds it, keyed on `SymbolHeader::id`. Why that beat the other two candidates the issue listed: * the key is the ID, which an evacuation copies verbatim — so the table needs no rekey pass, no root scanner and no budgeted step twin (where #7239 found the one real drift). It holds no GC pointer at all. * the text is copied BEFORE the allocation, so there is no window in which a description pointer is live-but-untraced. #7341's `RuntimeHandleScope` + `across_mut` in `alloc_symbol` is gone with it: it made the STORED pointer correct across `gc_malloc`, and there is no longer a stored pointer. * a `GC_TYPE_SYMBOL` would have been the principled fix but touches 190 `GC_TYPE_STRING` sites across runtime and codegen, plus the type table's verification contract. * the descriptions are pruned in `prune_dead_symbol_pointers` on the same liveness verdict that prunes `SYMBOL_POINTERS`, which pays down the retention cost the issue named as this option's price. `alloc_symbol` has exactly two callers, both fresh (`Symbol()` / `Symbol(desc)`); registered and well-known symbols are `Box::leak`'d and keep using the process-global `REGISTERED_SYMBOL_DESCRIPTIONS`. Ids are globally monotonic, so the thread-local and process-global maps never collide. The four readers now go through one `symbol_description_text` helper instead of open-coding the `registered_symbol_description(..).or_else(..)` chain. Witness, the issue's own reproducer, same compiler, A/B across the runtime rebuild (`PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off PERRY_GC_FORCE_EVACUATE=1`): before: B 1 5/5 deterministic after: B 0 10/10 (node 26.5.1: B 0) Movement confirmed live on the after-run: 87 copying minors, `copied_objects` 6008 / 4743 on the two that mattered. Plus three knob-free unit tests in `gc/tests/runtime_roots/symbol_description.rs` — structural (the payload pointer stays null), behavioural (the description survives reclamation of the string it came from, with the from-space bytes recycled into 'Z'-filled strings first so a stale read cannot pass by luck), and the prune. Sabotage-verified: restoring `(*ptr).description = description` fails all three. --- .../src/gc/tests/runtime_roots.rs | 1 + .../tests/runtime_roots/symbol_description.rs | 162 ++++++++++++++++ crates/perry-runtime/src/symbol.rs | 177 ++++++++++++++---- .../perry-runtime/src/symbol/constructors.rs | 38 ++-- crates/perry-runtime/src/symbol/properties.rs | 4 +- 5 files changed, 324 insertions(+), 58 deletions(-) create mode 100644 crates/perry-runtime/src/gc/tests/runtime_roots/symbol_description.rs diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots.rs b/crates/perry-runtime/src/gc/tests/runtime_roots.rs index 8e0d8ae583..ac3d1a8e2f 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots.rs @@ -11,6 +11,7 @@ mod json_shape_template; mod prototype_addr_cache; mod side_table_scanners; mod string_slice; +mod symbol_description; mod transient_handles; fn assert_panics_with(expected: &str, f: impl FnOnce()) { diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/symbol_description.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/symbol_description.rs new file mode 100644 index 0000000000..b8bf105301 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/symbol_description.rs @@ -0,0 +1,162 @@ +//! #7246 — a `Symbol`'s description used to be a `*mut StringHeader` inside +//! `SymbolHeader`, and **the collector never traced or rewrote it**. +//! +//! `alloc_symbol` gc_malloc's the header as `GC_TYPE_STRING`, whose type info +//! is `pointer_free: true` / `GcRewriteDescriptorKind::Leaf` / +//! `GcLayoutSlotKind::None`, so nothing walks into the payload. That is right +//! for a *string*, whose payload is bytes, and wrong for a *symbol*, whose +//! payload's third word was a heap pointer — and symbols and strings share one +//! GC type, so no descriptor could distinguish them. A symbol that was itself +//! perfectly rooted could have its description reaped or relocated out from +//! under it, and `String(sym)` / `sym.description` then read recycled memory. +//! +//! `SYMBOL_POINTERS` did not close it either: +//! `scan_symbol_pointer_metadata_roots_mut` visits the set with +//! `visit_metadata_usize_slot`, which rewrites a recorded address **without +//! marking**, and never looks at `(*ptr).description` at all. +//! +//! The fix removes the pointer instead of tracing it: the text is copied off +//! the GC heap at allocation time into an id-keyed thread-local map, and the +//! field is left null. So the strongest assertion here is structural — there +//! is no untraced pointer to get wrong — and it is the one a future change +//! would trip first. + +use super::*; +use crate::symbol::SymbolHeader; + +fn symbol_ptr_bits(sym: *mut SymbolHeader) -> u64 { + ptr_bits(sym as usize) +} + +/// STRUCTURAL. `SymbolHeader::description` must stay null: the whole point of +/// #7246's fix is that there is no longer a heap pointer in a payload the +/// collector treats as opaque bytes. A change that re-populates this field +/// re-opens the defect whether or not any behavioural test happens to catch it +/// on the day. +#[test] +fn a_fresh_symbol_stores_no_heap_pointer_in_its_payload() { + let _guard = CopyingNurseryTestGuard::new(0); + let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + crate::symbol::test_clear_fresh_symbol_descriptions(); + + unsafe { + let desc = crate::string::js_string_from_bytes(b"k".as_ptr(), 1); + let sym = crate::symbol::alloc_symbol(desc, false); + assert!( + (*sym).description.is_null(), + "SymbolHeader::description must stay null — GC_TYPE_STRING is a \ + pointer-free Leaf, so anything stored there is neither marked nor \ + rewritten (#7246)" + ); + // …and the description is still readable, off-heap. + let rendered = crate::symbol::js_symbol_to_string(f64::from_bits(symbol_ptr_bits(sym))); + assert_string_bytes(rendered as *const crate::StringHeader, b"Symbol(k)"); + } + + crate::symbol::test_clear_fresh_symbol_descriptions(); +} + +/// BEHAVIOURAL. The description string is referenced by nothing once +/// `alloc_symbol` has copied its text, so a collection reclaims it and the +/// from-space bytes are recycled into later allocations. The symbol itself is +/// kept alive in a shadow slot — that is deliberately NOT the subject; the +/// subject is whether its description survives. +/// +/// The recycling loop after the collection is load-bearing. Without it a stale +/// read can still find the old bytes intact and the test passes for the wrong +/// reason — the classic vacuous GC probe. +#[test] +fn a_symbols_description_survives_reclamation_of_the_string_it_came_from() { + let _guard = CopyingNurseryTestGuard::new(1); + let trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + register_runtime_handle_root_scanner_for_tests(); + crate::symbol::test_clear_fresh_symbol_descriptions(); + + let sym = unsafe { + let desc = crate::string::js_string_from_bytes(b"k".as_ptr(), 1); + crate::symbol::alloc_symbol(desc, false) + }; + let desc_addr_before = unsafe { (*sym).description as usize }; + js_shadow_slot_set(0, symbol_ptr_bits(sym)); + + force_next_general_arena_alloc_slow(); + trigger_guard.make_arena_trigger_due(); + let before = gc_collection_count(); + let _drive = crate::string::js_string_from_bytes(b"drive".as_ptr(), 5); + drain_scheduled_minor_gc(before, "description string reclamation"); + + // Recycle the retired bytes: 512 fresh strings of a distinctive byte, so a + // stale read finds 'Z's rather than the original 'k'. + for _ in 0..512 { + let filler = [b'Z'; 32]; + let _ = crate::string::js_string_from_bytes(filler.as_ptr(), filler.len() as u32); + } + + let sym_after = (js_shadow_slot_get(0) & POINTER_MASK) as *mut SymbolHeader; + unsafe { + let rendered = + crate::symbol::js_symbol_to_string(f64::from_bits(symbol_ptr_bits(sym_after))); + assert_string_bytes(rendered as *const crate::StringHeader, b"Symbol(k)"); + } + assert_eq!( + desc_addr_before, 0, + "the payload pointer must have been null all along (#7246); a non-null \ + value here means this test measured the OLD representation and its \ + green result says nothing" + ); + + crate::symbol::test_clear_fresh_symbol_descriptions(); +} + +/// The price of interning off-heap is retention, and the issue named it: +/// "a workload that makes millions of symbols would feel it". `prune_dead_symbol_pointers` +/// pays it down — the description map is keyed on the symbol id, so the same +/// liveness verdict that prunes `SYMBOL_POINTERS` prunes the descriptions. +/// +/// Asserted rather than assumed: without the prune, this table is a leak with a +/// doc comment claiming otherwise. +#[test] +fn dead_symbols_descriptions_are_pruned_with_their_pointers() { + let _guard = CopyingNurseryTestGuard::new(1); + let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + crate::symbol::test_clear_symbol_side_table_roots(); + crate::symbol::test_clear_fresh_symbol_descriptions(); + + // One symbol we keep, and several we abandon. + let keeper = unsafe { + let desc = crate::string::js_string_from_bytes(b"keeper".as_ptr(), 6); + crate::symbol::alloc_symbol(desc, false) + }; + js_shadow_slot_set(0, symbol_ptr_bits(keeper)); + for _ in 0..8 { + unsafe { + let desc = crate::string::js_string_from_bytes(b"doomed".as_ptr(), 6); + let _ = crate::symbol::alloc_symbol(desc, false); + } + } + let seeded = crate::symbol::test_fresh_symbol_description_count(); + assert_eq!( + seeded, 9, + "setup did not record one description per described symbol" + ); + + // Prune with a predicate that declares everything except the keeper dead — + // the same shape `gc::dead_owner` hands `prune_dead_symbol_pointers`. + let keeper_addr = keeper as usize; + crate::symbol::prune_dead_symbol_pointers(&|ptr| ptr != keeper_addr); + + assert_eq!( + crate::symbol::test_fresh_symbol_description_count(), + 1, + "descriptions of dead symbols must be pruned alongside their pointers — \ + otherwise interning off-heap trades a use-after-free for an unbounded \ + leak (#7246)" + ); + unsafe { + let rendered = crate::symbol::js_symbol_to_string(f64::from_bits(symbol_ptr_bits(keeper))); + assert_string_bytes(rendered as *const crate::StringHeader, b"Symbol(keeper)"); + } + + crate::symbol::test_clear_fresh_symbol_descriptions(); + crate::symbol::test_clear_symbol_side_table_roots(); +} diff --git a/crates/perry-runtime/src/symbol.rs b/crates/perry-runtime/src/symbol.rs index 938438f87c..541fb26e2e 100644 --- a/crates/perry-runtime/src/symbol.rs +++ b/crates/perry-runtime/src/symbol.rs @@ -76,6 +76,7 @@ pub(crate) use gc_roots::{ }; use crate::string::StringHeader; +use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::sync::Mutex; @@ -136,6 +137,85 @@ pub(crate) fn registered_symbol_description(sym_ptr: usize) -> Option` per symbol forever. + /// + /// The process-global `REGISTERED_SYMBOL_DESCRIPTIONS` above stays as it + /// is: registered and well-known symbols are `Box::leak`'d and shared + /// across `perry/thread` agents, so their descriptions must be + /// process-global. Fresh symbols are per-thread GC objects, so theirs are + /// thread-local. Ids are globally monotonic, so the two never collide. + static FRESH_SYMBOL_DESCRIPTIONS: RefCell>> = + RefCell::new(HashMap::new()); +} + +#[cfg(test)] +pub(crate) fn test_clear_fresh_symbol_descriptions() { + FRESH_SYMBOL_DESCRIPTIONS.with(|m| m.borrow_mut().clear()); +} + +/// The description text of `sym_ptr`, wherever it is kept. +/// +/// One helper rather than the `registered_symbol_description(..).or_else(..)` +/// chain each reader used to spell out: there are four readers, and a fifth +/// that forgot the fallback is exactly how a description goes silently missing. +pub(crate) unsafe fn symbol_description_text( + sym_ptr: *const SymbolHeader, +) -> Option> { + if sym_ptr.is_null() || (sym_ptr as usize) < 0x1000 { + return None; + } + if let Some(text) = registered_symbol_description(sym_ptr as usize) { + return Some(text); + } + let id = (*sym_ptr).id; + if let Some(text) = FRESH_SYMBOL_DESCRIPTIONS.with(|m| m.borrow().get(&id).cloned()) { + return Some(text); + } + // Legacy fallback: any symbol whose description still lives in the payload + // (nothing populates this today — `alloc_symbol` nulls it — but the field + // is still readable and a stale reader would otherwise silently return + // `None` instead of a description). + str_from_header((*sym_ptr).description).map(std::sync::Arc::from) +} + +fn record_fresh_symbol_description(id: u64, description: &str) { + FRESH_SYMBOL_DESCRIPTIONS + .with(|m| m.borrow_mut().insert(id, std::sync::Arc::from(description))); +} + +#[cfg(test)] +pub(crate) fn test_fresh_symbol_description_count() -> usize { + FRESH_SYMBOL_DESCRIPTIONS.with(|m| m.borrow().len()) +} + pub(crate) fn record_registered_symbol_description(sym_ptr: usize, description: &str) { let mut guard = REGISTERED_SYMBOL_DESCRIPTIONS.lock().unwrap(); if guard.is_none() { @@ -346,9 +426,33 @@ pub(crate) fn prune_dead_symbol_property_owners(is_dead_owner: &dyn Fn(usize) -> /// entry behind permanently (the address no longer attributes) — fixing /// that needs a dedicated symbol GC type with a finalize hook. pub(crate) fn prune_dead_symbol_pointers(is_dead_symbol: &dyn Fn(usize) -> bool) { - let mut guard = crate::gc::lock_gc_root_registry(&SYMBOL_POINTERS); - if let Some(set) = guard.as_mut() { - set.retain(|&ptr| !is_dead_symbol(ptr)); + let mut live_ids: Vec = Vec::new(); + { + let mut guard = crate::gc::lock_gc_root_registry(&SYMBOL_POINTERS); + if let Some(set) = guard.as_mut() { + set.retain(|&ptr| !is_dead_symbol(ptr)); + // #7246: the surviving symbols' ids, read while the lock is held and + // every remaining address is known live. Reading `(*ptr).id` of a + // symbol the predicate has just rejected would be a read of freed + // memory, which is why this is a second pass over the RETAINED set + // rather than a filter inside `retain`. + live_ids.reserve(set.len()); + for &ptr in set.iter() { + live_ids.push(unsafe { (*(ptr as *const SymbolHeader)).id }); + } + } + } + // #7246: descriptions are keyed on the id, so they are pruned by the same + // liveness verdict. Without this a `Symbol("x")` churn loop would retain one + // `Arc` per symbol for the life of the process — the cost the issue + // named as this fix's price, paid down here. + // + // Only prune when we actually observed a live set: an empty `SYMBOL_POINTERS` + // (uninitialised registry, or a thread that has allocated no symbols) must + // not be read as "every description is dead". + if !live_ids.is_empty() { + let live: HashSet = live_ids.into_iter().collect(); + FRESH_SYMBOL_DESCRIPTIONS.with(|m| m.borrow_mut().retain(|id, _| live.contains(id))); } } @@ -377,41 +481,46 @@ 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). - // #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. + // Allocated via gc_malloc as a leaf: `GC_TYPE_STRING`'s type info is + // `pointer_free: true` / `GcRewriteDescriptorKind::Leaf` / + // `GcLayoutSlotKind::None`, so nothing walks into the payload. // - // 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); - // `gc_malloc` can collect, so the description's address is only valid - // after it; `across_mut` binds the two together (#7341). - let (raw, description) = desc_root.across_mut::(|| { - crate::gc::gc_malloc( - std::mem::size_of::(), - crate::gc::GC_TYPE_STRING, - ) - }); + // ★ #7246. That was correct for a *string*, whose payload is bytes, and + // WRONG for a *symbol*, whose payload's third word used to be a + // `*mut StringHeader`. Symbols and strings share one GC type, so no + // descriptor could distinguish them and the description was never traced or + // rewritten: a perfectly rooted symbol could have its description reaped or + // relocated out from under it, and `String(sym)` / `sym.description` then + // read recycled memory. `SYMBOL_POINTERS` did not close it either — + // `scan_symbol_pointer_metadata_roots_mut` uses `visit_metadata_usize_slot`, + // which rewrites a recorded address WITHOUT marking, and never looks at + // `(*ptr).description` at all. + // + // The pointer is now gone rather than traced. Copy the text off the GC heap + // BEFORE allocating — so there is never a window in which a description + // pointer is live-but-untraced — and leave the field null. + // `FRESH_SYMBOL_DESCRIPTIONS` is keyed on the symbol's `id`, which an + // evacuation copies verbatim, so that table needs no rekey, no scanner and + // no budgeted step twin. See its declaration for why this beat a + // `GC_TYPE_SYMBOL` and beat tracing from the side table. + // + // (#7341's `RuntimeHandleScope` + `across_mut` here is therefore gone too: + // it made the STORED pointer correct across `gc_malloc`, and there is no + // longer a stored pointer. Nothing is live across the allocation.) + let description_text = str_from_header(description); + let raw = crate::gc::gc_malloc( + std::mem::size_of::(), + crate::gc::GC_TYPE_STRING, + ); let ptr = raw as *mut SymbolHeader; + let id = next_id(); (*ptr).magic = SYMBOL_MAGIC; (*ptr).registered = if registered { 1 } else { 0 }; - (*ptr).description = description; - (*ptr).id = next_id(); + (*ptr).description = std::ptr::null_mut(); + (*ptr).id = id; + if let Some(text) = description_text { + record_fresh_symbol_description(id, &text); + } register_symbol_pointer(ptr as usize); ptr } diff --git a/crates/perry-runtime/src/symbol/constructors.rs b/crates/perry-runtime/src/symbol/constructors.rs index e1da5b1eb0..3cb1af47c4 100644 --- a/crates/perry-runtime/src/symbol/constructors.rs +++ b/crates/perry-runtime/src/symbol/constructors.rs @@ -206,15 +206,14 @@ pub unsafe extern "C" fn js_symbol_key_for(sym_f64: f64) -> f64 { } // Registered symbols carry the description as Arc in the side // table; materialize a fresh StringHeader in this thread's arena. - if let Some(s) = registered_symbol_description(sym_ptr as usize) { - let header = js_string_from_bytes(s.as_bytes().as_ptr(), s.len() as u32); - return f64::from_bits(STRING_TAG | (header as u64 & POINTER_MASK)); - } - let desc = (*sym_ptr).description; - if desc.is_null() { + // #7246: descriptions live off the GC heap now (registered ones in the + // process-global map, fresh ones in the id-keyed thread-local one), so + // every reader materializes a fresh StringHeader in the CALLER's arena. + let Some(s) = symbol_description_text(sym_ptr) else { return f64::from_bits(TAG_UNDEFINED); - } - f64::from_bits(STRING_TAG | (desc as u64 & POINTER_MASK)) + }; + let header = js_string_from_bytes(s.as_bytes().as_ptr(), s.len() as u32); + f64::from_bits(STRING_TAG | (header as u64 & POINTER_MASK)) } /// `sym.description` — returns the original description or undefined. @@ -233,15 +232,14 @@ pub unsafe extern "C" fn js_symbol_description(sym_f64: f64) -> f64 { if (*sym_ptr).magic != SYMBOL_MAGIC { return f64::from_bits(TAG_UNDEFINED); } - if let Some(s) = registered_symbol_description(sym_ptr as usize) { - let header = js_string_from_bytes(s.as_bytes().as_ptr(), s.len() as u32); - return f64::from_bits(STRING_TAG | (header as u64 & POINTER_MASK)); - } - let desc = (*sym_ptr).description; - if desc.is_null() { + // #7246: descriptions live off the GC heap now (registered ones in the + // process-global map, fresh ones in the id-keyed thread-local one), so + // every reader materializes a fresh StringHeader in the CALLER's arena. + let Some(s) = symbol_description_text(sym_ptr) else { return f64::from_bits(TAG_UNDEFINED); - } - f64::from_bits(STRING_TAG | (desc as u64 & POINTER_MASK)) + }; + let header = js_string_from_bytes(s.as_bytes().as_ptr(), s.len() as u32); + f64::from_bits(STRING_TAG | (header as u64 & POINTER_MASK)) } /// `sym.toString()` — returns "Symbol(description)" as a StringHeader pointer. @@ -259,11 +257,9 @@ pub unsafe extern "C" fn js_symbol_to_string(sym_f64: f64) -> i64 { let s = b"Symbol()"; return js_string_from_bytes(s.as_ptr(), s.len() as u32) as i64; } - let desc_str = if let Some(s) = registered_symbol_description(sym_ptr as usize) { - s.as_ref().to_string() - } else { - str_from_header((*sym_ptr).description).unwrap_or_default() - }; + let desc_str = symbol_description_text(sym_ptr) + .map(|s| s.as_ref().to_string()) + .unwrap_or_default(); let rendered = format!("Symbol({})", desc_str); js_string_from_bytes(rendered.as_ptr(), rendered.len() as u32) as i64 } diff --git a/crates/perry-runtime/src/symbol/properties.rs b/crates/perry-runtime/src/symbol/properties.rs index 235f946ae5..31b300dfba 100644 --- a/crates/perry-runtime/src/symbol/properties.rs +++ b/crates/perry-runtime/src/symbol/properties.rs @@ -268,9 +268,7 @@ unsafe fn infer_symbol_function_name(sym_key: usize, val_bits: u64) { // empty string `""`; a symbol with a (possibly empty) string description // names it `"[" + description + "]"`. Distinguish "no description" (→ `""`) // from `Symbol("")` (→ `"[]"`). - let desc = registered_symbol_description(sym_ptr as usize) - .map(|s| s.as_ref().to_string()) - .or_else(|| str_from_header((*sym_ptr).description)); + let desc = symbol_description_text(sym_ptr).map(|s| s.as_ref().to_string()); let inferred = match desc { Some(d) => format!("[{}]", d), None => String::new(), From 396c8ef0565681044d9d50c22f80eb6c5e0efd83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 12:59:56 +0200 Subject: [PATCH 2/5] changelog: 7697 symbol description offheap --- .../7697-symbol-description-offheap.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 changelog.d/7697-symbol-description-offheap.md diff --git a/changelog.d/7697-symbol-description-offheap.md b/changelog.d/7697-symbol-description-offheap.md new file mode 100644 index 0000000000..234ce27d0a --- /dev/null +++ b/changelog.d/7697-symbol-description-offheap.md @@ -0,0 +1,40 @@ +### Fixed + +- **A `Symbol`'s description no longer lives in an untraced payload slot (#7246).** + `SymbolHeader::description` was a `*mut StringHeader` inside a payload the collector + treats as opaque bytes: `alloc_symbol` gc_malloc's the header as `GC_TYPE_STRING`, + whose type info is `pointer_free: true` / `GcRewriteDescriptorKind::Leaf` / + `GcLayoutSlotKind::None`. That is right for a *string*, whose payload is bytes, and + wrong for a *symbol*, whose payload's third word was a heap pointer — and the two + share one GC type, so no descriptor could distinguish them. A symbol that was itself + perfectly rooted could have its description reaped or relocated out from under it, + and `String(sym)` / `sym.description` then read recycled memory. `SYMBOL_POINTERS` + did not close it: `scan_symbol_pointer_metadata_roots_mut` rewrites recorded + addresses *without* marking and never looks at `(*ptr).description`. + + The pointer is removed rather than traced. `alloc_symbol` copies the description text + off the GC heap *before* it allocates — so a description pointer is never + live-but-untraced — and leaves the field null; the text goes into + `FRESH_SYMBOL_DESCRIPTIONS`, keyed on `SymbolHeader::id`. The key is the point: an id + is copied verbatim by an evacuation, so that table needs no rekey pass, no root + scanner, and no budgeted step twin (the shape #7239 found the one real drift in). A + `GC_TYPE_SYMBOL` would have been the principled fix and touches 190 `GC_TYPE_STRING` + sites; tracing the description from the side table needs a weak-table liveness + ordering the side table cannot supply. Descriptions are pruned in + `prune_dead_symbol_pointers` on the same verdict that prunes the pointers, which pays + down the retention cost the issue named as this option's price. + + Blast radius stays small because `alloc_symbol` has exactly two callers, both fresh + symbols; registered and well-known symbols are `Box::leak`'d and keep the + process-global `REGISTERED_SYMBOL_DESCRIPTIONS`. The four readers now share one + `symbol_description_text` helper. + + Witness — the issue's own reproducer, A/B across the runtime rebuild with the same + compiler, under `PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 + PERRY_CONSERVATIVE_STACK_SCAN=off PERRY_GC_FORCE_EVACUATE=1`: **`B 1` 5/5 before, + `B 0` 10/10 after** (node 26.5.1: `B 0`), with movement confirmed live on the + after-run (87 copying minors, `copied_objects` 6008 and 4743). Plus three knob-free + unit tests — structural (the payload pointer stays null), behavioural (the + description survives reclamation of the string it came from, with from-space bytes + recycled into `'Z'`-filled strings first so a stale read cannot pass by luck), and the + prune. Sabotage-verified: restoring `(*ptr).description = description` fails all three. From e52b2990b3f1678b1e6fd2721f836cc3fca9c79d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 13:07:47 +0200 Subject: [PATCH 3/5] fix(gc): keep a symbol's interned description as raw bytes, not str (#7246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `str_from_header` UTF-8-validates and returns `None` on failure, and a description built from a JS string carrying a lone surrogate is WTF-8, not UTF-8. Interning through `String` would therefore have turned a lone-surrogate `sym.description` from a string into `undefined` — a behaviour change smuggled in on a GC fix, in an area CLAUDE.md already lists as a known gap. The interned description is now `Arc<[u8]>` and round-trips through `js_string_from_bytes` unchanged. `js_symbol_to_string` still renders lossily, which is what `str_from_header(..).unwrap_or_default()` did before: a WTF-8 description was never formattable into a Rust `String` losslessly. Residual, stated in the code rather than hidden: the rebuilt `StringHeader` does not carry `STRING_FLAG_HAS_LONE_SURROGATES`, because the original flag is not recoverable from the payload. Pre-existing WTF-8 gap, and strictly better than dropping the description. --- crates/perry-runtime/src/symbol.rs | 36 +++++++++++++++---- .../perry-runtime/src/symbol/constructors.rs | 9 +++-- crates/perry-runtime/src/symbol/properties.rs | 3 +- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/crates/perry-runtime/src/symbol.rs b/crates/perry-runtime/src/symbol.rs index 541fb26e2e..5ad5a6da2d 100644 --- a/crates/perry-runtime/src/symbol.rs +++ b/crates/perry-runtime/src/symbol.rs @@ -172,7 +172,19 @@ thread_local! { /// across `perry/thread` agents, so their descriptions must be /// process-global. Fresh symbols are per-thread GC objects, so theirs are /// thread-local. Ids are globally monotonic, so the two never collide. - static FRESH_SYMBOL_DESCRIPTIONS: RefCell>> = + /// Stored as raw BYTES, not `str`. `str_from_header` UTF-8-validates and + /// returns `None` on failure, and a description built from a JS string with + /// a lone surrogate is WTF-8, not UTF-8. Interning through `String` would + /// therefore have turned a lone-surrogate `sym.description` into + /// `undefined` — a behaviour change smuggled in on a GC fix. Raw bytes + /// round-trip through `js_string_from_bytes` unchanged. + /// + /// Residual, stated rather than hidden: the rebuilt `StringHeader` does not + /// carry `STRING_FLAG_HAS_LONE_SURROGATES`, because the original flag is + /// not recoverable from the payload. That is the pre-existing WTF-8 gap + /// CLAUDE.md already lists, not a new one, and it is strictly better than + /// dropping the description. + static FRESH_SYMBOL_DESCRIPTIONS: RefCell>> = RefCell::new(HashMap::new()); } @@ -188,12 +200,12 @@ pub(crate) fn test_clear_fresh_symbol_descriptions() { /// that forgot the fallback is exactly how a description goes silently missing. pub(crate) unsafe fn symbol_description_text( sym_ptr: *const SymbolHeader, -) -> Option> { +) -> Option> { if sym_ptr.is_null() || (sym_ptr as usize) < 0x1000 { return None; } if let Some(text) = registered_symbol_description(sym_ptr as usize) { - return Some(text); + return Some(std::sync::Arc::from(text.as_bytes())); } let id = (*sym_ptr).id; if let Some(text) = FRESH_SYMBOL_DESCRIPTIONS.with(|m| m.borrow().get(&id).cloned()) { @@ -203,10 +215,22 @@ pub(crate) unsafe fn symbol_description_text( // (nothing populates this today — `alloc_symbol` nulls it — but the field // is still readable and a stale reader would otherwise silently return // `None` instead of a description). - str_from_header((*sym_ptr).description).map(std::sync::Arc::from) + description_bytes_from_header((*sym_ptr).description).map(std::sync::Arc::from) +} + +/// The raw payload bytes of a description `StringHeader`, WITHOUT UTF-8 +/// validation. `str_from_header` validates and would drop a WTF-8 description +/// on the floor (#7246). +unsafe fn description_bytes_from_header(ptr: *const StringHeader) -> Option> { + if ptr.is_null() || (ptr as usize) < 0x1000 { + return None; + } + let len = (*ptr).byte_len as usize; + let data = (ptr as *const u8).add(std::mem::size_of::()); + Some(std::slice::from_raw_parts(data, len).to_vec()) } -fn record_fresh_symbol_description(id: u64, description: &str) { +fn record_fresh_symbol_description(id: u64, description: &[u8]) { FRESH_SYMBOL_DESCRIPTIONS .with(|m| m.borrow_mut().insert(id, std::sync::Arc::from(description))); } @@ -507,7 +531,7 @@ pub(crate) unsafe fn alloc_symbol( // (#7341's `RuntimeHandleScope` + `across_mut` here is therefore gone too: // it made the STORED pointer correct across `gc_malloc`, and there is no // longer a stored pointer. Nothing is live across the allocation.) - let description_text = str_from_header(description); + let description_text = description_bytes_from_header(description); let raw = crate::gc::gc_malloc( std::mem::size_of::(), crate::gc::GC_TYPE_STRING, diff --git a/crates/perry-runtime/src/symbol/constructors.rs b/crates/perry-runtime/src/symbol/constructors.rs index 3cb1af47c4..34c8fa7026 100644 --- a/crates/perry-runtime/src/symbol/constructors.rs +++ b/crates/perry-runtime/src/symbol/constructors.rs @@ -212,7 +212,7 @@ pub unsafe extern "C" fn js_symbol_key_for(sym_f64: f64) -> f64 { let Some(s) = symbol_description_text(sym_ptr) else { return f64::from_bits(TAG_UNDEFINED); }; - let header = js_string_from_bytes(s.as_bytes().as_ptr(), s.len() as u32); + let header = js_string_from_bytes(s.as_ptr(), s.len() as u32); f64::from_bits(STRING_TAG | (header as u64 & POINTER_MASK)) } @@ -238,7 +238,7 @@ pub unsafe extern "C" fn js_symbol_description(sym_f64: f64) -> f64 { let Some(s) = symbol_description_text(sym_ptr) else { return f64::from_bits(TAG_UNDEFINED); }; - let header = js_string_from_bytes(s.as_bytes().as_ptr(), s.len() as u32); + let header = js_string_from_bytes(s.as_ptr(), s.len() as u32); f64::from_bits(STRING_TAG | (header as u64 & POINTER_MASK)) } @@ -257,8 +257,11 @@ pub unsafe extern "C" fn js_symbol_to_string(sym_f64: f64) -> i64 { let s = b"Symbol()"; return js_string_from_bytes(s.as_ptr(), s.len() as u32) as i64; } + // Lossy only for the rendered `Symbol(...)` form, matching what + // `str_from_header(..).unwrap_or_default()` produced before (#7246): a + // WTF-8 description could never be formatted into a Rust `String` losslessly. let desc_str = symbol_description_text(sym_ptr) - .map(|s| s.as_ref().to_string()) + .map(|s| String::from_utf8_lossy(s.as_ref()).into_owned()) .unwrap_or_default(); let rendered = format!("Symbol({})", desc_str); js_string_from_bytes(rendered.as_ptr(), rendered.len() as u32) as i64 diff --git a/crates/perry-runtime/src/symbol/properties.rs b/crates/perry-runtime/src/symbol/properties.rs index 31b300dfba..e2d50b99dd 100644 --- a/crates/perry-runtime/src/symbol/properties.rs +++ b/crates/perry-runtime/src/symbol/properties.rs @@ -268,7 +268,8 @@ unsafe fn infer_symbol_function_name(sym_key: usize, val_bits: u64) { // empty string `""`; a symbol with a (possibly empty) string description // names it `"[" + description + "]"`. Distinguish "no description" (→ `""`) // from `Symbol("")` (→ `"[]"`). - let desc = symbol_description_text(sym_ptr).map(|s| s.as_ref().to_string()); + let desc = + symbol_description_text(sym_ptr).map(|s| String::from_utf8_lossy(s.as_ref()).into_owned()); let inferred = match desc { Some(d) => format!("[{}]", d), None => String::new(), From 4ca77197c3d64f3c9996c23c8469396c35a28afa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 15:29:35 +0200 Subject: [PATCH 4/5] chore: bump version to 0.5.1400 Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --- CLAUDE.md | 2 +- Cargo.lock | 152 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 78 insertions(+), 78 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2cecc33957..772f16009f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1399 +**Current Version:** 0.5.1400 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index e00904a5c0..d0b104578d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1399" +version = "0.5.1400" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1399" +version = "0.5.1400" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1399" +version = "0.5.1400" [[package]] name = "perry-ui-tvos" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1399" +version = "0.5.1400" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index d8ee25823d..eb35b33d7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1399" +version = "0.5.1400" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" From 6220ac3d811fcee3a98270338da09d2545b250fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 15:35:22 +0200 Subject: [PATCH 5/5] fix(gc): use is_above_handle_band, not a bare address floor (#7246) addr_class_inventory ratcheted symbol.rs at 3 handle-floor sites; the two new dereference guards added a 4th and 5th. A bare < 0x1000 floor does not reject the fetch/zlib/proxy handle bands, which segfault on Linux. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --- crates/perry-runtime/src/symbol.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/perry-runtime/src/symbol.rs b/crates/perry-runtime/src/symbol.rs index 5ad5a6da2d..9855e0b0d5 100644 --- a/crates/perry-runtime/src/symbol.rs +++ b/crates/perry-runtime/src/symbol.rs @@ -201,7 +201,10 @@ pub(crate) fn test_clear_fresh_symbol_descriptions() { pub(crate) unsafe fn symbol_description_text( sym_ptr: *const SymbolHeader, ) -> Option> { - if sym_ptr.is_null() || (sym_ptr as usize) < 0x1000 { + // #1843/#6271: a bare `< 0x1000` floor does not reject the fetch/zlib/proxy + // handle bands, and dereferencing one segfaults on Linux while macOS hides + // it. `is_above_handle_band` is the predicate that does. + if sym_ptr.is_null() || !crate::value::addr_class::is_above_handle_band(sym_ptr as usize) { return None; } if let Some(text) = registered_symbol_description(sym_ptr as usize) { @@ -222,7 +225,8 @@ pub(crate) unsafe fn symbol_description_text( /// validation. `str_from_header` validates and would drop a WTF-8 description /// on the floor (#7246). unsafe fn description_bytes_from_header(ptr: *const StringHeader) -> Option> { - if ptr.is_null() || (ptr as usize) < 0x1000 { + // As above — this dereferences `ptr`, so the handle bands must be excluded. + if ptr.is_null() || !crate::value::addr_class::is_above_handle_band(ptr as usize) { return None; } let len = (*ptr).byte_len as usize;