Skip to content
This repository was archived by the owner on Sep 1, 2026. It is now read-only.

Commit 5e3302d

Browse files
author
Brad McCormack
committed
Merge remote-tracking branch 'upstream/main'
Brings in 39 upstream commits (a3b31c0..7d1dc9c) on top of the local context-mode experimental patches (b72f6f9, c3d1141, 6d28ca7). Resolution notes: * The local CHANGELOG.md diverged only in home-directory paths (`/Users/amlug/...` vs `~/projects/...`) that upstream scrubbed in PerryTS#7165. CHANGELOG.md is FROZEN at v0.5.1264 (see `changelog.d/README.md`), so the file is restored to its state at the merge base (df7214b, the v0.5.1264 version bump). The new context-mode entries go in `changelog.d/7220-context-mode-patches.md` instead, per the per-PR fragment policy. * The five perry-related file changes (eval_classifier, expr_new, alias_tracking, freshness, lib) all auto-merged cleanly. The workspace reorg from `perry/...` to `crates/perry/...` is picked up via git's rename detection, so the `freshness.rs` patch rides along at its new path. * No upstream-side changes were dropped or hand-edited.
2 parents 6d28ca7 + 7d1dc9c commit 5e3302d

298 files changed

Lines changed: 26705 additions & 2403 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
name: GC Root Dominance
2+
3+
# Static gate for the invariant a GC-managed value's root store must DOMINATE
4+
# every subsequent site that can trigger a collection (#7154).
5+
#
6+
# Two bugs of this class shipped before there was an instrument for it. #7184:
7+
# the root store was emitted but its shadow-slot index fell outside the pushed
8+
# frame, so `js_shadow_slot_bind` bounds-checked it into a silent no-op. #7192:
9+
# the store was emitted in-frame but AFTER a call that allocates. Both present
10+
# identically — a *rooted* slot holding a dangling pointer, surfacing cycles
11+
# later as "TypeError: value is not a function" — and neither is visible to any
12+
# runtime GC probe, because at the moment of the collection there is nothing
13+
# for the collector to find. A static pass over the emitted IR is the only
14+
# instrument that sees them before they crash, which is why this is a gate and
15+
# not a benchmark.
16+
#
17+
# THIS JOB IS DESIGNED TO BE ABLE TO FAIL, and is checked against all four ways
18+
# a gate can be unable to (CLAUDE.md):
19+
#
20+
# 1. no `continue-on-error`, no `|| true`, no pipe between the checker and
21+
# the shell's exit status;
22+
# 2. NOT yet in branch protection's required contexts. This was deliberate at
23+
# first — a new gate has never been green, so promoting it immediately
24+
# blocks every open PR — but the second step was never taken, and in the
25+
# meantime the job WAS red on `main` and blocked nothing: the five
26+
# ClassExprFresh hits now tracked in #7211 have been reported on every run
27+
# since #7198 and went unread. That is hazard 2 doing exactly what the
28+
# corollary in CLAUDE.md warns about.
29+
#
30+
# **ACTION REQUIRED, and it is not something this workflow can do to
31+
# itself**: with the allowlist below the job is green on `main`, so a
32+
# repo admin must add `gc-root-dominance` to branch protection's required
33+
# contexts. Until that happens this file is documentation, not a gate.
34+
# See docs/src/internals/gc-rooting-invariant.md, "Promoting this gate".
35+
# 3. `concurrency` cancels pull-request runs only, never `main` runs;
36+
# 4. the subject is ASSERTED live, not assumed, at three levels.
37+
# `--self-test` proves the checker still reports a planted violation in
38+
# hand-written IR and still clears the control. `--seeded-violations`
39+
# goes further and plants collection points into the REAL corpus,
40+
# requiring every one to be reported -- that is what catches the case
41+
# where perry's emitted IR drifts to a shape the parser can no longer
42+
# read, which frozen fixtures cannot detect. And `--min-files` /
43+
# `--min-binds` / `--min-funcs` refuse a clean verdict over a corpus with
44+
# too few modules, root stores or functions to have exercised anything.
45+
# An empty `.perry-trace/llvm` is a routine outcome of a failed compile,
46+
# so "0 violations" over 0 files must be an error rather than a pass.
47+
#
48+
# Known-remaining violations live in scripts/gc_root_dominance_allowlist.json,
49+
# one named entry each with an issue and a written justification -- NOT a
50+
# numeric threshold, which cannot tell a new violation from an old one. An
51+
# entry that matches nothing fails the build, so a fixed bug's entry must be
52+
# deleted rather than left to widen coverage later.
53+
54+
on:
55+
pull_request:
56+
push:
57+
branches: [main]
58+
workflow_dispatch:
59+
60+
permissions:
61+
contents: read
62+
63+
concurrency:
64+
# Per-event groups, cancelling PR runs only. A shared group with an
65+
# unconditional cancel-in-progress starves `main`: on a deep runner queue
66+
# every merge cancels the previous main run before it reaches a runner, and a
67+
# gate that is always cancelled never fails. Same reasoning as gc-ratchet.yml.
68+
group: gc-root-dominance-${{ github.event_name }}-${{ github.ref }}
69+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
70+
71+
env:
72+
CARGO_TERM_COLOR: always
73+
MACOSX_DEPLOYMENT_TARGET: "13.0"
74+
75+
jobs:
76+
gc-root-dominance:
77+
runs-on: macos-14
78+
timeout-minutes: 90
79+
steps:
80+
- uses: actions/checkout@v7
81+
with:
82+
persist-credentials: false
83+
84+
# Fast structural failure first: prove the checker can still fail before
85+
# spending a compiler build on it. This is the arm that would have caught
86+
# `PERRY_GC_FORCE_EVACUATE` being inert for every test that "exercised"
87+
# it (#6942/#6946).
88+
- name: Checker self-test (can this gate still fail?)
89+
run: python3 scripts/gc_root_dominance_check.py --self-test
90+
91+
- name: Install Rust toolchain
92+
uses: dtolnay/rust-toolchain@stable
93+
94+
- name: Cache cargo
95+
uses: actions/cache@v6
96+
with:
97+
path: |
98+
~/.cargo/registry
99+
~/.cargo/git
100+
target
101+
key: ${{ runner.os }}-cargo-gcdom-${{ hashFiles('**/Cargo.lock') }}
102+
restore-keys: ${{ runner.os }}-cargo-
103+
104+
- name: Build perry and the runtime archives
105+
run: |
106+
set -euo pipefail
107+
# perry-runtime and perry-stdlib are rlib-only; the .a files come from
108+
# the -static wrapper crates. The package set is fixed so cargo
109+
# feature unification matches every other job that builds the
110+
# compiler.
111+
cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static
112+
for artifact in perry libperry_runtime.a libperry_stdlib.a; do
113+
test -s "target/release/$artifact" \
114+
|| { echo "::error::target/release/$artifact was not produced"; exit 1; }
115+
done
116+
117+
# The source list and the env knobs live in the script, not here, so that
118+
# reproducing a CI failure is one command rather than a re-read of this
119+
# YAML. A retyped invocation that drops PERRY_GC_MOVING_LOOP_POLLS
120+
# produces IR in which the bug is not expressible at all, and the local
121+
# run then "cannot reproduce" a real finding.
122+
- name: Emit the IR corpus
123+
run: ./scripts/gc_root_dominance_corpus.sh ir-corpus
124+
125+
- name: Check root-store dominance
126+
run: |
127+
set -euo pipefail
128+
# No pipe: the checker's own exit status is the job's.
129+
#
130+
# The floors are asserted, not hoped for. On the corpus as of this
131+
# commit the run reports ~1993 functions / 117 modules / 2501 root
132+
# stores, so these sit below that with room for churn and well above
133+
# "something compiled". Raise them when the corpus grows; never lower
134+
# one to make a run pass -- a shrinking corpus is the finding.
135+
#
136+
# --seeded-violations plants 40 collection points into this very IR
137+
# and requires all 40 to be reported. That is the arm that fails if
138+
# the checker has stopped understanding perry's output, which is the
139+
# only way a green verdict here could be a lie.
140+
python3 scripts/gc_root_dominance_check.py ir-corpus \
141+
--moving-only \
142+
--min-files 90 --min-binds 1500 --min-funcs 1200 \
143+
--allowlist scripts/gc_root_dominance_allowlist.json \
144+
--seeded-violations 40 \
145+
-v
146+
147+
- name: Upload the IR corpus on failure
148+
if: failure()
149+
uses: actions/upload-artifact@v7
150+
with:
151+
name: gc-root-dominance-ir
152+
path: ir-corpus
153+
retention-days: 7

.github/workflows/test.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -967,6 +967,20 @@ jobs:
967967
if: github.event_name != 'pull_request'
968968
run: ./scripts/gc_repsel_matrix.sh --no-build --arms all --json gc-repsel-matrix.json
969969

970+
# GATING, and deliberately so. CLAUDE.md's GC knob kill-policy requires
971+
# every GC knob to have an arm that exercises it; the #7154 instruments
972+
# (PERRY_GC_PROTECT_FROMSPACE, PERRY_GC_ZEAL) would otherwise be dark
973+
# knobs on the subsystem with this repo's worst history of configuration
974+
# rot. This asserts BOTH defaults — inert with the knobs unset, live with
975+
# them set — and refuses to pass unless the zeal arm forced strictly more
976+
# collections than the pressure-only arm, so it cannot go green having
977+
# run zero copying minors (the #6942 / #7024 / #7025 failure mode).
978+
# The detection property itself is a required-gate unit test:
979+
# gc/tests/fromspace_protect.rs::quarantine_catches_a_planted_stale_from_space_deref.
980+
# ~20s: the fixture is sized for ~1200 back-edge polls, not #7154's 240k.
981+
- name: GC rooting-bug instruments (inert-when-off, live-when-on)
982+
run: ./scripts/gc_instrument_smoke.sh target/release/perry
983+
970984
- name: Run GC write-barrier stress tests
971985
# Informational: these are ~200s nondeterministic corruption-window
972986
# hunts (#5029). Kept out of the gate so a flake never blocks a PR.
@@ -1218,6 +1232,7 @@ jobs:
12181232
python3 scripts/compiler_output_regression.py census-self-test
12191233
python3 scripts/compiler_output_regression.py census-knob-isolation-self-test
12201234
python3 scripts/compiler_output_regression.py census-determinism-self-test
1235+
python3 scripts/compiler_output_regression.py census-temp-hygiene-self-test
12211236
python3 -m unittest tests.test_repsel_census
12221237
12231238
- name: Build compiler
@@ -1235,6 +1250,21 @@ jobs:
12351250
--repeat 2 \
12361251
--jobs 4
12371252
1253+
# #7144. The other consequence of content-addressing the `.ll`: workers
1254+
# holding identical IR share the name, so #7135 stopped deleting it and
1255+
# nothing else did — one leftover per distinct IR ever compiled. CI never
1256+
# saw it (runner temp dirs are reclaimed) while developer machines
1257+
# reached 29 GB. This step compiles with `TMPDIR` pointed at an empty
1258+
# directory and asserts it is still empty afterwards; note that a
1259+
# repeat-and-compare check would NOT have caught it, because identical
1260+
# IR reuses the identical name.
1261+
- name: Temp-directory hygiene
1262+
run: |
1263+
python3 scripts/compiler_output_regression.py census-temp-hygiene \
1264+
--perry target/debug/perry \
1265+
--repeat 2 \
1266+
--jobs 4
1267+
12381268
- name: Promotion census
12391269
run: |
12401270
python3 scripts/compiler_output_regression.py census \

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ benchmarks/suite/assets/
4040

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

4446
# Compiled test executables in the project root (no extension)
4547
/test_*

CLAUDE.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
88

99
Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation.
1010

11-
**Current Version:** 0.5.1269
11+
**Current Version:** 0.5.1277
1212

1313

1414
## TypeScript Parity Status
@@ -132,6 +132,19 @@ Generational mark-sweep GC in `crates/perry-runtime/src/gc.rs` (default since v0
132132

133133
**Escape hatches**: `PERRY_GEN_GC=0`/`off`/`false` reverts to full mark-sweep (bisection only). `PERRY_GEN_GC_EVACUATE=0`/`off`/`false` disables policy evacuation; `=1`/`on`/`true` is accepted as auto-policy allowed, not unconditional evacuation. `PERRY_GC_FORCE_EVACUATE=1` stress-copies every marked non-pinned nursery object only when generated write barriers are active and policy evacuation is allowed. `PERRY_GC_VERIFY_EVACUATION=1` panics if any mutable live slot still points at a forwarded nursery object after an evacuation/rewrite cycle. `PERRY_WRITE_BARRIERS=0`/`off`/`false` disables codegen-emitted write barriers at compile time and runtime exact helper barriers at runtime for benchmark/debug bisection; unset, `=1`/`on`/`true` keep barriers enabled. `PERRY_GC_DIAG=1` prints per-cycle diagnostics, including evacuation-policy decisions for considered cycles and `barriers_inactive` skips.
134134

135+
### Rooting-bug instruments (#7154 family) — what each knob ACTUALLY gates
136+
137+
A "GC value live but not rooted across a collection point" bug is invisible at collection time: there is nothing for the collector to find. It surfaces one or more cycles later, in a different function, as `TypeError: value is not a function`. These three knobs exist to collapse that latency. **All default-off; every boolean knob's OFF state is asserted in `gc/tests/fromspace_protect.rs`** (`…_DEPTH` is a magnitude, not a mode, so its floor and default are asserted instead). The instruments are **sabotage-tested**, not merely exercised: `quarantine_catches_a_planted_stale_from_space_deref` plants a #7184/#7192-shaped stale from-space pointer and asserts the instrument distinguishes it from the live object that would otherwise be recycled into those bytes — so a green protected run means the detector works, not that nothing was tried.
138+
139+
| knob | gates EXACTLY | does NOT |
140+
|---|---|---|
141+
| `PERRY_GC_PROTECT_FROMSPACE=1` (or `poison`) | the from-space reset performed by the **copying minor** (`arena::copying_reset_from_spaces_and_flip`). Retired Eden + active-survivor blocks are detached into a bounded quarantine, poison-filled (`0xDEADBEEFBAADF0DE`, `obj_type = 0xDE`) and, at `=1`, `mprotect(PROT_NONE)`d. A stale deref then SIGSEGVs at the faulting instruction; the installed reporter names the address, the retiring minor, and the last-known object's `obj_type`/size, then restores `SIG_DFL` and re-faults so a core/debugger still sees the real site. `poison` skips `mprotect`. | change the non-moving minor's `arena_reset_empty_blocks`, the full mark-sweep's reclaim, old-gen defrag, or the malloc sweep. **A run with zero copying minors protects nothing** — check that `PERRY_GC_DIAG=1` prints a `[gc-fromspace-protect] retired_set=#N` line. |
142+
| `PERRY_GC_PROTECT_FROMSPACE_DEPTH=N` (default 4) | how many retired page-sets stay quarantined. Evicted sets are restored to RW and **recycled back into Eden**, never `dealloc`'d, so footprint is bounded at `N × from-space bytes`. `0` is clamped to 1 — a depth of 0 would read as ON and protect nothing. **Raise this when a suspected bug does not fault**: a value can cross hundreds of collections between its last valid observation and its stale use (one per back-edge poll under zeal). #7154's `new C(…)` reproducer needs `800` — its constructor crosses 600 polls, so the default 4 misses it silently. ||
143+
| `PERRY_GC_ZEAL=1` | forces an evacuating minor at every **GC safepoint**: `js_gc_loop_safepoint` (loop back-edge) and the outermost microtask-pump safepoint. It bypasses exactly two things — the `GC_SAFEPOINT_PENDING` requirement in `js_gc_loop_safepoint`, and the `gc_budgeted_due_trigger()` "is anything due?" test in `gc_safepoint_moving_minor`. Also makes `gc_force_evacuate_enabled()` true, so survivors actually MOVE. | bypass `gc_safepoint_moving_minor`'s **entry guards**: a safepoint reached mid-allocation (`GC_FLAG_IN_ALLOC`), suppressed (`GC_FLAG_SUPPRESSED`), inside an unsafe FFI zone, under a non-zero `GC_ROOT_LOCK_DEPTH`, or during a budgeted cycle still returns without collecting. Nor does it override an explicit `PERRY_GEN_GC_EVACUATE=0` — that wins, and with it set zeal moves nothing and surfaces nothing. Nor does it emit loop polls — those need the **compile-time** `PERRY_GC_MOVING_LOOP_POLLS=1` (default off since #7161). Zeal on a binary compiled without polls only fires at event-loop boundaries; a compute-only loop never collects. Check `crate::gc::zeal_forced_collections()` is nonzero. There is deliberately **no level 2**: the alloc-point arm forces a conservative stack scan, which makes the copying minor ineligible, so an "every allocation" zeal would run non-moving minors and move nothing. |
144+
| `PERRY_GC_FROMSPACE_SCAN_ABORT=1` | now **implies** `PERRY_GC_FROMSPACE_SCAN=1`. It used to be inert alone (the scan never ran, so nothing aborted, and the run reported success). ||
145+
146+
`PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1` together is the pairing that turns a #7154 bug into an immediate precise fault. Compile *and* run with `PERRY_GC_MOVING_LOOP_POLLS=1` for in-loop coverage.
147+
135148
### GC knob kill-policy (binding)
136149

137150
**Every GC env knob either has a required CI arm exercising its OFF state, or it is deleted after one release of soak.** At most one diagnostic-only knob may exist at a time, and it must be labelled untested.
@@ -237,3 +250,4 @@ Corollary: a *new* gate has never been green, so promoting it to required immedi
237250
- **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.
238251
- **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.
239252
- **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.
253+
- **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.

0 commit comments

Comments
 (0)