Skip to content

fix(gc): Layer 1 rooting slice 7 — child_process, Proxy/Reflect, await (#7615) - #7662

Open
proggeramlug wants to merge 1 commit into
mainfrom
gc/7615-layer1-slice7-expr-modules
Open

fix(gc): Layer 1 rooting slice 7 — child_process, Proxy/Reflect, await (#7615)#7662
proggeramlug wants to merge 1 commit into
mainfrom
gc/7615-layer1-slice7-expr-modules

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Slice 7 of the Layer 1 emitter migration (#7615). Migrates three expr/ modules
onto crate::rooting, adds them to the MIGRATED_MODULES ledger, and fixes what
reading them turned up.

What is here

module before after
expr/child_proc.rs 3 arms rooted unconditionally through the raw API, 5 rooted nothing while holding raw heap pointers across user lowerings one argument-list skeleton (lower_cp_args), validators below the whole list
expr/proxy_reflect.rs 1 arm of 29 made a rooting decision 28 lowerings migrated; the 8 reflect-metadata arms share one body
expr/fs_await.rs root correct, never released scope owned by with_rooted_group, released in the merge block

The node-visible bug

child_process validated each argument the instant it was lowered — lower
command, throw ERR_INVALID_ARG_TYPE, never evaluate options. JS evaluates a
call's whole argument list before the callee is entered, so node runs those side
effects and only then throws.

A probe over all seven entry points, A/B'd against node 26.5.1 with a baseline
compiler built from main in a separate target directory (identical -p set,
identical flags, PERRY_NO_AUTO_OPTIMIZE=1, no PERRY_GC_MOVING_LOOP_POLLS on
either arm):

--- node vs base ---   14 missing lines (every args / options / callback expression)
--- node vs fix  ---   (empty)

The rooting bugs

Taxonomy (a) — a raw pointer above a collection point, which root_reload
structurally cannot repair:

Taxonomy (c) — operand-to-operand: 28 Proxy.* / Reflect.* arms.
Reflect.has(target, key) handed the pre-collection target register to
js_reflect_has; Reflect.set does it with four operands.

#7154 accumulator: proxy_build_args_array threaded the argument array's raw
*mut ArrayHeader through its push loop in a bare SSA register, and had no way
to root its caller's receiver across the same loop. Deleted; the four call
sites now build the array inside a RootedGroup that holds both.

Never released: fs_await.rs pushed the await-loop root unconditionally and
emitted no truncate on any path. Over-retention in the alloca lowering; in the
FFI fallback a js_gc_temp_root_push per execution with no truncate — #7462's
shape for an await in a loop.

API

RootedGroup::adopt_emitted — the combinator rooting.rs deleted unused and
said could return "with its caller and with a written argument for why
call_rooted cannot serve". Two callers turned up, same shape: a GC-managed
value produced by an emitted step rather than by lowering an Expr. The
argument, and what it weakens, are in its doc.

Verification

  • Gates: all 22 lint commands extracted from test.yml — pass.
    cargo test -p perry-codegen --lib --no-fail-fast 738 pass;
    cargo test -p perry-runtime --lib --no-fail-fast 1917 pass;
    cargo check --all-targets clean; the 14 native_root_coverage tests pass.
  • Dominance: gc_root_dominance_check.py --moving-only --seeded-violations 40
    over a 149-module corpus — 0 violations, 40 planted / 40 caught / 0 missed.
    --unrooted-allocas --moving-only 0 over 7864 gc-capable allocas.
  • Cost: corpus root stores 9799 → 9805 (+6). Three zero-cost pins are
    committed so a future "root everything" change goes red.
  • Test sabotage: the pre-fix source of all five touched files restored from
    HEADerror[ count 0, Running unittests present, 11 of 13 red. The 2
    that stay green are the deliberate zero-cost pins.
  • Ledger sabotage: run once per newly listed module — compiling
    temp_root_push_double / temp_root_truncate pair planted in each, error[
    count 0 in all three, ledger red and naming both planted lines.

No runtime fault is claimed. Every window is demonstrated in IR; whether a
stale pointer is observably wrong depends on what is recycled into those bytes.

Caveat on the dominance gate

It compiles its corpus under PERRY_RS4GC=0 — the shadow lowering, which is
not the default since #7370. Its green verdict says nothing about the native
lowering that ships; the native_root_coverage suite is what covers that, and it
is run above.

Left for later slices, with reasons

  • expr/static_field_meta.rs, expr/math_simple.rs, expr/dyn_extern_i18n.rs
    not audited here. A survey flagged candidates in all three (ClassExprFresh's
    caps_arr accumulator, ArrayMap's receiver unboxed below its own window, and
    a raw path_handle reused across a dynamic-import compare loop that runs module
    __init bodies) but I have not verified them myself, so they are leads, not
    findings.
  • lower_call/new.rs — unblocked by slice 6's RootedGroup but at 1988/2000
    lines against the file-size gate; needs its own slice with a split.

Summary by CodeRabbit

  • Bug Fixes

    • Improved reliability for child-process operations, including spawning, execution, and forking.
    • Improved stability for Proxy, Reflect, environment-variable, and dynamic assignment operations.
    • Fixed edge cases affecting asynchronous waits, promise handling, and sequential awaits.
    • Preserved correct evaluation order and behavior when operations trigger memory cleanup.
  • Tests

    • Added extensive coverage for these operations, including asynchronous and non-collecting scenarios.

#7615)

Migrate `expr/child_proc.rs`, `expr/proxy_reflect.rs` and `expr/fs_await.rs`
onto `crate::rooting` and list them in the `MIGRATED_MODULES` ledger. All three
named `expr::temp_root` before the migration, so all three lines are
load-bearing on the committed source.

One node-visible bug: `child_process` validated each argument the instant it was
lowered, so a bad `command` threw before the later arguments were evaluated. JS
evaluates a call's whole argument list before the callee is entered. A probe over
all seven entry points diffs empty against node 26.5.1 with the fix and drops 14
side effects without it.

Rooting: nine windows across five `child_process` arms (raw `StringHeader*`
carried across user lowerings, plus `fork`'s `js_jsvalue_to_string_coerce`
result), twenty-eight unprotected `Proxy.*` / `Reflect.*` lowerings, the
`process.env[k] = v` key in both its literal and computed forms, and
`proxy_build_args_array`'s bare-register accumulator (deleted; its four call
sites now build the array inside a `RootedGroup` that also holds the receiver).
`fs_await.rs`'s await-loop root was correct and never released.

Adds `RootedGroup::adopt_emitted` — the combinator `rooting.rs` refused to add
ahead of a caller — for a GC-managed value produced by an emitted step rather
than by lowering an `Expr`.
@proggeramlug
proggeramlug force-pushed the gc/7615-layer1-slice7-expr-modules branch from 387f2d8 to a1a8b90 Compare August 8, 2026 20:06
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Layer 1 rooting migration updates child-process, Proxy/Reflect, environment, and await lowering to use RootedGroup. It adds emitted-value rereads, removes manual temporary-root handling, and adds LLVM IR tests for rooting and evaluation order.

Changes

Expression rooting migration

Layer / File(s) Summary
Emitted-value rooting infrastructure
crates/perry-codegen/src/rooting.rs
RootedGroup now adopts and rereads caller-emitted pointer or boxed values. The migration ledger includes the three migrated expression modules.
Child-process rooted lowering
crates/perry-codegen/src/expr/child_proc.rs
Child-process lowering roots command, argument, and option operands. It rereads operands after evaluation and validates them before runtime calls.
Proxy, Reflect, and assignment lowering
crates/perry-codegen/src/expr/proxy_reflect.rs, crates/perry-codegen/src/expr/helpers.rs, crates/perry-codegen/src/expr/mod.rs
Proxy, Reflect, process.env, and PutValue paths use shared rooted helpers. The unrooted proxy argument helper and its re-export are removed.
Await cleanup and regression coverage
crates/perry-codegen/src/expr/fs_await.rs, crates/perry-codegen/src/expr/slice7_rooting_tests.rs, changelog.d/7662-layer1-slice7-expr-modules.md
Await lowering rereads the rooted promise and releases it at the merge path. Tests inspect LLVM IR rooting, reloads, evaluation order, and slot reuse. The changelog records Slice 7.

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

Sequence Diagram(s)

sequenceDiagram
  participant ExpressionLowering
  participant RootedGroup
  participant RuntimeCall
  ExpressionLowering->>RootedGroup: lower and root collecting operands
  ExpressionLowering->>RootedGroup: reread values after collection points
  RootedGroup->>RuntimeCall: provide current pointer or boxed operands
Loading

Possibly related issues

Possibly related PRs

  • PerryTS/perry#7651 — Slice 7 extends the RootedGroup infrastructure introduced for multi-point operand rereads and accumulator handling.
  • PerryTS/perry#7375 — This PR generalizes the earlier await promise-rooting fix with RootedGroup and rereads.
  • PerryTS/perry#7617 — Both PRs migrate expression codegen modules to the rooting-by-construction API and update the migration ledger.

Suggested reviewers: thehypnoo

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the Layer 1 rooting migration and the three affected expression areas.
Description check ✅ Passed The description explains the scope, behavior fixes, rooting changes, tests, verification results, and deferred work in sufficient detail.
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/7615-layer1-slice7-expr-modules

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/perry-codegen/src/expr/proxy_reflect.rs (1)

1602-1626: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Root inst_handle before calling into emit_class_capture_writeback.

inst_handle is an unrooted raw i64 pointer. emit_class_capture_writeback emits js_box_set, and the js_box_get/js_box_set helper family is treated as a rooted accessor path that requires the box pointer to remain valid across its allocation/reentrancy window. Root the object pointer, emit js_box_set through that rooted state, then release the root.

🤖 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/proxy_reflect.rs` around lines 1602 - 1626, In
the class write-back block of the Reflect.construct lowering, root inst_handle
before calling emit_class_capture_writeback, since that helper emits js_box_set
across an allocation/reentrancy window. Release the root after the write-back
completes, preserving the existing pointer derivation and class capture
behavior.
🧹 Nitpick comments (1)
crates/perry-codegen/src/expr/slice7_rooting_tests.rs (1)

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

Apply the declare filter to last_alloc_before for consistency.

call_line at line 69 and temp_root_calls at line 125 both exclude declare lines, and the module doc at lines 63-65 states that the exclusion is load-bearing. last_alloc_before does not apply it, so a declare for js_object_alloc can be counted as an allocation site.

The current tests cannot pass falsely because every case lowers a real allocating operand. The omission is a latent trap for the next test added to this file.

♻️ Proposed change
 fn last_alloc_before(ir: &str, before: usize) -> usize {
     ir.lines()
         .enumerate()
         .take(before)
+        .filter(|(_, l)| !l.trim_start().starts_with("declare"))
         .filter(|(_, l)| l.contains("`@js_object_alloc`"))
         .map(|(i, _)| i)
         .last()
         .unwrap_or_else(|| panic!("no object allocation above line {before} in:\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/expr/slice7_rooting_tests.rs` around lines 106 -
119, Update last_alloc_before to exclude IR lines containing “declare” before
filtering for “@js_object_alloc”, matching the existing call_line and
temp_root_calls behavior. Preserve the current line-index tracking and
no-allocation panic behavior, and keep last_alloc delegating through
last_alloc_before.
🤖 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/child_proc.rs`:
- Around line 82-96: Update lower_cp_args so operands remain rooted across
child-process validator calls, especially the spawnSync(cmd, args, opts) path
where opts is the final operand consumed by the validator. Ensure the
validator-emitting arms pass across_call = true, or otherwise make collects
cover the full validator window while preserving discarded-operand handling and
slot_ptr register expectations.

In `@crates/perry-codegen/src/expr/proxy_reflect.rs`:
- Around line 1236-1263: The assignment result is returned from a pre-call
register that may be invalidated by GC or re-entry. In
crates/perry-codegen/src/expr/proxy_reflect.rs lines 1236-1263, root val_double
across js_setenv and return its reread register below the call in both
computed-key and literal branches, adding the needed RootedGroup for the literal
branch. In lines 1278-1288, root the value operand across js_proxy_set and
return its post-call reread register instead of the pre-trap v[2].
- Around line 1513-1563: In the explicit-receiver branch of the surrounding
lowering function, root the lowered value using a collects flag derived from
receiver before calling lower_expr on receiver. Re-read the rooted value after
lower_expr(ctx, receiver)? and pass that reread value to js_put_value_set; leave
the same-receiver branch unchanged.

In `@crates/perry-codegen/src/expr/slice7_rooting_tests.rs`:
- Around line 172-188: The three zero-cost rooting tests use temp_root_calls,
which is blind to statepoint/RS4GC lowering; replace it with
temp_root_slot_width so the assertions cover all lowerings. In
crates/perry-codegen/src/expr/slice7_rooting_tests.rs at lines 172-188, 259-271,
and 437-442, update the no-options execSync, single-operand Reflect.ownKeys, and
string-literal process.env key tests to assert temp_root_slot_width(&ir) == 0.

---

Outside diff comments:
In `@crates/perry-codegen/src/expr/proxy_reflect.rs`:
- Around line 1602-1626: In the class write-back block of the Reflect.construct
lowering, root inst_handle before calling emit_class_capture_writeback, since
that helper emits js_box_set across an allocation/reentrancy window. Release the
root after the write-back completes, preserving the existing pointer derivation
and class capture behavior.

---

Nitpick comments:
In `@crates/perry-codegen/src/expr/slice7_rooting_tests.rs`:
- Around line 106-119: Update last_alloc_before to exclude IR lines containing
“declare” before filtering for “@js_object_alloc”, matching the existing
call_line and temp_root_calls behavior. Preserve the current line-index tracking
and no-allocation panic behavior, and keep last_alloc delegating through
last_alloc_before.
🪄 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: a040028b-1278-43ac-9a71-7a8146c3d3d4

📥 Commits

Reviewing files that changed from the base of the PR and between 384eba7 and a1a8b90.

📒 Files selected for processing (8)
  • changelog.d/7662-layer1-slice7-expr-modules.md
  • crates/perry-codegen/src/expr/child_proc.rs
  • crates/perry-codegen/src/expr/fs_await.rs
  • crates/perry-codegen/src/expr/helpers.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-codegen/src/expr/proxy_reflect.rs
  • crates/perry-codegen/src/expr/slice7_rooting_tests.rs
  • crates/perry-codegen/src/rooting.rs

Comment on lines +82 to +96
fn lower_cp_args<'a>(
ctx: &mut FnCtx<'_>,
group: &mut RootedGroup<'a>,
exprs: &[&'a Expr],
discarded: Option<usize>,
across_call: bool,
) -> Result<()> {
for (i, expr) in exprs.iter().enumerate() {
let consumed = Some(i) != discarded;
let collects = consumed
&& (across_call || any_operand_may_collect(ctx, exprs[i + 1..].iter().copied()));
group.lower(ctx, expr, collects)?;
}
Ok(())
}

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 | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect the child-process validator runtime implementations for own-property reads that can invoke user accessors.
set -euo pipefail

fd -t f . crates/perry-runtime/src --exec rg -l 'js_child_process_validate_options' {} \; | while IFS= read -r f; do
  echo "=== $f ==="
  rg -n -A60 'fn js_child_process_validate_options' "$f"
done

echo
echo "=== validate_spawn_args / validate_args / validate_command ==="
rg -n -A40 'fn js_child_process_validate_(spawn_args|args|command)\b' crates/perry-runtime/src

Repository: PerryTS/perry

Length of output: 205


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== candidate child process files ==="
fd -t f 'child_process' crates/perry-runtime crates/perry-codegen crates/perry 2>/dev/null || true

echo
echo "=== relevant symbols in runtime ==="
rg -n 'js_child_process_validate_options|js_child_process_validate_(spawn_args|args|command)\b|validate.*spawn|validate.*args|validate.*command|ownProperties|Object\.getOwnProperty|readOwnProperty' crates/perry-runtime/src || true

echo
echo "=== child_proc outline and relevant snippets ==="
ast-grep outline crates/perry-codegen/src/expr/child_proc.rs || true
sed -n '1,140p' crates/perry-codegen/src/expr/child_proc.rs | cat -n

Repository: PerryTS/perry

Length of output: 25054


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== validate.rs relevant sections ==="
sed -n '1,425p' crates/perry-runtime/src/child_process/validate.rs | cat -n

echo
echo "=== child_process usages in codegen arms ==="
rg -n 'lower_cp_args|emit_cp_validators|js_jsvalue_to_string_coerce|across_call|slot_ptr|emit_cp_validate_options|emit_cp_validate_spawn_args|emit_cp_validate_args|emit_cp_validate_command|emit_cp_validate_fork_module' crates/perry-codegen/src/expr/child_proc.rs

echo
echo "=== child_proc emit validators and lower call sites ==="
sed -n '160,310p' crates/perry-codegen/src/expr/child_proc.rs | cat -n

Repository: PerryTS/perry

Length of output: 30670


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== locate cp_get_field / cp_value_to_string implementations ==="
rg -n 'fn cp_get_field|fn cp_value_to_string|fn cp_array_ptr|fn cp_object_ptr|fn extract_closure_ptr|is_registered_buffer|is_url_object_shape|js_array_get_f64' crates/perry-runtime/src crates/perry-codegen/src || true

echo
echo "=== files containing cp_get_field definitions ==="
rg -l 'fn cp_get_field|cp_get_field' crates/perry-runtime/src/child_process crates/perry-runtime/src | while IFS= read -r f; do
  echo "--- $f"
  rg -n -A35 'fn cp_get_field|fn cp_value_to_string|fn cp_array_ptr|fn cp_object_ptr|fn extract_closure_ptr' "$f" || true
done

echo
echo "=== read-write verifier for validate own-property reads ==="
python3 - <<'PY'
from pathlib import Path
p = Path("crates/perry-runtime/src/child_process/validate.rs")
s = p.read_text()
fn_name = "cp_validate_options"
idx = s.index("fn " + fn_name)
end = s.index("\n}", idx)
body = s[idx:end]
checks = [
    ("cp_get_field", "field reads"),
    ("cp_array_ptr", "array reads (loop element reads)"),
    ("js_array_get_f64", "array element reads"),
    ("js_is_symbol", "own property reflection"),
    ("extract_closure_ptr", "own property reflection"),
    ("js_validate_signal", "own property reflection"),
]
for needle, label in checks:
    count = body.count(needle)
    print(f"{needle}: {count} occurrences -> {label}")
print("body_len", len(body))
print("contains cp_throw_option_type", "cp_throw_option_type" in body)
print("contains cp_signal_is_valid", "cp_signal_is_valid" in body)
PY

Repository: PerryTS/perry

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== child_process helper definitions ==="
fd -t f . crates/perry-runtime/src/child_process \
  | while IFS= read -r f; do
      echo "--- $f"
      rg -n -A45 'pub \(crate\) fn cp_get_field|pub \(crate\) fn cp_value_to_string|fn cp_get_field|fn cp_value_to_string|fn cp_array_ptr|fn cp_object_ptr|fn cp_validate_stdio_entry|fn cp_signal_is_valid|fn cp_throw_option_type' "$f" || true
    done

echo
echo "=== rooting/reread behavior ==="
fd -t f . crates/perry-codegen/src/rooting.rs crates/perry-codegen/src -i 'rooting' \
  | while IFS= read -r f; do
      echo "--- $f"
      rg -n -A60 'struct RootedGroup|fn reread|fn lower|with_operands_rooted_across_call|fn with_rooted_group|any_operand_may_collect' "$f" || true
    done

Repository: PerryTS/perry

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== cp_get_field / cp_value_to_string definitions in child_process ==="
rg -n -A80 'pub \(crate\) fn cp_get_field|pub \(crate\) fn cp_value_to_string|fn cp_get_field|fn cp_value_to_string|fn cp_array_ptr|fn cp_object_ptr|pub \(crate\) fn cp_signal_is_valid|fn cp_signal_is_valid' crates/perry-runtime/src/child_process/*

echo
echo "=== focused validate helper calls in cp_validate_options ==="
python3 - <<'PY'
from pathlib import Path
s = Path("crates/perry-runtime/src/child_process/validate.rs").read_text()
start = s.index("fn cp_validate_options")
end = s.index("\n}", start)
body = s[start:end]
for f in ["cp_get_field", "cp_value_to_string", "cp_array_ptr", "js_array_get_f64", "is_registered_buffer", "extract_closure_ptr", "is_url_object_shape"]:
    print(f, body.count(f))
print("option field reads:", len([line for line in body.splitlines() if "cp_get_field(value" in line]))
PY

Repository: PerryTS/perry

Length of output: 19030


Root the operands across all child-process validators.

js_child_process_validate_options calls js_object_get_field_by_name_f64 for each option field, so unprotected operands can be relocated through slot_box while the validator is still reading them. spawnSync(cmd, args, opts) leaves opts unprotected as the last operand, then slot_ptr(ctx, g, at[2]) names the pre-validator register for the consuming call. Set across_call = true for arms that emit validators, or make collects include the validator window.

🤖 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/child_proc.rs` around lines 82 - 96, Update
lower_cp_args so operands remain rooted across child-process validator calls,
especially the spawnSync(cmd, args, opts) path where opts is the final operand
consumed by the validator. Ensure the validator-emitting arms pass across_call =
true, or otherwise make collects cover the full validator window while
preserving discarded-operand handling and slot_ptr register expectations.

Source: Learnings

Comment on lines +1236 to +1263
let val_double = lower_expr(ctx, value)?;
let key_idx = ctx.strings.intern(property);
let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global);
let blk = ctx.block();
let key_box = blk.load(DOUBLE, &key_handle_global);
let key_handle = unbox_to_i64(blk, &key_box);
blk.call_void("js_setenv", &[(I64, &key_handle), (DOUBLE, &val_double)]);
return Ok(Some(val_double));
}
// Computed key. `js_to_property_key` must run ABOVE the value's evaluation
// — ES2022 moved `ToPropertyKey` before the RHS — so the value that has to
// survive that evaluation is the COERCED key, a fresh heap string produced
// by an emitted call rather than by lowering an expression. That is what
// `RootedGroup::adopt_emitted` is for.
with_rooted_group(ctx, 1, |ctx, g| {
let key_box = lower_expr(ctx, key)?;
let property_key = ctx
.block()
.call(DOUBLE, "js_to_property_key", &[(DOUBLE, &key_box)]);
let key_slot = g.adopt_emitted(ctx, Repr::Boxed, &property_key);
let val_double = lower_expr(ctx, value)?;
// The strip happens BELOW the window, never above it.
let key_box = g.reread_emitted(ctx, key_slot);
let key_handle = unbox_str_handle(ctx.block(), &key_box);
ctx.block()
.call_void("js_setenv", &[(I64, &key_handle), (DOUBLE, &val_double)]);
Ok(Some(val_double))
})

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 | 🟡 Minor | ⚡ Quick win

Assignment-result registers are read above the consuming call that runs user code. Both arms return the value register produced before a helper that can allocate or re-enter user code, so an evacuating collection inside that helper leaves the returned register naming from-space whenever the assignment is used as an expression.

  • crates/perry-codegen/src/expr/proxy_reflect.rs#L1236-L1263: adopt val_double into the open RootedGroup and return reread_emitted below the js_setenv call; apply the same treatment to the literal branch, which currently has no group.
  • crates/perry-codegen/src/expr/proxy_reflect.rs#L1278-L1288: root the value operand across js_proxy_set and return the re-read register instead of the pre-trap v[2].
📍 Affects 1 file
  • crates/perry-codegen/src/expr/proxy_reflect.rs#L1236-L1263 (this comment)
  • crates/perry-codegen/src/expr/proxy_reflect.rs#L1278-L1288
🤖 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/proxy_reflect.rs` around lines 1236 - 1263, The
assignment result is returned from a pre-call register that may be invalidated
by GC or re-entry. In crates/perry-codegen/src/expr/proxy_reflect.rs lines
1236-1263, root val_double across js_setenv and return its reread register below
the call in both computed-key and literal branches, adding the needed
RootedGroup for the literal branch. In lines 1278-1288, root the value operand
across js_proxy_set and return its post-call reread register instead of the
pre-trap v[2].

Comment on lines +1513 to +1563
with_rooted_group(ctx, 2, |ctx, g| {
// The receiver's window covers BOTH the key's lowering and the
// value's, so its `collects` is the disjunction — `o[f()] = 1`
// has an inert value and a collecting key.
let recv_collects =
any_operand_may_collect(ctx, [key.as_ref(), value.as_ref(), receiver.as_ref()]);
let recv_slot = g.lower(ctx, target, recv_collects)?;
let key_collects =
any_operand_may_collect(ctx, [value.as_ref(), receiver.as_ref()]);
let key_slot = g.lower(ctx, key, key_collects)?;
let v = lower_expr(ctx, value)?;
// #6812 (w12): same-receiver dynamic-key stores that failed the
// inline gate (computed target expressions) still take the
// outlined 3-way IC helper.
if same_put_value_receiver_expr(target, receiver) {
let k = g.reread(ctx, key_slot)?;
let t = g.reread(ctx, recv_slot)?;
let site_id = ctx.ic_site_counter;
ctx.ic_site_counter += 1;
let cache_name = format!("perry_ic_{}", site_id);
ctx.ic_globals.push(cache_name.clone());
let cache_ref = format!("@{}", cache_name);
Ok(ctx.block().call(
DOUBLE,
"js_put_value_set_dyn_ic",
&[
(crate::types::PTR, &cache_ref),
(DOUBLE, &t),
(DOUBLE, &k),
(DOUBLE, &v),
(I32, strict_i32),
],
))
} else {
// The explicit-receiver form lowers a FOURTH operand, so the
// re-reads have to sit below it, not above.
let r = lower_expr(ctx, receiver)?;
let k = g.reread(ctx, key_slot)?;
let t = g.reread(ctx, recv_slot)?;
Ok(ctx.block().call(
DOUBLE,
"js_put_value_set",
&[
(DOUBLE, &t),
(DOUBLE, &k),
(DOUBLE, &v),
(DOUBLE, &r),
(I32, strict_i32),
],
))
}

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

value is not rooted across the explicit-receiver lowering.

The group holds target and key. v is lowered at line 1523 and, in the else branch, is carried across lower_expr(ctx, receiver)? at line 1549. receiver is an arbitrary user expression that can allocate and run user code. v then reaches js_put_value_set as a stale register.

This is operand-to-operand, the taxonomy (c) shape the module doc says the slice removed. The same-receiver branch is unaffected because nothing is lowered after v there.

Root value with a collects derived from receiver, and re-read it below the receiver's lowering.

🐛 Proposed fix
-            with_rooted_group(ctx, 2, |ctx, g| {
+            with_rooted_group(ctx, 3, |ctx, g| {
                 // The receiver's window covers BOTH the key's lowering and the
                 // value's, so its `collects` is the disjunction — `o[f()] = 1`
                 // has an inert value and a collecting key.
                 let recv_collects =
                     any_operand_may_collect(ctx, [key.as_ref(), value.as_ref(), receiver.as_ref()]);
                 let recv_slot = g.lower(ctx, target, recv_collects)?;
                 let key_collects =
                     any_operand_may_collect(ctx, [value.as_ref(), receiver.as_ref()]);
                 let key_slot = g.lower(ctx, key, key_collects)?;
-                let v = lower_expr(ctx, value)?;
+                // The value is live across the explicit-receiver lowering
+                // below, so it needs the same protection the other operands
+                // get.
+                let explicit_receiver = !same_put_value_receiver_expr(target, receiver);
+                let value_collects = explicit_receiver
+                    && any_operand_may_collect(ctx, std::iter::once(receiver.as_ref()));
+                let value_slot = g.lower(ctx, value, value_collects)?;
                 // `#6812` (w12): same-receiver dynamic-key stores that failed the
                 // inline gate (computed target expressions) still take the
                 // outlined 3-way IC helper.
                 if same_put_value_receiver_expr(target, receiver) {
+                    let v = g.reread(ctx, value_slot)?;
                     let k = g.reread(ctx, key_slot)?;
                     let t = g.reread(ctx, recv_slot)?;
@@
                 } else {
                     // The explicit-receiver form lowers a FOURTH operand, so the
                     // re-reads have to sit below it, not above.
                     let r = lower_expr(ctx, receiver)?;
+                    let v = g.reread(ctx, value_slot)?;
                     let k = g.reread(ctx, key_slot)?;
                     let t = g.reread(ctx, recv_slot)?;
📝 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
with_rooted_group(ctx, 2, |ctx, g| {
// The receiver's window covers BOTH the key's lowering and the
// value's, so its `collects` is the disjunction — `o[f()] = 1`
// has an inert value and a collecting key.
let recv_collects =
any_operand_may_collect(ctx, [key.as_ref(), value.as_ref(), receiver.as_ref()]);
let recv_slot = g.lower(ctx, target, recv_collects)?;
let key_collects =
any_operand_may_collect(ctx, [value.as_ref(), receiver.as_ref()]);
let key_slot = g.lower(ctx, key, key_collects)?;
let v = lower_expr(ctx, value)?;
// #6812 (w12): same-receiver dynamic-key stores that failed the
// inline gate (computed target expressions) still take the
// outlined 3-way IC helper.
if same_put_value_receiver_expr(target, receiver) {
let k = g.reread(ctx, key_slot)?;
let t = g.reread(ctx, recv_slot)?;
let site_id = ctx.ic_site_counter;
ctx.ic_site_counter += 1;
let cache_name = format!("perry_ic_{}", site_id);
ctx.ic_globals.push(cache_name.clone());
let cache_ref = format!("@{}", cache_name);
Ok(ctx.block().call(
DOUBLE,
"js_put_value_set_dyn_ic",
&[
(crate::types::PTR, &cache_ref),
(DOUBLE, &t),
(DOUBLE, &k),
(DOUBLE, &v),
(I32, strict_i32),
],
))
} else {
// The explicit-receiver form lowers a FOURTH operand, so the
// re-reads have to sit below it, not above.
let r = lower_expr(ctx, receiver)?;
let k = g.reread(ctx, key_slot)?;
let t = g.reread(ctx, recv_slot)?;
Ok(ctx.block().call(
DOUBLE,
"js_put_value_set",
&[
(DOUBLE, &t),
(DOUBLE, &k),
(DOUBLE, &v),
(DOUBLE, &r),
(I32, strict_i32),
],
))
}
with_rooted_group(ctx, 3, |ctx, g| {
// The receiver's window covers BOTH the key's lowering and the
// value's, so its `collects` is the disjunction — `o[f()] = 1`
// has an inert value and a collecting key.
let recv_collects =
any_operand_may_collect(ctx, [key.as_ref(), value.as_ref(), receiver.as_ref()]);
let recv_slot = g.lower(ctx, target, recv_collects)?;
let key_collects =
any_operand_may_collect(ctx, [value.as_ref(), receiver.as_ref()]);
let key_slot = g.lower(ctx, key, key_collects)?;
// The value is live across the explicit-receiver lowering
// below, so it needs the same protection the other operands
// get.
let explicit_receiver = !same_put_value_receiver_expr(target, receiver);
let value_collects = explicit_receiver
&& any_operand_may_collect(ctx, std::iter::once(receiver.as_ref()));
let value_slot = g.lower(ctx, value, value_collects)?;
// `#6812` (w12): same-receiver dynamic-key stores that failed the
// inline gate (computed target expressions) still take the
// outlined 3-way IC helper.
if same_put_value_receiver_expr(target, receiver) {
let v = g.reread(ctx, value_slot)?;
let k = g.reread(ctx, key_slot)?;
let t = g.reread(ctx, recv_slot)?;
let site_id = ctx.ic_site_counter;
ctx.ic_site_counter += 1;
let cache_name = format!("perry_ic_{}", site_id);
ctx.ic_globals.push(cache_name.clone());
let cache_ref = format!("@{}", cache_name);
Ok(ctx.block().call(
DOUBLE,
"js_put_value_set_dyn_ic",
&[
(crate::types::PTR, &cache_ref),
(DOUBLE, &t),
(DOUBLE, &k),
(DOUBLE, &v),
(I32, strict_i32),
],
))
} else {
// The explicit-receiver form lowers a FOURTH operand, so the
// re-reads have to sit below it, not above.
let r = lower_expr(ctx, receiver)?;
let v = g.reread(ctx, value_slot)?;
let k = g.reread(ctx, key_slot)?;
let t = g.reread(ctx, recv_slot)?;
Ok(ctx.block().call(
DOUBLE,
"js_put_value_set",
&[
(DOUBLE, &t),
(DOUBLE, &k),
(DOUBLE, &v),
(DOUBLE, &r),
(I32, strict_i32),
],
))
}
🤖 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/proxy_reflect.rs` around lines 1513 - 1563, In
the explicit-receiver branch of the surrounding lowering function, root the
lowered value using a collects flag derived from receiver before calling
lower_expr on receiver. Re-read the rooted value after lower_expr(ctx,
receiver)? and pass that reread value to js_put_value_set; leave the
same-receiver branch unchanged.

Comment on lines +172 to +188
#[test]
fn exec_sync_without_options_emits_no_rooting_traffic() {
let ir = compile_body(
"exec_sync_cold",
vec![Stmt::Expr(Expr::ChildProcessExecSync {
command: Box::new(Expr::String("ls".to_string())),
options: None,
})],
);
require_call_line(&ir, "js_child_process_exec_sync");
assert_eq!(
temp_root_calls(&ir),
0,
"a no-options execSync cannot collect between its operand and the call, so \
operand_protection must route it to Reuse\n{ir}"
);
}

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

The three zero-cost pins use a measure that is blind to the build's rooting lowering. temp_root_calls counts only js_gc_temp_root text, which the FFI fallback emits. The file's own temp_root_slot_width at lines 492-528 records that statepoint / RS4GC is the lowering this build uses, and under it a pooled slot is an entry alloca with no runtime call. All three pins therefore read 0 whether or not the operand was rooted. Replace temp_root_calls with temp_root_slot_width, which covers all three lowerings.

  • crates/perry-codegen/src/expr/slice7_rooting_tests.rs#L172-L188: assert temp_root_slot_width(&ir) == 0 for the no-options execSync.
  • crates/perry-codegen/src/expr/slice7_rooting_tests.rs#L259-L271: assert temp_root_slot_width(&ir) == 0 for the single-operand Reflect.ownKeys.
  • crates/perry-codegen/src/expr/slice7_rooting_tests.rs#L437-L442: assert temp_root_slot_width(&ir) == 0 for the string-literal process.env key.
📍 Affects 1 file
  • crates/perry-codegen/src/expr/slice7_rooting_tests.rs#L172-L188 (this comment)
  • crates/perry-codegen/src/expr/slice7_rooting_tests.rs#L259-L271
  • crates/perry-codegen/src/expr/slice7_rooting_tests.rs#L437-L442
🤖 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` around lines 172 -
188, The three zero-cost rooting tests use temp_root_calls, which is blind to
statepoint/RS4GC lowering; replace it with temp_root_slot_width so the
assertions cover all lowerings. In
crates/perry-codegen/src/expr/slice7_rooting_tests.rs at lines 172-188, 259-271,
and 437-442, update the no-options execSync, single-operand Reflect.ownKeys, and
string-literal process.env key tests to assert temp_root_slot_width(&ir) == 0.

proggeramlug added a commit that referenced this pull request Aug 8, 2026
…undaries (#7665)

* fix(opt-report): narrow test recording to the Session's own thread

opt_report's gate (FORCED) and sink are process-global, but Session's lock
only serialises tests that TAKE a Session. A concurrently-running test that
lowers code without one therefore emitted into the holder's sink, and the
holder's snapshot saw a neighbour's rows.

Observed, not theoretical: only_the_return_position_is_marked_served asserts
rows.len() == 2 and failed at 3 about one run in six once #7662 added lowering
tests, which changed the parallel schedule. 0/14 on main before that PR, 2/18
on it -- the extra row is what identified the mechanism, not the timing.

Recording is now additionally narrowed to the thread that opened the Session.
Production is unaffected: the thread check is inside the #[cfg(test)] forced
branch, and the env-var path is untouched.

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

* fix(ext-registry): narrow provider recording to the test holding the lock

USED_PROVIDERS is process-wide but PROVIDER_TEST_LOCK only serialises tests
that TAKE it, so a concurrently-running test that lowers any code touching an
ext symbol added providers to the holder's set.

Observed: ext_prefix_net_does_not_over_match asserts the set is empty after an
unlisted symbol and failed 1 run in 20 once #7662 added child_process lowering
tests, which call record_ffi_call.

ProviderTestGuard takes the lock AND claims the thread; record_ffi_call skips
the global set when another thread holds a provider test. Production is
unaffected -- PROVIDER_TEST_THREAD is #[cfg(test)] and the predicate returns
true whenever no provider test is running.

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

* docs(changelog): fragment for the test-isolation fixes

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

* chore: bump version to 0.5.1377

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

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant