Skip to content

fix(gc): refuse a forwarding walk out of, or into, a non-object, and give every rekeyed table a death story (#8174) - #8196

Merged
proggeramlug merged 4 commits into
mainfrom
gc/8174-rekeyed-table-registry
Aug 16, 2026
Merged

fix(gc): refuse a forwarding walk out of, or into, a non-object, and give every rekeyed table a death story (#8174)#8196
proggeramlug merged 4 commits into
mainfrom
gc/8174-rekeyed-table-registry

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Closes #8174, #8190, #8191, #8192, #8193, #8194, #8195.

What was wrong

GC_FLAG_FORWARDED means "the first payload word is where this object moved to". Both forwarding walkers — CopyingNurseryCollector::rewrite_raw_addr and gc::verify::try_rewrite_raw_addr — trusted that byte for any address in a known heap region, and trusted whatever word they found behind it.

For a slot the collector already proved is a live reference, both are safe. For a metadata key neither is. RuntimeRootVisitor::visit_metadata_usize_slot and its siblings rewrite a recorded raw heap address if a moving collection forwarded it, and deliberately do not mark it — the key is a side table's key, not a reference the program can reach, so rooting it would leak. The price is that the key's object can die and the arena can recycle the address under it.

#8040 is what that looks like, instrumented: recycled payload bytes at a dead FUNCTION_CLASS_IDS key presenting gc_flags = 0x86 (GC_FLAG_FORWARDED set by coincidence), obj_type = 104 — a type id no GcTypeInfo entry exists for — and a "forwarding pointer" that was really a NaN-boxed value (0x7FFF…). The walk followed it, could not classify the next hop, stopped and returned it, and visit_metadata_nanbox_key masked it to 48 bits into a live, unrelated survivor. #8168 removed that one dead key. This closes the following, and then removes the remaining dead keys.

1. Two discriminators, one at each end of the hop (gc/forwarding.rs, new)

  • forwarding_walk_header refuses to read a forwarding pointer out of an address that does not read back as a real arena object header (plausible_gc_header: registered obj_type, sane size, GC_FLAG_ARENA). [Next.js/dylib] Full production App Route compatibility tracker #8040's bytes fail on obj_type = 104. Every real forwarding source passes — set_forwarding_address overwrites one payload word and ORs one flag bit, and all four production installers (copying::move_young, promotion, gc::oldgen defrag, array::push_pop's growth stub) operate on arena objects.

    This is not the self.ptrs.classify() gate that rewrite_raw_addr's own doc records as having un-rekeyed legitimate shapes.entries keys and turned the verifier red. That one additionally narrows on SPACE and resolves the survivor thread-locals; the header test carries none of that. plausible_gc_header is already the acceptance test CopyingPointerSet::classify_arena applies to every arena pointer the collector classifies, so nothing it rejects was ever an object the collector could have moved.

  • accept_forwarding_target refuses a target that is not the start of a heap object, so a bogus word can no longer become the answer by virtue of the walk merely stopping at it. Off-arena it still accepts a malloc'd array-growth target, but only above the handle band and below HEAP_MAX — which is what the 0x7FFF… word fails.

Both are applied to the verifier too. try_rewrite_raw_addr is what RuntimeRootVisitMode::Verify runs, and it panics whenever it can rewrite a slot the rewrite pass left alone; tightening one walker alone would have converted a silent corruption into a PERRY_GC_VERIFY_EVACUATION abort blaming an innocent scanner.

Refusals are counted and, under PERRY_GC_DIAG=1, reported only when non-zero as

[gc-forwarding] copying_minor refused_sources=3 refused_targets=0 total_sources=8 total_targets=0 by_walk=[crate::object::shapes::scan_shape_table_rekey_mut=3]

The by_walk breakdown comes from pin::CopyingWalkPhaseGuard, which already names the scanner around every rewrite-pass walk. The aggregate says a stale key reached a rewrite walk; the breakdown says whose, which is the whole distance between "there is a bug of the #8040 shape somewhere" and a fix — #8040 itself took days to attribute.

2. The structural half

gc::dead_owner is the real fix for this class: drop the entry before its dead key can be walked. Its fan-out covered a dozen tables, #8168 made it thirteen, and nothing checked the list was complete.

  • DEAD_KEY_PRUNES (gc/dead_owner.rs) is now the registry fan_out iterates: 19 entries, each naming the tables it prunes and which of the pass's three deadness predicates it takes.
  • scripts/gc_rekeyed_key_tables.py, wired into lint (a required context), enumerates all 37 visit_metadata_* sites in perry-runtime/perry-stdlib and requires a written verdict for each in scripts/gc_rekeyed_key_tables.json.

What the gate rejects

shape result
a new rekey site with no verdict exit 1
a manifest entry matching no site (stale exemption) exit 1
dead_owner:<fn> naming a prune not in DEAD_KEY_PRUNES exit 1
self_pruned:<fn> naming a function that does not exist exit 1
a verdict with no reasoning exit 1
any open_gap verdict at all (MAX_OPEN_GAPS = 0) exit 1
the site scan or the registry parse matching too little exit 2 — a broken regex must not read as a clean, empty, green run

--self-test plants twelve shapes (every row above, plus an open_gap without an issue number, plus a doc comment that must not count as a site, plus a correctly-classified tree that must pass) and requires the checker to adjudicate each. It runs in the same lint step, before the real scan.

3. What the audit found — all six fixed, not exempted

The gate's first run turned up six more rekeyed tables with no death story. Rather than declare them, they are fixed, so the manifest lands with zero gaps and MAX_OPEN_GAPS = 0.

table fix issue
CONSOLE_INSTANCES prune_dead_console_instance_owners #8190
BOXED_PRIMITIVE_PAYLOADS prune_dead_boxed_primitive_payload_owners #8191
TRANSITION_CACHE_GLOBAL (prev_keys, key_ptr) prune_dead_transition_cache_entries #8192
ASYNC_STEP_GUARD.last_closure field deleted #8193
REFLECT_METADATA.target_bits prune_dead_reflect_metadata_targets #8194
SYMBOL_ACCESSOR_PROPERTIES (owner half) folded into prune_dead_symbol_property_owners #8195

#8193 is not a prune. AsyncStepGuard::last_closure held the address of the closure that took the last erroring async step, for a same-closure check that was deleted when #712/#921/#922 showed a runaway loop alternates between two closures. Nothing has read it since. It was not inert, though — it was a raw heap address the promise scanner rekeyed without marking, and nothing pruned it. Writing a prune to maintain state nobody reads is its own dead code, so the field goes, and with it the PROMISE_SCAN_ASYNC_STEP_GUARD budgeted phase whose only slot it was.

#8195 is not a new prune either. The accessor table shares its owner key with SYMBOL_PROPERTIES and SYMBOL_PROPERTY_ATTRS, both pruned since the 2026-07-09 audit, and was simply left out. It now takes the same pass's memoized owner verdict, so all three agree about every owner. That also closes a leak — a dead owner's accessor closures were immortal.

Tests

gc/tests/forwarding_target_validation.rs, 5 cases. The two sabotage cases plant #8040's shape verbatim and assert the premise first — the address classifies as heap, the byte carries GC_FLAG_FORWARDED, and 104 is not a registered type — so a green run says the discriminator works rather than that nothing was tried. The premise case asserts the opposite direction: a genuine evacuation still rewrites and neither refusal counter moves, which is the property the rejected classify()-based tightening broke. The registry case asserts DEAD_KEY_PRUNES has not shrunk, its labels are unique, and #8168's FUNCTION_CLASS_IDS entry is still present with its GC_TYPE_CLOSURE narrowing.

gc/tests/dead_owner_side_tables.rs, 10 new cases. Each new prune gets a pair: the prune fires (dead owner, one collection, the table observably shrinks) and its inverse (a rooted owner's entry survives — a prune that drops live entries is worse than the stale key it removes). The transition-cache case allocates its rooted next_keys in OLD-GEN on purpose: a reachable neighbour in the dead array's own nursery block would force-mark it (#7975) and the prune would correctly decline, which would have read as a failure of the prune.

Local validation

End-to-end, under the moving collector. A churn fixture (60 rounds × 120 objects, dropping all but a 3-round window) that exercises exactly the surfaces this touches — varied shapes, symbol-keyed properties, accessor descriptors, Map/Set + iterators, synthetic classes via plain-function prototypes, closure dynamic props, proxies with a get trap, Reflect.get, promises — run under

PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_SCHEDULE_SEED=<8174|8040|1> PERRY_GC_FORCE_EVACUATE=1 \
PERRY_GC_VERIFY_EVACUATION=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=64 PERRY_GC_DIAG=1
seed copying minors copied_objects retired_set verify panics [gc-forwarding] oracle
8174 3,605 296,537 #3604 0 0 byte-identical to node
8040 5,060 411,522 #5319 0 0 byte-identical to node
1 3,605 296,537 #3604 0 0 byte-identical to node

scripts/gc_evacuation_liveness_assert.py passes on all three, so the subject was live rather than "nothing threw". Zero [gc-forwarding] lines is the load-bearing number: on a healthy workload that hammers every rekeyed surface, the new discriminators refuse nothing, i.e. no legitimate rewrite was lost.

Suites (against bfb0707be):

  • cargo test -p perry-runtime --lib2514 passed / 0 failed / 4 ignored
  • cargo test -p perry --bin perry987 passed / 0 failed
  • cargo test -p perry-codegen --no-fail-fast1483 passed / 9 failed, the same 9 by name as main (this crate does not depend on perry-runtime)

Gates: cargo fmt --all -- --check, check_file_size.sh, gc_runtime_root_holders.py, gc_store_site_inventory.py, gc_pin_sites.py (+ --self-test), gc_gate_wiring_check.py, raw_handle_debt.py (990, unchanged), shape_descriptor_census.py, addr_class_inventory.py, check_gc_env_knobs.py, check_test_registration.py, class_id_collisions.py, workspace_architecture.py --check, check_gc_doc_claims.py, check_locale_independent_io.py, and the new gc_rekeyed_key_tables.py (+ --self-test) — all clean. check_thread_locals.py is red on main for three files this branch does not touch (dyn_eval/interp.rs, module_require.rs, node_vm.rs); the new counters use crate::perry_thread_local! and add no fourth.

scripts/gc_pin_sites.py gains one allowlist entry: the planted gc_flags = 0x86 carries bit 2, but nothing is being pinned — the address is payload interior of a live allocation with no object at it, and rewriting the byte as named flags would misreport what #8040 actually observed.

Not fixed here

#8163 is unaffected and stays open. Retested on 53e8a21e3 before this branch: the production Next App Route fixture's forced-evacuation arm still fails with TypeError: value is not a function (243 copying minors, 117,579 objects copied, 0 verify panics, normal arm green) — details on the issue. This branch narrows what a stale key can be followed into; the holder losing that closure is a different defect.

https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj

Summary by CodeRabbit

  • Bug Fixes

    • Improved garbage collection safety by rejecting invalid forwarding pointers and targets.
    • Removed stale entries from runtime metadata tables when their owning objects are collected.
    • Preserved valid rooted entries during cleanup.
    • Simplified asynchronous error handling by removing obsolete closure tracking.
  • Tests

    • Added comprehensive coverage for forwarding validation and stale metadata cleanup.
  • Chores

    • Added automated audits to verify garbage-collection cleanup coverage.

@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d78b28be-0404-48e4-b929-7ceeca7de951

📥 Commits

Reviewing files that changed from the base of the PR and between 6418563 and d0dd732.

📒 Files selected for processing (26)
  • .github/workflows/test.yml
  • changelog.d/8196-rekeyed-side-table-custody.md
  • crates/perry-runtime/src/builtins/console.rs
  • crates/perry-runtime/src/builtins/formatting.rs
  • crates/perry-runtime/src/builtins/formatting/boxed_primitives.rs
  • crates/perry-runtime/src/builtins/mod.rs
  • crates/perry-runtime/src/gc/copying.rs
  • crates/perry-runtime/src/gc/dead_owner.rs
  • crates/perry-runtime/src/gc/forwarding.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/pin.rs
  • crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs
  • crates/perry-runtime/src/gc/tests/forwarding_target_validation.rs
  • crates/perry-runtime/src/gc/tests/mod.rs
  • crates/perry-runtime/src/gc/tests/runtime_roots/callback_scanners.rs
  • crates/perry-runtime/src/gc/verify.rs
  • crates/perry-runtime/src/object/mod.rs
  • crates/perry-runtime/src/promise/microtasks.rs
  • crates/perry-runtime/src/promise/mod.rs
  • crates/perry-runtime/src/promise/scanners.rs
  • crates/perry-runtime/src/proxy.rs
  • crates/perry-runtime/src/symbol.rs
  • crates/perry-runtime/src/symbol/accessors.rs
  • scripts/gc_pin_sites.py
  • scripts/gc_rekeyed_key_tables.json
  • scripts/gc_rekeyed_key_tables.py

📝 Walkthrough

Walkthrough

This change validates GC forwarding sources and targets, adds registry-driven pruning for rekeyed side tables, introduces an audit manifest and required lint check, removes obsolete async-step closure state, and expands regression coverage.

Changes

GC forwarding validation and side-table custody

Layer / File(s) Summary
Forwarding source and target validation
crates/perry-runtime/src/gc/{forwarding.rs,copying.rs,verify.rs,mod.rs,pin.rs}, crates/perry-runtime/src/gc/tests/forwarding_target_validation.rs, scripts/gc_pin_sites.py
Forwarding walks validate plausible source headers and object-start targets. Invalid addresses are rejected and counted. Copying completion reports refusal diagnostics.
Registered dead-owner pruning
crates/perry-runtime/src/gc/dead_owner.rs, crates/perry-runtime/src/builtins/*, crates/perry-runtime/src/object/mod.rs, crates/perry-runtime/src/proxy.rs, crates/perry-runtime/src/symbol*, crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs
The dead-key registry invokes pruning for console instances, boxed primitive payloads, transition-cache entries, Reflect metadata, symbol accessors, and existing registered tables. Tests cover removal of dead entries and retention of rooted entries.
Rekeyed-table custody audit
scripts/gc_rekeyed_key_tables.{py,json}, .github/workflows/test.yml, changelog.d/8196-rekeyed-side-table-custody.md
The checker scans metadata rekey sites, validates manifest and registry coverage, runs self-tests, and executes as a required lint step.
Async-step closure state removal
crates/perry-runtime/src/promise/{microtasks.rs,mod.rs,scanners.rs}, crates/perry-runtime/src/gc/tests/runtime_roots/callback_scanners.rs
The unused closure identity field, root scanning phase, snapshot field, and related cleanup are removed. Error counting remains based on consecutive error dispatches.

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

Sequence Diagram(s)

sequenceDiagram
  participant GCCollector
  participant ForwardingValidator
  participant DeadOwnerPruner
  participant RekeyAudit
  GCCollector->>ForwardingValidator: validate forwarding source and target
  ForwardingValidator-->>GCCollector: rewrite address or refuse forwarding
  GCCollector->>DeadOwnerPruner: prune registered dead-key side tables
  DeadOwnerPruner-->>GCCollector: retain live entries
  RekeyAudit->>RekeyAudit: scan rekey sites and validate manifest
  RekeyAudit-->>GCCollector: return audit status
Loading

Possibly related issues

Possibly related PRs

  • PerryTS/perry#8041 — Also modifies GC forwarding and address validation to reject invalid forwarding data.
  • PerryTS/perry#8168 — Provides the existing FUNCTION_CLASS_IDS dead-key pruning extended by this registry and audit.
  • PerryTS/perry#7289 — Documents the runtime-table blind spot addressed by the rekeyed side-table audit.

Suggested reviewers: thehypnoo

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch gc/8174-rekeyed-table-registry

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.

Ralph Küpper added 4 commits August 16, 2026 11:28
`GC_FLAG_FORWARDED` means "the first payload word is where this object
moved to". Both forwarding walkers — `rewrite_raw_addr` and
`verify::try_rewrite_raw_addr` — trusted that byte for ANY address in a
known heap region, and trusted whatever word they found behind it.

That is safe for a slot the collector already proved is a live
reference. It is not safe for a METADATA KEY: `visit_metadata_*`
rewrites a recorded heap address if it moved and deliberately does NOT
mark it, so the object can die and the arena can recycle the address.

#8040, instrumented: recycled bytes at a dead FUNCTION_CLASS_IDS key
presented `gc_flags = 0x86` (FORWARDED set by coincidence) and
`obj_type = 104`, a type id no `GcTypeInfo` entry exists for. Its
"forwarding pointer" was a NaN-boxed value; the walk could not classify
the next hop, stopped, and RETURNED it — and the caller masked it to 48
bits into a live, unrelated survivor. #8168 removed that dead key; this
closes the following.

Two discriminators, one at each end of the hop (`gc/forwarding.rs`):

* `forwarding_walk_header` refuses to read a forwarding pointer out of
  an address that does not read back as a real arena object header.
  This is NOT the `self.ptrs.classify()` gate `rewrite_raw_addr`
  documents as having un-rekeyed legitimate `shapes.entries` keys —
  that one narrows on SPACE as well; the header test does not.
* `accept_forwarding_target` refuses a target that is not the start of
  a heap object, so a bogus word cannot become the answer by virtue of
  the walk merely stopping at it.

Both apply to the verifier too: it panics whenever it can rewrite a
slot the rewrite pass left alone, so tightening one walker alone would
have turned a silent corruption into an abort blaming an innocent
scanner. Refusals are counted and reported under `PERRY_GC_DIAG=1` only
when non-zero.

The structural half. `gc::dead_owner` is the real fix for this class —
drop the entry before its dead key can be walked — and its fan-out was
a hand-written list nothing checked. `DEAD_KEY_PRUNES` is now the
registry `fan_out` iterates, and `scripts/gc_rekeyed_key_tables.py`
(wired into `lint`) requires a written verdict for all 38
`visit_metadata_*` sites: a `dead_owner:` verdict must name a
registered prune, a new site fails, and an exemption matching nothing
fails too. Six tables have no prune and no rooting; they are declared
and capped (#8190-#8195) so the count can only go down.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>

Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj
The aggregate counter says a stale key reached a rewrite walk. It does
not say WHICH TABLE, which is the whole distance between "there is a
bug of the #8040 shape somewhere" and a fix — #8040 itself took days to
attribute. `pin::CopyingWalkPhaseGuard` already names the scanner around
every rewrite-pass walk; tally refusals against it and print the
breakdown in the `[gc-forwarding]` line.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>

Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj
)

The #8174 registry gate's first run enumerated every `visit_metadata_*`
site in the tree and asked each the same question #8168 had to answer by
hand: when this key's object dies, what removes the entry? Six tables
had no answer. That is live #8040 exposure in six places — a rekeyed
table's dead key is not a leak, it is an address the arena recycles and
the next rewrite pass reads as a GcHeader.

Fixed rather than exempted, so the manifest lands with ZERO declared
gaps and MAX_OPEN_GAPS = 0:

  CONSOLE_INSTANCES               prune_dead_console_instance_owners      #8190
  BOXED_PRIMITIVE_PAYLOADS        prune_dead_boxed_primitive_payload_...  #8191
  TRANSITION_CACHE_GLOBAL         prune_dead_transition_cache_entries     #8192
  ASYNC_STEP_GUARD.last_closure   field DELETED                           #8193
  REFLECT_METADATA.target_bits    prune_dead_reflect_metadata_targets     #8194
  SYMBOL_ACCESSOR_PROPERTIES      folded into the symbol-property prune   #8195

#8193 is not a prune. `last_closure` held the closure that took the last
erroring async step, for a same-closure check DELETED when #712/#921/#922
showed a runaway loop alternates between two closures. Nothing has read
it since — but it was still a raw heap address the promise scanner
rekeyed without marking, and nothing pruned it. Maintaining state nobody
reads is its own dead code, so the field goes, and with it the
PROMISE_SCAN_ASYNC_STEP_GUARD budgeted phase whose only slot it was.

#8195 is not a new prune either: the accessor table shares its owner key
with SYMBOL_PROPERTIES and SYMBOL_PROPERTY_ATTRS, both pruned since the
2026-07-09 audit, and was simply left out. It now takes the same pass's
memoized owner verdict. That also closes a leak — a dead owner's
accessor closures were immortal.

Each new prune has a pair of cases: the prune FIRES (dead owner, one
collection, the table observably shrinks) and its inverse (a rooted
owner's entry survives). The transition-cache case allocates its rooted
`next_keys` in OLD-GEN on purpose — a reachable neighbour in the dead
array's own nursery block would force-mark it (#7975) and the prune
would correctly decline, which would have read as a failure of the
prune.

cargo test -p perry-runtime --lib: 2514 passed / 0 failed / 4 ignored.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>

Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj
Co-authored-by: Ralph Küpper <ralph@skelpo.com>

Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj
@proggeramlug
proggeramlug force-pushed the gc/8174-rekeyed-table-registry branch from 1a000c5 to d0dd732 Compare August 16, 2026 09:28
@proggeramlug
proggeramlug marked this pull request as ready for review August 16, 2026 09:29
@proggeramlug
proggeramlug merged commit 3c95020 into main Aug 16, 2026
12 of 19 checks passed
@proggeramlug
proggeramlug deleted the gc/8174-rekeyed-table-registry branch August 16, 2026 09:30
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.

gc: rewrite_raw_addr follows a forwarding pointer out of an address that is not a live object start

1 participant