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
proggeramlug pushed a commit that referenced this pull request Aug 16, 2026
Corrects the residue figure (65,906 -> 65,915 after the rebase onto #8204/#8196,
neither of which moved it), and reports instructions and peak RSS together per
corpus row against a stated noise floor.

Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj
proggeramlug pushed a commit that referenced this pull request Aug 16, 2026
Corrects the residue figure (65,906 -> 65,915 after the rebase onto #8204/#8196,
neither of which moved it), and reports instructions and peak RSS together per
corpus row against a stated noise floor.

Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj
proggeramlug pushed a commit that referenced this pull request Aug 16, 2026
Rebase moved the base to current main, so both arms were rebuilt there and the
whole measurement retaken. Counters are bit-identical (releases == allocs,
residue constant at 65,915) and peak RSS reproduces within 0.3 MB, so none of
#8204/#8196/#8211/#8212/#8162 moves this residue.

Also records, rather than rounds away, the fixed +80 KB per-process startup cost
the change adds: it is page-granular first touch, not code size (binary +80 B,
__TEXT unchanged) and not the pool data (144 B of empty Vec headers).

Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj
proggeramlug pushed a commit that referenced this pull request Aug 16, 2026
Corrects the residue figure (65,906 -> 65,915 after the rebase onto #8204/#8196,
neither of which moved it), and reports instructions and peak RSS together per
corpus row against a stated noise floor.

Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj
proggeramlug pushed a commit that referenced this pull request Aug 16, 2026
Rebase moved the base to current main, so both arms were rebuilt there and the
whole measurement retaken. Counters are bit-identical (releases == allocs,
residue constant at 65,915) and peak RSS reproduces within 0.3 MB, so none of
#8204/#8196/#8211/#8212/#8162 moves this residue.

Also records, rather than rounds away, the fixed +80 KB per-process startup cost
the change adds: it is page-granular first touch, not code size (binary +80 B,
__TEXT unchanged) and not the pool data (144 B of empty Vec headers).

Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj
proggeramlug pushed a commit that referenced this pull request Aug 16, 2026
Corrects the residue figure (65,906 -> 65,915 after the rebase onto #8204/#8196,
neither of which moved it), and reports instructions and peak RSS together per
corpus row against a stated noise floor.

Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj
proggeramlug pushed a commit that referenced this pull request Aug 16, 2026
Rebase moved the base to current main, so both arms were rebuilt there and the
whole measurement retaken. Counters are bit-identical (releases == allocs,
residue constant at 65,915) and peak RSS reproduces within 0.3 MB, so none of
#8204/#8196/#8211/#8212/#8162 moves this residue.

Also records, rather than rounds away, the fixed +80 KB per-process startup cost
the change adds: it is page-granular first touch, not code size (binary +80 B,
__TEXT unchanged) and not the pool data (144 B of empty Vec headers).

Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj
proggeramlug added a commit that referenced this pull request Aug 17, 2026
…x cells (#7933 follow-up) (#8208)

* fix(async): release a completed plain-async activation's box cells for reuse (#7933 follow-up)

The async-to-generator transform's #7933 release cleared cells but kept
them registered and malloc-resident forever: ~500 B of cell + registry
bytes per completed activation, ~119 MB over an asyncpipe_big run whose
live heap is ~250 KB. Replace the LocalSet(id, undefined) release with a
Stmt::ReleaseBoxes HIR statement that codegen lowers to js_*box_release:
clear + de-register + park the cell in a quarantine that drains into a
per-kind free pool at the outermost microtask-pump boundary once the task
queue is empty; js_*box_alloc* then reuses pooled cells instead of
touching std::alloc. Also release the state-machine control cells, with
parked values chosen so a stray duplicate resume takes byte-for-byte the
pre-release terminal path (bool cells park true = the done short-circuit;
i32 cells park -1 = no dispatch case).

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

* test(transform,runtime): cover the ReleaseBoxes shape; route release plausibility through the canonical predicate

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

* test(codegen): pin the ReleaseBoxes lowering — kind selection, capture path, hint skip

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

* docs: changelog fragment for #8208

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

* fix(async,gc): close the ReleaseBoxes id-remap holes and re-argue the box exemption

Follow-up hardening on the #8208 release/reuse change, from an audit of the
94 exhaustive-match arms the new `Stmt::ReleaseBoxes` variant required.

Six sites were NOT among those 94, because `ReleaseBoxes` falls into a
pre-existing `_ => {}` catch-all there — so rustc said nothing. Three of them
renumber LocalIds, which is exactly the case the variant's own doc comment
declares incorrect: an unremapped `PreallocateBoxes` merely allocates a cell
nobody reads, but an unremapped `ReleaseBoxes` releases a STILL-LIVE local's
cell and hands it to the next allocation.

None is reachable today — intra-module inlining runs before the async
transform, the cross-module harvest refuses bodies containing a release, and
the two max-id scans feed a `next_local_id` computed earlier — but that
safety rests entirely on pipeline ordering that nothing enforces. Remapped
rather than left latent:

- `inline/substitute.rs` `substitute_locals_in_stmts_inner` — the neighbouring
  prealloc arm already remaps (issue #569); the release now does too.
- `perry-hir/src/analysis.rs` `remap_local_ids_in_stmt{,_propagating}` — the
  canonical HIR remappers, whose own doc says to keep the variant list in sync.
- `generator/per_iteration.rs` `rename_in_stmt` — a LocalId renamer inside the
  generator transform itself; its `each_expr_mut` helper only reaches ids that
  live inside an Expr, so all three bare-id-list variants were walked past.
- `generator/id_scan.rs` and `deforest/walk.rs` max-id scans now include the
  release ids, matching the deliberate #1029/#5143 defence on the prealloc arm.
- `perry-codegen/src/boxed_vars.rs` keeps NOT collecting release ids (a
  reclamation hint must not decide a local's representation) but says so
  explicitly instead of falling into the catch-all.

The invariant those last two lean on — the transform never releases an id it
did not also preallocate, or `emit_release_boxes` skips it and the release goes
silently inert with every test still green — is now asserted in both directions
(`every_released_id_is_also_preallocated`, with vacuity guards).

gc_root_dominance_check.py:

- The "box" immovable-source exemption rested on "boxes are never freed", which
  this PR falsified, while its probe only grepped for `dealloc(`/`arena_alloc(`
  — all of which a *recycle* path passes. The exemption stayed green on a dead
  premise, which the script's own docstring calls strictly worse than no
  exemption. Re-argued on the property #8208 actually preserves (cell memory is
  never returned to the allocator, so an address never stops naming box-cell
  memory and can never become another kind of object), and the probe now also
  requires the reuse path to stay quarantine-gated. Sabotage-tested: bypassing
  the quarantine and introducing a real `dealloc` each turn it red.
- Added the three `js_*box_release` names to NONCOLLECTING. This PR had added
  them to `gc_call_effects.rs` only, breaking the documented one-way
  containment — the same one-sided drift that cost #7510 358 spurious
  violations. `cannot_collect_stays_a_subset_of_the_checker_authority` now
  machine-checks that relation instead of trusting four comments that assert it.

Also refreshes the monotonicity docs the release invalidated, including the
load-bearing correctness argument in `expr/literals_vars.rs` that let a
`box_ptr` outlive a collecting call on the strength of "never freed".

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

* fix(hir,transform): scope the new id-remap arms strictly to ReleaseBoxes

The previous commit grouped `ReleaseBoxes` with `PreallocateBoxes` /
`PreallocateTdzBoxes` in `analysis.rs`'s two canonical remappers and in
`per_iteration.rs`'s renamer. In those three places the prealloc variants were
previously UNHANDLED, so the grouping quietly started remapping them too —
a behaviour change to existing programs riding along inside a PR about a new
statement variant.

That prealloc gap is real but pre-existing and benign in its failure direction:
an unremapped prealloc allocates a cell nobody reads, whereas an unremapped
release frees a live local's cell. Closing it can shift codegen and deserves
its own evidence, so it is documented at both sites and left alone.

With this, the hardening changes alter behaviour only for `ReleaseBoxes`, which
no pass in the tree can reach today — so they cannot move codegen output at all.
The sites where `ReleaseBoxes` was grouped with an arm that ALREADY handled the
prealloc variants (`inline/substitute.rs`, `generator/id_scan.rs`,
`deforest/walk.rs`) are unaffected and keep the grouping.

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

* docs: re-measure #8208 on 07c8040 and record the hardening

Corrects the residue figure (65,906 -> 65,915 after the rebase onto #8204/#8196,
neither of which moved it), and reports instructions and peak RSS together per
corpus row against a stated noise floor.

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

* docs: record the flush-boundary limitation and the exit-path coverage

Adds the measured degenerate case (an await cascade with no timer or I/O never
reaches the flush boundary, so releases are performed but never harvested:
+1.32% instructions, +0.3 MB RSS) and the seven-shape exit-path fixture that
matches the Node oracle byte-for-byte on both arms.

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

* test(gap): pin every async exit path across the #8208 box release

Behavioural half of the #8208 gate. Drives normal return, throw after an await,
early return from inside a loop after a suspend, await on a rejected promise,
try/finally across a suspend on both terminal arms, loop-created closures
capturing a per-iteration binding across a suspend, and async-generator
.return() versus a full drain — 400 iterations each — and prints values that
only come out right if every cell outlived its last reader.

A cell released while still reachable, or reused by a second live activation,
is a wrong answer rather than a crash, which is why this asserts printed values
against the Node oracle instead of merely running to completion.

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

* docs: re-measure #8208 with both arms rebuilt at b8d32ab

Rebase moved the base to current main, so both arms were rebuilt there and the
whole measurement retaken. Counters are bit-identical (releases == allocs,
residue constant at 65,915) and peak RSS reproduces within 0.3 MB, so none of
#8204/#8196/#8211/#8212/#8162 moves this residue.

Also records, rather than rounds away, the fixed +80 KB per-process startup cost
the change adds: it is page-granular first touch, not code size (binary +80 B,
__TEXT unchanged) and not the pool data (144 B of empty Vec headers).

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

* perf(runtime): thread the box reuse pool through the cells, deleting the side table

The free pool was a `Vec<usize>` per kind: one 8-byte slot per pooled cell, on
top of the cell. Its high-water mark is ~330 cells per unit of PEAK CONCURRENCY
(measured: resident_cells/SIZE is 329-334 across a 16x sweep of the fan-out
width), held for the life of the thread, so at SIZE=200 it was ~1 MB of side
table and made small async workloads a net RSS REGRESSION.

A free cell's own 8 bytes are dead, and every box kind is exactly pointer-sized
(now asserted at compile time), so the free list is threaded through the cells
themselves and costs zero side-table bytes.

Overwriting the cell is why only POST-QUARANTINE cells join the list: a
quarantined cell must keep the parked terminal value a stray duplicate resume
reads, and `flush_released_boxes` publishing it is exactly the point at which
the task queue is empty and no such resume can exist. The checker probe is
updated to fail if a release ever publishes directly.

The quarantine is deliberately NOT shrunk on flush: it refills to the same size
every interval, and handing the buffer back cost +5.3 MB peak RSS at
BATCHES=1200 in allocator churn (measured).

Measured on asyncpipe, matched arms at b8d32ab (peak RSS, best-of-5):

  BATCHES     30     60     90    120    300    600   1200
  delta MB  +0.80  +0.92  -0.19  -0.19  -8.17 -25.06 -69.73

Crossover moves from ~200 batches to between 60 and 90, and the 1200 row
improves from -63.8 MB to -69.7 MB. stdout is byte-identical at every size.
The residual sub-crossover cost is NOT this pool -- see the changelog.

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

* docs: record the RSS sweep, the remaining floor, and why a cap cannot fix it

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

* docs: record why no earlier publish point is safe (per-kind split refuted)

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

* docs: final numbers on matched 9233429 arms; gc-ratchet shared_ci OK

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

* fix(async): publish box cells at activation reachability zero

* test(async): close PR review and CI coverage gaps

* ci: classify the stale loop safepoint assertion

* ci: record inherited codegen integration failures

* fix(async): complete final review coverage

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
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