Skip to content

Commit 61c263f

Browse files
proggeramlugRalph Küpper
andauthored
gc: enumerate the runtime-side GC-pointer holders, with a gate (#7231) (#7695)
* gc: enumerate the runtime-side GC-pointer holders, with a gate (#7231) A `thread_local!` or `static` in perry-runtime / perry-stdlib that stores a pointer into the GC heap IS a GC root, and the collector only knows that if something registers it. Nothing static could find the class: the one static checker this repo has reads emitted LLVM IR, and a runtime table is not in it. #7226, #7239, #7268 and #7274 were all found by hand, each re-deriving the same sweep. This is that sweep as something that can fail. `scripts/gc_runtime_root_holders.py`: 1. Enumerates every static-shaped declaration in the two crates whose type can hold a GC pointer. Rule A: the type names a heap header or `JSValue`. Rule B: an integer/`f64` cell that some function in its own file both names and allocates in — which is the only way to catch `CACHED_ENV: Cell<f64>`, the highest-impact holder in the issue's original report. 2. COMPUTES coverage instead of trusting names. Registered scanners are read from every `gc_register_*root_scanner*(...)` call; a call graph over both crates is walked from them, and a holder counts as covered when its name appears in a reachable function DEFINED IN THE SAME FILE. `REGISTRY`, `SLOTS`, `ROOTS`, `STATES` and `CACHED` each name several different holders here, so a name-only match certifies the wrong one; the graph walk is what finds holders a scanner reaches through an accessor (`cp_live_lock()`, `get_closure_props()`, `buffer_props()`). 3. Requires a written verdict for the rest, in `scripts/gc_runtime_root_holders.json`. An unclassified holder fails, and so does an entry that no longer matches — which is what makes a fix delete its own exemption. Current state: 81 holders, 47 reached by a registered scanner, 30 classified. The inventory records 11 `covered_elsewhere` (the gate's known false positives, each naming the scanner that covers it), 15 `not_a_gc_pointer`, 1 `test_only`, 1 `unverified`, and two `open_gap`s the sweep found and nothing tracked: * `json/mod.rs` `PARSE_KEY_RING` — a hot-key mirror of the ROOTED `PARSE_KEY_CACHE`. A move rewrites one copy and not the other. Narrow: the keys are longlived/old-gen, so only old-gen defrag can move them. * `perf_hooks.rs` `PERF_ENTRY_KEYS_ARRAY` — a nursery `keys_array` address compared by identity and never rewritten. Stale ⇒ a silent slow path, or a match against a newly-allocated array recycled into the address. Three bugs found while building it, all in the checker rather than the tree, and all of the "green because it matched nothing" shape: * string literals were not stripped, so brace counting swallowed `scan_raw_json_key_root_mut` and reported `RAW_JSON_KEY` — which that scanner visits three lines below its declaration — as uncovered; * the registration regex captured only the FIRST argument, so `gc_register_mutable_root_scanner_named("name", scanner)` registered nothing and six worker_threads holders read as uncovered; * rule B keyed on the file rather than the function and reported 544 holders, four fifths of them counters — a gate nobody would read. `--self-test` plants a covered holder, one reached only through an accessor, one uncovered per rule, and a same-named decoy in another file, and asserts each classification; then asserts the verdict machinery can go red (empty inventory ⇒ everything unclassified; an entry matching nothing ⇒ stale; an entry for a COVERED holder ⇒ stale). Live sabotage: planting an unrooted `Cell<*mut ObjectHeader>` into `regex.rs` fails the real scan. The docstring names what the gate CANNOT see — `RuntimeState`'s fields (not declarations; a field-count floor makes growth loud), integer holders whose file never allocates, cross-file scanners, and whether a "covered" holder is covered CORRECTLY (the #7239 three-of-four-slots shape). It bounds the population; it does not audit semantics. * changelog: 7695 runtime root holder gate * gc: two ways the holder gate could not fail (#7231, CodeRabbit review) Both are the hazard this script exists to catch, in the script itself. 1. Bare-name reachability could certify the WRONG module's holder. `bodies` is keyed on the bare function name, so two modules defining `scan_roots_mut` share a key — and registering one made the other's body reachable, marking a holder in that module covered when nothing scans it. Not hypothetical: `scan_tls_roots_mut` is defined in BOTH perry-runtime and perry-stdlib, and `worker_threads` has several `scan_*_roots_mut` siblings. The registration text carries the module path (`crate::json::raw_json::scan_raw_json_key_root_mut`), so the ROOT set is now resolved to a defining file; a name that resolves to several definitions must match the path. Deeper hops stay bare-name — nothing in the text says which module a call resolved to — and the docstring's "cannot see" section now says that out loud rather than leaving it as an assumption. Self-test: two modules define `scan_dup_roots_mut`, only one is registered, and the unregistered module's holder must read UNCOVERED. Sabotage-verified — reverting to bare-name reachability fails that case and only that case. 2. `apply_inventory` accepted any object carrying a matching (file, name). No `verdict`, an invented `verdict`, an empty `why`, a `covered_elsewhere` naming no scanner, an `open_gap` citing no issue — each silenced a holder, and a suppression whose justification cannot be read or checked is a mute button rather than a decision record. `inventory_problems` now validates the vocabulary, requires a `why` long enough to be a reason, requires `scanner` on `covered_elsewhere` and `issue` on `open_gap`, rejects duplicates, and caps `unverified` at 2 so the one verdict that classifies nothing cannot quietly become the whole inventory. The self-test plants one malformed entry per rule and requires each to be rejected. It found seven of my own entries with reasons too thin to check ("Monotonic counter.", "Same six-slot loop.") on its first run. Those are rewritten. Gate output is unchanged: 81 holders, 47 reached by a registered scanner, 30 classified. * gc: delete the SHAPE_CACHE open_gap entry — #7694 fixed it The gate said so itself: an entry that no longer matches an open gap is stale and fails the build, which is what makes the inventory shrink-only. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix * chore: bump version to 0.5.1402 Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --------- Co-authored-by: Ralph Küpper <ralph@skelpo.com>
1 parent 69c5435 commit 61c263f

7 files changed

Lines changed: 1215 additions & 79 deletions

File tree

.github/workflows/test.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,23 @@ jobs:
217217
python3 scripts/gc_pin_sites.py --self-test
218218
python3 scripts/gc_pin_sites.py
219219
220+
# #7231. A runtime-side table holding a GC pointer IS a root, and nothing
221+
# static could see that class before: gc_root_dominance_check.py reads
222+
# emitted LLVM IR and a thread_local is not in it. #7226, #7239, #7268 and
223+
# #7274 were all found by hand, each one re-deriving the same enumeration.
224+
# This is that enumeration with a verdict required per holder — an
225+
# unclassified holder fails, and so does an inventory entry that no longer
226+
# matches (which is what makes a fix delete its own exemption).
227+
#
228+
# Cheap and build-free, so it belongs in `lint`, which IS a required
229+
# context — hazard 2 of CLAUDE.md's four is the step people forget, so
230+
# this gate is placed where that step does not exist.
231+
- name: Runtime GC-pointer holder custody audit
232+
if: ${{ !cancelled() }}
233+
run: |
234+
python3 scripts/gc_runtime_root_holders.py --self-test
235+
python3 scripts/gc_runtime_root_holders.py
236+
220237
# #7341 layer 3. A RuntimeHandleScope gives an object liveness; it does
221238
# nothing for a raw pointer already read out of the slot. Every rooting bug
222239
# in the quarantine sweep had rooting ALREADY -- what was missing was

CLAUDE.md

Lines changed: 2 additions & 2 deletions
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.1401
11+
**Current Version:** 0.5.1402
1212

1313

1414
## TypeScript Parity Status
@@ -251,4 +251,4 @@ Corollary: a *new* gate has never been green, so promoting it to required immedi
251251
- **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.
252252
- **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.
253253
- **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 where a back-edge poll is emitted, which is again the default (`PERRY_GC_MOVING_LOOP_POLLS`, kill switch `=0`) in every loop that can allocate — so a green default run does say something about this class, and `=0` is what makes it dark. **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), and that list is currently **empty** — every new hit is a red build.
254-
- **A runtime-side cache of a raw heap pointer is a GC root, and the static checker cannot see it.** `scripts/gc_root_dominance_check.py` reads emitted LLVM IR, so a thread-local or side table holding a `*mut` into the heap is structurally invisible to it — the runtime instruments above are the only detector, and they go at the workload *before* you grind the static checker's tail. Two tells. An unrooted *register* goes bad only when a collection lands in its window, so it is intermittent; an unrooted *cache* goes bad at collection #0 and stays bad, so **a perfectly reproducible GC bug means a table, not a register**. And the registry is `gc_register_mutable_root_scanner` in `gc/mod.rs` (~55 entries): when you add a cache of a heap pointer, add it there in the same commit. Worked examples: `changelog.d/7219-registry-gc-unrooted-caches.md`, `changelog.d/7239-gc-unrooted-runtime-caches.md`.
254+
- **A runtime-side cache of a raw heap pointer is a GC root, and the static checker cannot see it.** `scripts/gc_root_dominance_check.py` reads emitted LLVM IR, so a thread-local or side table holding a `*mut` into the heap is structurally invisible to it — the runtime instruments above are the only detector, and they go at the workload *before* you grind the static checker's tail. Two tells. An unrooted *register* goes bad only when a collection lands in its window, so it is intermittent; an unrooted *cache* goes bad at collection #0 and stays bad, so **a perfectly reproducible GC bug means a table, not a register**. And the registry is `gc_register_mutable_root_scanner` in `gc/mod.rs` (~55 entries): when you add a cache of a heap pointer, add it there in the same commit. **The population is now enumerated and gated**: `scripts/gc_runtime_root_holders.py` (in `lint`) lists every `static`/`thread_local!` in `perry-runtime`/`perry-stdlib` whose type can hold a heap pointer, computes which ones a registered scanner actually reaches (call-graph walk, so accessors like `cp_live_lock()` count), and requires a written verdict in `scripts/gc_runtime_root_holders.json` for the rest — a new unclassified holder fails, and so does a stale entry, so a fix must delete its own exemption. Worked examples: `changelog.d/7219-registry-gc-unrooted-caches.md`, `changelog.d/7239-gc-unrooted-runtime-caches.md`.

0 commit comments

Comments
 (0)