From 749eefe431e5ba149a5bbed507036e66dd528f90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 13 Aug 2026 23:29:05 +0200 Subject: [PATCH 1/4] fix(gc-gates): parse current copy-minor relocation evidence --- .github/workflows/test.yml | 1 + scripts/gc_parse_churn_layout_check.py | 37 +++++++++++----- scripts/gc_repsel_matrix.sh | 59 ++++++++++++++++++++++---- 3 files changed, 78 insertions(+), 19 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dd47702250..21199951f3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -431,6 +431,7 @@ jobs: - name: GC matrix liveness gate if: ${{ !cancelled() }} run: | + ./scripts/gc_repsel_matrix.sh --self-test-liveness-parser python3 scripts/gc_matrix_liveness_check.py --self-test python3 scripts/gc_matrix_liveness_check.py --check-registry diff --git a/scripts/gc_parse_churn_layout_check.py b/scripts/gc_parse_churn_layout_check.py index 003c49eb86..2aa3c405b0 100755 --- a/scripts/gc_parse_churn_layout_check.py +++ b/scripts/gc_parse_churn_layout_check.py @@ -22,8 +22,8 @@ line, which on the gate's own invocation manifests as an aborting nonzero exit -- see `scripts/gc_parse_churn_layout_gate.sh`). 2. LIVENESS -- at least one copying minor actually relocated objects - (`copied_objects` summed across every `[gc-copy-minor] ran ...` line is - nonzero). A run that never triggers the moving collector cannot fail + (`copied_objects + promoted_objects` across non-in-place + `[gc-copy-minor] ran ...` lines is nonzero). A run that never triggers the moving collector cannot fail however broken the layout state is. 3. EAGERNESS -- the from-space scan's own `objects=` census reached at least `--records` objects. `js_json_parse`'s Auto mode routes a @@ -58,7 +58,8 @@ SUCCESS_SENTINEL = "PARSE_CHURN_LAYOUT_GATE_OK" MISMATCH_RE = re.compile(r"^MISMATCHES (\d+)$", re.MULTILINE) -COPIED_OBJECTS_RE = re.compile(r"\[gc-copy-minor\] ran copied_objects=(\d+)") +COPY_MINOR_LINE_RE = re.compile(r"^\[gc-copy-minor\] ran\s+(.*)$", re.MULTILINE) +FIELD_RE = re.compile(r"\b([a-z_]+)=([^\s]+)") SCAN_LINE_RE = re.compile(r"^\[gc-fromspace-scan (\S+)\] objects=(\d+)", re.MULTILINE) OFFENDER_PHASES = {"OFFENDERS", "abort"} @@ -109,12 +110,20 @@ def evaluate(exit_code: int, stdout: str, stderr: str, records: int) -> Verdict: f"real defect the from-space scan can miss." ) - copied = [int(n) for n in COPIED_OBJECTS_RE.findall(stderr)] - total_copied = sum(copied) - if total_copied == 0: + moved = 0 + for raw_fields in COPY_MINOR_LINE_RE.findall(stderr): + fields = dict(FIELD_RE.findall(raw_fields)) + # #7744 whole-block promotion deliberately leaves every object at the + # same address. Ordinary promotion is an object-by-object copy and is + # relocation evidence just like `copied_objects` (#7657). + if fields.get("in_place") == "true": + continue + moved += int(fields.get("copied_objects", "0")) + moved += int(fields.get("promoted_objects", "0")) + if moved == 0: v.fail( - "no copying minor relocated anything (sum of every " - "'[gc-copy-minor] ran copied_objects=' line in stderr is 0). " + "no copying minor relocated anything (copied+promoted objects " + "across every non-in-place '[gc-copy-minor] ran' line is 0). " "The subject of this gate -- the moving collector -- never ran, " "so a clean scan proves nothing (CLAUDE.md's 'four ways a gate " "can be unable to fail', #4). Check PERRY_GC_MOVING_LOOP_POLLS=1 " @@ -178,9 +187,9 @@ def _self_test() -> int: ok_stdout = "BLOB_BYTES 245781\nPARSED_LENGTH 4000\nCHURN_TOUCH 240000\nMISMATCHES 0\n" + SUCCESS_SENTINEL + "\n" ok_stderr = ( - "[gc-copy-minor] ran copied_objects=537 copied_bytes=46112\n" + "[gc-copy-minor] ran in_place=false survival_permille=350 copied_objects=537 copied_bytes=46112 promoted_objects=0 promoted_bytes=0\n" "[gc-fromspace-scan clean] objects=6041 words=86354 fwd_owners_skipped=0 missing_rewrites=0 dangling=0 owners=0\n" - "[gc-copy-minor] ran copied_objects=612 copied_bytes=51200\n" + "[gc-copy-minor] ran in_place=false survival_permille=970 copied_objects=0 copied_bytes=0 promoted_objects=612 promoted_bytes=51200\n" "[gc-fromspace-scan clean] objects=9210 words=120000 fwd_owners_skipped=0 missing_rewrites=0 dangling=0 owners=0\n" ) cases.append(("clean run passes", 0, ok_stdout, ok_stderr, 4000, True)) @@ -204,13 +213,19 @@ def _self_test() -> int: ) cases.append(("no copying minor ever ran -> FAIL (liveness)", 0, ok_stdout, no_copy_stderr, 4000, False)) + in_place_only_stderr = ( + "[gc-copy-minor] ran in_place=true copied_objects=0 promoted_objects=4000\n" + "[gc-fromspace-scan clean] objects=6041 words=86354 fwd_owners_skipped=0 missing_rewrites=0 dangling=0 owners=0\n" + ) + cases.append(("whole-block promotion moved nothing -> FAIL (liveness)", 0, ok_stdout, in_place_only_stderr, 4000, False)) + no_scan_stderr = "\n".join( line for line in ok_stderr.splitlines() if "gc-fromspace-scan" not in line ) cases.append(("scan never ran at all -> FAIL (ABORT-alone-inert class)", 0, ok_stdout, no_scan_stderr, 4000, False)) lazy_stderr = ( - "[gc-copy-minor] ran copied_objects=4 copied_bytes=512\n" + "[gc-copy-minor] ran in_place=false copied_objects=4 copied_bytes=512 promoted_objects=0 promoted_bytes=0\n" "[gc-fromspace-scan clean] objects=9 words=88 fwd_owners_skipped=0 missing_rewrites=0 dangling=0 owners=0\n" ) cases.append(("tape stayed lazy: scan sees ~9 objects not 4000 -> FAIL (eagerness/vacuity)", 0, ok_stdout, lazy_stderr, 4000, False)) diff --git a/scripts/gc_repsel_matrix.sh b/scripts/gc_repsel_matrix.sh index eb4fc9559c..c560e4e5e1 100755 --- a/scripts/gc_repsel_matrix.sh +++ b/scripts/gc_repsel_matrix.sh @@ -71,8 +71,32 @@ # [--pressure ] [--jobs N] [--no-build] # [--profile ] [--json ] # [--list-arms] [--liveness-report-only] +# [--self-test-liveness-parser] 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" @@ -87,6 +111,7 @@ DO_BUILD=1 JSON_OUT="" PROFILE="release" LIVENESS_REPORT_ONLY=0 +SELF_TEST_LIVENESS_PARSER=0 while [ $# -gt 0 ]; do case "$1" in @@ -98,6 +123,7 @@ while [ $# -gt 0 ]; do --no-build) DO_BUILD=0; shift ;; --json) JSON_OUT="$2"; shift 2 ;; --list-arms) ARMS_SEL="__list__"; shift ;; + --self-test-liveness-parser) SELF_TEST_LIVENESS_PARSER=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. @@ -107,6 +133,22 @@ while [ $# -gt 0 ]; do esac done +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=""; } @@ -114,8 +156,8 @@ RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[0;33m'; NC=$'\033[0m' # Arms. Format: id | compile-env | run-env | liveness-requirement | note # # liveness requirement: -# scavenge the arm claims the COPYING MINOR runs -> require -# `[gc-copy-minor] ran copied_objects=` > 0. Strictly stronger than +# 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 @@ -458,17 +500,18 @@ while [ "$ai" -lt "$NARMS" ]; do # inside the mark-sweep collector -- the pre-existing # non-moving-minor path that relocates tenured objects # during a full cycle. - # scavenged= : `[gc-copy-minor] ran copied_objects=` from the - # copying young-gen minor -- the path #7019 made - # default-on, and the one the evacuating arms exist - # to exercise. + # 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=$(grep -oE '\[gc-copy-minor\] ran copied_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 From 79819bc573c5f9694989f6fec45d9ad22d4c2256 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 14 Aug 2026 00:11:23 +0200 Subject: [PATCH 2/4] docs(changelog): record copy-minor liveness fix --- changelog.d/8056-copy-minor-liveness-evidence.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/8056-copy-minor-liveness-evidence.md diff --git a/changelog.d/8056-copy-minor-liveness-evidence.md b/changelog.d/8056-copy-minor-liveness-evidence.md new file mode 100644 index 0000000000..18d97bbf69 --- /dev/null +++ b/changelog.d/8056-copy-minor-liveness-evidence.md @@ -0,0 +1,3 @@ +Fixed moving-GC gate liveness accounting for the current copying-minor +diagnostic format. The gates now count ordinary survivor copies and promotions +as relocation while continuing to reject non-moving whole-block promotions. From 5ca6418a26e518e5d116b25c6a8133ed57318889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 14 Aug 2026 01:25:20 +0200 Subject: [PATCH 3/4] test(gc): parse current copying-minor records --- .../tests/gc_copy_minor_under_heap_limit.rs | 66 +++++++++++++++---- 1 file changed, 54 insertions(+), 12 deletions(-) diff --git a/crates/perry/tests/gc_copy_minor_under_heap_limit.rs b/crates/perry/tests/gc_copy_minor_under_heap_limit.rs index 0d97549233..653c3ec110 100644 --- a/crates/perry/tests/gc_copy_minor_under_heap_limit.rs +++ b/crates/perry/tests/gc_copy_minor_under_heap_limit.rs @@ -26,8 +26,9 @@ //! *pre*-#7019 non-moving collector. //! //! What this test pins is the observable that distinguishes those two worlds: -//! `copied_objects > 0`. Asserting "a GC cycle happened" does not — the broken -//! build collected too. Exit 0 does not either; the broken build exited 0. +//! a non-in-place copying minor relocated at least one object. Asserting "a GC +//! cycle happened" does not — the broken build collected too. Exit 0 does not +//! either; the broken build exited 0. use std::path::PathBuf; use std::process::Command; @@ -36,24 +37,65 @@ fn perry_bin() -> PathBuf { PathBuf::from(env!("CARGO_BIN_EXE_perry")) } -/// Sum of every `[gc-copy-minor] ran copied_objects=N` in the collector's -/// `PERRY_GC_DIAG` output. This is the copying minor's OWN counter: the +/// Sum objects relocated by `[gc-copy-minor] ran ...` records in the +/// collector's `PERRY_GC_DIAG` output. This uses the copying minor's OWN +/// `copied_objects` and `promoted_objects` counters: the /// `moved_objects=` counter that also appears there belongs to the C4b /// evacuation policy inside the mark-sweep collector, a different collector /// entirely, and summing the two is how a green result was once reported for a /// run that scavenged nothing (#7025). -fn copied_objects(stderr: &str) -> u64 { +/// +/// The diagnostic is a key/value record, not a positional format. Ordinary +/// object-by-object promotion relocates survivors just like a nursery copy; +/// whole-block `in_place=true` promotion deliberately does not move them. +fn copy_minor_relocated_objects(stderr: &str) -> u64 { stderr .lines() - .filter_map(|line| line.strip_prefix("[gc-copy-minor] ran copied_objects=")) - .filter_map(|rest| { - rest.split_whitespace() - .next() - .and_then(|n| n.parse::().ok()) + .filter_map(|line| line.strip_prefix("[gc-copy-minor] ran ")) + .map(|fields| { + let mut in_place = false; + let mut copied = 0; + let mut promoted = 0; + + for field in fields.split_whitespace() { + let Some((key, value)) = field.split_once('=') else { + continue; + }; + match key { + "in_place" => in_place = value == "true", + "copied_objects" => copied = value.parse::().unwrap_or(0), + "promoted_objects" => promoted = value.parse::().unwrap_or(0), + _ => {} + } + } + + if in_place { + 0 + } else { + copied + promoted + } }) .sum() } +#[test] +fn copy_minor_parser_reads_current_field_order() { + let stderr = "[gc-copy-minor] ran in_place=false survival_permille=350 copied_objects=17 copied_bytes=272 promoted_objects=0 promoted_bytes=0\n"; + assert_eq!(copy_minor_relocated_objects(stderr), 17); +} + +#[test] +fn copy_minor_parser_counts_object_by_object_promotions() { + let stderr = "[gc-copy-minor] ran in_place=false copied_objects=0 promoted_objects=23 promoted_bytes=368\n"; + assert_eq!(copy_minor_relocated_objects(stderr), 23); +} + +#[test] +fn copy_minor_parser_rejects_in_place_promotion() { + let stderr = "[gc-copy-minor] ran in_place=true copied_objects=0 promoted_objects=999 promoted_bytes=15984\n"; + assert_eq!(copy_minor_relocated_objects(stderr), 0); +} + #[test] fn copying_minor_runs_under_an_explicit_heap_limit() { let dir = tempfile::tempdir().expect("tempdir"); @@ -117,9 +159,9 @@ console.log("checksum:", checksum); "the workload must still produce its result under a heap limit" ); - let copied = copied_objects(&stderr); + let relocated = copy_minor_relocated_objects(&stderr); assert!( - copied > 0, + relocated > 0, "no copying minor ran under PERRY_GC_HEAP_LIMIT=8 (#7024). The \ allocation-point deferral to the precise-root safepoint is \ unreachable again — check that the moving-defer allowance is still a \ From c37c28c786156621ab222c7da8c04a5b8c2477a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 14 Aug 2026 02:01:33 +0200 Subject: [PATCH 4/4] test(gc): isolate heap-limit collector arm --- .../tests/gc_copy_minor_under_heap_limit.rs | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/crates/perry/tests/gc_copy_minor_under_heap_limit.rs b/crates/perry/tests/gc_copy_minor_under_heap_limit.rs index 653c3ec110..7ea011cda5 100644 --- a/crates/perry/tests/gc_copy_minor_under_heap_limit.rs +++ b/crates/perry/tests/gc_copy_minor_under_heap_limit.rs @@ -33,6 +33,29 @@ use std::path::PathBuf; use std::process::Command; +// `Command` inherits the test runner's environment. Several of these knobs +// affect generated code as well as runtime collector policy, so clear the +// established override family from BOTH subprocesses before applying this +// test's intended heap-limit arm. +const GC_ENV_OVERRIDES: &[&str] = &[ + "PERRY_GEN_GC", + "PERRY_GC_SCAVENGE", + "PERRY_GC_SCAVENGE_NURSERY_MB", + "PERRY_GC_MOVING_SAFEPOINT", + "PERRY_GC_MOVING_LOOP_POLLS", + "PERRY_GC_FORCE_EVACUATE", + "PERRY_CONSERVATIVE_STACK_SCAN", + "PERRY_WRITE_BARRIERS", + "PERRY_GC_INCREMENTAL", + "PERRY_GC_HEAP_LIMIT", +]; + +fn remove_gc_env_overrides(command: &mut Command) { + for key in GC_ENV_OVERRIDES { + command.env_remove(key); + } +} + fn perry_bin() -> PathBuf { PathBuf::from(env!("CARGO_BIN_EXE_perry")) } @@ -122,15 +145,16 @@ console.log("checksum:", checksum); ) .expect("write entry"); - let compile = Command::new(perry_bin()) + let mut compile_command = Command::new(perry_bin()); + compile_command .current_dir(dir.path()) .arg("compile") .arg(&entry) .arg("-o") .arg(&output) - .arg("--no-cache") - .output() - .expect("run perry compile"); + .arg("--no-cache"); + remove_gc_env_overrides(&mut compile_command); + let compile = compile_command.output().expect("run perry compile"); assert!( compile.status.success(), "perry compile failed\nstdout:\n{}\nstderr:\n{}", @@ -138,8 +162,10 @@ console.log("checksum:", checksum); String::from_utf8_lossy(&compile.stderr) ); - let run = Command::new(&output) - .current_dir(dir.path()) + let mut run_command = Command::new(&output); + run_command.current_dir(dir.path()); + remove_gc_env_overrides(&mut run_command); + let run = run_command // 8 MB is the pressure setting `scripts/gc_repsel_matrix.sh` uses, and // the one on which the copying minor was measured to never run. .env("PERRY_GC_HEAP_LIMIT", "8")