Skip to content

fix(gc-matrix): the collect arms require a productive cycle, and the numarray probe reaches the collector (#7016, #7017) - #7676

Merged
proggeramlug merged 4 commits into
mainfrom
fix/7016-7017-gc-matrix-collect-liveness
Aug 9, 2026
Merged

fix(gc-matrix): the collect arms require a productive cycle, and the numarray probe reaches the collector (#7016, #7017)#7676
proggeramlug merged 4 commits into
mainfrom
fix/7016-7017-gc-matrix-collect-liveness

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Closes #7016. Closes #7017. Closes #7018 (does not reproduce — evidence below).

Three issues, one disease: a check that could not fail. For each one the first job was to decide whether the probe or the predicate was at fault, and the answer differs.

#7016 — the PROBE. 19 cells measured a collector that never ran.

Reproduced first, before changing anything:

PERRY_GC_HEAP_LIMIT=8 PERRY_GC_TRACE=1 PERRY_GC_DIAG=1 ./numarray_growth
exit 0   cycles=0   stderr lines=0

Zero cycles, and PERRY_GC_DIAG=1 printed nothing at all — the file allocates inside one 1 MB arena block and makes no gc_malloc calls, so the arena trigger never arms, and PERRY_GC_HEAP_LIMIT cannot reach it (gc_trigger_absolute_ceiling_bytes is budget/4 with a floor). The UNVER verdict was the gate working.

Sections 1–5 are untouched, so their "fully contained, therefore promoted" shape is byte-for-byte what it was; a new section 6 adds test_gap_repsel_gc_stress's escaping module-level churn sink, with the numeric-array local initialized before the churn, grown by push past several capacity doublings while it runs, and read after it in the same iteration.

Measured on all 13 arms — PASS on every one, output byte-identical to the pinned Node 26.5.1 oracle in each:

arm requires cycles copy-minor objects
default scavenge 4 74,404
safepoint_minor scavenge 3 72,148
evac_minor / force_evac / force_verify move 4 74,404
gen_gc_off / wb_off / gen_off_verify / all_four collect 7 0 (non-moving by construction)
shipped_default none 0 0 (control, unchanged)

Per the #7666 lesson, the check is on the copying minor, not merely on a cycle: a probe that allocates a lot can still run zero copying minors when arena_growth_full_escalation_due escalates every minor to a full sweep.

#7017 — the PREDICATE. cycles > 0 counted a teardown cycle.

A collect cell was PASS on any cycle. On a small file that cycle lands at the event-loop boundary after the program's last output and reclaims nothing, because everything allocated after it armed was born black — indistinguishable from a run that collected mid-program while the test's representation-selected locals were live.

collect now requires a productive cycle. Following #7657's widening of the gc-ratchet rule to copied + promoted > 0, the counter names a destination rather than a single number: reclaimed sums sweep_freed, block_reclaim, eden_dead_bytes, freed_bytes and dead_bytes, in both the k=N and JSON "k": N spellings.

Reading only k=N was itself a vacuous check, and it was caught by measurement. test_gap_gc_symbol_local_rooting runs 86 malloc-count-triggered cycles that free 31.9 MB of symbols; every arena-side counter reads 0 because the malloc sweep's bytes appear only in the JSON trace's sweep.freed_bytes. A predicate over the k=N shape alone would have scored it inert.

The script says out loud that this is a conservative proxy: a mid-program cycle over a heap that is entirely live reclaims nothing and reads UNVER. It can under-claim, never over-claim, which is the safe direction for a liveness gate; cycles over-claimed.

Both halves move together. reclaimed is threaded into the per-cell JSON and gc_matrix_liveness_check.py's REQUIREMENTS["collect"] reads it. Shown able to fail: reverting the counter to ("cycles",) fails exactly the two new self-test cases that pin the change —

SELF-TEST FAIL: a cycle that reclaimed nothing does NOT satisfy collect: wanted a violation, got []
SELF-TEST FAIL: collect is NOT satisfied by scavenged/evacuated alone: wanted a violation, got []

— and restoring it returns 24 checks, 0 failures. A third case pins that a productive cycle still passes.

The per-run liveness table now prints reclaimed beside collected, so the gap stays on screen instead of being folded away:

gen_gc_off   requires=collect  collected  3/ 3   reclaimed  1/ 3   moved-objects  0/ 3   copy-minor  0/ 3

Corpus-wide impact, measured under gen_gc_off over all 58 files: 12 never collect (already UNVER), 14 collect but reclaim nothingPASS before, honestly UNVER now — and 32 are productive, which keeps every collect arm live and the arm-level gate green.

The four known-inert arms are live, and CI has been saying so

Not in the issues, found while validating, and this PR cannot be green without it.

test-parity/gc_matrix_inert_arms.txt registered default, verify_evac, cons_scan_off and cons_scan_off_force as inert because PERRY_GC_MOVING_LOOP_POLLS is default-off, making the copying minor "ineligible by construction". The poll flag has not changed and all four scavenge anyway.

  • CI, gc-stress on main, run 31240304595 (2026-08-08): each satisfied requires=scavenge on 41 of 58 cells, default at counter=1384046, and the job was red with four STALE-REGISTRY lines. This is not a condition this PR introduces — it is one main has been red for.
  • Locally, isolated: --arms default,verify_evac,cons_scan_off,cons_scan_off_force --filter test_gap_repsel_gc_stress — a file this PR does not touch, under the scavenge predicate this PR does not change — 1/1 live on every arm, default copying 228,181 objects.
  • shipped_default (requires=none, no pressure knob, no GC env at all) copies 340,956 objects on the same file. The shipped configuration relocates today.

All four entries are deleted with the measurement recorded in their place, and the four arm notes in gc_repsel_matrix.sh that repeated the claim in prose are corrected — otherwise they become exactly the stale prose that file's own header warns about. What changed is the collector around the flag (#7370's statepoint default, #7432, #7657, #7666), not the flag. The registry is now empty; if the copying minor ever stops running in the shipped configuration, the gate fails in the other direction on the same line.

#7018 — does not reproduce, and the hypothesis is structurally refuted

PERRY_GC_TRACE=1 was reported to SIGSEGV test_gap_repsel_scalar_replaced_locals under the evacuating arms. 20 runs across both link modes: 0 crashes, stdout byte-identical with and without the flag, on that file and four others.

The first link mode was vacuous and is reported as such: auto-optimize relinks the runtime --no-default-features, so diagnostics is off and GcCycleTrace::emit falls to its stub — all 113 [gc] cycle lines read "diagnostics feature disabled", and the real tracer never ran. Re-run against the diagnostics archive: 110 real "event":"gc_cycle" objects, 466,424 objects copied. The arm is demonstrably live and still does not crash. 13 .ips crash reports were produced during the investigation and every one is SIGABRT from an unrelated instrument; zero SIGSEGV.

Structurally it cannot hold. PERRY_GC_TRACE is read in exactly one place (gc_trace_enabled(), gc/policy.rs:571) with two call sites: a scalar counter snapshot (policy.rs:760) and a thread-local u64 bump (barrier.rs:931). Emission serialises already-accumulated scalars. Nothing on that path dereferences a heap object, walks the object graph, or reads a GcHeader — so it cannot dereference a forwarded pointer, and it shares nothing with #6998/#6995. The issue's description ("the tracer runs inside a collection, walking structures the collector is mid-way through mutating") describes gc/trace.rs, the marking tracer, which runs on every collection regardless of the flag. A name collision, not a defect.

Validation

  • Full --arms pr matrix over all 58 corpus files, exit 0 on the liveness gate.
  • --arms pr --filter test_gap_repsel_p4a3: 21/21 cells byte-exact, PASS=21 UNVER=0 FAIL=0, all seven arms live.
  • --arms gen_gc_off,wb_off,evac_minor,shipped_default --filter test_gap_repsel_p4a3: the two probes that only reach a teardown cycle now read UNVER on the collect arms and PASS on evac_minor, which is the intended split.
  • All 22 lint gate commands green; gc_matrix_liveness_check.py --self-test 24 checks / 0 failures; bash -n clean.

One unrelated red seen while validating, reported rather than swallowed

The 58-file --arms pr run above ended PASS=341 UNVER=64 XFAIL=0 **FAIL=1**: test_gap_repsel_gc_stress × safepoint_minor, output-mismatch, cycles=52 scavenged=38184. It is not from this PR — different file, and safepoint_minor's requires=scavenge predicate is untouched here.

Re-run in isolation, same binary, same env: 10/10 clean, so it is intermittent, not deterministic. safepoint_minor is the arm that compiles and runs with PERRY_GC_MOVING_LOOP_POLLS=1 — the configuration #7161 turned off by default because of #7154's use-after-free — so an intermittent mismatch there is consistent with that open class. It is untriaged in test-parity/gc_repsel_triage.txt and CI's last main run reported FAIL=0, so it is a low-rate sampling difference rather than a new deterministic break. Filed separately; deliberately not added to the triage list here, because triaging a red I have not root-caused is how a real defect becomes permanent furniture.

Summary by CodeRabbit

  • Bug Fixes

    • Updated garbage-collection liveness checks to require measurable memory reclamation, not just a completed cycle.
    • Improved reclamation reporting across diagnostic formats, with clearer per-test results and JSON output.
    • Removed obsolete inert-arm entries after confirming those configurations now perform collection.
  • Tests

    • Added stress coverage for numeric-array growth during repeated allocation and collection.
    • Expanded self-tests to validate productive collection and reject non-reclaiming cycles.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds GC churn coverage for numeric-array growth. It changes collect liveness to require positive reclaimed memory, records reclamation metrics in matrix results, retires stale inert-arm entries, and documents the trace investigation.

Changes

GC liveness validation

Layer / File(s) Summary
Numeric-array growth under GC churn
test-files/test_gap_repsel_p4a3_numarray_growth.ts, changelog.d/7676-gc-matrix-collect-liveness.md
The probe allocates churn objects, grows dense and preallocated numeric arrays across collections, reads preserved elements, and reports checksums and churn epochs.
Reclamation-aware matrix liveness
scripts/gc_matrix_liveness_check.py, scripts/gc_repsel_matrix.sh, changelog.d/7676-gc-matrix-collect-liveness.md
collect now requires positive reclaimed memory. Text and JSON diagnostics provide reclamation metrics for per-cell liveness, summaries, and JSON output. Self-tests cover non-reclaiming, productive, and relocation-only cycles.
Arm status and validation records
test-parity/gc_matrix_inert_arms.txt, scripts/gc_repsel_matrix.sh, changelog.d/7676-gc-matrix-collect-liveness.md
Four stale inert-arm entries are retired after scavenging measurements. Arm metadata and trace-investigation records reflect the updated status.

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

Possibly related issues

  • PerryTS/perry issue 7294: Both changes require productive collection evidence instead of completed-cycle counts in GC matrix liveness.

Possibly related PRs

  • PerryTS/perry#7015: This PR follows up on the GC liveness and numarray-growth cases addressed by that PR.
  • PerryTS/perry#7040: Both PRs refine GC matrix liveness evidence and collector metric reporting.
  • PerryTS/perry#7275: This PR extends the GC matrix liveness checker by using reclaimed-memory evidence.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the two primary code changes: productive collect cycles and a collector-reaching numarray probe.
Description check ✅ Passed The description provides detailed summary, changes, related issues, validation commands, outputs, and investigation results, although the template checklist is omitted.
Linked Issues check ✅ Passed The PR addresses probe coverage, productive collection detection, and the #7018 investigation objectives for [#7016], [#7017], and [#7018].
Out of Scope Changes check ✅ Passed The registry cleanup, reporting updates, self-tests, and changelog support the stated GC matrix objectives and do not introduce unrelated code changes.
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/7016-7017-gc-matrix-collect-liveness

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 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 `@scripts/gc_matrix_liveness_check.py`:
- Around line 81-84: Update the collect requirement in REQUIREMENTS and the
arm_liveness() logic so collect is considered live only when both cycles and
reclaimed are positive, while preserving existing behavior for other
requirements. Add a self-test covering cycles=0 with reclaimed=1 and assert that
it reports a violation.

In `@scripts/gc_repsel_matrix.sh`:
- Line 193: Update the arm descriptions in scripts/gc_repsel_matrix.sh:193-193
and scripts/gc_repsel_matrix.sh:204-204 to remove stale known-inert claims. At
line 193, remove wording that a registry entry still names the blocker or that
the liveness gate will fail when default scavenges again; at line 204, remove or
explicitly mark as historical the claim that PERRY_GC_INCREMENTAL=0 is required
for cons_scan_off to be live.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fd8db6bc-66b6-428e-ac9e-fe0c9e7d35ac

📥 Commits

Reviewing files that changed from the base of the PR and between a853135 and 9cbc556.

📒 Files selected for processing (5)
  • changelog.d/7676-gc-matrix-collect-liveness.md
  • scripts/gc_matrix_liveness_check.py
  • scripts/gc_repsel_matrix.sh
  • test-files/test_gap_repsel_p4a3_numarray_growth.ts
  • test-parity/gc_matrix_inert_arms.txt

Comment on lines 81 to +84
REQUIREMENTS = {
"scavenge": ("copying young-gen minor", ("scavenged",)),
"move": ("any relocation", ("evacuated", "scavenged")),
"collect": ("any GC cycle", ("cycles",)),
"collect": ("a productive GC cycle", ("reclaimed",)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Require a cycle in the liveness checker.

arm_liveness() marks collect live when reclaimed > 0, even if cycles == 0. The matrix producer requires both values to be positive at scripts/gc_repsel_matrix.sh Lines 519-523.

Require cycles > 0 for collect in this checker. Add a self-test with cycles=0, reclaimed=1 that expects a violation.

Proposed fix
-        if value > 0:
+        if value > 0 and (
+            requires != "collect" or int(cell.get("cycles", 0) or 0) > 0
+        ):
             live += 1
🤖 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 `@scripts/gc_matrix_liveness_check.py` around lines 81 - 84, Update the collect
requirement in REQUIREMENTS and the arm_liveness() logic so collect is
considered live only when both cycles and reclaimed are positive, while
preserving existing behavior for other requirements. Add a self-test covering
cycles=0 with reclaimed=1 and assert that it reports a violation.

# ---------------------------------------------------------------------------
ARMS=(
"default||%P%|scavenge|as-shipped GC configuration under allocation pressure. ***INERT AT THE MOMENT, AND REGISTERED AS SUCH*** in test-parity/gc_matrix_inert_arms.txt. #7024 made this a relocating arm (the alloc-point trigger defers to js_gc_loop_safepoint -> gc_safepoint_moving_minor, which runs the copying minor on precise rewritable roots); #7161 then flipped PERRY_GC_MOVING_LOOP_POLLS default-OFF pending #7154, and that one env gates BOTH halves of the route -- perry-codegen's moving_safepoint_polls_enabled decides whether the back-edge polls are emitted at all, and perry-runtime's gc_moving_loop_polls_enabled decides whether the trigger defers to them. A default binary has neither, so the minor runs behind ManualGcScanGuard::force_full_scan and the copying minor is ineligible by construction. requires=scavenge STAYS: it is what the shipped default is FOR, the registry entry names what blocks it, and the liveness gate fails the day it scavenges again so the entry cannot outlive its cause. safepoint_minor carries the relocating claim meanwhile."
"default||%P%|scavenge|as-shipped GC configuration under allocation pressure. ***LIVE AGAIN AS OF 2026-08-09*** -- its known-inert entry was deleted in test-parity/gc_matrix_inert_arms.txt. #7024 made this a relocating arm (the alloc-point trigger defers to js_gc_loop_safepoint -> gc_safepoint_moving_minor, which runs the copying minor on precise rewritable roots); #7161 then flipped PERRY_GC_MOVING_LOOP_POLLS default-OFF pending #7154, and that one env gates BOTH halves of the route -- perry-codegen's moving_safepoint_polls_enabled decides whether the back-edge polls are emitted at all, and perry-runtime's gc_moving_loop_polls_enabled decides whether the trigger defers to them. A default binary has neither, so the minor runs behind ManualGcScanGuard::force_full_scan and the copying minor is ineligible by construction. requires=scavenge STAYS: it is what the shipped default is FOR, the registry entry names what blocks it, and the liveness gate fails the day it scavenges again so the entry cannot outlive its cause. safepoint_minor carries the relocating claim meanwhile."

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

Remove stale known-inert claims from arm descriptions.

The records now state that these arms are live and their registry entries were deleted. The retained wording states the opposite.

  • scripts/gc_repsel_matrix.sh#L193-L193: Remove the claim that a registry entry still names the blocker and that the gate will fail when default scavenges again.
  • scripts/gc_repsel_matrix.sh#L204-L204: Remove or mark as historical the claim that PERRY_GC_INCREMENTAL=0 is required to make cons_scan_off live.
📍 Affects 1 file
  • scripts/gc_repsel_matrix.sh#L193-L193 (this comment)
  • scripts/gc_repsel_matrix.sh#L204-L204
🤖 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 `@scripts/gc_repsel_matrix.sh` at line 193, Update the arm descriptions in
scripts/gc_repsel_matrix.sh:193-193 and scripts/gc_repsel_matrix.sh:204-204 to
remove stale known-inert claims. At line 193, remove wording that a registry
entry still names the blocker or that the liveness gate will fail when default
scavenges again; at line 204, remove or explicitly mark as historical the claim
that PERRY_GC_INCREMENTAL=0 is required for cons_scan_off to be live.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit — merging as v0.5.1388

#7016 verified, after I measured it wrong first. My bare invocation reported 0 cycles and I nearly filed that against you. The matrix runs its arms under PERRY_GC_HEAP_LIMIT=$PRESSURE_MB (default 8), which I had omitted. With it:

pressure gc_cycle markers
8 MB (the arm default) 4
16 MB 3
32 MB 1

Control: my build emits trace fine (a churn kernel shows 2 without pressure, 10 with). So the probe genuinely crosses collections now, and my first reading measured a configuration the gate never runs — the same shape as everything else in this cluster, applied to my own check.

#7017 is the right fix for the right reason. cycles is not evidence the collector saw anything; a cycle that reclaims nothing scores identically to one that reclaims 31.9 MB. Summing five reclamation counters in both k=N and JSON "k": N spellings is what makes it real — reading only k=N scored symbol_local_rooting's 86 malloc-triggered cycles as inert.

And the design note in the source is the part I most want kept: "one collector change cannot pin it permanently false." That is #7657's lesson — re-enabling the adaptive-tenuring seed drove copied_objects to 0 on two probes and would have pinned the old guard permanently false exactly where it last fired. Summing a family rather than naming one counter is the general fix.

14 of 58 files moving PASS → honest UNVER is a gain, not a regression: those cells were reporting on a collector that never ran.

#7018 closed by refutation, and that is worth as much as a fix: 20 runs without reproduction, plus the structural argument that PERRY_GC_TRACE never dereferences a heap object — the issue describes gc/trace.rs, the marking tracer. A refuted issue with evidence beats one that lingers.

The unasked-for part is the most valuable. All four entries in gc_matrix_inert_arms.txt were live, and CI's gc-stress on main has been red for it at 41/58 cells with four STALE-REGISTRY lines. A registry that outlives its cause is the same defect as a knob whose off-state nobody exercises — and the liveness gate failing the day an arm scavenges again is what keeps the entries from outliving their causes a second time.

Gates: 24/24 lint, matrix self-test and --check-registry clean, check_test_registration clean, script syntax clean. The registry now reports six relocating arms live: default, safepoint_minor, evac_minor, verify_evac, force_verify, cons_scan_off.

Worth watching when the queue drains: gc-stress on this PR is the confirmation that retiring those four entries was right — it has been red on main for exactly that reason.

@proggeramlug
proggeramlug force-pushed the fix/7016-7017-gc-matrix-collect-liveness branch from d7b2486 to 7ae4ea7 Compare August 9, 2026 06:51
@proggeramlug
proggeramlug merged commit e117e86 into main Aug 9, 2026
@proggeramlug
proggeramlug deleted the fix/7016-7017-gc-matrix-collect-liveness branch August 9, 2026 06:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment