From a0f8dde531e288a1ef033c4ceecc854d306b7c05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 10 Jul 2026 01:04:02 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(gc):=20#6206=20evacuation=20stale=20ref?= =?UTF-8?q?erences=20=E2=80=94=20shadow=20frames=20for=20closures/methods,?= =?UTF-8?q?=20helper=20operand=20rooting,=20old-page=20defrag=20off=20pend?= =?UTF-8?q?ing=20rewrite-contract=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent defects behind the "stale reference to a moved element" corruption (wild-pointer crash / silently corrupt cached value under the moving evacuation policy; masked by PERRY_GEN_GC_EVACUATE=0): 1. Frameless closures/methods (#6081, the main blast radius). Production copying/evacuating minors use exact roots only (conservative stack scan resolves to SkipDisabled), but shadow frames were emitted only for top-level functions and module init — compile_closure, compile_method, and compile_static_method passed an empty shadow_slot_map. Every pointer-typed param/local of every closure, arrow, method, and constructor body was invisible to an evacuating minor; a GC fired mid-body swept values reachable only from those frames. Now all three paths emit the same frame machinery the function path gets (collect_pointer_typed_locals → enable_shadow_frame → per-param js_shadow_slot_bind → slot updates at Let sites); methods root the receiver in one extra slot. Bodies with no pointer-typed locals still get no frame (closures/methods with an extra `this` slot excepted). 2. Runtime helpers holding raw operands across allocating calls: - js_array_map / js_array_filter rooted the receiver and result but not the callback closure param — an evacuating minor mid-loop swept/moved it and the next dispatch called freed memory. - js_map_set / map_set_string_key_value: ensure_capacity can fire a MOVING minor via gc_note_external_side_alloc (its "conservative + non-moving" comment predates evacuation); key/value — including a freshly built, not-yet-inserted object reachable via nothing else — are now rooted across the grow and re-derived after. 3. Old-page defrag evacuation leaves a stale reference on a non-heap path. With 1+2 fixed the reproducer still corrupted at the first old-page defrag cycle (same-binary A/B: defrag on = 6/6 CORRUPT, off = 6/6 clean). Instrumented scans (leaked forwarding stubs + brute-force payload walks bypassing the per-object layout filter) verified every heap-payload slot rewrites correctly — arrays (in-length), object fields, Map entry buffers, overflow fields, and both globalThis root slots — so the surviving stale reference lives on an address-keyed cache/IC/side-table path the defrag rewrite does not reach. Old-page defrag selection now returns empty pending that root-cause; nursery evacuation and tenured promotion are unaffected. PERRY_GC_OLD_DEFRAG=1 re-enables it for debugging/bisection. Diagnostics kept (per the issue's ask): the PERRY_GC_VERIFY_EVACUATION old→young edge verifier panic now prints per-GC-type histograms of the missing edges plus parent/child type/generation/mark state of the first miss, and PERRY_GC_VERIFY_RS_NONFATAL=1 demotes that verifier (which runs after mark consumed the dirty logs, so its misses can be a measurement artifact — coverage verifies complete at every restore point) to a warning so later same-cycle verifiers stay reachable. Validation: issue reproducer 8/8 clean under PERRY_GC_FORCE_EVACUATE=1, 4/4 clean on the default policy, 2/2 clean under PERRY_GEN_GC_EVACUATE=0 (previously ~6/6 crash rc=138/139 or CORRUPT exit). Fixes #6206. Refs #6081. --- crates/perry-codegen/src/codegen/closure.rs | 31 +++++++- crates/perry-codegen/src/codegen/method.rs | 66 +++++++++++++++-- .../perry-runtime/src/array/iter_methods.rs | 13 ++++ crates/perry-runtime/src/gc/oldgen.rs | 58 +++++++++++++++ crates/perry-runtime/src/gc/telemetry.rs | 37 ++++++++++ crates/perry-runtime/src/gc/tests/barrier.rs | 2 + .../gc/tests/copying/survival_and_malloc.rs | 1 + .../perry-runtime/src/gc/tests/cycle_state.rs | 1 + crates/perry-runtime/src/gc/tests/oldgen.rs | 1 + crates/perry-runtime/src/gc/verify.rs | 74 +++++++++++++++++-- crates/perry-runtime/src/map.rs | 20 ++++- 11 files changed, 290 insertions(+), 14 deletions(-) diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index e201bcf23d..782207c5ff 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -11,7 +11,7 @@ use crate::expr::FnCtx; use crate::module::LlModule; use crate::stmt; use crate::strings::StringPool; -use crate::types::{LlvmType, DOUBLE, I1, I32, I64}; +use crate::types::{LlvmType, DOUBLE, I1, I32, I64, PTR}; use super::opts::CrossModuleCtx; use super::typed_abi::{ @@ -568,6 +568,25 @@ pub(super) fn compile_closure( if typed_public_trampoline.is_some() { lf.linkage = "internal".to_string(); } + + // gh #6206 / #6081: closures/arrows compiled WITHOUT a shadow frame left + // their pointer-typed params/locals invisible to the exact-roots copying + // minor (production skips the conservative native-stack scan), so an + // evacuating GC fired mid-body swept values reachable only from the + // closure's own frame — the referrer then read freed-and-reused memory. + // Emit the same frame the top-level function path gets (function.rs). + let shadow_slot_map = if super::helpers::shadow_stack_enabled() { + let flat_const_ids: std::collections::HashSet = + cross_module.flat_const_arrays.keys().copied().collect(); + let m = crate::collectors::collect_pointer_typed_locals(params, body, &flat_const_ids); + lf.enable_shadow_frame(m.len() as u32); + m + } else { + std::collections::HashMap::new() + }; + let shadow_slot_clears_after_stmt = + crate::collectors::collect_shadow_slot_clear_points(body, &shadow_slot_map); + let _ = lf.create_block("entry"); let mut closure_boxed_vars = module_boxed_vars.clone(); @@ -581,6 +600,12 @@ pub(super) fn compile_closure( for p in params { let arg_name = format!("%arg{}", p.id); let slot = super::arguments::store_param_slot(blk, p, &closure_boxed_vars, &arg_name); + if let Some(slot_idx) = shadow_slot_map.get(&p.id).copied() { + blk.call_void( + "js_shadow_slot_bind", + &[(I32, &slot_idx.to_string()), (PTR, &slot)], + ); + } map.insert(p.id, slot); } map @@ -802,8 +827,8 @@ pub(super) fn compile_closure( pending_declares: Vec::new(), integer_locals: native_facts.integer_locals(), unsigned_i32_locals: native_facts.unsigned_i32_locals(), - shadow_slot_map: std::collections::HashMap::new(), - shadow_slot_clears_after_stmt: std::collections::HashMap::new(), + shadow_slot_map, + shadow_slot_clears_after_stmt, arena_state_slot: None, class_keys_slots: HashMap::new(), cached_lengths: HashMap::new(), diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index ee82ad2dae..55f289765d 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -10,7 +10,7 @@ use crate::expr::FnCtx; use crate::module::LlModule; use crate::stmt; use crate::strings::StringPool; -use crate::types::{LlvmType, DOUBLE, I1, I32, I64}; +use crate::types::{LlvmType, DOUBLE, I1, I32, I64, PTR}; use super::helpers::scoped_static_method_name; use super::opts::CrossModuleCtx; @@ -291,6 +291,28 @@ pub(super) fn compile_method( if typed_public_trampoline.is_some() || force_generic_body { lf.linkage = "internal".to_string(); } + + // gh #6206 / #6081: methods were compiled WITHOUT a shadow frame — same + // exact-roots liveness hole as closures (see compile_closure). One extra + // slot roots the receiver (`this` is a pointer value reachable from + // nothing else when the caller holds it only in a register temp). + let shadow_slot_map = if super::helpers::shadow_stack_enabled() { + let flat_const_ids: std::collections::HashSet = + cross_module.flat_const_arrays.keys().copied().collect(); + let m = crate::collectors::collect_pointer_typed_locals( + &method.params, + &method.body, + &flat_const_ids, + ); + lf.enable_shadow_frame(m.len() as u32 + 1); + m + } else { + std::collections::HashMap::new() + }; + let this_shadow_slot_idx = shadow_slot_map.len() as u32; + let shadow_slot_clears_after_stmt = + crate::collectors::collect_shadow_slot_clear_points(&method.body, &shadow_slot_map); + let _ = lf.create_block("entry"); let mut method_boxed_vars = module_boxed_vars.clone(); @@ -302,10 +324,22 @@ pub(super) fn compile_method( let blk = lf.block_mut(0).unwrap(); let this_slot = blk.alloca(DOUBLE); blk.store(DOUBLE, "%this_arg", &this_slot); + if super::helpers::shadow_stack_enabled() { + blk.call_void( + "js_shadow_slot_bind", + &[(I32, &this_shadow_slot_idx.to_string()), (PTR, &this_slot)], + ); + } let mut map = HashMap::new(); for p in &method.params { let arg_name = format!("%arg{}", p.id); let slot = super::arguments::store_param_slot(blk, p, &method_boxed_vars, &arg_name); + if let Some(slot_idx) = shadow_slot_map.get(&p.id).copied() { + blk.call_void( + "js_shadow_slot_bind", + &[(I32, &slot_idx.to_string()), (PTR, &slot)], + ); + } map.insert(p.id, slot); } (this_slot, map) @@ -422,8 +456,8 @@ pub(super) fn compile_method( pending_declares: Vec::new(), integer_locals: native_facts.integer_locals(), unsigned_i32_locals: native_facts.unsigned_i32_locals(), - shadow_slot_map: std::collections::HashMap::new(), - shadow_slot_clears_after_stmt: std::collections::HashMap::new(), + shadow_slot_map, + shadow_slot_clears_after_stmt, arena_state_slot: None, class_keys_slots: HashMap::new(), cached_lengths: HashMap::new(), @@ -1147,6 +1181,22 @@ pub(super) fn compile_static_method( let ic_base = llmod.ic_counter; let buffer_alias_base = llmod.buffer_alias_counter; let lf = llmod.define_function(&llvm_name, DOUBLE, params); + + // gh #6206 / #6081: same shadow-frame emission as compile_method — static + // method bodies were equally invisible to the exact-roots copying minor. + let shadow_slot_map = if super::helpers::shadow_stack_enabled() { + let flat_const_ids: std::collections::HashSet = + cross_module.flat_const_arrays.keys().copied().collect(); + let m = + crate::collectors::collect_pointer_typed_locals(&f.params, &f.body, &flat_const_ids); + lf.enable_shadow_frame(m.len() as u32); + m + } else { + std::collections::HashMap::new() + }; + let shadow_slot_clears_after_stmt = + crate::collectors::collect_shadow_slot_clear_points(&f.body, &shadow_slot_map); + let _ = lf.create_block("entry"); let mut static_boxed_vars = module_boxed_vars.clone(); @@ -1183,6 +1233,12 @@ pub(super) fn compile_static_method( for p in &f.params { let arg_name = format!("%arg{}", p.id); let slot = super::arguments::store_param_slot(blk, p, &static_boxed_vars, &arg_name); + if let Some(slot_idx) = shadow_slot_map.get(&p.id).copied() { + blk.call_void( + "js_shadow_slot_bind", + &[(I32, &slot_idx.to_string()), (PTR, &slot)], + ); + } map.insert(p.id, slot); } (this_slot, map) @@ -1298,8 +1354,8 @@ pub(super) fn compile_static_method( pending_declares: Vec::new(), integer_locals: native_facts.integer_locals(), unsigned_i32_locals: native_facts.unsigned_i32_locals(), - shadow_slot_map: std::collections::HashMap::new(), - shadow_slot_clears_after_stmt: std::collections::HashMap::new(), + shadow_slot_map, + shadow_slot_clears_after_stmt, arena_state_slot: None, class_keys_slots: HashMap::new(), cached_lengths: HashMap::new(), diff --git a/crates/perry-runtime/src/array/iter_methods.rs b/crates/perry-runtime/src/array/iter_methods.rs index 9f1b401760..39f9895b9e 100644 --- a/crates/perry-runtime/src/array/iter_methods.rs +++ b/crates/perry-runtime/src/array/iter_methods.rs @@ -170,6 +170,15 @@ pub extern "C" fn js_array_map( let length = (*arr).length; let scope = crate::gc::RuntimeHandleScope::new(); let rooted = RootedIterArray::new(&scope, arr); + // Root the callback closure across the iteration. A callback allocated + // by a frameless caller (arrow/method — #6081) is reachable ONLY via + // this raw param + the native stack, which an evacuating minor does NOT + // scan (copied-minor eligibility requires no conservative stack scan). + // Closures are non-movable, so an unrooted one is swept in place mid- + // loop → the next dispatch calls freed memory ("object is not a + // function" / wild-pointer crash). Masked by PERRY_GEN_GC_EVACUATE=0, + // whose non-moving minor DOES run the conservative scan. See gh #6206. + let cb_handle = scope.root_raw_const_ptr(callback); let _tg = DenseThisGuard::bind_undefined(); // ECMA-262 §23.1.3.20 step 5: ArraySpeciesCreate(O, len) runs BEFORE @@ -204,6 +213,7 @@ pub extern "C" fn js_array_map( } }; // JS .map() callback receives (element, index, array). + let callback = cb_handle.get_raw_const_ptr::(); let mapped = js_closure_call3(callback, element, i as f64, rooted.receiver()); if is_plain { let result = result_arr(&result_rooted); @@ -285,6 +295,8 @@ pub extern "C" fn js_array_filter( let length = (*arr).length; let scope = crate::gc::RuntimeHandleScope::new(); let rooted = RootedIterArray::new(&scope, arr); + // Root the callback across the loop — see js_array_map / gh #6206. + let cb_handle = scope.root_raw_const_ptr(callback); let _tg = DenseThisGuard::bind_undefined(); // ECMA-262 §23.1.3.7 step 5: ArraySpeciesCreate(O, 0) runs before the @@ -312,6 +324,7 @@ pub extern "C" fn js_array_filter( None => continue, } }; + let callback = cb_handle.get_raw_const_ptr::(); let keep = js_closure_call3(callback, element, i as f64, rooted.receiver()); // Proper truthy check: handles NaN-boxed booleans (TAG_FALSE != 0.0 but is falsy) if crate::value::js_is_truthy(keep) != 0 { diff --git a/crates/perry-runtime/src/gc/oldgen.rs b/crates/perry-runtime/src/gc/oldgen.rs index e0bad6bb20..d5441b0fbe 100644 --- a/crates/perry-runtime/src/gc/oldgen.rs +++ b/crates/perry-runtime/src/gc/oldgen.rs @@ -197,7 +197,65 @@ pub(super) fn select_old_page_defrag_pages_from_snapshot( selection } +/// gh #6206 test hook: the defrag machinery's unit tests exercise the +/// selection/copy/re-remember mechanics directly and must bypass the +/// production off-gate below. Thread-local so parallel tests don't race. +#[cfg(test)] +thread_local! { + pub(crate) static OLD_DEFRAG_TEST_OVERRIDE: std::cell::Cell> = + const { std::cell::Cell::new(None) }; +} + +/// RAII enable for the defrag unit tests: forces the off-gate open on this +/// thread for the guard's lifetime. +#[cfg(test)] +pub(crate) struct OldDefragTestEnable; + +#[cfg(test)] +impl OldDefragTestEnable { + pub(crate) fn new() -> Self { + OLD_DEFRAG_TEST_OVERRIDE.with(|c| c.set(Some(true))); + OldDefragTestEnable + } +} + +#[cfg(test)] +impl Drop for OldDefragTestEnable { + fn drop(&mut self) { + OLD_DEFRAG_TEST_OVERRIDE.with(|c| c.set(None)); + } +} + +fn old_page_defrag_enabled() -> bool { + #[cfg(test)] + if let Some(v) = OLD_DEFRAG_TEST_OVERRIDE.with(|c| c.get()) { + return v; + } + use std::sync::OnceLock; + static OPT_IN: OnceLock = OnceLock::new(); + *OPT_IN.get_or_init(|| { + matches!( + std::env::var("PERRY_GC_OLD_DEFRAG").as_deref(), + Ok("1") | Ok("on") | Ok("true") + ) + }) +} + pub(super) fn select_old_page_defrag_pages(force: bool) -> OldPageDefragSelection { + // gh #6206: old-page defrag evacuation is OFF pending a rewrite-contract + // fix. With defrag active, a reader can observe a pre-move address of a + // defrag-moved old object long after the cycle (wild-pointer crash / + // silently corrupt cached value); the reproducer corrupts 6/6 with defrag + // enabled and is clean 6/6 with it disabled, on the same binary, while + // every heap-payload slot (arrays in-length, object fields, Map entries) + // verifies as correctly rewritten — the stale reference lives on a + // non-heap path (address-keyed cache / IC / side table) the defrag + // rewrite doesn't reach. Nursery evacuation and tenured promotion (the + // reclaim-critical moving paths) are unaffected. Re-enable for + // debugging/bisection with PERRY_GC_OLD_DEFRAG=1. + if !old_page_defrag_enabled() { + return OldPageDefragSelection::default(); + } let snapshot = crate::arena::old_page_meta_snapshot(); select_old_page_defrag_pages_from_snapshot(&snapshot, force) } diff --git a/crates/perry-runtime/src/gc/telemetry.rs b/crates/perry-runtime/src/gc/telemetry.rs index 717a4370b6..58358e2aa6 100644 --- a/crates/perry-runtime/src/gc/telemetry.rs +++ b/crates/perry-runtime/src/gc/telemetry.rs @@ -70,6 +70,11 @@ pub(super) struct OldYoungEdgeMissing { pub(super) parent: usize, pub(super) slot: usize, pub(super) child: usize, + // gh #6206: edge-type diagnostics for the verifier panic. + pub(super) parent_obj_type: u8, + pub(super) child_obj_type: u8, + pub(super) parent_is_old_arena: bool, + pub(super) parent_marked: bool, } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] @@ -79,17 +84,49 @@ pub(super) struct OldYoungEdgeVerifyStats { pub(super) checked_old_to_young_edges: usize, pub(super) missing_edges: usize, pub(super) first_missing: Option, + // gh #6206: per-type histograms of missing edges + pub(super) missing_by_parent_type: [u32; 32], + pub(super) missing_by_child_type: [u32; 32], + pub(super) missing_parent_malloc: u32, + pub(super) missing_parent_unmarked: u32, } impl OldYoungEdgeVerifyStats { #[inline] pub(super) fn record_missing(&mut self, parent: usize, slot: usize, child: usize) { + self.record_missing_diag(parent, slot, child, 0, 0, false, false); + } + + #[inline] + #[allow(clippy::too_many_arguments)] + pub(super) fn record_missing_diag( + &mut self, + parent: usize, + slot: usize, + child: usize, + parent_obj_type: u8, + child_obj_type: u8, + parent_is_old_arena: bool, + parent_marked: bool, + ) { self.missing_edges = self.missing_edges.saturating_add(1); + self.missing_by_parent_type[(parent_obj_type as usize) & 31] += 1; + self.missing_by_child_type[(child_obj_type as usize) & 31] += 1; + if !parent_is_old_arena { + self.missing_parent_malloc += 1; + } + if !parent_marked { + self.missing_parent_unmarked += 1; + } if self.first_missing.is_none() { self.first_missing = Some(OldYoungEdgeMissing { parent, slot, child, + parent_obj_type, + child_obj_type, + parent_is_old_arena, + parent_marked, }); } } diff --git a/crates/perry-runtime/src/gc/tests/barrier.rs b/crates/perry-runtime/src/gc/tests/barrier.rs index 3588408935..83daee0808 100644 --- a/crates/perry-runtime/src/gc/tests/barrier.rs +++ b/crates/perry-runtime/src/gc/tests/barrier.rs @@ -646,7 +646,9 @@ fn test_old_young_edge_verifier_trace_json_shape() { parent: 0x1111, slot: 0x2222, child: 0x3333, + ..Default::default() }), + ..Default::default() }; trace.record_phase("old_young_edge_verify", std::time::Duration::from_micros(7)); diff --git a/crates/perry-runtime/src/gc/tests/copying/survival_and_malloc.rs b/crates/perry-runtime/src/gc/tests/copying/survival_and_malloc.rs index 030ab96150..f4bcf6d7a2 100644 --- a/crates/perry-runtime/src/gc/tests/copying/survival_and_malloc.rs +++ b/crates/perry-runtime/src/gc/tests/copying/survival_and_malloc.rs @@ -28,6 +28,7 @@ fn test_copying_minor_promotes_survivor_on_fourth_survival() { #[test] fn test_copying_minor_preserves_old_page_accounting_for_defrag_policy() { + let _defrag = OldDefragTestEnable::new(); struct ResetGcTestState { pinned_header: *mut GcHeader, } diff --git a/crates/perry-runtime/src/gc/tests/cycle_state.rs b/crates/perry-runtime/src/gc/tests/cycle_state.rs index b355622978..186cacbb2d 100644 --- a/crates/perry-runtime/src/gc/tests/cycle_state.rs +++ b/crates/perry-runtime/src/gc/tests/cycle_state.rs @@ -842,6 +842,7 @@ fn bounded_minor_fallback_preserves_age_and_trace_fields() { #[test] fn budgeted_minor_fallback_ignores_forced_evacuation_and_stays_non_moving() { + let _defrag = OldDefragTestEnable::new(); let _guard = CopyingNurseryTestGuard::new(2); let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); let _force = EnvVarGuard::set("PERRY_GC_FORCE_EVACUATE", "1"); diff --git a/crates/perry-runtime/src/gc/tests/oldgen.rs b/crates/perry-runtime/src/gc/tests/oldgen.rs index 814e20ea31..e1dc0ca47a 100644 --- a/crates/perry-runtime/src/gc/tests/oldgen.rs +++ b/crates/perry-runtime/src/gc/tests/oldgen.rs @@ -689,6 +689,7 @@ fn test_old_page_defrag_skips_non_movable_buffer_and_typed_array() { #[test] fn test_old_page_defrag_re_remembers_young_child_after_collection_clear() { + let _defrag = OldDefragTestEnable::new(); struct ResetGcTestState; impl Drop for ResetGcTestState { diff --git a/crates/perry-runtime/src/gc/verify.rs b/crates/perry-runtime/src/gc/verify.rs index d453fb5ae2..72d59e49e8 100644 --- a/crates/perry-runtime/src/gc/verify.rs +++ b/crates/perry-runtime/src/gc/verify.rs @@ -470,7 +470,28 @@ pub(super) unsafe fn verify_old_young_slot_covered( stats.checked_old_to_young_edges = stats.checked_old_to_young_edges.saturating_add(1); let parent_addr = parent_header as usize; if !old_young_slot_covered(snapshot, parent_addr, slot) { - stats.record_missing(parent_addr, slot as usize, child_addr); + // gh #6206: record parent/child GC types so the panic below can + // print per-type histograms of the dropped edges. + let parent_obj_type = (*parent_header).obj_type; + let parent_marked = (*parent_header).gc_flags & (GC_FLAG_MARKED | GC_FLAG_PINNED) != 0; + let parent_user = (parent_header as *mut u8).add(GC_HEADER_SIZE) as usize; + let parent_is_old_arena = matches!( + crate::arena::classify_heap_generation(parent_user), + crate::arena::HeapGeneration::Old + ); + let child_obj_type = { + let ch = (child_addr as *const u8).sub(GC_HEADER_SIZE) as *const GcHeader; + (*ch).obj_type + }; + stats.record_missing_diag( + parent_addr, + slot as usize, + child_addr, + parent_obj_type, + child_obj_type, + parent_is_old_arena, + parent_marked, + ); } } @@ -495,19 +516,43 @@ pub(super) unsafe fn verify_old_young_parent_slots_covered( #[cold] pub(super) fn panic_old_young_edge_verifier_failed(stats: OldYoungEdgeVerifyStats) -> ! { let missing = stats.first_missing.unwrap_or_default(); + // gh #6206: readable per-type histograms of the missing edges. + let type_name = |t: u8| -> &'static str { gc_type_info(t).map_or("?", |i| i.name) }; + let mut parent_hist = String::new(); + let mut child_hist = String::new(); + for t in 0u8..32 { + let p = stats.missing_by_parent_type[t as usize]; + if p != 0 { + parent_hist.push_str(&format!(" {}({})={}", type_name(t), t, p)); + } + let c = stats.missing_by_child_type[t as usize]; + if c != 0 { + child_hist.push_str(&format!(" {}({})={}", type_name(t), t, c)); + } + } panic!( - "old-young-edge-verifier failed: checked_old_objects={} checked_remembered_pages={} checked_old_to_young_edges={} missing_edges={} first_missing_parent=0x{:x} first_missing_slot=0x{:x} first_missing_child=0x{:x}", + "old-young-edge-verifier failed: checked_old_objects={} checked_remembered_pages={} checked_old_to_young_edges={} missing_edges={} malloc_parents={} unmarked_parents={}\n first_missing: parent=0x{:x} type={}({}) old_arena={} marked={} slot=0x{:x} child=0x{:x} child_type={}({})\n missing_by_parent_type:{}\n missing_by_child_type:{}", stats.checked_old_objects, stats.checked_remembered_pages, stats.checked_old_to_young_edges, stats.missing_edges, + stats.missing_parent_malloc, + stats.missing_parent_unmarked, missing.parent, + type_name(missing.parent_obj_type), + missing.parent_obj_type, + missing.parent_is_old_arena, + missing.parent_marked, missing.slot, - missing.child + missing.child, + type_name(missing.child_obj_type), + missing.child_obj_type, + parent_hist, + child_hist, ); } -pub(super) fn verify_old_to_young_edges_covered() -> OldYoungEdgeVerifyStats { +pub(super) fn verify_old_to_young_edges_collect() -> OldYoungEdgeVerifyStats { let snapshot = remembered_dirty_snapshot(); let mut stats = OldYoungEdgeVerifyStats { checked_remembered_pages: snapshot.dirty_pages.len(), @@ -524,8 +569,27 @@ pub(super) fn verify_old_to_young_edges_covered() -> OldYoungEdgeVerifyStats { } } }); + stats +} + +pub(super) fn verify_old_to_young_edges_covered() -> OldYoungEdgeVerifyStats { + let stats = verify_old_to_young_edges_collect(); if stats.missing_edges != 0 { - panic_old_young_edge_verifier_failed(stats); + // gh #6206: this check runs at AtomicFinalize, AFTER the mark phase + // consumed the dirty logs, so its "missing" edges can be a + // measurement artifact. PERRY_GC_VERIFY_RS_NONFATAL=1 demotes it to + // a warning so the stale-forwarded-refs verifier (which runs later + // in the same cycle) can be reached. + use std::sync::OnceLock; + static NONFATAL: OnceLock = OnceLock::new(); + if *NONFATAL.get_or_init(|| std::env::var_os("PERRY_GC_VERIFY_RS_NONFATAL").is_some()) { + eprintln!( + "[gc-verify] old-young-edge-verifier (non-fatal): missing_edges={}", + stats.missing_edges + ); + } else { + panic_old_young_edge_verifier_failed(stats); + } } stats } diff --git a/crates/perry-runtime/src/map.rs b/crates/perry-runtime/src/map.rs index eebb1f7a20..10027afe86 100644 --- a/crates/perry-runtime/src/map.rs +++ b/crates/perry-runtime/src/map.rs @@ -947,7 +947,15 @@ unsafe fn map_set_string_key_value( return map; } + // See js_map_set: ensure_capacity can fire a MOVING minor; root the key + // (a heap string, which CAN move) and value across it and re-derive the + // `*const StringHeader` before boxing. gh #6206. + let scope = crate::gc::RuntimeHandleScope::new(); + let key_handle = scope.root_string_ptr(key); + let value_handle = scope.root_nanbox_f64(value); let grew = ensure_capacity(map); + let key = key_handle.get_raw_const_ptr::(); + let value = value_handle.get_nanbox_f64(); let size = (*map).size; let entries = entries_ptr_mut(map); if grew && size > 0 { @@ -1014,8 +1022,18 @@ pub extern "C" fn js_map_set(map: *mut MapHeader, key: f64, value: f64) -> *mut return map; } - // Key doesn't exist, append a new entry + // Key doesn't exist, append a new entry. `ensure_capacity` can fire a + // MOVING minor (via gc_note_external_side_alloc) — its "conservative + + // non-moving" comment is false under evacuation. `key`/`value` are held + // only in these native-stack params (a freshly-built, not-yet-inserted + // object is reachable via nothing else), which an evacuating minor does + // not scan, so root them across the grow and re-derive after. gh #6206. + let scope = crate::gc::RuntimeHandleScope::new(); + let key_handle = scope.root_nanbox_f64(key); + let value_handle = scope.root_nanbox_f64(value); let grew = ensure_capacity(map); + let key = key_handle.get_nanbox_f64(); + let value = value_handle.get_nanbox_f64(); let size = (*map).size; let entries = entries_ptr_mut(map); if grew && size > 0 { From 0378c6e4b9d3f6a4336c8eea6617a6a90b005dad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 10 Jul 2026 06:32:26 +0200 Subject: [PATCH 2/2] fix(gc): root the resolved static-method receiver in the shadow frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit review on #6219: static `this` is usually the non-pointer INT32 class-ref, but js_static_this_resolve returns a REAL heap receiver for `C.m.call(x)` / `.apply(x)` / inherited `D.m()` dynamic dispatch — and that object can be reachable only from the prologue's this_slot. Mirror compile_method: one extra frame slot + js_shadow_slot_bind. --- crates/perry-codegen/src/codegen/method.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index 55f289765d..e0167a8dce 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -1184,16 +1184,21 @@ pub(super) fn compile_static_method( // gh #6206 / #6081: same shadow-frame emission as compile_method — static // method bodies were equally invisible to the exact-roots copying minor. + // One extra slot roots the resolved receiver: static `this` is usually + // the non-pointer INT32 class-ref, but `js_static_this_resolve` returns a + // REAL heap receiver for `C.m.call(x)` / `.apply(x)` / inherited `D.m()` + // dynamic dispatch, and that object may be reachable only from this slot. let shadow_slot_map = if super::helpers::shadow_stack_enabled() { let flat_const_ids: std::collections::HashSet = cross_module.flat_const_arrays.keys().copied().collect(); let m = crate::collectors::collect_pointer_typed_locals(&f.params, &f.body, &flat_const_ids); - lf.enable_shadow_frame(m.len() as u32); + lf.enable_shadow_frame(m.len() as u32 + 1); m } else { std::collections::HashMap::new() }; + let this_shadow_slot_idx = shadow_slot_map.len() as u32; let shadow_slot_clears_after_stmt = crate::collectors::collect_shadow_slot_clear_points(&f.body, &shadow_slot_map); @@ -1229,6 +1234,12 @@ pub(super) fn compile_static_method( &[(DOUBLE, &class_ref_lit)], ); blk.store(DOUBLE, &resolved_this, &this_slot); + if super::helpers::shadow_stack_enabled() { + blk.call_void( + "js_shadow_slot_bind", + &[(I32, &this_shadow_slot_idx.to_string()), (PTR, &this_slot)], + ); + } let mut map = HashMap::new(); for p in &f.params { let arg_name = format!("%arg{}", p.id);