From 4519a853d083f3d7fb0c8806c08884391a0d5d98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 8 Aug 2026 19:21:53 +0200 Subject: [PATCH 1/8] test(gc): cover the root lowering that actually ships (#7502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native roots (RS4GC statepoints) have been the default on every target the runtime can walk since #7370, and had no assertions anywhere. The two suites that read as this area's coverage are pinned to the shadow stack (#7493) and stay that way — both lowerings are supported — but that left nine root-lowering mechanics untested against the lowering Perry emits, and three tests passing vacuously because they counted `js_shadow_slot_bind` calls the native lowering never emits. Adds `perry-codegen/src/native_root_coverage`, eight mechanic tests plus five harness self-tests, asserting at three vantages: the `ptr addrspace(1)` allocas codegen asks for, the `"gc-live"` bundle of each `gc.statepoint` after the production pass string, and the per-safepoint root lists decoded out of the compact `__perry_gcmap` blob the collector reads at run time. In-crate `#[cfg(test)]` so it runs in the per-PR `cargo-test` gate rather than the nightly-only tier. Every test is sabotage-verified — ten sabotages, each one confirmed to compile and to reach the test binary before its verdict was believed. Details per test in the doc comments. Two findings worth naming: * #7502's table calls row 9 (#7184's out-of-range slot index) `n/a` under native roots. It is not. `lower_precise_roots_to_native_stack` collects roots with `roots.get_mut(idx)` over a `slot_count`-sized vector, so an out-of-range index still drops a root silently — the same failure one layer up from the runtime bounds check. Sizing that vector one short removes a root from the emitted map with no diagnostic, and now fails a test. * `mem2reg` promoting every root alloca is a load-bearing precondition with no shadow-stack counterpart: RS4GC relocates `addrspace(1)` SSA values and does not scan allocas, so a root slot that escapes promotion is never rewritten. Asserted directly, and sabotage-verified by making the alloca's address escape. Production changes are confined to two test seams and one named constant: `gc_map::decode_stack_map_roots` and `inprocess::statepoint_rewritten_ir` are `#[cfg(test)]`, and `STATEPOINT_REWRITE_PASSES` replaces an inline string literal with the identical value so the suite cannot drift onto a pipeline production stopped using. Emitted IR is unchanged. --- crates/perry-codegen/src/gc_map.rs | 36 + crates/perry-codegen/src/inprocess.rs | 62 +- crates/perry-codegen/src/lib.rs | 5 + .../harness_self_tests.rs | 207 ++++++ .../src/native_root_coverage/mechanics.rs | 693 ++++++++++++++++++ .../src/native_root_coverage/mod.rs | 544 ++++++++++++++ 6 files changed, 1542 insertions(+), 5 deletions(-) create mode 100644 crates/perry-codegen/src/native_root_coverage/harness_self_tests.rs create mode 100644 crates/perry-codegen/src/native_root_coverage/mechanics.rs create mode 100644 crates/perry-codegen/src/native_root_coverage/mod.rs diff --git a/crates/perry-codegen/src/gc_map.rs b/crates/perry-codegen/src/gc_map.rs index 1ccfdd4d5d..d3bd8596c0 100644 --- a/crates/perry-codegen/src/gc_map.rs +++ b/crates/perry-codegen/src/gc_map.rs @@ -895,6 +895,42 @@ fn compact_stack_map_asm(asm: &str, target: &str) -> Result Result>)>, String> { + let lines: Vec<&str> = asm.lines().collect(); + if find_block_start(&lines).is_none() { + return Err("assembly carries no stack-map section".to_string()); + } + let block = parse_block(&lines, word_width_for(target))?; + let functions = decode_v3(&block)?; + let stream = encode_stream(&functions); + verify_roundtrip(&functions, &stream)?; + Ok(functions + .into_iter() + .map(|f| { + ( + f.symbol, + f.records.into_iter().map(|r| r.roots).collect::>(), + ) + }) + .collect()) +} + /// Rewrite the stack map in `asm_path` into Perry's compact form, then /// assemble it to `obj_path`. /// diff --git a/crates/perry-codegen/src/inprocess.rs b/crates/perry-codegen/src/inprocess.rs index 2bcd87d8ce..c914ef58d8 100644 --- a/crates/perry-codegen/src/inprocess.rs +++ b/crates/perry-codegen/src/inprocess.rs @@ -29,6 +29,62 @@ use inkwell::targets::{ }; use inkwell::OptimizationLevel; +/// The pass string that inserts every statepoint, relocation and +/// downstream-use rewrite — i.e. the whole native-roots lowering, after +/// codegen has retyped its root allocas to `ptr addrspace(1)`. +/// +/// Named rather than spelled inline because `native_root_coverage` (#7502) +/// runs it too, and a coverage suite that spelled its own pass list would keep +/// passing against a pipeline production had stopped using. `mem2reg` is not +/// incidental company: RS4GC tracks `addrspace(1)` **SSA values**, not memory, +/// so a root alloca that survives promotion is a root the collector never sees. +pub(crate) const STATEPOINT_REWRITE_PASSES: &str = "function(mem2reg),rewrite-statepoints-for-gc"; + +/// Test seam (#7502): parse `ll_text`, run [`STATEPOINT_REWRITE_PASSES`] for +/// `effective_target`, and return the rewritten IR. +/// +/// Everything about the target machine — triple, CPU, data layout — comes from +/// the same helpers `optimize_and_emit` uses, so an assertion here is about the +/// lowering that ships rather than about a pipeline assembled for the test. +/// Both verifies are load-bearing: the first rejects IR codegen should never +/// have emitted, the second rejects a statepoint form LLVM would refuse to +/// codegen (that is how the Itanium landing-pad shape was found). +#[cfg(test)] +pub(crate) fn statepoint_rewritten_ir( + ll_text: &str, + effective_target: &str, + module_name: &str, +) -> Result { + global_init(&[]); + let context = Context::create(); + let module = parse_ir_text(&context, ll_text, module_name)?; + let triple = TargetTriple::create(effective_target); + let target = Target::from_triple(&triple) + .map_err(|e| anyhow!("no LLVM target for `{effective_target}`: {e}"))?; + let tm = target + .create_target_machine( + &triple, + default_cpu_for_triple(effective_target), + "", + OptimizationLevel::None, + RelocMode::PIC, + CodeModel::Default, + ) + .ok_or_else(|| anyhow!("failed to create TargetMachine for `{effective_target}`"))?; + module.set_triple(&triple); + module.set_data_layout(&tm.get_target_data().get_data_layout()); + module + .verify() + .map_err(|e| anyhow!("LLVM verifier rejected pre-statepoint module:\n{}", e))?; + module + .run_passes(STATEPOINT_REWRITE_PASSES, &tm, PassBuilderOptions::create()) + .map_err(|e| anyhow!("`{STATEPOINT_REWRITE_PASSES}` failed:\n{}", e))?; + module + .verify() + .map_err(|e| anyhow!("LLVM verifier rejected the statepoint module:\n{}", e))?; + Ok(module.print_to_string().to_string()) +} + /// One-time process-global LLVM setup: target registration and `-mllvm` /// pass-through flags. Both are process-global in LLVM itself, which is why /// they are applied under a `Once` and not per compile. The `-mllvm` value is @@ -320,11 +376,7 @@ fn optimize_and_emit( // which the explicit bridge refuses outright (#7327/#7330). if crate::codegen::helpers::rs4gc_enabled() { module - .run_passes( - "function(mem2reg),rewrite-statepoints-for-gc", - &tm, - PassBuilderOptions::create(), - ) + .run_passes(STATEPOINT_REWRITE_PASSES, &tm, PassBuilderOptions::create()) .map_err(|e| { anyhow!( "in-process rewrite-statepoints-for-gc failed:\n{}", diff --git a/crates/perry-codegen/src/lib.rs b/crates/perry-codegen/src/lib.rs index 39d885f8a4..af40b3f862 100644 --- a/crates/perry-codegen/src/lib.rs +++ b/crates/perry-codegen/src/lib.rs @@ -29,6 +29,11 @@ pub mod module; pub mod nanbox; #[cfg(feature = "llvm-inprocess")] pub mod native_emit; +/// Coverage for the native-roots (RS4GC statepoint) lowering that ships — +/// #7502. Test-only; see the module docs for what it asserts and why the +/// shadow-pinned suites are not a substitute. +#[cfg(test)] +mod native_root_coverage; pub(crate) mod native_value; pub(crate) mod nm_install; pub mod opt_report; diff --git a/crates/perry-codegen/src/native_root_coverage/harness_self_tests.rs b/crates/perry-codegen/src/native_root_coverage/harness_self_tests.rs new file mode 100644 index 0000000000..6e3fd5362a --- /dev/null +++ b/crates/perry-codegen/src/native_root_coverage/harness_self_tests.rs @@ -0,0 +1,207 @@ +//! The harness's own coverage. +//! +//! A parser that silently returns "no safepoints" or "no live values" would +//! make every negative assertion in [`super::mechanics`] pass for the wrong +//! reason, and nothing downstream could tell. So the parser is pinned on +//! fixtures whose answers are known by inspection, and the two "this subject +//! does not exist" paths are asserted to PANIC rather than to return empty. + +use super::*; + +/// One statepoint with a two-value live set and one with none — the exact two +/// shapes every mechanic below distinguishes between. +const FIXTURE: &str = r#"define double @probe() gc "statepoint-example" { +entry.0: + %tok = call token (i64, i32, ptr, i32, i32, ...) @llvm.experimental.gc.statepoint.p0(i64 2882400000, i32 0, ptr elementtype(i64 (i32)) @js_map_alloc, i32 1, i32 0, i32 8, i32 0, i32 0) + %tok2 = call token (i64, i32, ptr, i32, i32, ...) @llvm.experimental.gc.statepoint.p0(i64 2882400000, i32 0, ptr elementtype(i64 (i32)) @js_array_alloc, i32 1, i32 0, i32 0, i32 0, i32 0) [ "gc-live"(ptr addrspace(1) %a, ptr addrspace(1) %b) ] + ret double 0.0 +} +"#; + +fn fixture_points() -> Vec { + function_slice(FIXTURE, "probe") + .lines() + .filter(|l| l.contains("llvm.experimental.gc.statepoint")) + .map(super::parse_statepoint) + .collect() +} + +#[test] +fn the_statepoint_parser_reads_callee_and_live_set() { + let points = fixture_points(); + assert_eq!(points.len(), 2, "{points:?}"); + assert_eq!( + points[0], + Statepoint { + callee: "js_map_alloc".to_string(), + live: Vec::new(), + }, + "a statepoint with no bundle must read as zero live values, not as \ + unparsed" + ); + assert_eq!( + points[1], + Statepoint { + callee: "js_array_alloc".to_string(), + live: vec!["%a".to_string(), "%b".to_string()], + }, + "every operand of the `gc-live` bundle must be reported" + ); +} + +/// The parser must not report a live set for a call that has none *because it +/// failed to find the bundle*. Deleting the bundle text is the one edit that +/// tells those apart. +#[test] +fn a_deleted_live_bundle_changes_the_answer() { + let stripped = FIXTURE.replace( + " [ \"gc-live\"(ptr addrspace(1) %a, ptr addrspace(1) %b) ]", + "", + ); + let points: Vec = function_slice(&stripped, "probe") + .lines() + .filter(|l| l.contains("llvm.experimental.gc.statepoint")) + .map(super::parse_statepoint) + .collect(); + assert_eq!(points.len(), 2); + assert!( + points.iter().all(|sp| sp.live.is_empty()), + "{points:?} — and the unmodified fixture must NOT read this way" + ); + assert_eq!( + fixture_points()[1].live.len(), + 2, + "control: the unmodified fixture reports two live values, so the empty \ + result above is the edit and not the parser" + ); +} + +/// `Statepoints::at` is the guard against a mechanic silently asserting about +/// the empty set. It must panic, not return `[]`. +#[test] +#[should_panic(expected = "has no subject")] +fn asking_about_an_absent_callee_is_a_failure_not_an_empty_answer() { + let points = Statepoints { + function: "probe".to_string(), + points: fixture_points(), + }; + points.at("js_closure_call1"); +} + +/// Same contract on the map side: a function the collector will find no roots +/// in must not read as "this function has zero roots". +#[test] +#[should_panic(expected = "no stack-map entry")] +fn a_function_missing_from_the_map_is_a_failure() { + let target = NATIVE_TARGETS[0]; + // A module that DOES produce a map, so the panic under test is "this + // function is absent" rather than "there is no map at all" — the two + // failures this seam exists to keep apart. + let module = probe_module( + "selftest_missing.ts", + vec![ + let_stmt(1, "a", Expr::MapNew), + Stmt::Return(Some(Expr::LocalGet(1))), + ], + ); + let _pin = NativeRootsPin::native(); + let ir = native_ir(&module, target, false); + let asm = assembly_for(&ir, target); + map_records_for(&asm, target, "perry_fn_no_such_function"); +} + +/// End-to-end canary: the pipeline this module asserts through must produce +/// safepoints AND a decodable map for a trivial allocating program on both +/// shipped targets. If this goes red, every other test here is measuring +/// nothing regardless of what it reports. +#[test] +fn the_pipeline_produces_safepoints_and_a_map_on_every_shipped_target() { + for target in NATIVE_TARGETS { + let module = probe_module( + "selftest_canary.ts", + vec![ + let_stmt(1, "a", Expr::MapNew), + let_stmt(2, "b", Expr::MapNew), + Stmt::Return(Some(Expr::LocalGet(1))), + ], + ); + let _pin = NativeRootsPin::native(); + let ir = native_ir(&module, target, false); + let symbol = probe_symbol("selftest_canary.ts"); + let fn_ir = function_slice(&ir, &symbol); + + assert!( + fn_ir.contains("gc \"statepoint-example\""), + "[{target}] a function with root slots must carry the GC strategy, \ + or RS4GC skips it entirely:\n{fn_ir}" + ); + assert_eq!( + root_allocas(fn_ir), + 2, + "[{target}] two heap locals, two root slots:\n{fn_ir}" + ); + + let points = statepoints_of(&ir, target, &symbol); + assert!( + points.len() >= 2, + "[{target}] expected a safepoint per allocation, got {}", + points.len() + ); + + let asm = assembly_for(&ir, target); + let records = map_records_for(&asm, target, &symbol); + assert!( + records.len() >= 2, + "[{target}] the compact map must carry a record per safepoint, got \ + {}", + records.len() + ); + } +} + +/// **`mem2reg` promoting every root alloca is load-bearing, not incidental.** +/// +/// RS4GC tracks `addrspace(1)` SSA values; it does not scan allocas. A root +/// slot that survives promotion is therefore a slot whose contents the +/// collector never relocates — the native-roots analogue of #7184's +/// silently-bounds-checked `js_shadow_slot_bind`, and just as invisible: the +/// IR still says the value was rooted. +/// +/// **Sabotage** — `function/precise_roots.rs`, each root alloca's address +/// passed to a `gc-leaf-function` call right after its definition, so it +/// escapes and `mem2reg` must leave it in memory: RED, two `alloca ptr +/// addrspace(1)` survived the rewrite. Nothing else in the pipeline complains +/// about that IR — it verifies, it codegens, and it ships a frame whose roots +/// the collector cannot follow. +#[test] +fn no_root_alloca_survives_the_statepoint_rewrite() { + for target in NATIVE_TARGETS { + let module = probe_module( + "selftest_promotion.ts", + vec![ + let_stmt(1, "a", Expr::MapNew), + let_stmt(2, "b", Expr::MapNew), + console_log(vec![Expr::LocalGet(1), Expr::LocalGet(2)]), + Stmt::Return(Some(Expr::LocalGet(1))), + ], + ); + let _pin = NativeRootsPin::native(); + let ir = native_ir(&module, target, false); + let symbol = probe_symbol("selftest_promotion.ts"); + assert!( + root_allocas(function_slice(&ir, &symbol)) >= 2, + "[{target}] control: codegen must have asked for root slots here" + ); + + let rewritten = crate::inprocess::statepoint_rewritten_ir(&ir, target, "promotion") + .unwrap_or_else(|e| panic!("[{target}] statepoint rewrite failed: {e:#}")); + let body = function_slice(&rewritten, &symbol); + assert_eq!( + root_allocas(body), + 0, + "[{target}] a root alloca survived mem2reg. RS4GC relocates SSA \ + values, not memory, so this slot's contents are invisible to the \ + collector — the value reads as rooted and is not:\n{body}" + ); + } +} diff --git a/crates/perry-codegen/src/native_root_coverage/mechanics.rs b/crates/perry-codegen/src/native_root_coverage/mechanics.rs new file mode 100644 index 0000000000..80b6d8e553 --- /dev/null +++ b/crates/perry-codegen/src/native_root_coverage/mechanics.rs @@ -0,0 +1,693 @@ +//! One test per root-lowering mechanic #7502 lists as having no native-roots +//! assertion. Each names the shadow-stack test it is the counterpart of. + +use super::*; +use perry_hir::{BinaryOp, CompareOp}; + +/// `for (let i = 0; i < n; i++) { … }` with an OPAQUE bound. +/// +/// The bound is the function's parameter on purpose: a constant-trip loop is +/// unrolled, and an unrolled loop has no back edge, so a claim about "the next +/// iteration's safepoint" would be a claim about a loop that no longer exists. +fn counted_loop(body: Vec) -> Stmt { + Stmt::For { + init: Some(Box::new(Stmt::Let { + id: 90, + name: "i".to_string(), + ty: Type::Number, + mutable: true, + init: Some(Expr::Number(0.0)), + })), + condition: Some(Expr::Compare { + op: CompareOp::Lt, + left: Box::new(Expr::LocalGet(90)), + right: Box::new(Expr::LocalGet(100)), + }), + update: Some(Expr::LocalSet( + 90, + Box::new(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(90)), + right: Box::new(Expr::Number(1.0)), + }), + )), + body, + } +} + +// --------------------------------------------------------------------------- +// 1. A pointer-typed local is a root at the safepoints it is live across +// (shadow counterpart: `function_shadow_slots_clear_dead_values_and_skip_ +// numeric_roots`, first half) +// --------------------------------------------------------------------------- + +/// The load-bearing claim of the whole backend, stated where the collector will +/// read it: a heap value held in a local across a later allocation is in that +/// allocation's live set, in the emitted map. +/// +/// Asserted at all three vantages, because each can be right while the next is +/// wrong: codegen can ask for a root slot LLVM then declines to record, and +/// LLVM can record a statepoint whose roots the map encoder drops. +/// +/// **Sabotage 1** — `function/precise_roots.rs`, the alloca-retype arm emits +/// `alloca double` instead of `alloca ptr addrspace(1)`: RED, `root_allocas` +/// 2 → 0. Reddens all seven mechanics and both pipeline self-tests, which is +/// the point: nothing here can pass without the retype. +/// +/// **Sabotage 2** — the same arm's `.filter(|reg| roots.contains(reg))` +/// dropped, so every scalar alloca is retyped: RED. +#[test] +fn a_live_pointer_local_is_a_root_in_the_emitted_map() { + for target in NATIVE_TARGETS { + let name = "m1_live_local.ts"; + let module = probe_module( + name, + vec![ + let_stmt(1, "a", Expr::MapNew), + // Allocates while `a` is still live. + let_stmt(2, "b", Expr::MapNew), + Stmt::Return(Some(Expr::LocalGet(1))), + ], + ); + let _pin = NativeRootsPin::native(); + let ir = native_ir(&module, target, false); + let symbol = probe_symbol(name); + let fn_ir = function_slice(&ir, &symbol); + + // (1) the request + assert!( + fn_ir.contains("gc \"statepoint-example\""), + "[{target}] no GC strategy — RS4GC would skip this function \ + entirely:\n{fn_ir}" + ); + assert_eq!( + root_allocas(fn_ir), + 2, + "[{target}] both heap locals must be `ptr addrspace(1)` root \ + slots:\n{fn_ir}" + ); + + // (2) the result + let points = statepoints_of(&ir, target, &symbol); + let allocs = points.at("js_map_alloc"); + assert_eq!( + allocs.len(), + 2, + "[{target}] one safepoint per allocation: {:?}", + points.iter().collect::>() + ); + assert_eq!( + allocs[0].live.len(), + 0, + "[{target}] nothing is live yet at the first allocation: {:?}", + allocs[0] + ); + assert_eq!( + allocs[1].live.len(), + 1, + "[{target}] `a` is live across `b`'s allocation and must be in that \ + statepoint's live set — a value the collector cannot see here is \ + a stale pointer the moment the minor evacuates: {:?}", + allocs[1] + ); + + // (3) what the collector reads + assert_eq!( + map_max_roots(&assembly_for(&ir, target), target, &symbol), + 1, + "[{target}] the compact map must carry that root; a map that says \ + nothing lives here is indistinguishable at run time from no \ + rooting at all" + ); + } +} + +// --------------------------------------------------------------------------- +// 2. A dead value is not a root at the next safepoint +// (shadow counterpart: the `js_shadow_slot_set(i32 0, i64 0)` clear in +// `function_shadow_slots_clear_dead_values_and_skip_numeric_roots`) +// --------------------------------------------------------------------------- + +/// The shadow stack clears a dead slot before the next allocation so the +/// collector stops tracing it. Native roots have no clear to emit: liveness is +/// computed over SSA values, so the value is simply absent from the statepoint. +/// That is a *property of the composition* (`mem2reg` then RS4GC), not +/// something either half guarantees alone, and nothing asserted it. +/// +/// Stated differentially against a program that differs only in whether the +/// first local is read again. Without the control half, "zero roots" would be +/// satisfied by a lowering that roots nothing at all. +/// +/// **Sabotage** — `function/precise_roots.rs`, a reload-and-use of every root +/// alloca spliced in before each `ret`, which is the explicit statepoint +/// bridge's old conservative CFG-union liveness reintroduced: RED, the dead +/// value's live set at the second allocation went 0 → 1 +/// (`live: ["%rs4gc.s3"]`). This is the ONLY test of the seven that sabotage +/// reddens, so it is measuring its own subject and not a shared prerequisite. +#[test] +fn a_value_that_is_dead_at_a_safepoint_is_not_in_its_live_set() { + for target in NATIVE_TARGETS { + let _pin = NativeRootsPin::native(); + + let dead_name = "m2_dead.ts"; + let dead = probe_module( + dead_name, + vec![ + let_stmt(1, "dead", Expr::MapNew), + let_stmt(2, "live", Expr::MapNew), + Stmt::Return(Some(Expr::LocalGet(2))), + ], + ); + let dead_ir = native_ir(&dead, target, false); + let dead_sym = probe_symbol(dead_name); + let dead_points = statepoints_of(&dead_ir, target, &dead_sym); + let dead_allocs = dead_points.at("js_map_alloc"); + assert_eq!(dead_allocs.len(), 2, "[{target}] {dead_allocs:?}"); + assert_eq!( + dead_allocs[1].live.len(), + 0, + "[{target}] the first local is dead by the second allocation and \ + must not be traced: {:?}", + dead_allocs[1] + ); + + let live_name = "m2_live.ts"; + let live = probe_module( + live_name, + vec![ + let_stmt(1, "kept", Expr::MapNew), + let_stmt(2, "other", Expr::MapNew), + Stmt::Return(Some(Expr::LocalGet(1))), + ], + ); + let live_ir = native_ir(&live, target, false); + let live_sym = probe_symbol(live_name); + let live_allocs = statepoints_of(&live_ir, target, &live_sym); + let live_allocs = live_allocs.at("js_map_alloc"); + assert_eq!( + live_allocs[1].live.len(), + 1, + "[{target}] CONTROL: the same program with the first local read \ + afterwards must report it live — otherwise the zero above is an \ + inability to report roots, not an exclusion: {:?}", + live_allocs[1] + ); + + // Same claim where it is consumed. + assert_eq!( + map_max_roots(&assembly_for(&dead_ir, target), target, &dead_sym), + 0, + "[{target}] the emitted map must carry no root for a dead value" + ); + assert_eq!( + map_max_roots(&assembly_for(&live_ir, target), target, &live_sym), + 1, + "[{target}] CONTROL: the emitted map does carry the live one" + ); + } +} + +// --------------------------------------------------------------------------- +// 3. A numeric local reserves no root +// (shadow counterpart: `js_shadow_frame_enter(i32 2)` for three locals) +// --------------------------------------------------------------------------- + +/// A local that cannot hold a collectable value must not become a root — the +/// #6997 lesson, restated for the lowering that ships. Rooting a number costs a +/// map entry per safepoint it is live across, and the collector then traces an +/// integer as a pointer. +/// +/// The negative direction is asserted on PRE-`opt` IR, which is the only +/// vantage where "codegen never asked for a root" and "LLVM removed one" are +/// still distinguishable — and against a heap-valued twin that differs in +/// exactly one expression, so a lowering that stopped rooting anything fails +/// the control. +/// +/// **Sabotage** — `function/precise_roots.rs`, retyping every `alloca double` +/// rather than only the bound roots: RED, the numeric program's root slots went +/// 1 → 2 while the heap control stayed at 2, collapsing the difference the +/// mechanic is about. +#[test] +fn a_numeric_local_reserves_no_root_and_a_heap_one_does() { + for target in NATIVE_TARGETS { + let _pin = NativeRootsPin::native(); + + // Identical programs but for the type of `x`, which is live across the + // allocation in both. + let numeric_name = "m3_numeric.ts"; + let numeric = probe_module( + numeric_name, + vec![ + let_stmt(1, "x", Expr::Number(42.0)), + let_stmt(2, "b", Expr::MapNew), + Stmt::Return(Some(Expr::LocalGet(1))), + ], + ); + let numeric_ir = native_ir(&numeric, target, false); + let numeric_sym = probe_symbol(numeric_name); + let numeric_fn = function_slice(&numeric_ir, &numeric_sym); + + let heap_name = "m3_heap.ts"; + let heap = probe_module( + heap_name, + vec![ + let_stmt(1, "x", Expr::MapNew), + let_stmt(2, "b", Expr::MapNew), + Stmt::Return(Some(Expr::LocalGet(1))), + ], + ); + let heap_ir = native_ir(&heap, target, false); + let heap_sym = probe_symbol(heap_name); + let heap_fn = function_slice(&heap_ir, &heap_sym); + + assert_eq!( + (root_allocas(numeric_fn), root_allocas(heap_fn)), + (1, 2), + "[{target}] two locals, of which only the heap one may reserve a \ + root slot. Stated as a pair so a lowering that stopped rooting \ + ANYTHING fails the second half instead of passing the first.\ + \nnumeric:\n{numeric_fn}\nheap:\n{heap_fn}" + ); + assert!( + scalar_allocas(numeric_fn) > scalar_allocas(heap_fn), + "[{target}] the numeric local must still get a plain scalar slot: \ + {} vs {}", + scalar_allocas(numeric_fn), + scalar_allocas(heap_fn) + ); + + // The same claim where it costs: the numeric value is live across the + // allocation and must still not appear in its live set. + let numeric_alloc = statepoints_of(&numeric_ir, target, &numeric_sym); + let numeric_alloc = numeric_alloc.at("js_map_alloc"); + assert_eq!( + numeric_alloc[0].live.len(), + 0, + "[{target}] a number live across an allocation must not be traced: \ + {:?}", + numeric_alloc[0] + ); + let heap_alloc = statepoints_of(&heap_ir, target, &heap_sym); + let heap_alloc = heap_alloc.at("js_map_alloc"); + assert_eq!( + heap_alloc[1].live.len(), + 1, + "[{target}] CONTROL: the heap twin's value IS traced across the \ + same allocation: {:?}", + heap_alloc[1] + ); + } +} + +// --------------------------------------------------------------------------- +// 5. The entry module's roots begin after the init prelude +// (shadow counterpart: `entry_module_top_level_shadow_frame_starts_after_ +// init_prelude`) +// --------------------------------------------------------------------------- + +/// `main` runs an init prelude — `js_gc_init`, then the module's string table — +/// before any user code. The shadow lowering expresses "no root before that" by +/// pushing the frame after the prelude. Native roots have no frame, so the +/// property has to be stated directly: **no safepoint at or before `js_gc_init` +/// may carry a live GC value**, because there is no collector yet to relocate +/// it and no heap it could have come from. +/// +/// The non-vacuity half matters more than usual here: "no live roots before +/// `js_gc_init`" is trivially true of a `main` with no roots anywhere, which is +/// exactly what a broken entry lowering produces. So the test also requires a +/// non-empty live set to appear later in the same function. +/// +/// **Sabotage 1** — `codegen/entry.rs`, the `js_gc_init` call deleted from +/// `main`: RED ("entry `main` must initialize the GC"). Proves the anchor is +/// real rather than assumed. +/// +/// **Sabotage 2** — the module's `__perry_init_strings_*` call moved from the +/// prelude to a pre-return call, so it runs after user code: RED, "a root is +/// live before `__perry_init_strings_*` (#32 vs #4)". Both reddened only this +/// test. +#[test] +fn no_entry_module_root_is_live_before_the_gc_is_initialized() { + for target in NATIVE_TARGETS { + let _pin = NativeRootsPin::native(); + let module = entry_module( + "m5_entry.ts", + vec![ + let_stmt(1, "a", Expr::MapNew), + let_stmt(2, "b", Expr::MapNew), + console_log(vec![Expr::LocalGet(1), Expr::LocalGet(2)]), + ], + ); + let ir = native_ir(&module, target, true); + let points = statepoints_of(&ir, target, "main"); + let callees = || points.iter().map(|sp| &sp.callee).collect::>(); + + let gc_init = points + .iter() + .position(|sp| sp.callee == "js_gc_init") + .unwrap_or_else(|| { + panic!( + "[{target}] entry `main` must initialize the GC: {:?}", + callees() + ) + }); + let strings_init = points + .iter() + .position(|sp| sp.callee.starts_with("__perry_init_strings_")) + .unwrap_or_else(|| { + panic!( + "[{target}] entry `main` must run the module string table \ + before user code: {:?}", + callees() + ) + }); + // NON-VACUITY: this is the assertion that makes the two orderings below + // mean something. "Nothing is rooted before the prelude" is trivially + // true of a `main` that roots nothing anywhere — which is what a broken + // entry lowering produces. + let first_rooted = points + .iter() + .position(|sp| !sp.live.is_empty()) + .unwrap_or_else(|| { + panic!( + "[{target}] no safepoint anywhere in `main` carries a live \ + root, so an ordering claim about them is vacuous: {:?}", + points.iter().collect::>() + ) + }); + + assert!( + gc_init < first_rooted, + "[{target}] safepoint #{first_rooted} (`{}`) carries a live GC \ + value at or before `js_gc_init` (#{gc_init}) — before there is a \ + collector to relocate it or a heap it could have come from: {:?}", + points.iter().nth(first_rooted).unwrap().callee, + callees() + ); + assert!( + strings_init < first_rooted, + "[{target}] a root is live before `__perry_init_strings_*` \ + (#{strings_init} vs #{first_rooted}) — the shadow lowering states \ + this by pushing its frame after the prelude; native roots have no \ + frame, so it has to be stated here: {:?}", + callees() + ); + } +} + +// --------------------------------------------------------------------------- +// 6. A loop body's roots do not survive into the next iteration +// (shadow counterpart: `loop_body_shadow_slots_are_cleared_each_iteration`) +// --------------------------------------------------------------------------- + +/// A value allocated inside a loop body and dead at the back edge must not be a +/// root at the next iteration's allocation. On the shadow stack that needs an +/// emitted clear; here it needs the per-back-edge live sets to be per-iteration +/// rather than a union over the loop. +/// +/// The differential control is a second outer local that IS live across the +/// loop: the same in-loop safepoint then reports two roots, which proves the +/// one-root answer below is an exclusion and not a ceiling. +/// +/// **Sabotage** — `function/precise_roots.rs`, every root reloaded at the top +/// of each block and used before its terminator, so a root spans every block it +/// is defined before: RED, the in-loop live set went 1 → 2 +/// (`live: ["%.0", "%r16.0"]`). +/// +/// Worth recording what did NOT work, because it says something about the +/// mechanic: the weaker mechanic-2 sabotage (keep-alive at `ret` only) leaves +/// this test GREEN, and LLVM is right about that — the previous iteration's +/// value is genuinely dead at the next iteration's allocation, since the phi +/// that carries it to the return is redefined in the body. Only a lowering that +/// keeps a root live across the back edge itself can break this row. +#[test] +fn a_loop_iterations_dead_root_is_not_live_at_the_next_iteration() { + for target in NATIVE_TARGETS { + let _pin = NativeRootsPin::native(); + + let subject_name = "m6_loop.ts"; + let subject = probe_module( + subject_name, + vec![ + let_stmt(1, "acc", Expr::MapNew), + counted_loop(vec![let_stmt(2, "tmp", Expr::MapNew)]), + Stmt::Return(Some(Expr::LocalGet(1))), + ], + ); + let subject_ir = native_ir(&subject, target, false); + let subject_sym = probe_symbol(subject_name); + let subject_points = statepoints_of(&subject_ir, target, &subject_sym); + let subject_allocs = subject_points.at("js_map_alloc"); + assert_eq!( + subject_allocs.len(), + 2, + "[{target}] the loop must not have been unrolled or its body \ + sunk — one allocation outside, one inside: {subject_allocs:?}" + ); + assert_eq!( + subject_allocs[1].live.len(), + 1, + "[{target}] only `acc` may be live at the in-loop allocation. A \ + second root here is the previous iteration's `tmp` surviving the \ + back edge: {:?}", + subject_allocs[1] + ); + + let control_name = "m6_loop_control.ts"; + let control = probe_module( + control_name, + vec![ + let_stmt(1, "acc", Expr::MapNew), + let_stmt(3, "acc2", Expr::MapNew), + counted_loop(vec![let_stmt(2, "tmp", Expr::MapNew)]), + console_log(vec![Expr::LocalGet(3)]), + Stmt::Return(Some(Expr::LocalGet(1))), + ], + ); + let control_ir = native_ir(&control, target, false); + let control_sym = probe_symbol(control_name); + let control_points = statepoints_of(&control_ir, target, &control_sym); + let control_allocs = control_points.at("js_map_alloc"); + assert_eq!( + control_allocs.last().unwrap().live.len(), + 2, + "[{target}] CONTROL: two outer locals live across the loop must \ + both be roots at the in-loop allocation — so the count above is \ + not a cap: {:?}", + control_allocs.last().unwrap() + ); + } +} + +// --------------------------------------------------------------------------- +// 9. Every reserved root reaches the native lowering (#7184's shape) +// (shadow counterpart: `duplicate_var_declarations_keep_every_slot_inside_ +// the_frame`) +// --------------------------------------------------------------------------- + +/// #7502's table marks this row `n/a` — "no frame bound exists; the #7184 shape +/// is unrepresentable". **That is half right, and the half it gets wrong is the +/// dangerous half.** +/// +/// The frame bound is indeed gone. But `lower_precise_roots_to_native_stack` +/// sizes its root vector by `slot_count` and collects roots with +/// `roots.get_mut(idx)`, so a bind whose index is `>= slot_count` is dropped by +/// an `Option` returning `None` — exactly the shape of the runtime bounds check +/// that made #7184 silent, one layer up. A dropped index means the alloca is +/// never added to `root_ptrs`, is never retyped to `ptr addrspace(1)`, and is +/// therefore not a root at all. The IR still looks like rooted code. +/// +/// So the mechanic survives the change of lowering and needs an assertion. This +/// is the shadow suite's duplicate-`var` program (two `Stmt::Let`s sharing a +/// `LocalId`, plus a trailing local — the arrangement that used to burn a slot +/// index per declaration while the frame was sized by map cardinality), +/// asserted natively: both pointer locals must end up as root slots. +/// +/// **Sabotage** — `function/precise_roots.rs`, `roots` sized +/// `slot_count.saturating_sub(1)` so the last reserved index falls outside the +/// vector, i.e. `slot_count` under-counted by one: RED, the map's largest live +/// set went 2 → 1. One root vanished; the IR compiled, verified and emitted an +/// otherwise identical function. +#[test] +fn a_deduplicated_slot_index_still_reaches_the_native_root_set() { + for target in NATIVE_TARGETS { + let _pin = NativeRootsPin::native(); + let name = "m9_dup_var.ts"; + let module = probe_module( + name, + vec![ + let_stmt(1, "dup", Expr::MapNew), + // Same LocalId, second declaration site. + let_stmt(1, "dup", Expr::MapNew), + let_stmt(2, "later", Expr::MapNew), + // Keeps BOTH live across one allocation, so the map has to + // report two roots at a single safepoint. + Stmt::Return(Some(Expr::Array(vec![ + Expr::LocalGet(1), + Expr::LocalGet(2), + ]))), + ], + ); + let ir = native_ir(&module, target, false); + let symbol = probe_symbol(name); + + // Both locals are live across the returned array's allocation, so the + // collector must find TWO roots at that safepoint. A slot index that + // fell outside `roots` costs exactly one of them, silently. + assert_eq!( + map_max_roots(&assembly_for(&ir, target), target, &symbol), + 2, + "[{target}] both pointer locals are live across the array \ + allocation and must both be in the map. A dropped slot index does \ + not warn, does not fail to compile and does not change the shape \ + of the IR — it just removes a root:\n{}", + function_slice(&ir, &symbol) + ); + } +} + +// --------------------------------------------------------------------------- +// 7 / 8. Scalar-replaced slots (#6968 / #6997) +// (shadow counterparts: `scalar_replaced_object_field_holding_a_heap_value_ +// is_bound` and `numeric_only_scalar_replaced_object_emits_no_rooting`) +// --------------------------------------------------------------------------- + +/// Scalar replacement deletes an object literal and keeps one entry-block +/// alloca per field. Those allocas belong to no HIR local, so the pre-lowering +/// pointer analysis cannot see them — which is how #6968 shipped a field +/// holding a heap value with no root at all. +/// +/// Stated against a structurally identical numeric-only literal: same local, +/// same field count, same reads. The difference must be exactly one root slot +/// and a non-empty map. +/// +/// **Sabotage** — `expr/scalar_slot_root.rs`, `root_scalar_replaced_slot`'s +/// `root_entry_alloca` call removed, which is #6968 reintroduced exactly: RED, +/// "heap literal has 3, numeric control 3" — the difference vanished. +#[test] +fn a_scalar_replaced_field_holding_a_heap_value_is_a_native_root() { + for target in NATIVE_TARGETS { + let _pin = NativeRootsPin::native(); + + let heap = entry_module( + "m7_scalar_heap.ts", + vec![ + let_stmt( + 1, + "o", + Expr::Object(vec![ + ("a".to_string(), heap_value()), + ("b".to_string(), Expr::Number(2.0)), + ]), + ), + console_log(vec![field_get(1, "a"), field_get(1, "b")]), + ], + ); + let heap_ir = native_ir(&heap, target, true); + let heap_main = function_slice(&heap_ir, "main"); + + let numeric = entry_module( + "m7_scalar_numeric.ts", + vec![ + let_stmt( + 1, + "o", + Expr::Object(vec![ + ("a".to_string(), Expr::Number(1.0)), + ("b".to_string(), Expr::Number(2.0)), + ]), + ), + console_log(vec![field_get(1, "a"), field_get(1, "b")]), + ], + ); + let numeric_ir = native_ir(&numeric, target, true); + let numeric_main = function_slice(&numeric_ir, "main"); + + assert!( + root_allocas(heap_main) > root_allocas(numeric_main), + "[{target}] the pointer-capable scalar-replaced field must take a \ + root slot the pointer analysis could not have predicted: heap \ + literal has {}, numeric control {}", + root_allocas(heap_main), + root_allocas(numeric_main) + ); + assert!( + map_max_roots(&assembly_for(&heap_ir, target), target, "main") > 0, + "[{target}] and the collector must be able to find it" + ); + assert_eq!( + map_max_roots(&assembly_for(&numeric_ir, target), target, "main"), + 0, + "[{target}] CONTROL: the numeric-only twin puts nothing in the map, \ + so the root above is attributable to the heap field rather than to \ + anything else `main` contains" + ); + } +} + +/// The other side of the same gate (#6997): a literal whose every field is a +/// number must pay nothing. +/// +/// This is the assertion shape that was passing vacuously before #7502 — the +/// shadow-pinned original counted `js_shadow_slot_bind` calls, of which the +/// native lowering emits zero for every program. Here it is a claim about +/// `ptr addrspace(1)` allocas and about the emitted map, and it is paired with +/// the heap-valued literal in the same test so that a lowering which roots +/// nothing cannot satisfy it. +/// +/// **Sabotage** — `expr/scalar_slot_root.rs`, the +/// `expr_is_known_non_pointer_shadow_value` early-out removed so every +/// scalar-replaced field reserves a slot: RED, the numeric-only literal's map +/// went **0 → 2 roots**. That number is the whole answer to "is this negative +/// assertion vacuous under its lowering": it is not. +#[test] +fn a_numeric_only_scalar_replaced_literal_pays_no_native_rooting() { + for target in NATIVE_TARGETS { + let _pin = NativeRootsPin::native(); + + let numeric = entry_module( + "m8_numeric_only.ts", + vec![ + let_stmt( + 1, + "p", + Expr::Object(vec![ + ("x".to_string(), Expr::Number(1.0)), + ("y".to_string(), Expr::Number(2.0)), + ]), + ), + console_log(vec![field_get(1, "x"), field_get(1, "y")]), + ], + ); + let numeric_ir = native_ir(&numeric, target, true); + let numeric_asm = assembly_for(&numeric_ir, target); + assert_eq!( + map_max_roots(&numeric_asm, target, "main"), + 0, + "[{target}] a scalar-replaced literal with only numeric fields must \ + not put anything in the GC map" + ); + + // NON-VACUITY, in the same test: swap one field for a heap value and + // the same measurement must move. + let heap = entry_module( + "m8_one_heap_field.ts", + vec![ + let_stmt( + 1, + "p", + Expr::Object(vec![ + ("x".to_string(), heap_value()), + ("y".to_string(), Expr::Number(2.0)), + ]), + ), + console_log(vec![field_get(1, "x"), field_get(1, "y")]), + ], + ); + let heap_ir = native_ir(&heap, target, true); + assert!( + map_max_roots(&assembly_for(&heap_ir, target), target, "main") > 0, + "[{target}] CONTROL: one heap-valued field must produce a root, or \ + the zero above is measuring nothing" + ); + } +} diff --git a/crates/perry-codegen/src/native_root_coverage/mod.rs b/crates/perry-codegen/src/native_root_coverage/mod.rs new file mode 100644 index 0000000000..46cc82bfd3 --- /dev/null +++ b/crates/perry-codegen/src/native_root_coverage/mod.rs @@ -0,0 +1,544 @@ +//! Coverage for the root lowering that actually SHIPS: native roots / RS4GC +//! statepoints (#7502). +//! +//! # Why this module exists +//! +//! Since #7370 native roots are the default on every target whose frames the +//! runtime can walk — every target Perry ships to except `arm64_32` watchOS and +//! ARM64 Windows. The two suites that read as this area's coverage +//! (`tests/shadow_slot_hygiene.rs`, `tests/scalar_replaced_slot_roots.rs`) were +//! written against the shadow stack and, since #7493, correctly SAY so with +//! `NativeRootsPin::shadow()`. Both stay: both lowerings are supported and both +//! need coverage. But it left nine root-lowering mechanics with zero assertions +//! against the lowering Perry emits, six of them shapes +//! `docs/src/internals/gc-rooting-invariant.md` records as having already +//! shipped broken. +//! +//! Three of those tests had been *passing vacuously*: they asserted +//! `js_shadow_slot_bind` was absent, which under the native default is true of +//! every program, rooted or not. That is CLAUDE.md hazard 4 — the gate ran, its +//! subject never did. This module is built so that mistake is structurally +//! harder to make; see "Non-vacuity" below. +//! +//! # What is asserted, and against what +//! +//! Three vantage points, weakest to strongest: +//! +//! 1. **Pre-`opt` IR** — what [`compile_module`] returns. Shows the *request*: +//! which allocas codegen retyped to `ptr addrspace(1)`, and whether the +//! function carries `gc "statepoint-example"`. This is the right vantage for +//! the NEGATIVE direction (a numeric local must never become a root), because +//! it is the only place where "codegen never asked" and "LLVM optimized it +//! away" are still distinguishable. +//! 2. **Post-RS4GC IR** — [`crate::inprocess::statepoint_rewritten_ir`], which +//! runs the production pass string. Shows the *result*: every safepoint and +//! its `"gc-live"` bundle, keyed by callee name — so "is this value a root +//! ACROSS THAT CALL" becomes a direct question. This is the vantage the +//! shadow suites never had an equivalent for. +//! 3. **The emitted stack map** — [`crate::gc_map::decode_stack_map_roots`], +//! the compact `__perry_gcmap` blob the collector reads at run time. Strongest, +//! and the only one that can catch "the map says nothing lives here". +//! +//! # Non-vacuity, which is the whole point +//! +//! Every assertion here is written so that a broken lowering *cannot* satisfy +//! it by producing nothing: +//! +//! * **Positive claims assert their subject ran.** [`Statepoints::at`] panics if +//! the callee it is asked about produced no safepoint, and +//! [`map_records_for`] panics if the function is missing from the map +//! entirely. "Zero roots" is only ever asserted about a record that exists. +//! * **Every negative claim is paired with a differential control** that must +//! go the other way in the same test — the numeric-local and numeric-literal +//! cases each compare against a structurally identical heap-valued program. +//! A lowering that roots nothing fails the control half. +//! * **Targets are pinned, not host-derived.** `cargo-test` runs on x86_64 +//! Linux and this repo is developed on arm64 macOS; a suite that silently +//! changed subject with the host is how a target-specific lowering bug +//! reaches a release. Both shipped native-roots targets are compiled and +//! emitted for on every host, which LLVM does happily — only the *backend* +//! has to be initialized, not the OS. +//! +//! Each test's doc comment names the sabotage that was run against it and what +//! it did. An assertion nobody has watched fail is documentation. +//! +//! # Coverage against #7502's table +//! +//! | # | mechanic | native assertion | in | +//! |---|---|---|---| +//! | 1 | a pointer local is a root | `ptr addrspace(1)` slot + live at the next allocation's statepoint + in the map | `mechanics::a_live_pointer_local_is_a_root_in_the_emitted_map` | +//! | 2 | a dead value stops being a root | absent from the live set, against a live control | `mechanics::a_value_that_is_dead_at_a_safepoint_is_not_in_its_live_set` | +//! | 3 | a numeric local reserves nothing | slot counts `(1, 2)` against a heap twin | `mechanics::a_numeric_local_reserves_no_root_and_a_heap_one_does` | +//! | 4 | slot indices unshifted by a numeric local | *subsumed by 3* — native roots have no indices; the substance is that a numeric local does not perturb the root set | — | +//! | 5 | entry roots begin after the init prelude | first rooted safepoint follows `js_gc_init` and `__perry_init_strings_*` | `mechanics::no_entry_module_root_is_live_before_the_gc_is_initialized` | +//! | 6 | a loop's roots do not cross the back edge | in-loop live set is 1, against a 2-root control | `mechanics::a_loop_iterations_dead_root_is_not_live_at_the_next_iteration` | +//! | 7 | scalar-replaced heap field is a root (#6968) | extra slot + non-empty map, against the numeric twin | `mechanics::a_scalar_replaced_field_holding_a_heap_value_is_a_native_root` | +//! | 8 | scalar-replaced numeric literal pays nothing (#6997) | empty map, against a one-heap-field twin | `mechanics::a_numeric_only_scalar_replaced_literal_pays_no_native_rooting` | +//! | 9 | every reserved slot reaches the root set (#7184) | two live locals, two map roots | `mechanics::a_deduplicated_slot_index_still_reaches_the_native_root_set` | +//! +//! **Row 9 is where #7502's table is wrong**, and the correction is worth +//! stating: it marks the row `n/a` because "no frame bound exists; the #7184 +//! shape is unrepresentable". The *frame* bound is gone, but the failure is not +//! about a frame — it is about an index silently falling outside the structure +//! that collects roots, and `lower_precise_roots_to_native_stack` still has one +//! (`roots.get_mut(idx)` over a `slot_count`-sized vector). An out-of-range +//! index there drops the alloca from `root_ptrs`, so it is never retyped and +//! never rooted, with no diagnostic — the same silence for the same reason, one +//! layer up from the runtime bounds check. It now has a test and a sabotage. +//! +//! There is also a native-only prerequisite with no shadow counterpart at all, +//! covered in `harness_self_tests::no_root_alloca_survives_the_statepoint_rewrite`: +//! RS4GC relocates SSA values and does not scan allocas, so a root slot that +//! escapes `mem2reg` is a root the collector never rewrites. + +use crate::testing::NativeRootsPin; +use crate::{compile_module, AppMetadata, CompileOptions}; +use perry_hir::types::Type; +use perry_hir::{Expr, Function, Module, ModuleInitKind, Param, Stmt}; + +mod harness_self_tests; +mod mechanics; + +/// The two targets native roots ship on, one per object format and +/// architecture. Pinned rather than host-derived — see the module docs. +/// +/// `arm64_32` watchOS and ARM64 Windows are deliberately absent: they take the +/// shadow-stack lowering (`codegen::helpers::set_native_roots_for_target`), and +/// the suites that cover *that* lowering are the shadow-pinned ones. +pub(crate) const NATIVE_TARGETS: [&str; 2] = + ["arm64-apple-macosx15.0.0", "x86_64-unknown-linux-gnu"]; + +// --------------------------------------------------------------------------- +// HIR fixtures +// --------------------------------------------------------------------------- + +pub(crate) fn ir_opts(target: &str, is_entry: bool) -> CompileOptions { + CompileOptions { + target: Some(target.to_string()), + is_entry_module: is_entry, + non_entry_module_prefixes: Vec::new(), + nextjs_path_init_modules: Vec::new(), + import_function_prefixes: std::collections::HashMap::new(), + import_function_ffi_aliases: std::collections::HashMap::new(), + import_function_origin_names: std::collections::HashMap::new(), + import_function_v8_specifiers: std::collections::HashMap::new(), + import_function_node_submodule: std::collections::HashMap::new(), + namespace_node_submodules: std::collections::HashMap::new(), + namespace_v8_specifiers: std::collections::HashMap::new(), + namespace_member_prefixes: std::collections::HashMap::new(), + namespace_member_origin_names: std::collections::HashMap::new(), + emit_ir_only: true, + verify_native_regions: false, + disable_buffer_fast_path: false, + namespace_imports: Vec::new(), + imported_classes: Vec::new(), + imported_enums: Vec::new(), + imported_async_funcs: std::collections::HashSet::new(), + type_aliases: std::collections::HashMap::new(), + imported_func_param_counts: std::collections::HashMap::new(), + imported_func_has_rest: std::collections::HashSet::new(), + imported_func_synthetic_arguments: std::collections::HashSet::new(), + imported_func_return_types: std::collections::HashMap::new(), + imported_vars: std::collections::HashSet::new(), + output_type: "executable".to_string(), + needs_stdlib: false, + needs_ui: false, + needs_geisterhand: false, + geisterhand_port: 7676, + enabled_features: Vec::new(), + native_module_init_names: Vec::new(), + js_module_specifiers: Vec::new(), + bundled_extensions: Vec::new(), + native_library_functions: Vec::new(), + i18n_table: None, + fast_math: false, + fp_contract_mode: crate::FpContractMode::Off, + app_metadata: AppMetadata::default(), + namespace_entries: Vec::new(), + dynamic_import_path_to_prefix: std::collections::HashMap::new(), + deferred_module_prefixes: std::collections::HashSet::new(), + module_init_deps: Vec::new(), + is_dynamic_import_target: false, + debug_locations: false, + module_source: None, + debug_source_line_offset: 0, + } +} + +fn bare_module(name: &str) -> Module { + Module { + script_global_functions: Vec::new(), + references_global_this: false, + annexb_global_undefined_names: Vec::new(), + name: name.to_string(), + imports: Vec::new(), + exports: Vec::new(), + classes: Vec::new(), + interfaces: Vec::new(), + type_aliases: Vec::new(), + enums: Vec::new(), + globals: Vec::new(), + functions: Vec::new(), + init: Vec::new(), + exported_native_instances: Vec::new(), + exported_func_return_native_instances: Vec::new(), + exported_objects: Vec::new(), + exported_functions: Vec::new(), + widgets: Vec::new(), + uses_fetch: false, + uses_webassembly: false, + extern_funcs: Vec::new(), + init_was_unrolled: false, + has_top_level_await: false, + init_kind: ModuleInitKind::Eager, + async_step_closures: std::collections::HashSet::new(), + closure_display_names: std::collections::HashMap::new(), + class_display_names: std::collections::HashMap::new(), + closure_source_text: std::collections::HashMap::new(), + async_generator_funcs: std::collections::HashSet::new(), + gen_param_prologue_len: std::collections::HashMap::new(), + } +} + +/// A module whose only content is one non-exported function `probe(n)`. +/// +/// The parameter exists so a loop bound can be opaque: a constant-trip loop +/// unrolls, and an unrolled loop has no back edge to make claims about. +pub(crate) fn probe_module(name: &str, body: Vec) -> Module { + let mut module = bare_module(name); + module.functions = vec![Function { + id: 1, + name: "probe".to_string(), + type_params: Vec::new(), + params: vec![Param { + id: 100, + name: "n".to_string(), + ty: Type::Number, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }], + return_type: Type::Any, + body, + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }]; + module +} + +/// A module whose statements are top-level init — the shape that lands in +/// `main` for an entry module. +pub(crate) fn entry_module(name: &str, init: Vec) -> Module { + let mut module = bare_module(name); + module.init = init; + module +} + +/// The LLVM symbol `probe_module`'s function gets. +pub(crate) fn probe_symbol(module_name: &str) -> String { + format!( + "perry_fn_{}__probe", + module_name.replace(['.', '-', '/'], "_") + ) +} + +pub(crate) fn let_stmt(id: u32, name: &str, init: Expr) -> Stmt { + Stmt::Let { + id, + name: name.to_string(), + ty: Type::Any, + mutable: false, + init: Some(init), + } +} + +/// A fresh heap value bound to nothing else, so the slot under test is the only +/// reference to it. +pub(crate) fn heap_value() -> Expr { + Expr::Object(vec![("k".to_string(), Expr::Number(1.0))]) +} + +pub(crate) fn field_get(local: u32, field: &str) -> Expr { + Expr::PropertyGet { + object: Box::new(Expr::LocalGet(local)), + property: field.to_string(), + byte_offset: 0, + } +} + +pub(crate) fn console_log(args: Vec) -> Stmt { + Stmt::Expr(Expr::Call { + callee: Box::new(Expr::PropertyGet { + object: Box::new(Expr::GlobalGet(0)), + property: "log".to_string(), + byte_offset: 0, + }), + args, + type_args: Vec::new(), + byte_offset: 0, + }) +} + +// --------------------------------------------------------------------------- +// Compilation +// --------------------------------------------------------------------------- + +/// Compile `module` under the native-roots lowering for `target`. +/// +/// The pin is not redundant with the default: it also overrides `PERRY_RS4GC` +/// from the environment, so these assertions mean the same thing during a +/// `PERRY_RS4GC=0` bisection as they do in CI. +pub(crate) fn native_ir(module: &Module, target: &str, is_entry: bool) -> String { + let _pin = NativeRootsPin::native(); + let bytes = compile_module(module, ir_opts(target, is_entry)) + .unwrap_or_else(|e| panic!("codegen failed for {}: {e}", module.name)); + String::from_utf8(bytes).expect("LLVM IR should be UTF-8") +} + +/// The whole `define … { … }` body of `name`. +pub(crate) fn function_slice<'a>(ir: &'a str, name: &str) -> &'a str { + let marker = format!("@{}(", name); + let start = ir + .match_indices("define ") + .find_map(|(idx, _)| { + let line_end = ir[idx..].find('\n').map(|o| idx + o)?; + ir[idx..line_end].contains(&marker).then_some(idx) + }) + .unwrap_or_else(|| panic!("no function `{name}` in IR:\n{ir}")); + let end = ir[start..] + .find("\n}\n") + .map(|o| start + o + 3) + .unwrap_or(ir.len()); + &ir[start..end] +} + +/// Root slots codegen ASKED for in this function: allocas it retyped to +/// `ptr addrspace(1)` so RS4GC will treat their contents as GC references. +pub(crate) fn root_allocas(fn_ir: &str) -> usize { + fn_ir.matches("alloca ptr addrspace(1)").count() +} + +/// Entry-block slots codegen left as plain scalars — a local it decided can +/// never hold a collectable value. +pub(crate) fn scalar_allocas(fn_ir: &str) -> usize { + fn_ir.matches("= alloca double").count() + fn_ir.matches("= alloca i64").count() +} + +// --------------------------------------------------------------------------- +// Post-RS4GC IR: safepoints and their live sets +// --------------------------------------------------------------------------- + +/// One `gc.statepoint`: the call it wraps, and the GC values LLVM recorded as +/// live across it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Statepoint { + /// Direct callee name (no `@`), or ``. + pub callee: String, + /// SSA registers in the `"gc-live"` bundle, in printed order. + pub live: Vec, +} + +/// Every safepoint in one function, in program order. +#[derive(Debug, Clone)] +pub(crate) struct Statepoints { + function: String, + points: Vec, +} + +impl Statepoints { + pub fn len(&self) -> usize { + self.points.len() + } + + pub fn iter(&self) -> impl Iterator { + self.points.iter() + } + + /// Every safepoint whose callee is `callee`. + /// + /// **Panics when there are none.** That is the point: a mechanic asserted + /// about "the safepoint at `js_object_alloc`" must not quietly become a + /// claim about the empty set because the call was inlined, renamed, or + /// classified `gc-leaf-function`. + pub fn at(&self, callee: &str) -> Vec<&Statepoint> { + let hits: Vec<&Statepoint> = self + .points + .iter() + .filter(|sp| sp.callee == callee) + .collect(); + assert!( + !hits.is_empty(), + "no `{callee}` safepoint in @{} — this assertion has no subject. \ + Safepoints present: {:?}", + self.function, + self.points.iter().map(|sp| &sp.callee).collect::>() + ); + hits + } + + /// The largest live set at any safepoint in the function. + pub fn max_live(&self) -> usize { + self.points + .iter() + .map(|sp| sp.live.len()) + .max() + .unwrap_or(0) + } +} + +/// Run the production statepoint rewrite over `ir` and read back every +/// safepoint in `function`. +pub(crate) fn statepoints_of(ir: &str, target: &str, function: &str) -> Statepoints { + let rewritten = crate::inprocess::statepoint_rewritten_ir(ir, target, "native_root_coverage") + .unwrap_or_else(|e| panic!("statepoint rewrite failed for {target}: {e:#}")); + let body = function_slice(&rewritten, function); + let points = body + .lines() + .filter(|line| line.contains("llvm.experimental.gc.statepoint")) + .map(parse_statepoint) + .collect(); + Statepoints { + function: function.to_string(), + points, + } +} + +/// Parse one printed `gc.statepoint` call/invoke line. +/// +/// Two facts about LLVM's printer make this a line-at-a-time job: it prints one +/// instruction per line, and it prints the live set as a `"gc-live"` operand +/// bundle after the argument list. +/// +/// The callee is the operand after the `elementtype(...)` attribute, and +/// finding it needs paren MATCHING, not a substring search — the statepoint +/// intrinsic's own signature `(i64, i32, ptr, i32, i32, ...)` and the wrapped +/// callee's type `elementtype(i64 (i32))` both contain `) @`, and the first one +/// belongs to `@llvm.experimental.gc.statepoint.p0`. Reading that as the callee +/// makes every safepoint in every function look identical, which is exactly how +/// [`Statepoints::at`] would then find no subject anywhere — the self-test +/// `the_statepoint_parser_reads_callee_and_live_set` is what caught it. +fn parse_statepoint(line: &str) -> Statepoint { + let callee = callee_after_elementtype(line).unwrap_or_else(|| "".to_string()); + + let live = match group_after(line, "\"gc-live\"(") { + None => Vec::new(), + Some(operands) => operands + .split(',') + .filter_map(|operand| { + operand + .trim() + .rsplit_once(' ') + .map(|(_, reg)| reg.trim().to_string()) + }) + .filter(|reg| reg.starts_with('%')) + .collect(), + }; + + Statepoint { callee, live } +} + +/// The contents of the parenthesised group opened by `marker`, honouring +/// nesting. +/// +/// The nesting is not hypothetical and reading to the first `)` is not a near +/// miss: **every operand of a live set is spelled `ptr addrspace(1) %r`**, so a +/// first-paren scan truncates the list to `"ptr addrspace(1"`, drops it for +/// having no `%`, and reports an EMPTY LIVE SET for every statepoint in every +/// program. Half this module's assertions are "nothing is live here"; all of +/// them would have passed. `the_statepoint_parser_reads_callee_and_live_set` is +/// what caught it. +fn group_after<'a>(line: &'a str, marker: &str) -> Option<&'a str> { + let open = line.find(marker)? + marker.len(); + let rest = &line[open..]; + let mut depth = 1usize; + let close = rest.char_indices().find_map(|(index, ch)| match ch { + '(' => { + depth += 1; + None + } + ')' => { + depth -= 1; + (depth == 0).then_some(index) + } + _ => None, + })?; + Some(&rest[..close]) +} + +/// The `@name` immediately after the balanced `elementtype(…)` attribute, or +/// `None` for an indirect callee (a `%reg` there instead). +fn callee_after_elementtype(line: &str) -> Option { + let group = group_after(line, "elementtype(")?; + let after = + line[line.find("elementtype(")? + "elementtype(".len() + group.len() + 1..].trim_start(); + let name: String = after + .strip_prefix('@')? + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '$')) + .collect(); + (!name.is_empty()).then_some(name) +} + +// --------------------------------------------------------------------------- +// The emitted stack map +// --------------------------------------------------------------------------- + +/// Emit `ir` as assembly for `target`, through the production emission path. +/// +/// `-O0` on purpose. The claim under test is about the root SET, and at `-O3` +/// LLVM is free to delete an allocation whose result is unused — which would +/// turn "no roots live here" into a true statement about a program that no +/// longer contains the code being asserted about. +pub(crate) fn assembly_for(ir: &str, target: &str) -> String { + let context = inkwell::context::Context::create(); + let module = crate::inprocess::parse_ir_text(&context, ir, "native_root_coverage") + .unwrap_or_else(|e| panic!("IR does not parse for {target}: {e:#}")); + let bytes = crate::inprocess::optimize_and_emit_module( + &module, + target, + &["-O0".to_string(), "-S".to_string()], + ) + .unwrap_or_else(|e| panic!("assembly emission failed for {target}: {e:#}")); + String::from_utf8(bytes).expect("assembler text should be UTF-8") +} + +/// Per-safepoint root lists for `symbol` from the compact map the binary ships. +/// +/// **Panics if the function is absent from the map.** A missing function and a +/// function with no roots are the two answers this whole module exists to tell +/// apart. +pub(crate) fn map_records_for(asm: &str, target: &str, symbol: &str) -> Vec> { + let functions = crate::gc_map::decode_stack_map_roots(asm, target) + .unwrap_or_else(|e| panic!("stack map for {target} did not decode: {e}")); + // Mach-O prefixes global symbols with `_`; ELF does not. + let underscored = format!("_{symbol}"); + functions + .iter() + .find(|(name, _)| name == symbol || name == &underscored) + .map(|(_, records)| records.clone()) + .unwrap_or_else(|| { + panic!( + "`{symbol}` has no stack-map entry for {target} — the collector \ + would find no roots in it at all. Functions in the map: {:?}", + functions.iter().map(|(n, _)| n).collect::>() + ) + }) +} + +/// The most roots any one safepoint of `symbol` records. +pub(crate) fn map_max_roots(asm: &str, target: &str, symbol: &str) -> usize { + let records = map_records_for(asm, target, symbol); + assert!( + !records.is_empty(), + "`{symbol}` is in the {target} stack map with zero safepoints — nothing \ + was measured" + ); + records.iter().map(|r| r.len()).max().unwrap_or(0) +} From b462e61e8512cdbdab0d148387a0a1986a21f061 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 8 Aug 2026 19:27:57 +0200 Subject: [PATCH 2/8] docs(changelog): fragment for #7653 --- changelog.d/7653-native-root-coverage.md | 91 ++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 changelog.d/7653-native-root-coverage.md diff --git a/changelog.d/7653-native-root-coverage.md b/changelog.d/7653-native-root-coverage.md new file mode 100644 index 0000000000..e382b11e23 --- /dev/null +++ b/changelog.d/7653-native-root-coverage.md @@ -0,0 +1,91 @@ +### Coverage for the root lowering that actually ships (#7502) + +Native roots (RS4GC statepoints) have been the default lowering on every target +whose frames the runtime can walk since #7370, and had **no assertions +anywhere**. #7493 repaired `shadow_slot_hygiene` and `scalar_replaced_slot_roots` +by pinning them to `NativeRootsPin::shadow()`, which was the right repair — both +lowerings are supported and the shadow-pinned suites stay — but it made explicit +that nine root-lowering mechanics had zero coverage against the lowering Perry +emits, and that three tests reading as coverage were measuring nothing: they +asserted `js_shadow_slot_bind` was *absent*, which under the native default is +true of every program, rooted or not (CLAUDE.md hazard 4). + +`crates/perry-codegen/src/native_root_coverage/` adds **8 mechanic tests and 5 +harness self-tests**, in-crate `#[cfg(test)]` so they run in the per-PR +`cargo-test` gate rather than the nightly-only `tests/*.rs` tier (#5960). + +**Three vantages, because each is blind to what the next one catches.** + +1. *Pre-`opt` IR* — the `ptr addrspace(1)` allocas codegen asks for. The only + place "codegen never requested a root" and "LLVM removed one" are still + distinguishable, so the negative claims live here. +2. *Post-RS4GC IR* — each `gc.statepoint`'s `"gc-live"` bundle, keyed by callee + name, produced by running the production pass string + (`inprocess::STATEPOINT_REWRITE_PASSES`). Makes "is this value a root across + *that* call" a direct question; the shadow suites had no equivalent. +3. *The emitted stack map* — per-safepoint root lists decoded back out of the + compact `__perry_gcmap` blob the collector reads at run time + (`gc_map::decode_stack_map_roots`, which round-trips through `encode_stream` + + `verify_roundtrip`, so an assertion is about what the binary ships). + +Both shipped native-roots targets (`arm64-apple-macosx`, +`x86_64-unknown-linux-gnu`) are compiled and emitted for on every host, **pinned +rather than host-derived**: `cargo-test` runs on x86_64 Linux and development +happens on arm64 macOS, and a suite that silently changes subject with the host +is how a target-specific lowering bug reaches a release. + +**Non-vacuity is structural, not a promise.** `Statepoints::at` panics when the +callee it is asked about produced no safepoint and `map_records_for` panics when +the function is absent from the map, so "zero roots" is only ever asserted about +a record that exists; every negative claim carries a differential control in the +same test, so a lowering that roots *nothing* fails the control half. The +harness has its own coverage, which earned itself twice during development — the +callee parser initially read `@llvm.experimental.gc.statepoint.p0` for every +safepoint, and the live-set parser truncated at the `)` inside +`ptr addrspace(1)` and reported an empty live set for every statepoint in every +program. Either bug would have made every "nothing is live here" assertion pass +for the wrong reason. + +**Every test is sabotage-verified** — ten sabotages, each confirmed to compile +(`error[` count 0) and to reach the test binary (`Running unittests` present) +before its verdict was believed. Each test's doc comment names its sabotage and +the numbers it moved. Highlights: emitting root allocas as `alloca double` +reddens all eight mechanics; reintroducing the explicit bridge's conservative +CFG-union liveness reddens the dead-value test **and nothing else**; removing +`root_scalar_replaced_slot`'s `root_entry_alloca` call (#6968 reintroduced) +collapses the heap/numeric difference to `3 vs 3`; and removing the +`expr_is_known_non_pointer_shadow_value` early-out takes the numeric-only +literal's map from **0 to 2 roots**, which is the direct answer to whether that +negative assertion is vacuous under its lowering. + +**Two findings.** + +*#7502's table is wrong about row 9.* It marks #7184's out-of-range slot index +`n/a` under native roots because "no frame bound exists". The *frame* bound is +gone, but the defect was never about a frame — it is about an index falling +silently outside the structure that collects roots, and +`lower_precise_roots_to_native_stack` still has one: it collects with +`roots.get_mut(idx)` over a `slot_count`-sized vector, so an out-of-range index +drops the alloca from `root_ptrs` and it is never retyped and never rooted, with +no diagnostic. Sizing that vector one element short removes a root from the +emitted map while the function still compiles, verifies and emits identically. +Now tested and sabotage-verified. + +*`mem2reg` promoting every root alloca is a native-only precondition with no +shadow counterpart.* RS4GC relocates `addrspace(1)` **SSA values** and does not +scan allocas, so a root slot that escapes promotion is one the collector never +rewrites — the value reads as rooted and is not, which is the #7184/#7192 +presentation exactly. `no_root_alloca_survives_the_statepoint_rewrite` asserts +it directly; leaking a root alloca's address to a call reddens it with two +allocas surviving. + +Production surface is confined to two `#[cfg(test)]` seams +(`gc_map::decode_stack_map_roots`, `inprocess::statepoint_rewritten_ir`) and one +named constant replacing an inline string literal of identical value, so the +suite cannot go green against a pipeline production stopped using. Emitted IR is +unchanged. + +Validation: `perry-codegen --lib` 724/724, `perry-runtime --lib` 1915/1915, +`cargo check --all-targets`, all 22 lint-job commands, and +`gc_root_dominance_check.py --moving-only` over the 149-module corpus — 0 +violations, 40/40 seeded violations caught, 0 unrooted allocas. From e585124b8faf8aefb35c522f0f3b1de438e8490b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 8 Aug 2026 19:32:02 +0200 Subject: [PATCH 3/8] docs: correct a sabotage note to the post-mechanic-9 count (10 of 14 tests) --- crates/perry-codegen/src/native_root_coverage/mechanics.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/perry-codegen/src/native_root_coverage/mechanics.rs b/crates/perry-codegen/src/native_root_coverage/mechanics.rs index 80b6d8e553..6aadee35ce 100644 --- a/crates/perry-codegen/src/native_root_coverage/mechanics.rs +++ b/crates/perry-codegen/src/native_root_coverage/mechanics.rs @@ -51,8 +51,9 @@ fn counted_loop(body: Vec) -> Stmt { /// /// **Sabotage 1** — `function/precise_roots.rs`, the alloca-retype arm emits /// `alloca double` instead of `alloca ptr addrspace(1)`: RED, `root_allocas` -/// 2 → 0. Reddens all seven mechanics and both pipeline self-tests, which is -/// the point: nothing here can pass without the retype. +/// 2 → 0. This one reddens 10 of the 14 tests in the module (all eight +/// mechanics and both pipeline self-tests), which is the point: nothing here +/// can pass without the retype. /// /// **Sabotage 2** — the same arm's `.filter(|reg| roots.contains(reg))` /// dropped, so every scalar alloca is retyped: RED. From 552f8ab40f305dde845c0be84c9e1f7a5f57f47c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 8 Aug 2026 19:33:12 +0200 Subject: [PATCH 4/8] refactor: drop an unused Statepoints helper (new dead_code warning) --- crates/perry-codegen/src/native_root_coverage/mod.rs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/crates/perry-codegen/src/native_root_coverage/mod.rs b/crates/perry-codegen/src/native_root_coverage/mod.rs index 46cc82bfd3..e6929f41d3 100644 --- a/crates/perry-codegen/src/native_root_coverage/mod.rs +++ b/crates/perry-codegen/src/native_root_coverage/mod.rs @@ -382,15 +382,6 @@ impl Statepoints { ); hits } - - /// The largest live set at any safepoint in the function. - pub fn max_live(&self) -> usize { - self.points - .iter() - .map(|sp| sp.live.len()) - .max() - .unwrap_or(0) - } } /// Run the production statepoint rewrite over `ir` and read back every From 54c6bdf802cb5e667f8742dca8fa6ae441bd95a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 8 Aug 2026 19:36:24 +0200 Subject: [PATCH 5/8] build: gate the coverage module on llvm-inprocess as well as test Two of its three vantages run the statepoint rewrite and emit assembly through that pipeline, so `cargo test -p perry-codegen --no-default-features` (the text path, kept for bisection) had nothing for them to assert against. Verified: that build now compiles clean with no new dead-code warnings. --- crates/perry-codegen/src/gc_map.rs | 2 +- crates/perry-codegen/src/lib.rs | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/perry-codegen/src/gc_map.rs b/crates/perry-codegen/src/gc_map.rs index d3bd8596c0..b08c29f008 100644 --- a/crates/perry-codegen/src/gc_map.rs +++ b/crates/perry-codegen/src/gc_map.rs @@ -906,7 +906,7 @@ fn compact_stack_map_asm(asm: &str, target: &str) -> Result Date: Sat, 8 Aug 2026 19:36:40 +0200 Subject: [PATCH 6/8] docs(changelog): note the llvm-inprocess feature gate --- changelog.d/7653-native-root-coverage.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/changelog.d/7653-native-root-coverage.md b/changelog.d/7653-native-root-coverage.md index e382b11e23..ac4e9337fa 100644 --- a/changelog.d/7653-native-root-coverage.md +++ b/changelog.d/7653-native-root-coverage.md @@ -11,8 +11,10 @@ asserted `js_shadow_slot_bind` was *absent*, which under the native default is true of every program, rooted or not (CLAUDE.md hazard 4). `crates/perry-codegen/src/native_root_coverage/` adds **8 mechanic tests and 5 -harness self-tests**, in-crate `#[cfg(test)]` so they run in the per-PR -`cargo-test` gate rather than the nightly-only `tests/*.rs` tier (#5960). +harness self-tests**, in-crate `#[cfg(all(test, feature = "llvm-inprocess"))]` +so they run in the per-PR `cargo-test` gate rather than the nightly-only +`tests/*.rs` tier (#5960), and so `--no-default-features` (the text path kept +for bisection) still builds — two of the three vantages need that pipeline. **Three vantages, because each is blind to what the next one catches.** From 2f8b6fe0abeb51f6b2b542d5e4277f822e745117 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 8 Aug 2026 19:41:00 +0200 Subject: [PATCH 7/8] =?UTF-8?q?docs(gc):=20name=20the=20fourth=20blind=20s?= =?UTF-8?q?pot=20=E2=80=94=20the=20dominance=20corpus=20gates=20the=20lowe?= =?UTF-8?q?ring=20that=20does=20not=20ship?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/gc_root_dominance_corpus.sh has said so inline since #7370 flipped the default (it compiles under PERRY_RS4GC=0 because the checker anchors on @js_shadow_slot_bind calls the native lowering never emits). The invariant page did not, and that page is what a reader is told to read end to end — so a green gc-root-dominance read as evidence about the shipped lowering. Names the gap and points at the unit tests that now cover the native side. --- docs/src/internals/gc-rooting-invariant.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/src/internals/gc-rooting-invariant.md b/docs/src/internals/gc-rooting-invariant.md index eabfc8cae4..20ba9bf938 100644 --- a/docs/src/internals/gc-rooting-invariant.md +++ b/docs/src/internals/gc-rooting-invariant.md @@ -126,9 +126,23 @@ on a real workload. When adding a cache of a heap pointer, register it in alloca in generated code. Within that scope it is the only instrument that sees a defect before it crashes, which is why it runs first. -**It is blind to three classes, all found the hard way. A clean report is not +**It is blind to four classes, all found the hard way. A clean report is not evidence for any of them:** +- **★ The lowering that ships.** `gc_root_dominance_corpus.sh` compiles the + corpus under `PERRY_RS4GC=0`, which selects the **shadow stack** — and since + #7370 that is not the default on any target the runtime can walk. The reason + is stated in the script and is a real one (the checker anchors on + `@js_shadow_slot_bind` calls, and the native lowering emits **zero** of them, + so under the default the corpus contains 1251 statepoints, no binds at all, + and `--min-binds` fails the job). But the consequence has to be said out loud + here too, because this page is what a reader is pointed at: **a green + `gc-root-dominance` is evidence about the shadow lowering only.** Teaching the + checker to read `gc.statepoint` relocation bundles is the open work; until + then the native side is covered by unit tests rather than by this gate — + `crates/perry-codegen/src/native_root_coverage` (#7502), which asserts on the + `ptr addrspace(1)` root allocas, on each statepoint's `"gc-live"` bundle, and + on the compact `__perry_gcmap` map the collector actually reads. - **Runtime tables and interning caches** (#7231) — it reads emitted IR and cannot see a runtime cell. Tell: fails 10/10 rather than intermittently. - **Unrooted locals in runtime Rust** (#7249) — same reason. It read From 5e468a3bf2f3d6cc5a3aed5f5e68320195285f47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 8 Aug 2026 19:57:14 +0200 Subject: [PATCH 8/8] chore: bump version to 0.5.1372 Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --- CLAUDE.md | 2 +- Cargo.lock | 152 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 78 insertions(+), 78 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d0cd935637..4c9d4a6e67 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1371 +**Current Version:** 0.5.1372 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 1e18c36143..0a7dc3ce5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1371" +version = "0.5.1372" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1371" +version = "0.5.1372" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1371" +version = "0.5.1372" [[package]] name = "perry-ui-tvos" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1371" +version = "0.5.1372" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 0d34ec862e..c8023fb523 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1371" +version = "0.5.1372" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"