Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 53 additions & 57 deletions .github/workflows/gc-root-dominance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,37 @@ name: GC Root Dominance
#
# 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 — deliberately, because
# a new gate has never been green and promoting it immediately blocks every
# open PR. Promote after one clean week on `main`;
# 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**: with the allowlist below the job is green on `main`, so a
# repo admin must add `gc-root-dominance` to branch protection's required
# contexts. Until that happens this file is documentation, not a gate.
# 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. `--self-test` proves the
# checker still reports a planted violation and still clears the control,
# and `--min-files` / `--min-binds` refuse a clean verdict over a corpus
# that contained no modules or no root stores. 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.
# 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:
Expand Down Expand Up @@ -93,60 +114,35 @@ jobs:
|| { 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
env:
# PERRY_GC_MOVING_LOOP_POLLS=1 is what puts `js_gc_loop_safepoint` in
# the IR, which is what the MOVING classification keys on. It is off
# by default (#7161 stopgap), so without it this gate would run over
# IR that cannot express the bug — hazard 4 again.
PERRY_GC_MOVING_LOOP_POLLS: "1"
# Makes every root store the @js_shadow_slot_bind call form. The #7088
# inline diamond is equivalent but harder to anchor on.
PERRY_INLINE_SHADOW_SLOT: "0"
PERRY_NO_AUTO_OPTIMIZE: "1"
run: |
set -euo pipefail
mkdir -p ir-corpus
# A spread of shapes that exercise the lowerings this invariant runs
# through: construction, object/array literals and spreads, class
# expressions with statics, property and element stores, closures.
# Kept to test-files/ so the corpus is versioned with the repo rather
# than depending on a private workload.
shopt -s nullglob
sources=(
test-files/test_gap_gc_*.ts
test-files/test_gap_class*.ts
test-files/test_gap_object*.ts
test-files/test_gap_static*.ts
test-files/test_gap_prop*.ts
)
if [ "${#sources[@]}" -eq 0 ]; then
echo "::error::no corpus sources matched; the glob is stale"
exit 1
fi
for src in "${sources[@]}"; do
name="$(basename "$src" .ts)"
rm -rf .perry-trace/llvm
# A source that fails to compile must not silently shrink the
# corpus: --min-files below is the backstop, but say so here too.
if ! ./target/release/perry compile "$src" -o "/tmp/$name" --trace llvm >/dev/null 2>&1; then
echo "::warning::$src did not compile; skipping"
continue
fi
for ll in .perry-trace/llvm/*.ll; do
cp "$ll" "ir-corpus/${name}__$(basename "$ll")"
done
done
echo "corpus: $(find ir-corpus -name '*.ll' | wc -l) .ll files"
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. --min-binds
# asserts the corpus actually contained root stores, so a green
# verdict cannot come from IR that never had a subject.
# 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 5 --min-binds 50 -v
--moving-only \
--min-files 90 --min-binds 1500 --min-funcs 1200 \
--allowlist scripts/gc_root_dominance_allowlist.json \
--seeded-violations 40 \
-v

- name: Upload the IR corpus on failure
if: failure()
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ benchmarks/suite/assets/

# `perry compile --trace llvm` dumps per-module .ll files here.
.perry-trace/
# scripts/gc_root_dominance_corpus.sh output (regenerate, never commit)
ir-corpus/

# Compiled test executables in the project root (no extension)
/test_*
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,4 +250,4 @@ Corollary: a *new* gate has never been green, so promoting it to required immedi
- **Async-to-generator transform, body locals.** It boxes every body local into a shared mutable cell typed `Any`. Two consequences seen in the wild: per-iteration `let`/`const` bindings collapse for closures created in a loop, and computed numeric-key calls (`arr[i](x)`) lose their type proof and silently resolve by *method name*, evaporating the call.
- **Native base-class subclassing.** A native base's surface is installed at `super()` time and its parent edge lives in the class registry; keying any of that on a literal `extends` name loses it for fieldless classes, indirect subclasses, and class expressions.
- **Two prototype-resolution paths.** `CLASS_PROTOTYPE_OBJECTS` (synthetic: `Object.create`, plain-function ctors) vs `CLASS_DECL_PROTOTYPE_OBJECTS` (declared classes). `in`/`for…in` and `getPrototypeOf` have disagreed about the same chain.
- **Root-store dominance in codegen.** *A GC-managed value's root store must **dominate** every subsequent site that can collect.* Three ways it has broken, all shipped: the store's slot index fell outside the pushed shadow frame so `js_shadow_slot_bind` bounds-checked it into a silent no-op (#7184); the store was emitted in-frame but **after** a call that allocates (#7192); and the value lives in a plain `alloca_entry` that is neither a shadow slot nor a temp root, so the collector never rewrites it (`lower_call/new.rs`'s inline-ctor `this_slot`, still open). All three present identically — a *rooted* slot holding a dangling pointer, surfacing cycles later as `TypeError: value is not a function` — and **none is visible to any runtime GC probe**, because at the moment of the collection there is nothing for the collector to find. That is why #7154's from-space scan only ever saw offenders whose targets had already died. The instrument is static: `scripts/gc_root_dominance_check.py` over `--trace llvm` output (`--self-test` proves it can still fail). Only bites under `PERRY_GC_MOVING_LOOP_POLLS=1`, off by default since #7161 — so a green default run says nothing about this class.
- **Root-store dominance in codegen.** *A GC-managed value's root store must **dominate** every subsequent site that can collect.* Three ways it has broken, all shipped: the store's slot index fell outside the pushed shadow frame so `js_shadow_slot_bind` bounds-checked it into a silent no-op (#7184); the store was emitted in-frame but **after** a call that allocates (#7192); and the value lives in a plain `alloca_entry` that is neither a shadow slot nor a temp root, so the collector never rewrites it (`lower_call/new.rs`'s inline-ctor `this_slot`, closed by #7207; `--unrooted-allocas` is the detector for that shape, and its remaining hits are #7210's). All three present identically — a *rooted* slot holding a dangling pointer, surfacing cycles later as `TypeError: value is not a function` — and **none is visible to any runtime GC probe**, because at the moment of the collection there is nothing for the collector to find. That is why #7154's from-space scan only ever saw offenders whose targets had already died. The instrument is static: `scripts/gc_root_dominance_check.py` over `--trace llvm` output (`--self-test` proves it can still fail). Only bites under `PERRY_GC_MOVING_LOOP_POLLS=1`, off by default since #7161 — so a green default run says nothing about this class. **Full writeup, every known shape and how to check your work: `docs/src/internals/gc-rooting-invariant.md`.** The CI gate is `gc-root-dominance.yml` over `scripts/gc_root_dominance_corpus.sh`; known-remaining hits are named one-per-entry in `scripts/gc_root_dominance_allowlist.json` (an entry that matches nothing FAILS, so a fix must delete its entry). A fifth shape is open as #7211: `ClassExprFresh` roots only when it thinks the static *initializers* collect, and never asks whether its own emitted `js_object_set_field_by_name` does — the sophisticated version of the mistake, where the author wrote a rooting predicate and it asked the wrong question.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
65 changes: 65 additions & 0 deletions changelog.d/7212-gc-root-dominance-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
### CI: the GC root-dominance gate can now fail, and is baselined honestly

The static root-dominance checker added in #7198 had been **red on `main` ever
since it merged**, and blocked nothing: `gc-root-dominance` is not in branch
protection's required contexts, so the job reported failure without being able
to turn a merge red. That is hazard 2 from CLAUDE.md's "four ways a gate can be
unable to fail", and the corollary it warns about — run a new gate once, *then*
promote it; leaving the second step undone is how hazard 2 happens.

The five violations it had been reporting all along are real, and are now
tracked as #7211: `Expr::ClassExprFresh` roots its class object only when it
believes the static *initializers* can collect, and never asks whether the
lowering's own emitted `js_object_set_field_by_name` can. A class expression
whose statics are inert (`class C { static tag = tag }`) therefore holds the
object in a register across a collection point. `js_object_mark_class` does not
rescue it: that helper roots `CLASS_OBJECT_VALUES`' own copy, which keeps the
object alive and forwarded while leaving the register stale. Reachability is not
the invariant.

**Corpus** (`scripts/gc_root_dominance_corpus.sh`, new) — emission moves out of
the workflow so that reproducing a CI failure is one command rather than a
re-read of the YAML; an invocation retyped without `PERRY_GC_MOVING_LOOP_POLLS=1`
produces IR in which the bug is not expressible at all. Grown from 41 to 117
`.ll` files / 1993 functions / 2501 root stores over 99 sources, selected for the
lowerings this invariant runs through. A stale glob is a hard error and the
compiled-source count has an explicit floor.

**Allowlist** (`scripts/gc_root_dominance_allowlist.json`, new) — one named entry
per known-remaining hit, each with an issue and a written justification, instead
of a numeric threshold. A threshold cannot distinguish a new violation from an
old one, and the cheapest way to green a red build is to raise it by one. The
checker enforces that an entry matching nothing **fails** (so a fix must delete
its entry — that is the ratchet), that an entry suppresses at most its `count`,
and that an unnamed violation fails regardless of the total.

**Proof of failure** — `--seeded-violations N` splices synthetic collection
points into the *real* corpus IR between an allocation and its root store and
requires every one to be reported. `--self-test` only proves the checker fires on
frozen fixtures, which keeps passing even if perry's emitted IR drifts to a shape
the parser can no longer read; that is the case where the gate reports a serene
`violations: 0` over IR it is not analysing. `--self-test` additionally gained
arms covering the allowlist's anti-absorption properties.

**Visibility** — `--min-funcs` plus a `checked N functions / M modules` summary
line, so a silently-empty or silently-shrunken run is impossible to mistake for a
clean one.

Verified: green on `main` with the allowlist; red without it; red with any single
entry removed or its `count` lowered; exit 2 on a stale entry or an empty corpus;
40/40 seeded violations caught. Corpus emission ~80s, the check ~3s.

**Still required, and not something the workflow can do to itself:** a repo admin
must add `gc-root-dominance` to branch protection's required contexts.

### Docs

- `docs/src/internals/gc-rooting-invariant.md` — the rule stated plainly for
codegen authors, with all five real bugs as case studies, the symptom each
produces, and how to check your work. Includes the false-green caveat on
`PERRY_GC_PROTECT_FROMSPACE_DEPTH` (the default of 4 is not enough; use 800).
- `docs/src/internals/rfc-rooting-by-construction.md` — design proposal for
making the bug unrepresentable: V8's `Handle`/`HandleScope` discipline
expressed through Rust's borrow checker, so that using an unrooted value
across a collection point is a compile error. Four of the five real bugs would
be caught by construction. Proposal only; nothing implemented.
Loading
Loading