Skip to content

gc: the evacuation knobs stop lying about what they gate (#7611, #6946, #7604) - #7684

Merged
proggeramlug merged 4 commits into
mainfrom
gc/7611-7604-6946-knob-liveness
Aug 9, 2026
Merged

gc: the evacuation knobs stop lying about what they gate (#7611, #6946, #7604)#7684
proggeramlug merged 4 commits into
mainfrom
gc/7611-7604-6946-knob-liveness

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Closes #7611. Closes #6946. Closes #7604.

Three knobs believed to exercise something that did not. One is deleted, one
is made real, one is made checkable. Each is reported with what was
measured, whether the knob or the measurement was inert, and how the result is
shown able to fail.


#7611PERRY_GEN_GC_EVACUATE is deleted

The knob was inert where anyone was looking; the measurement was sound.

#7611's measurement stands: identical binaries and protocol on the pinned quiet
host, only the knob differing, 0 of 96 gc-ratchet cells moved — bit-identical
medians across 12 probes × 8 counters, check exit 0. PERRY_GEN_GC=0 moved 79
cells by the same procedure, so the harness was sensitive and this knob was not.

The mechanism #7611 flagged as unconfirmed is confirmed, from the code. The
knob gated evacuation_policy_allowed: the C4b tenured→old-gen policy
evacuation in the non-copying fallback minor, plus old-page defrag selection.
Every counter the ratchet reads (copied_objects, copied_bytes, promoted_*)
comes from gc_collect_minor_copying_fast_path, which the knob never gated and
which is reached before the fallback path.

Deleted rather than given an arm, per the binding kill-policy, and for a
reason beyond "nobody exercises it": its one unique live effect was a footgun. It
vetoed gc_force_evacuate_enabled(), so an ambient PERRY_GEN_GC_EVACUATE=0
silently disarmed PERRY_GC_ZEAL — the #7154 instrument. zeal_implies_forced_evacuation
used to take that precedence arm and return without exercising zeal at all,
which is the vacuous-green shape the policy exists to catch. It now has one arm
and that arm always runs.

The branch is not deleted with the knob, because the branch has another
controller that is exercised.
evacuation_policy_allowed is still false on
every budgeted low-pause cycle, and budgeted_low_pause_minor_does_not_evacuate
asserts that arm behaviourally — nothing moved, no forwarding stub, old-page
selection skipped, trace.evacuation_policy.reason == "low_pause_non_moving".
What stopped existing is the untested configuration, not the code path.


#6946PERRY_GC_FORCE_EVACUATE on the gc() path is fixed

The knob was genuinely inert here. It is read only on the minor path while
manual_gc_collect_now ran a full mark-sweep, which never evacuates. Five suites
still drive collection as gc(); assertFreed() under it and exercised no
evacuation at all.

An explicit gc() under the knob now runs an evacuating minor first, then
the full sweep. #7657 is what made this possible: the site used to take
ManualGcScanGuard::force_full_scan, and a forced conservative scan makes the
copying minor ineligible outright (CopiedMinorFallbackReason::ConservativeStack).
With precise roots the copying minor here is exactly as sound as the full sweep
that follows.

New FullEscalation::Refused keeps the two throughput-pacing predicates
(copied_minor_promotion_handoff_due, arena_growth_full_escalation_due) from
turning that minor back into a non-moving full sweep — the original bug in a new
place. It is scoped to this one caller; every automatic path keeps Allowed, so
the array-growth-stub reclamation those predicates exist for is untouched.
Default-off knob, so an ordinary gc() is unchanged.

Shown able to fail

explicit_gc_under_forced_evacuation_runs_a_moving_minor asserts both that a
copying minor completed and that the survivor's address changed — the counter
without the address cannot tell a copying minor that moved nothing from one that
moved this object, and the address without the counter cannot tell a copying
minor from any other reason a slot changed.

Deleting the forced minor from manual_gc_collect_now and re-running:

SABOTAGED RUN rc=101
compile errors: 0
Running unittests lines: 1
test result: FAILED. 0 passed; 1 failed; …
an explicit gc() under forced evacuation must run a COPYING minor (before=0 after=0).

error[ count 0 and Running unittests present, so the red is the assertion and
not a build break.


#7604 — zeal liveness is readable, and a vacuous zeal run now fails

Here the measurement was the inert thing, and the underlying worry was still
right — with a cause the issue did not name.

What does not reproduce on current main (v0.5.1384). On
benchmarks/gc_ratchet/probes/01_nursery_churn.ts, compiled with
PERRY_GC_MOVING_LOOP_POLLS=1 and run under
PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_DIAG=1, the recipe
produced 741,630 [gc-copy-minor] ran lines with real copied_objects, and
[gc-fromspace-protect] … retired_set=#N throughout. The knob is not inert.
(The issue's own benches, gc-handoff/bench/*.ts, are not in the repo, so its
exact workload could not be re-run.)

What is real, and is worse than "the recipe sometimes doesn't fire".

  1. The documented liveness check could not be performed. CLAUDE.md said
    "check crate::gc::zeal_forced_collections() is nonzero". There was no JS
    API, no diagnostic line and no exit report through which to read it from a
    compiled program. The only alternative, PERRY_GC_DIAG=1 plus grep, wrote
    212 MB of stderr in ten minutes on that same 400k-iteration probe — so it
    was not a usable check either.
  2. A second cause of "armed but never fires", not named in the issue. A
    compute-only loop compiled with the flag contains zero
    js_gc_loop_safepoint call sites, because codegen deliberately omits the poll
    for provably alloc-free bodies (loop_purity::loop_may_allocate) and
    incidentally omits it for the specialized for / for-of / for-in
    lowerings (emit_gc_loop_safepoint's own COVERAGE note). Under zeal that
    binary collected 5 times, at event-loop boundaries only — and nothing said so.
  3. The obvious external check is wrong. nm / objdump -d BIN | grep -c js_gc_loop_safepoint reports 0 on a binary whose polls then fire 20,064
    times. An operator following that advice concludes the polls are absent when
    they are not.

The fix. Three process-global counters — copying_minor_cycles(),
moved_objects_total(), loop_polls_reached() — and a verdict at the
process-exit boundary that exits 70 when the instrument did not fire.

# an ALLOCATING loop, compiled and run with PERRY_GC_MOVING_LOOP_POLLS=1
[gc-zeal] forced_collections=20069 copying_minors=20069 moved_objects=95506 loop_polls=20064
exit 0

# the COMPUTE-ONLY loop, same flags
[gc-zeal] forced_collections=5 copying_minors=5 moved_objects=4 loop_polls=0
[gc-zeal] THIS RUN EXERCISED NOTHING WORTH TRUSTING. … PERRY_GC_MOVING_LOOP_POLLS=1
was set but NOT ONE back-edge poll was reached, so every collection came from an
event-loop boundary and no loop body was covered. …
exit 70

Note what a two-counter verdict would have done with the second run:
forced=5, copying_minors=5, moved_objects=4 — every counter says "live".
Only loop_polls says the loop was never covered. That is why there are three.

Stated limitations rather than hidden ones: process.exit() terminates via
libc::_exit and never reaches the exit boundary, as does an uncaught throw.

Shown able to fail

  • Five unit tests over the pure zeal_verdict: forced=0; cycles=0 (every
    forced collection escalated to a non-moving full sweep, which forced > 0
    alone calls live); loop_polls=0 with polls requested, using the measured
    compute-only counters; the passing case with its numbers; and a pin that
    cycles>0, moved=0 is not a failure and loop_polls=0 without the request
    is fine — so a future "tighten it" edit has to argue with a test.
  • End to end on binaries built from this branch: compute-only exits 70 where
    it previously exited 0 silently, the allocating probe exits 0 with its numbers,
    and an unzealed run of the same binary prints no [gc-zeal] line at all.

Validation

gate result
cargo test -p perry-runtime --lib --no-fail-fast 1922 passed, 0 failed, 4 ignored
cargo test -p perry-codegen --lib --no-fail-fast 761 passed, 0 failed
the 14 native_root_coverage tests 14 passed, 0 failed
cargo check --all-targets 0 errors
all 22 lint-job commands, each exit status checked 0 failures out of 22
rustup run stable cargo fmt --all -- --check clean

error[ counts and Running unittests presence were checked on every test run,
so a green line is a run that happened rather than a build that did not.

CLAUDE.md

Three corrections, content only — no version bump:

  • the escape-hatch list loses PERRY_GEN_GC_EVACUATE and gains the gc()-path
    behaviour of PERRY_GC_FORCE_EVACUATE;
  • the zeal row loses the PERRY_GEN_GC_EVACUATE=0 precedence caveat (there is no
    such knob), gains the two ways codegen emits no poll, and replaces "check
    zeal_forced_collections()" — which was not performable — with the exit
    verdict;
  • the kill-policy's PERRY_GC_FORCE_EVACUATE precedent is marked closed and
    kept, because it is the argument for the policy.

@proggeramlug
proggeramlug force-pushed the gc/7611-7604-6946-knob-liveness branch from 71a4874 to 20d3edb Compare August 9, 2026 06:20
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@proggeramlug, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 3 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9b58219b-c071-4e18-aeaa-0a316e5efd37

📥 Commits

Reviewing files that changed from the base of the PR and between 175c5fd and 20d3edb.

📒 Files selected for processing (11)
  • CLAUDE.md
  • changelog.d/7684-evacuation-knob-liveness.md
  • crates/perry-runtime/src/array/iter_methods.rs
  • crates/perry-runtime/src/gc/copying.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/policy.rs
  • crates/perry-runtime/src/gc/tests/cycle_state.rs
  • crates/perry-runtime/src/gc/tests/evacuation.rs
  • crates/perry-runtime/src/gc/tests/fromspace_protect.rs
  • crates/perry-runtime/src/gc/zeal.rs
  • crates/perry-runtime/src/promise/mod.rs

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.

Ralph Küpper added 4 commits August 9, 2026 11:38
#7604)

Three knobs believed to exercise something that did not.

#7611 -- PERRY_GEN_GC_EVACUATE is DELETED. Measured on the pinned quiet host,
identical binaries and protocol, only the knob differing: 0 of 96 gc-ratchet
cells moved (12 probes x 8 counters), bit-identical medians, `check` exit 0.
PERRY_GEN_GC=0 moved 79 cells by the same procedure, so the harness was
sensitive and this knob was not. Mechanism confirmed from the code: it gated the
non-copying fallback's policy evacuation, while every counter the ratchet reads
comes from the copying minor it never gated. Its one unique live effect was a
footgun -- it vetoed `gc_force_evacuate_enabled()`, so an ambient
`PERRY_GEN_GC_EVACUATE=0` silently disarmed PERRY_GC_ZEAL. The branch it gated
is NOT deleted with it: `evacuation_policy_allowed` is still false on every
budgeted low-pause cycle, and `budgeted_low_pause_minor_does_not_evacuate`
asserts that arm behaviourally.

#6946 -- an explicit `gc()` under PERRY_GC_FORCE_EVACUATE now runs an
EVACUATING minor before the full mark-sweep. The knob is read only on the minor
path while `gc()` ran a full sweep, which never evacuates, so five suites that
drive collection as `gc(); assertFreed()` under it exercised no evacuation at
all. #7657 removed the forced conservative scan that made a copying minor
ineligible here, so this is now possible; `FullEscalation::Refused` keeps the
pacing predicates from turning it back into a non-moving sweep.

#7604 -- zeal liveness is now readable and a vacuous zeal run FAILS.
`zeal_forced_collections()` was unreachable from a compiled program: no JS API,
no diagnostic line, no exit report, while CLAUDE.md told operators to check it.
The only alternative, PERRY_GC_DIAG=1, wrote 212 MB of stderr in ten minutes on
a 400k-iteration probe. Two process-global counters are added
(`copying_minor_cycles`, `moved_objects_total`) and the exit boundary prints
`[gc-zeal] forced_collections=N copying_minors=M moved_objects=K`, exiting 70
when N or M is zero.

Shown able to fail: removing the forced minor from `manual_gc_collect_now`
turns `explicit_gc_under_forced_evacuation_runs_a_moving_minor` red with
`before=0 after=0` (0 compile errors, `Running unittests` present); the zeal
verdict has three unit tests covering both failure causes and the pass case.
#7611 deleted the knob but memory-model.md still listed it as a live escape
hatch and the zeal row still said it could veto forced evacuation. A documented
knob that no longer exists is the same defect the deletion was for.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit — merging as v0.5.1392

Three knobs, three different verdicts, each with the measurement that produced it. That is the right output for this class.

#7611 — deleted, and for a better reason than "unexercised"

The measurement stood (0 of 96 cells, with PERRY_GEN_GC=0 moving 79 by the same procedure — so the harness was sensitive and the knob was not), and you confirmed the mechanism from the code rather than leaving it inferred: the knob gated evacuation_policy_allowed in the non-copying fallback, while every counter the ratchet reads comes from gc_collect_minor_copying_fast_path, reached first.

The footgun is what justifies deletion over an arm. It vetoed gc_force_evacuate_enabled(), so an ambient =0 silently disarmed PERRY_GC_ZEAL — a debugging tool that surfaces nothing, quietly. And zeal_implies_forced_evacuation had a precedence arm that returned without exercising zeal at all, so the test that should have caught it was itself vacuous. That is the kill-policy's own argument: a mode that still exists is a decision nobody made.

Keeping evacuation_policy_allowed false on budgeted low-pause cycles, with budgeted_low_pause_minor_does_not_evacuate asserting it behaviourally, is the right residue — the branch survives where it has a test, and dies where it didn't.

#6946 — made real; #7604 — the measurement was inert, not the knob

gc() under PERRY_GC_FORCE_EVACUATE now runs an evacuating minor before the full sweep, unblocked by #7657 removing the forced conservative scan that made the copying minor ineligible. FullEscalation::Refused scoped to that one caller is the right guard against the pacing predicates quietly reintroducing it.

And #7604's three findings are worth more than the issue was:

  1. zeal_forced_collections() — the check CLAUDE.md instructs you to run — was unreachable from a compiled binary, and the fallback wrote 212 MB of stderr in 10 minutes.
  2. A compute-only loop compiled with the flag has zero poll sites, by design for alloc-free bodies and by omission for the specialized for/for-of/for-in lowerings.
  3. nm | grep -c js_gc_loop_safepoint reports 0 on a binary whose polls fire 20,064 times.

The verdict exiting 70 rather than printing a line is the correct shape, and your own note is the proof: the compute-only run reads forced=5 copying_minors=5 moved_objects=4 loop_polls=0, so the loud line as originally proposed would have printed "5" and passed. Only loop_polls catches it. a_zeal_run_that_exercised_nothing_is_an_error and polls_requested_but_never_reached_is_an_error are exactly the two assertions that needed to exist.

One thing I fixed on the way in

docs/src/internals/memory-model.md still documented the deleted knob as a live escape hatch, and its PERRY_GC_ZEAL row still said an explicit PERRY_GEN_GC_EVACUATE=0 "still wins" — i.e. it documented the footgun as current behaviour. This PR didn't touch that file. A documented knob that no longer exists is the same defect the deletion was for, so I removed the row and rewrote the zeal entry to carry it as history. Also repointed a stale comment in array/iter_methods.rs; the three in gc/ are deliberate tombstones and I left them.

Gates: 24/24 lint, fmt clean, perry-runtime --lib 1935, cargo check --all-targets clean, and the zeal/verdict suite green.

Your correction that #7666's large-Eden escalation was not the mechanism for #7604 on any workload you could reach is noted — I had offered it as a lead and it didn't hold.

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