Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions .github/workflows/gc-moving-witnesses.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,23 @@ name: GC Moving Witnesses
# reproducers.
#
# The witnesses themselves reach CI only through `gc-stress`, which runs the
# matrix's PR arm subset — `default,evac_minor,verify_evac,force_verify,
# cons_scan_off,shipped_default` — and not one of those arms compiles with the
# flag. So on every pull request these files were compiled into IR in which
# their bug is not expressible, then run to completion, green, proving nothing.
# They were reproductions, not gates — the fourth of CLAUDE.md's "four ways a
# gate can be unable to fail": the job runs, but its subject never does.
# matrix's PR arm subset, and when this job was written not one arm in that
# subset compiled with the flag. So on every pull request these files were
# compiled into IR in which their bug is not expressible, then run to
# completion, green, proving nothing. They were reproductions, not gates — the
# fourth of CLAUDE.md's "four ways a gate can be unable to fail": the job runs,
# but its subject never does.
#
# #7255 CHANGED HALF OF THAT AND DOES NOT REPLACE THIS JOB. The PR subset now
# carries `safepoint_minor`, which does compile and run with
# `PERRY_GC_MOVING_LOOP_POLLS=1`, so a witness is at least *expressible* on
# every PR. That arm is deliberately the UNFORCED route — polls only, no
# `PERRY_GC_INCREMENTAL=0`, no `PERRY_CONSERVATIVE_STACK_SCAN=off`, no
# `PERRY_GC_FORCE_EVACUATE=1` — because it is what the shipped default becomes
# the day #7161's stopgap lifts. `loop_polls` here stacks the evacuating base
# and forced evacuation on top of the same polls, and rejects `UNVER` per CELL
# rather than per arm. Strictly stronger, and still the only place the
# witnesses run that way.
#
# #7161 makes this sharper rather than softer. It flipped the evacuating minor
# default-OFF as a stopgap for #7154, so `PERRY_GC_MOVING_LOOP_POLLS=1` is now
Expand Down
14 changes: 14 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,20 @@ jobs:
python3 scripts/gc_gate_wiring_check.py --self-test
python3 scripts/gc_gate_wiring_check.py

# The other half of the same hazard (#7255). `gc_gate_wiring_check.py`
# asserts the matrix JOB can run; this asserts the matrix ARMS can fail.
# Four of the six PR-gating arms sat at copy-minor 0/50 for five weeks
# while the script's header advertised 12/22, because an all-UNVER table
# exits 0. Both checks are build-free, so they belong in `lint`:
# `--self-test` covers the red/green rule itself, `--check-registry`
# covers the known-inert list (names resolve to real arms, every entry
# cites an issue, and the matrix still calls the checker at all — a gate
# nobody invokes is the same hazard one level up).
- name: GC matrix liveness gate
run: |
python3 scripts/gc_matrix_liveness_check.py --self-test
python3 scripts/gc_matrix_liveness_check.py --check-registry

# ---------------------------------------------------------------------------
# Clippy — enforces the deny-level lints in [workspace.lints] (root
# Cargo.toml). `cargo clippy` exits nonzero only on `deny` lints, so
Expand Down
48 changes: 48 additions & 0 deletions changelog.d/7275-gc-matrix-liveness-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
### Fixed

**The GC stress matrix can now fail when an arm stops exercising the collector (#7255).**

`scripts/gc_repsel_matrix.sh` classifies a cell as `UNVER` when the output matched
the Node oracle but the arm was measurably inert, and that classification is
correct. What was missing is that `UNVER` could not turn a run red: the exit
status counted only `FAIL`. An arm that went inert across the *whole* corpus
therefore produced a yellow table and exit 0, which is CLAUDE.md's fourth way a
gate can be unable to fail — the job runs, and its subject never does.

That is not a hypothetical. #7024 measured the `default` arm at copy-minor
`0/22`, fixed it to `12/22`, and recorded the fix in the script header in its
strongest formatting. #7161 then flipped `PERRY_GC_MOVING_LOOP_POLLS`
default-OFF as a stopgap for #7154 — the gate for *both* halves of that route
(perry-codegen's `moving_safepoint_polls_enabled`, which decides whether
`js_gc_loop_safepoint` back-edge polls are emitted at all, and perry-runtime's
`gc_moving_loop_polls_enabled`, which decides whether the alloc-point nursery
trigger defers to them) — taking the same arm back to `0/50`. The header still
read `12/22`, because it was a hand-maintained number in a comment. Four of the
six PR-gating arms were inert, and #6982 and #7018 could not be judged in either
direction because both crash *inside* a copying minor that no longer ran.

Three changes, in the order the issue asked for them.

* **A relocating arm is back in the PR subset.** New arm `safepoint_minor`
compiles *and* runs with `PERRY_GC_MOVING_LOOP_POLLS=1` 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 roots are precise and rewritable, which is exactly what
`default` was between #7024 and #7161 and what it becomes again when the
stopgap lifts. It is in `PR_ARMS`.
* **The declarations are honest again, and self-healing.** `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` with the issue that blocks them.
Registering an arm is the only way to be inert without failing.
* **`scripts/gc_matrix_liveness_check.py` runs at the end of every matrix
invocation** and fails it when an unregistered arm satisfied its `requires=` on
zero cells — *and* when a registered arm starts biting again, so the registry
cannot rot the way the header did. It has `--self-test` and `--check-registry`
modes wired into `lint`, so the rule that decides red-versus-green is a tested
program rather than untested bash, and it is verified to be *wired* (the
registry check fails if the matrix stops calling it).

No liveness number is written down anywhere any more. The per-arm table is
derived from the collector's own `PERRY_GC_TRACE` / `PERRY_GC_DIAG` output and
printed on every run.
30 changes: 21 additions & 9 deletions docs/representation-selection-rfc.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,8 @@ one-time arguments:
GC arm — `PERRY_GC_FORCE_EVACUATE`, `PERRY_GC_VERIFY_EVACUATION`, `PERRY_GEN_GC=0`,
`PERRY_WRITE_BARRIERS=0`, `PERRY_CONSERVATIVE_STACK_SCAN=off`, `PERRY_GC_MOVING_LOOP_POLLS=1`,
their combinations, and *each representation flag OFF x evacuation* — byte-exact against the
pinned Node oracle. Wired into the `gc-stress` CI job: a fast 4-arm subset gates every PR, the
full arm list runs on push.
pinned Node oracle. Wired into the `gc-stress` CI job: the `PR_ARMS` subset gates every PR, the
full arm list runs on push and on the nightly schedule.
- **A NEW REPRESENTATION MUST REGISTER ITS GAP FILE** in `test-parity/gc_repsel_corpus.txt`. The
script fails when a `test_gap_repsel_*` / `test_gap_specabi_*` file exists that is not registered.
This is the GC-side counterpart of the single-decoder refactor #6910 established for mark/rewrite:
Expand All @@ -235,13 +235,25 @@ one-time arguments:
`test-files/test_gap_repsel_gc_stress.ts` is the corpus member built to be live: it holds each
representation's local across escaping allocation churn heavy enough to reach the collector. A new
representation should extend *that* file as well as adding its own, or its GC arms stay inert.
- **What the matrix cannot verify today.** No reachable configuration in an AOT-compiled program
performs an *evacuating minor*: every automatic collection is a full mark-sweep taken under
`ManualGcScanGuard::force_full_scan()`, which additionally pins raw locals conservatively (#6950,
extending #6946 from the `gc()` path). The rebase-after-safepoint contract in the bullets above —
the core GC claim of every pointer representation — is therefore still argued, not tested. #6942
tracks making it testable; when that lands, the matrix's evacuating arms flip from UNVERIFIED to
green with no change to the harness.
**Since #7255 liveness is also a GATE, not only a label**: an arm that satisfied its declared
`requires=` on zero cells fails the run (`scripts/gc_matrix_liveness_check.py`), because an
all-UNVERIFIED table used to exit 0 — four of the six PR-gating arms were inert for five weeks
and nothing said so. Arms that are known-inert for a named reason live in
`test-parity/gc_matrix_inert_arms.txt`, and the same checker fails when one of them starts biting
again, so that list cannot outlive its cause either.
- **What the matrix cannot verify today.** This bullet used to say that no reachable configuration
performs an *evacuating minor*, so the rebase-after-safepoint contract — the core GC claim of
every pointer representation — was argued rather than tested. Two of its three clauses have since
moved. #6977 made the allocation-point collection reachable as a real copying minor under
`PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off` (the matrix's `evac_minor`), and #7019
/ #7024 made the precise loop-back-edge safepoint reach it too (`safepoint_minor`), so the
contract IS tested on those arms. What remains untested is the **shipped default**: #7161 flipped
`PERRY_GC_MOVING_LOOP_POLLS` off pending #7154, and with it off a default binary emits no
back-edge polls and its alloc-point minor runs under `ManualGcScanGuard::force_full_scan()`,
which pins raw locals conservatively and makes the copying minor ineligible. That is why
`default`, `verify_evac`, `cons_scan_off` and `cons_scan_off_force` are listed in
`test-parity/gc_matrix_inert_arms.txt` rather than deleted — the claim is still theirs, it is
just blocked, and the liveness gate demands the list shrink when the stopgap lifts.

### 5.7 Typed heap (Phase 4)
Unboxed storage extends to heap slots where the *container's* shape is proven and stable:
Expand Down
Loading
Loading