Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions changelog.d/7366-statepoints-default.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions crates/perry-codegen/src/codegen/closure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ")
Expand Down
169 changes: 163 additions & 6 deletions crates/perry-codegen/src/codegen/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> {
use std::sync::OnceLock;
static CACHED: OnceLock<bool> = OnceLock::new();
*CACHED.get_or_init(|| {
matches!(
std::env::var("PERRY_RS4GC").as_deref(),
Ok("1") | Ok("on") | Ok("true")
)
static CACHED: OnceLock<Option<bool>> = 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<bool> = 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<Option<bool>> =
const { std::cell::Cell::new(None) };
}

#[cfg(test)]
pub(crate) struct NativeRootsPin(Option<bool>);

#[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 {
Expand Down Expand Up @@ -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"
);
Comment on lines +1577 to +1632

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -uo pipefail

cargo check -p perry --profile perry-dev || exit 1

status=0
for override in 0 1; do
  if ! env PERRY_RS4GC="$override" \
    cargo test -p perry-codegen --profile perry-dev \
    native_roots_target_tests -- --test-threads=1; then
    status=1
  fi
done
exit "$status"

Repository: PerryTS/perry

Length of output: 143


🏁 Script executed:

#!/usr/bin/env bash
set -uo pipefail

printf 'Repository files around helpers.rs:\n'
fd -a 'helpers.rs$' . | sed 's#^\./##'

printf '\nTarget symbols in crates/perry-codegen/src/codegen/helpers.rs:\n'
rg -n "rs4gc_(enabled|env_override|policy)|NATIVE_ROOTS_TARGET_OK|NATIVE_ROOTS_OVERRIDE|set_native_roots_for_target" crates/perry-codegen/src/codegen/helpers.rs || true

printf '\nRelevant source section:\n'
sed -n '1520,1645p' crates/perry-codegen/src/codegen/helpers.rs

printf '\nRelevant section above definitions:\n'
rg -n -C 8 "pub.*rs4gc|NATIVE_ROOTS_TARGET_OK|NATIVE_ROOTS_OVERRIDE|set_native_roots_for_target" crates/perry-codegen/src/codegen/helpers.rs

Repository: PerryTS/perry

Length of output: 15193


Isolate the native-roots target tests from PERRY_RS4GC.

rs4gc_enabled() uses cached PERRY_RS4GC before reading NATIVE_ROOTS_TARGET_OK, so PERRY_RS4GC=0 fails the walking-target assertions and PERRY_RS4GC=1 fails the unsupported-target assertions. Move the precedence decision into a pure helper, then test target defaults with explicit None and assert explicit overrides separately.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/codegen/helpers.rs` around lines 1577 - 1632,
Refactor the target-default decision used by rs4gc_enabled() into a pure helper
that accepts an explicit optional PERRY_RS4GC override and the target capability
result, preserving override precedence. Update
native_roots_default_matches_the_targets_gc_map_will_emit_for() to evaluate
defaults with None so the process environment cannot affect assertions, and
replace the current tautological override check in
the_target_default_is_a_default_not_a_veto() with assertions that explicit
Some(false) and Some(true) override the target default.

Source: Coding guidelines

}
}
3 changes: 3 additions & 0 deletions crates/perry-codegen/src/codegen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result<Vec<u8>>
// 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
15 changes: 15 additions & 0 deletions crates/perry-codegen/src/expr/shadow_inline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 "),
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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(
Expand All @@ -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);
Expand Down
Loading