Skip to content

fix(gc): run old-gen reclaim precisely at safepoints, stop scanning on OS memory pressure, and census every conservative-scan fallback - #7166

Merged
proggeramlug merged 8 commits into
mainfrom
fix/7148-safepoint-deferral
Aug 1, 2026
Merged

fix(gc): run old-gen reclaim precisely at safepoints, stop scanning on OS memory pressure, and census every conservative-scan fallback#7166
proggeramlug merged 8 commits into
mainfrom
fix/7148-safepoint-deferral

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Closes #7148 (in part — see "What this does not do").

main had six force_full_scan() sites, four reached without any user
calling gc()
. A forced conservative native-stack scan is not merely
imprecise, it is ruinous: it makes the copying minor ineligible
(CopiedMinorFallbackReason::ConservativeStack), so the cycle it covers runs
no copying minor at all. benchmarks/gc_ratchet/README.md measures the
end-to-end effect: heap_used_bytes +364% to +5371%, minor_cycles0
on all eight probes
.

#7148's end state is that the fallback becomes unreachable (always defer to
a precise safepoint), not imprecise (collect without the scan — a soundness
regression). This PR does that for the two automatic sites where a safepoint is
reachable, and makes all six measurable so "it never fires in practice" stops
being an assumption.

Per-site disposition

# site disposition pressure-spike answer depends on PERRY_GC_MOVING_LOOP_POLLS?
1 OldReclaim, gc/policy.rs keep + add a competing precise path none needed — nothing is delayed no — the arm never reads the gate
2 nursery-churn valve, gc/policy.rs keep as the bounded valve, now counted it is the answer — reaching it means no safepoint was reachable; GC_MOVING_DEFER_SLACK_BYTES bounds the growth yes (pre-existing) — polls off ⇒ every nursery collection scans
3 emergency reclaim, gc/mod.rs keep, justified, observable vacuous — allocation has already failed and the caller's next act is to panic, so there is no next safepoint no
4 host pressure, gc/pressure.rs defer when a generated frame is live; collect precisely when not the lowered arena trigger is untouched, so the alloc-point arm still collects at the next check — identical to the pre-existing blocked path precise path no; deferral's fastest drain yes, but two gate-independent backstops remain
5 gc(), gc/policy.rs keep, observable n/a — user request with synchronous semantics no
6 perry/gc minor(), gc/policy.rs keep, observable n/a — returns the freed byte count, so it must collect before returning no

Site 1 — the deferral was wrong, and the full suite is what said so

The first version of this PR deferred the allocation-point old-gen reclaim like
the nursery arm. The release suite failed
check_trigger_drives_old_reclaim_to_completion_without_host_stepping#5476's
regression test, which asserts that a single gc_check_trigger call (what
every allocation does) drives the reclaim to completion, because the workload it
was filed for is a compute-only loop that reaches no host step. Two reasons that
verdict is right and not just an awkward test:

  1. The headline RSS argument does not apply at this site. The
    +364%..+5371% figure is the cost of the copying minor becoming ineligible
    minor_cycles → 0. This arm runs a full mark-sweep, which is non-moving
    with or without the scan. What the scan costs here is conservative
    retention for one cycle, not the loss of evacuation. Much smaller, and I
    did not measure it.
  2. It trades a measured RSS guarantee for an unmeasured one. Memory not free in benchmark #5476's bug
    title was RSS climbs unbounded. Deferring turns "collected now" into
    "collected within one 32 MB growth quantum" on exactly that workload.

So the arm is unchanged and counted, and gc_safepoint_moving_minor keeps
the precise full mark-sweep it gained: it used to bail on OldReclaim ("stays
on its existing full mark-sweep path") and now runs the same cycle with
SkipDisabled roots. Every program that reaches a safepoint gets its old-gen
reclaim precisely and no later than before. The fallback is attacked by
adding a competing earlier precise path, not by postponing the collection —
which is a better reading of #7148's "unreachable, not imprecise" than the
deferral was.

Interaction with #7161 (proposed PERRY_GC_MOVING_LOOP_POLLS → OFF)

#7161 proposes flipping the polls default OFF as a stopgap for #7154. This PR
takes no position on that. It is written so the flip cannot break it, and every
claim above is tested in both directions (force_moving_gc_pacing /
force_legacy_gc_pacing):

  • Site 1 never reads the gate — identical behaviour and identical census
    under either default. Polls off simply means the precise safepoint path is
    reached less often (microtask pump only), so the census count rises. Inert,
    not unsound.
  • Site 4's precise inline collection — the part that actually removes a
    scan — never reads the gate either.
  • Site 4's deferral loses js_gc_loop_safepoint as a drain, but keeps two
    gate-independent backstops: the outermost microtask-pump boundary (gated by
    PERRY_GC_MOVING_SAFEPOINT, a different knob) and the lowered arena trigger,
    which makes the ordinary allocation-point check collect the owed cycle. A test
    pins the second one directly, because it is the one that holds for a program
    that never yields to the event loop.
  • Site 2 already depended on the gate before this PR: with polls off,
    gc_check_trigger skips the deferral entirely and every nursery collection
    runs behind a forced scan. That is pre-existing, sound, and unchanged by this
    PR — but the census now makes its cost visible, which is a number fix(gc): disable evacuating minor by default pending #7154 (use-after-free on dynamically-added fields) #7161's
    reviewer may want: with polls off, nursery_churn_slack_valve counts every
    nursery collection, and that is the minor_cycles → 0 regime the table above
    quantifies.

Nothing here becomes unsound under either default; the worst case is inert.

Site 4 — the rationale is now tested rather than assumed

The old comment said "a host may deliver the callback with unspilled locals on
the native frames above us". True — but only when there are generated frames
above us, and the module's own first paragraph says the common case is a
run-loop boundary with the JS stack unwound. So: empty shadow stack ⇒ the
precise root set is complete ⇒ collect here with no scan (the copying minor
stays eligible). Live generated frame ⇒ a safepoint is reachable ⇒ arm the
deferral and return 1, the code this API already documents as "trigger
lowered, collection deferred".

Deferring unconditionally would have been wrong in the direction that matters:
memory pressure arrives when a process is idle, and an idle process reaches no
loop back-edge and pumps no microtasks, so a blanket deferral would shed nothing
before the jetsam.

Making "unreachable" measurable — and the cross-link

New gc/scan_fallback.rs counts every one of the six sites (PERRY_GC_DIAG
prints [gc-scan-fallback] site=… automatic=… count=…) and counts every
precise-safepoint drain that replaced one ([gc-safepoint-drain] kind=…).
Automatic and explicit sites are separated: all eight ratchet probes end with an
explicit full gc(), so site 5 fires once per probe by construction and a
census that lumped them together would misread.

The statepoints branch (exp/stackmap-viability,
docs/statepoint-gc-experiment.md) states the invariant this measures
compliance with: a collection that skips the conservative stack scan consumes
only precise roots, so it may only begin at a declared safepoint
— enforced
there with a thread-local declared-safepoint flag plus a check at the root-scan
subphase, in two levels (heal / strict panic). This PR is the reachability
census
for the same contract on main. Census first, enforcement second: the
counts say whether the contract's "anywhere else" arm is ever taken, and the
enforcement check is what makes zero provable rather than observed. That
branch also states, in "Work deliberately left gated", that the next experiment
after the #7114 prerequisite is exactly this. The two compose.

PERRY_CONSERVATIVE_STACK_SCAN=full

Failed 134 of 1574 runtime tests on main — a shipped escape hatch nobody
had verified. Fixed rather than deleted, against the kill-policy's default,
for a specific reason: =full is gc-ratchet's validated sensitivity arm — the
run that produced the 60 regression rows proving that gate can fail. Deleting it
would delete a gate's only end-to-end proof, which is the failure the
kill-policy exists to prevent, one level up.

The failures were never soundness failures. A collector test's central assertion
is "this object should have been collected", and an ambient conservative scan
retains whatever the native stack looks like a pointer to. Since #7147 the test
build has one declared scan mode and the isolation guards are its authority, so
in the test build only a pinned override now beats an env request for
Full: the env var may make the scan less aggressive than a test declared,
never more. =0 still beats a pinned Full, so the bisection hatch is intact
(covered by its own test). Production binaries pin no override and are
unchanged.

Gates

A gate must assert its subject was live. Every deferral test here asserts both
that the conservative valve did not fire and that the precise collection
which replaced it did run (drain counter) — checking only the first half would
pass on a tree where the trigger never armed at all, i.e. the largest possible
regression reported as a pass.

Sabotage, five directions, each reverting one hunk on the finished tree:

sabotage tests that went red
C — host pressure scans again (any automatic site) both host_pressure_…
E — =full precedence rule reverted conservative_scan_env_full_…
F — precise OldReclaim safepoint path removed old_reclaim_runs_precisely_at_a_safepoint, host_pressure_defers_…
G — alloc-point arm deferred (the rejected design) old_reclaim_alloc_point_still_completes_immediately… and #5476's own check_trigger_drives_old_reclaim_to_completion_without_host_stepping

G is the interesting one: it is the design this PR started with, and it reddens
the pre-existing #5476 test as well as the new one. It also leaves
old_reclaim_is_unchanged_when_moving_loop_polls_are_off green, which is
exactly the property that test pins — the sabotage gates on polls being on, and
the polls-off arm is supposed to be unaffected.

What this does not do

ConservativeStackScanMode is not deleted — #7148 sequences that behind
this work and records why the matrix cons_scan_off evidence is circular (the
env value beats the override, so that arm already disables all six sites and a
removal branch reproduces it by construction). Sites 3, 5 and 6 keep the scan;
removing those needs precise roots at an arbitrary PC, which is the statepoint
contract's job, not a deferral's.

Summary by CodeRabbit

  • New Features

    • Improved garbage-collection scheduling by deferring eligible reclamation to precise safepoints.
    • Added diagnostics for conservative-scan fallback sources and deferred collection activity.
    • Added configurable conservative stack-scan modes, including automatic, disabled, and full scanning.
    • Improved memory-pressure handling to avoid unnecessary conservative scans when precise collection is available.
  • Bug Fixes

    • Preserved emergency and explicit collection paths while reducing unnecessary fallback scans.
    • Improved handling of scan-mode overrides and pressure-triggered collection behavior.
  • Documentation

    • Documented GC scan modes, fallback diagnostics, safepoint deferral, and memory-pressure behavior.

Ralph Küpper added 3 commits August 1, 2026 08:23
…s; census every conservative-scan fallback

#7148. Six force_full_scan() sites forced the conservative native-stack scan,
four of them without any user calling gc(). A scanning cycle is not merely
slower: it makes the copying minor ineligible, so it runs no copying minor at
all (gc_ratchet README: heap_used_bytes +364%..+5371%, minor_cycles -> 0 on all
eight probes).

Per-site disposition:

- OldReclaim alloc point -> DEFER. gc_safepoint_moving_minor now drains the
  old-gen reclaim as the same full mark-sweep with precise roots. The
  alloc-point arm keeps a slack valve bounded in the trigger's OWN unit
  (old-gen in-use + external side bytes, one 32 MB growth quantum), because an
  arena-unit slack would never expire for the >16 KB old-arena workload #5476
  exists for -- #7024's bug inverted.
- Host pressure -> DEFER when a generated frame is live (arm the deferral,
  return the already-documented code 1); collect PRECISELY, no scan, when the
  shadow stack is empty -- the run-loop-boundary case the module's own docs
  call typical, and the case where deferring would shed nothing because an idle
  process reaches no safepoint.
- Nursery-churn valve, emergency reclaim, gc(), perry/gc minor() -> KEEP,
  now counted. Emergency reclaim provably cannot defer: it runs after an
  allocation has already failed and the caller's next act is to panic, so there
  is no next safepoint.

Every site is now recorded in gc/scan_fallback.rs with a PERRY_GC_DIAG line, and
the two deferral paths record a drain counter -- so a gate can assert its
subject was live (CLAUDE.md four-ways-a-gate-cannot-fail #4) instead of only
that nothing crashed.

Also: PERRY_CONSERVATIVE_STACK_SCAN=full failed 134/1574 runtime tests. In the
test build a pinned per-thread override now beats an env request for Full -- the
env may make the scan less aggressive than a test declared, never more. Kept
rather than deleted because =full is the gc_ratchet's validated sensitivity arm,
the only end-to-end proof that gate can fail.
Every deferral test asserts BOTH that the conservative valve did not fire and
that the precise safepoint collection which replaced it DID run (a drain
counter). Checking only the first half would pass on a tree where the trigger
never armed at all -- the largest possible regression reported as a pass
(CLAUDE.md, four ways a gate cannot fail, #4).

Adds two test-only scoped overrides next to the existing force_legacy_gc_pacing
pattern: pinned moving pacing (so a deferral test cannot silently run under
legacy pacing and pass for the wrong reason) and a shrinkable old-gen deferral
slack (the shipped slack is 32 MB; crossing it for real would mean committing
32 MB of old-gen inside a unit test).
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The GC runtime classifies conservative scans and safepoint drains, defers eligible reclamation to precise safepoints, retains tagged fallback paths, and adds tests and documentation for pressure handling and scan-mode precedence.

Changes

GC fallback and reclaim flow

Layer / File(s) Summary
Scan mode and fallback accounting
crates/perry-runtime/src/gc/roots/scan_mode.rs, crates/perry-runtime/src/gc/roots.rs, crates/perry-runtime/src/gc/scan_fallback.rs, crates/perry-runtime/src/gc/mod.rs, crates/perry-runtime/src/gc/policy.rs, crates/perry-runtime/src/gc/tests/roots.rs
The runtime resolves conservative scan modes, classifies scan sites and safepoint drains, records counters, and tags emergency and explicit scans.
Deferred reclaim and safepoint draining
crates/perry-runtime/src/gc/policy.rs, crates/perry-runtime/src/gc/pressure.rs
Old-generation reclaim and host-pressure collections defer when precise safepoints are available. Exhausted slack uses tagged conservative fallback paths.
Fallback validation and documentation
crates/perry-runtime/src/gc/tests/*, benchmarks/gc_ratchet/README.md, changelog.d/7166-gc-safepoint-deferral-scan-census.md
Tests cover deferral, fallback activation, pressure handling, explicit collection, and scan-mode precedence. Documentation records test-build and production behavior.

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

Sequence Diagram(s)

sequenceDiagram
  participant AllocationOrPressure
  participant GCPolicy
  participant PreciseSafepoint
  participant ScanFallbackCounters
  AllocationOrPressure->>GCPolicy: request collection
  GCPolicy->>GCPolicy: check slack and generated-frame state
  GCPolicy->>PreciseSafepoint: defer eligible collection
  PreciseSafepoint->>GCPolicy: drain deferred collection
  GCPolicy->>ScanFallbackCounters: record safepoint drain or tagged fallback
Loading

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address #7148 by adding precise paths, measuring fallback reachability, and correcting test-build full-scan precedence.
Out of Scope Changes check ✅ Passed The documentation, runtime changes, diagnostics, and tests all support the linked GC 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 PR's precise GC reclaim, memory-pressure behavior, and conservative-scan fallback census changes.
Description check ✅ Passed The description is detailed and on-topic, covering the changes, related issue, rationale, test evidence, and scope limitations.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/7148-safepoint-deferral

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.

…conservative arm outright

Two follow-ups the compiler found.

1. gc/roots.rs crossed the 2000-line file-size gate (1992 -> 2035). The
   conservative-scan mode block is topically self-contained -- the mode enum,
   its env/override precedence, ManualGcScanGuard, and the decision the mark
   phase consults -- so it moves to roots/scan_mode.rs. roots.rs is now 1851.

2. ConservativeScanSite::HostPressure was never constructed, because after the
   deferral js_gc_memory_pressure has NO conservative arm left: it either
   collects with precise roots or defers. So the variant is deleted rather than
   kept 'for completeness'. An enum arm nothing can produce is a claim no test
   can check -- the host-pressure tests now assert automatic_scan_fallback_total
   == 0 instead, which catches reintroduction at any automatic site.

The counter read APIs are #[cfg(test)]; ops observability is the PERRY_GC_DIAG
line, which is unconditional.

@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.

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

67-69: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Update the HostPressure doc comments to reflect the new deferral behavior.

ConservativeScanSite::HostPressure (Line 67-69) is documented as "js_gc_memory_pressure with a generated frame live above us." That describes the pre-#7148 behavior, when a live frame forced a conservative scan. After this PR's change in pressure.rs, a live frame now defers to a safepoint instead of scanning, and the code never calls force_full_scan(ConservativeScanSite::HostPressure) anywhere. Sibling variants OldReclaimSlackValve and NurseryChurnSlackValve were updated to describe their new "after the safepoint deferral ran out of slack" semantics, but HostPressure's comment was not updated to match.

Similarly, SafepointDrainKind::HostPressure (Line 132-134) is documented as "collected at a safepoint," but it is recorded directly inside js_gc_memory_pressure when no frame is live (a synchronous inline collection), not via the js_gc_loop_safepointgc_safepoint_moving_minor drain path that the other two kinds use.

Since this module's stated purpose is to make claims about scan fallback measurable and precise, stale doc comments on the exact site this PR redesigned undercut that goal. Clarify that HostPressure (the ConservativeScanSite variant) is now structurally unreachable — its associated scan is superseded by deferral and, if the deferral ever fails to drain, gets attributed to OldReclaimSlackValve or NurseryChurnSlackValve instead.

📝 Suggested doc update
-    /// `js_gc_memory_pressure` with a generated frame live above us.
-    /// Host-driven.
+    /// `js_gc_memory_pressure`. Historically fired when a generated frame was
+    /// live above us; since `#7148` that case defers to a safepoint instead, so
+    /// this variant is retained for the census but is never actually recorded
+    /// (a valve firing in that scenario is attributed to
+    /// `OldReclaimSlackValve` or `NurseryChurnSlackValve` instead). Host-driven.
     HostPressure,
-    /// A host memory-pressure request collected at a safepoint (`#7148`).
+    /// A host memory-pressure request collected synchronously with precise
+    /// roots because no generated frame was live (`#7148`) — not a deferred
+    /// drain through `js_gc_loop_safepoint` like the other two kinds.
     HostPressure,

Also applies to: 132-135

🤖 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/scan_fallback.rs` around lines 67 - 69, Update
the documentation for both HostPressure variants: in ConservativeScanSite, state
that the variant is structurally unreachable because host-pressure handling
defers to a safepoint, with any failed deferral attributed to
OldReclaimSlackValve or NurseryChurnSlackValve; in SafepointDrainKind, describe
that host-pressure collection is recorded synchronously by js_gc_memory_pressure
when no frame is live rather than through the safepoint drain path.
🤖 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.

Nitpick comments:
In `@crates/perry-runtime/src/gc/scan_fallback.rs`:
- Around line 67-69: Update the documentation for both HostPressure variants: in
ConservativeScanSite, state that the variant is structurally unreachable because
host-pressure handling defers to a safepoint, with any failed deferral
attributed to OldReclaimSlackValve or NurseryChurnSlackValve; in
SafepointDrainKind, describe that host-pressure collection is recorded
synchronously by js_gc_memory_pressure when no frame is live rather than through
the safepoint drain path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d6919dbf-ba85-4bab-b8ad-9ee9fdcee9dc

📥 Commits

Reviewing files that changed from the base of the PR and between 47040d5 and 9518747.

📒 Files selected for processing (10)
  • benchmarks/gc_ratchet/README.md
  • changelog.d/7166-gc-safepoint-deferral-scan-census.md
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/policy.rs
  • crates/perry-runtime/src/gc/pressure.rs
  • crates/perry-runtime/src/gc/roots.rs
  • crates/perry-runtime/src/gc/scan_fallback.rs
  • crates/perry-runtime/src/gc/tests/mod.rs
  • crates/perry-runtime/src/gc/tests/roots.rs
  • crates/perry-runtime/src/gc/tests/scan_fallback.rs

…ous inline collection, not a deferred drain (CodeRabbit)

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

🤖 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/roots/scan_mode.rs`:
- Around line 98-122: Add #[must_use] to the ManualGcScanGuard struct
declaration so callers receive a compiler warning when force_full_scan returns
an unbound guard that would immediately drop. Leave force_full_scan and Drop
behavior unchanged.

In `@crates/perry-runtime/src/gc/scan_fallback.rs`:
- Around line 224-227: Update the documentation comment above the
conservative-scan fallback counter to say there are three automatic sites,
matching the variants classified as automatic by is_automatic(). Leave the
clarification that explicit gc() requests are excluded unchanged.
🪄 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: 723efde9-9683-482f-95c4-d630ddaf18d5

📥 Commits

Reviewing files that changed from the base of the PR and between 9518747 and 3bf3e54.

📒 Files selected for processing (7)
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/policy.rs
  • crates/perry-runtime/src/gc/roots.rs
  • crates/perry-runtime/src/gc/roots/scan_mode.rs
  • crates/perry-runtime/src/gc/scan_fallback.rs
  • crates/perry-runtime/src/gc/tests/roots.rs
  • crates/perry-runtime/src/gc/tests/scan_fallback.rs
🚧 Files skipped from review as they are similar to previous changes (4)
  • crates/perry-runtime/src/gc/tests/roots.rs
  • crates/perry-runtime/src/gc/tests/scan_fallback.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/policy.rs

Comment on lines +98 to +122
pub(crate) struct ManualGcScanGuard {
engaged: bool,
}

impl ManualGcScanGuard {
pub(crate) fn force_full_scan(site: super::ConservativeScanSite) -> Self {
super::record_scan_fallback(site);
let engaged = CONSERVATIVE_STACK_SCAN_OVERRIDE.with(|c| {
if c.get().is_some() {
return false;
}
c.set(Some(ConservativeStackScanMode::Full));
true
});
Self { engaged }
}
}

impl Drop for ManualGcScanGuard {
fn drop(&mut self) {
if self.engaged {
CONSERVATIVE_STACK_SCAN_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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add #[must_use] to ManualGcScanGuard.

ManualGcScanGuard relies entirely on Drop to un-pin the conservative scan mode. If a caller writes ManualGcScanGuard::force_full_scan(site); as a bare statement instead of binding it (let _guard = ...), the temporary drops at the end of that statement and the conservative scan never actually covers the intended work. The guard's own docstring says this exact class of bug (a missing precise root rescued only by the conservative scan) is the #4977 issue this guard was built to prevent, so a silent misuse here reintroduces that bug.

Mark the type #[must_use] so misuse produces a compiler warning.

🛡️ Proposed fix
+#[must_use = "binding this guard to `_` or a name keeps the scan mode pinned for its intended scope; a bare statement drops it immediately"]
 pub(crate) struct ManualGcScanGuard {
     engaged: bool,
 }
📝 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
pub(crate) struct ManualGcScanGuard {
engaged: bool,
}
impl ManualGcScanGuard {
pub(crate) fn force_full_scan(site: super::ConservativeScanSite) -> Self {
super::record_scan_fallback(site);
let engaged = CONSERVATIVE_STACK_SCAN_OVERRIDE.with(|c| {
if c.get().is_some() {
return false;
}
c.set(Some(ConservativeStackScanMode::Full));
true
});
Self { engaged }
}
}
impl Drop for ManualGcScanGuard {
fn drop(&mut self) {
if self.engaged {
CONSERVATIVE_STACK_SCAN_OVERRIDE.with(|c| c.set(None));
}
}
}
#[must_use = "binding this guard to `_` or a name keeps the scan mode pinned for its intended scope; a bare statement drops it immediately"]
pub(crate) struct ManualGcScanGuard {
engaged: bool,
}
impl ManualGcScanGuard {
pub(crate) fn force_full_scan(site: super::ConservativeScanSite) -> Self {
super::record_scan_fallback(site);
let engaged = CONSERVATIVE_STACK_SCAN_OVERRIDE.with(|c| {
if c.get().is_some() {
return false;
}
c.set(Some(ConservativeStackScanMode::Full));
true
});
Self { engaged }
}
}
impl Drop for ManualGcScanGuard {
fn drop(&mut self) {
if self.engaged {
CONSERVATIVE_STACK_SCAN_OVERRIDE.with(|c| c.set(None));
}
}
}
🤖 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/roots/scan_mode.rs` around lines 98 - 122, Add
#[must_use] to the ManualGcScanGuard struct declaration so callers receive a
compiler warning when force_full_scan returns an unbound guard that would
immediately drop. Leave force_full_scan and Drop behavior unchanged.

Comment on lines +224 to +227
/// Total conservative-scan fallbacks across the four **automatic** sites. This
/// is the number #7148 wants driven to zero: explicit `gc()` is a user request
/// and is not part of the claim.
#[cfg(test)]

Copy link
Copy Markdown

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

Fix "four automatic sites" doc comment — only 3 variants are automatic.

is_automatic() (Lines 107-114) returns true for exactly three variants: OldReclaimSlackValve, NurseryChurnSlackValve, EmergencyReclaim. ManualCollect and ManualMinor are explicit. This doc comment says "the four automatic sites", which is inconsistent with the enum in this same file — likely a leftover from before the HostPressure variant (previously automatic) was removed. Given this PR's stated goal is precise, auditable fallback-site accounting, this comment should say "three".

📝 Proposed fix
-/// Total conservative-scan fallbacks across the four **automatic** sites. This
+/// Total conservative-scan fallbacks across the three **automatic** sites. This
 /// is the number `#7148` wants driven to zero: explicit `gc()` is a user request
 /// and is not part of the claim.
📝 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
/// Total conservative-scan fallbacks across the four **automatic** sites. This
/// is the number #7148 wants driven to zero: explicit `gc()` is a user request
/// and is not part of the claim.
#[cfg(test)]
/// Total conservative-scan fallbacks across the three **automatic** sites. This
/// is the number `#7148` wants driven to zero: explicit `gc()` is a user request
/// and is not part of the claim.
#[cfg(test)]
🤖 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/scan_fallback.rs` around lines 224 - 227, Update
the documentation comment above the conservative-scan fallback counter to say
there are three automatic sites, matching the variants classified as automatic
by is_automatic(). Leave the clarification that explicit gc() requests are
excluded unchanged.

Ralph Küpper added 2 commits August 1, 2026 09:06
The full release suite caught the design error: deferring the allocation-point
old-gen reclaim breaks #5476's regression test, which asserts that a SINGLE
gc_check_trigger call completes the reclaim. That guarantee exists because the
workload it was filed for is a compute-only loop reaching no host step, and the
bug title was 'RSS climbs unbounded'. Two reasons the deferral was wrong here:

1. The headline RSS argument does not apply. The +364%..+5371% figure is the
   cost of the copying minor becoming ineligible; this arm runs a FULL
   mark-sweep, non-moving either way. The scan costs conservative retention for
   one cycle, not evacuation -- much smaller, and unmeasured.
2. It trades a measured RSS guarantee for an unmeasured one.

So the alloc-point arm is unchanged and counted, and gc_safepoint_moving_minor
keeps the precise full mark-sweep it gained -- programs reaching a safepoint get
the reclaim precisely and NO LATER than before. The fallback is attacked by
adding a competing earlier precise path, not by postponing the collection.
Removes the old-gen deferral state and the test-only slack override with it.

Default-robustness for #7161 (proposed PERRY_GC_MOVING_LOOP_POLLS flip to OFF),
now tested in both directions:
- old-gen alloc-point arm: does not read the gate at all -> identical behaviour
  and identical census under either default.
- host pressure precise inline collection: does not read the gate -> unaffected.
- host pressure deferral: js_gc_loop_safepoint goes no-op, but the lowered arena
  trigger still makes the next allocation-point check collect the owed cycle;
  test asserts that backstop directly.
- nursery-churn valve: pre-existing code already skips the deferral when polls
  are off, so every nursery collection scans conservatively -- inert and sound,
  but the census now makes that cost visible.

@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.

🧹 Nitpick comments (1)
crates/perry-runtime/src/gc/tests/scan_fallback.rs (1)

351-357: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert that the deferred old-reclaim request was consumed and attributed.

gc_collection_count() can increase for another collection. Also assert that GC_OLD_RECLAIM_PENDING is clear and that OldReclaimAllocPoint has count 1 after gc_check_trigger().

Proposed test assertions
     assert!(
         gc_collection_count() > collections_before,
         "LIVE SUBJECT: the deferred critical-pressure cycle actually ran"
     );
+    assert!(
+        !GC_OLD_RECLAIM_PENDING.with(std::cell::Cell::get),
+        "the allocation-point backstop must consume the deferred old reclaim"
+    );
+    assert_eq!(
+        scan_fallback_count(ConservativeScanSite::OldReclaimAllocPoint),
+        1,
+        "the polls-off backstop must retain its fallback attribution"
+    );
🤖 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/scan_fallback.rs` around lines 351 - 357,
Extend the assertions after gc_check_trigger() in the fallback test to verify
that GC_OLD_RECLAIM_PENDING is cleared and OldReclaimAllocPoint records exactly
one event. Keep the existing collection-count assertion, but use the pending
flag and attribution counter to confirm the deferred old-reclaim request was
consumed by the trigger.
🤖 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.

Nitpick comments:
In `@crates/perry-runtime/src/gc/tests/scan_fallback.rs`:
- Around line 351-357: Extend the assertions after gc_check_trigger() in the
fallback test to verify that GC_OLD_RECLAIM_PENDING is cleared and
OldReclaimAllocPoint records exactly one event. Keep the existing
collection-count assertion, but use the pending flag and attribution counter to
confirm the deferred old-reclaim request was consumed by the trigger.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f23b9f03-2fb5-47a7-b482-cc748452f59d

📥 Commits

Reviewing files that changed from the base of the PR and between 3bf3e54 and 684449a.

📒 Files selected for processing (3)
  • crates/perry-runtime/src/gc/policy.rs
  • crates/perry-runtime/src/gc/scan_fallback.rs
  • crates/perry-runtime/src/gc/tests/scan_fallback.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/perry-runtime/src/gc/scan_fallback.rs

@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 `@changelog.d/7166-gc-safepoint-deferral-scan-census.md`:
- Line 5: Update the Markdown text in the changelog entry so the issue reference
no longer begins with an unescaped hash; prefix `#7148` with descriptive text such
as “Issue” while preserving the existing meaning.
🪄 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: 9fde6674-2e51-4ef7-9a9c-772b5a4b82dc

📥 Commits

Reviewing files that changed from the base of the PR and between 684449a and 55a6f6f.

📒 Files selected for processing (1)
  • changelog.d/7166-gc-safepoint-deferral-scan-census.md


`main` reached a forced conservative native-stack scan from **six** sites, **four of them without any user calling `gc()`**, and nothing measured whether any of them ever ran. A forced scan is not merely an imprecision: it makes the copying minor ineligible (`CopiedMinorFallbackReason::ConservativeStack`), so the cycle it covers runs **no copying minor at all**. `benchmarks/gc_ratchet/README.md` quantifies the end state — `heap_used_bytes` **+364% to +5371%** and `minor_cycles` → **0 on all eight probes**.

#7148 asks for the fallback to become *unreachable*, not *imprecise*. Per site:

Copy link
Copy Markdown

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

Fix the Markdown issue reference.

Line 5 starts with #7148 without a space, which triggers markdownlint MD018. Prefix the reference with Issue or escape the hash.

Proposed fix
-#7148 asks for the fallback to become **unreachable**, not *imprecise*.
+Issue `#7148` asks for the fallback to become **unreachable**, not *imprecise*.
🧰 Tools
🪛 markdownlint-cli2 (0.23.1)

[warning] 5-5: No space after hash on atx style heading

(MD018, no-missing-space-atx)

🤖 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 `@changelog.d/7166-gc-safepoint-deferral-scan-census.md` at line 5, Update the
Markdown text in the changelog entry so the issue reference no longer begins
with an unescaped hash; prefix `#7148` with descriptive text such as “Issue” while
preserving the existing meaning.

Source: Linters/SAST tools

@proggeramlug proggeramlug changed the title fix(gc): defer old-gen reclaim and host pressure to precise safepoints; census every conservative-scan fallback fix(gc): run old-gen reclaim precisely at safepoints, stop scanning on OS memory pressure, and census every conservative-scan fallback Aug 1, 2026
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Measured results

Host: MacBook (darwin-arm64), release, --test-threads=1. The host was under
heavy load from three sibling agents throughout (load average 40–88).
That
invalidates wall-clock only; it is called out per-metric below rather than
buried.

Build discipline: one CARGO_TARGET_DIR for the branch, -p perry -p perry-runtime-static -p perry-stdlib-static, PERRY_RUNTIME_DIR pinned,
libperry_runtime.a mtime confirmed to move after the last edit (09:18 vs the
08:49 stale archive — the exact trap CLAUDE.md warns about).

Suite

run result
cargo test --release -p perry-runtime --lib -- --test-threads=1 1598 passed, 0 failed, 3 ignored
same, PERRY_CONSERVATIVE_STACK_SCAN=full 1598 passed, 0 failed, 3 ignored

The second row is the #7148 escape-hatch item: 134 → 0.

★ Census — the automatic fallbacks are already unreachable on this corpus

Every gc_ratchet probe compiled with the branch build and run under
PERRY_GC_DIAG=1:

probe old_reclaim_alloc_point nursery_churn_valve emergency host_pressure manual gc() drain: nursery_minor
01_nursery_churn 0 0 0 0 1 14
02_survivor_promotion 0 0 0 0 1 10
03_cross_gen_writes 0 0 0 0 3 22
04_dead_after_deep_stack 0 0 0 0 1 104
05_closure_capture 0 0 0 0 1 26
06_string_retention 0 0 0 0 1 64
07_array_grow_evacuate 0 0 0 0 1 80
08_map_set_sidetables 0 0 0 0 1 84

Two things to read off this.

The drain column is the live-subject assertion, and it checks out exactly.
Those counts are identical to the pinned baseline's minor_cycles column
(14/10/22/104/26/64/80/84). So the zeros in the fallback columns are not "the
program did nothing" — every collection these probes performed went down the
precise safepoint path.

The automatic fallbacks were already unreachable here before this PR. The
only non-zero site is the explicit gc() each probe ends with. That is the
honest headline and it cuts against the most dramatic reading of #7148: the
+364%..+5371% figure is what the fallback costs when taken, and on this corpus
it was never taken. The exposure was potential, not realised. What changes is
that this is now a measurement instead of an assumption, and that two of the
ways to reach it are gone (host pressure's conservative arm is deleted outright;
old-gen reclaim gained a precise competitor at safepoints).

gc-ratchet, 7 repeats (matching the pinned baseline)

profile verdict detail
shared_ci PASS (exit 0) 95 ok rows; one non-gating wall_ms drift, informational
pinned_host fails on wall_ms only all 8 probes red on wall; every retention, GC-accounting and RSS row ok

wall_ms is excluded from the shared-CI gate precisely because it is not
measurable off the quiet pinned host, and this host was at load 40–88. Two
independent runs on the same binary gave different wall deltas (3-repeat:
+6%..+23% on 3 probes; 7-repeat: +13%..+72% on 8) while every semantic metric
stayed bit-identical across both — which is what load noise looks like, not a
collector change. I did not get a quiet-host wall measurement; that is
unmeasured and I am not claiming otherwise.

★ RSS — flat, and that is the correct answer

rss_bytes and peak_rss_bytes are gated under pinned_host and all
sixteen rows passed:

probe heap_used_bytes Δ minor_cycles Δ rss_bytes Δ peak_rss_bytes Δ
01_nursery_churn +0.00% 14 → 14 +0.92% +1.13%
02_survivor_promotion +0.00% 10 → 10 −0.09% +0.00%
03_cross_gen_writes +0.00% 22 → 22 +0.91% +1.01%
04_dead_after_deep_stack +0.00% 104 → 104 +1.42% +1.46%
05_closure_capture +0.00% 26 → 26 −0.92% −0.70%
06_string_retention +0.00% 64 → 64 +1.22% +1.26%
07_array_grow_evacuate +0.00% 80 → 80 +1.10% +0.98%
08_map_set_sidetables +0.00% 84 → 84 −0.07% +1.30%

heap_used_bytes is bit-identical on all eight; minor_cycles,
promoted_* and freed_bytes likewise; copied_objects moves ≤0.02%. RSS
swings both ways within ±1.5%, inside the gate's allowance, on a loaded host.

No RSS win, and none was available — see the census. A change that moved RSS
here would have to have changed a path the probes take, and they take none of
the four automatic sites. Reporting a win would require a workload that reaches
one; the ratchet corpus is not it, and building one is the natural follow-up.

Sabotage

Five one-hunk sabotages on the finished tree, each reddening exactly the tests
that name the property it broke — including the rejected old-gen deferral, which
reddens #5476's own pre-existing regression test. Table in the PR body.

Not measured

@proggeramlug

Copy link
Copy Markdown
Contributor Author

GC × repsel stress matrix — --arms all --pressure 8

summary: PASS=504 UNVER=62 XFAIL=1 FAIL=0        (exit 0)
byte-exact vs node 26.5.1: 566/567 cells
21 arms × 27 corpus files, pressure=8MB

FAIL=0, and every requires=move arm bit:

arm requires collected moved-objects copy-minor
evac_minor move 26/27 26/27 26/27
force_evac move 26/27 26/27 26/27
force_verify move 26/27 26/27 26/27
loop_polls move 26/27 26/27 26/27
rep_*_off (7 arms) move 26/27 26/27 26/27
default scavenge 21/27 21/27 21/27
cons_scan_off scavenge 21/27 21/27 21/27
cons_scan_off_force scavenge 21/27 21/27 21/27

The moved-objects and copy-minor columns are the point: an arm that
"passes" while relocating nothing certifies nothing (#6942/#6946/#6950), and
none of the moving arms is in that state here.

cons_scan_off sitting at exactly the default numbers is the expected
result and worth stating explicitly, because #7148 flags this arm's evidence as
circular: PERRY_CONSERVATIVE_STACK_SCAN=off beats the per-thread override
and so disables all the force_full_scan sites at once, which means the arm
reproduces "no conservative scan" by construction rather than by anything this
PR did. It is a consistency check, not evidence for the change — the evidence
for the change is the census in the previous comment, taken with no GC env
set at all.

Caveats

  • Run on the same loaded host (load 40–88). The matrix's verdicts are
    byte-exactness and liveness, both load-independent.
  • UNVER=62 was not A/B'd against main. UNVER means "output matched but the
    arm was inert on this row", which is a property of the corpus and the arm, not
    of this diff — but I did not run the matrix on main to confirm the count is
    unchanged, so treat the absolute number as unverified. FAIL=0 and the
    liveness table are the load-bearing results.
  • repsel_p4a3_numarray_growth is UNVER on 20 of 21 arms; that is the bulk of
    the 62 and is pre-existing corpus behaviour, not something this PR touches.

@proggeramlug
proggeramlug merged commit 0b6e3ba into main Aug 1, 2026
7 checks passed
@proggeramlug
proggeramlug deleted the fix/7148-safepoint-deferral branch August 1, 2026 07:52
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: Track A2's premise is wrong — six force_full_scan() sites, four automatic, and PERRY_CONSERVATIVE_STACK_SCAN=full fails 134 runtime tests

1 participant