Skip to content

fix(gc): stop the collector moving objects at register-imprecise allocation points (#7682) - #7687

Merged
proggeramlug merged 3 commits into
mainfrom
fix/7682-stale-heap-string
Aug 9, 2026
Merged

fix(gc): stop the collector moving objects at register-imprecise allocation points (#7682)#7687
proggeramlug merged 3 commits into
mainfrom
fix/7682-stale-heap-string

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #7682.

The bug

A 189-statement tree-walking interpreter — ordinary TypeScript, no exotic
construct — returned a silently wrong number on default settings, every run:
1708662 where Node and a fully static build both give 1708840. No crash, no
TypeError, no diagnostic.

node --experimental-strip-types interp.ts   → 1708840
perry, before                               → 1708662     ← wrong, 6/6 runs
perry, after                                → 1708840      3/3, and with auto-optimize

Root cause

gc_check_trigger's nursery-churn arm collects from inside arena_cell_alloc,
i.e. at whatever half-finished expression happened to need a fresh arena block.
That program point is described by neither root lowering: the shadow stack
names only values codegen has already stored to a slot, and RS4GC relocates only
what it can type as ptr addrspace(1), which a NaN-boxed double operand in an
SSA register is not.

The arm therefore took ManualGcScanGuard::force_full_scan(). Its job there is
not retention but immobility — a conservative native-stack scan makes the
copying minor ineligible (CopiedMinorFallbackReason::ConservativeStack), so the
non-moving in-place minor runs and nothing relocates.

PERRY_GC_SCAVENGE gated that guard off. The guard is now unconditional.

Why it was conditional, and why both halves of the reason were false

The flag's doc comment said:

Phase-1 de-risking flag (OFF by default) … NOT sound as a production default
yet — the alloc point can be register-imprecise — so it stays behind this flag
for measurement … only.

Eight lines below it, the body said ON BY DEFAULT (#7056). That is the #6987
shape CLAUDE.md warns about, and this time the stale half was the one carrying
the soundness argument.

The body's own claim — "enabling this also defers alloc-point collections to a
precise safepoint" — was false too: that deferral is gated on
gc_moving_loop_polls_enabled(), OFF by default since #7161. In the shipped
configuration the two flags disagree, the deferral is dead code, and the
alloc-point minor ran right there with neither a scan nor a safepoint.

The failure, end to end

evalNode lowers { names: [n.name], … } by reading n.name into a register,
then inline-bump-allocating the one-element array. The bump overflows its block,
js_inline_arena_slow_allocarena_cell_allocgc_check_trigger runs an
evacuating minor, and the string moves. Control returns to the merge block —
which LLVM tail-merged across the fast and slow paths — and stores the pre-move
address into the new array. lookup then compares names[i] === name, a live
string against a moved one, falls through to its default, and naive fib, which
is just a count of leaves returning 1, comes back short by exactly the number
of missed lookups.

Localised with PERRY_GC_PROTECT_FROMSPACE=1 DEPTH=800 PERRY_GC_ZEAL=1, then
disassembled: the reported frame was the caller (the faulting callee is a
frameless leaf), and the faulting instruction is js_string_addref_if_heap_string
reading refcount at user_ptr + 12 on the value the tail-merged block just
stored.

Why every existing gate was green

gate verdict why it cannot see this
PERRY_GC_VERIFY_MARK OK marking is correct; the wrong holder is a register, so there is nothing in the heap to find
PERRY_GC_FROMSPACE_SCAN no live offender every owner it reports is marked=false, i.e. already dead — same reason
gc_root_dominance_check.py 0 violations / 380 root stores the value is never bound to a slot, so there is no store whose dominance it could question
the test_gap_gc_* probe corpus green every probe holds its subject across an explicit churn call; none holds one across the allocation of the literal being built

Tests

  • the_alloc_point_nursery_minor_retains_native_stack_values_under_shipped_pacing
    drives the arm with a value reachable only from a live native-stack word and
    asserts it survives, that a collection actually ran, and that the census
    counted the forced scan.
  • the_alloc_point_plant_dies_when_the_scan_is_pinned_off is its sabotage
    control: identical plant, scan pinned off, plant must DIE. Without it, "the
    malloc sweep never ran" and "the guard held" are the same green.
  • policy::force_shipped_default_gc_pacing pins polls OFF + scavenge ON.
    That combination had no test guard — force_legacy_gc_pacing pins both off,
    force_moving_gc_pacing pins both on — so every test in the crate declared a
    pacing mode in which the two flags agreed, and the interaction that broke is
    exactly the one where they disagree.
  • test-files/test_gap_gc_alloc_point_no_move.ts is the interpreter itself,
    diffed byte-for-byte against Node, registered in
    test-parity/gc_repsel_corpus.txt so gc-moving-witnesses runs it.

Verified non-vacuous both ways. The gap probe prints 1708662 against the
pre-fix runtime under the harness's own PERRY_NO_AUTO_OPTIMIZE=1; and with the
one-line guard reverted, the new unit test fails.

Cost, stated plainly

Alloc-point nursery collections are non-moving again — what they were before
#7056. Copying minors keep running at the precise safepoints
(gc_safepoint_moving_minor), where the root set is real. PERRY_GC_SCAVENGE
keeps its other job (routing nursery-churn triggers to the direct minor rather
than the budgeted non-moving stepper) and is now documented as the pacing knob
it is.

The gc-ratchet baseline pins the old, evacuating behaviour and will report a
breach on the evacuation counters.
That artifact is regenerated only when a
change is deliberately accepted, on the pinned quiet host; this is such a change.
Measured delta is in the comments below.

The way to get the evacuation back soundly is to make the alloc point precise,
or to let the copying minor run with conservative roots as pinning sources
rather than refusing outright (CopiedMinorEligibility::evaluate_with_stack_decision
returns ConservativeStack unconditionally today). Both are their own change
with their own proof obligation, and neither belongs in a P0 correctness fix.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed allocation-point garbage collections so live values in imprecise registers are retained safely.
    • Ensured these collections remain non-moving while preserving moving collections at precise safepoints.
    • Clarified that garbage-collection pacing settings control timing, not collection safety or object movement.
  • Tests

    • Added regression coverage for native-stack values, allocation-point collections, and shipped pacing defaults.
    • Added interpreter-based tests covering repeated allocations, closures, recursion, and cyclic environments.
  • Documentation

    • Updated garbage-collection guidance to explain collection pacing and movement behavior.

proggeramlug pushed a commit that referenced this pull request Aug 9, 2026
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4dc33028-25c0-4506-99a6-b170324ae6ab

📥 Commits

Reviewing files that changed from the base of the PR and between 514b6e9 and a6a94b2.

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

📝 Walkthrough

Walkthrough

Allocation-point nursery collections now force conservative scanning and remain non-moving. Runtime tests cover native-stack retention under shipped pacing. A TypeScript interpreter regression and corpus entry validate correct output and allocation-point behavior.

Changes

Allocation-point GC safety

Layer / File(s) Summary
GC policy and pacing contract
crates/perry-runtime/src/gc/policy.rs, crates/perry-runtime/src/gc/mod.rs
Allocation-point minors now force conservative scanning. PERRY_GC_SCAVENGE controls pacing and no longer controls evacuation eligibility. Documentation records the shipped defaults.
Native-stack retention regressions
crates/perry-runtime/src/gc/tests/scan_fallback.rs
Tests verify native-stack pointer retention under shipped pacing and collection behavior when conservative scanning is disabled.
Interpreter regression corpus
test-files/test_gap_gc_alloc_point_no_move.ts, test-parity/gc_repsel_corpus.txt
A recursive interpreter workload and corpus entry validate non-moving allocation-point collections through expected output and checksum results.
Changelog behavior record
changelog.d/7687-alloc-point-collections-must-not-move.md
The changelog records the relocation failure, corrected collection behavior, pacing semantics, artifact regeneration, and regression coverage.

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

Possibly related issues

Possibly related PRs

  • PerryTS/perry#7019 — Modifies GC scavenge and moving-minor behavior in the same runtime modules.
  • PerryTS/perry#7020 — Adds related test-only GC pacing controls in gc/policy.rs.
  • PerryTS/perry#7166 — Modifies ManualGcScanGuard::force_full_scan and conservative-scan fallback behavior.

Sequence Diagram(s)

sequenceDiagram
  participant GeneratedCode
  participant gc_check_trigger
  participant ManualGcScanGuard
  participant NativeStack
  GeneratedCode->>gc_check_trigger: request allocation-point collection
  gc_check_trigger->>ManualGcScanGuard: force_full_scan()
  ManualGcScanGuard->>NativeStack: conservatively scan imprecise roots
  NativeStack-->>GeneratedCode: retain pointer without evacuation
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main GC correctness fix for allocation-point collections.
Description check ✅ Passed The description provides a detailed summary, root cause, linked issue, implementation changes, tests, and behavior impact, despite not mirroring every template heading.
Linked Issues check ✅ Passed The changes address issue #7682 by preventing unsafe allocation-point movement, adding regression coverage, and registering the interpreter workload in the GC corpus.
Out of Scope Changes check ✅ Passed The documentation, changelog, pacing test support, regression tests, and corpus updates directly support the linked GC correctness objective.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ 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/7682-stale-heap-string

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 3 commits August 9, 2026 09:14
`gc_check_trigger`'s nursery-churn arm collects from inside `arena_cell_alloc`
— at whatever half-finished expression needed a fresh block. Neither root
lowering describes that point: the shadow stack names only values already
stored to a slot, and RS4GC relocates only what it can type as
`ptr addrspace(1)`, which a NaN-boxed `double` operand is not. The
`force_full_scan()` there exists for immobility, not retention: a conservative
scan makes the copying minor ineligible so nothing relocates.

`PERRY_GC_SCAVENGE` gated that guard off, on the strength of a doc comment
claiming the flag was "OFF by default … for measurement only" (it has been ON
since #7056) and a body comment claiming it "defers alloc-point collections to
a precise safepoint" (that deferral is gated on `gc_moving_loop_polls_enabled`,
OFF since #7161). So the shipped default ran an evacuating minor at a
register-imprecise point, and a tree-walking interpreter silently returned
1708662 instead of 1708840 — every run, no crash, no diagnostic.

The guard is now unconditional. Scavenge keeps its pacing job; copying minors
keep running at the precise safepoints.

@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/policy.rs (1)

1880-1891: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Also correct the stale lead-in comment above this arm.

This block states the correct facts: PERRY_GC_SCAVENGE is ON by default since #7056, and the scan-skip is gone. The lead-in comment for the same if still states the opposite:

  • Line 1761 reads PERRY_GC_SCAVENGE (Phase-1 de-risking, OFF by default).
  • Line 1767 points the reader to "the force_full_scan skip below", which no longer exists.
  • Lines 1775-1777 describe gc_scavenge_enabled() as the branch "which skips the conservative scan HERE".

One function now carries two comments that disagree about the same flag. That is the same two-comments-disagree shape this PR identifies as the cause of #7682. Update lines 1761-1778 so the arm has a single description.

♻️ Suggested rewrite of the stale lead-in text
-    // PERRY_GC_SCAVENGE (Phase-1 de-risking, OFF by default): when the budgeted
-    // stepper is NOT blocked (all scanners budgeted), the nursery-churn triggers
+    // PERRY_GC_SCAVENGE (PACING, ON by default since `#7056`): when the budgeted
+    // stepper is NOT blocked (all scanners budgeted), the nursery-churn triggers
     // fall through to the budgeted mutator-assist step below, which is
     // deliberately non-moving (`low_pause_non_moving = is_budgeted()`), so a
     // reallocation-heavy loop's minors free nothing. Route those triggers to the
-    // direct (non-budgeted, atomic) minor here instead so the copying/evacuating
-    // fast path can run (see the `force_full_scan` skip below).
+    // direct (non-budgeted, atomic) minor here instead of leaving them to the
+    // stepper. The flag decides WHICH collector runs the trigger; it does not
+    // decide whether that collector may move (`#7682`).
-    // (`js_gc_loop_safepoint` → `gc_safepoint_moving_minor`), NOT here at the
-    // register-imprecise alloc point. Unlike `gc_scavenge_enabled()` (which skips
-    // the conservative scan HERE — sound only if the alloc point is precise), the
-    // loop-polls path never reaches the skip: it always defers to a real
-    // safepoint.
+    // (`js_gc_loop_safepoint` → `gc_safepoint_moving_minor`), NOT here at the
+    // register-imprecise alloc point. The alloc-point minor below is
+    // unconditionally non-moving (`force_full_scan`, `#7682`), so the loop-polls
+    // route is what makes an EVACUATING nursery collection possible at all.
🤖 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/policy.rs` around lines 1880 - 1891, Update the
stale lead-in comment for the same conditional arm, removing the claim that
PERRY_GC_SCAVENGE is off by default and the references to the nonexistent
force_full_scan skip or scan-skipping behavior. Make the comment consistently
state that the flag is on by default since `#7056` and that the scan-skip path is
gone, matching the surrounding arm comment.
🤖 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/policy.rs`:
- Around line 1880-1891: Update the stale lead-in comment for the same
conditional arm, removing the claim that PERRY_GC_SCAVENGE is off by default and
the references to the nonexistent force_full_scan skip or scan-skipping
behavior. Make the comment consistently state that the flag is on by default
since `#7056` and that the scan-skip path is gone, matching the surrounding arm
comment.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 55a72ec5-905c-4e13-b4cb-29c61b3ef514

📥 Commits

Reviewing files that changed from the base of the PR and between e117e86 and 8425db7.

📒 Files selected for processing (6)
  • changelog.d/7687-alloc-point-collections-must-not-move.md
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/policy.rs
  • crates/perry-runtime/src/gc/tests/scan_fallback.rs
  • test-files/test_gap_gc_alloc_point_no_move.ts
  • test-parity/gc_repsel_corpus.txt

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit — merging as v0.5.1389. P0 confirmed and fixed.

Reproduced independently, both arms built --profile perry-dev with PERRY_RUNTIME_DIR pinned, PERRY_NO_AUTO_OPTIMIZE=1:

arm 6 consecutive runs
main 1708662 ×6 — wrong, deterministic
this branch 1708840 ×6
node 26.5.1 1708840

A silently wrong number on default settings, from ordinary TypeScript. No crash, no diagnostic. That is the worst failure shape this project has.

The root cause is the one that matters most

That program point is described by neither root lowering: the shadow stack names only values codegen has already stored to a slot, and RS4GC relocates only what it can type as ptr addrspace(1), which a NaN-boxed double operand in an SSA register is not.

So the guard's job at the alloc point is not retention but immobility — the conservative scan makes the copying minor ineligible, and that is the entire mechanism keeping a register-held value valid across a collection nobody can describe. Gating it on a pacing flag traded a cost problem for a soundness problem, which is exactly what #7148 forbids.

And both halves of the reason it was conditional were false. The doc comment said "OFF by default … NOT sound as a production default"; eight lines below, the body said "ON BY DEFAULT (#7056)". That is the #6987 shape CLAUDE.md warns about — "a merge decision was made on the wrong one" — and this time the stale half was the one carrying the soundness argument. The body's fallback claim (that enabling it also defers to a precise safepoint) was gated on gc_moving_loop_polls_enabled(), off by default since #7161, so in the shipped configuration the deferral was dead code and the two flags disagreed.

Sabotage verified independently

Restoring the (!gc_scavenge_enabled()).then(…) gate on the nursery-churn arm reddens the_alloc_point_nursery_minor_retains_native_stack_values_under_shipped_pacing while its control the_alloc_point_plant_dies_when_the_scan_is_pinned_off stays green — error[ 0, test binary reached. (My first attempt re-gated the old-reclaim arm by mistake and reddened a different test; that sabotage proved nothing and I redid it.)

The control is the part I want to single out: "the malloc sweep never ran" and "the guard held" are the same green without it. That is the distinction four of this repo's gates have failed to make.

force_shipped_default_gc_pacing is the other real finding — every test in the crate declared a pacing mode in which the two flags agreed, so the one interaction that breaks had no guard at all.

Why every existing gate was green

The table in the description is correct and worth keeping: VERIFY_MARK sees correct marking; FROMSPACE_SCAN reports only marked=false owners; the dominance checker has no store whose dominance it could question, because the value is never bound to a slot; and every test_gap_gc_* probe holds its subject across an explicit churn call rather than across the allocation of the literal being built. Four instruments, one blind spot, and the new probe is shaped to sit in it.

Gates: 24/24 lint, fmt clean, perry-runtime --lib 1930, perry-codegen --lib 778, and the gap test is registered in gc_repsel_corpus.txt so gc-moving-witnesses actually runs it rather than it being a dark test.

The cost, and the immediate follow-up

Alloc-point nursery collections are non-moving again — what they were before #7056 — with copying minors still running at the precise safepoints where the root set is real. That is the right trade for a correctness bug of this shape.

This PR does not carry the ratchet re-pin, so gc-ratchet will breach on the evacuation counters the moment it lands. I am merging anyway because a deterministic wrong answer on default settings outranks a red measurement gate — but the re-pin is now the top of my queue, on the pinned quiet mini, with the delta recorded as a deliberate acceptance. Anyone reading a red gc-ratchet before that lands should attribute it here.

The two sound routes you name for getting the evacuation back — make the alloc point precise, or let the copying minor treat conservative roots as pinning sources rather than refusing outright (evaluate_with_stack_decision returns ConservativeStack unconditionally today) — are the right framing, and correctly out of scope for a P0 fix.

@proggeramlug
proggeramlug force-pushed the fix/7682-stale-heap-string branch from a6a94b2 to ac3b7ab Compare August 9, 2026 07:27
@proggeramlug
proggeramlug merged commit 3273a56 into main Aug 9, 2026
@proggeramlug
proggeramlug deleted the fix/7682-stale-heap-string branch August 9, 2026 07:27
proggeramlug pushed a commit that referenced this pull request Aug 9, 2026
The #7687 merge landed without its bump: the branch was based on 0.5.1387
and my sed targeted 1388, so it matched nothing and the commit was empty.

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

Copy link
Copy Markdown
Contributor Author

Measured cost, and why it is recoverable

Pinned quiet host (load 1.4), 5 repeats, the interpreter probe. Same binary in
every arm except D, which is a separate compile because the poll flag is
compile-time as well as run-time.

arm wall peak RSS answer
A fixed default — non-moving at the alloc point 9.06 s 58.2 MB 1708840 ✓
B PERRY_CONSERVATIVE_STACK_SCAN=off — the pre-fix code path 4.78 s 88.4 MB 1708839 ✗
C fixed + PERRY_GC_SCAVENGE=0 6.88 s 102.8 MB 1708840 ✓
D fixed + PERRY_GC_MOVING_LOOP_POLLS=1 4.85 s 88.4 MB 1708840 ✓

B is a one-env-var reproducer on the fixed binaryPERRY_CONSERVATIVE_STACK_SCAN=off
beats force_full_scan (asserted by conservative_scan_env_off_still_beats_a_forced_scan),
so it re-enters the exact pre-fix path: 37 copying minors, wrong answer, same
compiler, same runtime. That is the cleanest A/B available and it is what the
table is built on.

Two things worth reading off it:

  1. This is not a memory regression. A retains 34% less than the unsound
    arm it replaces. The nursery cap plus a non-moving minor keeps a lower
    high-water mark here than evacuation does. The trade is time, not footprint.
  2. The time is fully recoverable, soundly. Arm D runs the same 37 copying
    minors
    as the unsound arm, at the same RSS, within noise of its wall time,
    and gets the right answer — because a back-edge poll puts the copying minor
    at a declared safepoint, where the root set is real.
    PERRY_GC_MOVING_LOOP_POLLS is off by default only as fix(gc): disable evacuating minor by default pending #7154 (use-after-free on dynamically-added fields) #7161's stopgap for
    GC: evacuating minor drops an old-to-young field[1] edge, crashing with 'value is not a function' #7154, whose class now has its own static gate (gc-root-dominance.yml) with
    an empty allowlist. Re-examining that stopgap is the obvious follow-up, and
    this change is a precondition for it rather than an obstacle.

The collector was already printing this

PERRY_GC_DIAG=1, default env, pre-fix:

[gc-copy-minor] ran copied_objects=6149 … trigger=ArenaBytes declared_safepoint=false

37 of those, and zero nursery_churn_slack_valve scan fallbacks. After the fix
it inverts exactly: 0 copying minors, 376 valve fires. declared_safepoint=false
on a [gc-copy-minor] ran line is the bug in one field.

It also restores a claim already checked in

test-parity/gc_matrix_inert_arms.txt registers the default matrix arm as:

Shipped config + pressure. … It still COLLECTS; it cannot scavenge.

main contradicted that. After this change the registry entry is true again.

Gates

  • gc-native-roots' evacuation arms set PERRY_CONSERVATIVE_STACK_SCAN=off, which
    beats the guard, so they are unaffected — gc_evacuation_liveness_assert.py still
    sees its copying minors.
  • gc-moving-witnesses runs the loop_polls arm, which defers to a safepoint and
    never reaches the guard; the new probe relocates there like the rest of the family.
  • gc-ratchet will breach on the evacuation counters (copied_objects,
    freed_bytes) — that artifact pins the old behaviour and needs regenerating on
    the pinned host as a deliberate acceptance. It is not a required context.

Known sharp edge, not changed here

PERRY_CONSERVATIVE_STACK_SCAN=0/off still opts back into the unsound
configuration — that is arm B. It is documented as a bisection escape hatch and a
test pins the precedence, so narrowing it is a separate decision; but it is worth
knowing that this hatch now buys wrong answers, not merely less retention.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Update: the fix now costs nothing

The soundness fix alone was not shippable, and chasing that produced two more
changes. Final state on the pinned quiet host, iso_FIB, 5 repeats:

wall peak RSS answer
main before this PR 4.78 s 88.4 MB 437839 ✗
this PR 4.80 s 88.4 MB 437840 ✓
(1) alone — the guard 9.06 s 58.2 MB
(1)+(2), polls off (PERRY_GC_MOVING_LOOP_POLLS=0) 5.20 s 248.7 MB

Same speed, same footprint, right answer. 37 copying minors — the same count the
unsound path ran — and now all 37 report declared_safepoint=true, where
before every one said false. Zero alloc-point valve fires.

(2) The nursery cap applies only when the minor can evacuate

Fixing (1) alone took test_gap_gc_index_get_receiver_rooting from 0.66 s to
6.6 s
. That is a livelock, not a trade: the cap's basis is
copying_from_space_in_use_bytes(), which a non-moving minor never reduces —
it sweeps in place and from-space stays occupied — so a capped trigger firing a
non-moving minor is due again on the very next block. One whole-arena collection
per 1 MB allocated. Confirmed with no rebuild at all, via the tuning dial:
PERRY_GC_SCAVENGE_NURSERY_MB=4096 takes that test to 0.13 s. Same shape as
#7592.

#7056's own 2x2 already said the cap and the evacuating minor "ship together,
because either alone is a bad trade". That was advisory; it is now load-bearing.

(3) Moving-loop back-edge polls default-ON (#7161's stopgap retired)

Both conditions #7161 named for putting this back are met:

And after (1), leaving it off was the more dangerous state. Nursery pressure
has exactly two precise collection points — this poll and the microtask-pump
boundary — and a compute-only program reaches neither with polls off. "Polls
off" never meant "collect later, precisely"; it meant "never collect precisely
at all".

The flip is less of a leap than it looks. gc-moving-witnesses runs
gc_repsel_matrix.sh --arms loop_polls --filter test_gap_gc_ on every PR, and
loop_polls is the configuration this makes default. The whole 56-file
test_gap_gc_* corpus has therefore been gated in exactly this mode all along,
with UNVER rejected as hard as FAIL. The matrix header's warning that these arms
were red on 14 of 20 files when they first moved is about a state that job has
been holding ever since.

The four #7161 entries in test-parity/gc_matrix_inert_arms.txt named this
flip as their deletion condition and are deleted. One of them — default | It still COLLECTS; it cannot scavenge — was false on main for the whole time it
was listed
: the shipped default was scavenging, at the allocation point. The
liveness gate checks that a listed arm has not started biting again; nothing
checked whether the reason given for listing it was still true.

Two tests changed, and neither was waved through

generator_attach_prototype's pair (#7577) failed on their own live-subject
assertion — "subject not live — no copying minor moved the receiver during the
call, so this test proved nothing"
— not on a wrong address. They inject their
collection at an allocation point, which (1) makes unconditionally non-moving, so
nothing relocated and they correctly refused to pass. They now pin the
conservative scan off across the injected window (AllocPointRelocationGuard,
restored on drop including on a panicking assert) — the documented lever that
beats force_full_scan, and the one the matrix's %E% arms use. What they
assert is unchanged: a runtime helper must not bind a receiver's address across
its own allocation.
(1) removes one route to that hazard in the shipped
default; it does not make the helper correct, and
PERRY_CONSERVATIVE_STACK_SCAN=off is a supported configuration where the route
is still open.

test_effective_arena_trigger_respects_armed_values mirrors the cap gate, so it
follows it.

Local verification

check result
interpreter / iso_FIB / gap probe, default env correct, 3/3 each, byte-equal to Node
[gc-copy-minor] declared_safepoint 37/37 true (was 0/37)
from-space protector, DEPTH=800, default env clean, instrument live (38 retired page-sets)
gc_root_dominance_check.py under polls-ON codegen 0 violations / 372 root stores
new alloc-point unit test with the guard reverted FAILS, as it must
gap probe against the pre-fix runtime 341590 vs oracle 341768 — non-vacuous

Residual risk, stated

The rest of the gap corpus (479 tests) under polls-ON is not gated per-PR —
parity is tag-gated — and I could not run it locally (the box is at 100% disk).
gc-moving-witnesses covers the test_gap_gc_* slice, which is the slice this
class lives in, but a broader sweep before merge would be worth someone's disk.

gc-ratchet pins the pre-change evacuation accounting and needs regenerating on
the pinned host.

@proggeramlug proggeramlug changed the title fix(gc): an allocation-point collection must not move anything (#7682) fix(gc): stop the collector moving objects at register-imprecise allocation points (#7682) Aug 9, 2026
proggeramlug pushed a commit that referenced this pull request Aug 9, 2026
…retired)

Follow-up to #7687, which landed only the first of three changes. Both
conditions #7161 named for putting this back are met: its correctness reason
closed with #7154 on 2026-08-01, and its codegen-quality reason is discharged by
its own stated condition — emit_gc_loop_safepoint already consults
loop_purity::loop_may_allocate, so vectorizable loops stay call-free.

After #7687 leaving it off is the more dangerous state. Nursery pressure has
exactly two precise collection points, this poll and the microtask-pump
boundary, and a compute-only program reaches neither with polls off — so every
nursery collection lands at the register-imprecise alloc point, which #7687
correctly refuses to let move. 'Polls off' does not mean 'collect later,
precisely'; it means 'never collect precisely at all'.

Also repairs the two #7577 generator witnesses: they inject their collection at
an alloc point, which now neither moves (#7687's guard) nor happens there (the
deferral), so both failed on their own live-subject assertion. They pin
shipped-default pacing plus a scan override and assert the same invariant.
proggeramlug added a commit that referenced this pull request Aug 9, 2026
…#7682 (#7690)

* fix(gc): the scavenge nursery cap applies only when the minor can evacuate

The cap's basis is copying_from_space_in_use_bytes(), which a NON-MOVING minor
does not reduce — it sweeps in place and from-space stays occupied. So once
#7682 forced the alloc-point minor non-moving, a capped trigger was due again
on the very next block: one whole-arena collection per 1 MB allocated.
Measured on the quiet host, test_gap_gc_index_get_receiver_rooting went
0.66s -> 6.6s, and 0.13s with the cap lifted — a livelock, not the
'+23% wall for -33% RSS' the cap-only cell of #7056's 2x2 measured (every
collection there still evacuated).

Restores the pre-#7056 gating on gc_moving_loop_polls_enabled, so the cap
returns automatically, and in the configuration it was measured in, whenever
that flag goes default-ON again.

(cherry picked from commit bc06b69)

* test(gc): mirror the new nursery-cap gate in the trigger arithmetic test

(cherry picked from commit f39ec36)

* docs(gc-matrix): the shipped default reaches the safepoint route again

(cherry picked from commit d5d8409)

* docs(gc): correct the alloc-point prose scavenge no longer skips the guard

(cherry picked from commit 514b6e9)

* fix(gc): moving-loop back-edge polls default ON again (#7161 stopgap retired)

Follow-up to #7687, which landed only the first of three changes. Both
conditions #7161 named for putting this back are met: its correctness reason
closed with #7154 on 2026-08-01, and its codegen-quality reason is discharged by
its own stated condition — emit_gc_loop_safepoint already consults
loop_purity::loop_may_allocate, so vectorizable loops stay call-free.

After #7687 leaving it off is the more dangerous state. Nursery pressure has
exactly two precise collection points, this poll and the microtask-pump
boundary, and a compute-only program reaches neither with polls off — so every
nursery collection lands at the register-imprecise alloc point, which #7687
correctly refuses to let move. 'Polls off' does not mean 'collect later,
precisely'; it means 'never collect precisely at all'.

Also repairs the two #7577 generator witnesses: they inject their collection at
an alloc point, which now neither moves (#7687's guard) nor happens there (the
deferral), so both failed on their own live-subject assertion. They pin
shipped-default pacing plus a scan override and assert the same invariant.

* docs(changelog): fragment for the pacing follow-up

* docs(changelog): key the fragment on PR #7690

* docs: the polls default is ON again — CLAUDE.md and the rooting-invariant doc

Lost in the cherry-pick onto main (the commit carrying them conflicted on two
unrelated files and was re-applied code-only). Both statements would be false
the moment this branch lands, which is the exact defect class the branch is
about. Also corrects this PR's own earlier draft of the PERRY_GC_SCAVENGE
kill-policy note, which claimed the knob was near-inert on the strength of a
disjunct that does not hold under the default incremental stepper.

* review(#7690): the pacing guard is the kill switch, not the default

CodeRabbit's Major finding, and it is right: force_shipped_default_gc_pacing()
pins polls OFF, which stopped being the shipped default in the same PR that
introduced the guard. Every test naming it was claiming to assert the default
while asserting the kill switch.

- renamed to force_alloc_point_minor_pacing() and documented as the
  PERRY_GC_MOVING_LOOP_POLLS=0 configuration it selects;
- the three tests that use it renamed to say so, and the #7682 regression test
  keeps its assertion: '=0' is supported, and moving the collection elsewhere by
  default is no reason to let the alloc-point minor relocate when a user turns
  that route off;
- added the_shipped_default_defers_the_trigger_out_of_the_callees_window, the
  default-paced witness the review asked for, in the only non-vacuous form
  available: under the default there is no collection inside the callee to
  relocate anything, so it asserts the routing that removed it (no collection +
  GC_SAFEPOINT_PENDING set, which is also its live-subject check).

Also: scoped the gc-ratchet baseline note as explicitly out of scope rather than
leaving it ambiguous, and fixed the second stale 'the poll is off by default'
claim in the rooting-invariant doc (line 27 was corrected in 2f0fe92, lines
53-54 were not).

* fix(gc): defer the back-edge-poll default flip; keep the nursery-cap fix

The flip costs 6.6x on #7480's kernel -- a poll is a call, so the element-shape
fast clone's call-free admission declines and control falls to the slow arm --
and it makes its own regression test 2.4x slower (0.32s -> 0.76s). The
nursery-cap fix alone takes index_get from 6.94s to 0.11s with #7480 unchanged.

Part 2 wants per-arm poll emission, which is a separate change.

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

* chore: bump version to 0.5.1393

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

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug pushed a commit that referenced this pull request Aug 9, 2026
…he doc (#7690)

#7690 wrote the entire default-ON argument into two doc comments — the runtime's
`moving_loop_polls_enabled_from_env` and codegen's `moving_safepoint_polls_enabled`
— and changed neither body. Both still matched `1|on|true`, i.e. default OFF, and
no test pinned the default in either direction, even though the runtime predicate
had been factored out expressly to make it "unit-testable without touching process
env".

That is not a slower configuration, it is a different collector. Nursery pressure
has exactly two precise collection points, the loop back-edge poll and the
outermost microtask-pump boundary. With no poll emitted, a compute-only program
reaches neither, so every nursery collection happened at the register-imprecise
allocation point — where #7687 had just made it correctly non-moving. The shipped
result was a collector with no nursery evacuation at all.

Measured on the quiet bench host, best-of-3, `PERRY_NO_AUTO_OPTIMIZE=1` with a
pinned `PERRY_RUNTIME_DIR`, against `a853135aa` binaries rerun back-to-back on the
same host:

| bench | main | this | a853135 |
|---|--:|--:|--:|
| churn | 1.01 | 0.45 | 0.66 |
| churn_alloc | 0.90 | 0.42 | 0.36 |
| push_cls | 0.89 | 0.40 | 0.34 |
| retain | 2.33 | 1.37 | 1.33 |
| tree | 5.06 | 1.63 | 5.97 |
| tree_wide | 7.26 | 2.11 | 12.38 |
| cycles | 0.29 | 0.19 | 0.96 |

`churn_alloc` ran 13 whole-arena full collections (0.477 s of pause) where the same
program at `a853135aa` ran 105 copying minors (0.016 s). `tree`'s GC pause falls
4.107 s -> 0.626 s and its max pause 266 ms -> 23 ms; `trace_worklist` drops from
2,877 ms out of the top six phases entirely.

The #7161 blocker that made polls-off a stopgap is separately discharged: a poll
at every back-edge defeated the #7480 element-shape fast clone, and step 4 of that
work now refuses to emit a poll inside a call-free-by-construction clone. Measured
both ways, `churn_read` is 0.02 s.

Costs, measured rather than argued: `deeplist` 0.03 -> 0.33 and `retain1`
0.03 -> 0.42. Both are workloads whose heap stays under the initial 64 MB
threshold, so they previously ran ZERO collections and the moving nursery is pure
added cost; both still beat `a853135aa` (1.09 / —). `push_num` 0.16 -> 0.17.

Three tests pin what was unpinned: `polls_default_is_on` and its codegen mirror
`moving_safepoint_poll_default::unset_emits_the_poll` each pin one half against
the full spelling table, and `polls_default_matches_codegen_mirror` pins that the
two crates agree — the disagreement is silent in both directions, so it needs its
own assertion rather than being left to two doc comments claiming they match.
proggeramlug added a commit that referenced this pull request Aug 9, 2026
…he doc (#7690, #7682) (#7721)

* fix(gc): make the moving-loop poll default ON in the code, not just the doc (#7690)

#7690 wrote the entire default-ON argument into two doc comments — the runtime's
`moving_loop_polls_enabled_from_env` and codegen's `moving_safepoint_polls_enabled`
— and changed neither body. Both still matched `1|on|true`, i.e. default OFF, and
no test pinned the default in either direction, even though the runtime predicate
had been factored out expressly to make it "unit-testable without touching process
env".

That is not a slower configuration, it is a different collector. Nursery pressure
has exactly two precise collection points, the loop back-edge poll and the
outermost microtask-pump boundary. With no poll emitted, a compute-only program
reaches neither, so every nursery collection happened at the register-imprecise
allocation point — where #7687 had just made it correctly non-moving. The shipped
result was a collector with no nursery evacuation at all.

Measured on the quiet bench host, best-of-3, `PERRY_NO_AUTO_OPTIMIZE=1` with a
pinned `PERRY_RUNTIME_DIR`, against `a853135aa` binaries rerun back-to-back on the
same host:

| bench | main | this | a853135 |
|---|--:|--:|--:|
| churn | 1.01 | 0.45 | 0.66 |
| churn_alloc | 0.90 | 0.42 | 0.36 |
| push_cls | 0.89 | 0.40 | 0.34 |
| retain | 2.33 | 1.37 | 1.33 |
| tree | 5.06 | 1.63 | 5.97 |
| tree_wide | 7.26 | 2.11 | 12.38 |
| cycles | 0.29 | 0.19 | 0.96 |

`churn_alloc` ran 13 whole-arena full collections (0.477 s of pause) where the same
program at `a853135aa` ran 105 copying minors (0.016 s). `tree`'s GC pause falls
4.107 s -> 0.626 s and its max pause 266 ms -> 23 ms; `trace_worklist` drops from
2,877 ms out of the top six phases entirely.

The #7161 blocker that made polls-off a stopgap is separately discharged: a poll
at every back-edge defeated the #7480 element-shape fast clone, and step 4 of that
work now refuses to emit a poll inside a call-free-by-construction clone. Measured
both ways, `churn_read` is 0.02 s.

Costs, measured rather than argued: `deeplist` 0.03 -> 0.33 and `retain1`
0.03 -> 0.42. Both are workloads whose heap stays under the initial 64 MB
threshold, so they previously ran ZERO collections and the moving nursery is pure
added cost; both still beat `a853135aa` (1.09 / —). `push_num` 0.16 -> 0.17.

Three tests pin what was unpinned: `polls_default_is_on` and its codegen mirror
`moving_safepoint_poll_default::unset_emits_the_poll` each pin one half against
the full spelling table, and `polls_default_matches_codegen_mirror` pins that the
two crates agree — the disagreement is silent in both directions, so it needs its
own assertion rather than being left to two doc comments claiming they match.

* perf(gc): stop paying a shape-layout hash lookup per traced object for a disabled counter

`heap_payload_slot_selection` runs once per traced object per GC walk (mark,
rewrite, verify). For every GC_TYPE_OBJECT it computed
`raw_numeric_object_slots` via `with_typed_descriptor_for_query` — a per-object
map probe plus, for every class instance, a `SHAPE_LAYOUTS` hash lookup behind a
TLS RefCell borrow.

That number has exactly one consumer,
`record_layout_raw_numeric_object_field_range_skipped`, which returns on its
first line unless PERRY_GC_LAYOUT_SCAN_TRACE armed the counter. So the shipped
collector paid a hash lookup per object to produce a number nothing read — the
same shape as #7702, where a facility disabled at runtime was still having its
arguments evaluated. Gate the computation on `layout_scan_trace_active()`.

Second item, same walk: `shape_shared_pointer_mask` returned
`shape_shared_descriptor(user_ptr).map(|d| d.pointer_mask)`, cloning the whole
`TypedLayoutDescriptor` to keep one of its two masks. `LayoutSlotMask` is
`Heap(Vec<u64>)` above 64 slots, so a traced wide object allocated and freed a
second vector — the `raw_f64_mask` — on every walk. Borrow through
`with_shape_shared_descriptor` and clone only the mask returned;
`shape_shared_descriptor` had no other caller and is removed rather than left
as dead code.

* test(gc): declare the pacing the alloc-point rooting tests actually assert at

Four `runtime_roots` tests took no pacing guard, so they inherited the process
default — which this stack changes. They are not asserting about the default;
they are asserting that a specific runtime helper's object survives a collection
that happens at the allocation point, and they reach that collection through the
direct alloc-point minor.

Under moving-loop polls that pressure is deferred to a precise safepoint, and a
Rust unit test has no loop back-edge poll to drain it, so no collection runs and
`assert_automatic_minor_gc_progressed` reports neither a finished assist nor an
ACTIVE budgeted cycle.

`force_legacy_gc_pacing` is the wrong repair and the tests say so themselves.
Three of them carry an evacuation witness — "the minor did not evacuate, so
nothing here was exercised and a green result would be meaningless" — and legacy
pacing hands the work to the budgeted stepper, which is deliberately non-moving.
Pinning it turns a failed assist assertion into a failed liveness assertion,
which is the witness doing its job. `force_alloc_point_minor_pacing` (polls OFF,
scavenge ON) is the one combination in which both halves hold, and it is the
configuration these tests were written against.

`symbol_description` has no evacuation witness and takes `force_legacy_gc_pacing`.

The moving default's rooting coverage for these helpers is the gap suite's
`test_gap_gc_*_rooting.ts` cases and the zeal + from-space-protect runs, not this
vehicle — recorded in each test so the next reader does not mistake a pinned
pacing for the default being untested.

* docs(changelog): fragment for the moving-loop poll default (#7714)

* docs(changelog): key the fragment to its PR number (#7721)

* chore: bump version to 0.5.1418

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

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
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.

P0: silent wrong answer under the moving collector — stale heap-string deref in generated code (realistic interpreter workload)

1 participant