-
-
Notifications
You must be signed in to change notification settings - Fork 151
test(gc): run the unit-test build in production's conservative-scan mode #7147
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
86d154e
2479b71
cdb2912
35b0124
845683f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| **GC / tests:** the perry-runtime unit-test build ran a *different GC root | ||
| configuration from production*. `gc::roots::conservative_stack_scan_mode()` | ||
| defaulted the test build to `ConservativeStackScanMode::Full` while production | ||
| resolves `Auto -> SkipDisabled`, and every consequence pointed in the direction | ||
| that hides defects: a missing precise root (#7055's shape) was rescued by the | ||
| native scan under test while being a live wrong answer in production, so the | ||
| suite filtered out precisely the failure class it exists to catch; the copying | ||
| minor was ineligible under test (`CopiedMinorFallbackReason::ConservativeStack`) | ||
| so relocation went largely unexercised; and a conservative scan keeps arbitrary | ||
| native-stack garbage alive, which is the wrong direction for *"this object | ||
| should have been collected"* — degrading exactly the tests most dependent on | ||
| precision. The comment justifying the split described how some tests were | ||
| written, not a requirement of testing. The correct mechanism already existed and | ||
| the tests already used it (`RuntimeHandleScope` / `gc_temp_root_*`, plus the | ||
| isolation guards, which pin `Auto` themselves): **351 of the 531 gc tests were | ||
| already running in production's mode**, so flipping the default for the rest cost | ||
| exactly one test conversion. | ||
|
|
||
| That conversion is `test_minor_preserves_old_to_young_edge_across_minors`, which | ||
| asserted the parent's slot still held `ptr_bits(child)` — the address captured | ||
| *before* the first minor. It only held because `Full` made the minor non-moving; | ||
| with precise roots the copying minor relocates the child and correctly rewrites | ||
| the slot (`left: 9222531965582835720` vs `right: 9222531965580476424`). Reading a | ||
| raw local across a relocating collection is the defect class this suite exists to | ||
| catch, so the test now does what generated code does and re-reads the child out | ||
| of the parent's slot after every minor. Measured: cycles 0–2 run `copied=1 | ||
| promoted=0` with the remembered-set edge intact; cycle 3 reports `promoted=1` and | ||
| the child leaves the nursery, at which point the edge is old→old and the | ||
| remembered set correctly retires it — handled explicitly now, with an | ||
| `rs_covered_cycles >= 2` floor so the loop cannot go vacuous. Evidence (Mac mini, | ||
| darwin-arm64, release, `--test-threads=1`): `PERRY_CONSERVATIVE_STACK_SCAN=0` went | ||
| 1573/1 → **1574/0**, default unchanged at 1574/0. Sabotage-verified both ways — | ||
| injecting `remembered_set_clear()` after the minor (the #6181 dropped edge) turns | ||
| the converted test red in *both* scan modes. | ||
|
|
||
| `ConservativeScanAutoGuard` is deleted: with the default already `Auto` it set the | ||
| value it was going to get anyway and could no longer fail, so per CLAUDE.md's | ||
| kill-policy it goes rather than lingering as an untested no-op. Its single user | ||
| moves to `ConservativeScanDisabledGuard`, which asserts the stronger property that | ||
| test needs (objects held only as native-stack locals must be *collected*). | ||
|
|
||
| **GC / runtime (#7145):** `js_shadow_frame_pop`'s corrupted-handle guard used | ||
| `debug_assert!(false, …)` **inside an `extern "C"` fn**. In a debug build the | ||
| assert fires, the panic cannot unwind across the `extern "C"` boundary, and the | ||
| process aborts — so `cargo test -p perry-runtime --lib` in the dev profile | ||
| SIGABRTed the entire test binary at | ||
| `gc::tests::shadow_stack_ops::out_of_range_frame_pop_is_ignored`, which drives | ||
| that path on purpose. CI runs the suite `--release`, where `debug_assert!` | ||
| compiles out, so the gate was structurally blind to it: a whole profile of the | ||
| runtime test suite was un-runnable and no gate could go red. Both that site and | ||
| `js_gc_temp_root_push`'s identical overflow guard (unreachable in practice, hence | ||
| never caught) now use the `report_growth_stub_skipped_below_heap_min` pattern — | ||
| `#[cold]`, one line on stderr, once per process, so it cannot perturb a stdout | ||
| parity comparison. Skip behaviour is unchanged in both profiles. | ||
|
|
||
| **Build:** `crates/perry-codegen/src/linker.rs:96` landed with a rustfmt violation | ||
| in #7135, leaving `cargo fmt --all -- --check` red on `main` as of `a3b31c0d8` and | ||
| `lint` failing on every open PR. Reformatted here; no behaviour change. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -528,7 +528,7 @@ pub extern "C" fn js_shadow_frame_pop(frame_handle: u64) { | |
| // wraps for a handle near `usize::MAX` and lets exactly the corrupted | ||
| // handles this guard exists for slip through into an unchecked read. | ||
| if base >= s.len { | ||
| debug_assert!(false, "shadow-stack pop past end (corrupted frame handle)"); | ||
| report_corrupt_frame_pop(frame_handle); | ||
| return; | ||
| } | ||
| s.frame_top = (*s.ptr.add(base)).value as usize; | ||
|
|
@@ -537,6 +537,36 @@ pub extern "C" fn js_shadow_frame_pop(frame_handle: u64) { | |
| }); | ||
| } | ||
|
|
||
| /// Report an out-of-range `js_shadow_frame_pop` handle without unwinding. | ||
| /// | ||
| /// #7145: this guard used to be `debug_assert!(false, …)`. That is unsound | ||
| /// *inside an `extern "C"` fn*: in a debug build the assert fires, the panic | ||
| /// cannot unwind across the `extern "C"` boundary, and the process aborts. | ||
| /// `gc::tests::shadow_stack_ops::out_of_range_frame_pop_is_ignored` calls this | ||
| /// path deliberately, so `cargo test -p perry-runtime --lib` in the dev profile | ||
| /// SIGABRTed the entire test binary — a whole profile of the runtime suite was | ||
| /// un-runnable, and CI could not see it because CI runs `--release`, where | ||
| /// `debug_assert!` compiles out. | ||
| /// | ||
| /// Skipping the malformed pop is the documented behaviour in both profiles now; | ||
| /// the diagnostic is one line on stderr, emitted once per process so it cannot | ||
| /// perturb a stdout parity comparison (the | ||
| /// `report_growth_stub_skipped_below_heap_min` pattern). | ||
| #[cold] | ||
| fn report_corrupt_frame_pop(frame_handle: u64) { | ||
| use std::sync::atomic::AtomicBool; | ||
| static REPORTED: AtomicBool = AtomicBool::new(false); | ||
| if REPORTED.swap(true, Ordering::Relaxed) { | ||
| return; | ||
| } | ||
| eprintln!( | ||
| "[perry-gc] shadow-stack pop past end: frame handle {frame_handle:#x} is out of \ | ||
| range; the pop was SKIPPED (memory-safe, over-approximates the root set). This \ | ||
| usually means a caller threaded a NaN-boxed value into js_shadow_frame_pop \ | ||
| instead of the index js_shadow_frame_push returned. Reported once per process." | ||
| ); | ||
|
Comment on lines
+562
to
+567
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🌐 Web query:
💡 Result: Yes, Rust's eprintln! macro panics if writing to standard error (stderr) fails [1][2]. According to the official Rust standard library documentation, this behavior occurs because the macro is designed for reporting error and progress messages, where it is generally considered appropriate to crash if the output mechanism itself fails [1][3]. Specifically, writing to non-blocking stderr can result in an error that leads the macro to panic [1][2]. Citations:
🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "## locate files"
fd -a 'shadow_stack\.rs|temp_roots\.rs' . || true
echo
echo "## relevant sections"
for f in crates/perry-runtime/src/gc/roots/shadow_stack.rs crates/perry-runtime/src/gc/roots/temp_roots.rs; do
if [ -f "$f" ]; then
echo
echo "--- $f ---"
nl -ba "$f" | sed -n '1,120p'
echo
nl -ba "$f" | sed -n '530,590p'
fi
done
echo
echo "## extern C / c_unwind / panic settings references"
rg -n 'extern\s+"C"|c_unwind|cfg_attr\s*\(|catch_unwind|should_panic|test_no_safety' crates/perry-runtime/src/gc crates/perry-runtime/src -g '*.rs' | head -200
echo
echo "## module/crate panic behavior"
rg -n 'panic!|expect\(|unwrap\(|assert!|catch_unwind|unwind|panic=' Cargo.toml crates/perry-runtime -g 'Cargo.toml' -g '*.rs' | head -250Repository: PerryTS/perry Length of output: 492 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "## relevant sections with cat -n"
for f in crates/perry-runtime/src/gc/roots/shadow_stack.rs crates/perry-runtime/src/gc/roots/temp_roots.rs; do
if [ -f "$f" ]; then
echo
echo "--- $f ---"
cat -n "$f" | sed -n '1,120p'
cat -n "$f" | sed -n '530,590p'
fi
done
echo
echo "## extern C / ABI panic references"
rg -n 'extern\s+"C"|extern "C"|c_unwind|cfg_attr\s*\(|catch_unwind|should_panic|test_no_safety' crates/perry-runtime/src/gc crates/perry-runtime/src -g '*.rs' | head -250 || true
echo
echo "## panic-related references"
rg -n 'panic!|expect\(|unwrap \(\)|assert!|catch_unwind|unwind|panic=' Cargo.toml crates/perry-runtime -g 'Cargo.toml' -g '*.rs' | head -300 || true
echo
echo "## locate C function exports"
rg -n '#\[no_mangle\]|\bjs_.*\(.*\)\s*{|pub extern "C"\s*fn|pub unsafe extern "C"\s*fn' crates/perry-runtime/src/gc crates/perry-runtime/src -g '*.rs' | head -300 || true
echo
echo "## deterministic read: source lines for print macros in target files"
python3 - <<'PY'
from pathlib import Path
for name in ["crates/perry-runtime/src/gc/roots/shadow_stack.rs", "crates/perry-runtime/src/gc/roots/temp_roots.rs"]:
p = Path(name)
print(f"\n--- {name} ---")
for i,line in enumerate(p.read_text(errors="replace").splitlines(), start=1):
if "eprintln!" in line or "extern" in line.lower() or "no_mangle" in line:
print(f"{i}: {line}")
PYRepository: PerryTS/perry Length of output: 50371 🌐 Web query:
💡 Result: The Rust Citations:
Make both C-ABI diagnostics non-panicking.
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| /// Update slot `idx` in the current frame with `value`. | ||
| /// Codegen emits this at safepoints for each live pointer-typed | ||
| /// local, and for the `value = 0` "local is dead from here" clear. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -751,7 +751,10 @@ fn test_old_page_defrag_re_remembers_young_child_after_collection_clear() { | |
| } | ||
|
|
||
| let _reset = ResetGcTestState; | ||
| let _scan = ConservativeScanAutoGuard::new(); | ||
| // This test verifies objects it holds only as native-stack locals are | ||
| // COLLECTED, so the native scan must not rescue them. `Disabled` says that | ||
| // unconditionally; the default `Auto` merely resolves that way today. | ||
| let _scan = ConservativeScanDisabledGuard::new(); | ||
|
Comment on lines
+754
to
+757
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Reload This guard enables relocation. The test dereferences Reload the child through a rewritten root or the relocated parent slot before the mark assertion. Otherwise the test can inspect reclaimed from-space. 🤖 Prompt for AI AgentsSource: Learnings |
||
| let _isolation = copying_nursery_isolation_lock(); | ||
| let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); | ||
| let _force = EnvVarGuard::set("PERRY_GC_FORCE_EVACUATE", "1"); | ||
|
|
@@ -1142,6 +1145,13 @@ fn test_minor_skips_whole_heap_old_to_young_rebuild() { | |
| /// `restore_surviving_dirty_coverage`. This is the under-remembering | ||
| /// (use-after-free) guard for Fix 2: if any minor dropped the edge, the | ||
| /// child would be swept and the RS root mark below would not reach it. | ||
| /// | ||
| /// The loop tracks the child through the parent's SLOT, never through the | ||
| /// pre-collection Rust local. With the conservative native-stack scan off — | ||
| /// precise roots only, which is what production runs — the copying minor is | ||
| /// eligible and RELOCATES the child on every cycle, rewriting the parent's | ||
| /// slot. A test that re-read the raw local would be reading a stale address, | ||
| /// which is the exact defect class this suite exists to catch. | ||
| #[test] | ||
| fn test_minor_preserves_old_to_young_edge_across_minors() { | ||
| let _isolation = copying_nursery_isolation_lock(); | ||
|
|
@@ -1172,8 +1182,10 @@ fn test_minor_preserves_old_to_young_edge_across_minors() { | |
| other_old.push(h); | ||
| } | ||
|
|
||
| let child = crate::arena::arena_alloc_gc(40, 8, GC_TYPE_OBJECT) as usize; | ||
| let child_header = unsafe { header_from_user_ptr(child as *const u8) }; | ||
| // Re-derived from the parent's slot after every minor (see the loop), so | ||
| // both are `mut`: a relocating minor moves the child. | ||
| let mut child = crate::arena::arena_alloc_gc(40, 8, GC_TYPE_OBJECT) as usize; | ||
| let mut child_header = unsafe { header_from_user_ptr(child as *const u8) }; | ||
| assert!(crate::arena::pointer_in_nursery(child)); | ||
| unsafe { | ||
| *fields = ptr_bits(child); | ||
|
|
@@ -1185,23 +1197,58 @@ fn test_minor_preserves_old_to_young_edge_across_minors() { | |
| "barrier must record the old→young edge" | ||
| ); | ||
|
|
||
| // Cycles that actually exercised the old→young RS edge. Asserted non-zero | ||
| // below so the loop cannot go vacuous if the child is tenured early. | ||
| let mut rs_covered_cycles = 0; | ||
|
|
||
| for cycle in 0..4 { | ||
| let trace = collect_minor_trace(GcTriggerKind::Direct); | ||
| assert_eq!( | ||
| trace.old_to_young_rebuild_objects_scanned, 0, | ||
| "cycle {cycle}: minor must skip the whole-heap RS rebuild" | ||
| ); | ||
|
|
||
| // Follow the edge through the parent's SLOT — the authoritative root — | ||
| // rather than through the pre-collection local. A relocating minor | ||
| // moves the child and rewrites this slot; that rewrite is the property | ||
| // under test, not a nuisance. | ||
| let slot = unsafe { *fields }; | ||
| let slot_child = (slot & POINTER_MASK) as usize; | ||
| assert_eq!( | ||
| slot, | ||
| ptr_bits(slot_child), | ||
| "cycle {cycle}: the parent's slot must still hold a tagged pointer" | ||
| ); | ||
| if trace.copying_nursery.copied_objects == 0 && trace.copying_nursery.promoted_objects == 0 | ||
| { | ||
| assert_eq!( | ||
| slot_child, child, | ||
| "cycle {cycle}: a minor that moved nothing must leave the parent's slot intact" | ||
| ); | ||
| } | ||
| child = slot_child; | ||
| child_header = unsafe { header_from_user_ptr(child as *const u8) }; | ||
|
|
||
| if !crate::arena::pointer_in_nursery(child) { | ||
| // The child aged out: a relocating minor TENURED it into the old | ||
| // gen. The edge is now old→old, so the remembered set correctly | ||
| // retires it and RS coverage no longer applies. The | ||
| // under-remembering property still has to hold, and here it means | ||
| // the child is live old-gen memory rather than swept. | ||
| assert!( | ||
| crate::arena::pointer_in_old_gen(child), | ||
| "cycle {cycle}: a child that left the nursery must be in the old gen, \ | ||
| not swept out from under the parent's slot" | ||
| ); | ||
| break; | ||
| } | ||
|
|
||
| // The child (reachable only via the old parent) must still be covered | ||
| // by the remembered set: RS root marking reaches and marks it. | ||
| assert!( | ||
| remembered_set_size() > 0, | ||
| "cycle {cycle}: old→young edge must survive the minor" | ||
| ); | ||
| assert_eq!( | ||
| unsafe { *fields }, | ||
| ptr_bits(child), | ||
| "cycle {cycle}: non-moving minor must leave the parent's slot intact" | ||
| ); | ||
| clear_marks(); | ||
| let valid_ptrs = build_valid_pointer_set(); | ||
| let stats = mark_remembered_set_roots(&valid_ptrs); | ||
|
|
@@ -1219,8 +1266,15 @@ fn test_minor_preserves_old_to_young_edge_across_minors() { | |
| // from the remembered set alone (not a stale mark). | ||
| (*child_header).gc_flags &= !GC_FLAG_MARKED; | ||
| } | ||
| rs_covered_cycles += 1; | ||
| } | ||
|
|
||
| assert!( | ||
| rs_covered_cycles >= 2, | ||
| "the loop must actually exercise the old→young edge across repeated \ | ||
| minors; only {rs_covered_cycles} cycle(s) did" | ||
| ); | ||
|
|
||
| unsafe { | ||
| (*parent_header).gc_flags &= !GC_FLAG_PINNED; | ||
| for h in other_old { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Update the related test-helper comment.
ScopedRootScannerRegistryGuard::newstill says that itsAutooverride opts out of a test-onlyFulldefault. This change removes that default. Keep the override if it establishes test isolation, but describe its current purpose.🤖 Prompt for AI Agents