Skip to content

gc: enumerate the runtime-side GC-pointer holders, with a gate (#7231) - #7695

Merged
proggeramlug merged 5 commits into
mainfrom
gc/7231-runtime-root-holder-gate
Aug 9, 2026
Merged

gc: enumerate the runtime-side GC-pointer holders, with a gate (#7231)#7695
proggeramlug merged 5 commits into
mainfrom
gc/7231-runtime-root-holder-gate

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Addresses #7231 — the enumeration, with a mechanical gate rather than a list in
a comment.

Why a gate and not a sweep

A runtime-side table holding a GC pointer is a root, and nothing static
could find the class: scripts/gc_root_dominance_check.py reads emitted LLVM
IR, and a thread_local! is not in it. That is not a gap in that tool, it is
outside its subject. #7226, #7239, #7268 and #7274 were each found by hand, and
each one re-derived the same sweep. The issue's own closing line is the point:
a list nobody checks is how this class got here.

What the gate does

scripts/gc_runtime_root_holders.py, in lint (already a required context —
hazard 2 of CLAUDE.md's four is the promotion step people forget, so the gate is
placed where that step does not exist).

  1. Enumerate. Every static-shaped declaration in perry-runtime /
    perry-stdlib 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 — the only way to catch
    CACHED_ENV: Cell<f64>, the highest-impact holder in the issue's report.

  2. Compute coverage, don't trust names. Registered scanners are read from
    every gc_register_*root_scanner*(...) call site; a call graph over both
    crates is walked from them to depth 3; a holder counts as covered when its
    identifier appears in a reachable function defined in the same file as the
    declaration
    .

    Both halves are load-bearing. The graph walk is what finds holders a scanner
    reaches through an accessor rather than by name (cp_live_lock(),
    get_closure_props(), buffer_props()). The same-file requirement is what
    stops REGISTRY, SLOTS, ROOTS, STATES and CACHED — each of which
    names several different holders in this tree — from certifying the wrong
    one.

  3. Require a verdict for the rest, in scripts/gc_runtime_root_holders.json.
    An unclassified holder fails. An entry that no longer matches also fails,
    which is what makes a fix delete its own exemption (the
    gc_root_dominance_allowlist.json rule).

Current state

81 holder declarations scanned, 47 reached by a registered scanner, 30 classified in the inventory (122 registered scanners).

The inventory records 11 covered_elsewhere — the gate's known false positives,
each naming the scanner that actually covers it, so they are documented rather
than silenced — 15 not_a_gc_pointer, 1 test_only, 1 unverified, and two
open_gaps the sweep found that nothing tracked
:

  • json/mod.rs PARSE_KEY_RING — a hot-key mirror of the rooted
    PARSE_KEY_CACHE, holding the same *const StringHeader values. A move
    rewrites one copy and not the other, so a ring hit hands out a pre-move
    address. Narrow: the keys are js_string_from_bytes_longlived, i.e. old-gen,
    so only old-gen defrag can move them — not the copying minor.
  • perf_hooks.rs PERF_ENTRY_KEYS_ARRAY — a nursery keys_array address
    compared by identity at perf_hooks.rs:119 and never rewritten. Stale ⇒ a
    silent slow path, or — the sharper half — a match against a newly-allocated
    array recycled into the recycled address. The neighbouring PERF_ENTRIES is
    covered; this one slot is not.

SHAPE_CACHE is also listed, as an open_gap pointing at #7268 / #7694. If
#7694 lands first, delete that one entry
— the gate will say so, which is the
mechanism working.

Three bugs found while building it — all in the checker, all "green because it matched nothing"

Worth recording, because this is the failure mode the repo keeps paying for:

  1. String literals were not stripped, so the brace counting that delimits a
    function body swallowed scan_raw_json_key_root_mut — and RAW_JSON_KEY,
    which that scanner visits three lines below its own declaration, reported as
    uncovered.
  2. The registration regex captured only the first argument, so
    gc_register_mutable_root_scanner_named("stdlib:worker_threads:workers", scan_worker_roots_mut) registered nothing and six worker_threads holders
    read as uncovered.
  3. Rule B keyed on the file rather than the function and reported 544
    holders, four fifths of them counters and ids — a gate nobody would read.

Each would have produced a plausible-looking inventory full of entries that were
simply wrong.

How it is built to be able to fail

  • --self-test plants a covered holder, one reachable only through an
    accessor, one uncovered per rule, and a same-named decoy in another file, and
    asserts every classification. Then it asserts the verdict machinery can go
    red: an empty inventory must leave every uncovered holder unclassified; an
    entry matching nothing must be stale; an entry for a covered holder must
    be stale.
  • Floors: fewer than MIN_HOLDERS declarations or MIN_REGISTERED scanners
    exits 2, because a broken regex would otherwise report a clean, empty,
    green run — and a broken registration regex would make everything read as
    uncovered, which is noise, not a gate.
  • Live sabotage: planting an unrooted
    Cell<*mut ObjectHeader> into crates/perry-runtime/src/regex.rs fails the
    real scan, naming the file, line, type and rule. Restored; the scan is green
    again.

What it CANNOT see — stated in the docstring, because an unstated limit is how a gate gets trusted past its subject

  • RuntimeState's fields. state.rs absorbed roughly a dozen former
    thread_local!s; they are struct fields reached through state(), so no
    declaration-site scan sees them. All are covered today. A field-count floor
    makes the struct growing at least loud.
  • Integer holders whose own file never allocates.
  • Cross-file scanners — they read as uncovered and need a
    covered_elsewhere entry naming the scanner.
  • Whether a "covered" holder is covered CORRECTLY. A scanner may visit three
    of a table's four slots — the shape fix(gc): root the ten unrooted runtime-side caches, and the scanner that walked 1 of 3 sibling slots (#7231) #7239 found in
    scan_parent_port_event_roots_mut. This gate bounds the population; it does
    not audit semantics.

CLAUDE.md's runtime-cache bullet gains one clause pointing at the gate. No
version bump.

Summary by CodeRabbit

  • New Features

    • Added an automated audit for runtime garbage-collection pointer holders.
    • Added an inventory documenting holder coverage, classifications, and known gaps.
    • Added checks for missing classifications, stale entries, and unexpected runtime-state changes.
  • Bug Fixes

    • Prevented lint from passing when runtime GC-pointer holders lack verified custody coverage.
  • Documentation

    • Documented the GC-rooting inventory and enforcement process.
    • Added a changelog entry describing coverage, limitations, and validation checks.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 792eb9b2-6819-465a-b2e5-0edfe93a5ae6

📥 Commits

Reviewing files that changed from the base of the PR and between 339db36 and 377d33c.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (3)
  • CLAUDE.md
  • Cargo.toml
  • scripts/gc_runtime_root_holders.json
🚧 Files skipped from review as they are similar to previous changes (2)
  • scripts/gc_runtime_root_holders.json
  • CLAUDE.md

📝 Walkthrough

Walkthrough

Added a runtime GC-pointer holder audit. The audit discovers holders, evaluates scanner coverage, reconciles classifications, runs self-tests, and fails lint for missing or stale inventory entries.

Changes

Runtime GC custody audit

Layer / File(s) Summary
Holder discovery and scanner coverage
scripts/gc_runtime_root_holders.py
Discovers static and thread-local GC-pointer holders, extracts registered scanners, checks bounded call-graph coverage, and validates extraction thresholds.
Inventory reconciliation and reporting
scripts/gc_runtime_root_holders.py, scripts/gc_runtime_root_holders.json
Validates holder classifications, detects missing and stale entries, reports audit results, and supports listing and quiet modes.
Self-tests and lint integration
scripts/gc_runtime_root_holders.py, .github/workflows/test.yml, CLAUDE.md, changelog.d/7695-runtime-root-holder-gate.md, Cargo.toml
Adds synthetic and repository self-tests, runs the audit in lint, documents the inventory gate, and updates the workspace version to 0.5.1402.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Lint
  participant gc_runtime_root_holders.py
  participant RuntimeSources
  participant HolderInventory

  Lint->>gc_runtime_root_holders.py: Run self-tests and audit
  gc_runtime_root_holders.py->>RuntimeSources: Discover holders and scanners
  RuntimeSources-->>gc_runtime_root_holders.py: Return declarations and scanner reachability
  gc_runtime_root_holders.py->>HolderInventory: Load and reconcile classifications
  HolderInventory-->>gc_runtime_root_holders.py: Return missing and stale entries
  gc_runtime_root_holders.py-->>Lint: Report success or fail lint
Loading

Possibly related issues

Possibly related PRs

  • PerryTS/perry#7226 — Introduces runtime caches and scanner registrations tracked by this inventory.
  • PerryTS/perry#7240 — Adds runtime GC-pointer storage and scanners covered by this audit.
  • PerryTS/perry#7281 — Adds runtime-cache GC-rooting guidance extended by this lint-enforced inventory.

Suggested reviewers: jdalton

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed and relevant, but it omits required template sections and conflicts with the recorded version and CLAUDE.md changes. Add the required Changes, Related issue, Test plan, and Checklist sections, and correct the description or remove the prohibited metadata changes.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary change: adding a gate to enumerate runtime-side GC-pointer holders.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch gc/7231-runtime-root-holder-gate

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
CLAUDE.md (1)

254-254: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Keep this guidance concise.

This bullet includes implementation rules, current inventory counts, historical checker defects, and test details. Keep a short instruction to run scripts/gc_runtime_root_holders.py and maintain its inventory. Keep the detailed history in changelog.d/7695-runtime-root-holder-gate.md.

As per coding guidelines, "CLAUDE.md" must stay concise and detailed change history belongs in changelog.d/ fragments.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CLAUDE.md` at line 254, Condense the runtime-side cache guidance in CLAUDE.md
to a brief instruction to run scripts/gc_runtime_root_holders.py and keep its
inventory current, while retaining the essential requirement to register new
heap-pointer caches with gc_register_mutable_root_scanner. Move implementation
rules, checker history, counts, and test details into
changelog.d/7695-runtime-root-holder-gate.md.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/gc_runtime_root_holders.py`:
- Around line 350-371: Update apply_inventory to validate each matching
inventory entry before marking a holder as covered: require verdict and why,
accept only recognized verdicts, reject unverified, and resolve
covered_elsewhere through the registered scanner definitions. Preserve
stale-entry detection and ensure every relevant holder has scanner coverage or
an explicit valid verdict. Extend the --self-test fixtures with incomplete,
unknown, unverified, and unresolved-scanner inventory entries.
- Around line 282-319: Update the call-graph construction around REGISTER_CALL,
function_bodies, and reachable_text_by_file to preserve module-qualified
function identities instead of collapsing names with rsplit. Store bodies and
registered entries under qualified keys, resolve each IDENT call relative to the
defining module, and only mark the matching function reachable. Add a self-test
covering a registered scanner and an unrelated same-named function that
references an uncovered holder, ensuring the holder remains uncovered.

---

Nitpick comments:
In `@CLAUDE.md`:
- Line 254: Condense the runtime-side cache guidance in CLAUDE.md to a brief
instruction to run scripts/gc_runtime_root_holders.py and keep its inventory
current, while retaining the essential requirement to register new heap-pointer
caches with gc_register_mutable_root_scanner. Move implementation rules, checker
history, counts, and test details into
changelog.d/7695-runtime-root-holder-gate.md.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1b264112-ef4d-46b1-94d4-3f5ea20acc81

📥 Commits

Reviewing files that changed from the base of the PR and between d1aa968 and b446729.

📒 Files selected for processing (5)
  • .github/workflows/test.yml
  • CLAUDE.md
  • changelog.d/7695-runtime-root-holder-gate.md
  • scripts/gc_runtime_root_holders.json
  • scripts/gc_runtime_root_holders.py

Comment thread scripts/gc_runtime_root_holders.py Outdated
Comment thread scripts/gc_runtime_root_holders.py
Ralph Küpper added 5 commits August 9, 2026 15:50
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.
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.
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
@proggeramlug
proggeramlug force-pushed the gc/7231-runtime-root-holder-gate branch from 339db36 to 377d33c Compare August 9, 2026 13:52
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit — merging as v0.5.1402, with the stale entry deleted

The gate predicted its own first failure and was right. Its SHAPE_CACHE entry said, verbatim:

Fixed in PR #7694DELETE THIS ENTRY when that lands, the gate will tell you.

#7694 landed, and both the gate and its self-test went red on exactly that entry. I deleted it: holders 30 → 29, both green. That is the shrink-only property working on its first real test rather than being asserted.

80 holder declarations scanned, 47 reached by a registered scanner, 29 classified, 122 registered scanners. The thing that makes this a gate rather than a list is that coverage is computed — registered scanners → call-graph walk → holder named in a reachable function in the same file — instead of declared.

Sabotage-verified independently: I planted static AUDIT_PLANT_HOLDER: Mutex<Option<*mut ArrayHeader>> into json/mod.rs and it exits 1 naming the holder, its line, and the rule that caught it ([rule A]). Wired into lint (now 26 commands, up from 24).

The three checker bugs you found in it are the report's best content

All three were "green because it matched nothing", which is the failure this whole campaign is about, appearing inside the tool built to detect it:

  • unstripped string literals made brace-counting swallow scan_raw_json_key_root_mut, so RAW_JSON_KEY — visited three lines below its own declaration — read as uncovered;
  • the registration regex read only the first argument, so ..._named("…", scanner) registered nothing;
  • rule B keyed on file rather than function and reported 544 holders.

And CodeRabbit's two more are the same shape: bare-name module collisions could certify the wrong module's holder (realscan_tls_roots_mut exists in both crates), and the inventory accepted entries with no verdict. A coverage checker that over-reports coverage is worse than none, and five separate ways to do it were found before this shipped.

The two previously-untracked open_gaps it surfaced — PARSE_KEY_RING (an unrewritten mirror of the rooted PARSE_KEY_CACHE) and PERF_ENTRY_KEYS_ARRAY — are the payoff.

Gates: 26/26, perry-runtime --lib 1944 passed.

@proggeramlug
proggeramlug merged commit 61c263f into main Aug 9, 2026
11 of 14 checks passed
@proggeramlug
proggeramlug deleted the gc/7231-runtime-root-holder-gate branch August 9, 2026 13:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant