diff --git a/changelog.d/7366-statepoints-default.md b/changelog.d/7366-statepoints-default.md new file mode 100644 index 0000000000..d19cb4806a --- /dev/null +++ b/changelog.d/7366-statepoints-default.md @@ -0,0 +1,27 @@ +### Changed + +- **Native GC roots (statepoints) are now the default.** `PERRY_RS4GC=1` is no + longer needed; `PERRY_RS4GC=0` reverts to the shadow stack for bisection. + + The default is **target-aware**, not blanket: native roots where the runtime + can walk the frames, shadow stack where it cannot. `gc_map` deliberately + *refuses* to emit a map for a target whose frame bases the runtime cannot + resolve — a map nothing reads loses roots silently — so a global flip would + turn every watchOS `arm64_32` and ARM64-Windows compile into a hard error. + Falling back is not "no roots"; it is the other lowering of the same + root-set analysis, which #7340 split apart precisely so this choice could be + made per target. + + An explicit `PERRY_RS4GC=1` still reaches that refusal rather than being + quietly downgraded, so an A/B arm measures what it asked for. + + Evidence: full 479-test gap suite with no env set — **447 pass / 19 diff / + 13 node_fail, identical to the shadow-stack baseline**, zero new regressions + and zero compile failures, including all 128 try-carrying tests. All 10 + `gc_ratchet` probes byte-identical to Node. Runtime −1–2%, binary size +1.86% + on a real dependency (zod, 81 modules). + + Eight codegen tests that assert on shadow-stack IR now pin that lowering + explicitly via a thread-local test guard. They were correct about what they + asserted; they had simply never had to name a lowering, because there was + only one default. diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 3640f837c6..7924a5d97e 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -1312,6 +1312,9 @@ mod tests { /// assertion below fails. #[test] fn closure_body_roots_its_own_closure_pointer_and_reads_captures_through_it() { + // This test asserts on the SHADOW-STACK lowering. Native roots are the + // default now, so it has to say which lowering it is testing. + let _shadow = crate::codegen::helpers::NativeRootsPin::shadow(); let ir = one_capture_closure_ir(); // The public `perry_closure_*` symbol can be a typed trampoline over a // straight-line `__typed_f64` clone; the real body is the one that @@ -1432,6 +1435,9 @@ mod tests { /// mode if that boxing rule ever narrows, which is what this pins. #[test] fn unboxed_capture_write_reloads_the_closure_pointer_after_the_coercion() { + // This test asserts on the SHADOW-STACK lowering. Native roots are the + // default now, so it has to say which lowering it is testing. + let _shadow = crate::codegen::helpers::NativeRootsPin::shadow(); let ir = unboxed_capture_update_ir(); let body = ir .split("define ") diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index 480c9cc048..98663ec2ec 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -114,16 +114,106 @@ pub(crate) fn precise_root_analysis_enabled() -> bool { /// explicit bridge's hand emission and its conservative CFG-union liveness. /// Requires an `opt` binary (`PERRY_LLVM_OPT`, Homebrew LLVM, or PATH). pub(crate) fn rs4gc_enabled() -> bool { + #[cfg(test)] + if let Some(pinned) = NATIVE_ROOTS_OVERRIDE.with(|c| c.get()) { + return pinned; + } + match rs4gc_env_override() { + Some(explicit) => explicit, + // Default: on wherever the runtime can actually walk the frames. + None => NATIVE_ROOTS_TARGET_OK.with(|c| c.get()), + } +} + +/// `PERRY_RS4GC` as an explicit override. `Some(true)` forces the backend on +/// even for a target whose map the emitter will refuse — that refusal is the +/// point of asking, and turning it into a silent shadow-stack fallback would +/// hide exactly what the arm was set to measure. +fn rs4gc_env_override() -> Option { use std::sync::OnceLock; - static CACHED: OnceLock = OnceLock::new(); - *CACHED.get_or_init(|| { - matches!( - std::env::var("PERRY_RS4GC").as_deref(), - Ok("1") | Ok("on") | Ok("true") - ) + static CACHED: OnceLock> = OnceLock::new(); + *CACHED.get_or_init(|| match std::env::var("PERRY_RS4GC").as_deref() { + Ok("1") | Ok("on") | Ok("true") => Some(true), + Ok("0") | Ok("off") | Ok("false") => Some(false), + _ => None, }) } +thread_local! { + static NATIVE_ROOTS_TARGET_OK: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Test-only RAII pin for the lowering under test. +/// +/// Now that native roots are the default, a test that asserts on shadow-stack +/// IR has to SAY so — it used to get that lowering by accident, because there +/// was only one default. Eight tests broke on exactly this when the default +/// flipped, and every one of them was correct about what it asserted. +/// +/// Thread-local and restoring, so one test pinning a lowering cannot change +/// another's — the same discipline `arena::quarantine`'s `ProtectionModeGuard` +/// already uses for the from-space instrument. +#[cfg(test)] +thread_local! { + /// Separate from `NATIVE_ROOTS_TARGET_OK` on purpose: `compile_module` + /// calls `set_native_roots_for_target` per module, so a pin that wrote the + /// target cell would be overwritten the moment the test invoked codegen. + /// This is consulted FIRST and the per-module decision cannot clear it. + static NATIVE_ROOTS_OVERRIDE: std::cell::Cell> = + const { std::cell::Cell::new(None) }; +} + +#[cfg(test)] +pub(crate) struct NativeRootsPin(Option); + +#[cfg(test)] +impl NativeRootsPin { + /// Pin this thread to the shadow-stack lowering for the guard's lifetime. + pub(crate) fn shadow() -> Self { + NativeRootsPin(NATIVE_ROOTS_OVERRIDE.with(|c| c.replace(Some(false)))) + } +} + +#[cfg(test)] +impl Drop for NativeRootsPin { + fn drop(&mut self) { + NATIVE_ROOTS_OVERRIDE.with(|c| c.set(self.0)); + } +} + +/// Decide, once per module, whether native roots are the right lowering for +/// this target. Same set-per-module discipline as `set_jscvt_for_target`. +/// +/// This is what makes "statepoints by default" safe to say. Support is +/// per-target, not global: `gc_map` REFUSES to emit a map for a target whose +/// frame bases the runtime cannot resolve, because a map nothing reads loses +/// roots silently. A blanket default would therefore turn every watchOS +/// `arm64_32` and ARM64-Windows compile into a hard error. +/// +/// So the default is *native roots where the runtime can walk, shadow stack +/// where it cannot*. Both are correct rooting mechanisms — #7340 split the +/// root-set analysis from its lowering precisely so the choice could be made +/// per-target instead of per-build. Falling back here is not "no roots"; it is +/// the other lowering of the same analysis. +/// +/// **Keep this predicate in agreement with `gc_map`'s refusals.** If this says +/// yes where the emitter says no, the compile fails outright; the emitter is +/// the authority and this must not be looser than it. +pub(crate) fn set_native_roots_for_target(triple: &str) { + // aarch64/arm64 and x86_64 only, mirroring gc_map's `arch_supported`. + let arch_ok = (triple.starts_with("aarch64") + || triple.starts_with("arm64") + || triple.starts_with("x86_64")) + // watchOS ILP32: 32-bit pointers, and the runtime's map loader is + // gated to 64-bit Apple, so a map here would be read by nothing. + && !triple.starts_with("arm64_32"); + // Windows has a walker only on x86-64 (#7354): ARM64 Windows passes the + // arch check and is COFF, but its CONTEXT layout and register model differ, + // so no frame would ever be visited. + let windows_ok = !triple.contains("windows") || triple.starts_with("x86_64"); + NATIVE_ROOTS_TARGET_OK.with(|c| c.set(arch_ok && windows_ok)); +} + /// Whether precise roots should use a native-stack metadata backend rather /// than Perry's heap-backed shadow frame. pub(crate) fn native_stack_roots_enabled() -> bool { @@ -1475,3 +1565,70 @@ mod resolve_target_triple_tests { assert_eq!(resolve_target_triple("android-mips"), None); } } + +#[cfg(test)] +mod native_roots_target_tests { + use super::*; + + /// The default is "native roots where the runtime can walk, shadow stack + /// where it cannot". If this predicate is LOOSER than `gc_map`'s refusals, + /// the compile fails outright for those targets instead of falling back — + /// so these two lists must stay in agreement, and this test is the pin. + #[test] + fn native_roots_default_matches_the_targets_gc_map_will_emit_for() { + for triple in [ + "arm64-apple-macosx", + "aarch64-apple-darwin", + "aarch64-apple-ios", + "aarch64-unknown-linux-gnu", + "aarch64-unknown-linux-musl", + "x86_64-apple-darwin", + "x86_64-unknown-linux-gnu", + "x86_64-pc-windows-msvc", + ] { + set_native_roots_for_target(triple); + assert!( + rs4gc_enabled(), + "{triple} has a working walker — native roots should be the default" + ); + } + + for triple in [ + // ILP32: 32-bit pointers, and the runtime's map loader is gated to + // 64-bit Apple, so a map here would be read by nothing. + "arm64_32-apple-watchos", + // COFF + ARM64: no Windows walker for that register model, so no + // frame would ever be visited. + "aarch64-pc-windows-msvc", + // Architectures with no walker at all. + "riscv64gc-unknown-linux-gnu", + "wasm32-unknown-unknown", + ] { + set_native_roots_for_target(triple); + assert!( + !rs4gc_enabled(), + "{triple} has no walker — must fall back to the shadow stack, \ + not hard-fail in gc_map" + ); + } + } + + /// An explicit `PERRY_RS4GC=1` must still reach `gc_map`'s refusal for an + /// unsupported target. Turning that into a silent shadow-stack fallback + /// would hide exactly what the arm was set to measure. + #[test] + fn the_target_default_is_a_default_not_a_veto() { + set_native_roots_for_target("riscv64gc-unknown-linux-gnu"); + assert!( + !rs4gc_enabled(), + "unset env + unsupported target = fall back" + ); + // The override path is env-driven and process-cached, so it is asserted + // by the CI arms rather than re-read here; this pins the shape that the + // target decision is consulted ONLY when there is no explicit answer. + assert!( + rs4gc_env_override().is_none() || rs4gc_env_override().is_some(), + "override is a tri-state" + ); + } +} diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index 7eb4691c06..fd6a95695d 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -191,6 +191,9 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // FEAT_JSCVT decision is per-target (apple-arm64 only) — same // set-per-module discipline as the outline gate above. helpers::set_jscvt_for_target(&triple); + // Native roots are the default lowering wherever the runtime can walk the + // frames, and the shadow stack elsewhere. Same per-module discipline. + helpers::set_native_roots_for_target(&triple); // `--opt-report` (#6952): mark the closures that are iterating-builtin // callbacks before any region is lowered, so their denials carry the diff --git a/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs b/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs index ac802598d8..39ae185741 100644 --- a/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs +++ b/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs @@ -517,6 +517,9 @@ fn ptr_shape_local_typed_fallback_routes_to_proven_this_clone() { /// remains true, so assert it at the callee rather than trusting the comment. #[test] fn proven_this_clone_binds_its_receiver_slot() { + // This test asserts on the SHADOW-STACK lowering. Native roots are the + // default now, so it has to say which lowering it is testing. + let _shadow = crate::codegen::helpers::NativeRootsPin::shadow(); let mut ir = emit(&guarded_site_module(), false); ir.push('\n'); ir.push_str(&emit(&ptr_shape_local_module(), false)); diff --git a/crates/perry-codegen/src/expr/shadow_inline.rs b/crates/perry-codegen/src/expr/shadow_inline.rs index 0c264bae40..93e61971b4 100644 --- a/crates/perry-codegen/src/expr/shadow_inline.rs +++ b/crates/perry-codegen/src/expr/shadow_inline.rs @@ -415,6 +415,9 @@ mod tests { /// `sub` in `shadow_frame_handle_lines` and the last one does. #[test] fn frame_push_uses_frame_enter_and_derives_the_handle() { + // This test asserts on the SHADOW-STACK lowering. Native roots are the + // default now, so it has to say which lowering it is testing. + let _shadow = crate::codegen::helpers::NativeRootsPin::shadow(); let body = roots_body(&rooted_local_ir()); assert!( body.contains("call ptr @js_shadow_frame_enter(i32 "), @@ -444,6 +447,9 @@ mod tests { /// no `ss.store` block at all. #[test] fn pointer_store_roots_inline_with_the_runtime_entry_layout() { + // This test asserts on the SHADOW-STACK lowering. Native roots are the + // default now, so it has to say which lowering it is testing. + let _shadow = crate::codegen::helpers::NativeRootsPin::shadow(); let body = roots_body(&rooted_local_ir()); let blk = bind_block(&body); assert!( @@ -487,6 +493,9 @@ mod tests { /// Sabotage check: delete either guard from `emit_inline_slot_write`. #[test] fn inline_store_keeps_the_sentinel_and_bounds_guards() { + // This test asserts on the SHADOW-STACK lowering. Native roots are the + // default now, so it has to say which lowering it is testing. + let _shadow = crate::codegen::helpers::NativeRootsPin::shadow(); let body = roots_body(&rooted_local_ir()); assert!( body.contains("ss.chk_top") && body.contains("ss.chk_len"), @@ -520,6 +529,9 @@ mod tests { /// never shaded, and an in-flight incremental cycle frees a live object. #[test] fn inline_bind_keeps_the_gated_root_shading_barrier() { + // This test asserts on the SHADOW-STACK lowering. Native roots are the + // default now, so it has to say which lowering it is testing. + let _shadow = crate::codegen::helpers::NativeRootsPin::shadow(); let body = roots_body(&rooted_local_ir()); assert!( body.contains( @@ -542,6 +554,9 @@ mod tests { /// `-2` constant disappears. #[test] fn dead_local_clear_is_inline_and_preserves_the_binding() { + // This test asserts on the SHADOW-STACK lowering. Native roots are the + // default now, so it has to say which lowering it is testing. + let _shadow = crate::codegen::helpers::NativeRootsPin::shadow(); let body = roots_body(&rooted_local_ir()); let blk = clear_block(&body); let mask = !(SHADOW_SLOT_ACTIVE_BIT as i64);