Skip to content

fix(gc): make argument temporaries precise roots (#6951) - #6972

Merged
proggeramlug merged 3 commits into
mainfrom
fix/6951-precise-root-argument-temporaries
Jul 29, 2026
Merged

fix(gc): make argument temporaries precise roots (#6951)#6972
proggeramlug merged 3 commits into
mainfrom
fix/6951-precise-root-argument-temporaries

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Fixes #6951.

The bug

With the conservative native-stack scan disabled — precise/shadow-stack roots only — a collection landing during argument evaluation dropped console.log's string-literal argument. No crash, no diagnostic.

$ PERRY_CONSERVATIVE_STACK_SCAN=off PERRY_GC_HEAP_LIMIT=8 ./repro
alpha 417894
bravo 421991
charlie 417894
421991          <- "delta" gone
417894          <- "echo" gone

Reproduced on the issue's 15-line repro (--profile perry-dev, macOS arm64, node 26.5.0), byte-identically with all six representation gates off (PERRY_CANONICAL_I32_LOCALS=0 PERRY_CANONICAL_STR_LOCALS=0 PERRY_PTR_SHAPE_LOCALS=0 PERRY_PTR_NUMARRAY_LOCALS=0 PERRY_SPECIALIZED_ABI=0 PERRY_INT_VALUED_LOCALS=0) — a runtime rooting gap, not a representation defect. PERRY_GC_TRACE=1 confirms 16 completed cycles, all non-moving.

Root cause

The shadow stack roots named locals: one slot per pointer-typed local, bound to that local's alloca. It has no slot for the values that exist only between two instructions, and an LLVM SSA register is not a GC root.

console.log("alpha", churn(420000)) lowers to:

%r7  = call i64 @js_array_alloc(i32 2)
%r8  = load double, ptr @repro_ts_.str.4.handle        ; "alpha"
%r9  = call i64 @js_array_push_f64(i64 %r7, double %r8)
%r10 = call double @churn(...)                          ; <-- collects here
%r11 = call i64 @js_array_push_f64(i64 %r9, double %r10)
call void @js_console_log_spread(i64 %r11)

%r9 — the argument accumulator, holding the only reference to "alpha" — is live across churn in nothing but an SSA register. The sweep freed it, churn reused the block, and the next js_array_push_f64 wrote the number into a header whose length had been reset to 0. Hence a one-element array and a vanished label with no leading space.

Conservative stack scanning hid this: gc_check_trigger takes ManualGcScanGuard::force_full_scan() on both automatic arms, while gc/roots.rs's nominal production default is Auto -> SkipDisabled. The scan was doing load-bearing correctness work, not acting as a safety net.

Scope found (measured, not asserted)

A 20-shape probe suite, each shape repeated 6× at top level (the failure is a use-after-free — one collection is not enough, the block has to be recycled), run under PERRY_CONSERVATIVE_STACK_SCAN=off PERRY_GC_HEAP_LIMIT=8 against node 26.5.0. 13 of 20 shapes were broken before this PR; 4 of them SIGSEGV'd or aborted.

shape before after
console.log("lit", f()) DIFF ok
console.log("a", "b", f()) DIFF ok
console.log(fresh(), f()) DIFF ok
fresh string in a local across f() (control) ok ok
fresh() + "/" + f() SIGSEGV ok
`${fresh()}#${f()}` DIFF ok
console.log([fresh()], f()) DIFF ok
console.log(JSON.stringify(o), f()) DIFF ok
console.log(fresh(), fresh2(), f()) DIFF ok
[fresh(), f()] (scalar-replaced) DIFF DIFF (#6968)
{ a: fresh(), b: f() } (scalar-replaced) DIFF DIFF (#6968)
new C(fresh(), f()) SIGSEGV DIFF (#6969)
map.set(fresh(), f()) abort abort (#6970)
`fresh().concat(" " + f())` SIGSEGV
join2(fresh(), f()), obj.m(…), arr.push(…), [...a, f()], nested calls, single-arg ok ok

Fixed here:

  • the variadic argument accumulatorconsole.log/info/warn/error/debug/trace/assert/timeLog and their *_spread consumers;
  • the string-concat operand pair (lower_string_concat, lower_string_coerce_concat) and the n-way concat chain (lower_string_concat_chain — template literals, log lines), plus the intermediate js_jsvalue_to_string handle in the both-non-string fallback;
  • the object-literal handle across its initializers (all three lowering paths: unboxed-xy, shape-cache, generic by-name) — the handle is allocated before the property values and lives in a register across every one of them;
  • array-literal element values, which are deliberately evaluated before the array is allocated.

Not fixed, filed granularly with the probe that reproduces each:

The honest general statement: every pointer-valued SSA temporary live across a GC-capable call is unrooted. expr/array_literal.rs said so out loud before this PR ("Element expressions with their own allocations lower to SSA values pinned by conservative stack scanning"). This PR fixes the argument-temporary class the issue names plus the concat/literal families; a codegen-wide safepoint-liveness pass is what would close the rest, and that is a separate epic — the four issues above are its concrete, reproducible entry points.

Mechanism

crates/perry-runtime/src/gc/roots/temp_roots.rs: a per-thread temp-root stack callable from generated code, registered in gc_init as a budgeted mutable root scanner — so slots are marked and rewritten, not pinned. Slots are visited with visit_heap_word_u64_slot, the same decoder the shadow stack uses, so a slot may hold either word form the gc::root_words contract admits: a NaN-boxed value (what lower_expr produces) or a bare heap address (the raw i64 array pointers threaded through js_array_alloc / js_array_push_f64). Immediates decode to nothing, so callers need not prove pointer-ness.

Emission contract, in order:

%idx = call i32 @js_gc_temp_root_push(i64 <bits>)   ; before the collection point
...                                                  ; anything that may collect
%v   = call i64 @js_gc_temp_root_get(i32 %idx)       ; ALWAYS re-read
       call void @js_gc_temp_root_truncate(i32 %idx) ; after the CONSUMING call

Re-reading is mandatory, not defensive: the slot is a mutable root, so an evacuating cycle rewrites it and the register pushed beforehand is stale. That is exactly why this is preferable to widening conservative scanning — conservative roots have to pin, precise ones can move, which is the property #6950 needs.

js_array_push_f64_temp_rooted(idx, value) is the fused accumulator push (get + push + write-back), so an argument list pays the same number of calls per argument as before, plus three per list.

Truncate is a stack cut, not a pop, so a missed release is bounded by the next one rather than leaking forever. Non-local exits are covered without touching crate::exception: ShadowSavepoint now carries the temp-root depth, so the longjmp unwind that already restores the shadow stack restores this stack with it.

Cost

Emission is gated three ways, and any one of them suppresses it entirely:

  1. nothing after this value reaches a collection point (expr_may_trigger_gc, one-sided — unrecognized means "assume it collects");
  2. the value provably cannot be a heap reference (expr_is_known_non_pointer_shadow_value);
  3. the value is a string literal, i.e. a load from a module global already registered via js_gc_register_global_root.

"user_" + i, [1, 2, 3], {a: i, b: total} and all-local argument lists emit byte-identical IR to before — asserted by non_allocating_element_list_emits_no_rooting_calls.

Measured on a hot loop doing a concat, an array literal, an object literal and a template literal per iteration: the three gates took emitted rooting calls from 32 → 12, and the remaining 12 are the template literal's one coerced heap operand (3 calls) plus the once-per-program console.log. A protected site costs one js_gc_temp_root_push + one js_gc_temp_root_get, each a TLS access and a Vec op, plus one truncate per group.

The one place rooting is not gated is the console argument accumulator, which is always rooted: console.log is not a hot path and the arguments are about to be util.inspect-formatted, so a conditional there would buy nothing and could miss a case.

Verification

  • Repro: fixed. Byte-exact under PERRY_CONSERVATIVE_STACK_SCAN=off PERRY_GC_HEAP_LIMIT=8, with PERRY_GC_TRACE=1 confirming real collections.
  • Direct A/B on the corpus member the triage entry names, test_gap_repsel_gc_stress.ts under PERRY_CONSERVATIVE_STACK_SCAN=off PERRY_GC_HEAP_LIMIT=8, 17 completed cycles in both arms:
origin/main                          this branch / node 26.5.0
shapeAndI32 3200030                  shapeAndI32 3200030
strLocals 5395000                    strLocals 5395000
numArray 810240000                   numArray 810240000
40800000        <- "taSum" gone      taSum 40800000
                <- taMix line gone   taMix 43040009
churnAcc 1597830                     churnAcc 1597830
churnSink 2170                       churnSink 2170
  • scripts/gc_repsel_matrix.sh --arms all, pinned node 26.5.0, pressure 8 MB: 361/361 cells byte-exact, FAIL=0, XFAIL=0. test_gap_repsel_gc_stress × cons_scan_off and × cons_scan_off_force moved from XFAIL to PASS with the arm measurably live (17 completed cycles) — the two entries in test-parity/gc_repsel_triage.txt are removed, so cons_scan_off (a PR arm) is now a hard gate on this shape.
  • Gap corpus A/B: 431 test_gap_*.ts files, byte-exact vs node 26.5.0, origin/main vs this branch — identical result sets (402 OK; the 7 DIFF / 9 RUNFAIL / 13 NODEFAIL are pre-existing and unchanged; the last two categories are this ad-hoc harness lacking the real runner's normalization and skip list).
  • Unit tests: 4 new in gc::tests::temp_roots, every one pinning ConservativeStackScanMode::Disabled — with the scan on the bug is invisible, so an unpinned test proves nothing. The load-bearing one (temp_rooted_value_survives_a_real_collection) was verified to have teeth: dropping the scanner registration makes it fail with an object reachable only through a temp root must be marked, not swept (#6951).
  • Codegen IR tests: 3 new in crates/perry-codegen/tests/temp_root_argument_temporaries.rs pinning the emission contract (push / fused push / re-read / truncate, in that order) and the no-cost gate.
  • Two local failure sets are pre-existing and verified identical on origin/main, both unrelated to this change: a workspace cargo test build cannot link the perry-ext-http / perry-ext-http-server lib-test binaries (undefined js_request_new / js_response_new — a feature-unification artifact of the workspace-wide selection; those packages build fine standalone), and
  • crates/perry-codegen/tests/native_proof_regressions.rs has 3 failures (mutable_string_key_rejects_static_write_pic, static_put_value_rejects_write_pic_when_rhs_can_allocate, nested_same_shape_object_writes_version_one_through_four_fields) — verified identical on origin/main, unrelated to this PR.

What this does not fix

moved_objects is still 0 in every arm, and 333 of the 361 matrix cells are still UNVERIFIED. That is #6950 (gc_check_trigger forces a full conservative scan on both automatic arms, making copied-minor ineligible) plus the corpus members that perform zero collections. This PR removes the reason the precise-roots-only arm could not discriminate; making an evacuating precise-rooted minor reachable is the next step, and it is now unblocked.

Ralph Küpper added 2 commits July 29, 2026 07:18
With the conservative native-stack scan disabled -- precise/shadow-stack
roots only -- a collection landing during argument evaluation dropped
console.log's string-literal argument. No crash, no diagnostic. Harder
shapes (string concat with an allocating right operand, `new C(str, f())`)
segfaulted.

Root cause: the shadow stack roots named locals. It has no slot for the
values that exist only between two instructions, and an LLVM SSA register
is not a GC root. `console.log(a, b)` lowers to `js_array_alloc(2)` plus
one `js_array_push_f64` per argument, with the accumulator threaded through
an SSA register. That register held the ONLY reference to everything pushed
so far -- argument 0 included -- across argument 1's evaluation. A
collection there swept the half-built array and the following push landed
in recycled memory whose length had been reset to 0, so the label
disappeared and only the number printed.

Conservative stack scanning hid this: gc_check_trigger forces a full
conservative scan on both automatic arms, while gc/roots.rs's nominal
production default is Auto -> SkipDisabled. The scan was doing load-bearing
correctness work, not acting as a safety net.

New mechanism: a per-thread temp-root stack callable from generated code
(gc/roots/temp_roots.rs), registered as a budgeted mutable root scanner, so
slots are both MARKED and REWRITTEN. Slots decode through
visit_heap_word_u64_slot, accepting both word forms the gc::root_words
contract admits (NaN-boxed values and bare heap addresses -- generated code
pushes both). Generated code pushes before the collection point, re-reads
after (mandatory: an evacuating cycle rewrites the slot), and truncates
after the consuming call. ShadowSavepoint carries the depth, so the longjmp
unwind that already restores the shadow stack restores this stack with it.

Rooted sites: the variadic argument accumulator (console log/info/warn/
error/debug/trace/assert/timeLog), the string-concat operand pair and the
n-way concat chain (template literals, log lines), the object-literal
handle across its initializers, and array-literal element values. Emission
is gated on "does anything after this reach a collection point" -- `"a" + i`,
`[1, 2, 3]` and all-local argument lists emit byte-identical IR to before.

test-parity/gc_repsel_triage.txt: both triaged cells clear. The matrix's
cons_scan_off arm (in the PR arm set) is now a hard gate on this shape.
Two gates on top of "does anything after this collect":

- A value `expr_is_known_non_pointer_shadow_value` proves is not a heap
  reference roots nothing, so a slot for it is pure TLS traffic. This is
  what keeps `total + s.length` and every other numeric operand pair on
  byte-identical IR.
- A string literal loads from a module global that
  `__perry_init_strings_*` already registered with
  `js_gc_register_global_root`, so the sweep can never take it. (The
  loaded register is still stale after an *evacuating* cycle, but that
  is true of every `Expr::String` use in the compiler and is not the
  hazard #6951 is about.) Template literals are mostly literal parts,
  so this is the difference between 3 and 7 rooting calls per
  interpolation.

Measured on a hot loop doing `"user_" + i`, an array literal, an object
literal and a template literal per iteration: 32 -> 12 emitted rooting
calls, and the remaining 12 are the template literal's one coerced heap
operand plus the once-per-program console.log.
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a per-thread precise temporary-root stack, registers it with the GC, and applies it to generated argument, literal, concatenation, binary, and console lowering. Runtime, codegen, regression tests, and GC parity triage are updated for precise collection without conservative stack scanning.

Changes

Precise GC root handling

Layer / File(s) Summary
Runtime temporary-root stack
crates/perry-runtime/src/gc/...
Adds TLS-backed temporary-root slots, mutable scanning, FFI operations, array-growth support, test exports, and shadow-savepoint restoration.
Codegen rooting framework
crates/perry-codegen/src/expr/temp_root.rs, crates/perry-codegen/src/expr/mod.rs, crates/perry-codegen/src/runtime_decls/arrays.rs
Adds GC-effect analysis, rooted expression and operand lowering, rooted arrays, conditional rooted handles, and runtime declarations.
Expression and literal rooting
crates/perry-codegen/src/expr/array_literal.rs, crates/perry-codegen/src/expr/object_literal.rs, crates/perry-codegen/src/expr/binary.rs, crates/perry-codegen/src/lower_string_method.rs
Preserves array elements, object handles, binary operands, and string-concatenation values across potentially allocating operations.
Console argument accumulation
crates/perry-codegen/src/lower_call/console_promise.rs
Uses rooted accumulators and re-reads rooted labels and conditions before console runtime calls.
GC rooting validation
crates/perry-codegen/tests/temp_root_argument_temporaries.rs, crates/perry-runtime/src/gc/tests/*, test-parity/gc_repsel_triage.txt, changelog.d/6972-precise-root-argument-temporaries.md
Tests GC survival, stack semantics, array reallocation, codegen gates, and parity expectations while documenting the fix.

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

Sequence Diagram(s)

sequenceDiagram
  participant ExpressionLowering
  participant TempRootAPI
  participant GC
  participant RuntimeCall
  ExpressionLowering->>TempRootAPI: push evaluated temporary
  ExpressionLowering->>ExpressionLowering: lower later allocating expression
  GC->>TempRootAPI: scan and rewrite root slot
  ExpressionLowering->>TempRootAPI: re-read updated value
  ExpressionLowering->>RuntimeCall: consume rooted values
  ExpressionLowering->>TempRootAPI: truncate temporary roots
Loading

Possibly related issues

  • #6971 — Adds related temporary-root utilities but leaves another string receiver/argument lowering path unchanged.
  • #6942 — Concerns the same GC-rooting family and evacuating-minor regression coverage.
  • #6949 — Addresses values held across potentially allocating string operations.
  • #6946 — Concerns precise GC-root testing and forced-evacuation behavior.
  • #6970 — Covers analogous precise rooting for evaluated native-method arguments.
  • #6969 — Identifies similar unrooted constructor argument temporaries.

Suggested reviewers: thehypnoo

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The code adds precise temp-root support and uses it for the console argument-temporary regression described in #6951.
Out of Scope Changes check ✅ Passed The changes stay focused on precise temp roots, related codegen/runtime support, tests, and triage for the reported GC bug.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Title check ✅ Passed The title accurately summarizes the main change: making argument temporaries precise GC roots for issue #6951.
Description check ✅ Passed The description covers the bug, root cause, fix, verification, and linked issue, though it doesn't follow the template headings exactly.
✨ 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/6951-precise-root-argument-temporaries

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
proggeramlug merged commit 760db2f into main Jul 29, 2026
5 of 6 checks passed
@proggeramlug
proggeramlug deleted the fix/6951-precise-root-argument-temporaries branch July 29, 2026 06:17

@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

Caution

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

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

1408-1463: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Root lhs_box before lowering the non-string rhs.

In the non-string rhs path, rhs_val/js_jsvalue_to_string can run while lhs_box is live in an SSA register, reproducing the same #6951 use-after-free pattern fixed by lower_operand_pair_rooted/temp_root_* elsewhere. Use lower_operand_pair_rooted(ctx, &slot, rhs)/temp_root_release, or root the slot result before evaluating rhs and re-read it before the dispatch/unbox.

🤖 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 1408 - 1463,
Root the destination slot value before lowering the non-string rhs in
lower_canonical_str_self_append, preventing rhs evaluation and
js_jsvalue_to_string from invalidating the live lhs_box SSA value. Use the
existing lower_operand_pair_rooted and temp_root_release flow, or root the slot
result and reload it before tag dispatch and unboxing; preserve the current
append branching behavior.
crates/perry-codegen/src/expr/binary.rs (1)

543-550: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Root the left operand across the BigInt dynamic-helper call.

Use lower_operand_pair_rooted(ctx, left, right) here and call temp_root_release(ctx, guard) after ctx.block().call(DOUBLE, fname, ...) so a GC during right’s evaluation cannot collect/move a pointer-bearing left operand that fname still needs.

🤖 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/binary.rs` around lines 543 - 550, Update the
!inline_bitwise branch in the BigInt dynamic-helper path to use
lower_operand_pair_rooted(ctx, left, right), retaining the returned guard and
rooted operands. Call ctx.block().call(DOUBLE, fname, ...) with those operands,
then invoke temp_root_release(ctx, guard) after the call before returning,
ensuring the left operand remains rooted through right evaluation and helper
execution.
crates/perry-codegen/src/expr/object_literal.rs (1)

472-539: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Root postponed closure values before reusing them.

this_patches keeps method-closure handles across later lower_expr calls, and after the loop each value is used as a pointer argument to js_closure_set_capture_bits. Root every pushed closure value or all deferred slots together, then refresh each closure_val before the patch loop so a later allocation cannot move an earlier queue entry.

🤖 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/object_literal.rs` around lines 472 - 539, The
deferred values stored in this_patches can become stale after later lower_expr
allocations. Root each pushed closure value (or the complete deferred
collection) while building the object, then refresh the rooted closure value
before using it in the js_closure_set_capture_bits loop; update the this_patches
handling without changing the existing capture-index or patching order.
🧹 Nitpick comments (1)
crates/perry-codegen/tests/temp_root_argument_temporaries.rs (1)

139-227: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Move this #6951 coverage into cargo-test.

crates/perry-codegen/tests/temp_root_argument_temporaries.rs is integration coverage behind ci_e2e_scope.py; the scoping rules select direct suite/module/fixture changes but otherwise don’t map perry-codegen src/ changes to suites. A regression in expr/temp_root.rs, expr/array_literal.rs, or related codegen could leave these exact temp-root checks unrun.

🤖 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/tests/temp_root_argument_temporaries.rs` around lines
139 - 227, Move the `#6951` temp-root coverage from the integration test module
into the cargo-test suite so it runs under the applicable CI scope. Preserve the
assertions and ordering checks from console_argument_accumulator_is_temp_rooted,
non_allocating_element_list_emits_no_rooting_calls, and
array_literal_roots_elements_before_an_allocating_element, adapting only the
test harness needed for cargo-test.

Sources: Coding guidelines, Learnings

🤖 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/temp_root.rs`:
- Around line 99-139: Update expr_may_trigger_gc to conservatively treat
coercion-capable expressions as GC-triggering: Compare, Unary, and non-Add
Binary operations must return true unless their operands are provably inert
primitives, while still recursing through safe operand evaluations as
appropriate. Ensure loose/relational comparisons and numeric coercions cannot
incorrectly return false when runtime conversion may invoke user-defined methods
or allocate.

---

Outside diff comments:
In `@crates/perry-codegen/src/expr/binary.rs`:
- Around line 543-550: Update the !inline_bitwise branch in the BigInt
dynamic-helper path to use lower_operand_pair_rooted(ctx, left, right),
retaining the returned guard and rooted operands. Call ctx.block().call(DOUBLE,
fname, ...) with those operands, then invoke temp_root_release(ctx, guard) after
the call before returning, ensuring the left operand remains rooted through
right evaluation and helper execution.

In `@crates/perry-codegen/src/expr/object_literal.rs`:
- Around line 472-539: The deferred values stored in this_patches can become
stale after later lower_expr allocations. Root each pushed closure value (or the
complete deferred collection) while building the object, then refresh the rooted
closure value before using it in the js_closure_set_capture_bits loop; update
the this_patches handling without changing the existing capture-index or
patching order.

In `@crates/perry-codegen/src/lower_string_method.rs`:
- Around line 1408-1463: Root the destination slot value before lowering the
non-string rhs in lower_canonical_str_self_append, preventing rhs evaluation and
js_jsvalue_to_string from invalidating the live lhs_box SSA value. Use the
existing lower_operand_pair_rooted and temp_root_release flow, or root the slot
result and reload it before tag dispatch and unboxing; preserve the current
append branching behavior.

---

Nitpick comments:
In `@crates/perry-codegen/tests/temp_root_argument_temporaries.rs`:
- Around line 139-227: Move the `#6951` temp-root coverage from the integration
test module into the cargo-test suite so it runs under the applicable CI scope.
Preserve the assertions and ordering checks from
console_argument_accumulator_is_temp_rooted,
non_allocating_element_list_emits_no_rooting_calls, and
array_literal_roots_elements_before_an_allocating_element, adapting only the
test harness needed for cargo-test.
🪄 Autofix (Beta)

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: e3832b0e-7978-4fc3-b247-0054ee4c5a00

📥 Commits

Reviewing files that changed from the base of the PR and between 8327ced and e46c5c8.

📒 Files selected for processing (17)
  • changelog.d/6972-precise-root-argument-temporaries.md
  • crates/perry-codegen/src/expr/array_literal.rs
  • crates/perry-codegen/src/expr/binary.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-codegen/src/expr/object_literal.rs
  • crates/perry-codegen/src/expr/temp_root.rs
  • crates/perry-codegen/src/lower_call/console_promise.rs
  • crates/perry-codegen/src/lower_string_method.rs
  • crates/perry-codegen/src/runtime_decls/arrays.rs
  • crates/perry-codegen/tests/temp_root_argument_temporaries.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/roots.rs
  • crates/perry-runtime/src/gc/roots/shadow_stack.rs
  • crates/perry-runtime/src/gc/roots/temp_roots.rs
  • crates/perry-runtime/src/gc/tests/mod.rs
  • crates/perry-runtime/src/gc/tests/temp_roots.rs
  • test-parity/gc_repsel_triage.txt

Comment on lines +99 to +139
pub(crate) fn expr_may_trigger_gc(expr: &Expr) -> bool {
match expr {
// Immediates and plain slot reads. `LocalGet` reads an alloca,
// `GlobalGet` a module global — neither allocates.
Expr::Undefined
| Expr::Null
| Expr::Bool(_)
| Expr::Number(_)
| Expr::Integer(_)
| Expr::LocalGet(_)
| Expr::GlobalGet(_) => false,
// A string literal is materialized once into a module-global handle by
// `__perry_init_strings_*` and registered as a GC root there; the use
// site is a load.
Expr::String(_) => false,
Expr::Unary { operand, .. } => expr_may_trigger_gc(operand),
Expr::Compare { left, right, .. } => {
expr_may_trigger_gc(left) || expr_may_trigger_gc(right)
}
// `+` on unknown operands can be string concatenation, which allocates;
// every other binary operator is numeric or bitwise.
Expr::Binary {
op, left, right, ..
} => {
matches!(op, perry_hir::BinaryOp::Add)
|| expr_may_trigger_gc(left)
|| expr_may_trigger_gc(right)
}
Expr::Conditional {
condition,
then_expr,
else_expr,
} => {
expr_may_trigger_gc(condition)
|| expr_may_trigger_gc(then_expr)
|| expr_may_trigger_gc(else_expr)
}
Expr::Sequence(exprs) => exprs.iter().any(expr_may_trigger_gc),
_ => true,
}
}

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 | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect CompareOp variants and how loose/relational comparisons + non-Add
# binary/unary ops are lowered, to see if they can invoke user-defined
# valueOf/toPrimitive (allocating) code without being flagged by
# expr_may_trigger_gc.
rg -n 'enum CompareOp' -A 15
rg -n 'enum BinaryOp' -A 20
rg -nP '\bfn lower\b' -A 60 crates/perry-codegen/src/expr/compare.rs
rg -nP '\bfn lower\b' -A 60 crates/perry-codegen/src/expr/binary.rs
rg -nP '\bfn lower\b' -A 60 crates/perry-codegen/src/expr/unary.rs
rg -n 'js_loose_eq|js_to_primitive|js_relational_compare|js_to_number|js_abstract' crates/perry-runtime/src -g '*.rs'

Repository: PerryTS/perry

Length of output: 151


🏁 Script executed:

#!/bin/bash
set -u

echo "repo files match expr/temp_root.rs:"
git ls-files | grep -F 'crates/perry-codegen/src/expr/temp_root.rs' || true
git ls-files | grep -F 'crates/perry-codegen/src/expr' | head -80 || true

echo
echo "temp_root.rs relevant section:"
if [ -f crates/perry-codegen/src/expr/temp_root.rs ]; then
  nl -ba crates/perry-codegen/src/expr/temp_root.rs | sed -n '1,180p'
fi

echo
echo "hir comparison/op enums by filenames:"
git ls-files | rg 'src/.*(compare|binary|unary).*\.rs$|src/.*(hir|temporal).*\.rs$' | head -200
rg -n 'enum CompareOp|enum BinaryOp|enum UnaryOp|LooseEq|LooseNe|Strict|Loose|Relational|NumberCheck|BigInt|Integer|String|Boolean|Null|Undefined' -g '*.rs' crates | head -250

Repository: PerryTS/perry

Length of output: 31185


🏁 Script executed:

#!/bin/bash
set -u

echo "temp_root.rs first 180 lines:"
awk '{printf "%5d    %s\n", NR, $0}' crates/perry-codegen/src/expr/temp_root.rs | sed -n '1,180p'

echo
echo "compare/binary/unary outlines:"
for f in crates/perry-codegen/src/expr/compare.rs crates/perry-codegen/src/expr/binary.rs crates/perry-codegen/src/expr/unary.rs; do
  echo "--- $f"
  ast-grep outline "$f" || true
done

echo
echo "compare.rs lower body:"
awk '{printf "%5d    %s\n", NR, $0}' crates/perry-codegen/src/expr/compare.rs | sed -n '1,240p'

echo
echo "binary.rs relevant contents:"
awk '{printf "%5d    %s\n", NR, $0}' crates/perry-codegen/src/expr/binary.rs | sed -n '1,260p'

echo
echo "unary.rs relevant contents:"
awk '{printf "%5d    %s\n", NR, $0}' crates/perry-codegen/src/expr/unary.rs | sed -n '1,260p'

echo
echo "CompareOp/BinaryOp/UnaryOp definitions in perry-hir:"
rg -n 'pub enum ComparisonOp|pub enum BinaryOp|pub enum UnaryOp|LooseEq|LooseNe|Lt|Gt|Le|Ge|NumberCheck|BigInt' crates/perry-hir crates/perry-codegen/src -g '*.rs' | head -240

Repository: PerryTS/perry

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -u

echo "temp_root.rs compare/binary/unary callsites and guard logic:"
awk '{printf "%5d    %s\n", NR, $0}' crates/perry-codegen/src/expr/temp_root.rs | sed -n '180,380p'

echo
echo "perry-hir ComparisonOp/BinaryOp/UnaryOp definitions:"
rg -n 'ComparisonOp|Bin(Op|op)|UnaryOp|LooseEq|LooseNe|Lt|Gt|Le|Ge|Pos|Neg|BitNot|Add|Eq|Ne' crates/perry-hir/src -g '*.rs' | head -220

echo
echo "binary.rs public lower body:"
awk '{printf "%5d    %s\n", NR, $0}' crates/perry-codegen/src/expr/binary.rs | sed -n '260,560p'

echo
echo "runtime compare coercion calls:"
rg -n 'pub unsafe extern "C" fn js_rel|pub unsafe extern "C" fn js_loose_eq|pub unsafe extern "C" fn js_dynamic|js_to_primitive|js_to_number|js_abstract' crates/perry-runtime/src -g '*.rs' | head -220

echo
echo "runtime js_rel implementation snippets:"
for f in crates/perry-runtime/src/object/compute.rs crates/perry-runtime/src/object/mod.rs crates/perry-runtime/src/*.rs; do
  [ -f "$f" ] || continue
  if rg -q 'js_rel|js_loose_eq|js_dynamic' "$f"; then
    echo "--- $f"
    rg -n 'js_rel|js_loose_eq|js_dynamic_to_primitive|js_to_number_to_js_value|js_to_primitive|js_to_number|js_value_to_string|js_value_to_numeric|js_value_to_big_int|js_value_to_number' "$f" -A 80 -B 10 | head -260
  fi
done

Repository: PerryTS/perry

Length of output: 48335


🏁 Script executed:

#!/bin/bash
set -u

echo "compare.rs remaining lower body:"
awk '{printf "%5d    %s\n", NR, $0}' crates/perry-codegen/src/expr/compare.rs | sed -n '240,520p'

echo
echo "HIR enum definition exact snippets:"
rg -n '^pub (enum|struct) ComparisonOp|^pub (enum|struct) BinaryOp|^pub (enum|struct) UnaryOp|LooseEq|LooseNe|Lt|Gt|Le|Ge|Add|BitAnd|BitOr|BitXor|Shl|Shr|UShr|Pow' crates/perry-hir/src/codes.rs crates/perry-hir/src/ir.rs crates/perry-hir/src/ast.rs crates/perry-hir/src/expr -g '*.rs' -A 3 -B 3 | head -280 || true

echo
echo "js_rel and loose helper declarations / definitions:"
rg -n 'pub unsafe extern "C" fn js_rel|pub unsafe extern "C" fn js_loose_eq|pub unsafe extern "C" fn js_value_to|fn js_rel|fn js_loose|js_rel\(|js_loose_eq\(|Symbol::toPrimitive|toPrimitive|valueOf|toString' crates/perry-runtime/src -g '*.rs' -A 8 -B 3 | head -320

echo
echo "runtime symbol js_to_primitive implementation:"
awk '{printf "%5d    %s\n", NR, $0}' crates/perry-runtime/src/symbol/iterator.rs | sed -n '440,530p'

echo
echo "runtime dynamic arithmetic coercion bodies:"
awk '{printf "%5d    %s\n", NR, $0}' crates/perry-runtime/src/value/dynamic_arith.rs | sed -n '180,260p'

echo
echo "low-level coercion implementations:"
rg -n 'pub\s+unsafe\s+extern\s+"C"\s+fn\s+js_value_to_numeric|pub\s+unsafe\s+extern\s+"C" f' crates/perry-runtime/src -g '*.rs' -A 80 -B 10 | head -360

Repository: PerryTS/perry

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -u

echo "compare.rs final fallthrough:"
awk '{printf "%5d    %s\n", NR, $0}' crates/perry-codegen/src/expr/compare.rs | sed -n '508,620p'

echo
echo "js_rel loose helper direct matches:"
rg -n 'js_rel|js_loose_eq|js_to_primitive|js_value_to_string|js_value_to_number|js_to_number' crates/perry-runtime/src/value crates/perry-runtime/src/object -g '*.rs' -A 80 -B 6 | head -420

Repository: PerryTS/perry

Length of output: 49674


Root across coercion-capable operands in expr_may_trigger_gc.

Compare, unary numeric coercions, and non-add binary coercion paths can dispatch to runtime helpers that invoke user-defined Symbol.toPrimitive/valueOf/toString and allocation can occur on those operand evaluations. The current false case for Compare ignores the loose/relational ops, and the current Unary/non-Add Binary cases only recurse into operands; make these cases return true unless the operands are provably inert primitives, or handle the coercion points explicitly when computing GC roots.

🤖 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/temp_root.rs` around lines 99 - 139, Update
expr_may_trigger_gc to conservatively treat coercion-capable expressions as
GC-triggering: Compare, Unary, and non-Add Binary operations must return true
unless their operands are provably inert primitives, while still recursing
through safe operand evaluations as appropriate. Ensure loose/relational
comparisons and numeric coercions cannot incorrectly return false when runtime
conversion may invoke user-defined methods or allocate.

proggeramlug pushed a commit that referenced this pull request Jul 29, 2026
The GC x representation matrix has not run on main since #6925. That PR
added test-files/test_gap_repsel_proven_this_frozen.ts without registering
it in test-parity/gc_repsel_corpus.txt, and scripts/gc_repsel_matrix.sh
enforces registration by exiting 3 BEFORE running anything. So `gc-stress`
was failing on every PR with an UNREGISTERED message and the corpus was
never exercised -- the gate was dark, not green. That is exactly the
omission the manifest exists to catch (#6954, RFC 5.6); registering the
file is one line.

With the gate running again a genuine red cell appears, and it is Phase
5a's own escape hatch:

  repsel_ptr_shape_locals x rep_ptr_shape_off  (PERRY_PTR_SHAPE_LOCALS=0
  + PERRY_GC_FORCE_EVACUATE=1) dies partway with
  `TypeError: Cannot read properties of undefined (reading 'area')`,
  losing its last five output lines.

Bisected: rc=0 at 8327ced (parent of #6925), rc=1 at 1a533a3 (#6925),
rc=1 on current main. NOT #6972 -- a full --arms all run on that branch
(based on 8327ced) was 361/361 byte-exact with FAIL=0, which included
this cell. Filed as #6976 and triaged against it, with an explicit
instruction to remove the entry when it is fixed: an arm that turns a
representation OFF is supposed to be the safest cell in the matrix.

Result: 380 cells, 379 byte-exact, PASS=29 UNVER=350 XFAIL=1 FAIL=0, and
repsel_gc_stress x cons_scan_off / cons_scan_off_force stay PASS with the
arm live.
proggeramlug added a commit that referenced this pull request Jul 29, 2026
…rom the #6972 review, plus restore the dark gc-stress gate (#6975)

* fix(gc): close four rooting gaps CodeRabbit found in the #6951 review

All four are real; three are sites, one is a soundness hole in the gate.

1. `expr_may_trigger_gc` was unsound for coercing operators. It answered
   `false` for `Compare` / `Unary` / non-`Add` `Binary` whenever it could
   recurse into the operands without finding an allocation — but `o < x`,
   `-o` and `o * 2` run ToPrimitive / ToNumber on their operands, and a
   user-defined `Symbol.toPrimitive` / `valueOf` / `toString` is arbitrary
   JS: it allocates and it collects. `a < b` over two plain `LocalGet`s
   recursed straight to `false`. Since the predicate's whole contract is
   "false must mean provably no collection", that re-introduced the very
   bug in a narrower case. These operators are now GC-capable unless every
   operand is a proven inert primitive (`expr_is_inert_primitive`:
   literals, and locals the type analysis proved Number / Int32 / Boolean /
   Null / Void / Never with no reserved shadow slot). `i < n` and `x * 2`
   on numeric locals stay free — hot-loop rooting-call count is unchanged
   at 12.

2. `expr/binary.rs` BigInt dynamic helper, `!inline_bitwise` branch: a
   second copy of the two-`lower_expr` shape that the first pass missed
   (different indentation). Now uses `lower_operand_pair_rooted`.

3. `lower_canonical_str_self_append`: `s += rhs` must load `s` BEFORE
   evaluating `rhs` (a `rhs` that reassigns `s` must not be observed), so
   the pre-rhs value is carried across `rhs` and across
   `js_jsvalue_to_string` — both allocate. Re-reading the slot would take
   the wrong value, so the loaded box goes into a temp root. The coerced
   rhs handle is rooted too: the cold arm's `unbox_str_handle`
   materializes an SSO destination onto the heap, which is another
   allocation with the rhs handle live.

4. `lower_object_literal`'s `this_patches`: method-closure values are
   deferred and reused after every remaining property is lowered, so they
   sit in SSA registers across all of those allocations. Rooted when any
   initializer can collect, and refreshed before the patch loop.

Re-verified: repro fixed; probe suite unchanged; 431-file gap sweep
identical to the origin/main baseline; gc_repsel_matrix --arms all
361/361 byte-exact, FAIL=0, XFAIL=0; throw-through-argument-list
byte-exact under both arms.

* docs: changelog fragment for #6975

* test(gc): restore the gc-stress gate that #6925 left dark

The GC x representation matrix has not run on main since #6925. That PR
added test-files/test_gap_repsel_proven_this_frozen.ts without registering
it in test-parity/gc_repsel_corpus.txt, and scripts/gc_repsel_matrix.sh
enforces registration by exiting 3 BEFORE running anything. So `gc-stress`
was failing on every PR with an UNREGISTERED message and the corpus was
never exercised -- the gate was dark, not green. That is exactly the
omission the manifest exists to catch (#6954, RFC 5.6); registering the
file is one line.

With the gate running again a genuine red cell appears, and it is Phase
5a's own escape hatch:

  repsel_ptr_shape_locals x rep_ptr_shape_off  (PERRY_PTR_SHAPE_LOCALS=0
  + PERRY_GC_FORCE_EVACUATE=1) dies partway with
  `TypeError: Cannot read properties of undefined (reading 'area')`,
  losing its last five output lines.

Bisected: rc=0 at 8327ced (parent of #6925), rc=1 at 1a533a3 (#6925),
rc=1 on current main. NOT #6972 -- a full --arms all run on that branch
(based on 8327ced) was 361/361 byte-exact with FAIL=0, which included
this cell. Filed as #6976 and triaged against it, with an explicit
instruction to remove the entry when it is fixed: an arm that turns a
representation OFF is supposed to be the safest cell in the matrix.

Result: 380 cells, 379 byte-exact, PASS=29 UNVER=350 XFAIL=1 FAIL=0, and
repsel_gc_stress x cons_scan_off / cons_scan_off_force stay PASS with the
arm live.

---------

Co-authored-by: Ralph Küpper <[email protected]>
proggeramlug added a commit that referenced this pull request Jul 29, 2026
…6987) (#6989)

* docs(gc): policy gate defaults said OFF while the code defaults ON

gc_incremental_enabled's doc header claimed 'EXPERIMENTAL — default OFF' eight
lines above a body comment reading 'DEFAULT ON (#6180 flip)'. gc_safepoint_moving_minor
described its gate gc_moving_safepoint_enabled as '(default off)' when that
accessor is !matches!(env, 0|off|false), i.e. default ON.

Both now state the real default and name the kill switch. Also records why the
incremental default is load-bearing beyond pause times: with the stepper on,
budgeted cycles skip the conservative stack-scan subphase structurally, so a
compiled program completes precise-roots-only cycles in its shipped config.
Reasoning that assumes the automatic arms force a conservative scan is wrong by
default — the mistake #6972 made while this comment still said the gate was off.

gc_moving_loop_polls_enabled's 'opt-in, default OFF' is accurate and unchanged.

Refs #6987

* changelog: fragment for #6989

---------

Co-authored-by: Ralph Küpper <[email protected]>
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