Skip to content

gc: reload js_closure_get_capture_bits results across a collection point (#7725) - #7732

Merged
proggeramlug merged 2 commits into
mainfrom
gc/7725-capture-bits-reload
Aug 9, 2026
Merged

gc: reload js_closure_get_capture_bits results across a collection point (#7725)#7732
proggeramlug merged 2 commits into
mainfrom
gc/7725-capture-bits-reload

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #7725, the follow-up #7724 split out: the last 2 unrooted:capture hits on gc-root-dominance-statepoints, both js_closure_get_capture_bits's return value never being re-entered into a protected domain (unlike %this_closure itself, already protected via current_closure_slot, #7055).

  • test_gap_class_expr_dynamic_parent_ctor::__closure_21 — a captured dynamic-parent-class reference read at the top of a synthesized implicit constructor, used ~60 lines later as js_new_function_construct's callee, across js_object_alloc_class_inline_keys and the class's own user constructor.
  • test_gap_computed_key_method_nested_this::__closure_9 — a captured numeric local held across js_number_coerce (which can run a user Symbol.toPrimitive).

Confirming the framing before implementing

The task was to verify #7724's diagnosis — that this needs root_reload.rs's Facts to model a call as a reloadable source, not just a load — rather than assume it and start coding.

I checked the alternative: root every capture read at its own call site via the existing TempRootPool (rooting/temp_root.rs). Rejected — it would pay a store + shadow-slot bind on every capture read, including the overwhelming majority never held across a collection point, where root_reload.rs's whole design point is to cost nothing where the reload turns out to be unnecessary (LLVM CSEs a redundant one away; a needed one stays because the intervening call is opaque). The generic-read call sites (literals_vars.rs, closure.rs, array_push.rs, instance_misc1.rs, lower_array_method.rs, expr/mod.rs) also have no way to know at emission time whether this particular read will cross a later collection — that's exactly the question root_reload.rs's post-hoc CFG walk exists to answer cheaply, and it's the same question it already answers for string-handle globals and shadow-slot masks. So the Facts-as-call extension is the right layer, confirmed rather than assumed.

The fix

js_closure_get_capture_bits(ptr, idx) calls whose ptr operand already belongs to a reloadable recipe (the closure-ptr shadow-slot load chain #7055 protects) now extend that recipe exactly like the existing transparent and/bitcast steps — Facts gained capture_get_key, set when the call's index argument is a compile-time literal (every real emission site formats one directly, confirmed by grep). A reload point re-materializes the whole chain: a fresh slot load, a fresh mask, a fresh call to js_closure_get_capture_bits with the same index, and any further cast — not just a load.

The store side-condition needed its own key rather than the inherited closure-ptr-slot one (that slot is written exactly once, at closure entry, so checking it there would make the invalidation vacuous — a real soundness gap I caught while implementing, not something I noticed after). A capture-GET recipe's root_ptr switches to a synthetic $closure_capture:<idx> key from that call onward; js_closure_set_capture_bits populates the existing stores_to field with the same key, reusing the store-invalidation machinery a plain store already has rather than adding a parallel one. That required widening the reload pass's grouping key from recipe[0] alone to (recipe[0], root_ptr), since before this a chain's root_ptr never changed mid-derivation — every pre-existing chain has one root_ptr throughout, so the wider key is additive and provably doesn't change any existing (non-capture) grouping.

raw_facts (the invoke/try rendering path) gets the same stores_to detection for the SET half; the GET half is deliberately gated on !is_invoke, since materialize re-emits a recipe step as a plain mid-block instruction and an invoke is a terminator — cloning one in would be invalid IR. In practice this call is never seen there except as an invoke (every real emission goes through the typed LlInst::Call form otherwise), so the guard is defensive rather than load-bearing today.

Before / after (gated mode, both lowerings)

before after
--statepoints --moving-only, native corpus unrooted 2, stale 0 unrooted 0, stale 0
--lowering shadow (dominance / --unrooted-allocas / --stale-registers) clean clean (6 pre-existing --stale-registers hits, unrelated to capture-bits, unchanged, well under the 39 budget)

Reached 0, so gc-root-dominance-statepoints' --max-unrooted is deleted (not set to 0) — the flag already defaults to 0, and a budget nobody re-measures is the exact failure mode #7706 fixed for a different gate on this same file: it silently absorbs the next real hazard instead of going red.

Test plan

  • python3 scripts/gc_root_dominance_check.py --self-test — clean, including gc: fix root-dominance phi false positives + 3 of 5 real hits, lower --max-unrooted to 2 (#7664) #7724's phi_safe_edge/phi_hazard_edge fixtures (confirms this change doesn't touch the checker)
  • Native corpus (131/131 sources compiled, 0 skipped, PERRY_RUNTIME_DIR pinned to the build under test both times): --statepoints --moving-only --max-unrooted 999 --max-stale 9992 → 0 unrooted, stale held at 0
  • Full CI-shaped invocation (--min-statepoints 15000 --min-live-bundles 8000 --min-relocates 20000 --max-unrooted 0 --max-stale 0 --seeded-violations 40) passes; all 40 planted violations still caught
  • --lowering shadow corpus (131/131 compiled): dominance, --unrooted-allocas --moving-only, --stale-registers --moving-only all clean
  • cargo test -p perry-codegen --lib — 809 passed (805 on origin/main + 4 new tests), 0 failed
  • cargo test -p perry-codegen --no-fail-fast (the full suite including crates/*/tests/*.rs, which per-PR CI does not run) — the same 6 pre-existing failures across the same 4 targets (large_object_barriers, native_proof_buffer_views, native_proof_regressions, typed_shape_descriptors) reproduce byte-identically on a fresh origin/main checkout; zero new failures
  • cargo fmt --all -- --check / scripts/check_file_size.sh — clean

root_reload.rs crossed the 2,000-line file-size cap gaining this fix; its #[cfg(test)] mod tests moved to a sibling root_reload_tests.rs (the existing linker.rs/type_analysis.rs #[path] idiom), which is also where the four new capture-bits tests landed.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed native garbage-collection safety issues involving closure capture reads and updates.
    • Improved value reloading across collecting calls, control-flow paths, loops, invokes, and slot reassignment.
    • Preserved correct behavior for different integer widths, globals, derived values, and multiple operands.
  • Tests

    • Added comprehensive regression coverage for reload placement, operand renaming, closure captures, and capture-index-specific updates.
  • Documentation

    • Documented the fixes and verification results.
  • Chores

    • Updated the application version to 0.5.1425.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The reload pass now rematerializes closure capture reads, tracks invalidation by capture index, and handles capture calls in typed and raw instructions. New tests cover capture reloads and broader reload behavior. The workflow, changelog, and package version were updated.

Changes

Closure capture reload support

Layer / File(s) Summary
Capture fact tracking
crates/perry-codegen/src/root_reload.rs
The pass recognizes capture GET/SET helpers, parses literal indices, and records synthetic per-index invalidation keys for typed and raw instructions.
Derivation invalidation grouping
crates/perry-codegen/src/root_reload.rs
Capture derivations use synthetic capture keys. Recipe grouping now includes the root load and invalidation location.
Reload recipe materialization
crates/perry-codegen/src/root_reload.rs
Result-bearing capture calls can be renamed and rematerialized. Invoke handling preserves exception-edge behavior.
Reload regression coverage
crates/perry-codegen/src/root_reload.rs, crates/perry-codegen/src/root_reload_tests.rs
Tests cover collecting calls, CFG paths, invokes, loops, slot and global invalidation, derived values, capture reads, and capture-index-specific writes.
Gate and release updates
.github/workflows/gc-root-dominance.yml, changelog.d/7725-capture-bits-reload.md, CLAUDE.md, Cargo.toml
The workflow records clean corpora results and uses the checker default limit. The changelog and documented package version were updated.

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

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed, but it omits required template sections and does not address the prohibited Cargo.toml and CLAUDE.md edits. Restore the required sections, complete the checklist, and remove the version metadata edits before merge.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: reloading capture-bit results across collection points.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch gc/7725-capture-bits-reload

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 2 commits August 9, 2026 22:20
…int (#7725)

The last 2 unrooted:capture hits gc-root-dominance-statepoints' budget
was carrying: js_closure_get_capture_bits(ptr, idx)'s return value is
never re-entered into the RS4GC-tracked domain or a temp root, unlike
%this_closure itself (already protected via current_closure_slot,
#7055). root_reload.rs's Facts now recognizes a capture-bits GET call
whose ptr operand already belongs to a reloadable recipe as an
extension of that recipe -- the same treatment string-handle globals
and shadow slots already get -- with a synthetic per-index store
side-condition so a same-index SET still suppresses the reload.

Native corpus: 2 -> 0 unrooted hazards, stale held at 0.
gc-root-dominance-statepoints' --max-unrooted is deleted (the flag
already defaults to 0) rather than set to 0, so a future hit can't be
silently absorbed by a stale budget.

root_reload.rs crossed the 2,000-line file-size cap gaining this; its
test module moved to a sibling root_reload_tests.rs (the existing
linker.rs/type_analysis.rs #[path] idiom), which is also where the
four new capture-bits tests landed.
@proggeramlug
proggeramlug force-pushed the gc/7725-capture-bits-reload branch from 2843fbc to 6a91539 Compare August 9, 2026 20:20

@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: 4

🧹 Nitpick comments (3)
crates/perry-codegen/src/root_reload.rs (1)

811-815: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider narrowing the call arm to the capture-bits callee.

inst_result and set_inst_result now accept every result-bearing LlInst::Call. Today only CAPTURE_GET_CALLEE reaches materialize, because it is the only call that facts_of marks transparent. That coupling is implicit. If a future change marks another call transparent, materialize will duplicate that call with no further review gate.

A callee check here makes the invariant local and self-checking.

♻️ Proposed narrowing
-        LlInst::Call { dst: Some(dst), .. } => dst,
+        LlInst::Call {
+            dst: Some(dst),
+            callee,
+            ..
+        } if callee == CAPTURE_GET_CALLEE => dst,
🤖 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 `@crates/perry-codegen/src/root_reload.rs` around lines 811 - 815, Narrow the
result-bearing LlInst::Call arm in materialize to accept only the
CAPTURE_GET_CALLEE callee, while preserving the existing dst extraction for that
call. Add an explicit callee check so other transparent calls cannot be
materialized accidentally.
crates/perry-codegen/src/root_reload_tests.rs (2)

843-881: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a fixture for a capture SET with a non-literal index.

The two fixtures cover literal indices only. literal_capture_idx returns None for a register index, and the SET then records no stores_to. No test pins the behavior for that input. A fixture that passes a register as the index operand of js_closure_set_capture_bits would lock down whichever fallback you choose for the gap raised in crates/perry-codegen/src/root_reload.rs.

🤖 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 `@crates/perry-codegen/src/root_reload_tests.rs` around lines 843 - 881, Add a
fixture alongside capture_get_chain_with_set that invokes
js_closure_set_capture_bits with a register-valued, non-literal index instead of
set_idx. Use it in a test covering the root-reload fallback for dynamic capture
indices, ensuring the expected stores_to behavior is explicitly pinned.

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

Make the stale-operand check unconditional.

If position returns None, the if let skips the assertion and the test still passes. That is the half of the test that proves the consumer stopped reading the pre-call register. Use expect so a missing original bitcast fails the test instead of disabling the check.

💚 Proposed change
-    let orig_bitcast = lines
+    let stale = lines
         .iter()
         .position(|l| l.contains("= bitcast i64 %r") && !recipe.contains(l))
-        .map(|i| lines[i].split_whitespace().next().unwrap());
-    if let Some(stale) = orig_bitcast {
-        assert!(
-            !lines[use_idx].contains(&format!("double {stale},")),
-            "the consumer must NOT still read the pre-call capture read"
-        );
-    }
+        .map(|i| lines[i].split_whitespace().next().unwrap())
+        .expect("the pre-call bitcast must still be in the IR");
+    assert!(
+        !lines[use_idx].contains(&format!("double {stale},")),
+        "the consumer must NOT still read the pre-call capture read\n{ir}"
+    );
🤖 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 `@crates/perry-codegen/src/root_reload_tests.rs` around lines 813 - 823, Make
the stale original-bitcast lookup in the test unconditional: replace the
optional result handling around `orig_bitcast` with an expectation that fails
when no matching pre-call bitcast is found, then always assert that the consumer
line does not contain the stale operand. Preserve the existing diagnostic
message and operand check.
🤖 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 `@Cargo.toml`:
- Line 318: Revert the per-PR release metadata updates: restore
[workspace.package].version in Cargo.toml (line 318) to 0.5.1424 and restore
Current Version in CLAUDE.md (line 11) to 0.5.1424; leave the changelog fragment
unchanged.

In `@crates/perry-codegen/src/root_reload.rs`:
- Around line 493-499: The capture-chain walk in root_reload.rs around the
capture_get_key handling must preserve both the inherited root_ptr invalidation
key and the capture-index key, invalidating the chain when either the
closure-ptr slot is stored to or the capture index is set; do not replace
root_ptr with capture_get_key. In root_reload_tests.rs around the existing
fixture, add coverage that stores a new value into the closure-ptr shadow slot
during the window and asserts the capture reload is suppressed.
- Around line 915-928: The typed LlInst::Call arm at
crates/perry-codegen/src/root_reload.rs:915-928 and raw call arm at
crates/perry-codegen/src/root_reload.rs:1077-1102 must assign a wildcard capture
key to stores_to when literal_capture_idx or raw_call_literal_arg cannot
determine the SET index. Update the stores_to comparisons in apply_to_function
at lines 615, 633, and 692 so that wildcard invalidation matches every key
produced by capture_slot_key; preserve precise matching for known indices and
GET’s opaque behavior.
- Around line 1148-1152: Update the argument parsing in the surrounding
capture-index function to split `arg.trim()` at the last space rather than the
first, so multi-token types leave only the operand token in `val`. Preserve the
existing empty-value and `%` filtering and returned string behavior.

---

Nitpick comments:
In `@crates/perry-codegen/src/root_reload_tests.rs`:
- Around line 843-881: Add a fixture alongside capture_get_chain_with_set that
invokes js_closure_set_capture_bits with a register-valued, non-literal index
instead of set_idx. Use it in a test covering the root-reload fallback for
dynamic capture indices, ensuring the expected stores_to behavior is explicitly
pinned.
- Around line 813-823: Make the stale original-bitcast lookup in the test
unconditional: replace the optional result handling around `orig_bitcast` with
an expectation that fails when no matching pre-call bitcast is found, then
always assert that the consumer line does not contain the stale operand.
Preserve the existing diagnostic message and operand check.

In `@crates/perry-codegen/src/root_reload.rs`:
- Around line 811-815: Narrow the result-bearing LlInst::Call arm in materialize
to accept only the CAPTURE_GET_CALLEE callee, while preserving the existing dst
extraction for that call. Add an explicit callee check so other transparent
calls cannot be materialized accidentally.
🪄 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: 9acc7cbc-f00b-48a2-bd6b-52f29d0a950e

📥 Commits

Reviewing files that changed from the base of the PR and between 01afc04 and 6a91539.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (6)
  • .github/workflows/gc-root-dominance.yml
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/7725-capture-bits-reload.md
  • crates/perry-codegen/src/root_reload.rs
  • crates/perry-codegen/src/root_reload_tests.rs

Comment thread Cargo.toml

[workspace.package]
version = "0.5.1424"
version = "0.5.1425"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Revert the per-PR release metadata updates.

The PR already includes changelog.d/7725-capture-bits-reload.md. Keep version metadata unchanged in the PR. Let maintainers apply the release update during merge or release.

  • Cargo.toml#L318-L318: restore [workspace.package].version to 0.5.1424.
  • CLAUDE.md#L11-L11: restore Current Version to 0.5.1424.

As per coding guidelines, external contributors must not modify [workspace.package].version. Based on learnings, maintainers own Cargo.toml and CLAUDE.md version metadata when the PR-keyed changelog fragment is present.

📍 Affects 2 files
  • Cargo.toml#L318-L318 (this comment)
  • CLAUDE.md#L11-L11
🤖 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 `@Cargo.toml` at line 318, Revert the per-PR release metadata updates: restore
[workspace.package].version in Cargo.toml (line 318) to 0.5.1424 and restore
Current Version in CLAUDE.md (line 11) to 0.5.1424; leave the changelog fragment
unchanged.

Sources: Coding guidelines, Learnings

Comment on lines +493 to +499
// #7725: a capture-GET call whose ptr operand is already reloadable extends the
// SAME recipe, but its own store side-condition is "was THIS INDEX set", not
// "was the closure-ptr slot stored to" — the ptr slot never is (it is written
// once, at closure entry). Swap the key here so every step from this call
// onward — including a further transparent cast like `bitcast i64 to double` —
// is invalidated by the right condition.
let root = f.capture_get_key.clone().unwrap_or(root);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

A capture chain loses the store condition of its own root load, and no test pins that case. Line 499 replaces the inherited root_ptr with the capture key. root_ptr is the single invalidation key the group walk uses, so the chain stops checking stores to the closure-ptr slot even though recipe[0] still loads from that slot and materialize re-emits that load.

  • crates/perry-codegen/src/root_reload.rs#L493-L499: carry both keys instead of replacing one with the other, so the chain is invalidated by a store to the inherited root OR by a set to the capture index.
  • crates/perry-codegen/src/root_reload_tests.rs#L883-L911: add a fixture that stores a new value into the closure-ptr shadow slot inside the window, and assert the capture reload is suppressed.
📍 Affects 2 files
  • crates/perry-codegen/src/root_reload.rs#L493-L499 (this comment)
  • crates/perry-codegen/src/root_reload_tests.rs#L883-L911
🤖 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 `@crates/perry-codegen/src/root_reload.rs` around lines 493 - 499, The
capture-chain walk in root_reload.rs around the capture_get_key handling must
preserve both the inherited root_ptr invalidation key and the capture-index key,
invalidating the chain when either the closure-ptr slot is stored to or the
capture index is set; do not replace root_ptr with capture_get_key. In
root_reload_tests.rs around the existing fixture, add coverage that stores a new
value into the closure-ptr shadow slot during the window and asserts the capture
reload is suppressed.

Comment on lines +915 to +928
// #7725: the two capture-bits halves. GET extends a derivation like a transparent
// bit op (see CAPTURE_GET_CALLEE); SET's side effect on the capture slot has to
// invalidate that derivation the way a store does, via the shared `stores_to`
// machinery.
if callee == CAPTURE_GET_CALLEE {
if let Some(idx) = literal_capture_idx(args) {
transparent = true;
capture_get_key = Some(capture_slot_key(idx));
}
} else if callee == CAPTURE_SET_CALLEE {
if let Some(idx) = literal_capture_idx(args) {
stores_to = Some(capture_slot_key(idx));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

A non-literal capture SET index records no invalidation in either analysis arm. literal_capture_idx and raw_call_literal_arg both return None for a register operand. The GET side handles that safely by staying opaque. The SET side does not: it leaves stores_to as None, so the write becomes invisible to the group walk and a capture reload can be re-materialized below it. An unknown index must invalidate every capture key, not none of them.

  • crates/perry-codegen/src/root_reload.rs#L915-L928: in the typed LlInst::Call arm, set stores_to to a wildcard capture key when literal_capture_idx(args) returns None.
  • crates/perry-codegen/src/root_reload.rs#L1077-L1102: apply the same wildcard fallback in the raw arm when raw_call_literal_arg(rhs, &name, 1) returns None.

The two stores_to comparisons in apply_to_function (Line 615, Line 633, Line 692) must then treat the wildcard as matching any key produced by capture_slot_key.

📍 Affects 1 file
  • crates/perry-codegen/src/root_reload.rs#L915-L928 (this comment)
  • crates/perry-codegen/src/root_reload.rs#L1077-L1102
🤖 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 `@crates/perry-codegen/src/root_reload.rs` around lines 915 - 928, The typed
LlInst::Call arm at crates/perry-codegen/src/root_reload.rs:915-928 and raw call
arm at crates/perry-codegen/src/root_reload.rs:1077-1102 must assign a wildcard
capture key to stores_to when literal_capture_idx or raw_call_literal_arg cannot
determine the SET index. Update the stores_to comparisons in apply_to_function
at lines 615, 633, and 692 so that wildcard invalidation matches every key
produced by capture_slot_key; preserve precise matching for known indices and
GET’s opaque behavior.

Comment on lines +1148 to +1152
let args_text = &rhs[start..end?];
let arg = args_text.split(',').nth(index)?;
let (_, val) = arg.trim().split_once(' ')?;
let val = val.trim();
(!val.is_empty() && !val.starts_with('%')).then(|| val.to_string())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Take the last token of the argument, not the tail after the first space.

split_once(' ') assumes the type is a single token. A multi-token type such as ptr addrspace(1) %x makes val become addrspace(1) %x. That string is non-empty and does not start with %, so the function returns it as a literal index. The caller then builds a wrong capture key. On the SET side a wrong key means the write is recorded under an index nobody checks, and the invalidation is lost.

The two capture callees use single-token operand types today, so this does not fire now. rsplit_once removes the assumption at no cost.

🛡️ Proposed fix
-    let (_, val) = arg.trim().split_once(' ')?;
+    // The VALUE is the last token; a type can be several tokens
+    // (`ptr addrspace(1)`, `i64 (i64)*`).
+    let (_, val) = arg.trim().rsplit_once(' ')?;
     let val = val.trim();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let args_text = &rhs[start..end?];
let arg = args_text.split(',').nth(index)?;
let (_, val) = arg.trim().split_once(' ')?;
let val = val.trim();
(!val.is_empty() && !val.starts_with('%')).then(|| val.to_string())
let args_text = &rhs[start..end?];
let arg = args_text.split(',').nth(index)?;
// The VALUE is the last token; a type can be several tokens
// (`ptr addrspace(1)`, `i64 (i64)*`).
let (_, val) = arg.trim().rsplit_once(' ')?;
let val = val.trim();
(!val.is_empty() && !val.starts_with('%')).then(|| val.to_string())
🤖 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 `@crates/perry-codegen/src/root_reload.rs` around lines 1148 - 1152, Update the
argument parsing in the surrounding capture-index function to split `arg.trim()`
at the last space rather than the first, so multi-token types leave only the
operand token in `val`. Preserve the existing empty-value and `%` filtering and
returned string behavior.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Merging as v0.5.1425 — the native lowering is now at ZERO unrooted hazards

I verified the headline claim independently rather than trusting the PR, because a wrong budget makes main red for everyone:

corpus (native): 131/131 sources compiled, 0 skipped, 151 .ll files
  statepoints: 32694   non-empty live bundles: 19552
gc_root_dominance_check.py --statepoints --moving-only   (no budget flag)
  → within budget: unrooted 0 <= 0      exit 0

21 → 0, and --max-unrooted is deleted rather than set to 0, per #7706's precedent. A flag that says "the budget is 0" and a flag that isn't there are the same today and different the first time someone edits the default.

Why the harder fix was the right one

The obvious alternative — root every capture read at its own site via TempRootPool — would tax every capture read, and the overwhelming majority never cross a collection point. root_reload.rs exists precisely to cost nothing when the reload turns out unneeded, and the generic read sites can't know at emission time whether a given read will later cross a safepoint. That is the question the pass's post-hoc CFG walk already answers for string-handle globals and shadow-slot masks. Extending it to treat a js_closure_get_capture_bits(ptr, idx) call as a transparent link in a reloadable recipe is the same mechanism, not a new one.

The bug caught during implementation is the best part

Inheriting the closure-ptr-slot's invalidation key would have made invalidation vacuous — that slot is written once, at entry, so nothing would ever invalidate the recipe. Caught before shipping, and fixed by switching a capture-GET recipe to a synthetic $closure_capture:<idx> key from that call onward, with js_closure_set_capture_bits populating stores_to. That required widening the pass's grouping key from recipe[0] to (recipe[0], root_ptr), since no chain's root had ever changed mid-derivation before.

A reload pass whose invalidation never fires is exactly the shape of bug this project keeps finding — it would have passed every test while doing nothing.

Verification

--self-test clean with seeded violations 40/40 still caught. cargo test -p perry-codegen --lib 809/809 (805 baseline + 4 new). Shadow lowering clean. root_reload.rs crossed the 2000-line cap gaining this, so its tests moved to a sibling root_reload_tests.rs via the existing #[path] idiom.

Gates 21/21. (One gate run showed a perry-runtime --lib failure at host load 50 — clean on re-run at 1962 passed, 0 failed, and this PR touches codegen only.)

@proggeramlug
proggeramlug merged commit 1a7d8f7 into main Aug 9, 2026
1 of 17 checks passed
@proggeramlug
proggeramlug deleted the gc/7725-capture-bits-reload branch August 9, 2026 20:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant