Skip to content

fix(gc): restore safe old-page relocation - #7913

Merged
proggeramlug merged 2 commits into
mainfrom
fix/7876-old-defrag-contract
Aug 12, 2026
Merged

fix(gc): restore safe old-page relocation#7913
proggeramlug merged 2 commits into
mainfrom
fix/7876-old-defrag-contract

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Restores the rewrite contract required for old-generation page defragmentation and enables it by default, with PERRY_GC_OLD_DEFRAG=0 as an explicit rollback switch. The core collector fix evacuates every indexed occupant of a selected source block atomically; a minor trace cannot treat an unmarked old-generation neighbor as dead.

Changes

  • make old-page evacuation source-block complete and all-or-nothing, rejecting a block before any forwarding if an occupant is pinned, conservatively pinned, invalid, or non-movable
  • add rewrite coverage for the JSON parse-key ring, the perf entry keys cache, and diagnostics symbol-keyed state
  • make cached @perry_class_keys_* local copies precise function-lifetime mutable roots, and remove the now-invalid class-key immovability exemption from the static gate
  • fail the runtime-holder inventory on open_gap or unverified movable-address contracts
  • enable old-page relocation by default with a kill switch
  • defer page snapshot/selection until the copying fast path declines the minor, avoiding an O(old pages) charge on ordinary copying collections
  • add direct red/green holder tests, class-key IR tests, source-block neighbor/pinning tests, and a mixed-size block-reclamation ratchet

Related issue

Fixes #7876

Test plan

  • cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static on the quiet M1 mini
  • cargo test -p perry-runtime -- --test-threads=1 — 2,159 passed, 4 ignored
  • cargo test -p perry-codegen — 903 unit tests plus integration suites passed
  • GC root-dominance self-test and immovable-source audit
  • runtime-root-holder self/live checks and GC env-knob self/live checks
  • cargo fmt --all -- --check, git diff --check, and bash scripts/check_file_size.sh
  • Historical GC evacuation leaves a stale reference to a moved array element (wild-pointer crash / corrupt cached value) #6206 workload: rebuilt main with relocation enabled fails with TypeError: Cannot convert object to primitive value; rebuilt fix passed 6/6 runs with evacuation verification enabled, each stdout byte-identical to the clean main control
  • 25-program M1 corpus, rebuilt main/fix compiler-runtime pairs, byte-identical output, best-of-15 interleaved: the initial 8.29% retain_wide regression was removed by deferring selection; final result +0.14% best / -0.09% median. Four tight shape/class-allocation kernels are +3.70% to +4.45% from the required class-key mutable root; iso_miss is +1.44%, interp is -2.85%, and all other programs are within +/-1.3% best-time delta.

The dependency-scale witness compiled the same 81 modules / 13.5 MB IR in both main and fix arms on the mini, but both hit the same existing missing-zod-export link failure before runtime. This branch therefore has no dependency-scale runtime result; the PR gate with a full npm ci environment remains authoritative.

Screenshots / output

Not applicable.

Checklist

  • I have NOT bumped the workspace version or edited CLAUDE.md / CHANGELOG.md (maintainer handles these at merge)
  • My commits follow the loose feat: / fix: / docs: / chore: prefix convention used in the log
  • I've read CONTRIBUTING.md and agree to the Code of Conduct

Summary by CodeRabbit

  • New Features

    • Old-generation defragmentation is now enabled by default.
    • Defragmentation safely evacuates eligible memory blocks and preserves pinned or non-movable objects.
    • Added an environment setting to disable old-generation defragmentation when needed.
  • Bug Fixes

    • Improved garbage-collection updates for relocated cached keys and runtime roots.
    • Reduced unnecessary metadata work during routine minor collections.
    • Improved recovery from fragmented memory and reclamation of fragmented blocks.
  • Tests

    • Added coverage for relocation safety, rollback behavior, root updates, and fragmentation recovery.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Old-generation defragmentation is enabled by default and evacuates complete source blocks safely. Code generation roots movable class-key globals. Runtime caches rewrite forwarded addresses. GC inventory checks now reject unresolved movable-address gaps.

Changes

Old-generation defragmentation contract

Layer / File(s) Summary
Rooted class-key lowering
crates/perry-codegen/src/expr/..., crates/perry-codegen/src/lower_call/..., crates/perry-codegen/src/testing/..., crates/perry-codegen/tests/...
Class-key globals use rooted entry initialization. Shadow and native root tests verify initialization order and root representation. Temporary-root analysis excludes class-key cache roots by provenance.
Block-safe old-generation evacuation
crates/perry-runtime/src/gc/..., crates/perry-runtime/src/arena/...
Defragmentation selection runs after the copying-minor fast path declines. Evacuation validates and moves every occupant in a source block only when all occupants are movable and unpinned. Tests cover pinned blocks, mixed-size fragmentation, reclamation, and metadata-call avoidance.
Runtime movable-root rewriting
crates/perry-runtime/src/json/mod.rs, crates/perry-runtime/src/perf_hooks.rs, crates/perry-runtime/src/node_submodules/..., crates/perry-runtime/src/gc/tests/runtime_roots/...
JSON parse-key caches, performance-entry keys, and diagnostic symbol lookup keys are rewritten after forwarding. Runtime tests verify each address update.
Movable-address inventory enforcement
scripts/gc_root_dominance_check.py, scripts/gc_runtime_root_holders.*
Class-key immovability exemptions and old-defrag override handling are removed. Unresolved open_gap and unverified holders now fail inventory validation.
Default policy and changelog
crates/perry-runtime/src/gc/oldgen_defrag.rs, changelog.d/7913-old-defrag-contract.md
Defragmentation is enabled unless PERRY_GC_OLD_DEFRAG is 0, off, or false. The changelog records the rewrite contract and performance behavior.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MinorGC
  participant OldDefrag
  participant RuntimeRoots
  participant Cache
  MinorGC->>OldDefrag: select and evacuate movable old-page blocks
  OldDefrag->>RuntimeRoots: provide forwarded addresses
  RuntimeRoots->>Cache: rewrite cached object addresses
  Cache-->>MinorGC: retain valid relocated references
Loading

Possibly related PRs

  • PerryTS/perry#7443: Extends the same old-generation defragmentation and evacuation paths.
  • PerryTS/perry#7235: Introduces related class-key movable-address analysis and exemption changes.
  • PerryTS/perry#7695: Provides the runtime GC-holder inventory and validation gate updated here.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address the rewrite contract, safe evacuation, movable-address inventory, default defragmentation, reclamation metrics, and regression coverage required by [#7876].
Out of Scope Changes check ✅ Passed The code, tests, inventory updates, runtime fixes, and changelog entry directly support the linked issue objectives.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Title check ✅ Passed The title clearly summarizes the main change: restoring safe old-generation page relocation.
Description check ✅ Passed The description includes all required sections, explains the changes, links issue #7876, and documents detailed verification results.
✨ 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/7876-old-defrag-contract

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.

@proggeramlug
proggeramlug marked this pull request as ready for review August 12, 2026 05:28
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audited and merging. The validation is the strongest in this campaign for the risk class: the historical #6206 workload rebuilt on main with relocation force-enabled reproduces the original TypeError, and the fix passes it 6/6 under evacuation verification with byte-identical stdout; the 25-program corpus is byte-identical with the perf ledger honest in both directions (the 8.29% regression found and engineered away via deferred selection; the residual +3.7–4.5% on four class-allocation kernels named as the price of the REQUIRED class-key mutable root). The exemption edits all tighten: open_gap/unverified holder verdicts now fail while relocation ships, and the class-key immovability exemption is deleted from the static gate rather than widened.

One policy clock starts at this merge, per CLAUDE.md's GC knob kill-policy: PERRY_GC_OLD_DEFRAG=0 is a rollback switch whose OFF state has no required CI arm. Either an arm lands (a matrix row or gc-stress variant exercising =0), or the switch should be deleted after one release of soak — a mode that still exists is a decision that hasn't been made.

@proggeramlug
proggeramlug merged commit 4e510d5 into main Aug 12, 2026
12 of 19 checks passed
@proggeramlug
proggeramlug deleted the fix/7876-old-defrag-contract branch August 12, 2026 05:33

@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: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/perry-runtime/src/gc/tests/oldgen.rs (1)

491-518: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Two defrag tests call evacuate_selected_old_pages_collecting without suppressing GC triggers. In production this helper runs inside gc_collect_minor_with_trigger_inner, which holds GC_FLAG_IN_ALLOC for the whole cycle so a recursive gc_check_trigger bails out. Both tests call the helper directly, so that protection is absent and the per-object arena_alloc_gc_old_excluding_pages call can trigger a collection between allocating a destination and installing the forwarding address. The sibling tests at lines 558 and 947 already hold the guard.

  • crates/perry-runtime/src/gc/tests/oldgen.rs#L491-L518: add let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); after copying_nursery_isolation_lock() in test_old_page_defrag_moves_every_source_block_occupant_during_a_minor.
  • crates/perry-runtime/src/gc/tests/oldgen.rs#L639-L665: add the same guard after copying_nursery_isolation_lock() in test_old_page_defrag_skips_pinned_old_objects.
🤖 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/tests/oldgen.rs` around lines 491 - 518, Both
direct defrag tests must suppress automatic GC triggers before calling
evacuate_selected_old_pages_collecting. In
crates/perry-runtime/src/gc/tests/oldgen.rs:491-518, add a
GcTriggerThresholdTestGuard::suppress_automatic_triggers() guard after
copying_nursery_isolation_lock() in
test_old_page_defrag_moves_every_source_block_occupant_during_a_minor; make the
same change at crates/perry-runtime/src/gc/tests/oldgen.rs:639-665 in
test_old_page_defrag_skips_pinned_old_objects.

Source: Learnings

🧹 Nitpick comments (4)
crates/perry-runtime/src/gc/oldgen.rs (1)

1883-1902: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The comment and the flags != 0 gate disagree about dead occupants.

The comment states that dead old objects "remain indexed until a full trace proves them dead, so conservatively copying them here preserves the same minor-GC retention contract." The gate does the opposite: flags != 0 makes any zero-flag occupant fail source_block_is_movable, so the whole block is declined and nothing is copied.

Declining is the safe outcome, so this is not a correctness defect. The comment should describe it, because a later reader could relax flags != 0 on the belief that copying was already the intent.

📝 Suggested comment wording
-    // remain indexed until a full trace proves them dead, so conservatively
-    // copying them here preserves the same minor-GC retention contract.
+    // remain indexed until a full trace proves them dead; such an occupant has
+    // zero flags and fails the movability gate below, so the block is declined
+    // rather than partially moved. That preserves the minor-GC retention
+    // contract without copying objects a minor cannot classify.
🤖 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/oldgen.rs` around lines 1883 - 1902, Update the
comment above source block evacuation to match the existing
`source_block_is_movable` behavior: zero-flag dead occupants cause the block to
be declined rather than copied. Clarify that retaining the block is the
conservative outcome and preserve the `flags != 0` gate unchanged.
crates/perry-runtime/src/gc/tests/oldgen.rs (2)

1016-1035: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the duplicate marking loop.

Lines 1021-1025 set GC_FLAG_MARKED on every entry of live_headers. Lines 1026-1035 iterate the same vector and set the same flag again at lines 1032-1034. The second write is redundant.

Folding the page-membership assertion into the first loop makes the intent clear.

♻️ Proposed cleanup
-    for &header in &live_headers {
-        unsafe {
-            (*header).gc_flags |= GC_FLAG_MARKED;
-        }
-    }
     for &header in &live_headers {
         let total = unsafe { (*header).size as usize };
         assert!(
             old_object_pages_all_selected(header, total, &selection.pages),
             "every live fixture object must lie wholly on selected fragmented pages"
         );
         unsafe {
             (*header).gc_flags |= GC_FLAG_MARKED;
         }
     }
🤖 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/tests/oldgen.rs` around lines 1016 - 1035, Remove
the first standalone marking loop over live_headers and fold its GC_FLAG_MARKED
assignment into the existing loop containing old_object_pages_all_selected. Keep
the page-membership assertion and ensure each live header is marked exactly once
after validation.

960-962: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Express the filler size in terms of BLOCK_SIZE.

This allocation requests a total of two BLOCK_SIZE units. Replace 2 * 1024 * 1024 with 2 * crate::arena::BLOCK_SIZE so the fixture tracks allocator block sizing changes.

🤖 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/tests/oldgen.rs` around lines 960 - 962, Update
the filler allocation in the old-generation GC test around
old_pages_begin_gc_cycle to calculate its size as 2 * crate::arena::BLOCK_SIZE
minus GC_HEADER_SIZE instead of using the hardcoded 2 MiB value.
crates/perry-codegen/src/collectors/proven_this_routing_tests.rs (1)

816-831: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider scoping the ordering check to one function body.

ir.find(store) and ir.find(line) search the whole module text. emit produces several pshape clones, and SSA names restart per function, so identical instruction text can appear in more than one clone. In that case the two offsets can come from different functions and the ordering assertion no longer proves store-before-bind inside the function under test.

Using line indices from a single ir.lines().enumerate() pass, or slicing the IR to the clone's define block first, makes the check exact.

♻️ Sketch: compare line indices from one pass
-    let store_pos = ir
-        .find(store)
-        .expect("the hoisted class-keys store should be in the function");
-    let bind_pos = ir
-        .lines()
-        .find(|line| line.contains("call void `@js_shadow_slot_bind`") && line.contains(slot))
-        .and_then(|line| ir.find(line))
+    let store_pos = ir
+        .lines()
+        .position(|line| line == store)
+        .expect("the hoisted class-keys store should be in the function");
+    let bind_pos = ir
+        .lines()
+        .position(|line| {
+            line.contains("call void `@js_shadow_slot_bind`") && line.contains(slot)
+        })
         .unwrap_or_else(|| {
             panic!(
                 "the cached class-keys pointer is not a mutable shadow root; old-page moves would leave this copy stale:\n{ir}"
             )
         });
🤖 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/collectors/proven_this_routing_tests.rs` around
lines 816 - 831, Scope the ordering validation around the relevant function body
instead of searching the entire module. Update the checks using ir.find(store)
and the js_shadow_slot_bind lookup to derive store and bind positions from one
ir.lines().enumerate() pass or from the selected pshape clone’s define block,
then compare those same-function line indices so identical SSA instructions in
other clones cannot satisfy the assertion.
🤖 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-codegen/src/expr/scalar_slot_root.rs`:
- Around line 118-120: Update the fallback in the scalar-slot root handling
around reserve_shadow_slot so a None result is not accepted when native roots
are inactive and Auto scan mode would omit conservative scanning. Reject this
unsupported configuration or route the cache slot through an existing
precise-root mechanism, ensuring moving collections cannot leave the entry slot
pointing to a relocated class-key object.

In `@crates/perry-runtime/src/gc/oldgen_defrag.rs`:
- Around line 109-111: Update old_page_defrag_enabled_from_value to trim
surrounding whitespace and compare the normalized value case-insensitively
against "0", "off", and "false", preserving default-on behavior for other
values. Add coverage for "OFF", "False", and " 0 ".

In `@crates/perry-runtime/src/gc/tests/runtime_roots.rs`:
- Line 11: Set RUST_TEST_THREADS=1 in the test command environments used by
scripts/run_memory_stability_tests.sh and scripts/native_abi_evidence_packet.sh,
matching the perry-runtime CI commands. Ensure every perry-runtime test
invocation in both scripts inherits this setting.

In `@crates/perry-runtime/src/gc/tests/runtime_roots/old_defrag_contract.rs`:
- Around line 8-15: Prevent automatic collection across each forwarding
fixture’s entire from-allocation-to-set_forwarding_address sequence, including
destination allocation, so the raw from pointer remains valid until forwarding
is installed. Apply this in forwarded_string at
crates/perry-runtime/src/gc/tests/runtime_roots/old_defrag_contract.rs#L8-L15,
the fixture at `#L40-L46`, and the fixture at `#L61-L69`; use the existing
automatic-trigger suppression mechanism and preserve each fixture’s current
forwarding behavior.
- Around line 20-36: Restore thread-local cache state with panic-safe test-state
guards in all three fixtures: at
crates/perry-runtime/src/gc/tests/runtime_roots/old_defrag_contract.rs lines
20-36, ensure the guard clears both parse roots and PARSE_KEY_RING; at lines
47-58, clear PERF_ENTRY_KEYS_ARRAY after the assertion; and at lines 70-83,
clear DIAG_CHANNEL_BY_KEY after the assertion. Use guards so each cleanup runs
even when an assertion panics, and do not rely on arena-reset teardown to clear
thread-local cache scanners.

---

Outside diff comments:
In `@crates/perry-runtime/src/gc/tests/oldgen.rs`:
- Around line 491-518: Both direct defrag tests must suppress automatic GC
triggers before calling evacuate_selected_old_pages_collecting. In
crates/perry-runtime/src/gc/tests/oldgen.rs:491-518, add a
GcTriggerThresholdTestGuard::suppress_automatic_triggers() guard after
copying_nursery_isolation_lock() in
test_old_page_defrag_moves_every_source_block_occupant_during_a_minor; make the
same change at crates/perry-runtime/src/gc/tests/oldgen.rs:639-665 in
test_old_page_defrag_skips_pinned_old_objects.

---

Nitpick comments:
In `@crates/perry-codegen/src/collectors/proven_this_routing_tests.rs`:
- Around line 816-831: Scope the ordering validation around the relevant
function body instead of searching the entire module. Update the checks using
ir.find(store) and the js_shadow_slot_bind lookup to derive store and bind
positions from one ir.lines().enumerate() pass or from the selected pshape
clone’s define block, then compare those same-function line indices so identical
SSA instructions in other clones cannot satisfy the assertion.

In `@crates/perry-runtime/src/gc/oldgen.rs`:
- Around line 1883-1902: Update the comment above source block evacuation to
match the existing `source_block_is_movable` behavior: zero-flag dead occupants
cause the block to be declined rather than copied. Clarify that retaining the
block is the conservative outcome and preserve the `flags != 0` gate unchanged.

In `@crates/perry-runtime/src/gc/tests/oldgen.rs`:
- Around line 1016-1035: Remove the first standalone marking loop over
live_headers and fold its GC_FLAG_MARKED assignment into the existing loop
containing old_object_pages_all_selected. Keep the page-membership assertion and
ensure each live header is marked exactly once after validation.
- Around line 960-962: Update the filler allocation in the old-generation GC
test around old_pages_begin_gc_cycle to calculate its size as 2 *
crate::arena::BLOCK_SIZE minus GC_HEADER_SIZE instead of using the hardcoded 2
MiB value.
🪄 Autofix

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: 932d6b1b-ce38-4d77-afad-1bd12bf7d3e2

📥 Commits

Reviewing files that changed from the base of the PR and between c4b2c1c and f9f6bf3.

📒 Files selected for processing (26)
  • changelog.d/7913-old-defrag-contract.md
  • crates/perry-codegen/src/collectors/proven_this_routing_tests.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-codegen/src/expr/scalar_slot_root.rs
  • crates/perry-codegen/src/lower_call/method_override.rs
  • crates/perry-codegen/src/lower_call/new_alloc.rs
  • crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs
  • crates/perry-codegen/src/lower_call/scalar_method.rs
  • crates/perry-codegen/src/testing/temp_slots.rs
  • crates/perry-codegen/tests/temp_root_operand_temporaries.rs
  • crates/perry-runtime/src/arena/mod.rs
  • crates/perry-runtime/src/arena/page_meta.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/oldgen.rs
  • crates/perry-runtime/src/gc/oldgen_defrag.rs
  • crates/perry-runtime/src/gc/tests/copying/survival_and_malloc.rs
  • crates/perry-runtime/src/gc/tests/oldgen.rs
  • crates/perry-runtime/src/gc/tests/runtime_roots.rs
  • crates/perry-runtime/src/gc/tests/runtime_roots/old_defrag_contract.rs
  • crates/perry-runtime/src/json/mod.rs
  • crates/perry-runtime/src/node_submodules/diagnostics.rs
  • crates/perry-runtime/src/node_submodules/mod.rs
  • crates/perry-runtime/src/perf_hooks.rs
  • scripts/gc_root_dominance_check.py
  • scripts/gc_runtime_root_holders.json
  • scripts/gc_runtime_root_holders.py

Comment on lines +118 to +120
let Some(idx) = ctx.func.reserve_shadow_slot() else {
return slot;
};

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 | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the root-reservation implementation and its callers.
ast-grep outline crates/perry-codegen/src --items all --match 'reserve_shadow_slot|entry_init_load_global|entry_setup_call_void'

# Inspect the no-slot configuration and related collector-mode gates.
rg -n -C 6 '\breserve_shadow_slot\s*\(|shadow[-_ ]slot|shadow[-_ ]stack|PERRY_GC_OLD_DEFRAG|old[-_ ]defrag|relocat' crates scripts

Repository: PerryTS/perry

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- scalar_slot_root.rs ---'
cat -n crates/perry-codegen/src/expr/scalar_slot_root.rs | sed -n '1,190p'

printf '%s\n' '--- reserve_shadow_slot implementation ---'
cat -n crates/perry-codegen/src/function.rs | sed -n '350,430p'

printf '%s\n' '--- native_stack_roots_enabled definitions and call sites ---'
rg -n -C 8 'fn native_stack_roots_enabled|native_stack_roots_enabled\(' crates/perry-codegen/src crates/perry-codegen/tests

printf '%s\n' '--- scalar helper callers ---'
rg -n -C 8 'entry_init_load_rooted_global|entry_init_load_global|scalar_slot_root|class.?keys|CLASS_KEYS' crates/perry-codegen/src/expr crates/perry-codegen/src

Repository: PerryTS/perry

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- shadow-frame enablement and configuration gates ---'
rg -n -C 12 'enable_(post_init_)?shadow_frame|shadow_frame_requested|shadow_stack_enabled\(|precise_root_analysis_enabled\(|PERRY_SHADOW_STACK|PERRY_CONSERVATIVE_STACK_SCAN|PERRY_RS4GC' crates/perry-codegen/src crates/perry-runtime/src

printf '%s\n' '--- collector mode and old-page relocation gates ---'
rg -n -C 10 'old.?page|defrag|relocat|conservative.*scan|CONSERVATIVE_STACK|precise.root|moving.*GC|minor.*mov|evacuat' crates/perry-runtime/src crates/perry-codegen/src | head -n 1200

printf '%s\n' '--- class-key cache lowering and uses ---'
rg -n -C 12 'entry_init_load_rooted_global|class_keys_slots|get_class_keys|class_keys_globals' crates/perry-codegen/src/expr crates/perry-codegen/src/codegen

Repository: PerryTS/perry

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- codegen helper mode definitions ---'
cat -n crates/perry-codegen/src/codegen/helpers.rs | sed -n '1,275p'

printf '%s\n' '--- exact class-key cache call sites ---'
rg -n -C 20 'entry_init_load_rooted_global|class_keys_slots' crates/perry-codegen/src/expr --glob '*.rs'

printf '%s\n' '--- runtime conservative-scan and defrag configuration ---'
rg -n -C 8 'PERRY_CONSERVATIVE_STACK_SCAN|CONSERVATIVE_STACK_SCAN|old_page|old-page|defrag|DEFRAG|evacuating|moving.*old|old.*moving' crates/perry-runtime/src --glob '*.rs' | head -n 1000

Repository: PerryTS/perry

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- all configuration references ---'
rg -n -C 10 'PERRY_CONSERVATIVE_STACK_SCAN|conservative_stack_scan|conservative.*scan|stack_scan_enabled|old_page.*evac|evacuation_eligible|defrag_enabled|old.*defrag' . --glob '!target/**' --glob '!node_modules/**' | head -n 1600

printf '%s\n' '--- GC root scan entry points ---'
rg -n -C 12 'scan.*stack|stack.*scan|native.*root|stack_map|gcmap|GC_MAP|conservative' crates/perry-runtime/src/gc crates/perry-runtime/src --glob '*.rs' | head -n 1800

printf '%s\n' '--- relocation collection entry points ---'
rg -n -C 12 'old_pages|evacuate_old|evacuate.*old|defrag|evacuation|collect.*old|minor.*collect|copying' crates/perry-runtime/src/gc crates/perry-runtime/src/arena --glob '*.rs' | head -n 1800

Repository: PerryTS/perry

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- conservative scan decision implementation ---'
rg -n 'enum ConservativeStackScanDecision|fn conservative_stack_scan_decision|PERRY_CONSERVATIVE_STACK_SCAN|mark_stack_roots_for_decision|pin_currently_marked_as_conservative' crates/perry-runtime/src/gc --glob '*.rs'
rg -n -C 18 'enum ConservativeStackScanDecision|fn conservative_stack_scan_decision|PERRY_CONSERVATIVE_STACK_SCAN|fn mark_stack_roots_for_decision|fn pin_currently_marked_as_conservative' crates/perry-runtime/src/gc --glob '*.rs'

printf '%s\n' '--- native stack scan and conservative pin semantics ---'
rg -n -C 18 'ConservativePin|conservative.*pin|pin.*conservative|stack_roots|scan_stack' crates/perry-runtime/src/gc --glob '*.rs' | head -n 1800

printf '%s\n' '--- exact old-page evacuation eligibility decisions ---'
rg -n -C 18 'evacuation_eligible|consider_evacuation|old_page.*evac|evacuate.*page|select.*page' crates/perry-runtime/src/gc crates/perry-runtime/src/arena --glob '*.rs' | head -n 1800

Repository: PerryTS/perry

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- files defining conservative scan configuration ---'
rg -l 'ConservativeStackScanDecision|PERRY_CONSERVATIVE_STACK_SCAN|conservative_stack_scan_decision' crates/perry-runtime/src/gc
printf '%s\n' '--- files defining evacuation policy ---'
rg -l 'evacuation_eligible|conservative_pinned_bytes|consider_evacuation' crates/perry-runtime/src/gc crates/perry-runtime/src/arena

printf '%s\n' '--- concise configuration excerpts ---'
for f in $(rg -l 'ConservativeStackScanDecision|PERRY_CONSERVATIVE_STACK_SCAN|conservative_stack_scan_decision' crates/perry-runtime/src/gc); do
  echo "### $f"
  rg -n -C 20 'enum ConservativeStackScanDecision|conservative_stack_scan_decision|PERRY_CONSERVATIVE_STACK_SCAN|Scan|Skip' "$f" | head -n 220
done

printf '%s\n' '--- concise evacuation excerpts ---'
for f in $(rg -l 'evacuation_eligible|conservative_pinned_bytes|consider_evacuation' crates/perry-runtime/src/gc crates/perry-runtime/src/arena); do
  echo "### $f"
  rg -n -C 16 'evacuation_eligible|conservative_pinned_bytes|consider_evacuation|old_page_selected|pinned_bytes' "$f" | head -n 260
done

Repository: PerryTS/perry

Length of output: 19762


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- scan_mode.rs ---'
cat -n crates/perry-runtime/src/gc/roots/scan_mode.rs | sed -n '1,260p'

printf '%s\n' '--- copied-minor conservative fallback and old-page policy ---'
cat -n crates/perry-runtime/src/gc/copying.rs | sed -n '1080,1145p'
rg -n -C 25 'consider_evacuation|evacuation_policy|conservative_stack_scan_decision|conservative_pinned_bytes' crates/perry-runtime/src/gc/cycle.rs crates/perry-runtime/src/gc/oldgen.rs crates/perry-runtime/src/gc/policy.rs | head -n 1200

printf '%s\n' '--- configuration documentation and validation ---'
rg -n -C 12 'PERRY_SHADOW_STACK|PERRY_CONSERVATIVE_STACK_SCAN|PERRY_RS4GC|conservative stack scan' README.md docs crates scripts Cargo.toml .github 2>/dev/null | head -n 1600

Repository: PerryTS/perry

Length of output: 50370


Provide a precise root when reserve_shadow_slot() returns None.

With PERRY_SHADOW_STACK=0 and native roots inactive, reserve_shadow_slot() returns None. The default Auto scan mode skips the conservative stack scan, so moving collections can relocate the class-key object while the entry slot retains its old address.

Reject this configuration or use a supported precise-root mechanism for the cache slot.

🤖 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/expr/scalar_slot_root.rs` around lines 118 - 120,
Update the fallback in the scalar-slot root handling around reserve_shadow_slot
so a None result is not accepted when native roots are inactive and Auto scan
mode would omit conservative scanning. Reject this unsupported configuration or
route the cache slot through an existing precise-root mechanism, ensuring moving
collections cannot leave the entry slot pointing to a relocated class-key
object.

Source: Coding guidelines

Comment on lines +109 to +111
fn old_page_defrag_enabled_from_value(value: Option<&str>) -> bool {
!matches!(value, Some("0") | Some("off") | Some("false"))
}

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 | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Compare env-knob parsing conventions across the GC runtime.
set -euo pipefail

echo "=== PERRY_GC_* / PERRY_GEN_GC* env reads ==="
rg -nP -C4 'var(_os)?\(\s*"PERRY_' crates/perry-runtime/src --type=rust

echo
echo "=== Existing boolean-knob normalization helpers ==="
rg -nP -C4 '(to_ascii_lowercase|eq_ignore_ascii_case|\.trim\(\))' crates/perry-runtime/src --type=rust | rg -n -C4 'PERRY|env|knob|enabled'

Repository: PerryTS/perry

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== oldgen defrag parser and tests ==="
sed -n '80,155p' crates/perry-runtime/src/gc/oldgen_defrag.rs

echo
echo "=== GC boolean parser helpers ==="
rg -n -C5 'fn .*from_value|resolve_.*knob|to_ascii_lowercase|eq_ignore_ascii_case|trim\(\)' \
  crates/perry-runtime/src/gc --type rust

echo
echo "=== PERRY_GC_OLD_DEFRAG documentation and references ==="
rg -n -C3 'PERRY_GC_OLD_DEFRAG|old.?gen.*defrag|old.?page.*defrag|`#6206`' \
  --glob '!target/**' .

Repository: PerryTS/perry

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

source = Path("crates/perry-runtime/src/gc/oldgen_defrag.rs").read_text()
assert 'Some("0") | Some("off") | Some("false")' in source

def current(value):
    return value not in {"0", "off", "false"}

def normalized(value):
    return value is not None and value.strip().lower() not in {"0", "off", "false"} \
        if value is not None else True

cases = [None, "0", "off", "false", "OFF", "False", " 0 ", " unexpected "]
for value in cases:
    print(f"{value!r}: current={current(value) if value is not None else True}, normalized={normalized(value)}")
PY

echo
echo "=== Focused rollback documentation ==="
rg -n -C2 'PERRY_GC_OLD_DEFRAG|`#6206`|`#7876`' \
  docs/src/internals/garbage-collector.md \
  changelog.d/7913-old-defrag-contract.md \
  crates/perry-runtime/src/gc/oldgen_defrag.rs \
  CLAUDE.md || true

Repository: PerryTS/perry

Length of output: 2510


Normalize PERRY_GC_OLD_DEFRAG before parsing

PERRY_GC_OLD_DEFRAG=0 is the rollback switch. The matching default-on parse_promote_in_place parser trims whitespace and ignores case. Normalize this value before checking "0", "off", and "false", and test "OFF", "False", and " 0 ".

🤖 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/oldgen_defrag.rs` around lines 109 - 111, Update
old_page_defrag_enabled_from_value to trim surrounding whitespace and compare
the normalized value case-insensitively against "0", "off", and "false",
preserving default-on behavior for other values. Add coverage for "OFF",
"False", and " 0 ".

mod interned_string_caches;
mod iter_result_keys;
mod json_shape_template;
mod old_defrag_contract;

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 | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

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

rg -n --hidden \
  -g '*.yml' -g '*.yaml' -g '*.sh' -g 'Makefile' -g '*.toml' \
  'RUST_TEST_THREADS=1|cargo test.*perry-runtime|test -p perry-runtime' .

Repository: PerryTS/perry

Length of output: 2915


🏁 Script executed:

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

printf '%s\n' '--- runtime_roots.rs ---'
cat -n crates/perry-runtime/src/gc/tests/runtime_roots.rs | sed -n '1,180p'

printf '%s\n' '--- workflow runtime test contexts ---'
sed -n '750,855p' .github/workflows/test.yml
sed -n '1095,1135p' .github/workflows/test.yml

printf '%s\n' '--- all perry-runtime test invocations in tracked automation ---'
rg -n -C 3 --hidden \
  -g '*.yml' -g '*.yaml' -g '*.sh' -g 'Makefile' -g '*.toml' \
  'cargo test( --[^\n]*)? -p perry-runtime|cargo test -p perry-runtime' .

Repository: PerryTS/perry

Length of output: 25991


🏁 Script executed:

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

printf '%s\n' '--- memory-stability script environment and helpers ---'
sed -n '1,120p' scripts/run_memory_stability_tests.sh
sed -n '1380,1500p' scripts/run_memory_stability_tests.sh
sed -n '1735,1845p' scripts/run_memory_stability_tests.sh

printf '%s\n' '--- native ABI script environment and runner ---'
sed -n '1,100p' scripts/native_abi_evidence_packet.sh
sed -n '360,420p' scripts/native_abi_evidence_packet.sh
rg -n -C 2 'RUST_TEST_THREADS|test-threads|run_canary|run_logged' \
  scripts/run_memory_stability_tests.sh scripts/native_abi_evidence_packet.sh

Repository: PerryTS/perry

Length of output: 28104


Set RUST_TEST_THREADS=1 for all perry-runtime test commands.

The CI commands set this variable, but scripts/run_memory_stability_tests.sh and scripts/native_abi_evidence_packet.sh do not.

🤖 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/tests/runtime_roots.rs` at line 11, Set
RUST_TEST_THREADS=1 in the test command environments used by
scripts/run_memory_stability_tests.sh and scripts/native_abi_evidence_packet.sh,
matching the perry-runtime CI commands. Ensure every perry-runtime test
invocation in both scripts inherits this setting.

Source: Coding guidelines

Comment on lines +8 to +15
fn forwarded_string() -> (usize, usize, ValidPointerSet) {
let from = crate::string::js_string_from_bytes_longlived(b"id".as_ptr(), 2) as usize;
let valid_ptrs = build_valid_pointer_set();
let to = crate::string::js_string_from_bytes_longlived(b"id".as_ptr(), 2) as usize;
unsafe {
set_forwarding_address(header_from_user_ptr(from as *const u8), to as *mut u8);
}
(from, to, valid_ptrs)

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

Prevent collection while each forwarding fixture uses from.

Each fixture keeps from only in a raw local while allocating to. If that allocation collects, from can move before header_from_user_ptr(from) writes the forwarding address.

  • crates/perry-runtime/src/gc/tests/runtime_roots/old_defrag_contract.rs#L8-L15: Hold automatic-trigger suppression from from allocation through set_forwarding_address.
  • crates/perry-runtime/src/gc/tests/runtime_roots/old_defrag_contract.rs#L40-L46: Hold automatic-trigger suppression from from allocation through set_forwarding_address.
  • crates/perry-runtime/src/gc/tests/runtime_roots/old_defrag_contract.rs#L61-L69: Hold automatic-trigger suppression from from allocation through set_forwarding_address.

Based on learnings, destination allocation must not collect before forwarding installation because the local source pointer then becomes stale.

📍 Affects 1 file
  • crates/perry-runtime/src/gc/tests/runtime_roots/old_defrag_contract.rs#L8-L15 (this comment)
  • crates/perry-runtime/src/gc/tests/runtime_roots/old_defrag_contract.rs#L40-L46
  • crates/perry-runtime/src/gc/tests/runtime_roots/old_defrag_contract.rs#L61-L69
🤖 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/tests/runtime_roots/old_defrag_contract.rs`
around lines 8 - 15, Prevent automatic collection across each forwarding
fixture’s entire from-allocation-to-set_forwarding_address sequence, including
destination allocation, so the raw from pointer remains valid until forwarding
is installed. Apply this in forwarded_string at
crates/perry-runtime/src/gc/tests/runtime_roots/old_defrag_contract.rs#L8-L15,
the fixture at `#L40-L46`, and the fixture at `#L61-L69`; use the existing
automatic-trigger suppression mechanism and preserve each fixture’s current
forwarding behavior.

Sources: Coding guidelines, Learnings

Comment on lines +20 to +36
crate::json::test_clear_parse_roots();
let (from, to, valid_ptrs) = forwarded_string();
crate::json::test_seed_parse_roots(
f64::from_bits(crate::value::TAG_UNDEFINED),
from as *const _,
);
crate::json::test_seed_parse_key_ring(from as *const _);

crate::json::scan_parse_roots_mut(&mut RuntimeRootVisitor::for_rewrite(&valid_ptrs));

assert_eq!(crate::json::test_parse_roots_snapshot().1, to);
assert_eq!(
crate::json::test_parse_key_ring_snapshot(),
vec![to],
"the hot-key mirror must not retain the old address after its owning cache rewrites"
);
crate::json::test_clear_parse_roots();

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

Restore thread-local cache state after each fixture.

These tests install arena addresses into thread-local runtime caches. Later tests can scan or use these stale addresses after an arena reset.

  • crates/perry-runtime/src/gc/tests/runtime_roots/old_defrag_contract.rs#L20-L36: Ensure cleanup also clears PARSE_KEY_RING.
  • crates/perry-runtime/src/gc/tests/runtime_roots/old_defrag_contract.rs#L47-L58: Clear PERF_ENTRY_KEYS_ARRAY after the assertion.
  • crates/perry-runtime/src/gc/tests/runtime_roots/old_defrag_contract.rs#L70-L83: Clear DIAG_CHANNEL_BY_KEY after the assertion.

Use a test-state guard so cleanup also runs when an assertion panics. Based on learnings, do not assume arena-reset teardown clears thread-local cache scanners.

📍 Affects 1 file
  • crates/perry-runtime/src/gc/tests/runtime_roots/old_defrag_contract.rs#L20-L36 (this comment)
  • crates/perry-runtime/src/gc/tests/runtime_roots/old_defrag_contract.rs#L47-L58
  • crates/perry-runtime/src/gc/tests/runtime_roots/old_defrag_contract.rs#L70-L83
🤖 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/tests/runtime_roots/old_defrag_contract.rs`
around lines 20 - 36, Restore thread-local cache state with panic-safe
test-state guards in all three fixtures: at
crates/perry-runtime/src/gc/tests/runtime_roots/old_defrag_contract.rs lines
20-36, ensure the guard clears both parse roots and PARSE_KEY_RING; at lines
47-58, clear PERF_ENTRY_KEYS_ARRAY after the assertion; and at lines 70-83,
clear DIAG_CHANNEL_BY_KEY after the assertion. Use guards so each cleanup runs
even when an assertion panics, and do not rely on arena-reset teardown to clear
thread-local cache scanners.

Source: Learnings

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.

gc: restore a safe old-generation defragmentation rewrite contract; production compaction is disabled

1 participant