Skip to content

refactor(codegen): migrate the computed read/write modules onto the Layer 1 rooting API (#7615) - #7642

Merged
proggeramlug merged 4 commits into
mainfrom
refactor/layer1-slice4
Aug 8, 2026
Merged

refactor(codegen): migrate the computed read/write modules onto the Layer 1 rooting API (#7615)#7642
proggeramlug merged 4 commits into
mainfrom
refactor/layer1-slice4

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Slice 4 of the Layer 1 campaign (#7615): the computed read/write lowerings.

Nine modules, all migrated end to end and listed in MIGRATED_MODULES
expr/index_get.rs, expr/index_get/guarded_array.rs,
expr/index_get/inline_dyn_typed_array.rs, expr/index_set.rs,
expr/index_set_typed_array.rs, expr/property_get.rs,
expr/property_get/globalget.rs, expr/property_get/helpers.rs,
expr/property_set.rs. Nothing names expr::temp_root after this, and no
module is left half-migrated.

One shape, no fourth combinator

Every rooting decision in this slice was the StoreOperandGuard family — lower
the receiver, guard it, lower the rest, re-read, store, release — and that is
exactly what with_operands_rooted / with_operands_rooted_across already say.
The across form is needed wherever the value is lowered by
lower_value_for_dynamic_{index,property}_set or
lower_value_for_optional_barrier, native-rep lowerings the operand list cannot
produce; everywhere else the plain form serves.

Honest scoping: three load-bearing, six vacuous

index_get.rs, index_set.rs and property_set.rs named the escape hatch
before the migration (guard_store_operand, guard_store_operand_across,
reread_store_operand, release_store_operand, expr_may_trigger_gc), so
their ledger lines hold on the committed source.

The other six named nothing, so their lines are vacuous and only the
sabotage arm makes them assertions. Each carries the audit that earned it in its
own file header rather than banking the count here. The one worth repeating:
the campaign map credits property_get/globalget.rs with 6 hazard sites and
all six are the same false positive — js_get_global_this_builtin_value
ctx.strings.internunbox_to_i64load. intern and format! emit no
IR. A window is measured in emissions, and there are none. That module lowers
no user expression at all.

property_get.rs's three .call(I64, "js_*") sites each hand their raw pointer
to a nanbox_*_inline in the same block, so call_rooted has no site there —
rooting them would add temp-root traffic to close a window that does not exist.

Three live bugs, fixed here

Each is the same window every sibling arm has guarded since #7154, and each is
on an arm whose store already routes through a runtime helper, so the root is
noise beside the call.

That last one is the argument for the combinator rather than for a better flag.
with_operands_rooted_window computes each operand's window as
across_collects || any_may_trigger_gc(exprs[i+1..]), so "the receiver is live
across everything after it" becomes a property of the operand list instead of
something an author restates per site. guard_store_operand's two-argument form
structurally could not say it. The same arm's two nested guards also collapse to
one group, retiring the release-inner-to-outer obligation that
temp_root_truncate's stack-cut semantics imposed.

What the migration surfaced and did NOT fix — #7640

Translating the guarded arms made visible that ~20 arms in the same files make
no rooting decision at all: the bounded-index array store (which sits
immediately above the arm #7341 fixed, and whose loop predicate explicitly
accepts allocating object/array RHSs), the #5525 inline typed-array stores,
globalThis[k] = v, ten read-side arms of index_get.rs where the base is live
across the key, an unsubstantiated "statepoint re-read" claim above the
class-field store, and a unbox_str_handle ordering hazard that is #7280's
case (a) — a raw i64 no temp root can name.

Filed with repros and a suggested order rather than fixed, because they sit on
inline fast paths and guard diamonds where a root is a measured cost, and
this slice was not permitted to benchmark. Slice 3's arr.push precedent
(#7634) is the standard: a behaviour-preserving refactor does not smuggle in a
change whose cost nobody measured.

That gap is now written into the ledger itself, because it is the campaign's
most misreadable result:

A listed module is not an audited module. The ledger asserts that every
rooting decision a module makes goes through crate::rooting. A window with
no decision at all is invisible to it.

Verification

IR identity. Byte-identical on all 149 modules of the curated corpus. On the
dependency-scale (zod) corpus, 80 of 81 byte-identical; the single difference is
one function, perry_closure_..._schemas_ts__126, and it is root-plumbing only:
the dynamic-string-key IndexSet arm's two operands are now re-read and
released in operand-list order (receiver, key) instead of the hand-written
inner-to-outer order (key, receiver). Same two slots, same set of clears, no
root added or removed — 32 diff lines, all register renumbering downstream of
that swap.

Probe vacuity checked before trusting the A/B. Every migrated arm's call
sites were counted in the emitted IR first: 543 object_get_field_by_name_f64,
37 object_set_field_by_name, 16 object_get_index_polymorphic, 10
array_set_index_or_string, and so on. The one arm the curated corpus never
calls is js_array_set_length_strictzero calls across all 129 sources
which is why the repairs get unit tests instead.

Gates. Both gated modes green on both corpora with an empty allowlist;
--seeded-violations 40 at 40/40 on each; --unrooted-allocas --moving-only at
0 over 7867 (curated) and 15242 (dep) gc-capable allocas; stale-register ratchet
unchanged at 23 / 115. Checker --self-test, --audit-alloc-re,
--audit-poll-capable, --audit-immovable-sources all clean.

Ledger sabotage, per module. A real, compiling temp_root_push_double /
temp_root_truncate pair was planted in each of the nine in turn — the assert
stops at the first offender, so one run cannot speak for nine — and each was
recorded red naming its own file and lines. Two of the nested submodules needed
crate::expr::temp_root:: rather than super::temp_root::; with super:: the
plant did not compile, which is a build failure and not a sabotage, so those two
were re-run with the resolving path.

Tests. -p perry-codegen --lib 702 passed; --doc 3 passed (both
compile_fail doctests still reject); -p perry-runtime --no-fail-fast 1902
passed, 0 failed, no rerun needed. cargo check --all-targets clean — the
#[cfg(test)] check #7631 asked for.

Gap families, both arms, PERRY_NO_AUTO_OPTIMIZE=1, prebuilt release
binaries per arm: test_gap_computed (5), test_gap_prop (1),
test_gap_array (13), test_gap_object (9), test_gap_gc_ (30). Verdict sets
identical on both arms
, including the two pre-existing failures —
test_gap_prop_plan_cache_invalidation (parity) and
test_gap_gc_same_module_call_argument_rooting (crash) — which reproduce on a
baseline build of e4b298946 and are not this PR's.

Lint: 23 of 24 enumerated steps pass. The one red is
benchmarks/ci_public_baseline_check.py, red on pristine main since
2026-07-29 and reported by #7618, #7620, #7627 and #7636 alike.

Coverage for the repaired arms

They are not reachable from ordinary TypeScript, and that was measured rather
than assumed. Six probe shapes — module-const and parameter receivers, string /
symbol / any keys, inside and outside a function — all routed past the
subject, because an ordinary o.k = v / o[k] = v assignment lowers to
Expr::PutValueSet and reaches the dynamic IC, not the Expr::PropertySet /
Expr::IndexSet arms in these modules.

So the coverage is built from HIR, and each test is differential: the same
store compiled twice, once with an allocating RHS and once with an inert one,
asserting the allocating one reserves strictly more root slots. An inert RHS
makes operand_protection return Reuse and the combinator emit nothing —
which is the property keeping this slice byte-identical on the corpus — so the
two runs bracket the repair and no corpus drift can make the assertion vacuous.
Each test also asserts by name that its arm was reached, and the slot count is
read under both root lowerings so it cannot silently measure zero. Sabotaging
the arr.length repair turns exactly that one test red, and only it.

Closes #7637, closes #7638, closes #7639. Map comment on #7615 follows.

Summary by CodeRabbit

  • Bug Fixes

    • Improved reliability for computed property reads and writes, including array indexing, dynamic keys, and array-length updates.
    • Prevented potential instability during operations that trigger memory allocation.
    • Preserved correct runtime behavior for numeric, string, and symbol-based property access.
  • Tests

    • Added coverage for computed stores and allocation-sensitive scenarios across supported runtime paths.
  • Documentation

    • Expanded release documentation covering the completed memory-safety improvements and verification results.

proggeramlug pushed a commit that referenced this pull request Aug 8, 2026
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Computed index reads and writes now use grouped rooting combinators. Property-set paths cover array length and dynamic stores. The migration ledger and HIR tests document and verify the changes.

Changes

Computed access rooting

Layer / File(s) Summary
Computed read rooting
crates/perry-codegen/src/expr/index_get.rs, crates/perry-codegen/src/expr/index_get/*, crates/perry-codegen/src/expr/property_get*
Index reads root receiver and index operands together during lowering. Supporting modules document their rooting audit status.
Computed write rooting
crates/perry-codegen/src/expr/index_set.rs, crates/perry-codegen/src/expr/index_set_typed_array.rs, crates/perry-codegen/src/expr/property_set.rs
Index and property stores replace manual temporary roots with operand groups. Array length, array-key, dynamic-key, and polymorphic paths preserve their setter dispatch and write barriers.
Regression coverage and migration tracking
crates/perry-codegen/src/expr/computed_store_rooting_tests.rs, crates/perry-codegen/src/expr/mod.rs, crates/perry-codegen/src/rooting.rs, changelog.d/7642-layer1-slice4-computed-reads-writes.md
HIR tests compare allocating and inert right-hand sides for three computed-store paths. The migration ledger and changelog record Slice 4 coverage and verification.

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

Sequence Diagram(s)

sequenceDiagram
  participant Codegen as index_set.rs/property_set.rs
  participant Rooting as rooting API
  participant RHS as right-hand-side lowering
  participant Setter as runtime setter
  Codegen->>Rooting: register receiver, key, and value operands
  Rooting->>RHS: lower operands across collection windows
  RHS-->>Rooting: return lowered values
  Rooting->>Setter: emit setter call with reread rooted operands
Loading

Possibly related issues

  • PerryTS/perry issue 7640: The PR fixes several rooting gaps in the same computed read and write modules and documents remaining gaps.
  • PerryTS/perry issue 7615: The PR implements the Slice 4 migration described by the issue.

Possibly related PRs

  • PerryTS/perry#7342: It addresses earlier array-store receiver rooting in index_set.rs, which this PR migrates to the shared rooting API.
  • PerryTS/perry#7617: It performs a related migration from manual temporary roots to the construction-based rooting API.
  • PerryTS/perry#7366: It shares index_set.rs and typed-array rooting changes with this PR.

Suggested reviewers: thehypnoo

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the migration of computed read/write codegen modules to the Layer 1 rooting API.
Description check ✅ Passed The description thoroughly covers the migration, bug fixes, testing, verification, linked issues, and remaining scope, despite not reproducing every template heading.
Linked Issues check ✅ Passed The changes address all three linked issues by rooting the required receivers and keys across value and key evaluation windows.
Out of Scope Changes check ✅ Passed The changelog, migration documentation, HIR tests, and code changes support the stated rooting migration and linked issue fixes.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/layer1-slice4

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 3 commits August 8, 2026 14:23
…ayer 1 rooting API (#7615)

Slice 4 of the Layer 1 campaign: `expr/index_get.rs`, `expr/index_set.rs`,
`expr/property_set.rs` and their five sibling submodules move off the raw
`expr::temp_root` API and onto `crate::rooting`'s combinators. All nine are
listed in `MIGRATED_MODULES`.

The whole slice is the `StoreOperandGuard` family — lower the receiver, guard
it, lower the rest, re-read, store, release — and it is exactly
`with_operands_rooted` / `with_operands_rooted_across`. No fourth combinator.

Three live bugs, all the shape the sibling arms already guard:

- #7645 `arr.length = f()` had no guard at all.
- #7646 `arr[k] = f()` on an array with a non-numeric key rooted the receiver
  but not the KEY, which is a heap string by construction on that arm; and
  `arr[stringKey] = f()` guarded neither operand.
- #7647 the polymorphic `o[k] = f()` fallback guarded neither operand, and the
  dynamic-string-key arm derived the receiver's window from `value` alone —
  the half-measure #7201 named, so `o[f()] = 1` was unguarded.

Fixed here because each is an already-slow arm whose store routes through a
runtime helper, so the root is noise beside the call. The rest of what the
migration surfaced is filed, not fixed: they sit on inline fast paths where a
root is a measured cost.
…7615)

The TS corpora structurally cannot reach them, and that was measured rather
than assumed: over the whole curated root-dominance corpus (129 sources, 149
modules) `js_array_set_length_strict` is called ZERO times, and the other two
arms are reached once each by a source whose operands provably cannot collect —
so the emitted IR is identical with and without the repair.

Six hand-written probe shapes did not close the gap either, for a structural
reason: an ordinary `o.k = v` / `o[k] = v` assignment lowers to
`Expr::PutValueSet` and reaches the dynamic IC, not the `Expr::PropertySet` /
`Expr::IndexSet` arms in these modules.

So the coverage is built from HIR, and each test is DIFFERENTIAL: the same store
compiled twice, once with an allocating RHS and once with an inert one,
asserting the allocating one reserves strictly more root slots. An inert RHS
makes `operand_protection` return `Reuse` and the combinator emit nothing, which
is exactly the property that keeps this slice byte-identical on the corpus — so
the two runs bracket the repair and no corpus drift can make the assertion
vacuous. Each test also asserts by name that its arm was reached.

Also fixes up the issue references in the slice's comments to the numbers the
tracker actually assigned (#7637, #7638, #7639, #7640).

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

🤖 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/expr/computed_store_rooting_tests.rs`:
- Around line 156-222: Add two HIR differential tests alongside the existing
store-rooting tests: one for the non-numeric array-key `#7638` arm and one for the
dynamic-string-key `#7639` arm representing o[f()] = 1. Build each test with the
appropriate receiver/key types and assert its expected runtime callee, comparing
both allocating and inert right-hand-side expressions through the existing
helper.
🪄 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: 6f0d16f1-e303-4c74-be2a-c1a835d8b012

📥 Commits

Reviewing files that changed from the base of the PR and between 88e0812 and c388342.

📒 Files selected for processing (13)
  • changelog.d/7642-layer1-slice4-computed-reads-writes.md
  • crates/perry-codegen/src/expr/computed_store_rooting_tests.rs
  • crates/perry-codegen/src/expr/index_get.rs
  • crates/perry-codegen/src/expr/index_get/guarded_array.rs
  • crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs
  • crates/perry-codegen/src/expr/index_set.rs
  • crates/perry-codegen/src/expr/index_set_typed_array.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-codegen/src/expr/property_get.rs
  • crates/perry-codegen/src/expr/property_get/globalget.rs
  • crates/perry-codegen/src/expr/property_get/helpers.rs
  • crates/perry-codegen/src/expr/property_set.rs
  • crates/perry-codegen/src/rooting.rs

Comment on lines +156 to +222
/// #7638 arm 2 — `arr[stringKey] = f()`. The key is a heap string by
/// construction on this arm and `unbox_str_handle` below the RHS would hand the
/// setter a pre-move `StringHeader*`.
#[test]
fn array_string_key_store_roots_both_operands_across_an_allocating_rhs() {
assert_operands_rooted_only_when_the_window_collects(
"array_string_key",
"js_typed_feedback_array_set_string_key",
|value| {
vec![
Stmt::Let {
id: 1,
name: "arr".to_string(),
ty: Type::Array(Box::new(Type::Any)),
mutable: false,
init: Some(Expr::Array(vec![Expr::Number(1.0)])),
},
Stmt::Let {
id: 2,
name: "key".to_string(),
ty: Type::String,
mutable: false,
init: Some(Expr::String("k".to_string())),
},
Stmt::Expr(Expr::IndexSet {
object: Box::new(Expr::LocalGet(1)),
index: Box::new(Expr::LocalGet(2)),
value: Box::new(value),
}),
]
},
);
}

/// #7639 arm 1 — the polymorphic `o[k] = f()` fallback, reached when nothing
/// about the receiver or the key is statically known. It guarded neither
/// operand, and it is the arm where both are heap values by default.
#[test]
fn polymorphic_index_store_roots_both_operands_across_an_allocating_rhs() {
assert_operands_rooted_only_when_the_window_collects(
"polymorphic_index",
"js_typed_feedback_object_set_index_polymorphic",
|value| {
vec![
Stmt::Let {
id: 1,
name: "recv".to_string(),
ty: Type::Any,
mutable: false,
init: Some(Expr::Object(Vec::new())),
},
Stmt::Let {
id: 2,
name: "key".to_string(),
ty: Type::Any,
mutable: false,
init: Some(Expr::Object(Vec::new())),
},
Stmt::Expr(Expr::IndexSet {
object: Box::new(Expr::LocalGet(1)),
index: Box::new(Expr::LocalGet(2)),
value: Box::new(value),
}),
]
},
);
}

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

Cover the remaining repaired store arms.

These tests cover js_typed_feedback_array_set_string_key and js_typed_feedback_object_set_index_polymorphic. They do not cover the separate #7638 non-numeric array-key arm or the #7639 dynamic-string-key arm for o[f()] = 1.

A regression in either arm leaves this suite green. Add one HIR differential test per arm. Each test must assert the expected runtime callee and compare allocating and inert right-hand sides.

🤖 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/computed_store_rooting_tests.rs` around lines
156 - 222, Add two HIR differential tests alongside the existing store-rooting
tests: one for the non-numeric array-key `#7638` arm and one for the
dynamic-string-key `#7639` arm representing o[f()] = 1. Build each test with the
appropriate receiver/key types and assert its expected runtime callee, comparing
both allocating and inert right-hand-side expressions through the existing
helper.

@proggeramlug
proggeramlug force-pushed the refactor/layer1-slice4 branch from c388342 to d129279 Compare August 8, 2026 12:36
@proggeramlug
proggeramlug merged commit d3a4b2e into main Aug 8, 2026
@proggeramlug
proggeramlug deleted the refactor/layer1-slice4 branch August 8, 2026 12:36
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit before merge — verified, merged as v0.5.1364

All three in-slice fixes verified behaviourally against node 26.5.1 —
a.length = shrink(), o[k()] = 1, o2[key2()] = key2() (evaluation order
preserved: {"z1":"z2"}), two consecutive computed element stores,
globalThis[k]=v, and a typed-array computed store — byte-identical, and
identical again under zeal + from-space protect.

Ledger sabotage red. Root-dominance both modes on the corpus: 129/129, 0
violations, 40/40 seeded, unrooted-allocas 0. Codegen 701/0, runtime 1,902/0,
--all-targets clean, all six lint scripts + file-size + fmt clean.

One correction to your lint line: ci_public_baseline_check.py is no
longer
the known main red — I regenerated the public artifact on the mac
mini and merged it as #7641 while you were working. It exits 0 on main now,
so slice 5 should see 24/24 and treat any red there as its own.

Three things from this slice worth carrying forward

  1. Six of nine ledger lines declared vacuous rather than banked, each with
    its audit in the file header — including the observation that
    globalget.rs's six mapped "hazards" are one false positive because
    ctx.strings.intern/format! emit no IR, and a window is measured in
    emissions
    . That sentence is the clearest statement of the campaign's
    hazard-counting rule anyone has written.

  2. The sabotage-verification trap you found is the most valuable output
    here
    , and I confirmed it applies to my own audit method: with
    super::temp_root:: the plant does not compile in nested submodules,
    and a harness grepping only for FAILED scores that build error as a
    successful sabotage. I now check for error[ alongside FAILED — my run
    above reports "0 compile errors — sabotage was real". Slice 5's brief will
    carry it.

  3. Probe vacuity checked before trusting the A/B, again — call sites
    counted in emitted IR (543 / 37 / 16 / 10), which is how you found
    js_array_set_length_strict is called zero times across all 129 sources
    and that six hand-written probes all missed because ordinary o.k = v
    lowers to Expr::PutValueSet and reaches the dynamic IC instead. The
    differential HIR-built unit tests (allocating vs inert RHS, asserting
    strictly more root slots) are the right answer to an arm no source can
    reach.

#7640 filed rather than fixed is the right call and matches #7634's
precedent: those arms sit on inline fast paths and guard diamonds where a root
is a measured cost, and you were correctly denied a benchmark host. When the
mini frees up, that ticket is a perf-gated follow-up, not a refactor.

Housekeeping: I'll clear the node_modules/ your dep-scale corpus needed when
I reclaim the worktree — thanks for flagging it.

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