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
71 changes: 64 additions & 7 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -752,18 +752,34 @@ jobs:
# poor fit for the blocking per-PR `cargo-test` gate (one flake blocked
# every unrelated PR). They are `#[ignore]`d there and run here instead.
#
# Opt-in + informational: `continue-on-error` so a flake never fails the
# workflow; triggered by the `run-extended-tests` PR label, a
# `workflow_dispatch` with `run_extended_tests=true`, or a tag push.
# The write-barrier stress tests stay opt-in + informational
# (`continue-on-error` on THEIR step, so a flake never fails the workflow).
#
# The job itself is no longer informational. It also runs the GC x
# representation-selection stress matrix (`scripts/gc_repsel_matrix.sh`),
# which IS a gate: until it existed, a representation could regress GC
# correctness and no CI job would say a word. Three weaknesses were fixed
# deliberately, and re-introducing any of them re-opens that hole:
# 1. job-level `continue-on-error: true` is gone (a gate that cannot fail
# is not a gate); it now sits on the legacy write-barrier step only;
# 2. the `if:` no longer requires a `run-extended-tests` label, which is
# why this job "skipped" on the representation PRs (#6911, #6925);
# 3. it no longer runs write-barrier stress *only* — nothing about the
# representation corpus was covered before.
#
# Cost split: a PR runs the 4-arm subset, whose arms all share one
# compile-time environment, so the corpus is compiled ONCE and run four
# times. push / workflow_dispatch run the full arm list (19 arms, 9 compile
# groups) as the deeper net. NOTE: this job is not yet in branch protection's
# required contexts — adding it there is what makes the gate blocking.
# ---------------------------------------------------------------------------
gc-stress:
continue-on-error: true
if: >-
github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs.run_extended_tests) ||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-extended-tests'))
github.event_name == 'pull_request' ||
github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 90
steps:
- uses: actions/checkout@v7

Expand All @@ -780,13 +796,54 @@ jobs:
sudo apt-get update
sudo apt-get install -y clang

- name: Setup Node.js
uses: actions/setup-node@v7
with:
# Single source of truth: .node-version at the repo root. Node is the
# matrix oracle (we byte-diff against it), so the version is a
# correctness input. scripts/gc_repsel_matrix.sh refuses to run when
# the running node disagrees with the pin — a test the oracle cannot
# run would drop out of the gate silently.
node-version-file: .node-version

- name: Build perry + runtime staticlibs (release)
env:
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: "-C linker-features=-lld"
run: |
cargo build --release \
-p perry -p perry-runtime -p perry-stdlib \
-p perry-runtime-static -p perry-stdlib-static

# GATING. Fails the job on any new untriaged red cell. Cells whose GC arm
# was measurably inert are reported UNVERIFIED, never green (#6942,
# #6946, #6950) — the script asserts liveness from the collector's own
# PERRY_GC_TRACE / PERRY_GC_DIAG output rather than trusting the env var.
- name: GC x representation-selection matrix (PR subset)
if: github.event_name == 'pull_request'
run: ./scripts/gc_repsel_matrix.sh --no-build --arms pr --json gc-repsel-matrix.json

- name: GC x representation-selection matrix (full)
if: github.event_name != 'pull_request'
run: ./scripts/gc_repsel_matrix.sh --no-build --arms all --json gc-repsel-matrix.json

- name: Run GC write-barrier stress tests
# Informational: these are ~200s nondeterministic corruption-window
# hunts (#5029). Kept out of the gate so a flake never blocks a PR.
continue-on-error: true
env:
# Match the cargo-test gate's linker workaround (lld SIGBUS on the
# shared runner during large test links).
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: "-C linker-features=-lld"
run: cargo test -p perry --test gc_write_barrier_stress -- --ignored

- name: Upload matrix report
if: always()
uses: actions/upload-artifact@v7
with:
name: gc-repsel-matrix
path: gc-repsel-matrix.json
if-no-files-found: ignore

# ---------------------------------------------------------------------------
# Compiler-output regression gate
#
Expand Down
17 changes: 17 additions & 0 deletions changelog.d/6954-gc-repsel-stress-matrix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
**GC × representation-selection stress matrix — the campaign's closing correctness gate, and the measurement that the GC stress arms were inert (#6954).**

Six representations shipped during the representation-selection campaign (canonical unboxed i32 locals #6903, tagged-at-rest `Str` #6909, `Ptr<Shape>` #6911, `Ptr<NumArray>` #6915/#6916, spec-ABI raw params #6905, native-i32 residency #6898). Each argued its own GC safety in its own PR, verified once by hand at merge time, while the collector changed underneath all of them (#6910 root-word parity, #6921 typed-shape layout on the ctor exit, #6892 minor-sweep finalization, #6655 operand rooting). The cross-product was never verified, and no CI job could have caught a regression in it: `gc-stress` was `continue-on-error: true` (it could never fail a build), required a `run-extended-tests` label (it did not run on ordinary PRs), and ran only `gc_write_barrier_stress` (nothing about the representation corpus).

`scripts/gc_repsel_matrix.sh` runs the corpus (`test-parity/gc_repsel_corpus.txt`) against 19 GC arms — force/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 × evacuation — byte-exact against the pinned Node oracle, non-zero exit on any new untriaged red cell. Portable to bash 3.2. Compile-time arms are grouped so the corpus compiles once per distinct compile environment (every one of those vars is object-cache-keyed, so arms cannot silently share objects).

**Liveness is part of every cell, and that is the point.** Setting a GC env var does not prove the GC did anything. Each run carries `PERRY_GC_TRACE=1` (one marker per completed collection) and `PERRY_GC_DIAG=1` (`moved_objects`), and a cell that matched the oracle under a measurably inert arm reports **UNVERIFIED**, never green. Full run at 83a6767ff (macOS arm64, release, Node 26.5.0): **361 cells, FAIL=0, PASS=26, UNVERIFIED=333, XFAIL=2**, all 361 byte-exact.

The 333 UNVERIFIED cells are the finding. The 18 pre-existing corpus files perform **zero** collections under every arm; ordinary allocation pressure does not reach the collector below ~1M escaping allocations, and `PERRY_GC_HEAP_LIMIT` does not lower that first-collection point; and **no reachable configuration in a compiled program performs an evacuating minor** — every automatic collection is a full mark-sweep taken under `ManualGcScanGuard::force_full_scan()`, which also pins raw locals conservatively (filed as #6950, extending #6946 from the `gc()` path to the allocation-driven one). "Byte-exact under `PERRY_GC_FORCE_EVACUATE=1`", the standard validation sentence across this campaign, therefore asserts nothing on either reachable path. The harness flips those cells to green with no change once #6942 lands.

`test-files/test_gap_repsel_gc_stress.ts` is the corpus member built to be live: each representation's local is held across escaping allocation churn heavy enough to reach the collector (2 collections at default settings, 11 under `--pressure 8`). All six representation gates change its emitted binary, so every representation is genuinely engaged. `test-parity/gc_repsel_corpus.txt` doubles as the enforcement point — the script fails when a `test_gap_repsel_*` / `test_gap_specabi_*` file exists that is not registered, the GC-side counterpart of #6910's single-decoder rule that adding a representation teaches all paths at once. RFC §5.6.1 documents the contract and what the matrix cannot verify today.

Also found and filed: **#6951** — with the conservative stack scan disabled, a plain non-moving mark-sweep drops `console.log` string-literal argument temporaries (15-line deterministic repro, no evacuation or manual `minor()` needed). Reproduces byte-identically with all six representation gates off, so it is a runtime rooting gap rather than a representation defect; it is carried as the matrix's two triaged XFAILs and is what currently blocks `cons_scan_off`, the only arm that can observe a missing shadow-slot binding on a representation-selected pointer local.

CI: `gc-stress` loses its job-level `continue-on-error` (it now sits on the legacy write-barrier step only, which stays informational), runs on ordinary pull requests, and runs the matrix — a 4-arm subset on PRs (all four share one compile environment, so the corpus compiles once) and the full arm list on push. Timeout 30 → 90 min. The job is not yet in branch protection's required contexts; adding it there is what makes the gate blocking.

No wall-clock or performance claims: this is a correctness gate.
34 changes: 34 additions & 0 deletions docs/representation-selection-rfc.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,40 @@ pointer-rep rules:
- Unwinding runs the existing frame-pop path; registered slots die with the frame — no new
exception machinery.

#### 5.6.1 Enforcement — the GC x representation matrix

Each representation above shipped its GC-safety argument in its own PR, verified once by hand at
merge time, while the collector changed underneath all of them (#6910 mark/rewrite root-word
parity, #6921 typed-shape layout on the ctor exit, #6892 minor-sweep finalization, #6655 operand
rooting). Nobody verified the cross-product. It is now a maintained gate rather than a set of
one-time arguments:

- **The matrix.** `scripts/gc_repsel_matrix.sh` runs the whole representation corpus against every
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.
- **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:
adding a representation teaches all the paths at once, or CI says so.
- **Liveness is part of the result.** Setting a GC env var does not prove the GC did anything. The
first automatic collection needs ~1M escaping allocations, so a small gap test performs *zero*
collections and every GC arm against it is inert (#6942, #6946, #6950). The harness therefore
asserts liveness from the collector's own `PERRY_GC_TRACE` / `PERRY_GC_DIAG` output and reports an
output-matching cell under an inert arm as **UNVERIFIED**, never green.
`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.

### 5.7 Typed heap (Phase 4)
Unboxed storage extends to heap slots where the *container's* shape is proven and stable:
- **Eligibility:** an object qualifies for unboxed field layout only if its shape is
Expand Down
Loading
Loading