fix(gc): coercion-capable operators can collect — four rooting gaps from the #6972 review, plus restore the dark gc-stress gate - #6975
Conversation
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.
📝 WalkthroughWalkthroughThe compiler’s GC-trigger prediction now accounts for user-defined coercions. Additional rooting protects BigInt operands, string self-append values, and deferred object-literal closures across allocating operations. ChangesGC-safe codegen temporaries
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant ExpressionLowering
participant TempRootAnalysis
participant RuntimeCoercion
participant GC
ExpressionLowering->>TempRootAnalysis: classify operands and later expressions
TempRootAnalysis-->>ExpressionLowering: require temporary roots when coercion may allocate
ExpressionLowering->>RuntimeCoercion: evaluate ToNumeric or ToString
RuntimeCoercion->>GC: allocate or trigger collection
GC-->>ExpressionLowering: preserve rooted and refreshed values
Possibly related issues
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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/object_literal.rs (1)
473-538: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winRelease
this-closure temp roots before the final rooted handle release.
rooted_handle_release(ctx, rooted)only truncates the root pushed byrooted_handle_begin, sotemp_root_push_doubleentries added forcaptures_this: trueclosures inthis_patchesremain on the temp-root stack. Add/release or truncate eachclosure_rootwhen it is consumed.🤖 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 473 - 538, Release each non-None closure root from this_patches after it has been consumed to refresh the deferred closure value, before rooted_handle_release(ctx, rooted) runs. Update the final this_patches transformation or patch-consumption flow to call the existing temp-root release/truncate mechanism for every closure_root, while preserving the refreshed closure values and None behavior.
🤖 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.
Outside diff comments:
In `@crates/perry-codegen/src/expr/object_literal.rs`:
- Around line 473-538: Release each non-None closure root from this_patches
after it has been consumed to refresh the deferred closure value, before
rooted_handle_release(ctx, rooted) runs. Update the final this_patches
transformation or patch-consumption flow to call the existing temp-root
release/truncate mechanism for every closure_root, while preserving the
refreshed closure values and None behavior.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 69482090-1e53-4354-a75e-9d6020bf4715
📒 Files selected for processing (5)
changelog.d/6975-temp-root-coercion-gate.mdcrates/perry-codegen/src/expr/binary.rscrates/perry-codegen/src/expr/object_literal.rscrates/perry-codegen/src/expr/temp_root.rscrates/perry-codegen/src/lower_string_method.rs
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.
…constructor args, string-method receiver (#6970, #6969, #6971) (#6983) * fix(gc): root Map/Set collection-method operands across allocating operands (#6970) * fix(gc): root collection forEach receiver across callback lowering (#6970) * fix(gc): root string-method receiver and concat accumulator (#6971) * fix(gc): root constructor arguments across the instance allocation (#6969) * fix(gc): root constructor args as they are lowered, not after the loop (#6969) * test(gc): pin the operand-rooting contract for #6969/#6970/#6971 * fix(gc): operand_needs_root must check the shadow slot, not just LocalGet * fix(gc): adopt the #6975 expr_may_trigger_gc signature * docs: changelog fragment for #6969/#6970/#6971 * docs: key the changelog fragment to PR #6983 * fix(gc): re-load registered-root operands instead of reusing a stale register A shadow-slotted local, a module global and a string literal are all marked by the collector, so a suppressed operand can never be swept. But an evacuating cycle REWRITES their storage, and the register loaded beforehand keeps the pre-move address -- the same staleness #6981 reports for a raw typed-array pointer under the specialized ABI. Re-lowering the operand at the re-read point emits the load again and costs no runtime call. * docs: record the re-load half of the fix * fix(gc): root the MapSet receiver before the key is lowered, and refresh ctor args at late consumers Two review findings from CodeRabbit on #6983, both real: 1. The MapSet receiver was rooted only AFTER the key had been lowered, so its exposure window (the key's own lowering, which allocates in `m.set(fresh(k), churn(N))`) was unprotected -- and pushing an already-dead register into a scanned slot is strictly worse than not rooting, the same escalation that turned #6969 into a SIGSEGV. RootedOperands is now built incrementally so each operand is rooted before the next is lowered. 2. lower_new's single post-allocation re-read did not cover the consumers that sit behind field initializers and an inlined constructor body (marshal_imported_ctor_args, the dynamic-parent super args buffer). Those values stay rooted, so this was staleness under evacuation rather than a use-after-free, but it is the same class this PR treats as mandatory. Extracted refresh_rooted_args and called it at those sites too. * fix(gc): snapshot mutable constructor operands instead of re-lowering them CodeRabbit review finding on #6983, and a miscompile I introduced rather than a rooting gap. operand_is_reloadable admitted LocalGet and GlobalGet on the grounds that both are registered roots whose storage evacuation rewrites, so re-lowering recovers the post-move address for free. It does -- but it also reads the variable's CURRENT value, and 'current' is after the later arguments, the field initializers and possibly an inlined constructor body have run. Any of those may have reassigned it. let g = 'before'; function bump() { g = 'after'; return 1; } const c = new C(g, bump()); // c.p must be 'before' printed 'after' on this branch; node and main print 'before'. Only provably immutable sources may be re-loaded, so operand_is_reloadable is now string literals alone. Locals and globals get a real temp-root slot, which preserves the call-time value AND is rewritten on evacuation -- the only option that satisfies both. Regression test: test_gap_ctor_arg_capture_order.ts, verified to fail with the old predicate ('global captured: after'). * fix(codegen): don't emit the string-method root release after a terminator The unknown-property arm of lower_string_method_dispatch throws via js_throw_type_error_not_a_function, emits `unreachable`, then still returns Ok(placeholder) so the caller has a register to phi against. Control therefore returns to the receiver's release site with the block already terminated, and appending js_gc_temp_root_truncate there emits an instruction after the terminator -- invalid IR. Guard with is_terminated(), the idiom already used in codegen/{closure,entry}.rs. Skipping the release is sound: unreachable means no path resumes, and the temp-root stack is cut by the enclosing scope regardless. No regression test: an attempted HIR-level reproducer never reached the throwing arm (no `unreachable` in the emitted IR), so it passed with and without the guard. A green-either-way test is worse than none, so it was removed and the reachability is recorded as unproven. Reported by CodeRabbit on #6983. --------- Co-authored-by: Ralph Küpper <[email protected]>
Follow-up to #6972 (merged). CodeRabbit's review landed after that PR was merged; all four of its findings are real, and one of them is a soundness hole in the predicate that gates every rooting decision the merged change makes. Three are additional sites of the same #6951 bug class.
1.
expr_may_trigger_gcwas unsound for coercing operators (the important one)expr/temp_root.rsdocuments the predicate's contract explicitly:falsemust mean "provably allocates nothing", and then violated it.o < x,-oando * 2all run ToPrimitive / ToNumber on their operands, and a user-definedSymbol.toPrimitive/valueOf/toStringis arbitrary JS — it allocates, and it collects. Recursing into the operands does not see that:a < bover two plainLocalGets recursed straight tofalse. So an argument list of the shapef(freshString(), a < b)would skip rootingfreshString()even thougha < bcan collect — the exact #6951 use-after-free, in a narrower case.These three now answer "GC-capable" unless every operand is a proven inert primitive, via a new
expr_is_inert_primitive: literals, and locals the type analysis provedNumber/Int32/Boolean/Null/Void/Neverand that have no reserved shadow slot (a reserved slot means pointer-possible regardless of the refined type).Addis never inert, since concatenation allocates even over two literals.The predicate now takes
&FnCtxto reachlocal_types/shadow_slot_map;any_later_arg_may_trigger_gchad no callers and is deleted rather than shipped dead.Cost: unchanged.
i < nandx * 2on proven-numeric locals stay inert, so the hot-loop benchmark from #6972 still emits exactly 12 rooting calls.2.
expr/binary.rs— BigInt dynamic helper,!inline_bitwisebranchA second copy of the
let l = lower_expr(..); let r = lower_expr(..); call(fname, l, r)shape that #6972's pass missed (it differed only in indentation). The helper runs ToNumeric on both operands, so a pointer-bearing left operand has to survive the right operand's evaluation. Now useslower_operand_pair_rooted.3.
lower_canonical_str_self_append—s += rhss += rhsmust loadsbefore evaluatingrhs(arhsthat reassignssmust not be observed), so the pre-rhs value is carried acrossrhs's evaluation and acrossjs_jsvalue_to_string— both allocate. Re-reading the slot afterwards would take the wrong value, so the loaded box goes into a temp root and is re-read from there.The coerced rhs handle is rooted too: the cold arm's
unbox_str_handlematerializes an SSO destination onto the heap, which is another allocation with the bare rhs handle live in a register. Both arms now re-read it, and one truncate in the merge block releases the pair (the index register is defined in the entry block, so it dominates).4.
lower_object_literal— deferredthis_patchesMethod-closure values are queued and reused after every remaining property has been lowered, so they sit in SSA registers across all of those allocations, and are then passed to
js_closure_set_capture_bitsas raw pointers. They are now rooted whenever any initializer can collect, and refreshed from their roots before the patch loop.5. Restores the
gc-stressgate, which has been dark since #6925 (#6976)Not planned scope, but it blocks this PR and every other one.
#6925addedtest-files/test_gap_repsel_proven_this_frozen.tswithout registering it intest-parity/gc_repsel_corpus.txt, andscripts/gc_repsel_matrix.shenforces registration by exiting 3 before running anything. Sogc-stresshas been failing onmainwith anUNREGISTEREDmessage ever since, and the corpus was never exercised — the gate was dark, not green. 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:
Bisected: rc=0 at
8327ced52(parent of #6925), rc=1 at1a533a3a8(#6925), rc=1 on currentmain. Not #6972 — a full--arms allrun on that branch (based on8327ced52) was 361/361 byte-exact with FAIL=0, and that run included this exact cell. Filed as #6976 and triaged against it, with an explicit note to remove the entry when fixed: an arm that turns a representation off is supposed to be the safest cell in the matrix.Matrix after this PR: 380 cells, 379 byte-exact, PASS=29 UNVER=350 XFAIL=1 FAIL=0, with
repsel_gc_stress x cons_scan_offandx cons_scan_off_forcestill PASS and the arm measurably live.Verification
Everything from #6972 re-run on this branch, against pinned Node 26.5.0:
PERRY_CONSERVATIVE_STACK_SCAN=off PERRY_GC_HEAP_LIMIT=8.test_gap_*.tsbyte-exact vs Node 26.5.0 — identical result set to theorigin/mainbaseline.scripts/gc_repsel_matrix.sh --arms all: 380 cells, 379 byte-exact, FAIL=0, XFAIL=1 (the gc-stress gate is dark on main: #6925 left its gap file unregistered, and regressed repsel_ptr_shape_locals under PERRY_PTR_SHAPE_LOCALS=0 #6976 cell above);test_gap_repsel_gc_stress × cons_scan_offand× cons_scan_off_forcestill PASS with the arm live.cargo fmt --all -- --checkclean.Summary by CodeRabbit
+=), and object literal method closure handling.