Skip to content

gc: Layer 1 rooting slice 8 — the raw API becomes unreachable (#7615) - #7670

Merged
proggeramlug merged 7 commits into
mainfrom
fix/7615-rooting-slice-8
Aug 9, 2026
Merged

gc: Layer 1 rooting slice 8 — the raw API becomes unreachable (#7615)#7670
proggeramlug merged 7 commits into
mainfrom
fix/7615-rooting-slice-8

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Slice 8 — the last slice of the Layer 1 emitter-migration campaign (#7615).
It reaches the terminal condition the plan stated, and the interesting parts of
the work are the three places the brief was wrong and the two places my own
verification was vacuous.

The terminal condition, and why the file had to move

the terminal condition is expr/temp_root.rs going pub(in crate::rooting)
the raw accessor unreachable, not merely uncounted.

As literally spelled that is not expressible in Rust. pub(in path)
requires path to be an ANCESTOR module of the item (E0742), and
crate::rooting is not an ancestor of crate::expr::temp_root. So the file
moved: the raw API is now crate::rooting::temp_root, declared with a
private mod temp_root; and with pub(in crate::rooting) on every
accessor. Either alone would do it; both are here because the module
declaration is one keyword from re-widening twenty-five items at once.

A raw call planted in a migrated module now fails to compile:

error[E0603]: module `temp_root` is private
   --> crates/perry-codegen/src/expr/binary.rs
    |   crate::rooting::temp_root::temp_root_push_i64(ctx, "0")

That is the difference between this and a ledger line, and it is the second of
ten sabotage arms below.

Two items keep pub(crate) and are re-exported from rooting/mod.rs. Neither
is an accessor and neither can be called in the wrong order: TempRootPool
(compile-time slot bookkeeping) and expr_is_inert_primitive (the purity
predicate crate::loop_purity shares).

Fourteen entry points were deleted rather than narrowed, because the
migration left them with no caller — lower_exprs_rooted,
lower_operand_pair_rooted, any_later_ref_may_trigger_gc,
RootedOperands::is_rooted, the whole StoreOperandGuard family, the whole
RootedHandle family, temp_root_scope_begin/_end. CLAUDE.md's kill-policy:
the losing mode should stop compiling.

Three corrections to the brief

1. lower_string_method.rs has 27 escape-hatch sites, not 3 — and it needed
a split too.
The brief's per-file counts come from a predicate that misses
use crate::expr::temp_root::{…} imports. Under the ledger's own predicate
(non-comment lines naming temp_root/rooted_handle) the real distribution is
lower_string_method.rs 27, expr/static_field_meta.rs 10, lower_call/new.rs
9, expr/math_simple.rs 9, expr/binary.rs 6, expr/dyn_extern_i18n.rs 4.
loop_purity.rs has zero (its one hit is a doc link), and two files the
brief omits — gc_call_effects.rs and runtime_decls/arrays.rs — have five
each, all of them the runtime SYMBOL NAME "js_gc_temp_root_push".

2. So new.rs was not the one blocker. lower_string_method.rs was 1,957
lines against the 2,000-line cap with 27 sites to migrate — 43 lines of
headroom for five closure scopes. It got its own split, same as new.rs.

3. The dyn_extern_i18n.rs lead is wrong about the CFG — see below.

Modules migrated vs classified decision-free

Telling them apart is half the work, because a ledger line on a module that
never had a decision to make looks substantive and asserts nothing.

Eight listed (seven load-bearing on the committed source):

module what it was what it is
expr/binary.rs five lower_operand_pair_rooted + temp_root_release pairs, each releasing on its own return one lower_rooted_dynamic_binary over with_operands_rooted
expr/math_simple.rs MapSet's two operands with unequal windows, re-read at eight arm-specific points a RootedGroup; MapGet/MapHas are the plain shape
expr/static_field_meta.rs ClassExprFresh's rooted_handle_* + a per-static store guard a RootedGroup over the class object, nested with_rooted_accumulator for caps_arr, nested with_operands_rooted per symbol static
expr/dyn_extern_i18n.rs the #7280 namespace-object build with_rooted_accumulator
lower_string_method.rs a receiver root spanning ~60 return paths open_rooted_group
lower_string_concat.rs four raw push/get/truncate/release spellings + two block-splitting diamonds with_operands_rooted / with_rooted_group
lower_call/new.rs refresh_rooted_args at three points under a scope marker over ~20 return paths one escaping RootedGroup; the null marker slot is gone with it
lower_call/new_alloc.rs (new file) vacuous, listed anyway — an unlisted sibling of a listed module is where a raw push would go

Nine classified decision-free and deliberately NOT listed: expr/mod.rs (a
module declaration and a field type — both gone with the move); the four
FnCtx constructors (TempRootPool::default() — constructing the pool is not
using it); stmt/loops.rs (one call to a purity predicate); loop_purity.rs
(a doc link); and root_reload.rs / gc_call_effects.rs /
runtime_decls/arrays.rs plus five test files, whose js_gc_temp_root_*
occurrences are runtime symbol names, not this module.
(linker_temp_lifecycle_tests.rs's temp_root_if_clang_available is about a
temporary directory.)

The three leads

  • static_field_meta.rs's caps_arr — VERIFIED as a real accumulator shape
    with a provably empty window.
    The __perry_ctor_caps array held the only
    reference to everything pushed so far across the next element's lowering, in
    a bare SSA register — gc: console.log argument temporaries are not precise roots — a precise-roots-only collection drops string-literal args (minimal repro, no evacuation needed) #6951 exactly. But captured_args is built at ONE site
    (lower/lower_expr/arm_class.rs:153) as
    ids.iter().map(|id| Expr::LocalGet(*id)), and expr_may_trigger_gc answers
    false for every LocalGet. It is now a with_rooted_accumulator whose
    protect is computed rather than assumed: today false, IR byte for byte
    unchanged, and automatically correct the day a non-inert expression reaches
    the list.
  • math_simple.rs's ArrayMap — VERIFIED live. Below.
  • dyn_extern_i18n.rs's path_handle — DISMISSED; the premise is wrong about
    the CFG.
    Each <prefix>__init() is emitted into that iteration's match
    block
    , which branches straight to the join — no __init dominates any later
    use of path_handle. Along the fallthrough chain the only emissions between
    the handle's production and its last use are js_get_string_pointer_unified
    and js_string_equals, neither of which re-enters user code or enumerates an
    object — the standard with_operands_rooted_across_call's doc sets for an
    emitted step (fix(codegen): close #7192's own residual root-store hole, and make the dominance checker able to fail #7198). What that module DID have is the namespace-object
    accumulator, which is migrated.

The live bug: Expr::ArrayMap

arr.map(cb) lowered the receiver, lowered the callback, and only THEN unboxed
the receiver — the unbox sat below its own window and masked a stale box
rather than repairing it (#7280 taxonomy (c)).

IR evidence, main baseline vs this branch, both --profile perry-dev,
PERRY_NO_AUTO_OPTIMIZE=1, PERRY_GC_MOVING_LOOP_POLLS=1, separate target
dirs. Module-global receiver, baseline:

%r1 = load double, ptr @perry_global_m_ts__0        ; receiver
%r2 = call i64 @js_closure_alloc_singleton(...)     ; the window
%r5 = bitcast double %r1 to i64                     ; STALE
%r6 = and i64 %r5, 281474976710655
%r8 = call i64 @js_array_map(i64 %r6, ...)

this branch:

%r1 = load double, ptr @perry_global_m_ts__0
%r2 = bitcast double %r1 to i64
store ptr addrspace(1) %rs4gc.s1, ptr %r3           ; ROOT STORE, dominates the window
%r7 = call i64 @js_closure_alloc_singleton(...)     ; the window
%r10.rs4p = load ptr addrspace(1), ptr %r3          ; RE-READ below it
%r12 = and i64 %r10, 281474976710655
%r14 = call i64 @js_array_map(i64 %r12, ...)
store ptr addrspace(1) null, ptr %r3                ; release, after the call

The same shape appears for a class-field read and for a closure capture
(js_closure_get_capture_bits, a raw i64 — taxonomy (a), which
root_reload structurally cannot repair). Frame slot count is unchanged: the
pooled slot is reused by the watermark for the next temp.

Where the window is NOT real, and why it matters: for an array-typed
local receiver, codegen's ptr addrspace(1) retype pass rematerialises the
load from the local's own root slot at the use site, so the pre-fix code
re-read the receiver by accident. Measured, not assumed — the baseline arm
emits %r65.rs4p = load ptr addrspace(1), ptr %r1 below the closure alloc all
by itself.

Two ways my own verification was vacuous

Both were found by the sabotage arm (restore the pre-fix lowering, require red),
not by reasoning:

  1. Slice 7's assert_operand_survives_the_window cannot see this bug. It
    compares the operand register's OWN definition line against the window, and
    for ArrayMap that register is and i64 %stale, POINTER_MASK — emitted
    below the window while masking a value loaded above it. "The unbox sits
    below its own window" is exactly the shape a one-level check misses. These
    tests chase the definition chain through pure bit-twiddling to the first real
    producer. (Slice 7's own tests are not wrong — their subjects are raw
    pointers stripped above the window — but the helper is weaker than its
    name.)
  2. A local receiver makes the test measure nothing, per the paragraph above.
    The tests use a field read.

★ A trap worth recording: a sabotage harness that restores a .bak

cp f f.bak; <patch f>; cargo test; mv f.bak f leaves f with an older
mtime than the sabotaged build, so cargo keeps the sabotaged binary and every
later run measures the sabotage. It presented as an intermittent lowering-test
failure — 1 run in 10, then 20 in 20 — with byte-identical IR between the
green and red runs
, which reads exactly like the process-global sinks #7665
fixed and is not. touch after the restore. Diagnosing from the wrong VALUE is
what settled it: "the producer is one line above the window" is precisely the
pre-fix lowering, not a race.

Verification

  • node 26.5.1 byte-for-byte, 27 lines of output, four arms — {main
    baseline, this branch} × {PERRY_GC_MOVING_LOOP_POLLS 0, 1} — over a probe
    covering ArrayMap (including evaluation order), the five dynamic binary
    arms, string append/concat/chain/methods, Map set/get/has (typed and
    generic), class expressions with statics + captures + static {} blocks,
    new with allocating and literal arguments, subclassing, and a 4,000-object
    churn between them. All four MATCH; baselines built in separate target
    dirs, PERRY_NO_AUTO_OPTIMIZE=1 throughout.
  • 22/22 lint-workflow commands (extracted from .github/workflows/test.yml)
    plus rustup run stable cargo fmt --all -- --check and
    ./scripts/check_file_size.sh (largest file now rooting/mod.rs at 1,880).
  • cargo test -p perry-codegen --lib --no-fail-fast 747 passed, and 20
    consecutive full-suite runs green
    after the mtime fix.
  • cargo test -p perry-runtime --lib --no-fail-fast 1,917 passed.
  • cargo check --all-targets clean.
  • 14/14 native_root_coverage.
  • Both dominance arms, release build, subject-liveness asserted:
    • --moving-only --seeded-violations 40: 129/129 sources, 149 modules, 40
      planted / 40 caught / 0 missed
      , 0 violations.
    • --unrooted-allocas --moving-only: 7,695 gc-capable allocas, 0.
    • --statepoints --moving-only --max-unrooted 21 --max-stale 0: 2,458
      functions, 30,080 safepoints, 17,516 live bundles, 40,819 relocates, 40
      planted / 40 caught
      , hazards 21 unrooted / 0 stale — unchanged, and
      deliberately not touched while gc: 21 unrooted-across-safepoint hazards in the NATIVE root lowering (the --max-unrooted budget's referent) #7664's budget is being lowered separately.
  • Sabotage, 10 arms, 0 failures: eight textual (plant a raw-API mention in
    each newly listed module, require the ledger red AND naming the file:line)
    and two visibility (plant a real raw call, require E0603).

What this does NOT claim

The ledger's own caveat, drawn the hard way in slice 4: a listed module cannot
make an ORDERING mistake against the raw API, because it no longer names it. A
window with no rooting decision at all is invisible to that check, and the
only instrument for it is reading the module. 36 modules are listed;
docs/engine-plan.md item 7 records the remaining audit as the open half, with
#7640 holding the deferred sites, and Layer 3 as the rest of the rooting work.

Summary by CodeRabbit

  • Bug Fixes

    • Improved compiler reliability for arrays, maps, strings, constructors, asynchronous operations, and dynamic property handling.
    • Prevented intermediate values from being lost during operations that may trigger memory collection.
  • Performance

    • Optimized string concatenation and object construction, including fewer allocations in common cases.
  • Tests

    • Added coverage for value preservation and allocation behavior across key operations.
  • Documentation

    • Updated engineering documentation to reflect completion of the latest memory-safety milestone.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 36fa5526-fc79-47a1-8727-083700aa5e1e

📥 Commits

Reviewing files that changed from the base of the PR and between 66c3045 and d21c41f.

📒 Files selected for processing (1)
  • crates/perry-codegen/src/expr/mod.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/perry-codegen/src/expr/mod.rs

📝 Walkthrough

Walkthrough

Layer 1 rooting migration is complete for the listed codegen slices. Raw rooting APIs moved into a private module, affected lowering paths use RootedGroup and rooted accumulators, constructor and string lowering were split, and slice 8 tests were added.

Changes

Rooting migration

Layer / File(s) Summary
Private rooting boundary and migration ledger
crates/perry-codegen/src/rooting/*, crates/perry-codegen/src/expr/mod.rs, docs/engine-plan.md, changelog.d/*
Raw rooting APIs moved under crate::rooting::temp_root. Accessors are restricted, obsolete APIs were removed, and migration enforcement tests were added.
Constructor rooting and allocation
crates/perry-codegen/src/lower_call/*
Constructor arguments and instances now use RootedGroup. Instance allocation moved to new_alloc.rs with dynamic, outlined, inline, and packed-key paths.
String lowering extraction and rooting
crates/perry-codegen/src/lower_string_concat.rs, crates/perry-codegen/src/lower_string_method.rs, crates/perry-codegen/src/lower_string_method/*
String concatenation moved to a dedicated module. String methods use grouped receiver rooting and rooted accumulators.
Expression rooting and slice 8 validation
crates/perry-codegen/src/expr/*
Binary, map, i18n, static-field, await, and reflection paths use shared rooting APIs. Tests verify receiver rereads after allocating operands and no rooting traffic for inert callbacks.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related issues

Possibly related PRs

  • PerryTS/perry#6972 — Earlier temporary-rooting work that this change refactors.
  • PerryTS/perry#6983 — Earlier map, constructor, and string-method rooting work migrated here.
  • PerryTS/perry#7662 — Earlier RootedGroup::adopt_emitted migration work extended by this change.

Suggested labels: run-extended-tests

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the Layer 1 rooting change and the raw API visibility outcome.
Description check ✅ Passed The description thoroughly explains the changes, rationale, related issue, risks, and extensive verification, despite not following every template heading.
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 fix/7615-rooting-slice-8

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

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (5)
crates/perry-codegen/src/expr/slice7_rooting_tests.rs (2)

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

assert_operand_survives_the_window has no cross-module consumer.

slice8_rooting_tests.rs imports only allocating, require_call_line and temp_root_calls. Its module header states explicitly that it does not reuse this helper, because a one-level definition check cannot see the Expr::ArrayMap window. Keeping the widened visibility suggests the opposite. Consider reverting this one to private.

♻️ Proposed change
-pub(super) fn assert_operand_survives_the_window(ir: &str, callee: &str, n: usize, what: &str) {
+fn assert_operand_survives_the_window(ir: &str, callee: &str, n: usize, what: &str) {
🤖 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/expr/slice7_rooting_tests.rs` at line 133, Revert
the visibility of assert_operand_survives_the_window to private, since no other
module consumes it and slice8_rooting_tests.rs explicitly does not reuse this
helper.

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

The slice 7 → slice 8 test-helper visibility split is applied inconsistently. Five helpers were involved. Three (allocating, require_call_line, temp_root_calls) were widened and are imported. One (assert_operand_survives_the_window) was widened and has no consumer. One (compile_body) was not widened and was copied verbatim into slice 8 instead, under a rationale that describes a semantic difference that does not exist. Pick one rule — export what slice 8 uses, keep the rest private — and apply it to all five.

  • crates/perry-codegen/src/expr/slice7_rooting_tests.rs#L133-L133: revert assert_operand_survives_the_window to private, since slice8_rooting_tests.rs deliberately does not use it.
  • crates/perry-codegen/src/expr/slice8_rooting_tests.rs#L172-L201: widen slice7_rooting_tests::compile_body to pub(super) and import it, deleting this byte-identical copy; if the copy is kept, replace the "these arms need a local" rationale with the real reason.
🤖 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/expr/slice7_rooting_tests.rs` at line 133, The slice
7-to-slice 8 helper visibility and reuse rules are inconsistent. In
crates/perry-codegen/src/expr/slice7_rooting_tests.rs:133-133, make
assert_operand_survives_the_window private again because slice 8 does not use
it; in crates/perry-codegen/src/expr/slice8_rooting_tests.rs:172-201, widen
slice7_rooting_tests::compile_body to pub(super), import and reuse it, and
remove the byte-identical local copy.
crates/perry-codegen/src/lower_string_concat.rs (1)

56-58: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the unused _lhs binding.

_lhs is never read. The underscore prefix hides it from the rustc unused warning, so it will stay indefinitely. The file move is the cheapest moment to drop it.

♻️ Proposed cleanup
         let lhs_val = ctx.block().load(DOUBLE, &slot);
-        let _lhs = lhs_val.clone();
         let rhs_val = lower_expr(ctx, rhs)?;
🤖 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/lower_string_concat.rs` around lines 56 - 58, Remove
the unused _lhs binding from the string-concatenation lowering flow, while
retaining lhs_val and the subsequent lower_expr call used by
lower_string_concat.
crates/perry-codegen/src/lower_string_method.rs (1)

228-232: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Consider narrowing the raw recv_box parameter.

lower_string_method_dispatch now receives both the raw receiver register and the group. Only the concat arm reads the raw register, and it is safe there because it unboxes before any argument is lowered. Every other arm must use reread_recv. This keeps a stale register in scope for ~60 arms, which is the exact hazard RootedGroup removes by never handing the lowered register back.

One option: hoist the concat arm's eager unbox into lower_string_method next to adopt_emitted, pass the resulting handle instead of recv_box, and give it a name that states it is pre-window (for example recv_handle_prewindow). A future arm then cannot pick up a boxed receiver by accident.

Not blocking. The current code is correct.

🤖 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/lower_string_method.rs` around lines 228 - 232,
Narrow lower_string_method_dispatch by removing the raw recv_box parameter and
eagerly unboxing the receiver in lower_string_method alongside adopt_emitted.
Pass the resulting pre-window receiver handle with a name such as
recv_handle_prewindow, update only the concat arm to use it, and keep all other
arms on reread_recv.
crates/perry-codegen/src/expr/slice8_rooting_tests.rs (1)

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

The section header names Expr::MapHas, but no MapHas test follows.

math_simple.rs migrated MapGet and MapHas to with_operands_rooted in the same slice. Only MapGet is covered here. Either add the mirroring MapHas case or drop MapHas from the header at line 315.

The MapHas case is a near-copy of map_get_receiver_survives_an_allocating_key with js_map_has as the consumer. Do you want me to generate it?

🧪 Proposed test
/// `m.has(k)` has the same receiver-before-key ordering as `m.get(k)`.
#[test]
fn map_has_receiver_survives_an_allocating_key() {
    let ir = compile_body(
        "map_has_window",
        with_object_local(
            1,
            Stmt::Expr(Expr::MapHas {
                map: Box::new(field_receiver(1)),
                key: Box::new(allocating("k")),
            }),
        ),
    );
    assert_reread_below_operand(
        &ir,
        "js_map_has",
        0,
        "js_map_has",
        1,
        "Map.has evaluates the receiver first and the key second",
    );
}
🤖 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/expr/slice8_rooting_tests.rs` around lines 314 -
342, Add a mirroring test beside map_get_receiver_survives_an_allocating_key for
Expr::MapHas, using the map_has_window fixture and asserting receiver rereading
around the js_map_has consumer. Keep the receiver-before-key ordering assertion
and update the test documentation to describe Map.has.
🤖 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 `@crates/perry-codegen/src/lower_call/new.rs`:
- Around line 216-219: Update the tail of the lowering flow around
lower_new_impl_inner to call group.release(ctx) only when
ctx.block().is_terminated() is false; preserve returning result unchanged and
avoid emitting the release operation after lower_new_impl_inner has terminated
the block.

In `@crates/perry-codegen/src/rooting/temp_root.rs`:
- Around line 655-662: Update the doc comments for temp_root_release and guard
to remove the deleted lower_exprs_rooted reference and replace the obsolete
rest-argument-lowering wording with the current RootedOperands::guard,
RootedGroup::first_slot, and RootedGroup::adopt terminology. Keep the
documentation focused on the existing guard lifecycle and caller behavior.

---

Nitpick comments:
In `@crates/perry-codegen/src/expr/slice7_rooting_tests.rs`:
- Line 133: Revert the visibility of assert_operand_survives_the_window to
private, since no other module consumes it and slice8_rooting_tests.rs
explicitly does not reuse this helper.
- Line 133: The slice 7-to-slice 8 helper visibility and reuse rules are
inconsistent. In crates/perry-codegen/src/expr/slice7_rooting_tests.rs:133-133,
make assert_operand_survives_the_window private again because slice 8 does not
use it; in crates/perry-codegen/src/expr/slice8_rooting_tests.rs:172-201, widen
slice7_rooting_tests::compile_body to pub(super), import and reuse it, and
remove the byte-identical local copy.

In `@crates/perry-codegen/src/expr/slice8_rooting_tests.rs`:
- Around line 314-342: Add a mirroring test beside
map_get_receiver_survives_an_allocating_key for Expr::MapHas, using the
map_has_window fixture and asserting receiver rereading around the js_map_has
consumer. Keep the receiver-before-key ordering assertion and update the test
documentation to describe Map.has.

In `@crates/perry-codegen/src/lower_string_concat.rs`:
- Around line 56-58: Remove the unused _lhs binding from the
string-concatenation lowering flow, while retaining lhs_val and the subsequent
lower_expr call used by lower_string_concat.

In `@crates/perry-codegen/src/lower_string_method.rs`:
- Around line 228-232: Narrow lower_string_method_dispatch by removing the raw
recv_box parameter and eagerly unboxing the receiver in lower_string_method
alongside adopt_emitted. Pass the resulting pre-window receiver handle with a
name such as recv_handle_prewindow, update only the concat arm to use it, and
keep all other arms on reread_recv.
🪄 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: 6fc5641b-875a-4e5f-bd83-ffc7e6bcb26b

📥 Commits

Reviewing files that changed from the base of the PR and between 3beef03 and 66c3045.

📒 Files selected for processing (29)
  • changelog.d/7670-layer1-rooting-slice8-terminal-condition.md
  • crates/perry-codegen/src/codegen/closure.rs
  • crates/perry-codegen/src/codegen/entry.rs
  • crates/perry-codegen/src/codegen/function.rs
  • crates/perry-codegen/src/codegen/method.rs
  • crates/perry-codegen/src/expr/binary.rs
  • crates/perry-codegen/src/expr/dyn_extern_i18n.rs
  • crates/perry-codegen/src/expr/fs_await.rs
  • crates/perry-codegen/src/expr/literals_vars.rs
  • crates/perry-codegen/src/expr/math_simple.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-codegen/src/expr/proxy_reflect.rs
  • crates/perry-codegen/src/expr/shadow_slot.rs
  • crates/perry-codegen/src/expr/slice7_rooting_tests.rs
  • crates/perry-codegen/src/expr/slice8_rooting_tests.rs
  • crates/perry-codegen/src/expr/static_field_meta.rs
  • crates/perry-codegen/src/lib.rs
  • crates/perry-codegen/src/loop_purity.rs
  • crates/perry-codegen/src/lower_call/mod.rs
  • crates/perry-codegen/src/lower_call/new.rs
  • crates/perry-codegen/src/lower_call/new_alloc.rs
  • crates/perry-codegen/src/lower_string_concat.rs
  • crates/perry-codegen/src/lower_string_method.rs
  • crates/perry-codegen/src/lower_string_method/char_code_at.rs
  • crates/perry-codegen/src/root_reload.rs
  • crates/perry-codegen/src/rooting/mod.rs
  • crates/perry-codegen/src/rooting/temp_root.rs
  • crates/perry-codegen/src/stmt/loops.rs
  • docs/engine-plan.md

Comment on lines +216 to 219
let mut group = open_rooted_group(args.len() + 1);
let result = lower_new_impl_inner(ctx, class_name, args, caps_absent_from_args, &mut group);
group.release(ctx);
result

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check whether temp_root_release / temp_root_truncate guard on block termination.
set -euo pipefail

fd -t f 'temp_root.rs' crates/perry-codegen/src/rooting --exec ast-grep outline {} --items all

fd -t f 'temp_root.rs' crates/perry-codegen/src/rooting \
  --exec rg -n -C 12 'fn temp_root_release|fn temp_root_truncate' {}

# Compare with the guarded caller and any other open_rooted_group users.
rg -n -C 4 'open_rooted_group\(' crates/perry-codegen/src
rg -n -C 3 'is_terminated\(\)' crates/perry-codegen/src/rooting

Repository: PerryTS/perry

Length of output: 8736


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== new.rs tail and release =="
sed -n '200,222p' crates/perry-codegen/src/lower_call/new.rs

echo "== string guarded release context =="
sed -n '160,205p' crates/perry-codegen/src/lower_string_method.rs

echo "== temp_root_truncate implementation =="
sed -n '220,255p' crates/perry-codegen/src/rooting/temp_root.rs

echo "== RootedGroup release and open_rooted_group definitions =="
rg -n -C 8 'struct RootedGroup|impl RootedGroup|fn open_rooted_group|pub fn release|fn release' crates/perry-codegen/src/rooting/mod.rs crates/perry-codegen/src/rooting

echo "== temp_root_release implementation =="
sed -n '650,662p' crates/perry-codegen/src/rooting/temp_root.rs
sed -n '630,662p' crates/perry-codegen/src/rooting/temp_root.rs

echo "== any_may_emit_unreachable / is_terminated usage in new/string =="
rg -n -C 3 'unreachable\(|is_terminated\(\)|Release|release' crates/perry-codegen/src/lower_call/new.rs crates/perry-codegen/src/lower_string_method.rs

Repository: PerryTS/perry

Length of output: 31650


Guard group.release on terminated blocks.

lower_new_impl_inner can emit unreachable, and then the body returns Ok(...) before reaching this tail. group.release(ctx) calls temp_root_truncate, which emits a runtime call and is invalid after a terminator. Add the !ctx.block().is_terminated() guard before releasing the RootedGroup.

🤖 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/lower_call/new.rs` around lines 216 - 219, Update
the tail of the lowering flow around lower_new_impl_inner to call
group.release(ctx) only when ctx.block().is_terminated() is false; preserve
returning result unchanged and avoid emitting the release operation after
lower_new_impl_inner has terminated the block.

Comment on lines 655 to 662
/// Release a guard returned by [`lower_exprs_rooted`]. Call it *after* the
/// consuming call, not before: the consumer allocates while reading these
/// values.
pub(crate) fn temp_root_release(ctx: &mut FnCtx<'_>, guard: Option<String>) {
pub(in crate::rooting) fn temp_root_release(ctx: &mut FnCtx<'_>, guard: Option<String>) {
if let Some(idx) = guard {
temp_root_truncate(ctx, &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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update the doc comment: lower_exprs_rooted was deleted in this change.

The doc on temp_root_release links to [lower_exprs_rooted], which this slice removed (the ledger doc in rooting/mod.rs lists it among the fourteen deleted items). The guard now comes from RootedOperands::guard or RootedGroup::first_slot. Rustdoc cannot resolve the link, and the prose points a reader at a symbol that no longer exists.

The same staleness applies to the guard() doc just above: it names "the rest-argument lowering" as the caller, but the in-module caller is now RootedGroup::adopt.

📝 Proposed doc fix
-/// Release a guard returned by [`lower_exprs_rooted`]. Call it *after* the
-/// consuming call, not before: the consumer allocates while reading these
-/// values.
+/// Release a guard returned by [`RootedOperands::guard`]. Call it *after* the
+/// consuming call, not before: the consumer allocates while reading these
+/// values.
 pub(in crate::rooting) fn temp_root_release(ctx: &mut FnCtx<'_>, guard: Option<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/rooting/temp_root.rs` around lines 655 - 662, Update
the doc comments for temp_root_release and guard to remove the deleted
lower_exprs_rooted reference and replace the obsolete rest-argument-lowering
wording with the current RootedOperands::guard, RootedGroup::first_slot, and
RootedGroup::adopt terminology. Keep the documentation focused on the existing
guard lifecycle and caller behavior.

Ralph Küpper added 7 commits August 9, 2026 05:31
…w.rs (#7615)

`new.rs` was 1,988 lines against `scripts/check_file_size.sh`'s 2,000-line
cap, which blocked the Layer 1 rooting migration (#7615 slice 8) — that
migration has to ADD lines to the file, replacing `refresh_rooted_args`
and the `temp_root_scope_*` marker with a `RootedGroup`.

Pure move, no behaviour change: `lower_new_impl_inner`'s field-count
computation and its three-arm object allocation become
`new_alloc::emit_instance_alloc(ctx, class_name, class) -> String`.
`new_site_is_in_loop` moves with them (its only caller is the inline
bump-allocator arm). The boundary is a boundary rather than a cut because
none of the locals the block defines — `field_count`, `cid_str`,
`parent_cid_str`, `n_str`, `packed_keys`, `alloc_field_count` — is read
anywhere below the allocation.

No rooting decision moves with it: everything the extracted block emits
sits ABOVE the instance root, whose push is the caller's next act on the
returned handle.

new.rs 1,988 -> 1,501; new_alloc.rs 531.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
…7615)

`lower_string_method.rs` was 1,957 lines against the 2,000-line cap, and
the Layer 1 rooting migration adds closure scopes to five of its
functions — `with_operands_rooted` and `with_rooted_accumulator` both
re-indent the body they own, which is line growth on a file with 43 lines
of headroom.

Pure move at the boundary the file already had: everything above
`lower_string_self_append` dispatches a `str.<method>(...)` call,
everything from it down lowers `a + b` / `s += x` on strings.
`str_operand_handle_tag_dispatched` becomes `pub(crate)` because three
dispatch arms above still call it.

lower_string_method.rs 1,957 -> 1,368; lower_string_concat.rs 612.

Also lands the first four module migrations of slice 8 (they share the
`expr/binary.rs` import line with the move):

* `expr/binary.rs` — five `lower_operand_pair_rooted` + `temp_root_release`
  pairs collapse into one `lower_rooted_dynamic_binary` helper over
  `with_operands_rooted`.
* `expr/math_simple.rs` — `MapSet` becomes a `RootedGroup` (two operands,
  unequal windows, eight arm-specific re-read points); `MapGet`/`MapHas`
  become `with_operands_rooted`. `Expr::ArrayMap` gains the root it never
  had: the receiver was lowered, the callback was lowered, and only THEN
  was the receiver unboxed — the unbox sat below its own window.
* `expr/static_field_meta.rs` — `ClassExprFresh` becomes a `RootedGroup`
  over the class object plus a nested `with_rooted_accumulator` for the
  `__perry_ctor_caps` snapshot array, which was threaded through a bare
  SSA register.
* `expr/dyn_extern_i18n.rs` — the namespace-object build becomes
  `with_rooted_accumulator`.
* `lower_call/new.rs` — `refresh_rooted_args` and the
  `temp_root_scope_begin`/`_end` marker become one escaping `RootedGroup`;
  the null marker slot is gone with them.

`RootedGroup::adopt_emitted` gains a `protect` flag (the WINDOW, not the
strategy) and `RootedGroup::is_rooted` returns whether a slot exists.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
…7615)

The campaign's terminal condition, made true: `expr/temp_root.rs` is now
`crate::rooting::temp_root`, declared with a PRIVATE `mod temp_root;` and
with every accessor additionally carrying `pub(in crate::rooting)`.

The plan spelled the condition as "`expr/temp_root.rs` going
`pub(in crate::rooting)`", which is not expressible in Rust — `pub(in path)`
requires `path` to be an ancestor module of the item (E0742), and
`crate::rooting` is not an ancestor of `crate::expr::temp_root`. Hence the
move. Both belts are worn because either alone is one keyword from being
undone.

Two items keep `pub(crate)` and are re-exported from `rooting/mod.rs`;
neither is an accessor and neither can be called in the wrong order:
`TempRootPool` (compile-time slot bookkeeping `FnCtx` owns) and
`expr_is_inert_primitive` (the shared "can evaluating this run user code?"
predicate the loop back-edge poll consults).

Fourteen items are DELETED rather than narrowed, because the migration
left them with no caller: `lower_exprs_rooted`, `lower_operand_pair_rooted`,
`any_later_ref_may_trigger_gc`, `RootedOperands::is_rooted`, the whole
`StoreOperandGuard` family and the whole `RootedHandle` family, and
`temp_root_scope_begin`/`_end`. CLAUDE.md's kill-policy: the losing mode
should stop compiling.

Eight modules migrate (seven load-bearing on the committed source, one —
`lower_call/new_alloc.rs` — vacuous and listed anyway so an unlisted
sibling of a listed module cannot become the place a raw push goes):
`expr/binary.rs`, `expr/math_simple.rs`, `expr/static_field_meta.rs`,
`expr/dyn_extern_i18n.rs`, `lower_string_method.rs`,
`lower_string_concat.rs`, `lower_call/new.rs`, `lower_call/new_alloc.rs`.

Nine further files mention the raw API and make no rooting decision, so
they are deliberately NOT listed: `expr/mod.rs` (module declaration and a
field type, both gone with the move), the four `FnCtx` constructors
(`TempRootPool::default()`), `stmt/loops.rs` (one purity predicate),
`loop_purity.rs` (a doc link only), and `root_reload.rs` /
`gc_call_effects.rs` / `runtime_decls/arrays.rs` plus five test files,
whose `js_gc_temp_root_*` occurrences are runtime SYMBOL NAMES.

One live bug fixed: `Expr::ArrayMap` lowered the receiver, lowered the
callback, and only then unboxed the receiver — the unbox sat below its own
window and masked a stale box rather than repairing it (#7280 taxonomy
(c)).

New: a terminal-condition test over `temp_root.rs`'s own source, with its
own sabotage arm.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
…cuous (#7615)

Four lowering tests over emitted IR, plus the terminal-condition test's
ledger entry and the campaign's close-out in docs/engine-plan.md.

★ Both vacuities were MEASURED by the sabotage arm (restore the pre-fix
`Expr::ArrayMap` lowering, require red), not reasoned about:

1. Slice 7's `assert_operand_survives_the_window` compares the operand
   register's OWN definition line against the window. For `ArrayMap` that
   register is `and i64 %stale, POINTER_MASK` — emitted BELOW the window
   while masking a value loaded above it. A one-level check cannot see
   "the unbox sits below its own window", which is the bug. These tests
   chase the definition chain through pure bit-twiddling to the first
   real producer.

2. An array-typed LOCAL receiver has no window at all: codegen's
   `ptr addrspace(1)` retype pass rematerialises the load from the local's
   own root slot at the use site, so the pre-fix code re-read the receiver
   by accident. The windows that are real — verified by A/B on emitted IR
   against a `main` baseline — are the receivers with no slot to
   rematerialise from: a module global, a class-field read and a closure
   capture. The tests use the field read.

The window is anchored on the LATER OPERAND'S producer rather than on
"the last object allocation above the call", because which helper an
`Expr::Object` lowering reaches for is not this module's property.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
#7667 added new_target_save using crate::expr::temp_root while this slice
moved the module to crate::rooting::temp_root. The two PRs were developed in
parallel; the break only appears once both are on the same tree.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit — merging as v0.5.1384. The Layer-1 campaign is finished.

The terminal condition, verified two ways

I planted a raw call in a migrated module — once at top level (math_simple.rs), once in a nested submodule (static_field_meta.rs). Both: error[E0603]: module temp_root is private. The raw accessor is unreachable, not merely uncounted. That is the difference between a ledger line and a guarantee, and it is what eight slices were for.

Your correction to the plan's own wording is right and I'd have got it wrong. pub(in path) requires path to be an ancestor (E0742), and crate::rooting is not an ancestor of crate::expr::temp_root — so the condition as written is not expressible in Rust. Moving the file and using both a private mod temp_root; and pub(in crate::rooting) on all 25 accessors is the honest reading of the intent, and belt-and-braces is right for something one keyword from being undone.

Fourteen entry points deleted rather than narrowed, per the kill-policy. A narrowed-but-live API is a decision nobody has made.

An integration break I caught building it

The rebase surfaced a genuine one: #7667 added new_target_save using crate::expr::temp_root, while this slice moved the module — error[E0433]: cannot find temp_root in expr. Neither PR could see it; it exists only once both are on one tree. Fixed here (the module is a private sibling inside crate::rooting, so it names directly). Worth noting that the first thing I ran was a plain cargo check, and that is what found it — the sabotage would have "passed" against an already-broken baseline.

Also resolved two rebase conflicts: root_reload.rs (kept #7667's doc block, took your re-pointed intra-doc link) and docs/engine-plan.md (kept main's item 6 as closed by #7669, took your item 7).

Three corrections to my brief, all mine

lower_string_method.rs has 27 escape-hatch sites, not the 3 I counted — my grep missed use crate::expr::temp_root::{…} imports, so my census was wrong for every module that imports rather than fully-qualifies. It, not new.rs, was the real blocker at 1,957/2,000. loop_purity.rs has zero (its hit is a doc link), and two files I omitted have five each, all runtime symbol-name strings.

The leads, and the discipline in how you closed them

caps_arrreal accumulator shape, provably empty window: captured_args is built at one site as LocalGets, and expr_may_trigger_gc is false for every LocalGet. Making it a with_rooted_accumulator whose protect is computed — false today, IR byte-identical, correct by construction if the list ever gains a non-inert element — is better than either fixing or dismissing it.

path_handledismissed, and my brief's premise was the thing that was wrong: each __init() is emitted into that iteration's match block which branches straight to the join, so no __init dominates any later use.

ArrayMapconfirmed live, with the IR showing the receiver unboxed below its own window and the fix's root store dominating it.

★ The trap worth carrying

cp f f.bak; patch; cargo test; mv f.bak f restores an older mtime, so cargo keeps the sabotaged binary.

It presented as a 1-in-10 lowering-test failure with byte-identical IR between green and red runs — indistinguishable from #7665's global sinks, which I had just spent hours on. touch after restore. And you settled it by diagnosing from the wrong value ("producer one line above the window" = exactly the pre-fix lowering) rather than from the timing — the same method that cracked all three global-sink flakes.

Gates: 22/22 lint, fmt clean, check_file_size.sh clean (largest now rooting/mod.rs at 1,931), perry-codegen --lib 761, perry-runtime --lib 1917, native_root_coverage 14/14, cargo check --all-targets clean.

What you explicitly do not claim — that a listed module cannot make an ordering mistake, while a window with no decision at all stays invisible to the ledger — is the right caveat to leave standing, with #7640 and Layer 3 named as the open half.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant