Skip to content

gc: whole-heap from-space scan, independent of the rewrite pass (#7035) - #7041

Merged
proggeramlug merged 1 commit into
mainfrom
fix/7022-moving-minor-precise-root-gap
Jul 30, 2026
Merged

gc: whole-heap from-space scan, independent of the rewrite pass (#7035)#7041
proggeramlug merged 1 commit into
mainfrom
fix/7022-moving-minor-precise-root-gap

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

What

Adds PERRY_GC_FROMSPACE_SCAN — a debug-only, whole-heap verification pass that answers, after the copying minor's rewrite pass and before from-space is reset:

Does any live object outside from-space still contain a word that decodes to a from-space address?

This lands on its own merit, independent of #7022. It is not a fix for that crash and does not depend on it.

Why — we now have four measurement instruments with documented holes

instrument hole
PERRY_GC_TRACE SIGSEGVs on the corpus row that most needs it (#7018)
gc_repsel_matrix.sh --pressure disables the path under test (#7024)
matrix moved= counter sums C4b mark-sweep with the copying minor (#7025)
PERRY_GC_VERIFY_EVACUATION can only check the rewrite pass against itself (#7035)

The last one is what this PR addresses. The existing verifier walks the same surfaces the rewrite pass walksvisit_shadow_stack_root_slots, visit_global_root_slots, and the registered side-table scanners. Both derive from one enumeration, so a holder the rewrite pass never knew about is invisible to the verifier too. It is a self-consistency check, not independent evidence.

That is not hypothetical: on the #7022 reproducer the verifier reports clean while the program dies of malloc-heap corruption. I read "verifier clean" as evidence against a missing rewrite and spent several bisect cycles on other hypotheses because of it. A verification tool that silently under-reports is worse than one that is absent.

How

Walks the whole heap — the arena census (Address-order ArenaObjectCursor, covering Eden / both survivor semispaces / old / longlived) plus the malloc-tracked registry — and decodes every payload word through decode_root_word, the decoder the mark and rewrite paths already share (#6910). Objects that are themselves in from-space are skipped: a dead object legitimately still points at its dead peers, and on the #7022 reproducer from-space holds tens of thousands of them.

Offenders are classified along two axes, because they have different fixes:

  • missing rewrite (target carries GC_FLAG_FORWARDED — it moved and this reference was not updated) vs dangling (target was never evacuated and is about to be recycled);
  • remembered-set coverage of the slot's page: never_dirty (a store path skipped the write barrier), lost_dirty (the edge was recorded and then lost), dirty_but_missed (the page is dirty and the slot was still missed). This reuses the existing EVER_DIRTY_OLD_PAGES tracking, whose own comment already calls this "the decisive split for the missing old→young edge bug"; it is now enabled by this scan as well as by PERRY_GC_VERIFY_EVACUATION.

PERRY_GC_FROMSPACE_SCAN_ABORT=1 aborts on the first offender so a crash report captures the offending cycle rather than the downstream corruption.

O(live heap) per collection, so it is strictly opt-in and off by default. New module rather than an addition to copying.rs, which is close to the 2000-line check_file_size.sh cap.

It works

On the #7022 reproducer it is clean on the first collection and then names the holders:

[gc-fromspace-scan clean]     objects=4614  missing_rewrites=0     dangling=0     owners=0
[gc-fromspace-scan OFFENDERS] objects=17868 missing_rewrites=20480 dangling=0     owners=7  | never_dirty=0 lost_dirty=0     dirty_but_missed=20480
[gc-fromspace-scan OFFENDERS] objects=31733 missing_rewrites=35328 dangling=5635  owners=15 | never_dirty=5 lost_dirty=18380 dirty_but_missed=22578
...
  owner=0x…0e9a0 type=1 space=Old +144 nanbox -> 0x…9a740 (NurseryEden) MISSING-REWRITE (target moved) [slot dirty_now=false ever_dirty=true]

i.e. old-gen arrays (GC_TYPE_ARRAY) whose element slots still point at evacuated nursery objects, with lost_dirty dominating — old→young edges that were recorded and then dropped. The evacuation verifier reports clean on the same runs.

Corroboration: gc::tests::copying::large_object_old_born_array_slot_write_keeps_young_child_alive is already red on main and asserts exactly this invariant.

Validation

  • cargo test -p perry-runtime fromspace_scan — 3 passed.
  • cargo test -p perry-runtime gc:: — no new failures. Two pre-existing failures verified red at pristine main in the same worktree first: copying::large_object_old_born_array_slot_write_keeps_young_child_alive (real, and related — see above) and the teardown::map_set_* family (flaky; a different subset fails per run).
  • cargo fmt -p perry-runtime clean. (cargo fmt --all --check is red on main for perry-codegen/src/loop_purity.rs, untouched here.)

Teeth, verified in both directions

Three unit tests: the scan finds a planted un-rewritten old→young reference; it stops reporting once the reference is removed; it separates dangling from missing-rewrite; and it ignores references held by from-space objects. Assertions are deltas, since a test process shares one thread-local heap.

Teeth verified by sabotage rather than assumed — blinding the from-space predicate turns two of the three red with the exact signature the real defect would produce:

the scan must report a planted un-rewritten old->young reference (baseline missing_rewrites=0, planted=0)

The third asserts absence and correctly still passes under sabotage.

Refs #7035, #7022.

Summary by CodeRabbit

  • New Features

    • Added an optional debug-only garbage collection scan that detects stale references to evacuated or unevacuated objects.
    • Reports whether issues result from missing rewrites, dangling references, or remembered-set coverage gaps.
    • Supports aborting on the first detected issue for easier debugging.
  • Tests

    • Added coverage for detecting, classifying, clearing, and ignoring invalid references during collection.

PERRY_GC_VERIFY_EVACUATION walks the same surfaces the rewrite pass walks, so
it can only check that the rewrite pass agreed with itself: a holder the
rewrite pass never enumerated is invisible to both. On the #7022 reproducer it
reports clean while the program dies of malloc-heap corruption.

Add PERRY_GC_FROMSPACE_SCAN, which asks a question that depends on no root
enumeration: after the copying minor rewrite pass and before from-space is
reset, does any live object OUTSIDE from-space still contain a word that
decodes to a from-space address? It walks the arena census plus the
malloc-tracked registry and decodes every payload word through
decode_root_word, the decoder the mark and rewrite paths already share.
From-space objects are skipped -- a dead object legitimately still points at
its dead peers.

Offenders are split along two axes with different fixes: missing rewrite
(target is FORWARDED -- it moved and the reference was not updated) vs
dangling (target was never evacuated); and remembered-set coverage of the
slot page -- never_dirty (a store path skipped the barrier), lost_dirty (the
edge was recorded then lost), dirty_but_missed. The coverage split reuses the
existing EVER_DIRTY_OLD_PAGES tracking, now enabled by this scan too.

Debug-only and off by default (O(live heap) per collection).
PERRY_GC_FROMSPACE_SCAN_ABORT=1 aborts on the first offender.

Teeth verified by sabotage: blinding the from-space predicate turns two of the
three unit tests red with the exact planted=0 signature.

Claude-Session: https://claude.ai/code/session_01G4k1vE6PVb53m2dRZ2aDtv
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an opt-in whole-heap from-space scan to the copying minor GC, including remembered-set diagnostics, offender reporting, abort support, integration before from-space reset, documentation, and targeted tests.

Changes

From-space scan verification

Layer / File(s) Summary
Scan contracts and diagnostic tracking
crates/perry-runtime/src/gc/fromspace_scan.rs, crates/perry-runtime/src/gc/barrier.rs, crates/perry-runtime/src/gc/mod.rs
Defines scan reports, environment controls, from-space detection, and current/historical dirty-page queries.
Heap traversal and offender reporting
crates/perry-runtime/src/gc/fromspace_scan.rs
Scans arena and malloc-tracked objects, decodes payload words, classifies missing rewrites versus dangling references, records samples, and emits or aborts on reports.
GC integration and validation
crates/perry-runtime/src/gc/copying.rs, crates/perry-runtime/src/gc/tests/*, changelog.d/7041-gc-whole-heap-fromspace-scan.md
Runs the scan after rewriting and before reset, with tests covering planted stale references, dangling targets, cleanup, and from-space owners; documents the feature.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CopyingMinorGC
  participant FromSpaceScan
  participant BarrierTracking
  participant HeapObjects
  CopyingMinorGC->>FromSpaceScan: run_fromspace_scan()
  FromSpaceScan->>HeapObjects: scan arena and malloc-tracked objects
  HeapObjects-->>FromSpaceScan: payload words
  FromSpaceScan->>BarrierTracking: query dirty-page coverage
  FromSpaceScan-->>CopyingMinorGC: emit clean or OFFENDERS report
Loading

Possibly related issues

  • Issue 7035 — Directly tracks the whole-heap, rewrite-independent from-space verifier implemented here.

Possibly related PRs

  • PerryTS/perry#6831 — Extends the same ever-dirty old-page diagnostics used by this scan.
  • PerryTS/perry#6929 — Introduces shared root-word parsing consumed by the new payload scan.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly names the new whole-heap from-space scan and its independence from the rewrite pass.
Description check ✅ Passed The description is detailed and covers purpose, changes, related issue, and validation, though it does not follow the template headings verbatim.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/7022-moving-minor-precise-root-gap

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with 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.

Inline comments:
In `@crates/perry-runtime/src/gc/fromspace_scan.rs`:
- Around line 267-273: Update report_and_abort to call std::process::abort()
after emit_report(report, "abort") instead of panicking, ensuring
PERRY_GC_FROMSPACE_SCAN_ABORT always terminates unconditionally.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4160590e-7105-4e20-82da-541af9a5b654

📥 Commits

Reviewing files that changed from the base of the PR and between 926c11f and 3ea0380.

📒 Files selected for processing (7)
  • changelog.d/7041-gc-whole-heap-fromspace-scan.md
  • crates/perry-runtime/src/gc/barrier.rs
  • crates/perry-runtime/src/gc/copying.rs
  • crates/perry-runtime/src/gc/fromspace_scan.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/tests/fromspace_scan.rs
  • crates/perry-runtime/src/gc/tests/mod.rs

Comment on lines +267 to +273
fn report_and_abort(report: &FromSpaceScanReport) -> ! {
emit_report(report, "abort");
panic!(
"gc from-space scan: {} missing rewrite(s), {} dangling reference(s) survived the rewrite pass",
report.missing_rewrites, report.dangling
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether this GC cycle can run underneath a catch_unwind boundary,
# and whether the workspace uses panic="abort" anywhere.
rg -n 'panic\s*=\s*"abort"' -g '*.toml'
rg -n 'catch_unwind' --type rust

Repository: PerryTS/perry

Length of output: 151


🏁 Script executed:

#!/bin/bash
set -u

echo "Tracked GC fromspace_scan files:"
git ls-files | rg '(^|/)fromspace_scan\.rs$|(^|/)change|CHANGE|changelog|from-space|from_space' || true

echo
echo "File size and relevant context:"
wc -l crates/perry-runtime/src/gc/fromspace_scan.rs
sed -n '1,80p' crates/perry-runtime/src/gc/fromspace_scan.rs
sed -n '240,290p' crates/perry-runtime/src/gc/fromspace_scan.rs

echo
echo "Search for reported symbol/context:"
rg -n 'report_and_abort|FromSpaceScanReport|missing_rewrites|dangling|PERRY_GC_FROMSPACE_SCAN_ABORT|from-space scan|from_space_scan|emit_report|std::process::abort|panic!' crates/perry-runtime/src --type rust || true

Repository: PerryTS/perry

Length of output: 27242


🏁 Script executed:

#!/bin/bash
set -u

echo "Relevant from-space scan call site:"
sed -n '180,210p' crates/perry-runtime/src/gc/fromspace_scan.rs
sed -n '1180,1210p' crates/perry-runtime/src/gc/copying.rs
sed -n '70,90p' crates/perry-runtime/src/gc/mod.rs

echo
echo "Runtime panic settings across toml files:"
rg -n 'panic\s*=' . --glob '*.toml' || true

echo
echo "panic behavior in the codebase (excluding generated/dist/build likely dirs):"
rg -n 'std::process::abort\(|panic!' crates/perry-runtime/src --type rust | rg 'perry-runtime/src/(gc|exception|process|typed_feedback|object/field_get_set|process/env_misc|abi_trampoline).*:|^crates/perry-runtime/src/gc/fromspace|crates/perry-runtime/src/gc/copying:|^crates/perry-runtime/src/gc/verify:' || true

echo
echo "Focused Rust unwind/catch_unwind search in runtime:"
rg -n 'catch_unwind|AssertUnwindSafe|LocalHook|panic|Panic' crates/perry-runtime/src --type rust || true

Repository: PerryTS/perry

Length of output: 24545


🏁 Script executed:

#!/bin/bash
set -u

echo "Top-level Cargo profile settings:"
sed -n '80,105p' Cargo.toml
sed -n '215,230p' crates/perry-runtime/Cargo.toml

echo
echo "gc policy panic settings around comment:"
sed -n '300,325p' crates/perry-runtime/src/gc/mod.rs

echo
echo "scan_heap_for_fromspace_refs callers:"
rg -n 'scan_heap_for_fromspace_refs|run_fromspace_scan|PERRY_GC_FROMSPACE_SCAN' --type rust .

Repository: PerryTS/perry

Length of output: 5346


Use an unconditional abort for PERRY_GC_FROMSPACE_SCAN_ABORT.

report_and_abort() calls panic!() while the GC docs say this flag should always cause a debugger/crash-reportable abort. Since the release profile uses panic = "unwind", any unwind boundary above this diagnostics path can swallow the panic; use std::process::abort() after emitting the report to preserve the intended behavior.

🛠️ Proposed fix
 fn report_and_abort(report: &FromSpaceScanReport) -> ! {
     emit_report(report, "abort");
-    panic!(
+    eprintln!(
         "gc from-space scan: {} missing rewrite(s), {} dangling reference(s) survived the rewrite pass",
         report.missing_rewrites, report.dangling
     );
+    std::process::abort();
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn report_and_abort(report: &FromSpaceScanReport) -> ! {
emit_report(report, "abort");
panic!(
"gc from-space scan: {} missing rewrite(s), {} dangling reference(s) survived the rewrite pass",
report.missing_rewrites, report.dangling
);
}
fn report_and_abort(report: &FromSpaceScanReport) -> ! {
emit_report(report, "abort");
eprintln!(
"gc from-space scan: {} missing rewrite(s), {} dangling reference(s) survived the rewrite pass",
report.missing_rewrites, report.dangling
);
std::process::abort();
}
🤖 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-runtime/src/gc/fromspace_scan.rs` around lines 267 - 273, Update
report_and_abort to call std::process::abort() after emit_report(report,
"abort") instead of panicking, ensuring PERRY_GC_FROMSPACE_SCAN_ABORT always
terminates unconditionally.

@proggeramlug
proggeramlug merged commit f9b92f8 into main Jul 30, 2026
6 checks passed
@proggeramlug
proggeramlug deleted the fix/7022-moving-minor-precise-root-gap branch July 30, 2026 05:47
proggeramlug added a commit that referenced this pull request Jul 30, 2026
…ce offenders by snapshot coverage (#7043)

Two diagnostics salvaged from the #7022 investigation, both landable on their own.

1. js_array_grow skips installing the growth forwarding stub when the array sits
   below the platform heap floor. That is an address-conditional silent
   divergence: behaviour depends on where the allocator happened to place memory,
   with no signal. Now a debug_assert plus a once-per-process stderr line.

   This has already cost debugging time. An experiment replacing arena blocks
   with mmap'd guard-paged blocks used a NULL hint, landed below macOS's 2 TB
   floor, silently disabled every growth stub, and came back falsely clean -- it
   was only caught by re-running with a high MAP_FIXED hint.

2. The from-space scan (#7041) now splits offenders by whether their page was in
   this cycle's dirty snapshot:
     not_in_snapshot -- the in-cycle remembered-set scan never looked at the page
     in_snapshot     -- the scan looked and still did not rewrite the slot

   That split is what established #7022's failure as in-cycle, rather than an
   edge recorded and later dropped.

Refs #7022, #7041.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Follow-up from #7022 (fixed in #7050): on that reproducer the scan's offender population is ~99% structural noise, and the lost_dirty / dirty_but_missed split it produced pointed the investigation at a defect that does not exist.

I extended scan_object with two extra axes and re-ran test_gap_repsel_gc_stress under the evacuating base:

  • is the offender's owner itself GC_FLAG_FORWARDED?
  • is the offending slot inside the collector's own rewrite enumeration for that owner (visit_gc_rewrite_slots)?
[gc-fromspace-scan OFFENDERS] missing_rewrites=2054  dangling=0  owners=3 | never_dirty=6 lost_dirty=0 dirty_but_missed=2048
[...       split] live_owner_refs=6 (enumerated=0 not_enumerated=6 unaligned=0) live_owners=2 stub_owner_refs=2048 stub_owners=1 stub_forwarding_words=0
[gc-fromspace-scan OFFENDERS] missing_rewrites=24799 dangling=1882 owners=66 | never_dirty=57 lost_dirty=1915 dirty_but_missed=24709
[...       split] live_owner_refs=57 (enumerated=0 not_enumerated=57 unaligned=47) live_owners=57 stub_owner_refs=26624 stub_owners=9 stub_forwarding_words=0

enumerated=0 on every cycle of every run. Three classes, none of them a reference any collector surface reads:

  1. Payload of a GC_FLAG_FORWARDED record — 2 048 → ~70 000 words/cycle, 99%+ of the total. These are js_array_grow's permanent growth stubs (Array.push from inside an async function silently caps at 16 elements when the array is a function parameter #233). A forwarded header's payload is dead storage by construction: visit_gc_rewrite_slot_descriptors, gc_child_slots, scan_dirty_header_once and trace_one_worklist_header all return early on it; only word 0 (the forwarding address) is live, and stub_forwarding_words=0 throughout — no forwarding word ever pointed into from-space. Because the stub's pages stay dirty forever and its slots are never rescanned, these are exactly what produced dirty_but_missed, and then lost_dirty on later cycles.
  2. Uninitialized bytes inside an array's unused capacity — arena blocks are recycled, not zeroed, and move_young copies the whole payload, so e.g. a len=1 cap=16 array carries 15 slots of the previous occupant's bytes into to-space. Dumping an offending owner shows a packed run of plausible GcHeaders (obj_type=3, gc_flags=0x82) and their forwarding words, two cycles stale.
  3. Unaligned wordsdecode_root_word accepts any 48-bit value; the copying collector's own CopyingPointerSet::decode_bits additionally requires 8-byte alignment, so these can never be relocated and can never be stale.

Suggested change: keep the whole-heap walk (that part works and is the point of #7035), but

  • for a GC_FLAG_FORWARDED owner, check only word 0 and skip the rest of the payload;
  • report the enumerated / not_enumerated and owner_forwarded / live-owner split alongside the headline counts, so "offenders" names the population that is unambiguously a collector defect.

With that split, the population that is a defect — live owner, enumerated slot — reads zero on this reproducer both before and after #7050, which is the measurement that eliminated the missing-rewrite hypothesis and sent the investigation to Arena::alloc.

Deliberately not folded into #7050: it would have invalidated that PR's matrix run, and a measurement-tool change does not belong in the same diff as a collector fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant