From 17ecbcd0e8dc890e33aaa51aeff2d7a91ea8eb69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 28 Jul 2026 20:50:17 -0700 Subject: [PATCH 1/2] =?UTF-8?q?test(gc):=20GC=20x=20representation-selecti?= =?UTF-8?q?on=20stress=20matrix=20=E2=80=94=20harness,=20live=20corpus=20m?= =?UTF-8?q?ember,=20real=20CI=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The representation-selection campaign shipped five representations, each of which 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, #6921, #6892, #6655). Nobody verified the cross-product, and no CI job would have said a word if a representation regressed GC correctness: gc-stress was continue-on-error: true (it could never fail a build), gated behind 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: the corpus x 19 GC arms, byte-exact against the pinned Node oracle. Liveness is part of the result: every run carries PERRY_GC_TRACE=1 / PERRY_GC_DIAG=1 and a cell that matched the oracle under a measurably inert arm is reported UNVERIFIED, never green. Portable to bash 3.2. Fails on any new untriaged red cell. - test-parity/gc_repsel_corpus.txt: the corpus manifest, and the enforcement point — the script fails when a test_gap_repsel_* / test_gap_specabi_* file exists that is not registered. - test-files/test_gap_repsel_gc_stress.ts: the corpus member that actually reaches the collector. Every other repsel gap file performs ZERO collections (measured), which makes every GC arm inert against it. This one holds each representation's local live across escaping allocation churn: 2 collections at default settings, 11 under --pressure 8. Byte-exact vs Node 26.5.0, and each of the six representation gates changes its emitted binary, so every representation is genuinely engaged. - test-parity/gc_repsel_triage.txt: triaged expected-reds, each naming an open issue and why the redness is not a representation defect. - CI: gc-stress loses its job-level continue-on-error (a gate that cannot fail is not a gate — it now sits on the legacy write-barrier step only), 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. - RFC 5.6.1 documents the enforcement contract and what the matrix cannot verify today. Full matrix on 83a6767ff, macOS arm64, node 26.5.0, release: 361 cells, FAIL=0, PASS=26, UNVERIFIED=333, XFAIL=2. All 361 cells are byte-exact against the oracle. The 333 UNVERIFIED cells are the finding, not a formality: no reachable configuration in a compiled program performs an evacuating minor, so every evacuation arm is inert (#6950), and the 18 pre-existing corpus files perform no collections at all. The two XFAILs are #6951. --- .github/workflows/test.yml | 71 +++- docs/representation-selection-rfc.md | 34 ++ scripts/gc_repsel_matrix.sh | 434 ++++++++++++++++++++++++ test-files/test_gap_repsel_gc_stress.ts | 139 ++++++++ test-parity/gc_repsel_corpus.txt | 49 +++ test-parity/gc_repsel_triage.txt | 11 + 6 files changed, 731 insertions(+), 7 deletions(-) create mode 100755 scripts/gc_repsel_matrix.sh create mode 100644 test-files/test_gap_repsel_gc_stress.ts create mode 100644 test-parity/gc_repsel_corpus.txt create mode 100644 test-parity/gc_repsel_triage.txt diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5b2405ffaa..c752f6b139 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 @@ -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 # diff --git a/docs/representation-selection-rfc.md b/docs/representation-selection-rfc.md index c26460353f..90ea53c460 100644 --- a/docs/representation-selection-rfc.md +++ b/docs/representation-selection-rfc.md @@ -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 diff --git a/scripts/gc_repsel_matrix.sh b/scripts/gc_repsel_matrix.sh new file mode 100755 index 0000000000..c65bd0420c --- /dev/null +++ b/scripts/gc_repsel_matrix.sh @@ -0,0 +1,434 @@ +#!/usr/bin/env bash +# GC x representation-selection stress matrix. +# +# WHY THIS EXISTS +# --------------- +# The representation-selection campaign shipped several new value +# representations (canonical unboxed i32 locals #6903, tagged-at-rest `Str` +# #6909, `Ptr` #6911, `Ptr` #6915/#6916, spec-ABI raw params +# #6905, native-i32 residency #6898). Each made its own GC-safety argument in +# its own PR, verified once by hand at merge time. Meanwhile 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. This script is that +# cross-product, as a maintained gate. +# +# ***LIVENESS IS PART OF THE RESULT.*** +# Setting a GC env var does not prove the GC did anything (#6942, #6946, #6950). +# Measured on main: the whole representation corpus performs ZERO collections -- +# the first-GC trigger needs ~1M escaping allocations and a gap test allocates a +# few KB. So every GC arm is INERT against it, and "passes under +# PERRY_GC_FORCE_EVACUATE=1" asserts nothing. +# +# Therefore every run is executed with `PERRY_GC_TRACE=1` (one `[gc] cycle` +# marker per completed collection -- a sound cycle counter) and `PERRY_GC_DIAG=1` +# (`moved_objects=` / `[gc-copy-minor] ran` -- evacuation evidence), and each +# cell is reported as one of: +# +# PASS output byte-exact vs the pinned Node oracle AND the arm's liveness +# requirement was met on this test +# UNVER output byte-exact but the arm was INERT here (no collection, or +# nothing moved for an evacuating arm) -- NOT green, by design +# XFAIL a triaged, justified expected-red (test-parity/gc_repsel_triage.txt) +# FAIL output mismatch, crash, non-zero exit, or compile failure +# +# A cell that matched the oracle under an inert arm is UNVER, never PASS. A +# matrix of green cells from inert arms would license exactly the false +# confidence this gate exists to remove. +# +# `test_gap_repsel_gc_stress` is the corpus member deliberately built to be +# LIVE (measured: 2 collections at default settings, 11 under `--pressure 8`). +# If a collector change stops it collecting, its cells go UNVER and the arm +# liveness summary shows 0/N -- that is the signal to re-tune its churn budget. +# +# ADDING A REPRESENTATION: register its gap file in +# test-parity/gc_repsel_corpus.txt. This script FAILS if a `test_gap_repsel_*` +# or `test_gap_specabi_*` file exists that is not registered (see +# docs/representation-selection-rfc.md 5.6). +# +# Portable to bash 3.2 (macOS system bash): no associative arrays, no mapfile. +# +# Usage: +# scripts/gc_repsel_matrix.sh [--arms pr|all|] [--filter ] +# [--pressure ] [--jobs N] [--no-build] +# [--profile ] [--json ] +# [--list-arms] +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$ROOT" + +MANIFEST="test-parity/gc_repsel_corpus.txt" +TRIAGE="test-parity/gc_repsel_triage.txt" +ARMS_SEL="pr" +FILTER="" +PRESSURE_MB="8" +JOBS="$(sysctl -n hw.ncpu 2>/dev/null || nproc 2>/dev/null || echo 4)" +DO_BUILD=1 +JSON_OUT="" +PROFILE="release" + +while [ $# -gt 0 ]; do + case "$1" in + --arms) ARMS_SEL="$2"; shift 2 ;; + --filter) FILTER="$2"; shift 2 ;; + --pressure) PRESSURE_MB="$2"; shift 2 ;; + --jobs) JOBS="$2"; shift 2 ;; + --profile) PROFILE="$2"; shift 2 ;; + --no-build) DO_BUILD=0; shift ;; + --json) JSON_OUT="$2"; shift 2 ;; + --list-arms) ARMS_SEL="__list__"; shift ;; + -h|--help) sed -n '1,56p' "$0"; exit 0 ;; + *) echo "unknown flag: $1" >&2; exit 2 ;; + esac +done + +RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[0;33m'; NC=$'\033[0m' +[ -t 1 ] || { RED=""; GREEN=""; YELLOW=""; NC=""; } + +# --------------------------------------------------------------------------- +# Arms. Format: id | compile-env | run-env | liveness-requirement | note +# +# liveness requirement: +# move the arm claims to evacuate -> require moved/copied objects > 0 +# collect the arm claims to collect -> require at least one GC cycle +# none no GC claim of its own (an explicit control) +# +# %P% expands to the pressure env (PERRY_GC_HEAP_LIMIT=) unless +# --pressure 0. `-` means "no run env at all". Compile-time vars change emitted +# IR; all of them are keyed into the object cache +# (perry/src/commands/compile/object_cache.rs), so arms never silently share +# cached objects. +# --------------------------------------------------------------------------- +ARMS=( +"default||%P%|collect|as-shipped GC configuration under allocation pressure" +"force_evac||%P% PERRY_GC_FORCE_EVACUATE=1|move|stress-copy every marked non-pinned nursery object" +"verify_evac||%P% PERRY_GC_VERIFY_EVACUATION=1|collect|panic if a live slot still points at a forwarded object" +"force_verify||%P% PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1|move|force + verify" +"gen_gc_off||%P% PERRY_GEN_GC=0|collect|full mark-sweep only; no nursery => no evacuation by construction" +"wb_off|PERRY_WRITE_BARRIERS=0|%P% PERRY_WRITE_BARRIERS=0|collect|no codegen write barriers => copying nursery ineligible by construction" +"gen_off_verify||%P% PERRY_GEN_GC=0 PERRY_GC_VERIFY_EVACUATION=1|collect|full mark-sweep + evacuation verifier" +"wb_off_force|PERRY_WRITE_BARRIERS=0|%P% PERRY_WRITE_BARRIERS=0 PERRY_GC_FORCE_EVACUATE=1|collect|force-evacuate is a documented no-op without barriers (barriers_inactive)" +"all_four|PERRY_WRITE_BARRIERS=0|%P% PERRY_GEN_GC=0 PERRY_WRITE_BARRIERS=0 PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1|collect|every escape hatch at once" +"cons_scan_off||%P% PERRY_CONSERVATIVE_STACK_SCAN=off|collect|PRECISE ROOTS ONLY -- removes the conservative-stack pinning that every automatic collection otherwise forces (ManualGcScanGuard::force_full_scan). The only arm that can observe a missing shadow-slot binding." +"cons_scan_off_force||%P% PERRY_CONSERVATIVE_STACK_SCAN=off PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1|collect|precise roots + force/verify evacuation" +"loop_polls|PERRY_GC_MOVING_LOOP_POLLS=1|%P% PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_GC_FORCE_EVACUATE=1|move|defer the alloc-point collection to a loop back-edge precise-root safepoint, where the copying minor may MOVE survivors" +"rep_i32_off|PERRY_CANONICAL_I32_LOCALS=0|%P% PERRY_GC_FORCE_EVACUATE=1|move|repsel Phase 1 OFF x evacuation" +"rep_str_off|PERRY_CANONICAL_STR_LOCALS=0|%P% PERRY_GC_FORCE_EVACUATE=1|move|repsel Phase 3a OFF x evacuation" +"rep_ptr_shape_off|PERRY_PTR_SHAPE_LOCALS=0|%P% PERRY_GC_FORCE_EVACUATE=1|move|repsel Phase 3b OFF x evacuation" +"rep_ptr_numarray_off|PERRY_PTR_NUMARRAY_LOCALS=0|%P% PERRY_GC_FORCE_EVACUATE=1|move|repsel Phase 4a.3 OFF x evacuation" +"rep_spec_abi_off|PERRY_SPECIALIZED_ABI=0|%P% PERRY_GC_FORCE_EVACUATE=1|move|repsel Phase 2 OFF x evacuation" +"rep_int_valued_off|PERRY_INT_VALUED_LOCALS=0|%P% PERRY_GC_FORCE_EVACUATE=1|move|native-i32 residency (#6898) OFF x evacuation" +"shipped_default||-|none|control: exactly the as-shipped configuration -- no pressure knob, no GC env at all" +) + +# PR-gating subset: the arms with the most detection power per second -- +# as-shipped under pressure, force+verify evacuation, precise-roots-only, and +# the untouched shipped configuration as a control. +PR_ARMS="default,force_verify,cons_scan_off,shipped_default" + +arm_field() { # $1 = arm record, $2 = 1..5 + printf '%s' "$1" | cut -d'|' -f"$2" +} + +if [ "$ARMS_SEL" = "__list__" ]; then + printf '%-24s %-9s %s\n' "ARM" "REQUIRES" "NOTE" + for rec in "${ARMS[@]}"; do + printf '%-24s %-9s %s\n' "$(arm_field "$rec" 1)" "$(arm_field "$rec" 4)" "$(arm_field "$rec" 5)" + done + exit 0 +fi + +case "$ARMS_SEL" in + pr) SELECTED="$PR_ARMS" ;; + all) SELECTED="$(for rec in "${ARMS[@]}"; do printf '%s,' "$(arm_field "$rec" 1)"; done)" ;; + *) SELECTED="$ARMS_SEL" ;; +esac +SELECTED="${SELECTED%,}" + +# --------------------------------------------------------------------------- +# Corpus + registration enforcement. +# --------------------------------------------------------------------------- +[ -f "$MANIFEST" ] || { echo "missing corpus manifest $MANIFEST" >&2; exit 2; } +CORPUS=() +while IFS= read -r line; do + line="${line%%#*}" + line="$(printf '%s' "$line" | tr -d '[:space:]')" + [ -n "$line" ] && CORPUS+=("$line") +done < "$MANIFEST" + +missing_reg=0 +for f in test-files/test_gap_repsel_*.ts test-files/test_gap_specabi_*.ts; do + [ -f "$f" ] || continue + b="$(basename "$f" .ts)" + found=0 + for c in "${CORPUS[@]}"; do [ "$c" = "$b" ] && found=1 && break; done + if [ "$found" = 0 ]; then + echo "${RED}UNREGISTERED${NC} $f is a representation-selection gap file but is not in $MANIFEST" >&2 + missing_reg=1 + fi +done +if [ "$missing_reg" = 1 ]; then + echo "A NEW REPRESENTATION MUST REGISTER ITS GAP FILE in $MANIFEST." >&2 + echo "See docs/representation-selection-rfc.md 5.6 (GC under unboxed representations)." >&2 + exit 3 +fi + +for b in "${CORPUS[@]}"; do + [ -f "test-files/$b.ts" ] || { echo "manifest lists $b but test-files/$b.ts does not exist" >&2; exit 3; } +done + +if [ -n "$FILTER" ]; then + FILTERED=() + for b in "${CORPUS[@]}"; do + case "$b" in *"$FILTER"*) FILTERED+=("$b") ;; esac + done + CORPUS=(${FILTERED[@]+"${FILTERED[@]}"}) +fi +[ "${#CORPUS[@]}" -gt 0 ] || { echo "empty corpus after filter" >&2; exit 2; } + +# --------------------------------------------------------------------------- +# Oracle + compiler. THE ORACLE VERSION IS LOAD-BEARING: a test the oracle +# cannot run would drop out of the gate silently, so refuse to run at all. +# --------------------------------------------------------------------------- +PINNED_NODE="$(tr -d 'v \n' < .node-version 2>/dev/null || true)" +NODE_V="$(node --version 2>/dev/null | tr -d 'v \n')" +[ -n "$NODE_V" ] || { echo "node not on PATH" >&2; exit 2; } +if [ -n "$PINNED_NODE" ] && [ "$NODE_V" != "$PINNED_NODE" ]; then + echo "${RED}ORACLE MISMATCH${NC}: node $NODE_V but .node-version pins $PINNED_NODE" >&2 + exit 2 +fi + +TARGET_DIR="${CARGO_TARGET_DIR:-target}" +PERRY_BIN="$TARGET_DIR/$PROFILE/perry" +if [ "$DO_BUILD" = 1 ]; then + echo "==> cargo build --profile $PROFILE (perry + runtime/stdlib staticlibs)" + cargo build --profile "$PROFILE" --quiet \ + -p perry -p perry-runtime -p perry-stdlib \ + -p perry-runtime-static -p perry-stdlib-static \ + || { echo "${RED}build failed${NC}" >&2; exit 2; } +fi +[ -x "$PERRY_BIN" ] || { echo "${RED}missing $PERRY_BIN${NC}" >&2; exit 2; } + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/perry-gcmatrix.XXXXXX")" +trap 'rm -rf "$WORK"' EXIT +mkdir -p "$WORK/oracle" "$WORK/bin" "$WORK/out" + +echo "==> oracle: node $NODE_V x ${#CORPUS[@]} corpus files" +oracle_fail=0 +for b in "${CORPUS[@]}"; do + if ! node --experimental-strip-types "test-files/$b.ts" > "$WORK/oracle/$b.out" 2>/dev/null; then + echo "${RED}ORACLE FAIL${NC} node cannot run test-files/$b.ts -- it would drop out of the gate" >&2 + oracle_fail=1 + fi +done +[ "$oracle_fail" = 0 ] || exit 2 + +# --------------------------------------------------------------------------- +# Arm selection + compile groups (one compile pass per distinct compile env). +# --------------------------------------------------------------------------- +ARM_IDS=(); ARM_CENVS=(); ARM_RENVS=(); ARM_LIVES=(); ARM_NOTES=(); ARM_SLUGS=() +GROUP_SLUGS=(); GROUP_ENVS=() +for rec in "${ARMS[@]}"; do + id="$(arm_field "$rec" 1)" + case ",$SELECTED," in *",$id,"*) ;; *) continue ;; esac + cenv="$(arm_field "$rec" 2)" + slug="$(printf '%s' "${cenv:-_base}" | tr -c 'A-Za-z0-9' '_')" + ARM_IDS+=("$id"); ARM_CENVS+=("$cenv"); ARM_RENVS+=("$(arm_field "$rec" 3)") + ARM_LIVES+=("$(arm_field "$rec" 4)"); ARM_NOTES+=("$(arm_field "$rec" 5)") + ARM_SLUGS+=("$slug") + known=0 + for g in ${GROUP_SLUGS[@]+"${GROUP_SLUGS[@]}"}; do [ "$g" = "$slug" ] && known=1 && break; done + if [ "$known" = 0 ]; then GROUP_SLUGS+=("$slug"); GROUP_ENVS+=("$cenv"); fi +done +NARMS="${#ARM_IDS[@]}" +[ "$NARMS" -gt 0 ] || { echo "no arms selected ($ARMS_SEL)" >&2; exit 2; } + +# Warm the auto-optimize archive serially first: the parallel fan-out below +# would otherwise have N processes racing to build the same target/perry-auto-* +# archive on a cold tree. The harness deliberately does NOT set +# PERRY_NO_AUTO_OPTIMIZE, so the binaries under test are linked exactly the way +# a shipped `perry file.ts` links them (that also decides whether the runtime +# carries the `diagnostics` feature, which changes the GC trace format -- both +# are handled below). +echo "==> warming the auto-optimize archive" +mkdir -p "$WORK/bin/_warm" +"$PERRY_BIN" "test-files/${CORPUS[0]}.ts" -o "$WORK/bin/_warm/warm" > "$WORK/bin/_warm/warm.log" 2>&1 \ + || { echo "${RED}warm-up compile failed${NC} (see $WORK/bin/_warm/warm.log)" >&2; } + +echo "==> compiling ${#CORPUS[@]} files x ${#GROUP_SLUGS[@]} compile-env groups (jobs=$JOBS)" +gi=0 +while [ "$gi" -lt "${#GROUP_SLUGS[@]}" ]; do + slug="${GROUP_SLUGS[$gi]}"; cenv="${GROUP_ENVS[$gi]}" + mkdir -p "$WORK/bin/$slug" + printf '%s\n' "${CORPUS[@]}" | WORK="$WORK" PERRY_BIN="$PERRY_BIN" CENV="$cenv" SLUG="$slug" \ + xargs -P "$JOBS" -I{} sh -c \ + 'env $CENV "$PERRY_BIN" "test-files/$1.ts" -o "$WORK/bin/$SLUG/$1" > "$WORK/bin/$SLUG/$1.log" 2>&1 || echo "COMPILEFAIL $SLUG $1"' _ {} + gi=$((gi+1)) +done + +# --------------------------------------------------------------------------- +# Run + classify. CELLS / EVID are flat arrays indexed test*NARMS + arm. +# --------------------------------------------------------------------------- +PRESSURE_ENV="" +[ "$PRESSURE_MB" != "0" ] && PRESSURE_ENV="PERRY_GC_HEAP_LIMIT=$PRESSURE_MB" + +triage_reason() { # $1 test, $2 arm + [ -f "$TRIAGE" ] || return 1 + grep -v '^[[:space:]]*#' "$TRIAGE" 2>/dev/null \ + | awk -F'|' -v t="$1" -v a="$2" ' + { gsub(/^[ \t]+|[ \t]+$/, "", $1); gsub(/^[ \t]+|[ \t]+$/, "", $2); + if ($1 == t && $2 == a) { sub(/^[ \t]+/, "", $3); print $3; found=1 } } + END { exit(found ? 0 : 1) }' +} + +CELLS=(); EVID=() +n_pass=0; n_unver=0; n_fail=0; n_xfail=0 +ai=0 +while [ "$ai" -lt "$NARMS" ]; do + id="${ARM_IDS[$ai]}"; slug="${ARM_SLUGS[$ai]}"; live="${ARM_LIVES[$ai]}" + renv="$(printf '%s' "${ARM_RENVS[$ai]}" | sed "s/%P%/$PRESSURE_ENV/")" + [ "$renv" = "-" ] && renv="" + echo "==> arm $id" + ti=0 + while [ "$ti" -lt "${#CORPUS[@]}" ]; do + b="${CORPUS[$ti]}"; bin="$WORK/bin/$slug/$b"; idx=$((ti*NARMS+ai)) + if [ ! -x "$bin" ]; then + result="FAIL"; ev="compile-failed" + else + # One run per cell: stdout is the parity artifact, stderr carries + # both liveness signals (PERRY_GC_TRACE=1 -> one `[gc] cycle` + # marker per collection; PERRY_GC_DIAG=1 -> evacuation counters). + # shellcheck disable=SC2086 + env $renv PERRY_GC_TRACE=1 PERRY_GC_DIAG=1 "$bin" \ + > "$WORK/out/$b.$id.out" 2> "$WORK/out/$b.$id.err" + rc=$? + # Two trace formats exist: a runtime staticlib built WITHOUT the + # `diagnostics` feature prints one `[gc] cycle (…disabled…)` marker + # per collection; one built WITH it prints the full JSON trace + # object. Count either -- both are exactly one line per cycle. + cycles=$(grep -cE '^\[gc\] cycle|^\{.*"phase_progression"' "$WORK/out/$b.$id.err" 2>/dev/null | tr -d ' ') + moved=$( { grep -oE 'moved_objects=[0-9]+' "$WORK/out/$b.$id.err" 2>/dev/null || true; \ + grep -oE '\[gc-copy-minor\] ran copied_objects=[0-9]+' "$WORK/out/$b.$id.err" 2>/dev/null || true; } \ + | grep -oE '[0-9]+$' | awk '{s+=$1} END {print s+0}') + : "${cycles:=0}"; : "${moved:=0}" + ev="cycles=$cycles moved=$moved" + if [ "$rc" -ne 0 ]; then + result="FAIL"; ev="exit=$rc $ev" + elif ! cmp -s "$WORK/out/$b.$id.out" "$WORK/oracle/$b.out"; then + result="FAIL"; ev="output-mismatch $ev" + else + case "$live" in + move) [ "$moved" -gt 0 ] && result="PASS" || result="UNVER" ;; + collect) [ "$cycles" -gt 0 ] && result="PASS" || result="UNVER" ;; + *) result="PASS" ;; + esac + fi + fi + if [ "$result" = "FAIL" ]; then + if reason="$(triage_reason "$b" "$id")"; then + result="XFAIL"; ev="$reason | $ev" + fi + fi + CELLS[$idx]="$result"; EVID[$idx]="$ev" + case "$result" in + PASS) n_pass=$((n_pass+1)) ;; + UNVER) n_unver=$((n_unver+1)) ;; + XFAIL) n_xfail=$((n_xfail+1)); echo " ${YELLOW}XFAIL${NC} $b" ;; + FAIL) n_fail=$((n_fail+1)); echo " ${RED}FAIL${NC} $b ($ev)" ;; + esac + ti=$((ti+1)) + done + ai=$((ai+1)) +done + +# --------------------------------------------------------------------------- +# Table + arm liveness summary. +# --------------------------------------------------------------------------- +echo +printf '%-40s' "test \\ arm" +for id in "${ARM_IDS[@]}"; do printf '%-8s' "$(printf '%s' "$id" | cut -c1-7)"; done +echo +ti=0 +while [ "$ti" -lt "${#CORPUS[@]}" ]; do + b="${CORPUS[$ti]}" + printf '%-40s' "${b#test_gap_}" + ai=0 + while [ "$ai" -lt "$NARMS" ]; do + c="${CELLS[$((ti*NARMS+ai))]:-?}" + case "$c" in + PASS) printf '%s%-8s%s' "$GREEN" "PASS" "$NC" ;; + UNVER) printf '%s%-8s%s' "$YELLOW" "UNVER" "$NC" ;; + XFAIL) printf '%s%-8s%s' "$YELLOW" "XFAIL" "$NC" ;; + *) printf '%s%-8s%s' "$RED" "$c" "$NC" ;; + esac + ai=$((ai+1)) + done + echo + ti=$((ti+1)) +done + +echo +echo "arm liveness across the corpus (cells where the arm actually bit):" +ai=0 +while [ "$ai" -lt "$NARMS" ]; do + tot=0; livec=0; livem=0; ti=0 + while [ "$ti" -lt "${#CORPUS[@]}" ]; do + ev="${EVID[$((ti*NARMS+ai))]:-}" + cy="$(printf '%s' "$ev" | sed -nE 's/.*cycles=([0-9]+).*/\1/p')" + mv="$(printf '%s' "$ev" | sed -nE 's/.*moved=([0-9]+).*/\1/p')" + tot=$((tot+1)) + [ "${cy:-0}" -gt 0 ] 2>/dev/null && livec=$((livec+1)) + [ "${mv:-0}" -gt 0 ] 2>/dev/null && livem=$((livem+1)) + ti=$((ti+1)) + done + printf ' %-24s requires=%-8s collected %2d/%2d moved-objects %2d/%2d\n' \ + "${ARM_IDS[$ai]}" "${ARM_LIVES[$ai]}" "$livec" "$tot" "$livem" "$tot" + ai=$((ai+1)) +done + +echo +# Two DIFFERENT properties are being reported and they must not be conflated: +# * byte-exactness vs the pinned Node oracle -- verified in every cell that +# is not FAIL, including cells whose GC arm was inert. For the +# representation-flag arms this IS the meaningful result: the rep's ON and +# OFF lowerings agree byte-for-byte. +# * the GC-stress property -- only verified where the arm was measurably +# live. That is what PASS vs UNVER distinguishes. +n_byte_exact=$((n_pass + n_unver)) +n_cells=$((n_pass + n_unver + n_xfail + n_fail)) +echo "byte-exact vs node $NODE_V: $n_byte_exact/$n_cells cells (the parity property)" +echo "summary: PASS=$n_pass UNVER=$n_unver XFAIL=$n_xfail FAIL=$n_fail" +echo " (pressure=${PRESSURE_MB}MB, node $NODE_V, $PERRY_BIN)" +echo " UNVER = output matched but the arm was inert here; see #6942 / #6946 / #6950." + +if [ -n "$JSON_OUT" ]; then + { + printf '{"node":"%s","pressure_mb":"%s","arms":[' "$NODE_V" "$PRESSURE_MB" + ai=0 + while [ "$ai" -lt "$NARMS" ]; do + [ "$ai" = 0 ] || printf ',' + printf '{"id":"%s","requires":"%s"}' "${ARM_IDS[$ai]}" "${ARM_LIVES[$ai]}" + ai=$((ai+1)) + done + printf '],"cells":[' + first=1; ti=0 + while [ "$ti" -lt "${#CORPUS[@]}" ]; do + ai=0 + while [ "$ai" -lt "$NARMS" ]; do + [ "$first" = 1 ] || printf ','; first=0 + printf '{"test":"%s","arm":"%s","result":"%s","evidence":"%s"}' \ + "${CORPUS[$ti]}" "${ARM_IDS[$ai]}" "${CELLS[$((ti*NARMS+ai))]:-?}" "${EVID[$((ti*NARMS+ai))]:-}" + ai=$((ai+1)) + done + ti=$((ti+1)) + done + printf '],"summary":{"pass":%d,"unverified":%d,"xfail":%d,"fail":%d}}\n' \ + "$n_pass" "$n_unver" "$n_xfail" "$n_fail" + } > "$JSON_OUT" + echo "json: $JSON_OUT" +fi + +[ "$n_fail" = 0 ] || exit 1 +exit 0 diff --git a/test-files/test_gap_repsel_gc_stress.ts b/test-files/test_gap_repsel_gc_stress.ts new file mode 100644 index 0000000000..40790d8d0a --- /dev/null +++ b/test-files/test_gap_repsel_gc_stress.ts @@ -0,0 +1,139 @@ +// GC x representation-selection stress. +// +// Every other file in the representation corpus is a small, fast correctness +// test -- and measurably performs ZERO garbage collections, which makes every +// GC env-var arm inert against it (see docs/representation-selection-rfc.md +// 5.6 and issues #6942 / #6946). This file is the corpus member that actually +// reaches the collector: each representation-selected local is held live +// ACROSS escaping allocation churn, so a collection can land while the +// representation's value is in flight. +// +// The shape of every function here is deliberate: +// * the representation-selected local is initialized BEFORE the churn loop, +// * `churn()` allocates objects that escape into a module-level sink (so the +// allocations cannot be optimized away and the arena actually grows), +// * the local is READ AFTER the churn call in the same iteration, so a +// collection at the allocation point must not have invalidated it. +// +// Keep the churn budget in sync with the harness: `scripts/gc_repsel_matrix.sh` +// reports how many GC cycles each arm actually drove (`PERRY_GC_TRACE=1`), and +// this file is the corpus member expected to be LIVE. If a collector change +// raises the trigger, this test silently stops collecting -- the harness +// reports it as UNVERIFIED rather than green, which is the signal to re-tune. + +class Point { + x: number; + y: number; + tag: string; + constructor(x: number, y: number, tag: string) { + this.x = x; + this.y = y; + this.tag = tag; + } + norm(): number { + return this.x * this.x + this.y * this.y; + } +} + +// Escaping allocation churn. The sink is module-level and reassigned, so the +// allocations are genuinely live for a while and then genuinely dead -- the +// pattern that grows the arena and produces sweepable garbage. +let churnSink: unknown[] = []; +let churnAcc = 0; + +function churn(i: number): void { + churnSink.push({ i: i, s: "c" + (i & 1023), a: [i, i + 1] }); + if (churnSink.length > 4096) { + churnAcc = (churnAcc + churnSink.length) | 0; + churnSink = []; + } +} + +// (1) canonical unboxed i32 locals + (2) Ptr proven object local. +// `p` is a shape-proven object local read on every iteration after the churn +// call; `acc` / `i` are canonical i32 locals with no shadow binding at all. +function shapeAndI32(n: number): number { + const p = new Point(3, 4, "point"); + let acc = 0; + let i = 0; + while (i < n) { + churn(i); + acc = (acc + p.x * 2 + p.y) | 0; + i = (i + 1) | 0; + } + return (acc + p.norm() + p.tag.length) | 0; +} + +// (3) canonical string locals (Str tagged-at-rest). `s` is a string local +// re-assigned and read across the churn call; short values stay in the NaN box +// (SSO), longer ones are heap strings that a collection can sweep or move. +function strLocals(n: number): number { + let s = ""; + let total = 0; + for (let i = 0; i < n; i++) { + churn(i); + s = i & 1 ? "v" + (i & 63) : "a-heap-resident-string-value-" + (i & 63); + total = (total + s.length) | 0; + if (s === "v7") { + total = (total + 1) | 0; + } + } + return total | 0; +} + +// (4) Ptr — guard-free numeric element access on a proven local. +// `arr` is allocated with a static length and only ever element-read/written +// with numeric values, which is the Phase 4a.3 eligibility shape. +function numArray(n: number): number { + const arr = new Array(64); + for (let k = 0; k < 64; k++) { + arr[k] = k; + } + let sum = 0; + for (let i = 0; i < n; i++) { + churn(i); + const k = i & 63; + arr[k] = (arr[k] + 1) | 0; + sum = (sum + arr[k]) | 0; + } + return sum | 0; +} + +// (5) spec-ABI raw typed-array param (`TaPtr`). `buf` arrives as a raw +// storage pointer and is re-read after every churn call -- if the callee-side +// binding were dropped and the header moved, this is where it would show. +function taSum(buf: Int32Array, n: number): number { + let acc = 0; + for (let i = 0; i < n; i++) { + churn(i); + acc = (acc + buf[i & 255]) | 0; + } + return acc | 0; +} + +// A second spec-ABI shape: raw param + an escaping object allocated in the +// same frame, so the frame has both a raw pointer param and a movable object. +function taMix(buf: Int32Array, n: number): number { + const p = new Point(7, 9, "mix"); + let acc = 0; + for (let i = 0; i < n; i++) { + churn(i); + acc = (acc + buf[i & 255] + p.x) | 0; + } + return (acc + p.y) | 0; +} + +const N = 320000; + +const ta = new Int32Array(256); +for (let i = 0; i < 256; i++) { + ta[i] = (i * 3) & 255; +} + +console.log("shapeAndI32", shapeAndI32(N)); +console.log("strLocals", strLocals(N)); +console.log("numArray", numArray(N)); +console.log("taSum", taSum(ta, N)); +console.log("taMix", taMix(ta, N)); +console.log("churnAcc", churnAcc); +console.log("churnSink", churnSink.length); diff --git a/test-parity/gc_repsel_corpus.txt b/test-parity/gc_repsel_corpus.txt new file mode 100644 index 0000000000..cce8252d95 --- /dev/null +++ b/test-parity/gc_repsel_corpus.txt @@ -0,0 +1,49 @@ +# Corpus for the GC x representation-selection stress matrix +# (scripts/gc_repsel_matrix.sh). +# +# ***IF YOU ARE ADDING A REPRESENTATION, ADD ITS GAP FILE HERE.*** +# The matrix script FAILS if a `test_gap_repsel_*` / `test_gap_specabi_*` file +# exists in test-files/ that is not registered below. That is the enforcement +# point behind docs/representation-selection-rfc.md 5.6: a new representation +# must be exercised against every GC arm, not just verified once by hand in its +# own PR. Files outside those two prefixes (typed-array param reads, int-valued +# typed-array locals) are not auto-detected, so register them explicitly. +# +# One basename per line, without the .ts extension. `#` starts a comment. + +# --- Phase 1: canonical unboxed i32 locals (#6903) -------------------------- +test_gap_repsel_canonical_i32 +test_gap_int_valued_ta_locals + +# --- Phase 2: specialized calling convention / spec-ABI raw params (#6905) -- +test_gap_specabi_polymorphic_coexist +test_gap_specabi_reassign +test_gap_specabi_recursion_escape +test_gap_specabi_view_detach +test_gap_ta_param_numeric_read +test_gap_typedarray_param_read + +# --- Phase 3a: canonical string locals, tagged-at-rest Str (#6909) ---------- +test_gap_repsel_canonical_str_locals + +# --- Phase 3b: Ptr proven object locals (#6911) ---------------------- +test_gap_repsel_ptr_shape_locals +test_gap_repsel_ptr_shape_barriers + +# --- Phase 4a / 4a.3: Ptr numeric arrays (#6915, #6916) ----------- +test_gap_repsel_p4a_holes_axis +test_gap_repsel_p4a_inline_tiers +test_gap_repsel_p4a_logical_numeric +test_gap_repsel_p4a3_numarray_barriers +test_gap_repsel_p4a3_numarray_growth +test_gap_repsel_p4a3_ptr_numarray + +# --- Phase 4b: class-field store note/addref elision (#6919) ---------------- +test_gap_repsel_p4b_field_store_elision + +# --- The GC-live member ------------------------------------------------------ +# Every file above performs ZERO collections (measured, #6950), which makes the +# GC arms inert against them. This one holds each representation's local live +# across escaping allocation churn heavy enough to reach the collector, so the +# "collect" arms actually bite. Keep it registered and keep it collecting. +test_gap_repsel_gc_stress diff --git a/test-parity/gc_repsel_triage.txt b/test-parity/gc_repsel_triage.txt new file mode 100644 index 0000000000..4399fddcd1 --- /dev/null +++ b/test-parity/gc_repsel_triage.txt @@ -0,0 +1,11 @@ +# Triaged expected-red cells for scripts/gc_repsel_matrix.sh. +# +# Format: | | +# +# A cell listed here reports XFAIL instead of FAIL and does not fail the gate. +# EVERY entry must name an open issue and say why the redness is not a +# representation defect. Do not add an entry to make a table green: an +# untriaged red cell is the whole point of this gate. + +test_gap_repsel_gc_stress | cons_scan_off | #6951 -- with the conservative stack scan disabled, console.log argument temporaries are not precise roots and string-literal labels are dropped. Reproduces byte-identically with ALL representation gates off, so it is a runtime rooting gap, not a representation defect. This arm becomes usable (and becomes the highest-value arm in the matrix) once #6951 is fixed. +test_gap_repsel_gc_stress | cons_scan_off_force | #6951 -- same root cause as the cons_scan_off arm; the evacuation flags add nothing because no automatic collection ever evacuates (#6950). From 2ced897b935f627e198b4ad981fcb7130ceb758e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 28 Jul 2026 20:53:40 -0700 Subject: [PATCH 2/2] docs: changelog fragment for #6954 --- changelog.d/6954-gc-repsel-stress-matrix.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 changelog.d/6954-gc-repsel-stress-matrix.md diff --git a/changelog.d/6954-gc-repsel-stress-matrix.md b/changelog.d/6954-gc-repsel-stress-matrix.md new file mode 100644 index 0000000000..b919b192f5 --- /dev/null +++ b/changelog.d/6954-gc-repsel-stress-matrix.md @@ -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` #6911, `Ptr` #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.