#!/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). # Without the pressure knob a gap test allocates a few KB against a first-GC # trigger that needs ~1M escaping allocations, so every GC arm is INERT against # it and "passes under PERRY_GC_FORCE_EVACUATE=1" asserts nothing. That is what # `--pressure` is for, and it is not sufficient on its own -- see the liveness # table every run prints, and the known-inert registry it is checked against. # # 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: it collects even in the shipped configuration with no pressure knob at # all, which almost nothing else in the corpus does. If a collector change stops # it collecting, its cells go UNVER, the arm liveness summary drops, and the # liveness gate fails -- that is the signal to re-tune its churn budget. (Its # per-run cycle counts belong in the run's own output, not here.) # # 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). # # ***AND LIVENESS IS NOW GATED, NOT MERELY REPORTED (#7255).*** # UNVER was "not green" but it was never red either: the exit status counts only # FAIL, so an arm that went inert across the WHOLE corpus produced a yellow table # and exit 0. Every run therefore ends in # `scripts/gc_matrix_liveness_check.py`, which fails when an arm satisfied its # own `requires=` on zero cells — and equally when an arm listed in # `test-parity/gc_matrix_inert_arms.txt` starts biting again, so the registry # cannot rot in the other direction either. # # ***DO NOT PUT LIVENESS NUMBERS IN THIS HEADER.*** The last hand-maintained # pair (`default: 0/22 -> 12/22` after #7024) read as settled fact for five weeks # after #7161 took that same arm back to 0/49, and it is the reason nobody # re-derived it (#7255). The per-arm table printed by every run is the only place # those numbers are true, and it is derived from the collector's own output. # # 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 ] # [--shard N/M] [--defer-liveness] # [--list-arms] [--liveness-report-only] # [--self-test-liveness-parser] # [--self-test-fixture-env] set -uo pipefail # Sum objects actually relocated by completed copying minors. The diagnostic # line is a key/value record, not a positional format: #7744 inserted # `in_place=...` before `copied_objects`, which made the old exact-prefix grep # read every live run as zero. Object-by-object promotions MOVE just like # survivor copies (#7657); whole-block in-place promotions do not. sum_copy_minor_moved() { awk ' /\[gc-copy-minor\] ran / { copied = 0 promoted = 0 in_place = "false" for (i = 1; i <= NF; i++) { split($i, kv, "=") if (kv[1] == "copied_objects") copied = kv[2] + 0 if (kv[1] == "promoted_objects") promoted = kv[2] + 0 if (kv[1] == "in_place") in_place = kv[2] } if (in_place != "true") moved += copied + promoted } END { print moved + 0 } ' "$@" } 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" LIVENESS_REPORT_ONLY=0 SELF_TEST_LIVENESS_PARSER=0 SELF_TEST_FIXTURE_ENV=0 SHARD_INDEX=1 SHARD_COUNT=1 DEFER_LIVENESS=0 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 ;; --shard) shard_spec="$2" SHARD_INDEX="${shard_spec%%/*}" SHARD_COUNT="${shard_spec#*/}" if [ "$shard_spec" != "$SHARD_INDEX/$SHARD_COUNT" ]; then echo "invalid --shard '$shard_spec' (expected N/M)" >&2 exit 2 fi shift 2 ;; # A shard cannot make a corpus-wide liveness claim: the few tests on # it may legitimately be inert for an arm that bites elsewhere. CI # passes this on every shard and runs the gate once on the strict # fan-in report produced by gc_repsel_matrix_merge.py. --defer-liveness) DEFER_LIVENESS=1; shift ;; --list-arms) ARMS_SEL="__list__"; shift ;; --self-test-liveness-parser) SELF_TEST_LIVENESS_PARSER=1; shift ;; --self-test-fixture-env) SELF_TEST_FIXTURE_ENV=1; shift ;; # 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 ;; *) echo "unknown flag: $1" >&2; exit 2 ;; esac done if [ "$SELF_TEST_FIXTURE_ENV" = 1 ]; then exec python3 "$SCRIPT_DIR/gc_matrix_fixture_env.py" --self-test fi case "$PRESSURE_MB" in ''|*[!0-9]*) echo "invalid --pressure '$PRESSURE_MB' (expected integer MB)" >&2; exit 2 ;; esac case "$SHARD_INDEX:$SHARD_COUNT" in *[!0-9:]*|:*|*:) echo "invalid --shard '$SHARD_INDEX/$SHARD_COUNT' (expected positive integers)" >&2 exit 2 ;; esac if [ "$SHARD_INDEX" -lt 1 ] || [ "$SHARD_COUNT" -lt 1 ] || [ "$SHARD_INDEX" -gt "$SHARD_COUNT" ]; then echo "invalid --shard '$SHARD_INDEX/$SHARD_COUNT' (require 1 <= N <= M)" >&2 exit 2 fi if [ "$DEFER_LIVENESS" = 1 ] && [ -z "$JSON_OUT" ]; then echo "--defer-liveness requires --json so the fan-in has evidence to check" >&2 exit 2 fi # Keep a durable, append-only breadcrumb beside the final JSON. The report is # intentionally written only when complete (the fan-in rejects a missing one), # while this file survives a TERM/INT and tells an interrupted CI shard exactly # which file/environment was active plus every completed cell's counters. PROGRESS_OUT="" if [ -n "$JSON_OUT" ]; then PROGRESS_OUT="$JSON_OUT.progress.log" : > "$PROGRESS_OUT" fi progress() { [ -n "$PROGRESS_OUT" ] || return 0 printf '%s shard=%s/%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ "$SHARD_INDEX" "$SHARD_COUNT" "$*" >> "$PROGRESS_OUT" } progress "start arms=$ARMS_SEL filter=${FILTER:-}" WORK="" cleanup() { rc=$? if [ "$rc" -ne 0 ]; then progress "exit status=$rc (final JSON may be absent; this log is the partial evidence)" fi # WORK is assigned only from mktemp below. Keep the empty guard explicit: # this trap is intentionally armed before registration/oracle checks so an # early interruption is preserved too. [ -z "$WORK" ] || rm -rf "$WORK" } on_signal() { signal="$1"; status="$2" progress "interrupted signal=$signal" exit "$status" } trap cleanup EXIT trap 'on_signal INT 130' INT trap 'on_signal TERM 143' TERM trap 'on_signal HUP 129' HUP if [ "$SELF_TEST_LIVENESS_PARSER" = 1 ]; then got="$(sum_copy_minor_moved <<'EOF' [gc-copy-minor] eligible=true fallback=none [gc-copy-minor] ran copied_objects=4 copied_bytes=64 promoted_objects=3 promoted_bytes=48 [gc-copy-minor] ran in_place=false untraced=false copied_objects=0 copied_bytes=0 promoted_objects=5 promoted_bytes=80 [gc-copy-minor] ran in_place=true untraced=false copied_objects=0 copied_bytes=0 promoted_objects=999 promoted_bytes=15984 EOF )" if [ "$got" != 12 ]; then echo "gc_repsel_matrix liveness parser self-test: expected 12 moved objects, got $got" >&2 exit 1 fi echo "gc_repsel_matrix liveness parser self-test: OK (legacy, current, promoted, and in-place forms)" exit 0 fi 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: # scavenge the arm claims the COPYING MINOR runs -> require a non-in-place # `[gc-copy-minor] ran` with copied+promoted objects > 0. Strictly stronger than # `move`, which the C4b mark-sweep evacuation satisfies on its own # (#7025) -- `default` reported `moved=7 610 512` while running zero # copying minors. Any arm whose subject is the relocating young-gen # minor #7019 shipped must use THIS, not `move`. # move the arm claims to evacuate -> require moved/copied objects > 0 # collect the arm claims to collect -> require a PRODUCTIVE cycle: one that # reclaimed something (#7017). `cycles>0` alone counts a cycle that # lands at the event-loop boundary AFTER the program's last output # and frees nothing, which cannot have observed the test's live # locals -- the property the matrix exists to assert. # 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. # # %E% expands to THE EVACUATING BASE (#6950): # # PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off # # Every arm whose requirement is `move` carries it, because without it those # arms are INERT and were reported UNVER across the whole corpus. Two # independent blockers, both measured: # # 1. `PERRY_GC_INCREMENTAL=0`. With incremental mode on (the default), # `registered_root_scanners_block_budgeted_gc()` reduces to "any copy-only # scanner", which a compiled program has none of. So `gc_check_trigger` # skips the direct-collection arm and hands the trigger to the budgeted # stepper, whose mutator assists never drive the cycle to completion. # Turning incremental off restores the direct synchronous minor. # 2. `PERRY_CONSERVATIVE_STACK_SCAN=off`. The direct arm takes # `ManualGcScanGuard::force_full_scan()`, and a forced conservative scan # makes the copying minor ineligible (`fallback=conservative_stack`) -- # the exact sentence #6950 quotes from `gc/policy.rs`. An explicit env # value BEATS that guard in `conservative_stack_scan_mode()`, so this is # what turns the automatic collection into a precise-rooted copying minor # that actually relocates survivors. # # Every %E% arm reports `[gc-copy-minor] eligible=true fallback=none`; the # per-run liveness table at the bottom of the output says how many cells that # was, on the tree you are actually running. (There used to be a hard-coded # before/after table here. It was true when written and false a month later -- # see the DO NOT PUT LIVENESS NUMBERS IN THIS HEADER note above, and #7255.) # # NOTE this is a MEASUREMENT configuration, not the shipped one. It says the # collector's evacuating path is exercised; it does not say the shipped default # reaches that path. # # THE SHIPPED DEFAULT REACHES IT AGAIN SINCE #7682. #7019/#7024 made it reach # the path by the sound route -- defer the alloc-point trigger to a precise-root # safepoint and run the copying minor there -- #7161 turned that route off # pending #7154, and #7682 turned it back on once #7154 closed and the poll # became allocation-gated. So the WHERE distinction still stands and still # matters (a safepoint has an unwound JS stack and roots precise by # construction; %E% forces relocation at the register-imprecise allocation # point, which is the only place an unrooted runtime-side local is exposed) -- # but `default` now carries the safepoint route itself, alongside # `safepoint_minor`, and its known-inert registration is deleted. # # #7682 is also why %E% is now a strictly-measurement configuration in a # stronger sense than before: `PERRY_CONSERVATIVE_STACK_SCAN=off` is what lets # it relocate at the alloc point at all, and the shipped default no longer can # -- the guard there is unconditional. An %E% arm therefore exercises a # relocation the default build will not perform, which is the point of it. # # ***AND WHEN THESE ARMS FIRST MOVED, THEY WERE RED.*** The first `--arms all` # run in which anything actually moved failed 14 of the 20 corpus files then in # the corpus: 5 crashes and 9 output mismatches (#6981), plus one intermittent # SIGSEGV that does not even need precise roots (#6982). The discriminator is # NOT relocation -- with the conservative stack scan still on, the same # evacuating cycles passed 19/20 while copying thousands of objects. It is # precise roots: the values only the conservative scan was keeping alive. That # is the finding this gate was built to produce, and the arms stay configured to # keep producing it. Do not quiet them down -- and note that "quiet" now has a # second, cheaper failure mode than lowering a requires=: letting an arm go # inert. The liveness gate exists because that one is invisible on screen. # --------------------------------------------------------------------------- ARMS=( "default||%P%|scavenge|as-shipped GC configuration under allocation pressure. ***LIVE AGAIN AS OF 2026-08-09*** -- its known-inert entry was deleted in test-parity/gc_matrix_inert_arms.txt. #7024 made this a relocating arm (the alloc-point trigger defers to js_gc_loop_safepoint -> gc_safepoint_moving_minor, which runs the copying minor on precise rewritable roots); #7161 then flipped PERRY_GC_MOVING_LOOP_POLLS default-OFF pending #7154, and that one env gates BOTH halves of the route -- perry-codegen's moving_safepoint_polls_enabled decides whether the back-edge polls are emitted at all, and perry-runtime's gc_moving_loop_polls_enabled decides whether the trigger defers to them. A default binary has neither, so the minor runs behind ManualGcScanGuard::force_full_scan and the copying minor is ineligible by construction. requires=scavenge STAYS: it is what the shipped default is FOR, the registry entry names what blocks it, and the liveness gate fails the day it scavenges again so the entry cannot outlive its cause. safepoint_minor carries the relocating claim meanwhile." "safepoint_minor|PERRY_GC_MOVING_LOOP_POLLS=1|%P% PERRY_GC_MOVING_LOOP_POLLS=1|scavenge|THE SOUND RELOCATING ARM, and what keeps the #6993 defect class reachable per-PR while #7161's stopgap holds. Sets the poll flag at BOTH compile and run time (same env on both sides -- keyed into the object cache as env_gc_moving_loop_polls, so a warm cache cannot serve poll-free objects). The copying minor then runs at js_gc_loop_safepoint -> gc_safepoint_moving_minor, where the loop body has completed and every live heap value is a named local on the shadow stack: precise, rewritable roots. No %E%, no force -- this is exactly what default was between #7024 and #7161, and what default becomes again when the stopgap lifts. NOT a replacement for the %E% arms: a back-edge poll only fires while user JS runs, so it cannot expose an unrooted local inside runtime code that never re-enters user JS (#7249). evac_minor and force_verify remain in the PR subset for that." "evac_minor||%P% %E%|move|THE evacuating arm, and the STRONGER acceptance route (#7249): the automatic alloc-point collection as a COPYING minor that relocates survivors at a register-imprecise point, which is where an unrooted runtime-side local is exposed. No stress knob -- this is the collector's own moving path." "force_evac||%P% %E% PERRY_GC_FORCE_EVACUATE=1|move|stress-copy every marked non-pinned nursery object" "verify_evac||%P% PERRY_GC_VERIFY_EVACUATION=1|scavenge|panic if a live slot still points at a forwarded object. requires=scavenge: a verifier that runs over zero relocations verifies nothing. LIVE AGAIN as of 2026-08-09 (41/58 cells in CI run 31240304595); its #7161 known-inert entry is deleted." "force_verify||%P% %E% 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|scavenge|PRECISE ROOTS ONLY -- removes the conservative-stack pinning that the alloc-point fallback otherwise forces (ManualGcScanGuard::force_full_scan). An arm that can observe a missing shadow-slot binding. WAS registered known-inert (#7161), deleted 2026-08-09 after it scavenged on 41/58 cells: the argument was that precise roots beat that guard but the nursery trigger never reaches the direct arm in the first place -- registered_root_scanners_block_budgeted_gc() reduces to 'any copy-only scanner' under gc_incremental_enabled(), a compiled program has none, so the trigger goes to the budgeted stepper, which is non-moving by construction. Adding PERRY_GC_INCREMENTAL=0 is what turns it live, and that arm is evac_minor." "cons_scan_off_force||%P% PERRY_CONSERVATIVE_STACK_SCAN=off PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1|scavenge|precise roots + force/verify evacuation. was registered known-inert (#7161) on the same argument as cons_scan_off; deleted 2026-08-09 when it, too, scavenged. The #6942/#6946 shape it was said to have -- PERRY_GC_FORCE_EVACUATE read on a minor path the arm never reaches -- no longer applies, because the arm reaches it." "loop_polls|PERRY_GC_MOVING_LOOP_POLLS=1|%P% %E% 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% %E% PERRY_GC_FORCE_EVACUATE=1|move|repsel Phase 1 OFF x evacuation" "rep_str_off|PERRY_CANONICAL_STR_LOCALS=0|%P% %E% PERRY_GC_FORCE_EVACUATE=1|move|repsel Phase 3a OFF x evacuation" "rep_str_static_off|PERRY_STATIC_STRING_LOWERING=0|%P% %E% PERRY_GC_FORCE_EVACUATE=1|move|#7128 static-string lowerings OFF x evacuation -- the inline StringRef retag, the proven-heap operand handle and the tag-dispatched .length. Split off PERRY_CANONICAL_STR_LOCALS because they key on a value's static string type, not on a selected Str local; this arm is what keeps the off-state exercised." "rep_ptr_shape_off|PERRY_PTR_SHAPE_LOCALS=0|%P% %E% PERRY_GC_FORCE_EVACUATE=1|move|repsel Phase 3b OFF x evacuation" "rep_ptr_numarray_off|PERRY_PTR_NUMARRAY_LOCALS=0|%P% %E% PERRY_GC_FORCE_EVACUATE=1|move|repsel Phase 4a.3 OFF x evacuation" "rep_spec_abi_off|PERRY_SPECIALIZED_ABI=0|%P% %E% PERRY_GC_FORCE_EVACUATE=1|move|repsel Phase 2 OFF x evacuation" "rep_int_valued_off|PERRY_INT_VALUED_LOCALS=0|%P% %E% 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 -- 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. # # WHAT THIS SUBSET MUST BE ABLE TO DO, AND HOW THAT IS ENFORCED # ------------------------------------------------------------ # It must be able to reproduce the relocating-minor defect class (#6993: #6951, # #6972, #6982, #6991, #6992 -- "a raw reference held across a relocating # collection"). Before #7024 it could not, and the previous revision of this # comment recorded the fix in bold, with the measurement that justified it: # `default` had gone from copy-minor 0/22 to 12/22. # # ***THAT SENTENCE OUTLIVED ITS MEASUREMENT BY FIVE WEEKS (#7255).*** #7161 # flipped PERRY_GC_MOVING_LOOP_POLLS default-OFF pending #7154, which took # `default` -- and `verify_evac`, `cons_scan_off`, `cons_scan_off_force` -- back # to copy-minor 0 across the whole corpus. The comment still said 12/22, and # because an all-UNVER table exits 0, nothing else said anything. Two open crash # reports (#6982, #7018) sat un-judgeable for the duration because both fail # INSIDE a copying minor and the arms meant to run one ran none. # # So the property is no longer asserted in prose here. It is enforced, twice: # # * scripts/gc_matrix_liveness_check.py --check-registry (from `lint`, no # build) fails unless PR_ARMS contains 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. # * the same checker, run against every matrix invocation, fails when any arm # satisfied its own requires= on zero cells. # # WHY THE RELOCATING ARMS ARE THE ONES THEY ARE # --------------------------------------------- # `safepoint_minor` is the SOUND route (precise roots at a loop back-edge) and # is what `default` becomes again when #7161's stopgap lifts. `evac_minor` and # `force_verify` are the STRONGER acceptance route (#7249): a back-edge poll # only fires while user JS runs, so it cannot expose an unrooted local inside # runtime code that never re-enters user JS -- the register-imprecise # allocation point can. Both kinds are in, deliberately; neither substitutes for # the other. # # `default`, `verify_evac` and `cons_scan_off` STAY in the subset while they are # registered known-inert. They still verify byte-exactness against the oracle # under a collecting GC, they cost one run each, and leaving them in is what # makes the registry entry visible on every PR instead of quietly true. PR_ARMS="default,safepoint_minor,evac_minor,verify_evac,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; } CORPUS_TOTAL="${#CORPUS[@]}" if [ "$SHARD_COUNT" -gt 1 ]; then SHARDED=() corpus_i=0 for b in "${CORPUS[@]}"; do if [ $((corpus_i % SHARD_COUNT + 1)) -eq "$SHARD_INDEX" ]; then SHARDED+=("$b") fi corpus_i=$((corpus_i+1)) done CORPUS=(${SHARDED[@]+"${SHARDED[@]}"}) fi [ "${#CORPUS[@]}" -gt 0 ] || { echo "empty corpus in shard $SHARD_INDEX/$SHARD_COUNT" >&2; exit 2; } echo "==> shard $SHARD_INDEX/$SHARD_COUNT: ${#CORPUS[@]}/$CORPUS_TOTAL corpus files (stable manifest round-robin)" progress "selected files=${#CORPUS[@]} corpus_total=$CORPUS_TOTAL" # Fixture settings belong only to the explicit loop-poll witness arm. Validate # before compiling/running anything; no eval, shell quoting, or arbitrary env # keys are accepted. Apply the same settings at compile time so auto-optimize # selects gc-instruments for seeded/protected witnesses (freshness.rs). FIXTURE_ENVS=() for b in "${CORPUS[@]}"; do fixture_env="" case ",$SELECTED," in *,loop_polls,*) fixture_env="$(python3 "$SCRIPT_DIR/gc_matrix_fixture_env.py" "test-files/$b.ts")" || exit 2 ;; esac FIXTURE_ENVS+=("$fixture_env") done # --------------------------------------------------------------------------- # 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")" mkdir -p "$WORK/oracle" "$WORK/bin" "$WORK/out" echo "==> oracle: node $NODE_V x ${#CORPUS[@]} corpus files" oracle_fail=0 oracle_i=0 for b in "${CORPUS[@]}"; do oracle_i=$((oracle_i+1)) echo " oracle [$oracle_i/${#CORPUS[@]}] $b" progress "oracle-start test=$b" 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 progress "oracle-result test=$b result=FAIL" else progress "oracle-result test=$b result=PASS" 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=() GROUP_FIXTURES=() 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' '_')" fixture_group=0 # safepoint_minor has the same base compile env; never share its binaries # with loop_polls when the latter carries fixture-specific instruments. if [ "$id" = loop_polls ]; then slug="${slug}_fixture"; fixture_group=1; fi 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"); GROUP_FIXTURES+=("$fixture_group") 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" progress "compile-start env=_warm test=${CORPUS[0]}" "$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; } progress "compile-result env=_warm test=${CORPUS[0]} result=$([ -x "$WORK/bin/_warm/warm" ] && echo PASS || echo FAIL)" # Seed/protection metadata selects a second auto-optimize runtime feature set. # Warm it serially too, preserving normal shipping archive selection and the # existing parallelism for all corpus compiles that follow. ti=0 while [ "$ti" -lt "${#CORPUS[@]}" ]; do fixture_env="${FIXTURE_ENVS[$ti]}" case "$fixture_env" in *PERRY_GC_SCHEDULE_SEED=*|*PERRY_GC_PROTECT_FROMSPACE=*) progress "compile-start env=_warm_fixture test=${CORPUS[$ti]} compile_env=$fixture_env" # All words have been validated as literal GC KEY=VALUE tokens. # shellcheck disable=SC2086 env $fixture_env "$PERRY_BIN" "test-files/${CORPUS[$ti]}.ts" -o "$WORK/bin/_warm/fixture" \ > "$WORK/bin/_warm/fixture.log" 2>&1 \ || { echo "${RED}fixture warm-up compile failed${NC}" >&2; } progress "compile-result env=_warm_fixture test=${CORPUS[$ti]} result=$([ -x "$WORK/bin/_warm/fixture" ] && echo PASS || echo FAIL)" break ;; esac ti=$((ti+1)) done 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" "$WORK/env/$slug" ti=0 for b in "${CORPUS[@]}"; do effective_cenv="$cenv" if [ "${GROUP_FIXTURES[$gi]}" = 1 ] && [ -n "${FIXTURE_ENVS[$ti]}" ]; then effective_cenv="$effective_cenv ${FIXTURE_ENVS[$ti]}" fi printf '%s\n' "$effective_cenv" > "$WORK/env/$slug/$b" ti=$((ti+1)) done printf '%s\n' "${CORPUS[@]}" | WORK="$WORK" PERRY_BIN="$PERRY_BIN" CENV="$cenv" SLUG="$slug" \ PROGRESS_OUT="$PROGRESS_OUT" SHARD_INDEX="$SHARD_INDEX" SHARD_COUNT="$SHARD_COUNT" \ xargs -P "$JOBS" -I{} sh -c \ 'CENV=$(cat "$WORK/env/$SLUG/$1"); echo " compile env=$SLUG test=$1"; [ -z "$PROGRESS_OUT" ] || printf "%s shard=%s/%s compile-start env=%s test=%s compile_env=%s\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$SHARD_INDEX" "$SHARD_COUNT" "$SLUG" "$1" "$CENV" >> "$PROGRESS_OUT"; if env $CENV "$PERRY_BIN" "test-files/$1.ts" -o "$WORK/bin/$SLUG/$1" > "$WORK/bin/$SLUG/$1.log" 2>&1; then [ -z "$PROGRESS_OUT" ] || printf "%s shard=%s/%s compile-result env=%s test=%s result=PASS\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$SHARD_INDEX" "$SHARD_COUNT" "$SLUG" "$1" >> "$PROGRESS_OUT"; else echo "COMPILEFAIL $SLUG $1"; [ -z "$PROGRESS_OUT" ] || printf "%s shard=%s/%s compile-result env=%s test=%s result=FAIL\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$SHARD_INDEX" "$SHARD_COUNT" "$SLUG" "$1" >> "$PROGRESS_OUT"; fi' _ {} 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" # The evacuating base -- see the %E% note above the arm table. Both halves are # required and neither is sufficient alone. EVAC_ENV="PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off" 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=(); CYC=(); EVA=(); SCA=(); REC=(); CELL_CENVS=(); CELL_RENVS=() 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]}" arm_renv="$(printf '%s' "${ARM_RENVS[$ai]}" | sed -e "s/%P%/$PRESSURE_ENV/" -e "s/%E%/$EVAC_ENV/")" [ "$arm_renv" = "-" ] && arm_renv="" echo "==> arm $id" ti=0 while [ "$ti" -lt "${#CORPUS[@]}" ]; do b="${CORPUS[$ti]}"; bin="$WORK/bin/$slug/$b"; idx=$((ti*NARMS+ai)) renv="$arm_renv" if [ "$id" = loop_polls ] && [ -n "${FIXTURE_ENVS[$ti]}" ]; then renv="$renv ${FIXTURE_ENVS[$ti]}" fi CELL_CENVS[idx]="$(cat "$WORK/env/$slug/$b")" CELL_RENVS[idx]="$renv PERRY_GC_TRACE=1 PERRY_GC_DIAG=1" echo " run [$((ti+1))/${#CORPUS[@]}] test=$b env=$slug arm=$id" progress "cell-start test=$b env=$slug arm=$id compile_env=${CELL_CENVS[$idx]} run_env=${CELL_RENVS[$idx]}" cycles=0; evacuated=0; scavenged=0; reclaimed=0 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 ' ') # #7025: these are TWO different collectors and must be reported # separately. Summing them lets a `requires=move` cell go green on # relocation the arm was not testing: # evacuated= : `moved_objects=` from the C4b evacuation policy # inside the mark-sweep collector -- the pre-existing # non-moving-minor path that relocates tenured objects # during a full cycle. # scavenged= : copied+promoted objects from non-in-place # `[gc-copy-minor] ran` records. Both destinations # relocate (#7657); whole-block promotion does not. # This is the copying young-gen minor -- the path # #7019 made default-on, and the one the evacuating # arms exist to exercise. # A cell showing `evacuated=N scavenged=0` did relocate something, # but it did NOT run a copying minor, and the distinction is exactly # what tells you whether the arm bit. evacuated=$(grep -oE 'moved_objects=[0-9]+' "$WORK/out/$b.$id.err" 2>/dev/null \ | grep -oE '[0-9]+$' | awk '{s+=$1} END {print s+0}') scavenged=$(sum_copy_minor_moved "$WORK/out/$b.$id.err") # #7017: `cycles>0` cannot tell a mid-program collection from a # teardown one. On a small corpus file the shipped configuration # completes exactly one cycle, at the event-loop boundary AFTER the # program's last output, and it reclaims NOTHING -- everything # allocated after the cycle armed was born black: # # 45:8 <- last program output # [gc-step] pre_in_use=479816 post_in_use=483472 sweep_freed=0 ... # [gc] cycle # # Both shapes scored PASS. A cycle that traversed only the retained # graph at rest cannot have observed the test's live locals, which # is the property the matrix was built to assert -- the #6942 / # #6946 / #6950 lesson, one level up. # # So `collect` now requires a PRODUCTIVE cycle. Like the gc-ratchet # rule widened in #7657 (`copied + promoted > 0`, because pinning it # to one counter would have pinned it permanently false), this names # a DESTINATION rather than a counter, and sums every reclamation # counter the run emits: # # sweep_freed / block_reclaim [gc-step] (PERRY_GC_DIAG) # eden_dead_bytes [gc-tenuring] (PERRY_GC_DIAG) # freed_bytes [gc] blocks: (PERRY_GC_DIAG) # "freed_bytes"/"dead_bytes" JSON trace (diagnostics build) # # BOTH spellings are read, `k=N` and `"k": N`. Only the JSON form # carries the malloc sweep's bytes, and reading only `k=N` scored # `test_gap_gc_symbol_local_rooting` -- 86 malloc-count-triggered # cycles that free 31.9 MB of symbols -- as reclaiming zero. # # This is a conservative proxy and says so: a mid-program cycle over # a heap that is entirely live reclaims nothing and reads UNVER. It # can under-claim, never over-claim, which is the safe direction for # a liveness gate. Do not weaken it to `cycles>0` to make a cell # green -- that is the state #7017 was filed about. reclaimed=$(grep -ohE '(sweep_freed|block_reclaim|eden_dead_bytes|freed_bytes|dead_bytes)("?[:=] ?)[0-9]+' \ "$WORK/out/$b.$id.err" 2>/dev/null \ | grep -oE '[0-9]+$' | awk '{s+=$1} END {print s+0}') : "${cycles:=0}"; : "${evacuated:=0}"; : "${scavenged:=0}"; : "${reclaimed:=0}" moved=$((evacuated + scavenged)) ev="cycles=$cycles evacuated=$evacuated scavenged=$scavenged reclaimed=$reclaimed" 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 # #7024/#7025: the copying minor's OWN counter, never the # sum. An arm that certifies the relocating young-gen minor # must not go green on a C4b mark-sweep evacuation. scavenge) [ "$scavenged" -gt 0 ] && result="PASS" || result="UNVER" ;; move) [ "$moved" -gt 0 ] && result="PASS" || result="UNVER" ;; # #7017: a cycle, AND that cycle reclaimed something. See # the `reclaimed=` derivation above for why the counter is # not `cycles`. collect) [ "$cycles" -gt 0 ] && [ "$reclaimed" -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" # The liveness gate reads these as NUMBERS, not by re-parsing `$ev`: # a triage reason is free text and has already contained `=`. CYC[$idx]="$cycles"; EVA[$idx]="$evacuated"; SCA[$idx]="$scavenged" REC[$idx]="$reclaimed" 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 progress "cell-result test=$b env=$slug arm=$id result=$result $ev" 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; lives=0; liver=0; ti=0 while [ "$ti" -lt "${#CORPUS[@]}" ]; do idx=$((ti*NARMS+ai)) # Read the NUMBERS, not the free-text evidence: a triage reason has # already contained `=` (the reason CYC/EVA/SCA/REC exist). cy="${CYC[$idx]:-0}"; evac="${EVA[$idx]:-0}"; scav="${SCA[$idx]:-0}" recl="${REC[$idx]:-0}" tot=$((tot+1)) [ "${cy:-0}" -gt 0 ] 2>/dev/null && livec=$((livec+1)) [ $(( ${evac:-0} + ${scav:-0} )) -gt 0 ] 2>/dev/null && livem=$((livem+1)) [ "${scav:-0}" -gt 0 ] 2>/dev/null && lives=$((lives+1)) [ "${recl:-0}" -gt 0 ] 2>/dev/null && liver=$((liver+1)) ti=$((ti+1)) done # #7025: `copy-minor` is reported separately from `moved-objects` because the # latter counts BOTH collectors. An evacuating arm showing a healthy # moved-objects count but `copy-minor 0/N` did not run the path it exists to # test -- that is the shape #7024 describes, and summing the two hid it. # #7017: `reclaimed` is reported next to `collected` because they differ, # and the difference is the whole finding. A cell can collect and reclaim # nothing -- that is a cycle at the event-loop boundary after the program's # last output, which cannot have observed the test's live locals. `collect` # arms are scored on `reclaimed`; `collected` is kept beside it so the gap # stays visible instead of being folded away. printf ' %-24s requires=%-8s collected %2d/%2d reclaimed %2d/%2d moved-objects %2d/%2d copy-minor %2d/%2d\n' \ "${ARM_IDS[$ai]}" "${ARM_LIVES[$ai]}" "$livec" "$tot" "$liver" "$tot" \ "$livem" "$tot" "$lives" "$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." # The report is written UNCONDITIONALLY -- the liveness gate below consumes it, # so `--json` only decides whether a copy is kept where the caller asked for it. # A gate that runs only when someone remembered a flag is not a gate. JSON_REPORT="${JSON_OUT:-$WORK/matrix.json}" { printf '{"node":"%s","pressure_mb":"%s","complete":true,"shard":{"index":%d,"count":%d,"corpus_total":%d,"corpus_selected":%d},"arms":[' \ "$NODE_V" "$PRESSURE_MB" "$SHARD_INDEX" "$SHARD_COUNT" "$CORPUS_TOTAL" "${#CORPUS[@]}" 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 idx=$((ti*NARMS+ai)) # Evidence is free text (triage reasons quote code); strip the two # characters that would make this invalid JSON, so a malformed # report can never be the reason the gate fails. ev_json="$(printf '%s' "${EVID[$idx]:-}" | tr '"\\' "''")" printf '{"test":"%s","arm":"%s","result":"%s","cycles":%d,"evacuated":%d,"scavenged":%d,"reclaimed":%d,"evidence":"%s","compile_env":"%s","run_env":"%s"}' \ "${CORPUS[$ti]}" "${ARM_IDS[$ai]}" "${CELLS[$idx]:-?}" \ "${CYC[$idx]:-0}" "${EVA[$idx]:-0}" "${SCA[$idx]:-0}" "${REC[$idx]:-0}" "$ev_json" \ "${CELL_CENVS[$idx]}" "${CELL_RENVS[$idx]}" 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_REPORT" [ -n "$JSON_OUT" ] && echo "json: $JSON_OUT" progress "report-complete cells=$n_cells pass=$n_pass unver=$n_unver xfail=$n_xfail fail=$n_fail path=$JSON_REPORT" # --------------------------------------------------------------------------- # LIVENESS GATE (#7255). Half of this script's exit status. # # `n_fail` alone cannot express "the arm never bit": an all-UNVER table is # yellow on screen and exit 0 in CI, which is how four of the six PR-gating arms # sat at copy-minor 0/49 for five weeks while this file's header advertised # 12/22. The checker is a separate, self-testable program (it runs `--self-test` # and `--check-registry` from `lint`, where no build is needed) so the rule that # decides red-vs-green is not 30 lines of untested bash. # --------------------------------------------------------------------------- echo liveness_rc=0 if [ "$DEFER_LIVENESS" = 1 ]; then echo "liveness gate: deferred to gc_repsel_matrix_merge.py over all $SHARD_COUNT shard report(s)" progress "liveness-deferred" elif command -v python3 > /dev/null 2>&1; then liveness_args="" [ "$LIVENESS_REPORT_ONLY" = 1 ] && liveness_args="--report-only" # shellcheck disable=SC2086 python3 "$SCRIPT_DIR/gc_matrix_liveness_check.py" $liveness_args "$JSON_REPORT" || liveness_rc=1 else echo "${RED}LIVENESS GATE SKIPPED${NC}: python3 not on PATH." >&2 echo " This is a gate, not a report. Refusing to claim a green run without it." >&2 liveness_rc=1 fi [ "$n_fail" = 0 ] || exit 1 [ "$liveness_rc" = 0 ] || exit 1 exit 0