Skip to content

fix(gc): make old-page defrag opt-in again until a workload can exercise it - #7922

Merged
proggeramlug merged 2 commits into
mainfrom
fix/7917-old-defrag-default-off
Aug 12, 2026
Merged

fix(gc): make old-page defrag opt-in again until a workload can exercise it#7922
proggeramlug merged 2 commits into
mainfrom
fix/7917-old-defrag-default-off

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Makes old-page defragmentation opt-in again (PERRY_GC_OLD_DEFRAG=1), reverting only the default flip from #7913. Every piece of #7913's correctness work stays — this is not a revert of that PR.

Closes #7917.

Why only the default

#7913's contract work audits well and is worth keeping regardless of the default:

  • scripts/gc_root_dominance_allowlist.json is still empty, so the ~40 previously-exempted @perry_class_keys_* hits were genuinely fixed rather than re-suppressed.
  • The runtime holder policy was tightened: open_gap and unverified verdicts now fail outright.
  • PARSE_KEY_RING (GC: enumerate the unrooted runtime-side caches — the population no static checker can see #7231) and DIAG_CHANNEL_BY_KEY, whose safety rested explicitly on "only old-gen defrag can move them", were fixed rather than exempted.
  • It honoured the tripwire the deleted class-keys exemption left behind — that exemption's own becomes_real_when named this exact trigger.

The default flip is the separable part, and it shipped without the evidence its own issue required. #7876's stated acceptance criteria:

Re-enable defrag only after the reproducer and a dependency-scale stress corpus are clean.

No such corpus exists. Additionally, every GC gate (gc-ratchet, gc-moving-witnesses, gc-root-dominance, gc-native-roots, gc-ptr-shape-off-witness) was still queued/pending and had not executed when #7913 merged.

The structural reason this cannot be validated yet

Selection requires dead_bytes >= live_bytes on an old page — promote-then-die at scale. Measured survival on the benchmark corpus:

  • the retain family survives at 999–1000‰, so its old pages are ~fully live and dead_bytes ≈ 0
  • the churn family runs at 0–4‰ survival and promotes almost nothing, so there is barely an old generation to fragment
  • shapes runs 1 cycle; asyncpipe runs 0

No program in the 19-benchmark corpus can produce a candidate page. So shipping it on by default buys no benefit signal and no regression signal, while inheriting the full old-address rewrite surface. It is a footprint feature for long-lived servers with heterogeneous promoted objects — a workload class the suite does not contain.

The intended end state is one mode, not two: when a fragmentation workload exists and shows benefit, flip it on and delete the off branch (or delete the feature). Per CLAUDE.md, a mode that still exists is a decision that hasn't been made.

Changes

  • old_page_defrag_enabled_from_value now matches 1/on/true instead of negating 0/off/false, so an unrecognised value is OFF — a typo cannot silently enable old-generation relocation.
  • OldDefragTestDisable, an RAII guard setting the thread-local override to Some(false). Before this the override was only ever Some(true) or None, so the OFF state had no deterministic hook at all.
  • Two tests (see below).
  • The rationale is written at the gate itself, including the condition for removing this.

Test plan

  • old_page_defrag_is_opt_in_via_perry_gc_old_defrag — both arms plus the unrecognised-value case.
  • disabled_defrag_short_circuits_before_taking_a_page_snapshot — the behavioural OFF assertion, which did not previously exist. select_old_page_defrag_pages_from_snapshot does not consult the knob (the gate is only in select_old_page_defrag_pages), so every pre-existing selection test bypasses the switch entirely.
  • Sabotage-verified: deleting the if !old_page_defrag_enabled() early-out turns the new test RED and leaves the value-mapping test green — so it detects the gate's placement, not just its string mapping.
  • cargo test -p perry-runtime --lib gc::oldgen_defrag — 2 passed.

On the observable this test uses

It asserts the page-meta snapshot call counter, not the returned selection, and the positive control is load-bearing.

The first version of this test asserted only "disabled returns an empty selection". That is vacuous — a bare test process has no eligible old pages, so it passes identically against a kill switch that does nothing. It failed on its own positive-control assertion, which is how the problem was caught. Asserting the counter also pins that the short-circuit happens before the O(old pages) snapshot, so a disabled collector pays nothing per minor.

The counter is a thread_local!, as is the override, so this is safe under parallel test execution.


Update: the "cannot be validated" claim is now MEASURED, not inferred

The reasoning above argued from survival ratios that no corpus program can produce a candidate page. A separate GC workstream then measured it directly, with defrag at #7913's new default:

PERRY_GC_TRACE=1 reports old_page_candidate_pages = 0 and old_page_selected_pages = 0 on all 15 programs checked.

So old-page relocation never selects a page anywhere in the corpus. Two consequences:

  1. "19/19 corpus green with defrag on" is vacuous — it is CLAUDE.md's gc-matrix: --pressure disables the very path #7019 added — the 'default' arm runs ZERO copying minors on all 22 corpus rows #7024 shape, a gate that is green because its subject never ran. Any future validation of this feature must assert selected_pages > 0 before its verdict counts.
  2. fix(gc): restore safe old-page relocation #7913's default flip is unexercised by this corpus, empirically and not just in principle. That is the whole argument for this PR.

Related and now merged: #7919 pins that old-page relocation expands a described-but-unexpanded promoted page run before moving anything (the #7914 interaction). Notably the defence has to sit at the reader, not at selection — a freshly described page has dead_bytes == 0 so it is never selected, but #7913 widens evacuation to whole source blocks, so a described page can still be dragged in by a selected neighbour.

Summary by CodeRabbit

  • Bug Fixes
    • Old-generation defragmentation is now disabled by default.
    • Enable it with PERRY_GC_OLD_DEFRAG=1, on, or true.
    • Unrecognized configuration values now safely disable defragmentation.
  • Tests
    • Added coverage for disabled defragmentation, including forced-selection scenarios and early exit behavior.
  • Documentation
    • Documented the new opt-in behavior and configuration options.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Old-generation defragmentation is now disabled by default. Only 1, on, and true enable it. Tests cover configuration parsing, the test override, and early returns before page metadata snapshotting.

Changes

Old-generation defragmentation opt-in

Layer / File(s) Summary
Defragmentation gate and test override
crates/perry-runtime/src/gc/oldgen_defrag.rs
Environment parsing now uses opt-in values. Unset and unrecognized values disable defragmentation. A thread-local RAII guard provides a test-only disabled override.
Selection gate tests
crates/perry-runtime/src/gc/oldgen_defrag.rs, changelog.d/7922-old-defrag-default-off.md
Tests verify enabled and disabled parsing, empty disabled selections, forced-selection behavior, and the absence of page metadata snapshots when disabled. The changelog documents the behavior.

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

Possibly related issues

  • PerryTS/perry issue 7917 — This change adds coverage for the disabled old-generation defragmentation path and its early return.
  • PerryTS/perry issue 7876 — The issue also changes old-generation defragmentation behavior and its opt-in gating.

Possibly related PRs

  • PerryTS/perry#7913 — This PR changes the default-on policy introduced by that PR back to opt-in.
  • PerryTS/perry#7443 — This PR updates the defragmentation enablement and test-override logic introduced there.
  • PerryTS/perry#7161 — Both PRs use explicit environment-variable opt-in for GC behavior.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR adds disabled-path tests and sabotage verification, but it does not add the requested CI arm or delete the configuration knob. Add an end-to-end CI arm for PERRY_GC_OLD_DEFRAG=0, or explicitly delete the knob under the documented kill policy.
✅ Passed checks (4 passed)
Check name Status Explanation
Out of Scope Changes check ✅ Passed The code and changelog changes support the linked issue and PR objective; no unrelated implementation changes are evident.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title clearly and concisely describes the main change: making old-page defragmentation opt-in again.
Description check ✅ Passed The description covers the summary, concrete changes, related issue, rationale, and detailed test plan; only the checklist is omitted.
✨ 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/7917-old-defrag-default-off

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/oldgen_defrag.rs`:
- Around line 109-130: Update OldDefragTestDisable and OldDefragTestEnable to
save the current OLD_DEFRAG_TEST_OVERRIDE value when constructed, then restore
that saved Option<bool> in Drop instead of clearing it to None. Preserve nested
guard state, including an outer enable or disable override.
🪄 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: da36a3ac-2cd8-4fd9-a22d-149e98b69d92

📥 Commits

Reviewing files that changed from the base of the PR and between c109b08 and cfedea8.

📒 Files selected for processing (2)
  • changelog.d/7922-old-defrag-default-off.md
  • crates/perry-runtime/src/gc/oldgen_defrag.rs

Comment on lines +109 to +130
/// RAII *disable* for defrag on this thread, so the OFF arm is exercised
/// deterministically rather than depending on the ambient environment.
///
/// Without this the OFF state has no behavioural coverage at all: the value
/// mapping is unit-tested, but nothing asserts that a disabled collector
/// actually declines to select a page. That gap is what #7917 records.
#[cfg(test)]
pub(crate) struct OldDefragTestDisable;

#[cfg(test)]
impl OldDefragTestDisable {
pub(crate) fn new() -> Self {
OLD_DEFRAG_TEST_OVERRIDE.with(|c| c.set(Some(false)));
OldDefragTestDisable
}
}

#[cfg(test)]
impl Drop for OldDefragTestDisable {
fn drop(&mut self) {
OLD_DEFRAG_TEST_OVERRIDE.with(|c| c.set(None));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Restore the previous test override on drop.

OldDefragTestDisable::new() overwrites an active outer override. Drop then always sets the override to None. If an OldDefragTestEnable guard encloses this guard, dropping the inner guard loses the enabled state and falls back to the environment value.

Store the prior Option<bool> in the guard and restore it on drop. Apply the same pattern to OldDefragTestEnable.

Proposed fix
-pub(crate) struct OldDefragTestDisable;
+pub(crate) struct OldDefragTestDisable {
+    previous: Option<bool>,
+}

 impl OldDefragTestDisable {
     pub(crate) fn new() -> Self {
-        OLD_DEFRAG_TEST_OVERRIDE.with(|c| c.set(Some(false)));
-        OldDefragTestDisable
+        let previous = OLD_DEFRAG_TEST_OVERRIDE.with(|c| c.replace(Some(false)));
+        Self { previous }
     }
 }

 impl Drop for OldDefragTestDisable {
     fn drop(&mut self) {
-        OLD_DEFRAG_TEST_OVERRIDE.with(|c| c.set(None));
+        OLD_DEFRAG_TEST_OVERRIDE.with(|c| c.set(self.previous));
     }
 }
🤖 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 - 130, Update
OldDefragTestDisable and OldDefragTestEnable to save the current
OLD_DEFRAG_TEST_OVERRIDE value when constructed, then restore that saved
Option<bool> in Drop instead of clearing it to None. Preserve nested guard
state, including an outer enable or disable override.

@proggeramlug
proggeramlug force-pushed the fix/7917-old-defrag-default-off branch from cfedea8 to b150cc6 Compare August 12, 2026 06:45
@proggeramlug
proggeramlug merged commit f056913 into main Aug 12, 2026
@proggeramlug
proggeramlug deleted the fix/7917-old-defrag-default-off branch August 12, 2026 06:45
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: PERRY_GC_OLD_DEFRAG=0 (the rollback path for default-on old-page relocation) is exercised by no test and no CI arm

1 participant