Skip to content

fix(codegen): guard declared string self-append operator (#7841) #1741

fix(codegen): guard declared string self-append operator (#7841)

fix(codegen): guard declared string self-append operator (#7841) #1741

name: GC Root Dominance
# Static gate for the invariant a GC-managed value's root store must DOMINATE
# every subsequent site that can trigger a collection (#7154).
#
# Two bugs of this class shipped before there was an instrument for it. #7184:
# the root store was emitted but its shadow-slot index fell outside the pushed
# frame, so `js_shadow_slot_bind` bounds-checked it into a silent no-op. #7192:
# the store was emitted in-frame but AFTER a call that allocates. Both present
# identically — a *rooted* slot holding a dangling pointer, surfacing cycles
# later as "TypeError: value is not a function" — and neither is visible to any
# runtime GC probe, because at the moment of the collection there is nothing
# for the collector to find. A static pass over the emitted IR is the only
# instrument that sees them before they crash, which is why this is a gate and
# not a benchmark.
#
# THIS JOB IS DESIGNED TO BE ABLE TO FAIL, and is checked against all four ways
# a gate can be unable to (CLAUDE.md):
#
# 1. no `continue-on-error`, no `|| true`, no pipe between the checker and
# the shell's exit status;
# 2. NOT yet in branch protection's required contexts. This was deliberate at
# first — a new gate has never been green, so promoting it immediately
# blocks every open PR — but the second step was never taken, and in the
# meantime the job WAS red on `main` and blocked nothing: the five
# ClassExprFresh hits now tracked in #7211 have been reported on every run
# since #7198 and went unread. That is hazard 2 doing exactly what the
# corollary in CLAUDE.md warns about.
#
# **ACTION REQUIRED, and it is not something this workflow can do to
# itself**: a repo admin must add `gc-root-dominance` to branch
# protection's required contexts. Until that happens this file is
# documentation, not a gate.
#
# Both conditions #7198 named are met as of #7236: the dominance check is
# green with an EMPTY allowlist, and `--unrooted-allocas --moving-only` --
# which was 98 before #7235 and 2 after -- now reads 0 and is a step
# below. Promote after this job's first green run on `main` WITH that
# step, not before: a gate that has never been green in its current shape
# blocks every open PR the day it becomes required, which is the corollary
# that produced this whole paragraph.
# See docs/src/internals/gc-rooting-invariant.md, "Promoting this gate".
# 3. `concurrency` cancels pull-request runs only, never `main` runs;
# 4. the subject is ASSERTED live, not assumed, at three levels.
# `--self-test` proves the checker still reports a planted violation in
# hand-written IR and still clears the control. `--seeded-violations`
# goes further and plants collection points into the REAL corpus,
# requiring every one to be reported -- that is what catches the case
# where perry's emitted IR drifts to a shape the parser can no longer
# read, which frozen fixtures cannot detect. And `--min-files` /
# `--min-binds` / `--min-funcs` refuse a clean verdict over a corpus with
# too few modules, root stores or functions to have exercised anything.
# An empty `.perry-trace/llvm` is a routine outcome of a failed compile,
# so "0 violations" over 0 files must be an error rather than a pass.
#
# Known-remaining violations live in scripts/gc_root_dominance_allowlist.json,
# one named entry each with an issue and a written justification -- NOT a
# numeric threshold, which cannot tell a new violation from an old one. An
# entry that matches nothing fails the build, so a fixed bug's entry must be
# deleted rather than left to widen coverage later.
on:
pull_request:
# POST-MERGE ARM: staggered six-hourly sweep of `main`, NOT one run per merge.
# `push: branches: [main]` starved this gate and nine others for two days
# (#7856): ~29 jobs enqueued per merge at 58 merges/day against a ~9-job
# concurrency ceiling. The pull-request arm is unchanged -- every PR is still
# measured. Note this workflow runs `macos-14`, not `ubuntu-latest`; #7856's
# claim that it was unaffected was reading its PR arm, which drains because PR
# runs supersede each other, while its `main` arm queued like all the rest.
# ***DO NOT RESTORE `push: branches: [main]`.***
# Rationale, measurement and cost: docs/src/testing/ci-gate-scheduling.md
schedule:
- cron: "17 */6 * * *"
push:
tags: ["v*"]
workflow_dispatch:
permissions:
contents: read
concurrency:
# One group per main COMMIT, cancelling PR runs only. A shared group starves
# `main` on a deep runner queue, and a gate that is always cancelled never
# fails. `cancel-in-progress: false` is not enough on its own: GitHub allows
# at most one PENDING run per group and cancels the previously pending one
# when a new run enters, regardless of that setting (#7205 — measured on
# gc-ratchet, whose shape this file copied). Keying push runs on the SHA gives
# every merged commit a group of its own, so no two main runs can contend.
# Same reasoning as gc-ratchet.yml, which carries the full writeup.
group: gc-root-dominance-${{ github.event_name }}-${{ github.event_name == 'push' && github.sha || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
CARGO_TERM_COLOR: always
MACOSX_DEPLOYMENT_TARGET: "13.0"
jobs:
gc-root-dominance:
runs-on: macos-14
timeout-minutes: 90
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
# Fast structural failure first: prove the checker can still fail before
# spending a compiler build on it. This is the arm that would have caught
# `PERRY_GC_FORCE_EVACUATE` being inert for every test that "exercised"
# it (#6942/#6946).
- name: Checker self-test (can this gate still fail?)
run: python3 scripts/gc_root_dominance_check.py --self-test
# The checker only reports a stale register when it recognises what
# MATERIALIZED the value, and that recognition is one regex. An
# alternative in it that matches no real symbol reads as coverage and is
# not -- the gate-can't-fail pattern in regex form. It has shipped nine
# times across two rounds: four (`regexp_alloc\w*`, `promise_alloc\w*`,
# `bigint_alloc\w*`, `typed_array_alloc\w*`) found only because
# `js_regexp_new` cost #7154 a whole investigation round, and five more
# introduced by the change that removed those four. A prose audit does not
# survive its own next edit, so it is a gate now: every alternative must
# match at least one `extern "C" fn js_*` the runtime actually exports.
#
# Static and instant — no toolchain, no corpus — so it runs before the
# build with the self-test.
- name: ALLOC_RE alternatives must match real runtime symbols
run: python3 scripts/gc_root_dominance_check.py --audit-alloc-re
# The same hazard on the other axis, and a sharper one. `ALLOC_RE`
# decides whether a register has a heap-value source; `POLL_CAPABLE_RUNTIME`
# decides whether the window around it is MOVING -- which is the arm the
# two `--moving-only` steps below actually gate on. An entry naming a
# symbol that does not exist can never classify a window, so it reads as
# coverage of an operation the gate is in fact blind to, and unlike a dead
# regex alternative it survives review because the misspelling is a
# PLAUSIBLE name for a real operation.
#
# Ten of twenty-eight entries were phantoms when this step was added,
# including four separate spellings of "call a JS closure" (`js_call_closure`,
# `js_invoke_closure`, `js_function_call`, `js_apply_function`) where the
# real symbols are `js_closure_callN`. The consequence was measured at
# zod's `clone`: the from-space protector faulted deterministically on a
# window that `--stale-registers` classified `MOVING: no`, so the
# `--moving-only` arm could not see it.
#
# Static and instant, like the audit above, so it runs before the build.
- name: POLL_CAPABLE_RUNTIME entries must name real runtime symbols
run: python3 scripts/gc_root_dominance_check.py --audit-poll-capable
# ★ The THIRD audit, and the direction the other two structurally cannot
# look (#7616). Both of those hunt a NAME WITH NO REFERENT: a dead
# ALLOC_RE alternative, a phantom POLL_CAPABLE_RUNTIME entry. Nothing
# hunted a REFERENT WITH NO NAME -- a real allocating symbol that is
# missing from POLL_CAPABLE_RUNTIME -- and a missing entry suppresses
# findings exactly as silently as a phantom one.
#
# #7453 is the worked example and it cost a shipped use-after-free.
# `new URL(input, base)` held a raw `*mut StringHeader` across the
# lowering of `base`; the fix added `url_coerce_string` to ALLOC_RE,
# said in its own comment "that gap is why the checker did not flag
# #7453", and stopped one list short. Re-planting that exact code and
# running EVERY mode this workflow has (#7616's measurement):
#
# --moving-only (dominance) 0 clean -> 0 sabotaged
# --unrooted-allocas --moving-only 0 -> 0
# --stale-registers --moving-only 2 -> 2
# --statepoints --moving-only 2 -> 2 <- the mode
# that reads the lowering that SHIPS, added in #7663
# --stale-registers (unfiltered) 24 -> 35
# --statepoints (unfiltered) 15 -> 21
#
# Every GATED arm blind, both UNFILTERED arms not. The single missing
# list entry was all of it: adding `js_url_coerce_string` takes the
# sabotaged arms to 13 and 8 and leaves the clean arms at 2 and 2.
#
# Static and instant, like the two above, so it runs before the build.
- name: ALLOC_RE symbols that reach a poll-capable one must be listed
run: python3 scripts/gc_root_dominance_check.py --audit-poll-reach
# `--unrooted-allocas` exempts two heap-value sources whose objects can
# neither move nor be reclaimed (#7210): the old-arena class-keys array,
# and `js_box_alloc*`'s Box, which is not a GC-heap allocation at all.
# Each exemption SUPPRESSES reports, and each is only as sound as
# premises that live in the runtime crates and can change without anyone
# touching this checker — the class-keys array staying `_longlived`,
# old-page defrag staying off by default, the keys global staying a
# registered root, and boxes staying `std::alloc::alloc` with no free
# path.
#
# An exemption whose premise has quietly lapsed is strictly WORSE than no
# exemption: it reads as a triaged false positive and is a live hazard.
# So the premises are a gate rather than a paragraph. Static and instant,
# like the audit above, so it runs before the build.
- name: Immovable-source exemptions must still hold
run: python3 scripts/gc_root_dominance_check.py --audit-immovable-sources
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- uses: ./.github/actions/setup-llvm22
# ★ The dependency-scale corpus needs the dependency.
#
# `zod` is this repo's own package.json devDependency, pinned by
# package-lock.json and governed by the same soak window as everything
# else in that file -- not a fixture invented for this job.
# `--ignore-scripts` because nothing here needs a lifecycle script to
# run, and a corpus generator is a bad place to execute one.
- uses: actions/setup-node@v6
with:
node-version-file: .node-version
- name: Install the npm dependencies the dep corpus compiles
run: npm ci --ignore-scripts --no-audit --no-fund
- name: Cache cargo
uses: actions/cache@v6
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-gcdom-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- name: Build perry and the runtime archives
run: |
set -euo pipefail
# perry-runtime and perry-stdlib are rlib-only; the .a files come from
# the -static wrapper crates. The package set is fixed so cargo
# feature unification matches every other job that builds the
# compiler.
cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static
for artifact in perry libperry_runtime.a libperry_stdlib.a; do
test -s "target/release/$artifact" \
|| { echo "::error::target/release/$artifact was not produced"; exit 1; }
done
# The source list and the env knobs live in the script, not here, so that
# reproducing a CI failure is one command rather than a re-read of this
# YAML. A retyped invocation that drops PERRY_GC_MOVING_LOOP_POLLS
# produces IR in which the bug is not expressible at all, and the local
# run then "cannot reproduce" a real finding.
- name: Emit the IR corpus
run: ./scripts/gc_root_dominance_corpus.sh ir-corpus
- name: Check root-store dominance
run: |
set -euo pipefail
# No pipe: the checker's own exit status is the job's.
#
# The floors are asserted, not hoped for. On the corpus as of this
# commit the run reports ~1993 functions / 117 modules / 2501 root
# stores, so these sit below that with room for churn and well above
# "something compiled". Raise them when the corpus grows; never lower
# one to make a run pass -- a shrinking corpus is the finding.
#
# --seeded-violations plants 40 collection points into this very IR
# and requires all 40 to be reported. That is the arm that fails if
# the checker has stopped understanding perry's output, which is the
# only way a green verdict here could be a lie.
python3 scripts/gc_root_dominance_check.py ir-corpus \
--moving-only \
--min-files 90 --min-binds 1500 --min-funcs 1200 \
--allowlist scripts/gc_root_dominance_allowlist.json \
--seeded-violations 40 \
-v
# ★ The OTHER mode, and until #7236 it had never been at zero.
#
# `--unrooted-allocas` asks a different question from the step above: not
# "is the root store late" but "is there a root store AT ALL". #7235 split
# its heap-source predicate by movability and got the count from 98 to 2,
# and both residuals were one bug -- `Type::Symbol` classified as an
# immediate, so a `Symbol` local got no shadow slot. That is now fixed and
# the corpus reads 0, which is the condition #7198 named for promoting
# this job to a required context.
#
# A number that is the acceptance criterion for a promotion and is checked
# by nothing will regress silently -- CLAUDE.md hazard 4, applied to the
# measurement rather than to the job. So it is a step. It shares the
# corpus, the exemptions and the allowlist file with the step above, and
# carries the same liveness floors: "0 violations" over a corpus that did
# not compile means nothing.
#
# If this arm ever goes red for a hazard that is real but deliberately
# deferred, the answer is an entry in gc_root_dominance_allowlist.json
# with an issue number, NOT deleting the step -- rule 1 in that file
# (an entry matching nothing fails the build) is what makes the tolerance
# temporary.
- name: Check that every GC value in an alloca has a root store
run: |
set -euo pipefail
python3 scripts/gc_root_dominance_check.py ir-corpus \
--unrooted-allocas \
--moving-only \
--min-files 90 --min-binds 1500 --min-funcs 1200 \
--allowlist scripts/gc_root_dominance_allowlist.json \
-v
# ★★ The DEPENDENCY-SCALE corpus (#7280).
#
# Everything above this line runs over ~124 hand-written `test-files/`
# sources. That corpus read ZERO in both gated modes while twenty lines of
# stock `zod` faulted deterministically under the from-space protector,
# and #7280 records the gap in one sentence: 25 curated files pass while
# 20 lines of stock zod fail.
#
# It is not a size problem, it is a distribution problem. Measured with
# `--stale-registers --moving-only` on the same compiler:
#
# curated (124 sources, 144 modules): 116 stale uses, and what
# dominates is property-GET helper windows and js_number_coerce
# dependency-scale (81 modules, 62 MB): 370 stale uses, and what
# dominates is js_object_assign_one (object spread, 137) and
# js_new_function_construct (102) -- populations the curated corpus
# produces 12 and 1 of
#
# Nothing is sampled away: all 81 modules and all 62 MB are checked. The
# cost is ~8s to emit and ~4s for the two gated arms below, because those
# arms are linear in instruction count. The `--stale-registers` ratchet is
# the expensive one (~5 min) and says so where it runs.
- name: Emit the dependency-scale IR corpus
run: ./scripts/gc_root_dominance_dep_corpus.sh ir-corpus-dep
- name: Check root-store dominance (dependency-scale)
run: |
set -euo pipefail
# Floors from the corpus as of this commit (81 modules, ~12900
# functions, ~7700 root stores), set below that with room for the
# dependency's own churn. `zod` growing is fine; `zod` no longer
# compiling natively is the finding, and these are what make it one.
python3 scripts/gc_root_dominance_check.py ir-corpus-dep \
--moving-only \
--min-files 60 --min-binds 4000 --min-funcs 6000 \
--allowlist scripts/gc_root_dominance_allowlist.json \
--seeded-violations 40 \
-v
- name: Check that every GC value in an alloca has a root store (dependency-scale)
run: |
set -euo pipefail
python3 scripts/gc_root_dominance_check.py ir-corpus-dep \
--unrooted-allocas \
--moving-only \
--min-files 60 --min-binds 4000 --min-funcs 6000 \
--allowlist scripts/gc_root_dominance_allowlist.json \
-v
# ★ The stale-register RATCHET, on both corpora.
#
# `--stale-registers` asks the third question: not "is the root store
# late" and not "is there a root store at all", but "is a register holding
# a rooted value used below a collection point". It is the mode that found
# #7206's two bugs and the mode #7280's zod fault lives in, and until now
# it ran only by hand -- so its number could move in either direction
# between one investigation and the next with nothing to say so.
#
# It is a BUDGET rather than an allowlist because the residual is a
# population, not a list of triaged sites: the remaining uses are the ones
# whose slot the program itself reassigns inside the window, which need a
# temp root rather than a re-read (see crate::root_reload). A budget can
# only be lowered, and lowering it is the ratchet.
#
# This step is minutes, not seconds -- the scan is superlinear in
# instruction count and the dependency corpus is 62 MB. That is the price
# of checking the population that actually breaks.
- name: Stale-register budget (curated)
run: |
set -euo pipefail
python3 scripts/gc_root_dominance_check.py ir-corpus \
--stale-registers --moving-only \
--min-files 90 --min-binds 1500 --min-funcs 1200 \
--max-stale 39
# 86 -> 104 at #7616: widening POLL_CAPABLE_RUNTIME by 77 symbols makes
# windows MOVING that `--moving-only` previously dropped. Still inside
# the pinned 118, so the budget is deliberately NOT raised — a ratchet
# you loosen every time it gets closer is not a ratchet.
- name: Stale-register budget (dependency-scale)
run: |
set -euo pipefail
python3 scripts/gc_root_dominance_check.py ir-corpus-dep \
--stale-registers --moving-only \
--min-files 60 --min-binds 4000 --min-funcs 6000 \
--max-stale 118
- name: Upload the IR corpus on failure
if: failure()
uses: actions/upload-artifact@v7
with:
name: gc-root-dominance-ir
path: |
ir-corpus
ir-corpus-dep
retention-days: 7
# ★★★ THE LOWERING THAT ACTUALLY SHIPS (#7663).
#
# Everything in the job above compiles its corpus under `PERRY_RS4GC=0` — the
# SHADOW-STACK lowering — and since #7370 that is not the default on any
# target whose frames the runtime can walk. The reason was sound and is
# stated inline in `gc_root_dominance_corpus.sh`: the checker anchored on
# `@js_shadow_slot_bind`, the native lowering emits zero of them, and
# `--min-binds` therefore failed the job. So the gate was pointed at the only
# lowering it could read, and `docs/src/internals/gc-rooting-invariant.md`
# records the consequence as a blind spot: **a green `gc-root-dominance` was
# evidence about a lowering that does not ship.**
#
# This job closes that. It is a SEPARATE job, not more steps in the one
# above, for three reasons: it needs a differently-generated corpus, its
# floors are about safepoints rather than root stores, and — the one that
# matters — a separate job is a separate branch-protection context, so this
# can be promoted to required on its own schedule without dragging the
# shadow arms with it. The price is a second compiler build; `needs:` would
# avoid it and was rejected, because it would make this job unrunnable
# whenever the shadow job is red — precisely when someone wants to know
# whether the OTHER lowering is affected.
#
# NOT REQUIRED, deliberately, and this is the second step nobody took last
# time. A gate that has never been green blocks every open PR the day it
# becomes required (CLAUDE.md's corollary, which this repo has already paid
# for once — see hazard 2 in the header of this file). Let it run on `main`
# first, then promote. Promotion is an admin action and is tracked separately
# from the PR that adds this.
gc-root-dominance-statepoints:
runs-on: macos-14
timeout-minutes: 90
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
# Same fast structural failure first. `--self-test` now covers BOTH
# lowerings: the statepoint half asserts the bundle parser reads the
# wrapped callee and a non-empty live set (the two mistakes the Rust
# reader of this construct already made and caught), that the two verdict
# classes are distinguished by the bundle alone, that the relocated-value
# control reports zero, and that `--min-statepoints` refuses the shadow
# corpus while `--min-binds` refuses this one.
- name: Checker self-test (can this gate still fail?)
run: python3 scripts/gc_root_dominance_check.py --self-test
# The FOUR static audits are shared vocabulary, not shadow-mode
# details: `--statepoints` classifies its sources with the same ALLOC_RE,
# decides MOVING with the same POLL_CAPABLE_RUNTIME, and honours the same
# #7210 exemptions. A phantom entry in any of them is a hole in THIS gate
# too, so all four run here as well rather than being inherited by
# assumption from a job that may not have run.
#
# `--audit-poll-reach` matters most HERE, because #7616's measurement was
# taken in this job's mode: the re-planted #7453 shape reported 2 clean
# and 2 sabotaged under `--statepoints --moving-only`, i.e. the newest
# mode was as blind as the three it was added to cover for, and for the
# same one-line reason. The full census is in the shadow job above.
- name: ALLOC_RE alternatives must match real runtime symbols
run: python3 scripts/gc_root_dominance_check.py --audit-alloc-re
- name: POLL_CAPABLE_RUNTIME entries must name real runtime symbols
run: python3 scripts/gc_root_dominance_check.py --audit-poll-capable
- name: ALLOC_RE symbols that reach a poll-capable one must be listed
run: python3 scripts/gc_root_dominance_check.py --audit-poll-reach
- name: Immovable-source exemptions must still hold
run: python3 scripts/gc_root_dominance_check.py --audit-immovable-sources
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
# This also puts a matched LLVM 22 `opt` on disk and exports
# LLVM_SYS_221_PREFIX, which is how the corpus script finds it. The
# native corpus is `--trace llvm` output PLUS the production statepoint
# rewrite, because codegen does not emit statepoints — LLVM does, later,
# in the linker step.
- uses: ./.github/actions/setup-llvm22
- name: Cache cargo
uses: actions/cache@v6
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-gcdom-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- name: Build perry and the runtime archives
run: |
set -euo pipefail
cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static
for artifact in perry libperry_runtime.a libperry_stdlib.a; do
test -s "target/release/$artifact" \
|| { echo "::error::target/release/$artifact was not produced"; exit 1; }
done
# The script single-sources the pass string from
# `STATEPOINT_REWRITE_PASSES` in perry-codegen and refuses to run if it
# cannot read it, so this corpus cannot drift away from the pipeline
# production uses. It also asserts its own subject: zero statepoints or
# zero live bundles is an error at generation time, not a clean verdict
# downstream.
- name: Emit the NATIVE (statepoint) IR corpus
run: ./scripts/gc_root_dominance_corpus.sh ir-corpus-native --lowering native
- name: Check GC values across safepoints (native roots)
run: |
set -euo pipefail
# No pipe: the checker's own exit status is the job's.
#
# FLOORS, from the corpus as of this commit — 149 modules, 2452
# functions, 30033 safepoints, 17478 with a live bundle, 40759
# relocations — set below that with room for churn and far above
# "something compiled". Three separate floors because they fail for
# three different reasons and the message has to say which:
# --min-statepoints this is the shadow corpus, or codegen stopped
# marking functions gc "statepoint-example"
# --min-relocates the corpus was copied through un-rewritten
# (`opt` exits 0 on a module with nothing to do)
# --min-live-bundles safepoints that record NO roots, which is what
# an unrooted build looks like
#
# BUDGETS. `unrooted` (the object is in NO live bundle at all, so
# nothing marks or rewrites it) is the serious class, and it is a
# RATCHET rather than a calibrated zero: this is a young instrument
# pointed at a lowering nothing had ever checked statically, and the
# remainder is a population under triage rather than a number anyone
# has adjudicated. It is a budget rather than allowlist entries for
# the reason `--stale-registers` records — tombstones with no issue
# numbers would be worse documentation than one number that can only
# go down. Lower it as sites are fixed.
#
# 21 at #7663. #7664 fixed 14 of them in `root_reload.rs` and
# `lower_call/new.rs` — the whole `strhandle` population (10), the
# `js_new_target_get` save/restore (1), and 3 of the 7 unmasked
# receivers — so the budget is 7. #7616 (widening POLL_CAPABLE_RUNTIME)
# and #7690 (rooting the spread argument-bundle accumulator) moved it
# 7 -> 11 -> 8, tracked in the two changelog fragments for those PRs.
#
# ★ RE-VERIFIED AT #7664's SECOND PASS: the 8 above was already stale
# by the time this ran. A fresh corpus read 9, not 8 — a static-method
# receiver hazard in `test_gap_static_method_value_name_collision`
# (added after the "8" snapshot, by the #7689/#7691 fix that armed a
# receiver-sensitive static `this`) had joined the population without
# anyone lowering — er, RAISING — the budget to match. That in itself
# is the thing CLAUDE.md's hazard-4 corollary warns about: a budget
# nobody re-measures silently absorbs the next hazard.
#
# Of the 9: 4 were CHECKER FALSE POSITIVES, not real hits. `chain`
# (the untracked cast-closure a stale use is searched in) treated
# `phi` as unconditionally transparent — one tainted incoming edge
# blanket-tainted the phi's RESULT, and a downstream use of that
# result was checked against ANY CFG path from source to use
# (`between_blocks` is deliberately path-insensitive, which is sound
# for an ordinary register but not a phi, whose dynamic value depends
# on which edge was actually taken). All four were the identical
# shape, e.g. `readCtx`:
#
# entry.0: %r2 = <unmask of the receiver>
# br i1 %r4, label %then, label %merge
# merge: %r87 = phi double [ %r2, %entry.0 ],
# [ %r86, %pget.recv_merge ]
# ret double %r87
#
# The safepoints are all on the `%then` path, where the phi selects
# `%r86`. On the edge that carries `%r2` nothing collects between its
# definition and the join — the checker was reporting the OTHER
# edge's safepoint against THIS edge's value. Verified register-by-
# register on all four (`readCtx`, `__closure_5`, `Readable`,
# `__obj_method_toLocaleString_3` — every one a `logical.merge` join
# of an `&&`).
#
# Fixed: `_cast_closure` gained `phi_all_edges` — a phi joins `chain`
# only once EVERY incoming edge is independently in it, closing the
# false positive. That deliberately gives up the case of a SINGLE
# tainted edge with its own intervening safepoint before its own
# predecessor's terminator; `_phi_edge_hazard` covers that separately,
# checking each edge's window on its own. Two sabotage-tested
# self-test fixtures (`phi_safe_edge` / `phi_hazard_edge`) pin both
# directions.
#
# The remaining 5 were real, and split two ways:
#
# 3 unrooted:global. `test_gap_arraybuffer_transfer::main` (2,
# `new DataView`/`new Uint8Array` reading a module-global
# receiver, then holding it across a sibling `{ valueOf() {...} }`
# argument's allocation) — fixed upstream by #7719, which covers
# the identical shape across all 30 `lower_call/builtin.rs` ctor
# arms via a shared `RootedGroup`. `test_gap_static_method_value_
# name_collision::main` (1, `(Lexer as any).lex(...)`'s receiver
# held across arg-bundling that always allocates for a rest
# param) — fixed here, in
# `lower_call/property_get/static_dispatch.rs`, the same
# `RootedGroup::adopt`/`reread` shape on the receiver instead of
# a constructor argument.
# 2 unrooted:capture. `js_closure_get_capture_bits` returns a raw
# `i64` that may be a NaN-boxed heap value, and unlike
# `%this_closure` itself (which codegen already re-enters into
# the `ptr addrspace(1)` tracked domain), its generic "read a
# captured value" call sites never re-enter it into either that
# domain or a temp root.
# `test_gap_class_expr_dynamic_parent_ctor::__closure_21`: a
# captured dynamic-parent-class reference read at the top of the
# synthesized implicit ctor closure, used ~60 lines later as
# `js_new_function_construct`'s callee, across
# `js_object_alloc_class_inline_keys` AND the class's own user
# constructor.
# `test_gap_computed_key_method_nested_this::__closure_9`: a
# captured numeric local read, then `total + x`-style `fadd`
# after `js_number_coerce` (which can run a user
# `Symbol.toPrimitive`) intervenes. NOT reloadable the way a
# strhandle global is (re-deriving from `%this_closure` reads
# the pre-move closure — RS4GC does not relocate a raw `i64`
# parameter) — but IS reloadable the way OTHER root-derived
# values are: re-calling `js_closure_get_capture_bits` with the
# same closure pointer and index re-reads the same slot, sound
# under the identical store side-condition
# (`js_closure_set_capture_bits` to that index) `root_reload.rs`
# already tracks for shadow slots and handle globals.
#
# ★ #7725 CLOSED both `unrooted:capture` hits, and the budget is
# deleted rather than set to 0 — `--max-unrooted` already defaults
# to 0, and a budget nobody re-measures is exactly the silently-
# absorbing-the-next-hazard failure mode CLAUDE.md's hazard-4
# corollary warns about (the "8 was already stale, a fresh corpus
# read 9" paragraph above is that failure mode, once). The fix:
# `root_reload.rs`'s `Facts` gained `capture_get_key`, so a
# `js_closure_get_capture_bits` call whose ptr operand already
# belongs to a reloadable recipe (the closure-ptr shadow-slot load
# chain #7055 already protects) extends that recipe like a
# transparent bit op — the whole chain (slot load, mask, a FRESH
# call to `js_closure_get_capture_bits` with the same index, and
# any further cast) re-materialises at every reload point. The
# store side-condition switches from "was the closure-ptr slot
# reassigned" (never, in generated code) to "was THIS INDEX set" —
# a synthetic per-index key a `js_closure_set_capture_bits` call
# populates via the same `stores_to` field a plain store uses,
# which needed the `recipe[0]`-only group key widened to
# `(recipe[0], root_ptr)` so the two conditions don't get
# conflated under one reachability walk. Verified: native corpus
# 2 -> 0 (`stale` held at 0), `--lowering shadow` corpus clean,
# `--self-test` still proves the checker can fail.
#
# --seeded-violations plants 40 safepoints into this very IR, between
# a `ptrtoint ptr addrspace(1)` and its use, and requires all 40 to be
# reported. It runs even when the budgets are already red, because
# the run whose "can it still fail" arm you most want is the one that
# is already telling you something. That is the arm that fails if the
# mode has stopped understanding LLVM's output — the only way a green
# verdict here could be a lie.
python3 scripts/gc_root_dominance_check.py ir-corpus-native \
--statepoints \
--moving-only \
--min-files 90 --min-funcs 1200 \
--min-statepoints 15000 \
--min-live-bundles 8000 \
--min-relocates 20000 \
--max-stale 0 \
--allowlist scripts/gc_root_dominance_allowlist.json \
--seeded-violations 40 \
-v
# ★ The unfiltered arm, as a DIAGNOSTIC rather than a gate.
#
# `--moving-only` keeps only hazards whose window reaches a safepoint
# that can run an evacuating minor. The rest are still hazards under the
# RECLAIM half of the invariant (a value held only in an untracked
# register can be swept, not just moved), and the count is two orders of
# magnitude larger: 1444 (1123 unrooted, 321 stale) against the gated
# arm's 21. Printing it keeps the filtered number honest — if the gated
# arm ever reads zero, this line says whether that is the corpus or the
# filter. `|| true` is deliberate and is the ONLY one in this file —
# this step is not a gate and must not be read as one.
- name: Unfiltered hazard census (diagnostic, not a gate)
# The explicit opt-out `gc_gate_wiring_check.py` looks for. Without it
# that gate reads the `|| true` below as a swallowed failure -- which
# is exactly right for a gating step and exactly wrong for this one, so
# the difference has to be declared rather than argued in a comment.
continue-on-error: true
run: |
python3 scripts/gc_root_dominance_check.py ir-corpus-native \
--statepoints \
--min-files 90 --min-funcs 1200 \
--min-statepoints 15000 --min-live-bundles 8000 \
--min-relocates 20000 \
--max-unrooted 100000 --max-stale 100000 || true
- name: Upload the IR corpus on failure
if: failure()
uses: actions/upload-artifact@v7
with:
name: gc-root-dominance-statepoint-ir
path: ir-corpus-native
retention-days: 7