Skip to content

refactor(codegen): migrate instance_misc1 + logical_collections + map_set onto the Layer 1 rooting API (#7615) - #7627

Merged
proggeramlug merged 2 commits into
mainfrom
refactor/layer1-instance-collections
Aug 8, 2026
Merged

refactor(codegen): migrate instance_misc1 + logical_collections + map_set onto the Layer 1 rooting API (#7615)#7627
proggeramlug merged 2 commits into
mainfrom
refactor/layer1-instance-collections

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Slice 2 of the Layer 1 campaign (#7615): expr/instance_misc1.rs,
expr/logical_collections.rs and lower_call/property_get/map_set.rs are
migrated end to end onto crate::rooting and listed in MIGRATED_MODULES.
Follows the template (#7617) and slices 1a (#7618) / 1b (#7620).

Two combinators, each arriving with its callers

with_operands_rooted_across_call — a window whose across step is an
emitted runtime call rather than a lowered expression. re.test(s) /
re.exec(s) unconditionally emit js_jsvalue_to_string_coerce, which allocates
and, on an object argument, dispatches a user toString. There is no Expr for
any_may_trigger_gc to read, and deriving the window from the string operand
answers false for a plain local — dropping the root at exactly the site #7154
faults at. So the window is stated, not derived. The precedent is deliberate:
temp_root::guard_store_operand_across already took a bool for the same
reason, since #7201. operand_protection still decides how each operand is
protected; only the window's extent is stated, and it is stated conservatively.
All three with_operands_rooted* forms now share one implementation
(with_operands_rooted_window), so the family cannot grow three orderings.

with_rooted_accumulator — the operand group's mirror image. An operand is
lowered once and read once; an accumulator is written, read, rewritten and read
again with arbitrary user code lowered between the writes. It enforces the
invariant a raw handle cannot: the accumulator never exists as a register held
across an emission. Consuming calls re-read it as part of being emitted
(call/call_void), a helper that returns a fresh address publishes it straight
back (advance), and the single point where a register escapes is finish,
which runs below the last collection point and above the release. Both closures'
? paths release.

RootedSlot now carries its own Repr (Ptr / Boxed). A temp-root slot is
representation-agnostic, so before this the choice between temp_root_get_i64
and temp_root_get_double lived at each call site, where a mismatch is a silent
miscompile rather than a type error.

Live bugs found and fixed in-slice

  • with (o) { x = f() } (Expr::WithSet) materialised the receiver and
    the interned property key
    above the RHS and used them below it. The key is a
    load from a __perry_init_strings_* handle global — a registered root that
    evacuation rewrites — so the register named from-space while the global did
    not. String literal operand is not GC-rooted across an allocating call in the same expression (stale handle after evacuation) #7114 exactly, in a second arm: the write landed under a garbage key, on
    a stale receiver.
  • arr.filter(cb) / arr.some(cb) / arr.every(cb) held the array in a
    register while the callback was lowered, and a callback literal is a
    js_closure_new. refactor(codegen): migrate arrays_finds.rs + array_methods.rs onto the Layer 1 rooting API (#7615) #7620's find* finding in three more arms that were missed
    because they live in a different file.
  • Two unrooted raw accumulators. Math.min/Math.max with three or more
    arguments thread a raw ArrayHeader* through js_array_push_f64 across each
    remaining argument's own lowering, so Math.min(f(), g(), h()) pushed into a
    pre-move address. fetch(u, { headers: { k: f() } }) has the same shape with
    js_object_alloc. Both are GC: evacuating minor drops an old-to-young field[1] edge, crashing with 'value is not a function' #7154's ObjectSpread bug, and because what is
    stale is an i64 derived above the window rather than a NaN-boxed double,
    GC: #7154's residual is NOT fixed — the loop-polls config is red 0/30, and stock zod alone fails 5/40 #7280's root_reload structurally cannot repair either (slice 1b, finding 2).
  • fetch's three string operands sat in registers across the whole headers
    construction and across js_fetch_headers_to_json, which enumerates the own
    properties of a program-supplied value and so can re-enter user code through an
    accessor — the argument Expr::ObjectSpread already makes for
    js_object_copy_own_fields.
  • "k" in process.env and JSON.parse(<literal>, <closure>) loaded a
    string literal's handle above an intervening allocating call and used it below.
    Both are repaired by a single re-emitted load, no runtime call — the
    Reload half of operand_protection.
  • Operand-to-operand windows in delete o[k] (both forms), x instanceof <dynamic>, path.join / path.win32.* / path.relative /
    path.basename(p, ext), arr.includes(v, from), arr.splice(...),
    Array.from(it, fn), arr.join(sep), arr.slice(s, e), Object.groupBy /
    Map.groupBy, s.match(re) / s.matchAll(re), JSON.parse(t, reviver),
    parseInt(s, r), new RegExp(p, f), Array.prototype.<m>.call(like, ...),
    map.delete(k), a[i]++, and process.nextTick(cb, ...args).

Scoped honestly

map_set.rs was already hand-rooted end to end (#6970): its migration is a
translation, not a repair, and its emitted IR is byte-identical. What it buys
is that the release stops being a statement a later edit can move into a branch —
the shape #7462 shipped in URLSearchParams.delete, the direct sibling of these
arms. ObjectSpread and Object.assign were likewise already rooted; those
become the accumulator with no behavioural change.

Deliberately not closed: Expr::IndexUpdate (a[i]++) still holds its
re-read receiver and index across js_dyn_index_get, js_to_numeric and
js_numeric_step — three calls that can each run user code — before
js_dyn_index_set consumes them. Closing that needs a per-use re-read inside
the body rather than one group-wide re-read, which is a different combinator.
Per the template's rule it should arrive with the slice that needs it, not ahead
of one. The operand-to-operand half is closed here. Filed separately.

Verification (local; the CI backlog is deep, so this is the evidence)

#7622 control first, as the campaign now requires. Compiling the same source
twice with the same binary: clean on the 4-probe set (172/172 identical), and on
the 149-module corpus it reproduces 5 ordering-only permutations
(@.str.N numbering in __perry_init_strings_*, plus
class_expr_dynamic_parent_ctor::main). Only then was anything attributed.

IR identity. 4 purpose-built probes reaching every arm of all three modules:
172 functions, 153 identical, 19 differing — p1_mapset byte-identical
throughout
, and the whole-corpus net delta is +236 load / +174 store / +164 br / +149 ptrtoint / +82 icmp / +82 inttoptr / +77 bitcast / +71 write-barrier / +12 alloca / +11 invoke, i.e. root plumbing only, nothing deleted, with the
sole call-target delta being js_write_barrier_root_nanbox. Over the
gc-root-dominance corpus (2452 functions, 149 modules): 2436 identical, 16
differing — 11 root plumbing, 5 ordering-only of which 4 are in the control set
and the fifth (class_gap_ref_new_dynamic::main) was pinned by compiling it six
times with the baseline binary, differing every time. Net corpus delta is
+20 js_shadow_slot_bind / +20 js_shadow_slot_set / +44 store / +27 bitcast / +15 load / +4 alloca and nothing else.

The gate's subject is live: root stores 9826 → 9846 over the identical
129-source corpus, matching the +20 binds exactly.

  • gc-root-dominance green in both gated modes with the allowlist empty:
    2452 functions / 149 modules / 9846 root stores → 0 violations;
    --seeded-violations 40 → 40 planted, 40 caught, 0 missed; --unrooted-allocas --moving-only → 0 over 7867 gc-capable allocas. All four checker static audits
    pass (--self-test, --audit-alloc-re, --audit-poll-capable,
    --audit-immovable-sources).
  • Dependency-scale corpus (gc_root_dominance_dep_corpus.sh, 81 zod
    modules) green in both gated modes too: 12899 functions / 12908 root stores →
    0 violations, --seeded-violations 40 at 40/40, --unrooted-allocas 0 over
    15242 gc-capable allocas. Root stores 12878 → 12908 there as well. This is the
    population that matters most for the accumulator change, since zod is exactly
    "builds objects field by field out of data".
  • The informational --stale-registers --moving-only ratchet is unchanged on
    both arms
    : 23 on the curated corpus, 115 on the dependency corpus.
  • cargo test -p perry-codegen --lib 691 pass; --doc — both
    compile_fail,E0499 arms still reject. cargo test -p perry --bins 888 pass
    (scripts/ci_test_scope.py puts perry and perry-codegen in this diff's
    scope; both were run).
  • cargo test -p perry-runtime --no-fail-fast 1886 pass. One failure on the
    first run, promise::keyed_table::settling_many_keys_is_not_quadratic, the
    cargo-test: perry-runtime's suite fails a different number of tests on every run #7365 timing flake; green on rerun (it ran while an IR corpus was compiling).
  • 86 gap tests over 18 family filters, identical verdict sets on both arms,
    with the one non-PASS (test_gap_fetch_request_from_node_incoming_message,
    SIGABRT) reproduced on a pristine origin/main build. Filed separately — it
    is not in known_failures.json, and parity is tag-gated, so it has been
    able to hide.
  • All 4 probes produce identical stdout and exit codes on both arms, and the
    migrated arm is unchanged again under
    PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1.
  • Ledger sabotage, run per module (the assert stops at the first offender, so
    one run cannot speak for three): a real compiling temp_root_push_double /
    temp_root_truncate pair injected into each file turns
    migrated_modules_do_not_reach_past_the_rooting_api red naming both lines —
    map_set.rs:46/:47, logical_collections.rs:145/:146,
    instance_misc1.rs:181/:182 — then reverted, green again.
    Unlike slices 1a and 1b, these three named expr::temp_root before the
    migration, so the ledger line is load-bearing on the committed source too, not
    only under sabotage. Recorded in MIGRATED_MODULES' doc.
  • Full lint job step set enumerated from .github/workflows/test.yml and run:
    all pass except benchmarks/ci_public_baseline_check.py, which is red on
    main and independent of this diff (it hashes Cargo.toml and
    benchmarks/**; this diff touches only crates/perry-codegen/src/). refactor(codegen): Layer 1 slice 1 — migrate lower_array_method.rs onto the rooting API (#7615) #7618 and
    refactor(codegen): migrate arrays_finds.rs + array_methods.rs onto the Layer 1 rooting API (#7615) #7620 report the same step failing on every main run since 2026-07-29.
    cargo clippy -p perry-codegen emits no warning in any migrated file.

No version bump (maintainer bumps at merge). No measurement runs: this is a
behaviour-preserving refactor, and the benchmark hosts are reserved.

Summary by CodeRabbit

  • Bug Fixes

    • Improved runtime stability for complex collection, expression, regular expression, parsing, and update operations.
    • Reduced the risk of invalid values during memory allocation and callback execution.
  • Documentation

    • Added release documentation covering ongoing memory-safety improvements and verification status.
  • Chores

    • Updated the application version to 0.5.1357.

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8e1e1e59-a622-43c8-915c-3ec8870c61ad

📥 Commits

Reviewing files that changed from the base of the PR and between c413af0 and 291248c.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/7627-layer1-slice2-instance-collections-rooting.md
  • crates/perry-codegen/src/expr/instance_misc1.rs
  • crates/perry-codegen/src/expr/logical_collections.rs
  • crates/perry-codegen/src/lower_call/property_get/map_set.rs
  • crates/perry-codegen/src/rooting.rs

📝 Walkthrough

Walkthrough

The PR increments the workspace version to 0.5.1357 and migrates three codegen modules from temporary roots to shared rooting combinators. Rooted slots now preserve pointer or boxed representations, support void calls, and maintain mutable accumulators across collection windows.

Changes

Codegen rooting migration

Layer / File(s) Summary
Representation-aware rooting infrastructure
crates/perry-codegen/src/rooting.rs, CLAUDE.md, Cargo.toml, changelog.d/...
Rooted slots record their value representation. Operand windows, void calls, collection detection, and rooted accumulators now share scoped cleanup and re-read behavior. Version and migration documentation were updated.
Instance and runtime expression lowerings
crates/perry-codegen/src/expr/instance_misc1.rs
Instance, path, RegExp, array, string, update, and JSON lowerings now protect operands across lowering, allocation, and runtime calls.
Logical and collection lowerings
crates/perry-codegen/src/expr/logical_collections.rs
Collection methods, parsing, operators, dynamic RegExp construction, object spread, object assignment, and variadic math use rooted operands or accumulators.
Map, Set, and URLSearchParams lowerings
crates/perry-codegen/src/lower_call/property_get/map_set.rs
Map, Set, and URLSearchParams dispatch replaces manual temporary-root management with with_operands_rooted while preserving runtime calls and result handling.

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

Sequence Diagram(s)

sequenceDiagram
  participant Lowering as Codegen lowering
  participant Rooting as rooting API
  participant GC as Garbage collector
  participant Runtime as Runtime helper
  Lowering->>Rooting: Lower and root operands
  Rooting->>GC: Keep pointer and boxed slots valid
  GC-->>Rooting: Re-read rooted values
  Rooting->>Runtime: Emit call with materialized arguments
  Runtime-->>Lowering: Return result
Loading

Possibly related issues

Possibly related PRs

  • PerryTS/perry#6972 — Earlier temporary-root mechanisms address the same operand and accumulator rooting paths.
  • PerryTS/perry#7617 — Both PRs extend rooting.rs as part of the Layer 1 migration.
  • PerryTS/perry#7618 — Both PRs migrate codegen modules and update the rooting migration ledger.

Suggested labels: bug

Suggested reviewers: thehypnoo, jdalton

✨ 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 refactor/layer1-instance-collections

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.

proggeramlug pushed a commit that referenced this pull request Aug 8, 2026
…_set onto the Layer 1 rooting API (#7615) (#7627)

Slice 2. Adds two combinators with their callers: with_operands_rooted_across_call, for a window whose across step is an EMITTED runtime call and so cannot be derived from an Expr (re.test/re.exec's unconditional js_jsvalue_to_string_coerce), and with_rooted_accumulator, for a GC-managed value the lowering keeps updating while it lowers more user code. RootedSlot now carries its own representation so a boxed slot cannot be read as a raw pointer. Live bugs fixed in-slice: with-set materialising receiver AND interned key above the RHS (#7114 in a second arm), filter/some/every holding the array across the callback's js_closure_new, two unrooted RAW accumulators (Math.min/max variadic and fetch's static headers) that #7280 structurally cannot repair, fetch's three string operands across js_fetch_headers_to_json, and two string-literal reload sites. map_set.rs was already hand-rooted so its IR is byte-identical -- a translation, not a repair, and scoped as such. Deferred with reasons: IndexUpdate's intra-body window, which needs a per-use re-read combinator.
@proggeramlug
proggeramlug force-pushed the refactor/layer1-instance-collections branch from a66cfac to b99df1e Compare August 8, 2026 07:06
Ralph Küpper added 2 commits August 8, 2026 10:05
…_set onto the Layer 1 rooting API (#7615) (#7627)

Slice 2. Adds two combinators with their callers: with_operands_rooted_across_call, for a window whose across step is an EMITTED runtime call and so cannot be derived from an Expr (re.test/re.exec's unconditional js_jsvalue_to_string_coerce), and with_rooted_accumulator, for a GC-managed value the lowering keeps updating while it lowers more user code. RootedSlot now carries its own representation so a boxed slot cannot be read as a raw pointer. Live bugs fixed in-slice: with-set materialising receiver AND interned key above the RHS (#7114 in a second arm), filter/some/every holding the array across the callback's js_closure_new, two unrooted RAW accumulators (Math.min/max variadic and fetch's static headers) that #7280 structurally cannot repair, fetch's three string operands across js_fetch_headers_to_json, and two string-literal reload sites. map_set.rs was already hand-rooted so its IR is byte-identical -- a translation, not a repair, and scoped as such. Deferred with reasons: IndexUpdate's intra-body window, which needs a per-use re-read combinator.
@proggeramlug
proggeramlug force-pushed the refactor/layer1-instance-collections branch from b99df1e to 291248c Compare August 8, 2026 08:13
@proggeramlug
proggeramlug merged commit 418d548 into main Aug 8, 2026
9 checks passed
@proggeramlug
proggeramlug deleted the refactor/layer1-instance-collections branch August 8, 2026 08:13
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit before merge — verified, merged as v0.5.1357

Rebased onto #7625 (determinism), which doubles as its confirmation: my
3-compile control on a probe covering this slice's surface is now 2/2
identical
, where the same shape gave 5 ordering-only permutations before.
Your double-compile control was the right method; it is no longer needed on
this file set.

Behaviour verified against node 26.5.1 on the migrated surface — Map/Set
methods, filter/some/every with allocating callbacks, Math.max/min
with 3+ args, JSON.parse with a reviver, in process.env — byte-identical.

The with (o) { x = f() } fix needed a separate check: node's TypeScript
stripper rejects with outright
(ERR_INVALID_TYPESCRIPT_SYNTAX), so it
cannot be gap-tested from a .ts file at all. Verified via .js instead —
node 42, perry 42. Worth recording in the fragment, because the next person
to touch that arm will reach for a .ts gap test and conclude the feature is
broken.

Ledger sabotage re-verified on logical_collections.rs: a real
temp_root_push_i64 turns migrated_modules_do_not_reach_past_the_rooting_api
red. Root-dominance both modes: 129/129, 0 violations, 40/40 seeded caught.
Suites 694/0 codegen, 1886/0 runtime; full lint set + fmt clean.

What makes this slice the strongest of the three

Two raw accumulators that were not rooted at allMath.min/max with 3+
args threading an ArrayHeader* through js_array_push_f64 across each
remaining argument's lowering, and fetch's static-headers build doing the
same through js_object_alloc. Neither is reachable by #7280's root_reload
(the stale value is a derived i64, not a slot-backed double), which is the
taxonomy #7620 established now paying off as a search heuristic rather than a
post-hoc explanation.

And the with bug is the sharpest single find of the campaign so far: the
receiver and the interned key were both materialised above the RHS, and the
key is a __perry_init_strings_* load that evacuation rewrites — so the write
landed under a garbage key on a stale receiver. Two independently stale values
in one store.

The honest-scoping standard held again: map_set.rs is byte-identical and
claimed as translation, not repair (#6970 had hand-rooted it), with the stated
benefit being that the release can no longer be moved into a branch — #7462's
shape, in a direct sibling of these arms.

Two filed rather than smuggled: #7628 (a[i]++ needs a per-use re-read
combinator, which by the template's rule arrives with its caller) and #7629
— a fetch gap test SIGABRTing deterministically 3/3 on pristine main, in no
allowlist, invisible because parity is tag-gated. That one is a live bug on
main found as a side effect, and it is exactly what #7599's ratchet work
exists to surface.

proggeramlug pushed a commit that referenced this pull request Aug 8, 2026
Rebasing #7575 onto v0.5.1357 merges CLEANLY as text but does not compile:
#7627 added a `perry_hir::Class` literal in codegen/emission_order_tests.rs,
and #7575 adds a field to that struct. Textual mergeability is not a merge
check when one side widens a struct the other side constructs.
proggeramlug pushed a commit that referenced this pull request Aug 8, 2026
Rebasing #7575 onto v0.5.1357 merges CLEANLY as text but does not compile:
#7627 added a `perry_hir::Class` literal in codegen/emission_order_tests.rs,
and #7575 adds a field to that struct. Textual mergeability is not a merge
check when one side widens a struct the other side constructs.
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