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
76 changes: 72 additions & 4 deletions .github/workflows/gc-native-roots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,22 @@
#
# The defect is one layer down, at collection time. `chain_walkable` admits only
# aarch64's DWARF 29/31, so on x86-64 every frame falls back to the platform
# unwinder, which resolves the base with `_Unwind_GetGR(ctx, 7)` — and that does
# not reliably return the stack pointer (`_Unwind_GetCFA` is the supported way).
# Wild addresses, then a segfault when the collector writes through them. The
# compiler now refuses that target outright (#7324) rather than emitting a
# unwinder, which resolves the base with `_Unwind_GetGR(ctx, 7)`.
#
# MEASURED 2026-08-03 (#7333), and it is worse than the "unreliable value" this
# comment used to claim: that call SEGFAULTS. Probed on x86-64 Linux (glibc 2.39,
# gcc 13.3.0), one register per run from an `_Unwind_Backtrace` callback — RBX
# (3), RBP (6) and RIP (16) return correctly; RAX (0) and RSP (7) both SIGSEGV.
# The split is callee-saved versus not: libgcc tracks only the columns CFI
# restores, and RSP is not one of them (it is *derived* from the CFA), so reg 7
# is the single lookup guaranteed to fault — and it is the only register x86-64
# roots use.
#
# So the fault is IN the `_Unwind_GetGR` call, not in a later write through a
# wild address, and no address validation after it can help. The wording here
# before was a guess, and it pointed at the wrong fix.
#
# The compiler now refuses that target outright (#7324) rather than emitting a
# binary that crashes under collection, so an x86-64 run of this matrix would
# test nothing but the refusal — which is what `statepoints-refuse-x86` is for.
#
Expand Down Expand Up @@ -154,6 +166,62 @@ jobs:
grep -l "#gcmetric" $errs >/dev/null \
|| { echo "::error::no probe emitted gc metrics — the collector never ran"; exit 1; }

# #7326. The shadow stack's root-set analysis and its lowering used to be
# one knob, so PERRY_SHADOW_STACK=0 + PERRY_STATEPOINTS=1 switched the
# analysis off and left the statepoint lowering with nothing to lower: a
# binary with NO precise frame roots, no __perry_gcmap section, correct
# output, indistinguishable from a good build until a collection freed
# something live. #7332 made the pair a hard error as a stopgap.
#
# The predicate is now split, so the pair is expressible -- and the
# property that says the split is real is that the knob makes NO
# difference under statepoints. Assert that on the artifacts rather than
# the binary: the build embeds a PID-and-nonce scratch path, so two runs
# of the SAME configuration already differ byte-for-byte, and an
# end-to-end hash would be a test that can only fail.
#
# A mode nobody can select is a mode nobody can measure, which is why this
# matters for eventually removing the shadow-stack lowering at all.
- name: The shadow-stack knob is inert under statepoints (analysis/lowering split)
if: ${{ !cancelled() }}
run: |
set -euo pipefail
export PERRY_RUNTIME_DIR="$PWD/target/perry-dev"
export PERRY_NO_AUTO_OPTIMIZE=1
probe=benchmarks/gc_ratchet/probes/01_nursery_churn.ts

PERRY_STATEPOINTS=1 ./target/perry-dev/perry "$probe" -o /tmp/split-on
PERRY_STATEPOINTS=1 PERRY_SHADOW_STACK=0 \
./target/perry-dev/perry "$probe" -o /tmp/split-off

for v in on off; do
otool -l "/tmp/split-$v" | grep -q "sectname __perry_gcmap" \
|| { echo "::error::split-$v has no __perry_gcmap — the analysis did not run"; exit 1; }
otool -s __PERRY_GCMAP __perry_gcmap "/tmp/split-$v" | tail -n +3 \
| awk '{$1="";print}' > "/tmp/split-$v.gcmap"
otool -tV "/tmp/split-$v" | grep -v '^/tmp/' > "/tmp/split-$v.text"
done

cmp /tmp/split-on.gcmap /tmp/split-off.gcmap \
|| { echo "::error::root maps differ — PERRY_SHADOW_STACK still reaches the statepoint analysis"; exit 1; }
cmp /tmp/split-on.text /tmp/split-off.text \
|| { echo "::error::emitted code differs — the analysis and its lowering are not cleanly split"; exit 1; }
echo "analysis/lowering split holds: identical root map and __text across PERRY_SHADOW_STACK"

# And the knob must keep its OWN meaning: on its own it still means
# "no precise roots", which is the whole point of a bisection knob.
PERRY_SHADOW_STACK=0 ./target/perry-dev/perry "$probe" -o /tmp/split-alone
otool -l /tmp/split-alone | grep -q "sectname __perry_gcmap" \
&& { echo "::error::PERRY_SHADOW_STACK=0 alone emitted a root map — the knob lost its meaning"; exit 1; }

# ...and it must still be observable, or it is asserting nothing.
./target/perry-dev/perry "$probe" -o /tmp/split-default
otool -tV /tmp/split-default | grep -v '^/tmp/' > /tmp/split-default.text
otool -tV /tmp/split-alone | grep -v '^/tmp/' > /tmp/split-alone.text
cmp -s /tmp/split-default.text /tmp/split-alone.text \
&& { echo "::error::default and PERRY_SHADOW_STACK=0 emit identical code — this probe roots nothing, so the assertions above are vacuous"; exit 1; }
echo "shadow-stack knob remains observable on its own"

- name: Root-pressure report fails closed (--statepoint-report)
if: ${{ !cancelled() }}
run: |
Expand Down
21 changes: 21 additions & 0 deletions changelog.d/7340-decouple-root-analysis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
### Fixed

- **`PERRY_SHADOW_STACK=0` no longer silently disables statepoint roots.** The
shadow stack's root-set *analysis* and its *lowering* were one knob, so
combining the bisection knob with `PERRY_STATEPOINTS`/`PERRY_RS4GC` switched
the analysis off and left the statepoint lowering with nothing to lower — a
binary with no precise frame roots at all, no `__perry_gcmap` section, and
correct output right up until a collection freed a live object. #7332 made the
combination a hard error; this splits the predicate so it is *expressible*
instead.

The eight sites that build the slot map now gate on
`precise_root_analysis_enabled()`. The lowering choice was already independent
inside `LlFunction`, so nothing else moves. Under statepoints the knob is now
provably inert: identical 885-byte root map and identical `__text` with and
without it, asserted by a new `gc-native-roots` step that also fails if the
probe roots nothing (which would make the comparison vacuous).

This changes no default and deletes nothing. It is the prerequisite for the
shadow-stack lowering ever being *removed* rather than merely switched off in
a configuration nobody could run.
4 changes: 2 additions & 2 deletions crates/perry-codegen/src/codegen/closure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -582,7 +582,7 @@ pub(super) fn compile_closure(
// evacuating GC fired mid-body swept values reachable only from the
// closure's own frame — the referrer then read freed-and-reused memory.
// Emit the same frame the top-level function path gets (function.rs).
let shadow_slot_map = if super::helpers::shadow_stack_enabled() {
let shadow_slot_map = if super::helpers::precise_root_analysis_enabled() {
let flat_const_ids: std::collections::HashSet<u32> =
cross_module.flat_const_arrays.keys().copied().collect();
let m = crate::collectors::collect_pointer_typed_locals(params, body, &flat_const_ids);
Expand Down Expand Up @@ -701,7 +701,7 @@ pub(super) fn compile_closure(
// bind — an entry-setup hoist would make the slot active while the alloca
// still held stack garbage.
let capture_root_base = shadow_slot_map.len() as u32;
let bind_capture_slot = super::helpers::shadow_stack_enabled();
let bind_capture_slot = super::helpers::precise_root_analysis_enabled();
let new_target_stack = if captures_new_target {
let new_target_cap_idx = auto_captures.len() as u32;
let blk = lf.block_mut(0).unwrap();
Expand Down
4 changes: 2 additions & 2 deletions crates/perry-codegen/src/codegen/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use crate::stmt;
use crate::strings::StringPool;
use crate::types::{LlvmType, DOUBLE, I1, I32, I64, I8, PTR};

use super::helpers::shadow_stack_enabled;
use super::helpers::precise_root_analysis_enabled;
use super::helpers::{inline_hot_small_enabled, inline_hot_small_size_cap, INLINE_HOT_SMALL_MIN};
use super::opts::CrossModuleCtx;
use super::spec_abi::{
Expand Down Expand Up @@ -394,7 +394,7 @@ pub(super) fn compile_function(
// populate the frame with live values; today the slots stay
// zero (the tracer doesn't consume them yet — Phase A ship
// criterion is "shadow stack is built but not yet consumed").
let shadow_slot_map = if shadow_stack_enabled() {
let shadow_slot_map = if precise_root_analysis_enabled() {
let flat_const_ids: std::collections::HashSet<u32> =
cross_module.flat_const_arrays.keys().copied().collect();
let m =
Expand Down
58 changes: 31 additions & 27 deletions crates/perry-codegen/src/codegen/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,36 +71,40 @@ pub(super) fn shadow_stack_enabled() -> bool {
std::env::var("PERRY_SHADOW_STACK").as_deref(),
Ok("0") | Ok("off") | Ok("false")
);
// #7326: the statepoint backends are an alternative *lowering* of this
// analysis, not an independent mechanism. `reserve_shadow_slot()` is
// the single entry point that, under `native_stack_roots_enabled()`,
// allocates a stack-map slot instead of a shadow-stack slot — and the
// caller of that analysis returns empty maps outright when this is off.
//
// So switching the shadow stack off switches the statepoint roots off
// with it, and the result is a binary with NO precise frame roots that
// still runs and prints the right answer: measured, no `__perry_gcmap`
// section at all, same size as a plain shadow-off build. Nothing about
// the run distinguishes it from a correct one until a collection frees
// a live object.
//
// Refuse, rather than emit it. The bisection knob keeps its meaning on
// its own; it simply cannot be combined with a backend that depends on
// the analysis it disables.
if !on && native_stack_roots_enabled() {
panic!(
"perry: PERRY_SHADOW_STACK=0 cannot be combined with \
PERRY_STATEPOINTS/PERRY_RS4GC. The statepoint backends reuse the \
shadow stack's root-set analysis to decide what to root, so \
disabling it produces a binary with no precise frame roots at all \
— silently, since such a binary still runs correctly until a \
collection moves something live (#7326). Drop one of the two."
);
}
on
})
}

/// Whether the precise-root **analysis** runs — i.e. whether
/// `collect_pointer_typed_locals` assigns slot indices at all.
///
/// #7326 is the distinction this function exists to draw. There are two
/// separable questions and one knob used to answer both:
///
/// 1. *Which locals hold GC pointers, and where must each stay live?*
/// That is the analysis. It is backend-independent.
/// 2. *How is the answer represented in the emitted code?* — Perry's
/// heap-backed shadow frame, or a native stack map. That is the lowering,
/// and it is chosen inside `LlFunction` (`enable_shadow_frame_inner` and
/// `reserve_shadow_slot` both return the native path first).
///
/// Conflating them made `PERRY_SHADOW_STACK=0 + PERRY_STATEPOINTS=1` produce a
/// binary with **no precise frame roots at all** — the analysis was switched
/// off, so the statepoint lowering had nothing to lower. No `__perry_gcmap`
/// section, same size as a plain shadow-off build, correct output. Nothing
/// distinguished it from a good build until a collection freed a live object.
/// #7332 made that combination a hard error as a stopgap; splitting the
/// predicate makes it *expressible* instead, which is the prerequisite for the
/// shadow stack's lowering ever being removed — a mode nobody can select is a
/// mode nobody can measure.
///
/// Acceptance property, asserted by test: with statepoints on, this returns
/// true regardless of `PERRY_SHADOW_STACK`, so both spellings must emit
/// byte-identical code.
pub(crate) fn precise_root_analysis_enabled() -> bool {
shadow_stack_enabled() || native_stack_roots_enabled()
}

/// Research-only moving-GC backend using LLVM's explicit statepoint
/// relocation sequence (`PERRY_STATEPOINTS=1`).
///
Expand Down Expand Up @@ -264,7 +268,7 @@ pub(super) fn enable_module_init_shadow_frame(
stmts: &[perry_hir::Stmt],
flat_const_ids: &std::collections::HashSet<u32>,
) -> (HashMap<u32, u32>, HashMap<usize, Vec<u32>>) {
if !shadow_stack_enabled() {
if !precise_root_analysis_enabled() {
return (HashMap::new(), HashMap::new());
}

Expand Down
8 changes: 4 additions & 4 deletions crates/perry-codegen/src/codegen/method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ pub(super) fn compile_method(
// exact-roots liveness hole as closures (see compile_closure). One extra
// slot roots the receiver (`this` is a pointer value reachable from
// nothing else when the caller holds it only in a register temp).
let shadow_slot_map = if super::helpers::shadow_stack_enabled() {
let shadow_slot_map = if super::helpers::precise_root_analysis_enabled() {
let flat_const_ids: std::collections::HashSet<u32> =
cross_module.flat_const_arrays.keys().copied().collect();
let m = crate::collectors::collect_pointer_typed_locals(
Expand Down Expand Up @@ -333,7 +333,7 @@ pub(super) fn compile_method(
let blk = lf.block_mut(0).unwrap();
let this_slot = blk.alloca(DOUBLE);
blk.store(DOUBLE, "%this_arg", &this_slot);
if super::helpers::shadow_stack_enabled() {
if super::helpers::precise_root_analysis_enabled() {
blk.call_void(
"js_shadow_slot_bind",
&[(I32, &this_shadow_slot_idx.to_string()), (PTR, &this_slot)],
Expand Down Expand Up @@ -1337,7 +1337,7 @@ pub(super) fn compile_static_method(
// the non-pointer INT32 class-ref, but `js_static_this_resolve` returns a
// REAL heap receiver for `C.m.call(x)` / `.apply(x)` / inherited `D.m()`
// dynamic dispatch, and that object may be reachable only from this slot.
let shadow_slot_map = if super::helpers::shadow_stack_enabled() {
let shadow_slot_map = if super::helpers::precise_root_analysis_enabled() {
let flat_const_ids: std::collections::HashSet<u32> =
cross_module.flat_const_arrays.keys().copied().collect();
let m =
Expand Down Expand Up @@ -1383,7 +1383,7 @@ pub(super) fn compile_static_method(
&[(DOUBLE, &class_ref_lit)],
);
blk.store(DOUBLE, &resolved_this, &this_slot);
if super::helpers::shadow_stack_enabled() {
if super::helpers::precise_root_analysis_enabled() {
blk.call_void(
"js_shadow_slot_bind",
&[(I32, &this_shadow_slot_idx.to_string()), (PTR, &this_slot)],
Expand Down
Loading
Loading