Skip to content

test(gc): gate matrix arm liveness, and put a relocating arm back in the PR subset (#7255) - #7275

Merged
proggeramlug merged 1 commit into
mainfrom
fix/7255-matrix-liveness
Aug 2, 2026
Merged

test(gc): gate matrix arm liveness, and put a relocating arm back in the PR subset (#7255)#7275
proggeramlug merged 1 commit into
mainfrom
fix/7255-matrix-liveness

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #7255.

The defect

scripts/gc_repsel_matrix.sh reports an inert arm's cell as UNVER — "byte-exact
against the oracle, but the arm did not bite" — and UNVER is deliberately not
green. It was never red either: the script's exit status counts only FAIL. An
arm that goes inert across the whole corpus therefore produces a yellow table
and exit 0, and no CI job says a word. CLAUDE.md's fourth hazard exactly: the
gate runs, and its subject never does.

Measured at a25077491, --arms all --pressure 8, 50 corpus files, node 26.5.1,
macOS arm64, release — the run's own liveness summary:

arm requires collected copy-minor
default scavenge 24/50 0/50 inert
verify_evac scavenge 24/50 0/50 inert
cons_scan_off scavenge 24/50 0/50 inert
cons_scan_off_force scavenge 24/50 0/50 inert
shipped_default none 3/50 0/50 control
evac_minor, force_evac, force_verify, loop_polls, all seven rep_*_off move 49/50 49/50 live
gen_gc_off, wb_off, gen_off_verify, wb_off_force, all_four collect 24/50 0/50 live (non-moving by construction)

PASS=688 UNVER=341 XFAIL=21 FAIL=0 over 1050 cells — and exit 0. Four of the
six arms gc-stress runs on a pull request could only ever produce UNVER.

Root cause

Not the --pressure/defer-cap interaction of #7024 — that fix is intact.
#7161, which flipped PERRY_GC_MOVING_LOOP_POLLS default-OFF as a stopgap
for #7154. That one env gates both halves of the route #7019/#7024 opened:

  • perry-codegen/src/stmt/loops.rs::moving_safepoint_polls_enabled decides
    whether js_gc_loop_safepoint() back-edge polls are emitted at all, so a
    default binary contains none;
  • perry-runtime/src/gc/policy.rs::gc_moving_loop_polls_enabled decides whether
    the alloc-point nursery trigger defers to them, so nothing is ever deferred.

With it off, gc_check_trigger falls through to the direct minor behind
ManualGcScanGuard::force_full_scan(), which makes the copying minor ineligible
(fallback=conservative_stack). That is default and verify_evac.
cons_scan_off and cons_scan_off_force are dark for a second, independent
reason that #7024's deferral had been papering over: with the incremental
stepper at its default, registered_root_scanners_block_budgeted_gc() reduces to
"any copy-only scanner", a compiled program has none, so the nursery trigger
never reaches the direct arm at all and is handed to the budgeted stepper, which
is non-moving by construction.

Ruled out by measurement, not by argument: #7250 (lazy remembered-set
arming) is not implicated — evac_minor/force_evac/force_verify/loop_polls
and all seven rep_*_off arms relocate 49/50 on this same build, so write
barriers are active and evacuation is reachable. #7249's GcSuppressScope
bootstrap window is likewise not implicated for the same reason.

#7154 is closed, but its final comment says the stopgap stays until the
from-space protector is clean, so reverting #7161 is not on the table here.

What changed

1. A relocating arm is back in the PR subset. New arm safepoint_minor:
PERRY_GC_MOVING_LOOP_POLLS=1 at compile and run time, and nothing else — no
PERRY_GC_INCREMENTAL=0, no PERRY_CONSERVATIVE_STACK_SCAN=off, no forced
evacuation. That is the sound route (the copying minor runs at a loop back-edge
where every live heap value is a named local on the shadow stack), and it is
exactly what default was between #7024 and #7161. It does not replace the
allocation-point arms: per #7249 a back-edge poll only fires while user JS runs,
so evac_minor and force_verify stay in the subset as the stronger acceptance
route.

2. The declarations stay honest instead of being weakened. default,
verify_evac, cons_scan_off and cons_scan_off_force keep
requires=scavenge — that is still what they are for — and are listed in the
new test-parity/gc_matrix_inert_arms.txt, each naming #7161 and saying what
must become true for the entry to be deleted. Downgrading them to
requires=collect would have made the table green while deleting the record.

3. The liveness gate. scripts/gc_matrix_liveness_check.py runs at the end of
every matrix invocation and fails it when

  • an arm satisfied its own requires= on zero cells and is not registered, or
  • an arm is registered and just bit anyway — the blocker lifted, so the entry
    is stale. A registry that can only be added to rots exactly the way the header
    did.

It also carries a static check, run from lint with no build: PR_ARMS must
contain at least one arm that claims to relocate and is not on the known-inert
list.
Registering every inert arm is therefore not a way to buy a green subset.
--self-test (21 checks) and --check-registry are both wired into lint, so
the rule that decides red-versus-green is a tested program rather than untested
bash, and --check-registry fails if the matrix ever stops invoking the
checker — hazard 4 one level up.

4. The header. The default: 0/22 -> 12/22 table is gone, along with the
other hand-maintained counts, and replaced by an instruction not to put liveness
numbers there again. Every run prints its own derived per-arm table.

Verification

All on this branch's merge-base build, release profile, -p perry -p perry-runtime -p perry-stdlib -p perry-runtime-static -p perry-stdlib-static,
node 26.5.1 (the pin), macOS arm64.

Per-arm copy-minor, before → after (--arms pr --pressure 8):

arm before after
default 0/50 0/50 — registered inert (#7161)
safepoint_minor did not exist 33/50, 35 360 461 objects copied
evac_minor 49/50 49/50
verify_evac 0/50 0/50 — registered inert (#7161)
force_verify 49/50 49/50
cons_scan_off 0/50 0/50 — registered inert (#7161)
shipped_default 0/50 (requires=none) unchanged

PASS=177 UNVER=169 XFAIL=4 FAIL=0 over 350 cells, liveness gate exit 0. The
four XFAILs are the pre-existing #7247/#7248 entries; safepoint_minor is
PASS on both of those files, consistent with the triage notes that call them
clean on the safepoint route.

Re-confirmed after rebasing onto 61410a222 (corpus is 52 files there), full
rebuild, --arms pr --pressure 8:

  default          requires=scavenge  collected 24/52  copy-minor  0/52  inert (known #7161)
  safepoint_minor  requires=scavenge  collected 35/52  copy-minor 35/52  live, 35 243 788 copied
  evac_minor       requires=move      collected 51/52  copy-minor 51/52  live
  verify_evac      requires=scavenge  collected 24/52  copy-minor  0/52  inert (known #7161)
  force_verify     requires=move      collected 51/52  copy-minor 51/52  live
  cons_scan_off    requires=scavenge  collected 24/52  copy-minor  0/52  inert (known #7161)
  shipped_default  requires=none      collected  3/52  copy-minor  0/52  n/a
summary: PASS=185 UNVER=175 XFAIL=4 FAIL=0   liveness gate exit 0

Red, then green — four separate demonstrations:

  1. Wired, end-to-end, on the pre-fix arm table. The --arms all run above ends
    in the gate naming default, verify_evac, cons_scan_off and
    cons_scan_off_force, and exits 1.
  2. Wired, end-to-end, reproducible in seconds.
    --arms force_evac --filter test_gap_specabi_reassign --pressure 0 → the arm
    relocates nothing, the gate fires, script exit 1. Same command with
    --liveness-report-only → exit 0 with the violation still printed.
  3. The staleness tooth. Adding safepoint_minor to the registry and re-running
    the checker over the real after-report → exit 1, "listed as known-inert … but
    satisfied requires=scavenge on 33/50 cells".
  4. The structural tooth. Setting PR_ARMS to only inert/non-relocating arms →
    --check-registry exits 1 from lint, before anything is built.

The gate is ARMED, not report-only, and that does not create a new permanent
red: every currently-inert arm is registered, so the same --arms all table that
went red without the registry goes green with it (verified by running the checker
over the identical report both ways). The escape hatch (--liveness-report-only)
exists for local --filter work and is used nowhere in CI. If a Linux runner
turns out to disagree with this host about one arm, the fix is a line in
test-parity/gc_matrix_inert_arms.txt, not a triage debate.

Two blocked issues become judgeable

#7018 — judgeable, and it does not reproduce. Its exact repro
(PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off PERRY_GC_TRACE=1 on test_gap_repsel_scalar_replaced_locals) is the matrix's
evac_minor cell for that file, which is live: 1 577 269 objects copied.
10/10 runs are byte-exact with rc=0 and 61 GC cycles each with the tracer on;
10/10 without the tracer as a control.

#6982 — still not safely judgeable, and now we know why. Its stated
configuration (PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0, conservative scan
ON) is intermittently live: over 10 runs of test_gap_repsel_gc_stress a
copying minor ran in one run (141 711 objects) and in none of the other nine;
over a separate 20 runs on the rebased build, one copying minor total. So a
10-run green there is really a 1-run green. No matrix arm carries that
configuration, and adding one would be a flaky gate, which is the one thing #7255
says not to build. The neighbouring deterministic arm (evac_minor, which adds
CONSERVATIVE_STACK_SCAN=off) is 10/10 clean on that file with 10 copying minors
and 1 385 325 objects copied every run.

Not verified here

Summary by CodeRabbit

  • CI and Testing

    • Added automated validation to ensure GC matrix configurations remain active and correctly registered.
    • Expanded pull-request GC coverage with a relocating minor-collection configuration.
    • GC matrix results now include validated liveness evidence and fail when checks cannot be completed.
  • Documentation

    • Clarified GC stress-matrix coverage, intentionally inactive configurations, and scheduled full-run behavior.
    • Documented known inactive configurations and the conditions for removing them.

@proggeramlug
proggeramlug force-pushed the fix/7255-matrix-liveness branch from e65f39b to b2d82b5 Compare August 2, 2026 19:47
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The GC matrix adds safepoint_minor, records numeric liveness evidence, validates inert-arm declarations, enforces report checks during matrix runs, and runs checker self-tests and registry checks in CI lint.

Changes

GC matrix liveness enforcement

Layer / File(s) Summary
Matrix arms and liveness reporting
scripts/gc_repsel_matrix.sh
Adds the safepoint_minor arm, expands the PR subset, records separate collection, evacuation, and copying-minor counters, and always emits JSON reports.
Checker, registry, and self-tests
scripts/gc_matrix_liveness_check.py, test-parity/gc_matrix_inert_arms.txt
Adds report and registry parsing, liveness validation, inert-arm tracking, PR coverage checks, CLI modes, and self-tests.
CI wiring and documented matrix policy
.github/workflows/*, docs/representation-selection-rfc.md, changelog.d/7275-gc-matrix-liveness-gate.md
Runs checker self-tests and registry validation in lint. Documents the PR subset, inert arms, and enforced liveness behavior.

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

Possibly related issues

  • PerryTS/perry issue 7262 — Directly addresses inert GC matrix arms and liveness gating.
  • PerryTS/perry issue 7059 — Reports zero-collection GC matrix arms addressed by the new liveness checks.
  • PerryTS/perry issue 7017 — Concerns GC matrix liveness predicates and collection detection updated here.

Possibly related PRs

  • PerryTS/perry#7019 — Introduced moving-loop poll and evacuating-minor behavior tracked by the new checks.
  • PerryTS/perry#7040 — Added liveness reporting extended here with separate counters and enforcement.
  • PerryTS/perry#7161 — Changed moving-loop poll defaults documented and encoded in the inert-arm checks.

Sequence Diagram(s)

sequenceDiagram
  participant GC matrix
  participant Liveness checker
  participant Inert-arm registry
  participant CI lint
  GC matrix->>GC matrix: Run configured arms
  GC matrix->>Liveness checker: Write and validate JSON report
  Liveness checker->>Inert-arm registry: Load registered inert arms
  Liveness checker-->>GC matrix: Return pass or failure
  CI lint->>Liveness checker: Run self-test and registry check
  Liveness checker-->>CI lint: Return validation status
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: gating GC matrix arm liveness and restoring a relocating arm to the PR subset.
Description check ✅ Passed The description is detailed and covers the change, issue, implementation, verification, limitations, and impact, despite not matching every template heading exactly.
Linked Issues check ✅ Passed The PR addresses [#7255] by adding a live relocating arm, enforcing liveness, preserving requirements, and replacing stale manual counts.
Out of Scope Changes check ✅ Passed The changes remain within [#7255] and directly support GC matrix liveness detection, registry validation, documentation, and CI enforcement.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/7255-matrix-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: 4

🧹 Nitpick comments (3)
test-parity/gc_matrix_inert_arms.txt (1)

3-3: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document that the reason field cannot contain |.

parse_registry in scripts/gc_matrix_liveness_check.py splits each line on | and rejects the line unless it yields exactly three fields. A future reason that quotes a || operator or a shell pipe fails lint with a format error that reads as a typo. State the restriction next to the format line.

♻️ Proposed wording
 # Format:  <arm id> | <`#issue`> | <reason, and what makes this entry deletable>
+#          The reason is free text on ONE line and must not contain `|`.
🤖 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 `@test-parity/gc_matrix_inert_arms.txt` at line 3, Update the format comment in
gc_matrix_inert_arms.txt to explicitly state that the reason field must not
contain the pipe character (|), including quoted shell pipes or || operators, so
parse_registry continues receiving exactly three fields.
scripts/gc_matrix_liveness_check.py (1)

101-114: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

matrix_arms can silently skip arm records, and the self-test does not detect it.

^"([^"]*)"$ matches only records that contain no double quote. An arm note that quotes an identifier is skipped without error. check_registry would then report that a registered arm "no longer exists", and a skipped arm would never be liveness-checked. The self-test at line 408 only asserts len(arms) >= 5, so a partial parse still passes.

Count the record lines in the ARMS=( ... ) body and fail when the parsed count differs.

♻️ Proposed fix
     arms = {}
-    for line in re.finditer(r'^"([^"]*)"$', body.group(1), re.M):
+    record_lines = [
+        ln for ln in body.group(1).splitlines() if ln.strip() and not ln.lstrip().startswith("#")
+    ]
+    for line in re.finditer(r'^"(.*)"$', body.group(1), re.M):
         fields = line.group(1).split("|")
         if len(fields) < 4:
             raise Violation("malformed arm record: %r" % line.group(1)[:60])
         arms[fields[0]] = fields[3]
     if not arms:
         raise Violation("the ARMS=( ... ) array in %s parsed to nothing" % MATRIX.name)
+    if len(arms) != len(record_lines):
+        raise Violation(
+            "the ARMS=( ... ) array in %s has %d record line(s) but %d parsed. A record "
+            "this parser skips is an arm the liveness gate never checks."
+            % (MATRIX.name, len(record_lines), len(arms))
+        )
     return arms
🤖 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 101 - 114, Update
matrix_arms to count all arm record lines in the ARMS body and raise Violation
when that count differs from the number of successfully parsed records,
preventing quoted records from being silently skipped. Keep the existing
malformed-record validation and empty-array handling, and strengthen the
matrix_arms self-test so partial parsing cannot pass with only a minimum-length
assertion.
scripts/gc_repsel_matrix.sh (1)

213-216: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The subset summary omits force_verify.

PR_ARMS names seven arms. This sentence enumerates six roles and leaves out force_verify, which line 247 then treats as part of the stronger acceptance route. Add it here so the enumeration matches PR_ARMS.

♻️ Proposed wording
-# PR-gating subset: the arms with the most detection power per second -- the
-# shipped configuration under pressure, the two routes that actually relocate,
-# the evacuation verifier, precise-roots-only, and the untouched shipped
-# configuration as a control.
+# PR-gating subset: the arms with the most detection power per second -- the
+# shipped configuration under pressure, the two routes that actually relocate,
+# forced evacuation with the verifier, the evacuation verifier,
+# precise-roots-only, and the untouched shipped configuration as a control.
🤖 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` around lines 213 - 216, Update the PR-gating
subset summary comment near PR_ARMS to explicitly include the force_verify arm,
so the listed roles match all seven configured arms and the stronger acceptance
route described later.
🤖 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 194-254: Update check_registry() in
scripts/gc_matrix_liveness_check.py to add a violation when Path(__file__).name
is absent from matrix_text, verifying the matrix still invokes the checker. In
.github/workflows/test.yml lines 208-216 and
changelog.d/7275-gc-matrix-liveness-gate.md lines 38-44, retain the wiring
claims only because this assertion now enforces them; otherwise remove those
clauses.
- Around line 12-19: Unify the residual corpus-size wording across the four
affected documents: scripts/gc_matrix_liveness_check.py lines 12-19,
scripts/gc_repsel_matrix.sh lines 602-611, .github/workflows/test.yml lines
208-216, and changelog.d/7275-gc-matrix-liveness-gate.md lines 12-22. Choose the
correct denominator and use it consistently everywhere, or replace the
denominator with “corpus-wide” in every location.
- Around line 268-420: Update self_test’s final check-count reporting to derive
the total from the checks actually executed, including the expect calls,
registry-parse cases, and real-matrix assertions, instead of the hand-maintained
“12 + 6 + 3” expression. Preserve the existing failure counting and output
format while ensuring the reported total is 20 for the current checks.

In `@scripts/gc_repsel_matrix.sh`:
- Around line 101-105: Update the --help handler in scripts/gc_repsel_matrix.sh
to display the complete usage block, including --json, --list-arms, and
--liveness-report-only. Replace the hard-coded sed range in the option handling
with a range that reaches the usage block’s actual end without truncation.

---

Nitpick comments:
In `@scripts/gc_matrix_liveness_check.py`:
- Around line 101-114: Update matrix_arms to count all arm record lines in the
ARMS body and raise Violation when that count differs from the number of
successfully parsed records, preventing quoted records from being silently
skipped. Keep the existing malformed-record validation and empty-array handling,
and strengthen the matrix_arms self-test so partial parsing cannot pass with
only a minimum-length assertion.

In `@scripts/gc_repsel_matrix.sh`:
- Around line 213-216: Update the PR-gating subset summary comment near PR_ARMS
to explicitly include the force_verify arm, so the listed roles match all seven
configured arms and the stronger acceptance route described later.

In `@test-parity/gc_matrix_inert_arms.txt`:
- Line 3: Update the format comment in gc_matrix_inert_arms.txt to explicitly
state that the reason field must not contain the pipe character (|), including
quoted shell pipes or || operators, so parse_registry continues receiving
exactly three fields.
🪄 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: f569f992-2f1e-4e18-9a31-68546d94a6d3

📥 Commits

Reviewing files that changed from the base of the PR and between 61410a2 and b2d82b5.

📒 Files selected for processing (7)
  • .github/workflows/gc-moving-witnesses.yml
  • .github/workflows/test.yml
  • changelog.d/7275-gc-matrix-liveness-gate.md
  • docs/representation-selection-rfc.md
  • scripts/gc_matrix_liveness_check.py
  • scripts/gc_repsel_matrix.sh
  • test-parity/gc_matrix_inert_arms.txt

Comment on lines +12 to +19
That is not hypothetical. #7024 measured the `default` arm at copy-minor 0/22,
fixed it to 12/22, and the script's header recorded the fix in its strongest
formatting. #7161 then flipped `PERRY_GC_MOVING_LOOP_POLLS` default-OFF as a
stopgap for #7154, which took the same arm back to **0/49** — and the header
still read `12/22`, because it was a hand-maintained number. Four of the six
PR-gating arms were inert for weeks. Two open crash reports (#6982, #7018) could
not be judged in either direction, because both crash *inside* a copying minor
and the arms that were supposed to run one ran 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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The corpus denominator disagrees across the four new or changed documents. The same historical measurement is written as 0/49 in two places and 0/50 in two others. This PR removes hand-maintained liveness counts, so this residual number must be one value or absent.

  • scripts/gc_matrix_liveness_check.py#L12-L19: reads 0/49.
  • scripts/gc_repsel_matrix.sh#L602-L611: reads 0/49.
  • .github/workflows/test.yml#L208-L216: reads 0/50.
  • changelog.d/7275-gc-matrix-liveness-gate.md#L12-L22: reads 0/50.

Pick the correct corpus size, or write "corpus-wide" without a denominator.

📍 Affects 4 files
  • scripts/gc_matrix_liveness_check.py#L12-L19 (this comment)
  • scripts/gc_repsel_matrix.sh#L602-L611
  • .github/workflows/test.yml#L208-L216
  • changelog.d/7275-gc-matrix-liveness-gate.md#L12-L22
🤖 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 12 - 19, Unify the residual
corpus-size wording across the four affected documents:
scripts/gc_matrix_liveness_check.py lines 12-19, scripts/gc_repsel_matrix.sh
lines 602-611, .github/workflows/test.yml lines 208-216, and
changelog.d/7275-gc-matrix-liveness-gate.md lines 12-22. Choose the correct
denominator and use it consistently everywhere, or replace the denominator with
“corpus-wide” in every location.

Comment on lines +194 to +254
def check_registry(out=sys.stdout) -> list:
"""Static checks that need no run: names resolve, and the gate is wired in."""
violations = []
try:
registry = parse_registry(REGISTRY.read_text()) if REGISTRY.exists() else {}
except Violation as exc:
return [str(exc)]

matrix_text = MATRIX.read_text()
try:
known = matrix_arms(matrix_text)
pr_arms = matrix_pr_arms(matrix_text)
except Violation as exc:
return [str(exc)]

for arm_id, (issue, _) in sorted(registry.items()):
if arm_id not in known:
violations.append(
"%s lists arm %r (%s), which no longer exists in %s. A registry entry "
"for a deleted arm hides nothing and outlives its reason."
% (REGISTRY.name, arm_id, issue, MATRIX.name)
)

for arm_id in pr_arms:
if arm_id not in known:
violations.append("PR_ARMS names %r, which is not in the ARMS table" % arm_id)

# THE STRUCTURAL HALF OF #7255, and the reason this check is worth having in
# `lint` at all: the run-time gate can be satisfied by registering every
# inert arm, which would leave a PR subset that cannot relocate anything and
# still exits 0. So the subset must retain at least one arm that claims to
# relocate AND is not on the known-inert list. This is the property the
# matrix header used to assert in prose ("THIS SUBSET CAN NOW REPRODUCE THE
# RELOCATING-MINOR DEFECT CLASS") with a number that went stale.
relocating = [
arm
for arm in pr_arms
if known.get(arm) in ("scavenge", "move") and arm not in registry
]
if not relocating:
violations.append(
"PR_ARMS (%s) contains no arm that both claims to relocate "
"(requires=scavenge/move) and is absent from %s. On a pull request the "
"matrix would then be unable to observe a raw reference held across a "
"relocating collection at all — the #6993 class (#6951, #6972, #6982, "
"#6991, #6992), which is the hole #7255 was filed about. Registering "
"arms as known-inert is not a way to buy silence: put a live relocating "
"arm back in the subset." % (",".join(pr_arms), REGISTRY.name)
)

out.write(
"registry: %d known-inert arm(s), %d arms declared in %s\n"
% (len(registry), len(known), MATRIX.name)
)
out.write(
"PR subset: %s\n relocating and not known-inert: %s\n"
% (",".join(pr_arms), ",".join(relocating) or "NONE")
)
for arm_id, (issue, reason) in sorted(registry.items()):
out.write(" %-24s %-8s %s\n" % (arm_id, issue, reason[:100]))
return violations

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

Two documents assert a wiring check that check_registry() does not implement. The root cause is in the checker: check_registry() validates the registry, ARMS, and PR_ARMS, but never confirms that scripts/gc_repsel_matrix.sh still invokes gc_matrix_liveness_check.py. If the invocation is deleted, lint stays green and the run-time half of the gate disappears silently.

  • scripts/gc_matrix_liveness_check.py#L194-L254: add a violation when Path(__file__).name is absent from matrix_text.
  • .github/workflows/test.yml#L208-L216: keep the comment only after the assertion exists; otherwise remove the "the matrix still calls the checker at all" clause.
  • changelog.d/7275-gc-matrix-liveness-gate.md#L38-L44: keep the "verified to be wired" parenthetical only after the assertion exists; otherwise remove it.
📍 Affects 3 files
  • scripts/gc_matrix_liveness_check.py#L194-L254 (this comment)
  • .github/workflows/test.yml#L208-L216
  • changelog.d/7275-gc-matrix-liveness-gate.md#L38-L44
🤖 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 194 - 254, Update
check_registry() in scripts/gc_matrix_liveness_check.py to add a violation when
Path(__file__).name is absent from matrix_text, verifying the matrix still
invokes the checker. In .github/workflows/test.yml lines 208-216 and
changelog.d/7275-gc-matrix-liveness-gate.md lines 38-44, retain the wiring
claims only because this assertion now enforces them; otherwise remove those
clauses.

Comment on lines +268 to +420
def self_test() -> int:
import io

failures = []

def expect(name, violations, want):
got = len(violations)
if (got > 0) != want:
failures.append("%s: wanted %s, got %r" % (name, "a violation" if want else "clean", violations))

sink = io.StringIO()

# An arm that never scavenged and is not registered is the #7255 defect.
expect(
"inert scavenge arm is rejected",
check_report(
_report([{"id": "default", "requires": "scavenge"}], [_cell("default", cycles=3)]),
{},
sink,
),
True,
)
# ...and is accepted once registered, because the blocker is named.
expect(
"registered inert arm is accepted",
check_report(
_report([{"id": "default", "requires": "scavenge"}], [_cell("default", cycles=3)]),
{"default": ("#7161", "stopgap")},
sink,
),
False,
)
# A registry entry that has outlived its cause must be loud, not silent.
expect(
"stale registry entry is rejected",
check_report(
_report([{"id": "default", "requires": "scavenge"}], [_cell("default", scavenged=17)]),
{"default": ("#7161", "stopgap")},
sink,
),
True,
)
expect(
"live arm passes",
check_report(
_report([{"id": "evac", "requires": "scavenge"}], [_cell("evac", scavenged=17)]),
{},
sink,
),
False,
)
# #7025: `move` may be satisfied by the C4b mark-sweep evacuation alone, but
# `scavenge` may NOT — that distinction is the whole point of two counters.
expect(
"move is satisfied by evacuated-only",
check_report(
_report([{"id": "m", "requires": "move"}], [_cell("m", evacuated=9)]),
{},
sink,
),
False,
)
expect(
"scavenge is NOT satisfied by evacuated-only",
check_report(
_report([{"id": "s", "requires": "scavenge"}], [_cell("s", evacuated=9)]),
{},
sink,
),
True,
)
expect(
"requires=none is never a violation",
check_report(
_report([{"id": "ctl", "requires": "none"}], [_cell("ctl")]),
{},
sink,
),
False,
)
expect(
"collect arm needs a cycle",
check_report(
_report([{"id": "c", "requires": "collect"}], [_cell("c")]),
{},
sink,
),
True,
)
expect(
"an unknown requires= value is rejected",
check_report(
_report([{"id": "x", "requires": "sometimes"}], [_cell("x")]),
{},
sink,
),
True,
)
expect(
"an empty report is rejected",
check_report(_report([], []), {}, sink),
True,
)
# A liveness verdict must be per-arm, not pooled: one live arm must not
# vouch for an inert one (the shape #7025 describes for counters).
expect(
"one live arm does not vouch for an inert one",
check_report(
_report(
[{"id": "live", "requires": "scavenge"}, {"id": "dead", "requires": "scavenge"}],
[_cell("live", scavenged=5), _cell("dead", cycles=5)],
),
{},
sink,
),
True,
)

for text, want_ok in [
("arm | #123 | reason", True),
("arm | 123 | reason", False), # issue number must be an issue number
("arm | #123", False), # three fields
("arm | #123 | a\narm | #124 | b", False), # duplicate
("arm | #123 |", False), # empty reason
("# just a comment\n\n", True),
]:
try:
parse_registry(text)
ok = True
except Violation:
ok = False
if ok != want_ok:
failures.append("registry parse of %r: wanted ok=%s" % (text, want_ok))

# The parsers must survive the real file, or the registry check is vacuous —
# a regex that silently matches nothing is its own way for a gate to pass.
try:
text = MATRIX.read_text()
arms = matrix_arms(text)
pr_arms = matrix_pr_arms(text)
if len(arms) < 5 or arms.get("default") != "scavenge":
failures.append("matrix_arms parsed %r from the real matrix script" % (arms,))
if "default" not in pr_arms:
failures.append("matrix_pr_arms parsed %r from the real matrix script" % (pr_arms,))
if set(pr_arms) - set(arms):
failures.append("PR_ARMS names arms that are not in ARMS: %r" % (set(pr_arms) - set(arms),))
except Violation as exc:
failures.append("parsing the real matrix script failed: %s" % exc)

for failure in failures:
print("SELF-TEST FAIL: %s" % failure, file=sys.stderr)
print("self-test: %d checks, %d failures" % (12 + 6 + 3, len(failures)))
return 1 if failures else 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The self-test check count is hand-maintained and already wrong.

Line 419 prints 12 + 6 + 3. self_test makes 11 expect calls, iterates 6 registry-parse cases, and makes 3 real-matrix assertions. The total is 20, not 21. A count that nobody derives is the defect class this file was written to remove. Count the checks as they run.

🐛 Proposed fix
     failures = []
+    checks = 0
 
     def expect(name, violations, want):
+        nonlocal checks
+        checks += 1
         got = len(violations)
         if (got > 0) != want:
             failures.append("%s: wanted %s, got %r" % (name, "a violation" if want else "clean", violations))
         except Violation:
             ok = False
+        checks += 1
         if ok != want_ok:
             failures.append("registry parse of %r: wanted ok=%s" % (text, want_ok))
         if set(pr_arms) - set(arms):
             failures.append("PR_ARMS names arms that are not in ARMS: %r" % (set(pr_arms) - set(arms),))
+        checks += 3
     except Violation as exc:
         failures.append("parsing the real matrix script failed: %s" % exc)
 
     for failure in failures:
         print("SELF-TEST FAIL: %s" % failure, file=sys.stderr)
-    print("self-test: %d checks, %d failures" % (12 + 6 + 3, len(failures)))
+    print("self-test: %d checks, %d failures" % (checks, len(failures)))
📝 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
def self_test() -> int:
import io
failures = []
def expect(name, violations, want):
got = len(violations)
if (got > 0) != want:
failures.append("%s: wanted %s, got %r" % (name, "a violation" if want else "clean", violations))
sink = io.StringIO()
# An arm that never scavenged and is not registered is the #7255 defect.
expect(
"inert scavenge arm is rejected",
check_report(
_report([{"id": "default", "requires": "scavenge"}], [_cell("default", cycles=3)]),
{},
sink,
),
True,
)
# ...and is accepted once registered, because the blocker is named.
expect(
"registered inert arm is accepted",
check_report(
_report([{"id": "default", "requires": "scavenge"}], [_cell("default", cycles=3)]),
{"default": ("#7161", "stopgap")},
sink,
),
False,
)
# A registry entry that has outlived its cause must be loud, not silent.
expect(
"stale registry entry is rejected",
check_report(
_report([{"id": "default", "requires": "scavenge"}], [_cell("default", scavenged=17)]),
{"default": ("#7161", "stopgap")},
sink,
),
True,
)
expect(
"live arm passes",
check_report(
_report([{"id": "evac", "requires": "scavenge"}], [_cell("evac", scavenged=17)]),
{},
sink,
),
False,
)
# #7025: `move` may be satisfied by the C4b mark-sweep evacuation alone, but
# `scavenge` may NOT — that distinction is the whole point of two counters.
expect(
"move is satisfied by evacuated-only",
check_report(
_report([{"id": "m", "requires": "move"}], [_cell("m", evacuated=9)]),
{},
sink,
),
False,
)
expect(
"scavenge is NOT satisfied by evacuated-only",
check_report(
_report([{"id": "s", "requires": "scavenge"}], [_cell("s", evacuated=9)]),
{},
sink,
),
True,
)
expect(
"requires=none is never a violation",
check_report(
_report([{"id": "ctl", "requires": "none"}], [_cell("ctl")]),
{},
sink,
),
False,
)
expect(
"collect arm needs a cycle",
check_report(
_report([{"id": "c", "requires": "collect"}], [_cell("c")]),
{},
sink,
),
True,
)
expect(
"an unknown requires= value is rejected",
check_report(
_report([{"id": "x", "requires": "sometimes"}], [_cell("x")]),
{},
sink,
),
True,
)
expect(
"an empty report is rejected",
check_report(_report([], []), {}, sink),
True,
)
# A liveness verdict must be per-arm, not pooled: one live arm must not
# vouch for an inert one (the shape #7025 describes for counters).
expect(
"one live arm does not vouch for an inert one",
check_report(
_report(
[{"id": "live", "requires": "scavenge"}, {"id": "dead", "requires": "scavenge"}],
[_cell("live", scavenged=5), _cell("dead", cycles=5)],
),
{},
sink,
),
True,
)
for text, want_ok in [
("arm | #123 | reason", True),
("arm | 123 | reason", False), # issue number must be an issue number
("arm | #123", False), # three fields
("arm | #123 | a\narm | #124 | b", False), # duplicate
("arm | #123 |", False), # empty reason
("# just a comment\n\n", True),
]:
try:
parse_registry(text)
ok = True
except Violation:
ok = False
if ok != want_ok:
failures.append("registry parse of %r: wanted ok=%s" % (text, want_ok))
# The parsers must survive the real file, or the registry check is vacuous —
# a regex that silently matches nothing is its own way for a gate to pass.
try:
text = MATRIX.read_text()
arms = matrix_arms(text)
pr_arms = matrix_pr_arms(text)
if len(arms) < 5 or arms.get("default") != "scavenge":
failures.append("matrix_arms parsed %r from the real matrix script" % (arms,))
if "default" not in pr_arms:
failures.append("matrix_pr_arms parsed %r from the real matrix script" % (pr_arms,))
if set(pr_arms) - set(arms):
failures.append("PR_ARMS names arms that are not in ARMS: %r" % (set(pr_arms) - set(arms),))
except Violation as exc:
failures.append("parsing the real matrix script failed: %s" % exc)
for failure in failures:
print("SELF-TEST FAIL: %s" % failure, file=sys.stderr)
print("self-test: %d checks, %d failures" % (12 + 6 + 3, len(failures)))
return 1 if failures else 0
def self_test() -> int:
import io
failures = []
checks = 0
def expect(name, violations, want):
nonlocal checks
checks += 1
got = len(violations)
if (got > 0) != want:
failures.append("%s: wanted %s, got %r" % (name, "a violation" if want else "clean", violations))
sink = io.StringIO()
# An arm that never scavenged and is not registered is the `#7255` defect.
expect(
"inert scavenge arm is rejected",
check_report(
_report([{"id": "default", "requires": "scavenge"}], [_cell("default", cycles=3)]),
{},
sink,
),
True,
)
# ...and is accepted once registered, because the blocker is named.
expect(
"registered inert arm is accepted",
check_report(
_report([{"id": "default", "requires": "scavenge"}], [_cell("default", cycles=3)]),
{"default": ("`#7161`", "stopgap")},
sink,
),
False,
)
# A registry entry that has outlived its cause must be loud, not silent.
expect(
"stale registry entry is rejected",
check_report(
_report([{"id": "default", "requires": "scavenge"}], [_cell("default", scavenged=17)]),
{"default": ("`#7161`", "stopgap")},
sink,
),
True,
)
expect(
"live arm passes",
check_report(
_report([{"id": "evac", "requires": "scavenge"}], [_cell("evac", scavenged=17)]),
{},
sink,
),
False,
)
# `#7025`: `move` may be satisfied by the C4b mark-sweep evacuation alone, but
# `scavenge` may NOT — that distinction is the whole point of two counters.
expect(
"move is satisfied by evacuated-only",
check_report(
_report([{"id": "m", "requires": "move"}], [_cell("m", evacuated=9)]),
{},
sink,
),
False,
)
expect(
"scavenge is NOT satisfied by evacuated-only",
check_report(
_report([{"id": "s", "requires": "scavenge"}], [_cell("s", evacuated=9)]),
{},
sink,
),
True,
)
expect(
"requires=none is never a violation",
check_report(
_report([{"id": "ctl", "requires": "none"}], [_cell("ctl")]),
{},
sink,
),
False,
)
expect(
"collect arm needs a cycle",
check_report(
_report([{"id": "c", "requires": "collect"}], [_cell("c")]),
{},
sink,
),
True,
)
expect(
"an unknown requires= value is rejected",
check_report(
_report([{"id": "x", "requires": "sometimes"}], [_cell("x")]),
{},
sink,
),
True,
)
expect(
"an empty report is rejected",
check_report(_report([], []), {}, sink),
True,
)
# A liveness verdict must be per-arm, not pooled: one live arm must not
# vouch for an inert one (the shape `#7025` describes for counters).
expect(
"one live arm does not vouch for an inert one",
check_report(
_report(
[{"id": "live", "requires": "scavenge"}, {"id": "dead", "requires": "scavenge"}],
[_cell("live", scavenged=5), _cell("dead", cycles=5)],
),
{},
sink,
),
True,
)
for text, want_ok in [
("arm | `#123` | reason", True),
("arm | 123 | reason", False), # issue number must be an issue number
("arm | `#123`", False), # three fields
("arm | `#123` | a\narm | `#124` | b", False), # duplicate
("arm | `#123` |", False), # empty reason
("# just a comment\n\n", True),
]:
try:
parse_registry(text)
ok = True
except Violation:
ok = False
checks += 1
if ok != want_ok:
failures.append("registry parse of %r: wanted ok=%s" % (text, want_ok))
# The parsers must survive the real file, or the registry check is vacuous —
# a regex that silently matches nothing is its own way for a gate to pass.
try:
text = MATRIX.read_text()
arms = matrix_arms(text)
pr_arms = matrix_pr_arms(text)
if len(arms) < 5 or arms.get("default") != "scavenge":
failures.append("matrix_arms parsed %r from the real matrix script" % (arms,))
if "default" not in pr_arms:
failures.append("matrix_pr_arms parsed %r from the real matrix script" % (pr_arms,))
if set(pr_arms) - set(arms):
failures.append("PR_ARMS names arms that are not in ARMS: %r" % (set(pr_arms) - set(arms),))
checks += 3
except Violation as exc:
failures.append("parsing the real matrix script failed: %s" % exc)
for failure in failures:
print("SELF-TEST FAIL: %s" % failure, file=sys.stderr)
print("self-test: %d checks, %d failures" % (checks, len(failures)))
return 1 if failures else 0
🤖 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 268 - 420, Update
self_test’s final check-count reporting to derive the total from the checks
actually executed, including the expect calls, registry-parse cases, and
real-matrix assertions, instead of the hand-maintained “12 + 6 + 3” expression.
Preserve the existing failure counting and output format while ensuring the
reported total is 20 for the current checks.

Comment on lines +101 to +105
# Local exploration only (e.g. a `--filter` narrow enough that an arm
# legitimately has nothing to bite). CI never passes this: the whole
# point of #7255 is that an inert arm must be able to turn a run red.
--liveness-report-only) LIVENESS_REPORT_ONLY=1; shift ;;
-h|--help) sed -n '1,70p' "$0"; exit 0 ;;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

--help truncates the usage block at line 70, so the new flag is not shown.

The usage block now ends at line 73. sed -n '1,70p' stops before the --json, --list-arms, and --liveness-report-only lines. A user who runs --help cannot see the flag this PR adds.

Prefer a range that is not a hard-coded count, or extend it past the usage block.

🐛 Proposed fix
-        -h|--help) sed -n '1,70p' "$0"; exit 0 ;;
+        -h|--help) sed -n '/^# Usage:/,/^$/p' "$0"; exit 0 ;;
📝 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
# Local exploration only (e.g. a `--filter` narrow enough that an arm
# legitimately has nothing to bite). CI never passes this: the whole
# point of #7255 is that an inert arm must be able to turn a run red.
--liveness-report-only) LIVENESS_REPORT_ONLY=1; shift ;;
-h|--help) sed -n '1,70p' "$0"; exit 0 ;;
# Local exploration only (e.g. a `--filter` narrow enough that an arm
# legitimately has nothing to bite). CI never passes this: the whole
# point of `#7255` is that an inert arm must be able to turn a run red.
--liveness-report-only) LIVENESS_REPORT_ONLY=1; shift ;;
-h|--help) sed -n '/^# Usage:/,/^$/p' "$0"; exit 0 ;;
🤖 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` around lines 101 - 105, Update the --help
handler in scripts/gc_repsel_matrix.sh to display the complete usage block,
including --json, --list-arms, and --liveness-report-only. Replace the
hard-coded sed range in the option handling with a range that reaches the usage
block’s actual end without truncation.

@proggeramlug
proggeramlug merged commit 4e9b1b7 into main Aug 2, 2026
28 of 40 checks passed
@proggeramlug
proggeramlug deleted the fix/7255-matrix-liveness branch August 2, 2026 20:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant