From 499b71628d53b76d87ba68c6f4a59ed597e6d82e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 14:13:21 +0200 Subject: [PATCH 1/5] fix(gc): release malloc borrow before verification Snapshot malloc-backed headers before running verifier callbacks so exact child validation can lazily rebuild the malloc registry without re-entering its RefCell borrow. Add a worker-thread copying-minor regression fixture. Claude-Session: https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo --- .../verify-evacuation-malloc-borrow.md | 5 ++ crates/perry-runtime/src/gc/tests/copying.rs | 1 + .../gc/tests/copying/verify_malloc_borrow.rs | 73 +++++++++++++++++++ crates/perry-runtime/src/gc/verify.rs | 64 ++++++++-------- 4 files changed, 109 insertions(+), 34 deletions(-) create mode 100644 changelog.d/verify-evacuation-malloc-borrow.md create mode 100644 crates/perry-runtime/src/gc/tests/copying/verify_malloc_borrow.rs diff --git a/changelog.d/verify-evacuation-malloc-borrow.md b/changelog.d/verify-evacuation-malloc-borrow.md new file mode 100644 index 0000000000..ac7d7aeb25 --- /dev/null +++ b/changelog.d/verify-evacuation-malloc-borrow.md @@ -0,0 +1,5 @@ +Fixed `PERRY_GC_VERIFY_EVACUATION=1` re-entering the thread-local malloc +registry while it validated malloc-backed object fields. Diagnostic heap walks +now snapshot malloc headers before validation, so copying-minor verification can +run with a populated side table instead of panicking on a nested `RefCell` +borrow. diff --git a/crates/perry-runtime/src/gc/tests/copying.rs b/crates/perry-runtime/src/gc/tests/copying.rs index 289829b854..88312209a4 100644 --- a/crates/perry-runtime/src/gc/tests/copying.rs +++ b/crates/perry-runtime/src/gc/tests/copying.rs @@ -7,6 +7,7 @@ mod pointer_publish_7154; mod promise_side_tables; mod promoted_remembered_7803; mod survival_and_malloc; +mod verify_malloc_borrow; mod weak_holder_registry; mod weak_semantics; use super::super::*; diff --git a/crates/perry-runtime/src/gc/tests/copying/verify_malloc_borrow.rs b/crates/perry-runtime/src/gc/tests/copying/verify_malloc_borrow.rs new file mode 100644 index 0000000000..eef5e6aa24 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/copying/verify_malloc_borrow.rs @@ -0,0 +1,73 @@ +use super::*; + +#[test] +fn test_copied_minor_verify_evacuation_releases_malloc_registry_before_validation() { + std::thread::spawn(|| { + let _guard = CopyingNurseryTestGuard::new(2); + let _env_guard = VerifyEvacuationTestGuard::on(); + let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + + let malloc_child = gc_malloc( + std::mem::size_of::(), + GC_TYPE_CLOSURE, + ); + let malloc_parent = gc_malloc( + std::mem::size_of::() + 8, + GC_TYPE_CLOSURE, + ); + unsafe { + init_test_closure(malloc_child); + init_test_closure_with_one_capture(malloc_parent, ptr_bits(malloc_child as usize)); + } + js_shadow_slot_set(0, ptr_bits(malloc_parent as usize)); + let young = young_leaf(); + js_shadow_slot_set(1, ptr_bits(young)); + + // Make the exact-validation call do real registry work. The verifier's + // malloc-parent walk must snapshot the headers and release its borrow + // before this child lookup reaches `ensure_set_built`. Sabotage: put + // the verifier loop back inside `MALLOC_STATE.with(...borrow())`; the + // lookup's `borrow_mut()` then panics this worker thread. + deactivate_malloc_registry_for_tests(); + assert!( + MALLOC_STATE.with(|state| !state.borrow().objects.is_empty()), + "the malloc verifier fixture must populate the side table" + ); + assert!( + !malloc_registry_active_for_tests(), + "the exact-validation lookup must have a registry to rebuild" + ); + let rebuilds_before = MALLOC_REGISTRY_REBUILD_COUNT.with(|count| count.get()); + let stats = verify_old_to_young_edges_collect(); + let rebuilds_after = MALLOC_REGISTRY_REBUILD_COUNT.with(|count| count.get()); + assert!( + stats.checked_old_objects > 0 && stats.checked_old_to_young_edges > 0, + "the verifier must inspect the malloc parent and its malloc child" + ); + assert_eq!( + rebuilds_after, + rebuilds_before + 1, + "exact child validation must rebuild the non-empty malloc registry" + ); + + let trace = collect_minor_trace(GcTriggerKind::Direct); + assert_copied_minor_trace(&trace, true, CopiedMinorFallbackReason::None, false); + assert!( + trace.copying_nursery.copied_objects > 0, + "the worker-thread collection must copy a live nursery object" + ); + assert!( + trace.phase_us.contains_key("evacuation_verify"), + "the copied minor must run evacuation verification" + ); + assert!( + trace.phase_us.contains_key("old_young_edge_verify"), + "the copied minor must run the malloc-parent verifier" + ); + assert_ne!((js_shadow_slot_get(1) & POINTER_MASK) as usize, young); + assert!(malloc_user_ptr_tracked(malloc_parent)); + assert!(malloc_user_ptr_tracked(malloc_child)); + }) + .join() + .expect("worker-thread copying minor must complete without a RefCell borrow panic"); +} diff --git a/crates/perry-runtime/src/gc/verify.rs b/crates/perry-runtime/src/gc/verify.rs index 9d6bb0b26f..a6f6450c70 100644 --- a/crates/perry-runtime/src/gc/verify.rs +++ b/crates/perry-runtime/src/gc/verify.rs @@ -1,5 +1,16 @@ use super::*; +/// Snapshot malloc-backed headers before invoking a verifier callback. +/// +/// Slot validation can exact-check a candidate malloc pointer, which lazily +/// builds `MallocState.set` under a mutable borrow. Keeping even a shared +/// `MALLOC_STATE` borrow across that validation would make the diagnostic +/// verifier re-enter the same `RefCell` and panic instead of checking the heap. +#[inline] +fn malloc_headers_for_verification() -> Vec<*mut GcHeader> { + MALLOC_STATE.with(|state| state.borrow().objects.clone()) +} + /// Follow forwarding pointers for a word that may hold a heap reference, /// NaN-boxed or bare, preserving the form it was stored in. /// @@ -795,14 +806,11 @@ pub(super) fn verify_old_to_young_edges_collect() -> OldYoungEdgeVerifyStats { crate::arena::old_arena_walk_objects(|hp| unsafe { verify_old_young_parent_slots_covered(&snapshot, &mut stats, hp as *mut GcHeader); }); - MALLOC_STATE.with(|s| { - let s = s.borrow(); - for &header in s.objects.iter() { - unsafe { - verify_old_young_parent_slots_covered(&snapshot, &mut stats, header); - } + for header in malloc_headers_for_verification() { + unsafe { + verify_old_young_parent_slots_covered(&snapshot, &mut stats, header); } - }); + } stats } @@ -1025,14 +1033,11 @@ pub(super) fn verify_array_pointer_slots_enumerated() -> ArraySlotEnumerationSta } verify_array_pointer_slots_enumerated_for(&mut stats, header); }); - MALLOC_STATE.with(|s| { - let s = s.borrow(); - for &header in s.objects.iter() { - unsafe { - verify_array_pointer_slots_enumerated_for(&mut stats, header); - } + for header in malloc_headers_for_verification() { + unsafe { + verify_array_pointer_slots_enumerated_for(&mut stats, header); } - }); + } stats } @@ -1068,14 +1073,11 @@ pub(super) fn verify_marked_heap_no_unmarked_children() -> MarkInvariantVerifySt crate::arena::arena_walk_objects(|hp| unsafe { verify_marked_object_child_marks(&mut stats, hp as *mut GcHeader); }); - MALLOC_STATE.with(|s| { - let s = s.borrow(); - for &header in s.objects.iter() { - unsafe { - verify_marked_object_child_marks(&mut stats, header); - } + for header in malloc_headers_for_verification() { + unsafe { + verify_marked_object_child_marks(&mut stats, header); } - }); + } if stats.missing_edges != 0 { panic_mark_invariant_verifier_failed(stats); } @@ -1091,14 +1093,11 @@ pub(super) fn verify_marked_heap_report_nonfatal(phase: &str) { crate::arena::arena_walk_objects(|hp| unsafe { verify_marked_object_child_marks(&mut stats, hp as *mut GcHeader); }); - MALLOC_STATE.with(|s| { - let s = s.borrow(); - for &header in s.objects.iter() { - unsafe { - verify_marked_object_child_marks(&mut stats, header); - } + for header in malloc_headers_for_verification() { + unsafe { + verify_marked_object_child_marks(&mut stats, header); } - }); + } let tn = |t: u8| gc_type_info(t).map_or("?", |i| i.name); if let Some(m) = stats.first_missing { let (ptype, ctype) = unsafe { @@ -1453,12 +1452,9 @@ pub(super) fn verify_heap_objects(verifier: EvacuationVerifier<'_>) { verify_heap_object_fields(header, verifier, "heap fields"); }; crate::arena::arena_walk_objects(|hp| verify_one(hp as *mut GcHeader)); - MALLOC_STATE.with(|s| { - let s = s.borrow(); - for &h in s.objects.iter() { - verify_one(h); - } - }); + for header in malloc_headers_for_verification() { + verify_one(header); + } } pub(super) fn verify_evacuated_no_stale_forwarded_refs(verifier: EvacuationVerifier<'_>) { From 4295d390ddfa1cadeca376d32913366eff123212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 14:15:08 +0200 Subject: [PATCH 2/5] docs: report evacuation verifier borrow fix Record the re-entrancy path, structural fix, disk-gated validation status, and the requested perrymaster campaign handoff. Claude-Session: https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo --- .../codex/REPORT_verify_evacuation_borrow.md | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 cc-perf-campaign/codex/REPORT_verify_evacuation_borrow.md diff --git a/cc-perf-campaign/codex/REPORT_verify_evacuation_borrow.md b/cc-perf-campaign/codex/REPORT_verify_evacuation_borrow.md new file mode 100644 index 0000000000..928231c932 --- /dev/null +++ b/cc-perf-campaign/codex/REPORT_verify_evacuation_borrow.md @@ -0,0 +1,84 @@ +# `PERRY_GC_VERIFY_EVACUATION` malloc-borrow fix + +## Commit + +- Fix commit: `499b71628d53b76d87ba68c6f4a59ed597e6d82e` (`fix(gc): release malloc borrow before verification`) +- Base: `8b7dc3342b22fe6270739c8d51585c3d2cdfa618` (`origin/main` when the task started) + +## Re-entrancy path + +The observed copied-minor path is diagnostic-only: + +1. `crates/perry-runtime/src/gc/copying.rs:1516-1519` gates + `verify_old_to_young_edges_covered()` on + `gc_verify_evacuation_enabled()`. +2. Before this fix, `verify_old_to_young_edges_collect()` held a shared + `MALLOC_STATE` borrow while iterating `s.objects` at + `crates/perry-runtime/src/gc/verify.rs:798-805` (base commit lines). +3. Each candidate reached + `verify_old_young_parent_slots_covered()` → `visit_gc_rewrite_slots()` → + `verify_old_young_slot_covered()` at current + `crates/perry-runtime/src/gc/verify.rs:731-753` and `:690-700`. +4. A non-arena child reaches the exact membership check in + `remembered_child_needs_tracking()` at + `crates/perry-runtime/src/gc/barrier/mod.rs:1568-1585`. +5. That calls `gc_malloc_header_is_tracked()`, whose inner mutable borrow is + `crates/perry-runtime/src/gc/malloc.rs:526-529` (`borrow_mut()` is line 527) + and whose `ensure_set_built()` may rebuild from `objects` at `:508-515`. + +Because `MALLOC_STATE` is thread-local, the shared outer borrow and mutable +inner borrow are on the same collection thread. `RefCell` therefore panics +before the verifier can inspect the heap. The path is entered only when +`PERRY_GC_VERIFY_EVACUATION` is enabled; the later stale-forwarded-reference +walk is independently gated at `copying.rs:1596-1600`. + +I audited the production exact-membership callers (`barrier/mod.rs`, +`young_log.rs`, `native_handle.rs`, `timer.rs`, `path.rs`, `symbol/get.rs`, +`value/dyn_index.rs`, and `json/stringify.rs`). None invokes the helper while +holding a `MALLOC_STATE` borrow. The exact nested path above is verifier-only; +there is no non-diagnostic production re-entrancy to prioritize. A second +diagnostic (`PERRY_GC_VERIFY_CLASSIFIER`) can cause live classification from +some GC walks, but that is also diagnostic, not a production path. + +## Change + +`crates/perry-runtime/src/gc/verify.rs:10-12` now snapshots the malloc header +vector and releases the `MALLOC_STATE` borrow before any verifier callback. +Every verifier-owned malloc-object walk uses that helper, including the +old-to-young check, marked-child checks, array-slot enumeration, and the final +evacuation heap walk. Exact validation semantics remain unchanged: there is no +`try_borrow` fallback and no weakened pointer check. + +The named regression test is +`gc::tests::copying::verify_malloc_borrow::test_copied_minor_verify_evacuation_releases_malloc_registry_before_validation`. +It runs on a spawned worker thread, creates a malloc-backed closure parent and +malloc-backed child, makes the non-empty registry inactive, proves the exact +lookup rebuild count advances, then completes a copying minor with evacuation +verification enabled and asserts that an actual nursery object copied and both +verification phases ran. Sabotage is explicit: restore the malloc verifier loop +under `MALLOC_STATE.with(...borrow())`; the child lookup's `borrow_mut()` panics +the worker and makes `join().expect(...)` fail. + +## Validation + +Not run because the mandatory pre-Cargo check, `df -g /`, reported only **11 +GB available**, below the 12 GB floor. Per task instructions I did not invoke +Cargo and did not wait for disk capacity. Consequently these gates were not +run: + +- the named regression test; +- every test matching `verify_evacuation`; +- every test matching `malloc`; +- `cargo test -p perry-runtime --release --lib -- --test-threads=1`; +- `cargo build --release -p perry-runtime --features wasm-host`. + +Non-Cargo static checks completed: `rustfmt --check` on all edited Rust files +and `git diff --check`. The largest touched Rust file is 1,994 lines, below the +2,000-line repository cap. + +## Perrymaster request + +Relink on main's cache, then run `cc` with +`PERRY_GC_VERIFY_EVACUATION=1` for **4 turns**. The run must complete all four +turns, emit `[gc-verify]`-style verifier output proving the diagnostic was live, +and contain no `RefCell already borrowed` or other panic. From 1ec9e0e8ac72cbce4098d86940b205f90effafd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 15:31:41 +0200 Subject: [PATCH 3/5] fix(gc): attribute stale evacuation pointers Name heap parents, layout slots, root scanners, and collection coverage when evacuation verification finds a stale forwarding alias. Emit a compact success witness with heap-walk and remembered-edge counts under GC diagnostics. Add focused failure-attribution and success-line regression tests. Claude-Session: https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo --- crates/perry-runtime/src/gc/copying.rs | 14 +- crates/perry-runtime/src/gc/cycle.rs | 7 +- crates/perry-runtime/src/gc/instruments.rs | 13 + crates/perry-runtime/src/gc/mod.rs | 2 + crates/perry-runtime/src/gc/roots.rs | 13 +- crates/perry-runtime/src/gc/tests/copying.rs | 1 + .../gc/tests/copying/verify_parent_context.rs | 104 +++++++ crates/perry-runtime/src/gc/verify.rs | 108 +++++-- crates/perry-runtime/src/gc/verify_diag.rs | 293 ++++++++++++++++++ 9 files changed, 519 insertions(+), 36 deletions(-) create mode 100644 crates/perry-runtime/src/gc/tests/copying/verify_parent_context.rs create mode 100644 crates/perry-runtime/src/gc/verify_diag.rs diff --git a/crates/perry-runtime/src/gc/copying.rs b/crates/perry-runtime/src/gc/copying.rs index 2eb134d64f..31517322a0 100644 --- a/crates/perry-runtime/src/gc/copying.rs +++ b/crates/perry-runtime/src/gc/copying.rs @@ -1513,14 +1513,18 @@ pub(super) fn run_copied_minor_attempt( promoted_sticky.restore(); collector.sticky.extend(promoted_sticky); } - if gc_verify_evacuation_enabled() { + let old_young_edges = if gc_verify_evacuation_enabled() { let phase_start = trace_phase_start(trace); let old_young_edge_verifier = verify_old_to_young_edges_covered(); + let checked_edges = old_young_edge_verifier.checked_old_to_young_edges; trace_phase_record(trace, "old_young_edge_verify", phase_start); if let Some(trace) = trace.as_mut() { trace.old_young_edge_verifier = old_young_edge_verifier; } - } + checked_edges + } else { + 0 + }; // #7803: PERRY_GC_NATIVE_SLOT_VERIFY=1 — abort on the cycle that leaves a // native slot naming from-space, instead of many cycles later at the // pin-latch. Placed after every rewrite pass, before the from-space flip. @@ -1596,7 +1600,11 @@ pub(super) fn run_copied_minor_attempt( if gc_verify_evacuation_enabled() { let phase_start = trace_phase_start(trace); let valid_ptrs = build_valid_pointer_set(); - verify_evacuated_no_stale_forwarded_refs(EvacuationVerifier::copying_minor(&valid_ptrs)); + let context = begin_evacuation_verify_cycle(_trigger_kind, Some(&snapshot)); + let stats = verify_evacuated_no_stale_forwarded_refs( + EvacuationVerifier::copying_minor(&valid_ptrs).with_context(context), + ); + report_evacuation_success(context, stats, old_young_edges); trace_phase_record(trace, "evacuation_verify", phase_start); } diff --git a/crates/perry-runtime/src/gc/cycle.rs b/crates/perry-runtime/src/gc/cycle.rs index 66777e520a..a8e6eee6af 100644 --- a/crates/perry-runtime/src/gc/cycle.rs +++ b/crates/perry-runtime/src/gc/cycle.rs @@ -1411,9 +1411,10 @@ impl GcCycleState { trace_phase_record(&mut self.trace, "reference_rewrite", phase_start); if gc_verify_evacuation_enabled() { let phase_start = trace_phase_start(&self.trace); - verify_evacuated_no_stale_forwarded_refs(EvacuationVerifier::all_forwarded( - valid_ptrs, - )); + let context = begin_evacuation_verify_cycle(self.trigger_kind, None); + verify_evacuated_no_stale_forwarded_refs( + EvacuationVerifier::all_forwarded(valid_ptrs).with_context(context), + ); trace_phase_record(&mut self.trace, "evacuation_verify", phase_start); } let released = diff --git a/crates/perry-runtime/src/gc/instruments.rs b/crates/perry-runtime/src/gc/instruments.rs index 267a8bd1d5..da4e7bb3ab 100644 --- a/crates/perry-runtime/src/gc/instruments.rs +++ b/crates/perry-runtime/src/gc/instruments.rs @@ -9,6 +9,7 @@ //! //! Process-global rather than thread-local: the report is about the run. +use std::cell::Cell; use std::sync::atomic::{AtomicU64, Ordering}; static COPYING_MINORS: AtomicU64 = AtomicU64::new(0); @@ -63,6 +64,10 @@ static INCREMENTAL_CYCLE_STARTS: AtomicU64 = AtomicU64::new(0); static INCREMENTAL_STEPS: AtomicU64 = AtomicU64::new(0); static INCREMENTAL_COMPLETIONS: AtomicU64 = AtomicU64::new(0); +crate::perry_thread_local! { + static THREAD_INCREMENTAL_COMPLETIONS: Cell = const { Cell::new(0) }; +} + /// A budgeted (incremental) cycle was STARTED. #[inline] pub(crate) fn note_incremental_cycle_start() { @@ -82,6 +87,14 @@ pub(crate) fn note_incremental_step() { #[inline] pub(crate) fn note_incremental_completion() { INCREMENTAL_COMPLETIONS.fetch_add(1, Ordering::Relaxed); + THREAD_INCREMENTAL_COMPLETIONS.with(|count| count.set(count.get().saturating_add(1))); +} + +/// Budgeted cycles completed on the collection thread. Failure diagnostics +/// compare this with the value at the preceding verified minor, rather than +/// using the process-wide count (which would mix independent agent heaps). +pub(crate) fn incremental_completions_on_current_thread() -> u64 { + THREAD_INCREMENTAL_COMPLETIONS.with(Cell::get) } /// Budgeted incremental cycles started in this process. diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index 64409430cb..13ae921a77 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -213,6 +213,8 @@ pub(crate) use cycle_malloc_trim::{ reset_test_malloc_trim_executed_count, test_malloc_trim_executed_count, }; mod verify; +mod verify_diag; +use verify_diag::*; /// #7035: whole-heap from-space scan — verification that does NOT depend on /// the rewrite pass own root enumeration. Debug-only diff --git a/crates/perry-runtime/src/gc/roots.rs b/crates/perry-runtime/src/gc/roots.rs index c40c29cf9c..4b6483f14d 100644 --- a/crates/perry-runtime/src/gc/roots.rs +++ b/crates/perry-runtime/src/gc/roots.rs @@ -908,7 +908,7 @@ impl<'a> RuntimeRootVisitor<'a> { } RuntimeRootVisitMode::Verify { verifier, surface } => { if let Some(new_bits) = verifier.stale_nanboxed_value(bits) { - panic_stale_forwarded_reference(surface, 0, bits, new_bits); + panic_stale_forwarded_reference(*verifier, surface, 0, bits, new_bits); } None } @@ -937,7 +937,7 @@ impl<'a> RuntimeRootVisitor<'a> { RuntimeRootVisitMode::Rewrite { valid_ptrs } => try_rewrite_value(bits, valid_ptrs), RuntimeRootVisitMode::Verify { verifier, surface } => { if let Some(new_bits) = verifier.stale_value(bits) { - panic_stale_forwarded_reference(surface, 0, bits, new_bits); + panic_stale_forwarded_reference(*verifier, surface, 0, bits, new_bits); } None } @@ -975,6 +975,7 @@ impl<'a> RuntimeRootVisitor<'a> { RuntimeRootVisitMode::Verify { verifier, surface } => { if let Some(new_addr) = verifier.stale_raw_addr(addr) { panic_stale_forwarded_reference( + *verifier, surface, 0, copy_tag | (addr as u64 & POINTER_MASK), @@ -1002,7 +1003,13 @@ impl<'a> RuntimeRootVisitor<'a> { RuntimeRootVisitMode::CopyingRewrite { collector } => collector.rewrite_raw_addr(addr), RuntimeRootVisitMode::Verify { verifier, surface } => { if let Some(new_addr) = verifier.stale_raw_addr(addr) { - panic_stale_forwarded_reference(surface, 0, addr as u64, new_addr as u64); + panic_stale_forwarded_reference( + *verifier, + surface, + 0, + addr as u64, + new_addr as u64, + ); } None } diff --git a/crates/perry-runtime/src/gc/tests/copying.rs b/crates/perry-runtime/src/gc/tests/copying.rs index 88312209a4..81e1e3baac 100644 --- a/crates/perry-runtime/src/gc/tests/copying.rs +++ b/crates/perry-runtime/src/gc/tests/copying.rs @@ -8,6 +8,7 @@ mod promise_side_tables; mod promoted_remembered_7803; mod survival_and_malloc; mod verify_malloc_borrow; +mod verify_parent_context; mod weak_holder_registry; mod weak_semantics; use super::super::*; diff --git a/crates/perry-runtime/src/gc/tests/copying/verify_parent_context.rs b/crates/perry-runtime/src/gc/tests/copying/verify_parent_context.rs new file mode 100644 index 0000000000..69b1da1563 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/copying/verify_parent_context.rs @@ -0,0 +1,104 @@ +use super::*; + +fn panic_message(payload: Box) -> String { + match payload.downcast::() { + Ok(message) => *message, + Err(payload) => match payload.downcast::<&'static str>() { + Ok(message) => (*message).to_owned(), + Err(_) => "non-string panic payload".to_owned(), + }, + } +} + +#[test] +fn stale_forwarded_reference_panic_names_parent_slot_and_coverage() { + let message = std::thread::spawn(|| { + let failure = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = CopyingNurseryTestGuard::new(1); + let _verify = VerifyEvacuationTestGuard::on(); + let _trigger = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + + let child = young_leaf(); + let (_parent, field) = unsafe { alloc_old_test_object(1) }; + unsafe { + // Deliberate sabotage: publish the old -> young field without + // its write barrier, so neither the page nor the owner enters + // the remembered snapshot this minor walks. + *field = ptr_bits(child); + } + js_shadow_slot_set(0, ptr_bits(child)); + + let _ = collect_minor_trace(GcTriggerKind::Direct); + panic!("the sabotaged parent must leave a stale forwarded field"); + })); + panic_message(failure.expect_err("the evacuation verifier must reject the stale field")) + }) + .join() + .expect("worker thread must return the caught verifier panic"); + + for field in [ + "parent_type=", + "parent_space=old_page", + "slot_index=0", + "remembered=no", + "child_type=", + "minor=", + "trigger=", + ] { + assert!( + message.contains(field), + "verifier panic omitted {field:?}: {message}" + ); + } +} + +#[test] +fn evacuation_verifier_pass_line_counts_parents_and_slots() { + const CHILD_ENV: &str = "PERRY_TEST_VERIFY_PASS_LINE_CHILD"; + let thread = std::thread::current(); + let name = thread.name().expect("libtest must name the test thread"); + if std::env::var(CHILD_ENV).ok().as_deref() == Some(name) { + let _guard = CopyingNurseryTestGuard::new(1); + let _verify = VerifyEvacuationTestGuard::on(); + let _trigger = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + js_shadow_slot_set(0, ptr_bits(young_leaf())); + let trace = collect_minor_trace(GcTriggerKind::Direct); + assert_copied_minor_trace(&trace, true, CopiedMinorFallbackReason::None, false); + println!("evacuation verifier pass-line child completed"); + return; + } + + let output = std::process::Command::new(std::env::current_exe().expect("current test binary")) + .args(["--exact", name, "--nocapture", "--test-threads=1"]) + .env(CHILD_ENV, name) + .env("PERRY_GC_DIAG", "1") + .output() + .expect("launch isolated diagnostic witness"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success() && stdout.contains("evacuation verifier pass-line child completed"), + "diagnostic witness failed: {}\nstdout:\n{stdout}\nstderr:\n{stderr}", + output.status + ); + + let lines: Vec<_> = stderr + .lines() + .filter(|line| line.starts_with("[gc-verify] minor=") && line.contains(" evacuation_ok ")) + .collect(); + assert_eq!( + lines.len(), + 1, + "expected exactly one copied-minor verifier pass line; stderr:\n{stderr}" + ); + let parents = lines[0] + .split_whitespace() + .find_map(|word| word.strip_prefix("parents=")) + .and_then(|value| value.parse::().ok()) + .expect("pass line must contain a numeric parents count"); + assert!( + parents > 0, + "pass line must prove the heap walk ran: {}", + lines[0] + ); +} diff --git a/crates/perry-runtime/src/gc/verify.rs b/crates/perry-runtime/src/gc/verify.rs index a6f6450c70..732354f9fe 100644 --- a/crates/perry-runtime/src/gc/verify.rs +++ b/crates/perry-runtime/src/gc/verify.rs @@ -57,8 +57,10 @@ pub(super) fn try_rewrite_raw_addr(ptr_addr: usize, valid_ptrs: &ValidPointerSet /// Carry that distinction through every verifier surface, including FFI roots. #[derive(Clone, Copy)] pub(super) struct EvacuationVerifier<'a> { - valid_ptrs: &'a ValidPointerSet, + pub(super) valid_ptrs: &'a ValidPointerSet, copying_minor: bool, + pub(super) context: Option>, + pub(super) parent_header: Option<*mut GcHeader>, } impl<'a> EvacuationVerifier<'a> { @@ -66,6 +68,8 @@ impl<'a> EvacuationVerifier<'a> { Self { valid_ptrs, copying_minor: false, + context: None, + parent_header: None, } } @@ -74,9 +78,21 @@ impl<'a> EvacuationVerifier<'a> { Self { valid_ptrs, copying_minor: true, + context: None, + parent_header: None, } } + pub(super) fn with_context(mut self, context: EvacuationVerifyCycleContext<'a>) -> Self { + self.context = Some(context); + self + } + + fn with_parent(mut self, parent_header: *mut GcHeader) -> Self { + self.parent_header = Some(parent_header); + self + } + pub(super) fn stale_raw_addr(self, addr: usize) -> Option { follow_forwarding_raw_addr(addr, self.valid_ptrs, |source, target| { if !self.copying_minor { @@ -155,14 +171,13 @@ fn follow_forwarding_raw_addr( #[cold] pub(super) fn panic_stale_forwarded_reference( + verifier: EvacuationVerifier<'_>, surface: &str, slot_addr: usize, old_bits: u64, new_bits: u64, ) -> ! { - panic!( - "gc evacuation verification failed: stale forwarded pointer in {surface}: slot=0x{slot_addr:x} old=0x{old_bits:x} forwarded_to=0x{new_bits:x}" - ); + panic_stale_forwarded_reference_detailed(verifier, surface, slot_addr, old_bits, new_bits); } /// In-place rewrite helper: read `*slot`, run it through @@ -183,7 +198,7 @@ pub(super) unsafe fn verify_slot( ) { let bits = *slot; if let Some(new_bits) = verifier.stale_value(bits) { - panic_stale_forwarded_reference(surface, slot as usize, bits, new_bits); + panic_stale_forwarded_reference(verifier, surface, slot as usize, bits, new_bits); } } @@ -1205,15 +1220,25 @@ pub(super) unsafe fn verify_heap_object_fields( header: *mut GcHeader, verifier: EvacuationVerifier<'_>, surface: &'static str, -) { +) -> usize { let flags = (*header).gc_flags; if flags & GC_FLAG_FORWARDED != 0 { - return; - } - visit_gc_rewrite_slots(header, |slot| unsafe { - slot.record_layout_read(); - verify_slot(slot.slot as *const u64, verifier, surface); + return 0; + } + let verifier = verifier.with_parent(header); + let mut slots = 0usize; + visit_gc_rewrite_slot_descriptors(header, |descriptor| unsafe { + slots = slots.saturating_add(match descriptor { + GcMutableSlotDescriptor::Slot(_) => 1, + GcMutableSlotDescriptor::Range { range, .. } => range.slot_count(), + GcMutableSlotDescriptor::PointerFreeRange(_) => 0, + }); + descriptor.visit_slots(&mut |slot| { + slot.record_layout_read(); + verify_slot(slot.slot as *const u64, verifier, surface); + }); }); + slots } /// Walk every live (MARKED, non-FORWARDED) object on the heap and @@ -1356,17 +1381,18 @@ pub(super) fn verify_mutable_root_slots(verifier: EvacuationVerifier<'_>) { MutableRootSlotKind::NativeStack => "native stack-map roots", MutableRootSlotKind::GlobalRoot => "global roots", }; - panic_stale_forwarded_reference(surface, slot.ptr as usize, bits, new_bits); + panic_stale_forwarded_reference(verifier, surface, slot.ptr as usize, bits, new_bits); } }); } pub(super) fn verify_mutable_registered_roots(verifier: EvacuationVerifier<'_>) { let scanners: Vec = MUTABLE_ROOT_SCANNERS.with(|s| s.borrow().clone()); - let mut visitor = RuntimeRootVisitor::for_verify(verifier, "runtime mutable root scanner"); for entry in scanners { + let mut visitor = RuntimeRootVisitor::for_verify(verifier, entry.name); (entry.scanner)(&mut visitor); } + let mut visitor = RuntimeRootVisitor::for_verify(verifier, "ffi mutable root scanner"); visit_ffi_mutable_registered_roots(&mut visitor); } @@ -1376,7 +1402,7 @@ pub(super) fn verify_copy_only_scanner_bits( surface: &'static str, ) { if let Some(new_bits) = verifier.stale_nanboxed_value(bits) { - panic_stale_forwarded_reference(surface, 0, bits, new_bits); + panic_stale_forwarded_reference(verifier, surface, 0, bits, new_bits); } } @@ -1407,15 +1433,35 @@ pub(super) fn verify_copy_only_registered_roots(verifier: EvacuationVerifier<'_> pub(super) fn verify_remembered_dirty_ranges(verifier: EvacuationVerifier<'_>) { let snapshot = remembered_dirty_snapshot(); let mut stats = RememberedSetTraceStats::default(); - let mut verify_dirty_slot = |slot: *mut u64, _stats: &mut RememberedSetTraceStats| unsafe { - verify_slot(slot as *const u64, verifier, "remembered dirty ranges"); + let mut seen_headers = crate::fast_hash::new_ptr_hash_set(); + let mut verify_header = |header: *mut GcHeader| unsafe { + if !seen_headers.insert(header as usize) { + return; + } + let parent_verifier = verifier.with_parent(header); + let mut verify_dirty_slot = |slot: *mut u64, _stats: &mut RememberedSetTraceStats| { + verify_slot( + slot as *const u64, + parent_verifier, + "remembered dirty ranges", + ); + }; + scan_dirty_header_once( + header, + &snapshot.dirty_pages, + verifier.valid_ptrs, + &mut stats, + &mut verify_dirty_slot, + ); }; - scan_remembered_dirty_slot_ranges( - &snapshot, - verifier.valid_ptrs, - &mut stats, - &mut verify_dirty_slot, - ); + if !snapshot.dirty_old_pages.is_empty() { + crate::arena::old_arena_walk_objects_on_pages(&snapshot.dirty_old_pages, |header| { + verify_header(header as *mut GcHeader); + }); + } + for &(_, header) in &snapshot.external_dirty_entries { + verify_header(header as *mut GcHeader); + } for header_addr in snapshot.fallback_headers { let user_ptr = header_addr + GC_HEADER_SIZE; @@ -1432,8 +1478,9 @@ pub(super) fn verify_remembered_dirty_ranges(verifier: EvacuationVerifier<'_>) { } } -pub(super) fn verify_heap_objects(verifier: EvacuationVerifier<'_>) { - let verify_one = |header: *mut GcHeader| unsafe { +pub(super) fn verify_heap_objects(verifier: EvacuationVerifier<'_>) -> EvacuationVerifyStats { + let mut stats = EvacuationVerifyStats::default(); + let mut verify_one = |header: *mut GcHeader| unsafe { let flags = (*header).gc_flags; if flags & GC_FLAG_FORWARDED != 0 { return; @@ -1449,20 +1496,27 @@ pub(super) fn verify_heap_objects(verifier: EvacuationVerifier<'_>) { return; } } - verify_heap_object_fields(header, verifier, "heap fields"); + stats.parents = stats.parents.saturating_add(1); + stats.slots = + stats + .slots + .saturating_add(verify_heap_object_fields(header, verifier, "heap fields")); }; crate::arena::arena_walk_objects(|hp| verify_one(hp as *mut GcHeader)); for header in malloc_headers_for_verification() { verify_one(header); } + stats } -pub(super) fn verify_evacuated_no_stale_forwarded_refs(verifier: EvacuationVerifier<'_>) { +pub(super) fn verify_evacuated_no_stale_forwarded_refs( + verifier: EvacuationVerifier<'_>, +) -> EvacuationVerifyStats { verify_mutable_root_slots(verifier); verify_mutable_registered_roots(verifier); verify_copy_only_registered_roots(verifier); verify_remembered_dirty_ranges(verifier); - verify_heap_objects(verifier); + verify_heap_objects(verifier) } /// Top-level Phase C4b-γ-2 entry: rewrite every reference site we diff --git a/crates/perry-runtime/src/gc/verify_diag.rs b/crates/perry-runtime/src/gc/verify_diag.rs new file mode 100644 index 0000000000..21274291d4 --- /dev/null +++ b/crates/perry-runtime/src/gc/verify_diag.rs @@ -0,0 +1,293 @@ +//! Failure-only attribution for the evacuation verifier. +//! +//! The verifier's passing slot closure deliberately stays free of the page, +//! registry, type-name and descriptor re-walks below. A stale edge is already +//! fatal, so that path can spend the extra work needed to name the owner and +//! the collection coverage which failed to visit it. + +use super::*; +use std::cell::Cell; + +#[derive(Clone, Copy)] +pub(super) struct EvacuationVerifyCycleContext<'a> { + pub(super) minor: u64, + pub(super) trigger: GcTriggerKind, + pub(super) after_budgeted_step: bool, + pub(super) dirty_snapshot: Option<&'a RememberedDirtySnapshot>, +} + +crate::perry_thread_local! { + static VERIFY_MINOR_ORDINAL: Cell = const { Cell::new(0) }; + static LAST_VERIFY_BUDGETED_COMPLETIONS: Cell = const { Cell::new(0) }; +} + +pub(super) fn begin_evacuation_verify_cycle( + trigger: GcTriggerKind, + dirty_snapshot: Option<&RememberedDirtySnapshot>, +) -> EvacuationVerifyCycleContext<'_> { + let minor = VERIFY_MINOR_ORDINAL.with(|ordinal| { + let next = ordinal.get().saturating_add(1); + ordinal.set(next); + next + }); + let completed = super::instruments::incremental_completions_on_current_thread(); + let after_budgeted_step = LAST_VERIFY_BUDGETED_COMPLETIONS.with(|last| { + let after = completed > last.get(); + last.set(completed); + after + }); + EvacuationVerifyCycleContext { + minor, + trigger, + after_budgeted_step, + dirty_snapshot, + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(super) struct EvacuationVerifyStats { + pub(super) parents: usize, + pub(super) slots: usize, +} + +fn yes_no(value: bool) -> &'static str { + if value { + "yes" + } else { + "no" + } +} + +fn type_name(obj_type: u8) -> &'static str { + gc_type_info(obj_type).map_or("unknown", |info| info.name) +} + +fn decoded_addr(bits: u64) -> usize { + decode_root_word(bits) + .map(|word| word.addr()) + .unwrap_or((bits & POINTER_MASK) as usize) +} + +unsafe fn object_type_at(verifier: EvacuationVerifier<'_>, addr: usize) -> &'static str { + if addr <= GC_HEADER_SIZE || !verifier.valid_ptrs.contains(&addr) { + return "unknown"; + } + type_name((*header_from_user_ptr(addr as *const u8)).obj_type) +} + +unsafe fn object_space(header: *mut GcHeader, user: usize) -> &'static str { + let flags = (*header).gc_flags; + if flags & GC_FLAG_PINNED != 0 { + return "pinned"; + } + if flags & GC_FLAG_ARENA == 0 { + return "malloc"; + } + match crate::arena::classify_heap_space(user) { + crate::arena::HeapSpace::PromotedYoung => "promoted_in_place_this_cycle", + crate::arena::HeapSpace::NurseryEden => "nursery_from", + space if space == crate::arena::active_survivor_space() => "nursery_from", + space if space == crate::arena::inactive_survivor_space() => "nursery_to", + crate::arena::HeapSpace::Old | crate::arena::HeapSpace::Longlived => "old_page", + crate::arena::HeapSpace::Unknown + | crate::arena::HeapSpace::Survivor0 + | crate::arena::HeapSpace::Survivor1 => "old_page", + } +} + +unsafe fn forwarded_target_space(verifier: EvacuationVerifier<'_>, addr: usize) -> &'static str { + if addr <= GC_HEADER_SIZE || !verifier.valid_ptrs.contains(&addr) { + return "old_page"; + } + object_space(header_from_user_ptr(addr as *const u8), addr) +} + +fn layout_visitor_name(kind: GcLayoutSlotKind) -> &'static str { + match kind { + GcLayoutSlotKind::None => "GcMutableSlotDescriptor", + GcLayoutSlotKind::ArrayElements => "ArrayElements", + GcLayoutSlotKind::ObjectFields => "ObjectFields", + GcLayoutSlotKind::RegExpFields => "RegExpFields", + GcLayoutSlotKind::ClosureCaptures => "ClosureCaptures", + GcLayoutSlotKind::ObjectMeta => "ObjectMeta", + } +} + +fn rewrite_visitor_name(kind: GcRewriteDescriptorKind) -> &'static str { + match kind { + GcRewriteDescriptorKind::Leaf => "GcMutableSlotDescriptor", + GcRewriteDescriptorKind::Array => "ArrayFields", + GcRewriteDescriptorKind::Object => "ObjectSideFields", + GcRewriteDescriptorKind::RegExp => "RegExpFields", + GcRewriteDescriptorKind::Closure => "ClosureSideFields", + GcRewriteDescriptorKind::Promise => "PromiseFields", + GcRewriteDescriptorKind::Error => "ErrorFields", + GcRewriteDescriptorKind::Map => "MapEntries", + GcRewriteDescriptorKind::LazyArray => "LazyArrayFields", + GcRewriteDescriptorKind::Set => "SetElements", + GcRewriteDescriptorKind::NativeTypedView => "NativeTypedViewFields", + GcRewriteDescriptorKind::NativePodView => "NativePodViewFields", + GcRewriteDescriptorKind::ObjectMeta => "ObjectMeta", + GcRewriteDescriptorKind::MetaOnly => "MetaOnlyFields", + } +} + +unsafe fn descriptor_slot_index( + descriptor: GcMutableSlotDescriptor, + wanted: usize, +) -> Option { + match descriptor { + GcMutableSlotDescriptor::Slot(slot) => (slot.slot as usize == wanted).then_some(0), + GcMutableSlotDescriptor::Range { range, .. } => { + let start = range.slots() as usize; + let offset = wanted.checked_sub(start)?; + (offset % std::mem::size_of::() == 0 + && offset / std::mem::size_of::() < range.slot_count()) + .then_some(offset / std::mem::size_of::()) + } + GcMutableSlotDescriptor::PointerFreeRange(_) => None, + } +} + +unsafe fn descriptor_slot_count(descriptor: GcMutableSlotDescriptor) -> usize { + match descriptor { + GcMutableSlotDescriptor::Slot(_) => 1, + GcMutableSlotDescriptor::Range { range, .. } => range.slot_count(), + GcMutableSlotDescriptor::PointerFreeRange(_) => 0, + } +} + +unsafe fn describe_parent_slot( + header: *mut GcHeader, + slot_addr: usize, +) -> (Option, &'static str) { + let layout_kind = gc_type_layout_slot_kind((*header).obj_type); + let mut layout_match = None; + let mut layout_base = 0usize; + visit_gc_layout_slot_descriptors(header, &mut |descriptor| { + if layout_match.is_none() { + layout_match = descriptor_slot_index(descriptor, slot_addr) + .map(|index| layout_base.saturating_add(index)); + } + layout_base = layout_base.saturating_add(descriptor_slot_count(descriptor)); + }); + if let Some(index) = layout_match { + return (Some(index), layout_visitor_name(layout_kind)); + } + + let rewrite_kind = gc_type_rewrite_descriptor_kind((*header).obj_type); + let mut rewrite_match = None; + let mut rewrite_base = 0usize; + visit_gc_rewrite_slot_descriptors(header, |descriptor| { + if rewrite_match.is_none() { + rewrite_match = descriptor_slot_index(descriptor, slot_addr) + .map(|index| rewrite_base.saturating_add(index)); + } + rewrite_base = rewrite_base.saturating_add(descriptor_slot_count(descriptor)); + }); + (rewrite_match, rewrite_visitor_name(rewrite_kind)) +} + +#[cold] +pub(super) fn panic_stale_forwarded_reference_detailed( + verifier: EvacuationVerifier<'_>, + surface: &str, + slot_addr: usize, + old_bits: u64, + new_bits: u64, +) -> ! { + let surface_token = surface + .chars() + .map(|ch| if ch.is_ascii_whitespace() { '_' } else { ch }) + .collect::(); + let old_addr = decoded_addr(old_bits); + let new_addr = decoded_addr(new_bits); + let (child_type, child_space) = unsafe { + ( + object_type_at(verifier, old_addr).or_else_unknown(object_type_at(verifier, new_addr)), + forwarded_target_space(verifier, new_addr), + ) + }; + let (minor, trigger, after_budgeted_step) = verifier.context.map_or_else( + || ("n/a".to_owned(), "n/a".to_owned(), "n/a"), + |context| { + ( + context.minor.to_string(), + format!("{:?}", context.trigger), + yes_no(context.after_budgeted_step), + ) + }, + ); + + if let Some(parent_header) = verifier.parent_header { + unsafe { + let parent = (parent_header as *mut u8).add(GC_HEADER_SIZE) as usize; + let parent_space = object_space(parent_header, parent); + let (slot_index, visitor) = describe_parent_slot(parent_header, slot_addr); + let slot_index = slot_index.map_or_else(|| "n/a".to_owned(), |i| i.to_string()); + let coverage_expected = matches!( + parent_space, + "old_page" | "malloc" | "promoted_in_place_this_cycle" + ); + let (remembered, dirty_snapshot) = if coverage_expected { + let remembered_snapshot = remembered_dirty_snapshot(); + let remembered = old_young_slot_covered( + &remembered_snapshot, + parent_header as usize, + slot_addr as *mut u64, + ); + let dirty = verifier + .context + .and_then(|context| context.dirty_snapshot) + .map(|snapshot| { + old_young_slot_covered( + snapshot, + parent_header as usize, + slot_addr as *mut u64, + ) + }); + ( + yes_no(remembered), + dirty.map_or("n/a(no_cycle_snapshot)", yes_no), + ) + } else { + ("n/a(nursery_parent)", "n/a(nursery_parent)") + }; + panic!( + "gc evacuation verification failed: stale forwarded pointer in {surface}: surface={surface_token} parent=0x{parent:x} parent_type={} parent_space={parent_space} slot=0x{slot_addr:x} slot_index={slot_index} visitor={visitor} old=0x{old_bits:x} forwarded_to=0x{new_bits:x} child_type={child_type} child_space={child_space} remembered={remembered} young_logged=n/a(heap_parent_uses_remembered_set) dirty_snapshot={dirty_snapshot} minor={minor} trigger={trigger} after_budgeted_step={after_budgeted_step}", + type_name((*parent_header).obj_type), + ); + } + } + + panic!( + "gc evacuation verification failed: stale forwarded pointer in {surface}: surface={surface_token} parent=n/a(root) parent_type=n/a(root) parent_space=n/a(root) slot=0x{slot_addr:x} slot_index=n/a(root) visitor={surface_token} old=0x{old_bits:x} forwarded_to=0x{new_bits:x} child_type={child_type} child_space={child_space} remembered=n/a(root) young_logged=n/a(root_scanner_does_not_expose_owner_key) dirty_snapshot=n/a(root) minor={minor} trigger={trigger} after_budgeted_step={after_budgeted_step}" + ); +} + +trait UnknownTypeFallback { + fn or_else_unknown(self, fallback: Self) -> Self; +} + +impl UnknownTypeFallback for &'static str { + fn or_else_unknown(self, fallback: Self) -> Self { + if self == "unknown" { + fallback + } else { + self + } + } +} + +pub(super) fn report_evacuation_success( + context: EvacuationVerifyCycleContext<'_>, + stats: EvacuationVerifyStats, + old_young_edges: usize, +) { + if gc_diag_enabled() { + eprintln!( + "[gc-verify] minor={} evacuation_ok parents={} slots={} old_young_edges={old_young_edges}", + context.minor, stats.parents, stats.slots, + ); + } +} From 38229ccb0698f0512935b234fba5b79a091daa6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 15:34:10 +0200 Subject: [PATCH 4/5] docs(gc): report verifier parent attribution Record VF2 field derivation, covered failure sites, passing-path cost, focused test evidence, disk-limited gates, and the perrymaster campaign request. Claude-Session: https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo --- .../codex/REPORT_verify_evacuation_borrow.md | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/cc-perf-campaign/codex/REPORT_verify_evacuation_borrow.md b/cc-perf-campaign/codex/REPORT_verify_evacuation_borrow.md index 928231c932..0fb5e6201b 100644 --- a/cc-perf-campaign/codex/REPORT_verify_evacuation_borrow.md +++ b/cc-perf-campaign/codex/REPORT_verify_evacuation_borrow.md @@ -82,3 +82,147 @@ Relink on main's cache, then run `cc` with `PERRY_GC_VERIFY_EVACUATION=1` for **4 turns**. The run must complete all four turns, emit `[gc-verify]`-style verifier output proving the diagnostic was live, and contain no `RefCell already borrowed` or other panic. + +## VF2: the parent names itself + +### Commit + +- Implementation commit: `1ec9e0e8ac72cbce4098d86940b205f90effafd8` + (`fix(gc): attribute stale evacuation pointers`) +- Extended branch base: `4295d390d` (the first verifier report commit) + +### Failure line and field derivation + +Every stale-forwarding panic remains one physical line and now carries the +same field vocabulary for heap slots, roots, and runtime side-table scanners: + +- `parent` is the user address (`header + GC_HEADER_SIZE`). `parent_type` is + `gc_type_info(header.obj_type).name`. Root-owned slots use `n/a(root)` because + their scanner has no GC parent header. +- `parent_space` first reads `GC_FLAG_PINNED` (`pinned`), then + `GC_FLAG_ARENA` (clear means `malloc`). Arena parents use the current arena + block class: `PromotedYoung` means `promoted_in_place_this_cycle`; Eden and + the active survivor half mean `nursery_from`; the inactive survivor half + means `nursery_to`; Old and Longlived mean `old_page`. The transient + `PromotedYoung` block class is the collector's this-cycle promotion set, so + no historical/guessed tenuring classification is used. +- `slot_index` is the cumulative zero-based pointer-slot index within the + parent. It is derived only after failure by replaying the layout descriptors. + `visitor` is the matching `GcLayoutSlotKind` or, for side fields absent from + the layout walk, the matching `GcRewriteDescriptorKind` (for example + `GcMutableSlotDescriptor`, `ArrayElements`, `ObjectFields`, `RegExpFields`, + `ClosureCaptures`, `ObjectMeta`, or the corresponding side-field family). +- `child_type` reads `obj_type` from the still-valid forwarding source header; + if that source is not in the exact pointer census, it falls back to the + forwarded-to header. `child_space` classifies `forwarded_to` with the same + `GC_FLAG_PINNED` / `GC_FLAG_ARENA` checks and arena block classes. Thus + `nursery_to` is a survivor copy and `old_page` is a promoted copy. +- `remembered` re-snapshots the live remembered set on the cold failure path + and applies `old_young_slot_covered`, including an external `(page, owner)` + entry when the slot is outside its parent. `dirty_snapshot` tests the exact + pre-collection `RememberedDirtySnapshot` consumed by this copied minor. + Nursery parents report both as `n/a(nursery_parent)`. `young_logged` is + `n/a(heap_parent_uses_remembered_set)` for heap parents: young-entry logs own + side-table keys, not heap parents. Root/side-table failures report + `n/a(root_scanner_does_not_expose_owner_key)` because the scanner API exposes + the scanner class and value but not the table's log key; this is an explicit + non-answer rather than a guessed `no`. +- `minor` is a per-collection-thread evacuation-verifier ordinal. `trigger` is + the cycle's `GcTriggerKind`. `after_budgeted_step` compares a new per-thread + budgeted-cycle completion counter with the value observed by the preceding + verified minor, so an unrelated agent heap cannot set it. + +The original `slot`, `old`, and `forwarded_to` values remain present. The +failure line also has `surface`, a whitespace-free scanner/surface token, so a +root-side miss can be grouped without parsing prose. + +### Panic sites covered + +All callers of `panic_stale_forwarded_reference` pass the cycle context and +use the common formatter: + +- heap object rewrite descriptors (`heap fields`), remembered dirty ranges, + and remembered fallback headers; +- shadow-stack, native stack-map, and global mutable roots; +- each named Rust mutable-root scanner and the FFI mutable-root scanner; +- Rust and FFI copy-only root scanners; +- the `RuntimeRootVisitor` NaN-box, heap-word, tagged-raw-address, and + metadata-raw-address paths used by runtime side tables. + +Heap dirty-range verification now retains the owner header while visiting a +dirty slot, so a failure in that earlier verifier surface names the parent too, +instead of waiting for the later whole-heap walk. + +### Passing-path cost and liveness line + +The expensive work is behind the cold stale-reference branch: type lookup, +space classification, remembered-set re-snapshot, coverage probes, string +construction, and descriptor replay occur only immediately before panic. The +passing per-slot closure is unchanged: it records the layout read and invokes +`verify_slot`, with no new type, arena, set, or snapshot read. Counts are +accumulated once per accepted parent and once per descriptor from the +descriptor's existing slot count; there is no added per-slot diagnostic probe. + +When a copied minor passes and `PERRY_GC_DIAG=1`, it emits exactly one: + +```text +[gc-verify] minor= evacuation_ok parents= slots= old_young_edges= +``` + +`parents` and `slots` come from the live whole-heap verification walk; +`old_young_edges` is the already-computed count from +`verify_old_to_young_edges_covered`, so the line proves both verifier phases +ran without adding a second edge walk. + +### Tests and gates + +The disk-compliant focused gate ran with 13 GB available: + +```text +cargo test -p perry-runtime --release --lib -j4 -- --test-threads=1 verify +``` + +All 16 matching tests passed: the 14 existing verify/malloc-borrow tests plus +the two named VF2 tests. That run preceded the final failure-only dirty-owner +retention and cumulative slot-index cleanup; rerunning the resulting commit is +**not run: disk**. + +- `stale_forwarded_reference_panic_names_parent_slot_and_coverage` publishes + an old-page parent's nursery field without the write barrier, catches the + copied-minor verifier panic on a worker thread, and asserts + `parent_type=`, `parent_space=old_page`, `slot_index=0`, `remembered=no`, + `child_type=`, `minor=`, and `trigger=`. +- `evacuation_verifier_pass_line_counts_parents_and_slots` uses an isolated + child-test process with diagnostics enabled, captures stderr, requires + exactly one copied-minor success line, and parses `parents` as a positive + integer. + +Sabotage reruns: **not run: disk**. Removing one asserted panic field was +restored without retaining the mutation; the mandatory pre-Cargo check then +reported 11 GB, below the 12 GB floor. Skipping the pass line was likewise not +run for the same disk reason. The assertions are direct (missing the field or +line reaches `assert!` / `assert_eq!`), but no mutation-test pass is claimed. + +The full release `--lib` suite and the release `wasm-host` build are **not run: +disk**: subsequent checks reported 10--11 GB available. The attempted field +sabotage command was interrupted immediately after its pre-check exposed the +sub-floor value; no further Cargo gate completed. Static gates passed: +`rustfmt --check` and +`git diff --check`. Touched Rust files are below 2,000 lines; the largest is +`copying.rs` at 1,928 lines (`roots.rs` 1,886; `cycle.rs` 1,797; `verify.rs` +1,558; `verify_diag.rs` 293). + +### Perrymaster request + +Relink `app-vf` (main + this branch) and `app-vfat` (the AT2 tree + this branch) +on their existing caches. Run **N = 6** four-turn 3300 sessions for each app +with: + +```text +PERRY_GC_VERIFY_EVACUATION=1 PERRY_GC_DIAG=1 RUST_BACKTRACE=1 +``` + +Report every verifier failure line verbatim together with that minor's +preceding `[gc-step]`, `[gc-trigger]`, and `[gc-survival]` lines. For one clean +run of each app, report the `[gc-verify]` pass-line counts so verifier liveness +is independently visible. From 4fc86a2e03ae5d42cd5931be901220350fe73766 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 18:00:12 +0200 Subject: [PATCH 5/5] chore(gc): re-audit census snapshot inventory Re-pin the PASS1_MARKED non-moving window after auditing the verifier diagnostic plumbing, and classify its three counter-only TLS holders. Claude-Session: https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo --- scripts/gc_runtime_root_holders.json | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index f1582b60c2..63835f9c47 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -276,7 +276,7 @@ "file": "crates/perry-runtime/src/gc/census.rs", "name": "PASS1_MARKED", "verdict": "non_moving_snapshot", - "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete \u2192 sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs \u2014 it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase \u2014 after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged \u2014 `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` \u2014 and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` \u2192 `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only \u2014 no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound \u2014 the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses \u2014 no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects \u2014 and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged.", + "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete \u2192 sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs \u2014 it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase \u2014 after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged \u2014 `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` \u2014 and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` \u2192 `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only \u2014 no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound \u2014 the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses \u2014 no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects \u2014 and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected.", "window": { "start": { "file": "crates/perry-runtime/src/gc/census.rs", @@ -292,8 +292,8 @@ }, "sources": { "crates/perry-runtime/src/gc/census.rs": "388414f9629f196e84673e91bebd04bdcdcabdaa180252d2dfe4b82d1b49ca5a", - "crates/perry-runtime/src/gc/cycle.rs": "2e2f5adca2229f74409e01a1cb571e2147cd8a33f58d0976711fce98d4777309", - "crates/perry-runtime/src/gc/mod.rs": "43523b66595c61516ef6fcd4139d3ec5b4768a13c46ae1470c1d45481eacfdd9", + "crates/perry-runtime/src/gc/cycle.rs": "77eadaf7c4157308c3b14be5e1ff11d7198b84b503a50ff2d244851248d3e800", + "crates/perry-runtime/src/gc/mod.rs": "cf763b4d1743cd4ab5a571aef9b1eddba973ef8a205d343dea8f34775ff2fa8a", "crates/perry-runtime/src/gc/policy.rs": "dc9242ed40c0aa9c411d1ec0235c0219c6716dd82d56eb4d46578f7e889825d2", "crates/perry-runtime/src/gc/progress.rs": "a5ad3971bbe4047229ca57325234780daa85921dbc778e1c08dff4ad07ccfb96" } @@ -341,6 +341,12 @@ "verdict": "not_a_gc_pointer", "why": "#9794: per-method primitive-dispatch counts, `HashMap`. Rust-owned method-name strings and counters." }, + { + "file": "crates/perry-runtime/src/gc/instruments.rs", + "name": "THREAD_INCREMENTAL_COMPLETIONS", + "verdict": "not_a_gc_pointer", + "why": "#9965 verifier attribution: per-thread `Cell` tally of completed budgeted cycles. `note_incremental_completion` only increments the count and `incremental_completions_on_current_thread` only reads it; it cannot hold a heap pointer or NaN-boxed value." + }, { "file": "crates/perry-runtime/src/gc/oldgen_defrag.rs", "name": "LAST_IDLE_PREDICTED_RELEASE", @@ -365,6 +371,18 @@ "verdict": "not_a_gc_pointer", "why": "#9717: monotonic count of array-growth forwarding stubs a budgeted full cycle admitted through `classifier_valid_object_start`, reported as `forwarded_stub_recoveries=` on the PERRY_GC_DIAG `[gc-incremental]` line. A `Cell` holding a tally, never an address \u2014 the stubs it counts are reached through the worklist, not retained here. Nothing for the collector." }, + { + "file": "crates/perry-runtime/src/gc/verify_diag.rs", + "name": "LAST_VERIFY_BUDGETED_COMPLETIONS", + "verdict": "not_a_gc_pointer", + "why": "#9965 verifier attribution: per-thread `Cell` storing the preceding verifier invocation's budgeted-cycle completion count. It is compared with the current count to derive the diagnostic `after_budgeted_step` boolean and can never contain a heap pointer or NaN-boxed value." + }, + { + "file": "crates/perry-runtime/src/gc/verify_diag.rs", + "name": "VERIFY_MINOR_ORDINAL", + "verdict": "not_a_gc_pointer", + "why": "#9965 verifier attribution: per-thread `Cell` monotonically counting evacuation-verifier invocations so failure and success diagnostics can label the minor ordinal. It stores only a saturating counter, never a heap pointer or NaN-boxed value." + }, { "file": "crates/perry-runtime/src/hot_diag.rs", "name": "ENUM_DIAG",