From c55c1044c17f6b954074cc31e02af0959ba9f314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 1 Aug 2026 19:44:54 +0200 Subject: [PATCH 1/8] test(gc): reproducers for #7200/#7201/#7202 --- .../test_gap_gc_inline_ctor_this_rooting.ts | 55 +++++++++++++++++++ .../test_gap_gc_spread_accessor_rooting.ts | 52 ++++++++++++++++++ .../test_gap_gc_static_block_this_rooting.ts | 50 +++++++++++++++++ 3 files changed, 157 insertions(+) create mode 100644 test-files/test_gap_gc_inline_ctor_this_rooting.ts create mode 100644 test-files/test_gap_gc_spread_accessor_rooting.ts create mode 100644 test-files/test_gap_gc_static_block_this_rooting.ts diff --git a/test-files/test_gap_gc_inline_ctor_this_rooting.ts b/test-files/test_gap_gc_inline_ctor_this_rooting.ts new file mode 100644 index 0000000000..31d58b96de --- /dev/null +++ b/test-files/test_gap_gc_inline_ctor_this_rooting.ts @@ -0,0 +1,55 @@ +// #7202: the inline-constructor path parks the instance in a plain +// `alloca_entry` slot (`this_slot`) that the collector never rewrites. +// +// `force_ctor_call` requires `class.constructor.is_some()`, so a class with +// FIELDS but no own constructor takes the inline path by default — no +// `PERRY_INLINE_CTOR` needed. Every `this` read inside the inlined body loads +// from that bare alloca. #7192 temp-roots the instance across the body, so an +// evacuating minor MOVES it rather than freeing it and rewrites the temp root — +// but `this_slot` still holds the pre-move address, so every field initializer +// after the collection stores into abandoned from-space memory and the fields +// simply never appear on the object the program keeps. +// +// `first`'s initializer allocates hard enough to reach the collector; `second` +// and `third` are stored AFTER it, through the stale `this`. +// +// LIVE BY CONSTRUCTION AND ONLY ON THE MOVING ARMS: a non-moving minor leaves +// the instance where it is, so `this_slot` stays accidentally correct. + +function churn(): number { + const a: any[] = []; + for (let i = 0; i < 600; i++) { + a.push({ i: i, s: "w" }); + } + return a.length; +} + +class Holder { + first: number = churn(); + second: number = 42; + third: string = "tail"; +} + +// `new Holder()` must ESCAPE, or scalar replacement deletes the object +// outright and every field becomes its own entry alloca — which #6968 already +// shadow-binds, so the inline-ctor `this_slot` is never materialized. Returning +// the instance from a separate function is the smallest escape that keeps the +// inline-constructor path live. +function mk(): any { + return new Holder(); +} + +function run(): string { + let badFirst = 0; + let badSecond = 0; + let badThird = 0; + for (let r = 0; r < 400; r++) { + const h = mk(); + if (h.first !== 600) badFirst++; + if (h.second !== 42) badSecond++; + if (h.third !== "tail") badThird++; + } + return "first " + badFirst + " second " + badSecond + " third " + badThird; +} + +console.log("bad", run()); diff --git a/test-files/test_gap_gc_spread_accessor_rooting.ts b/test-files/test_gap_gc_spread_accessor_rooting.ts new file mode 100644 index 0000000000..67879d42b5 --- /dev/null +++ b/test-files/test_gap_gc_spread_accessor_rooting.ts @@ -0,0 +1,52 @@ +// #7200: `{ ...src, tail: 7 }` where `src` carries an ACCESSOR must survive an +// evacuating minor that runs inside the spread helper. +// +// Since #809 an object literal containing a spread lowers to a source-ordered +// IIFE built on `js_object_assign_one` (`lower/expr_object.rs`), NOT to +// `Expr::ObjectSpread` (which is JSX-only). `js_object_assign_one` reads every +// own key of the source, so a getter there runs arbitrary USER CODE inside the +// runtime helper — and user code reaches a loop back-edge poll, which under +// `PERRY_GC_MOVING_LOOP_POLLS=1` is an evacuating minor. +// +// Two values are stale across that window and both had to be fixed: +// * codegen side — the accumulator `acc` was threaded through a bare SSA +// register across every `js_object_assign_one` call, so the destination +// object named from-space after the first accessor collected; +// * runtime side — `js_object_assign_one` held the target, the source and the +// key list in Rust locals across the getter invocation and stored into them +// afterwards. +// +// LIVE BY CONSTRUCTION AND ONLY ON THE MOVING ARMS. The getter allocates hard +// enough to reach the collector, and the copied value is read back immediately +// after — a non-moving collection cannot expose it, so the `requires=move` arms +// are the ones that bite. + +function churn(): number { + const a: any[] = []; + for (let i = 0; i < 600; i++) { + a.push({ i: i, s: "z" }); + } + return a.length; +} + +function run(): string { + let badPlain = 0; + let badHot = 0; + let badTail = 0; + for (let r = 0; r < 400; r++) { + const src: any = { plain: 5 }; + Object.defineProperty(src, "hot", { + enumerable: true, + get: function () { + return churn(); + }, + }); + const out: any = { ...src, tail: 7 }; + if (out.plain !== 5) badPlain++; + if (out.hot !== 600) badHot++; + if (out.tail !== 7) badTail++; + } + return "plain " + badPlain + " hot " + badHot + " tail " + badTail; +} + +console.log("bad", run()); diff --git a/test-files/test_gap_gc_static_block_this_rooting.ts b/test-files/test_gap_gc_static_block_this_rooting.ts new file mode 100644 index 0000000000..a749ec8a5c --- /dev/null +++ b/test-files/test_gap_gc_static_block_this_rooting.ts @@ -0,0 +1,50 @@ +// #7201: a class EXPRESSION carrying a `static { … }` block whose body +// allocates must keep working under an evacuating minor. +// +// #7192/#7198 rooted the class object itself: `Expr::ClassExprFresh` now +// temp-roots it and re-reads that root before every use, including before +// `js_static_this_arm_value` and after the static-block body returns. The crash +// survived that, because the stale value is the one the RUNTIME parks: the +// static-`this` one-shot cell that `js_static_this_arm_value` writes and the +// compiled block body reads back through `js_static_this_resolve`. That cell +// was a plain thread-local word — not marked, and above all not REWRITTEN on +// evacuation — so if the class object relocates between the arm and the +// resolve, or between the resolve and the body's `this.x = …` stores, the cell +// hands out a from-space address. +// +// LIVE BY CONSTRUCTION AND ONLY ON THE MOVING ARMS. The block body allocates +// past the nursery, and both statics are read back immediately after the +// factory returns. + +function churn(): number { + const a: any[] = []; + for (let i = 0; i < 600; i++) { + a.push({ i: i, s: "q" }); + } + return a.length; +} + +function make(): any { + return class { + static k: number = 1; + static { + (this as any).viaBlock = churn(); + } + }; +} + +function run(): number { + let bad = 0; + for (let r = 0; r < 400; r++) { + const C: any = make(); + if (C.k !== 1) { + bad++; + } + if (C.viaBlock !== 600) { + bad++; + } + } + return bad; +} + +console.log("bad", run()); From fd7a4626792507819849ebc0ca8f01c4b0c4f8de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 1 Aug 2026 19:54:30 +0200 Subject: [PATCH 2/8] fix(gc): close the three residual root-store holes (#7200, #7201, #7202) --- crates/perry-codegen/src/expr/index_set.rs | 9 +- .../src/expr/logical_collections.rs | 26 ++- crates/perry-codegen/src/expr/mod.rs | 2 +- crates/perry-codegen/src/expr/property_set.rs | 5 +- .../perry-codegen/src/expr/proxy_reflect.rs | 95 ++++++--- .../src/expr/scalar_slot_root.rs | 31 ++- .../src/expr/static_field_meta.rs | 2 +- crates/perry-codegen/src/expr/temp_root.rs | 75 ++++++-- crates/perry-codegen/src/lower_call/new.rs | 30 +++ crates/perry-runtime/src/object/alloc.rs | 182 +++++++++++++++--- 10 files changed, 384 insertions(+), 73 deletions(-) diff --git a/crates/perry-codegen/src/expr/index_set.rs b/crates/perry-codegen/src/expr/index_set.rs index 665e884d5f..9c49ef0535 100644 --- a/crates/perry-codegen/src/expr/index_set.rs +++ b/crates/perry-codegen/src/expr/index_set.rs @@ -1437,7 +1437,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "index_set.literal_string_value_bits", "literal_string_index_set_helper_edge", )?; - let obj_box = super::temp_root::reread_store_operand(ctx, &recv_guard, &obj_box); + let obj_box = + super::temp_root::reread_store_operand(ctx, &recv_guard, object, &obj_box)?; let key_idx = ctx.strings.intern(literal); let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); let obj_bits = ctx.block().bitcast_double_to_i64(&obj_box); @@ -1501,8 +1502,10 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "index_set.string_value_bits", "string_index_set_helper_edge", )?; - let key_box = super::temp_root::reread_store_operand(ctx, &key_guard, &key_box); - let obj_box = super::temp_root::reread_store_operand(ctx, &recv_guard, &obj_box); + let key_box = + super::temp_root::reread_store_operand(ctx, &key_guard, index, &key_box)?; + let obj_box = + super::temp_root::reread_store_operand(ctx, &recv_guard, object, &obj_box)?; let obj_bits = ctx.block().bitcast_double_to_i64(&obj_box); super::property_set::emit_nullish_write_guard( ctx, diff --git a/crates/perry-codegen/src/expr/logical_collections.rs b/crates/perry-codegen/src/expr/logical_collections.rs index 805843790e..0f2ec62ccf 100644 --- a/crates/perry-codegen/src/expr/logical_collections.rs +++ b/crates/perry-codegen/src/expr/logical_collections.rs @@ -983,7 +983,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // Refs #590. Expr::ObjectAssign { target, sources } => { let target_box = lower_expr(ctx, target)?; - let mut acc = ctx.block().call( + let acc = ctx.block().call( DOUBLE, "js_object_assign_validate_target", &[(DOUBLE, &target_box)], @@ -998,14 +998,34 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { if sources.is_empty() { return Ok(acc); } + // #7200: `acc` is a live object handle across every remaining + // source's lowering AND across every `js_object_assign_one` call — + // that helper reads every own key of the source, so an accessor + // there runs arbitrary user code inside the helper. `Expr::Object` + // has rooted its accumulator since #6951; this arm never copied it. + // + // Unconditional whenever there is a source: the user-code re-entry + // is inside the callee, so no property of the *source expression* + // can rule it out. (#7198 declined the "a helper's own allocation + // initiates a moving collection" argument on evidence; this is the + // route it accepted instead.) + let acc_slot = super::temp_root::temp_root_push_double(ctx, &acc); for src in sources { let src_box = lower_expr(ctx, src)?; - acc = ctx.block().call( + let acc_now = super::temp_root::temp_root_get_double(ctx, &acc_slot); + let next = ctx.block().call( DOUBLE, "js_object_assign_one", - &[(DOUBLE, &acc), (DOUBLE, &src_box)], + &[(DOUBLE, &acc_now), (DOUBLE, &src_box)], ); + // The helper now returns the post-collection target address, so + // publish that back into the root rather than keeping the + // pre-call one: `Object.assign(t, a, b)` threads it into `b`'s + // link, and the caller receives it. + super::temp_root::temp_root_set_double(ctx, &acc_slot, &next); } + let acc = super::temp_root::temp_root_get_double(ctx, &acc_slot); + super::temp_root::temp_root_truncate(ctx, &acc_slot); Ok(acc) } diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index b430f5e345..06c5f1c25d 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -156,7 +156,7 @@ pub(crate) use slot_rep::{ pub(crate) use dispatch::{lower_expr, lower_math_operand}; pub(crate) use scalar_slot_root::{ - root_scalar_replaced_slot, root_scalar_replaced_slot_unconditional, + root_entry_alloca, root_scalar_replaced_slot, root_scalar_replaced_slot_unconditional, }; pub(crate) use shadow_slot::{ current_closure_ptr_value, emit_persistent_shadow_root_barrier, diff --git a/crates/perry-codegen/src/expr/property_set.rs b/crates/perry-codegen/src/expr/property_set.rs index a1e4c955b8..b8e6666ed3 100644 --- a/crates/perry-codegen/src/expr/property_set.rs +++ b/crates/perry-codegen/src/expr/property_set.rs @@ -53,7 +53,7 @@ fn lower_runtime_property_set_by_name( // #7154: root the receiver across the value's evaluation, which allocates. let recv_guard = super::temp_root::guard_store_operand(ctx, object, &recv_box, value); let val_double = lower_expr(ctx, value)?; - let recv_box = super::temp_root::reread_store_operand(ctx, &recv_guard, &recv_box); + let recv_box = super::temp_root::reread_store_operand(ctx, &recv_guard, object, &recv_box)?; let key_idx = ctx.strings.intern(property); let dispatch_global = ctx.strings.static_dispatch_global(key_idx); let blk = ctx.block(); @@ -1010,7 +1010,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "property_set.dynamic_value_bits", "dynamic_property_set_helper_edge", )?; - let obj_box = super::temp_root::reread_store_operand(ctx, &recv_guard, &obj_box); + let obj_box = + super::temp_root::reread_store_operand(ctx, &recv_guard, object, &obj_box)?; // Intern the field name in the StringPool (same one the // matching getter uses, so they share the global string). let key_idx = ctx.strings.intern(property); diff --git a/crates/perry-codegen/src/expr/proxy_reflect.rs b/crates/perry-codegen/src/expr/proxy_reflect.rs index 0af64c14cd..82daa08611 100644 --- a/crates/perry-codegen/src/expr/proxy_reflect.rs +++ b/crates/perry-codegen/src/expr/proxy_reflect.rs @@ -1262,34 +1262,71 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let strict_i32 = if *strict { "1" } else { "0" }; // #6812 (w12) inline path: evaluation order k → v → t. The // target is a pure local read, so hoisting key/value evaluation - // above its REGISTER materialization is unobservable — and it - // makes the path GC-clean with NO compile-time value gate: a GC - // during key/value evaluation happens before the target pointer - // exists; a moved key merely misses by stale bits (identity - // compare — false negatives only); the store re-checks the - // VALUE's tag at runtime and routes reference-creating values - // (pointer/string/bigint) to the outlined path. + // above its REGISTER materialization is unobservable, and that + // ordering is what keeps the TARGET safe — the pointer is + // materialized below everything that can collect. + // + // #7201: the KEY is not covered by that argument, and the comment + // that used to sit here claimed it was — "a moved key merely misses + // by stale bits (identity compare — false negatives only)". That is + // true of the three way-compares and false of everything below + // them: the miss falls through to `put.dynic.slow`, which hands the + // SAME register to `js_put_value_set_dyn_ic`, which DEREFERENCES it + // as a `StringHeader*`. `this.viaBlock = churn()` inside a + // `static { … }` block is the shipped shape (#7201): the key + // literal's `__perry_init_strings_*` handle is a registered root + // that evacuation REWRITES, so the register loaded above `churn()` + // names from-space and the slow path reads a relocated string + // header — SIGSEGV, or a property under a garbage name. + // + // An identity-compare-only use is not the only use. Re-derive the + // key below the value, exactly as every other store operand does. let dyn_inline = same_put_value_receiver_expr(target, receiver) && matches!(target.as_ref(), Expr::LocalGet(_) | Expr::This); if dyn_inline { let k = lower_expr(ctx, key)?; + let key_guard = super::temp_root::guard_store_operand(ctx, key, &k, value); let v = lower_expr(ctx, value)?; + let k = super::temp_root::reread_store_operand(ctx, &key_guard, key, &k)?; let t = lower_expr(ctx, target)?; - return lower_put_value_dyn_ic_inline(ctx, &t, &k, &v, strict_i32); + let result = lower_put_value_dyn_ic_inline(ctx, &t, &k, &v, strict_i32)?; + // After the store: the outlined helper allocates while reading + // the key (interning, keys-array growth, shape transition). + super::temp_root::release_store_operand(ctx, key_guard); + return Ok(result); } + // #7201, outlined arms: `t` is lowered FIRST here, so it is live + // across both `k`'s and `v`'s lowering, and `k` across `v`'s. Both + // are consumed by helpers that dereference them. let t = lower_expr(ctx, target)?; + // 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 = super::temp_root::expr_may_trigger_gc(ctx, key) + || super::temp_root::expr_may_trigger_gc(ctx, value) + || super::temp_root::expr_may_trigger_gc(ctx, receiver); + let recv_guard = + super::temp_root::guard_store_operand_across(ctx, target, &t, recv_collects); let k = lower_expr(ctx, key)?; + // Pushed AFTER the receiver's so the two `temp_root_truncate` cuts + // nest: a release of the outer one drops the inner. + let key_collects = super::temp_root::expr_may_trigger_gc(ctx, value) + || super::temp_root::expr_may_trigger_gc(ctx, receiver); + let key_guard = + super::temp_root::guard_store_operand_across(ctx, key, &k, 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 result = if same_put_value_receiver_expr(target, receiver) { + let k = super::temp_root::reread_store_operand(ctx, &key_guard, key, &k)?; + let t = super::temp_root::reread_store_operand(ctx, &recv_guard, target, &t)?; 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); - return Ok(ctx.block().call( + ctx.block().call( DOUBLE, "js_put_value_set_dyn_ic", &[ @@ -1299,20 +1336,30 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { (DOUBLE, &v), (I32, strict_i32), ], - )); - } - let r = lower_expr(ctx, receiver)?; - Ok(ctx.block().call( - DOUBLE, - "js_put_value_set", - &[ - (DOUBLE, &t), - (DOUBLE, &k), - (DOUBLE, &v), - (DOUBLE, &r), - (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 = super::temp_root::reread_store_operand(ctx, &key_guard, key, &k)?; + let t = super::temp_root::reread_store_operand(ctx, &recv_guard, target, &t)?; + ctx.block().call( + DOUBLE, + "js_put_value_set", + &[ + (DOUBLE, &t), + (DOUBLE, &k), + (DOUBLE, &v), + (DOUBLE, &r), + (I32, strict_i32), + ], + ) + }; + // Released after the store: every one of these helpers allocates + // while reading the operands. + super::temp_root::release_store_operand(ctx, key_guard); + super::temp_root::release_store_operand(ctx, recv_guard); + Ok(result) } Expr::ReflectHas { target, key } => { downgrade_unknown_call_expr(ctx, target); diff --git a/crates/perry-codegen/src/expr/scalar_slot_root.rs b/crates/perry-codegen/src/expr/scalar_slot_root.rs index 23f96ae80d..91f25397df 100644 --- a/crates/perry-codegen/src/expr/scalar_slot_root.rs +++ b/crates/perry-codegen/src/expr/scalar_slot_root.rs @@ -86,7 +86,7 @@ pub(crate) fn root_scalar_replaced_slot(ctx: &mut FnCtx<'_>, slot: &str, value: if expr_is_known_non_pointer_shadow_value(ctx, value) { return; } - bind_scalar_replaced_slot(ctx, slot); + root_entry_alloca(ctx, slot); } /// Root a scalar-replacement alloca whose stored value has no HIR expression @@ -96,10 +96,35 @@ pub(crate) fn root_scalar_replaced_slot(ctx: &mut FnCtx<'_>, slot: &str, value: /// slots receive `js_string_split_part_value` results — heap strings with /// nothing else referring to them. pub(crate) fn root_scalar_replaced_slot_unconditional(ctx: &mut FnCtx<'_>, slot: &str) { - bind_scalar_replaced_slot(ctx, slot); + root_entry_alloca(ctx, slot); } -fn bind_scalar_replaced_slot(ctx: &mut FnCtx<'_>, slot: &str) { +/// Make an arbitrary entry-block alloca a **rewritten** GC root (#7202). +/// +/// Scalar replacement is not the only producer of storage that holds a heap +/// value and has no HIR local: the inline-constructor `this` slot +/// (`lower_call/new.rs`), a closure's captured `this` / `new.target` slots +/// (`codegen/closure.rs`) and a `catch (e)` parameter slot (`stmt/try_stmt.rs`) +/// are all bare `alloca_entry`s that live across arbitrary user code. A bare +/// alloca is neither a shadow slot nor a temp root, so an evacuating minor +/// neither marks nor rewrites it and every load below the collection point +/// names from-space. +/// +/// # Contract for callers +/// +/// 1. **Seed the alloca to `undefined` in `entry_allocas` first.** The bind is +/// hoisted to entry setup, which makes the slot `active` from function entry +/// — the collector dereferences it before any store reaches it, and +/// uninitialized stack garbage can pass `is_plausible_heap_addr`. +/// 2. **Call this *after* the store**, not before: the emitted root barrier +/// reads the alloca back. +/// +/// Binding (rather than temp-rooting) is what makes this a one-line fix at +/// ~30 read sites: every reader already does `load DOUBLE, ptr `, and +/// `js_shadow_slot_bind` records `slot_ptrs[idx] = alloca`, so evacuation +/// rewrites the alloca in place and those loads become correct without being +/// touched. +pub(crate) fn root_entry_alloca(ctx: &mut FnCtx<'_>, slot: &str) { if !ctx.scalar_slot_shadow_slots.contains_key(slot) { // `None` means shadow-stack emission is off for this build; the // caller must not emit slot traffic either. diff --git a/crates/perry-codegen/src/expr/static_field_meta.rs b/crates/perry-codegen/src/expr/static_field_meta.rs index 104c15b0b1..9616a4225f 100644 --- a/crates/perry-codegen/src/expr/static_field_meta.rs +++ b/crates/perry-codegen/src/expr/static_field_meta.rs @@ -544,7 +544,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // exposure the receiver has, one operand over. Root it. let key_guard = super::temp_root::guard_store_operand(ctx, key, &k, init); let v = lower_expr(ctx, init)?; - let k = super::temp_root::reread_store_operand(ctx, &key_guard, &k); + let k = super::temp_root::reread_store_operand(ctx, &key_guard, key, &k)?; // #7154: both lowerings above can collect; re-derive the // receiver from the root rather than reusing the register. let obj = super::temp_root::rooted_handle_get(ctx, &rooted); diff --git a/crates/perry-codegen/src/expr/temp_root.rs b/crates/perry-codegen/src/expr/temp_root.rs index 6bf002c092..81bec6d35a 100644 --- a/crates/perry-codegen/src/expr/temp_root.rs +++ b/crates/perry-codegen/src/expr/temp_root.rs @@ -86,6 +86,16 @@ pub(crate) fn temp_root_set_i64(ctx: &mut FnCtx<'_>, idx: &str, value_i64: &str) .call_void("js_gc_temp_root_set", &[(I32, idx), (I64, value_i64)]); } +/// Overwrite slot `idx` with a new NaN-boxed `double`. +/// +/// The `Object.assign` accumulator (#7200) is the same shape as the `concat` +/// one: `js_object_assign_one` returns the target's *post-collection* address, +/// so each link must republish rather than keep the address it passed in. +pub(crate) fn temp_root_set_double(ctx: &mut FnCtx<'_>, idx: &str, value: &str) { + let bits = ctx.block().bitcast_double_to_i64(value); + temp_root_set_i64(ctx, idx, &bits); +} + /// Drop slot `idx` and everything pushed above it. pub(crate) fn temp_root_truncate(ctx: &mut FnCtx<'_>, idx: &str) { ctx.block() @@ -585,6 +595,10 @@ pub(crate) fn temp_root_release(ctx: &mut FnCtx<'_>, guard: Option) { /// release of the outer one drops the inner. pub(crate) struct StoreOperandGuard { slot: Option, + /// The operand took [`OperandProtection::Reload`]: no runtime slot, but the + /// re-read below the collection point must re-emit the lowering rather than + /// reuse the register. See [`reread_store_operand`]. + reload: bool, } /// Root `lowered` (the already-lowered `operand`) if evaluating `value` can @@ -597,31 +611,68 @@ pub(crate) fn guard_store_operand( value: &Expr, ) -> StoreOperandGuard { let collects = expr_may_trigger_gc(ctx, value); - let slot = match operand_protection(ctx, operand, collects) { + guard_store_operand_across(ctx, operand, lowered, collects) +} + +/// [`guard_store_operand`] with the window stated explicitly. +/// +/// The hazard is not visible from a single sibling expression: a receiver +/// lowered before both the key and the value is live across *both*, so its +/// `collects` is the disjunction. Deriving it from the value alone — which is +/// what every caller did before #7201 — leaves `o[f()] = 1` unguarded, because +/// the literal `1` cannot collect while `f()` obviously can. This mirrors +/// [`RootedOperands::push`], whose doc already states that "for `m.set(k, v)` +/// the receiver's window covers both `key`'s lowering and `value`'s". +pub(crate) fn guard_store_operand_across( + ctx: &mut FnCtx<'_>, + operand: &Expr, + lowered: &str, + collects: bool, +) -> StoreOperandGuard { + let protection = operand_protection(ctx, operand, collects); + let slot = match protection { OperandProtection::Root => Some(temp_root_push_double(ctx, lowered)), - // `Reload` means the operand is a string literal: a registered, - // immutable global root, so re-deriving it below the collection point - // is exact — and the call sites here re-lower nothing, they simply keep - // the register, which for a literal is a load from that same global. - // `Reuse` means a proven non-pointer, which relocation cannot touch. + // `Reload` emits no runtime call, but it is NOT "keep the register": + // [`reread_store_operand`] re-lowers the operand below the collection + // point. `Reuse` means a proven non-pointer, which relocation cannot + // touch, so its register is genuinely reusable. OperandProtection::Reload | OperandProtection::Reuse => None, }; - StoreOperandGuard { slot } + StoreOperandGuard { + slot, + reload: protection == OperandProtection::Reload, + } } /// Re-read the operand below the value's evaluation. Returns `lowered` -/// unchanged when nothing was rooted. +/// unchanged only when the operand is a proven non-pointer. +/// +/// # Why `Reload` must re-lower, not reuse (#7201) +/// +/// Until this was fixed, the `Reload` arm returned the caller's register +/// unchanged, on the reasoning that "for a literal [the register] is a load +/// from that same global". It is a load from that global *taken before the +/// collection point*. A string literal's `__perry_init_strings_*` handle is a +/// registered root that evacuation **rewrites** — that is the whole content of +/// #7114 — so the pre-collection register names from-space and the global does +/// not. Emitting the load again is the entire fix and costs no runtime call. +/// +/// This now matches [`RootedOperands::reread`], which has always re-lowered its +/// `Reload` operands. The two helper families answering the same question +/// differently is exactly the drift that produced #7114. pub(crate) fn reread_store_operand( ctx: &mut FnCtx<'_>, guard: &StoreOperandGuard, - recv: &str, -) -> String { + operand: &Expr, + lowered: &str, +) -> anyhow::Result { match &guard.slot { Some(idx) => { let idx = idx.clone(); - temp_root_get_double(ctx, &idx) + Ok(temp_root_get_double(ctx, &idx)) } - None => recv.to_string(), + None if guard.reload => super::lower_expr(ctx, operand), + None => Ok(lowered.to_string()), } } diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index 4cea219ea2..ee4d8dca8f 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -1107,7 +1107,37 @@ fn lower_new_impl_inner( // closures that capture `this`), so hoist to the entry block for // dominance safety. let this_slot = ctx.func.alloca_entry(DOUBLE); + // #7202: this alloca holds the INSTANCE for the whole inlined constructor + // body, and every `this` read below is a `load` from it. It is a plain + // `alloca_entry` — not a shadow slot, not a temp root — so an evacuating + // minor at a field initializer's back-edge poll neither marks nor rewrites + // it, and every `this.x = …` after that collection stores into abandoned + // from-space memory. + // + // #7192 rooted the instance for the *caller* (`instance_root` above, + // re-read by `reload_instance` at the tail) precisely because this window + // collects — so the object survives and MOVES. That made the caller's copy + // correct and left this one behind: the same address, taken one line later, + // that nothing rewrites. The #7154 comment on `ctor_result_slot` states the + // invariant and applies it only to that sibling. + // + // Reachability is the default, not an opt-in: `force_ctor_call` requires + // `class.constructor.is_some()`, so `class C { payload = mk() }` and + // `class C extends B {}` take this path with `PERRY_INLINE_CTOR` unset — + // and `construction_runs_user_code` (which gates `instance_root`) is true + // for exactly those, i.e. the code already asserts this window collects. + // + // Binding it — rather than routing `Expr::This` through a temp root — + // leaves all ~30 `ctx.this_stack.last()` readers untouched: they load from + // the alloca, and `js_shadow_slot_bind` makes evacuation rewrite the alloca + // in place. The `undefined` seed is required by `root_entry_alloca`'s + // contract: the bind is hoisted to entry setup, so the slot is live to the + // collector before this store executes. + let undef = crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + ctx.func + .entry_allocas_push_store(DOUBLE, &undef, &this_slot); ctx.block().store(DOUBLE, &obj_box, &this_slot); + crate::expr::root_entry_alloca(ctx, &this_slot); ctx.this_stack.push(this_slot); ctx.class_stack.push(class_name.to_string()); diff --git a/crates/perry-runtime/src/object/alloc.rs b/crates/perry-runtime/src/object/alloc.rs index e203eca884..ecaf57339a 100644 --- a/crates/perry-runtime/src/object/alloc.rs +++ b/crates/perry-runtime/src/object/alloc.rs @@ -1141,14 +1141,28 @@ unsafe fn object_assign_string_source( let Ok(s) = std::str::from_utf8(bytes) else { return; }; + // #7200: three allocations per iteration (`key_ptr`, `value_ptr`, and the + // write funnel's interning/growth) with `target` and `key_ptr` live across + // them. The second `js_string_from_bytes` alone can move the first. + let scope = crate::gc::RuntimeHandleScope::new(); + let tgt_h = scope.root_raw_mut_ptr(target); for (idx, ch) in s.chars().enumerate() { + let iter_scope = crate::gc::RuntimeHandleScope::new(); let key = idx.to_string(); let key_ptr = crate::string::js_string_from_bytes(key.as_ptr(), key.len() as u32); + let key_h = iter_scope.root_string_ptr(key_ptr); let mut buf = [0u8; 4]; let ch_str = ch.encode_utf8(&mut buf); let value_ptr = crate::string::js_string_from_bytes(ch_str.as_ptr(), ch_str.len() as u32); - let value_f64 = f64::from_bits(JSValue::string_ptr(value_ptr).bits()); - object_assign_set_string_key(target, target_is_array, key_ptr, value_f64); + let value_h = iter_scope.root_string_ptr(value_ptr); + object_assign_set_string_key( + tgt_h.get_raw_mut_ptr::(), + target_is_array, + key_h.get_raw_const_ptr::(), + f64::from_bits( + JSValue::string_ptr(value_h.get_raw_mut_ptr::()).bits(), + ), + ); } } @@ -1159,7 +1173,6 @@ unsafe fn object_assign_string_source( /// `Object.assign` requires. unsafe fn object_assign_proxy_source( target: *mut ObjectHeader, - target_f64: f64, target_is_array: bool, source_f64: f64, ) { @@ -1174,34 +1187,64 @@ unsafe fn object_assign_proxy_source( return; } let n = crate::array::js_array_length(arr); + // #7200: the widest window in the file. TWO trap invocations per key — + // `getOwnPropertyDescriptor` and `get` — each arbitrary user code, with the + // `ownKeys` result array and the target held across both and used after. + let scope = crate::gc::RuntimeHandleScope::new(); + let tgt_h = scope.root_raw_mut_ptr(target); + let keys_h = scope.root_raw_const_ptr(arr); + let source_h = scope.root_nanbox_f64(source_f64); for i in 0..n { + let arr = keys_h.get_raw_const_ptr::(); + let source_f64 = source_h.get_nanbox_f64(); let key = crate::array::js_array_get(arr, i); + let iter_scope = crate::gc::RuntimeHandleScope::new(); + let key_h = iter_scope.root_nanbox_u64(key.bits()); let key_f64 = f64::from_bits(key.bits()); // `[[GetOwnProperty]]` — fires the getOwnPropertyDescriptor trap. let desc = crate::proxy::js_reflect_get_own_property_descriptor(source_f64, key_f64); - let desc_ptr = (desc.to_bits() & crate::value::POINTER_MASK) as *const ObjectHeader; + let desc_h = iter_scope.root_nanbox_f64(desc); + let desc_ptr = + (desc_h.get_nanbox_f64().to_bits() & crate::value::POINTER_MASK) as *const ObjectHeader; if desc.to_bits() == JSValue::undefined().bits() || desc_ptr.is_null() { continue; } let ek = crate::string::js_string_from_bytes(b"enumerable".as_ptr(), 10); - if crate::value::js_is_truthy(crate::object::js_object_get_field_by_name_f64(desc_ptr, ek)) - == 0 + if crate::value::js_is_truthy(crate::object::js_object_get_field_by_name_f64( + (desc_h.get_nanbox_f64().to_bits() & crate::value::POINTER_MASK) as *const ObjectHeader, + ek, + )) == 0 { continue; } // `[[Get]]` — fires the get trap. - let value_f64 = crate::proxy::js_proxy_get(source_f64, key_f64); + let key_f64 = f64::from_bits(key_h.get_nanbox_u64()); + let value_f64 = crate::proxy::js_proxy_get(source_h.get_nanbox_f64(), key_f64); + let value_h = iter_scope.root_nanbox_f64(value_f64); + let key_f64 = f64::from_bits(key_h.get_nanbox_u64()); if key.is_any_string() { let key_ptr = crate::value::js_get_string_pointer_unified(key_f64) as *const crate::StringHeader; if !key_ptr.is_null() { - object_assign_set_string_key(target, target_is_array, key_ptr, value_f64); + object_assign_set_string_key( + tgt_h.get_raw_mut_ptr::(), + target_is_array, + key_ptr, + value_h.get_nanbox_f64(), + ); } } else if key.is_pointer() { // Strict `Set` semantics for symbol keys, same as the ordinary path. - let sym_ptr = (key.bits() & crate::value::POINTER_MASK) as usize; - object_assign_throw_if_symbol_set_rejected(target, sym_ptr); - crate::symbol::js_object_set_symbol_property(target_f64, key_f64, value_f64); + let sym_ptr = (key_f64.to_bits() & crate::value::POINTER_MASK) as usize; + object_assign_throw_if_symbol_set_rejected( + tgt_h.get_raw_mut_ptr::(), + sym_ptr, + ); + crate::symbol::js_object_set_symbol_property( + crate::value::js_nanbox_pointer(tgt_h.get_raw_mut_ptr::() as i64), + key_f64, + value_h.get_nanbox_f64(), + ); } } } @@ -1252,8 +1295,12 @@ pub unsafe extern "C" fn js_object_assign_one(target_f64: f64, source_f64: f64) return target_f64; } if source.is_any_string() { + // #7200: the callee allocates per character, so the target it returns + // through must be the post-collection one. + let scope = crate::gc::RuntimeHandleScope::new(); + let tgt_h = scope.root_raw_mut_ptr(target); object_assign_string_source(target, target_is_array, source_f64); - return target_f64; + return crate::value::js_nanbox_pointer(tgt_h.get_raw_mut_ptr::() as i64); } // A Proxy source isn't an `ObjectHeader` (its NaN-box payload is a small @@ -1264,8 +1311,12 @@ pub unsafe extern "C" fn js_object_assign_one(target_f64: f64, source_f64: f64) // each value — with every trap's abrupt completion propagating out (test262 // Object/assign/source-own-prop-error + source-own-prop-keys-error). if crate::proxy::js_proxy_is_proxy(source_f64) != 0 { - object_assign_proxy_source(target, target_f64, target_is_array, source_f64); - return target_f64; + // #7200: every proxy trap is user code; the target can be anywhere by + // the time the last one returns. + let scope = crate::gc::RuntimeHandleScope::new(); + let tgt_h = scope.root_raw_mut_ptr(target); + object_assign_proxy_source(target, target_is_array, source_f64); + return crate::value::js_nanbox_pointer(tgt_h.get_raw_mut_ptr::() as i64); } // Decode source pointer. Skip null/undefined/non-pointer sources. @@ -1366,10 +1417,37 @@ pub unsafe extern "C" fn js_object_assign_one(target_f64: f64, source_f64: f64) }; let source_is_array = source_obj_type == crate::gc::GC_TYPE_ARRAY; + // #7200: EVERYTHING BELOW RUNS WITH USER CODE IN THE WINDOW. + // + // Both copy loops reach a `[[Get]]` that short-circuits into + // `invoke_accessor_getter` when the source carries an accessor descriptor — + // i.e. they run ARBITRARY USER CODE inside this runtime helper. User code + // reaches a loop back-edge poll, and under `PERRY_GC_MOVING_LOOP_POLLS=1` + // that is an evacuating minor running with this Rust frame live. + // + // `target`, `src`, `src_keys`, `arr` and each `key_ptr` are raw addresses + // in Rust locals. The collector rewrites ROOTS; a local is not one. Every + // one of them is used *after* the getter returns — `target` and `key_ptr` + // by the write funnel on the very next line, `src_keys`/`src`/`arr` by the + // next iteration — so each is a from-space address for the rest of the + // copy. That is the SIGSEGV in `{ ...src, tail: 7 }` with an accessor + // source, and the silently-dropped value in its lighter variant. + // + // The function already models the fix one branch up: the native-module arm + // opens a scope, roots `target`, and returns the handle-reloaded pointer. + // This is that treatment applied to the arms the syntax actually takes, and + // it spans BOTH numbered sections because the symbol tail's `[[Get]]` is a + // symbol-keyed getter with exactly the same reach. + let scope = crate::gc::RuntimeHandleScope::new(); + let tgt_h = scope.root_raw_mut_ptr(target); + let src_h = scope.root_raw_const_ptr(src); + let source_h = scope.root_nanbox_f64(source_f64); + // 1) Copy own string-keyed enumerable properties from source to target, // in source insertion order. Mirrors `js_object_copy_own_fields`. if source_is_array { - let arr = src_raw as *const crate::array::ArrayHeader; + let arr_h = scope.root_raw_const_ptr(src_raw as *const crate::array::ArrayHeader); + let arr = arr_h.get_raw_const_ptr::(); let n = crate::array::js_array_length(arr); // Snapshot string expandos (`arr.foo = …`, kept in the named-property // side table) BEFORE the index loop: that loop allocates, which can @@ -1383,6 +1461,10 @@ pub unsafe extern "C" fn js_object_assign_one(target_f64: f64, source_f64: f64) }) .collect(); for i in 0..n { + // Re-derive from the handle: `js_string_from_bytes` and the write + // funnel both allocate, so the previous iteration may have moved the + // source array and the target. + let arr = arr_h.get_raw_const_ptr::(); // Holes (absent indices) in a sparse array are NOT own enumerable // properties and must be skipped — Object.assign only copies own // enumerable properties (test262 assign/target-Array.js). @@ -1390,21 +1472,33 @@ pub unsafe extern "C" fn js_object_assign_one(target_f64: f64, source_f64: f64) continue; } let value = crate::array::js_array_get(arr, i); + let iter_scope = crate::gc::RuntimeHandleScope::new(); + let val_h = iter_scope.root_nanbox_u64(value.bits()); let key = i.to_string(); let key_ptr = crate::string::js_string_from_bytes(key.as_ptr(), key.len() as u32); + let key_h = iter_scope.root_string_ptr(key_ptr); object_assign_set_string_key( - target, + tgt_h.get_raw_mut_ptr::(), target_is_array, - key_ptr, - f64::from_bits(value.bits()), + key_h.get_raw_const_ptr::(), + f64::from_bits(val_h.get_nanbox_u64()), ); } for (name, value) in expandos { + let iter_scope = crate::gc::RuntimeHandleScope::new(); + let val_h = iter_scope.root_nanbox_f64(value); let key_ptr = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); - object_assign_set_string_key(target, target_is_array, key_ptr, value); + let key_h = iter_scope.root_string_ptr(key_ptr); + object_assign_set_string_key( + tgt_h.get_raw_mut_ptr::(), + target_is_array, + key_h.get_raw_const_ptr::(), + val_h.get_nanbox_f64(), + ); } } else if source_obj_type == crate::gc::GC_TYPE_OBJECT { let src_keys = (*src).keys_array; + let keys_h = scope.root_raw_mut_ptr(src_keys); if !src_keys.is_null() && (src_keys as usize) >= 0x10000 { // Cap the key count at the keys array's capacity: a malformed keys // array can report a bogus, pointer-sized length, and an unclamped @@ -1415,6 +1509,12 @@ pub unsafe extern "C" fn js_object_assign_one(target_f64: f64, source_f64: f64) // Use the public [[Get]] path, not raw field slots, so accessors run // and abrupt completions propagate the way Object.assign requires. for i in 0..key_count { + // Re-derive every raw address from its handle at the top of the + // iteration: the PREVIOUS iteration's getter may have moved all + // of them. + let src_keys = keys_h.get_raw_mut_ptr::(); + let src = src_h.get_raw_const_ptr::(); + let src_raw = src as usize; let key_val = crate::array::js_array_get(src_keys, i as u32); if !key_val.is_any_string() { continue; @@ -1441,8 +1541,22 @@ pub unsafe extern "C" fn js_object_assign_one(target_f64: f64, source_f64: f64) } } } + // Per-iteration scope so the key/value roots are cut each time + // round rather than growing the handle stack by 2 per key. + let iter_scope = crate::gc::RuntimeHandleScope::new(); + let key_h = iter_scope.root_string_ptr(key_ptr); let field_f64 = f64::from_bits(js_object_get_field_by_name(src, key_ptr).bits()); - object_assign_set_string_key(target, target_is_array, key_ptr, field_f64); + // The getter's RETURN VALUE is a fresh heap reference reachable + // from nothing else, and the write funnel below allocates (key + // interning, keys-array growth, shape transition). Root it and + // read it back, exactly like the pointers. + let val_h = iter_scope.root_nanbox_f64(field_f64); + object_assign_set_string_key( + tgt_h.get_raw_mut_ptr::(), + target_is_array, + key_h.get_raw_const_ptr::(), + val_h.get_nanbox_f64(), + ); } } } @@ -1456,7 +1570,7 @@ pub unsafe extern "C" fn js_object_assign_one(target_f64: f64, source_f64: f64) // the symbol pointers first: the inner `[[Get]]` / set re-acquire // SYMBOL_PROPERTIES, so iterating a held snapshot avoids re-entrancy. let sym_keys: Vec = { - let arr_raw = crate::symbol::js_object_get_own_property_symbols(source_f64); + let arr_raw = crate::symbol::js_object_get_own_property_symbols(source_h.get_nanbox_f64()); let mut v = Vec::new(); if arr_raw != 0 { let arr = arr_raw as *const crate::array::ArrayHeader; @@ -1474,19 +1588,39 @@ pub unsafe extern "C" fn js_object_assign_one(target_f64: f64, source_f64: f64) v }; for sym_ptr in sym_keys { + // #7200: `js_object_get_symbol_property` below is a symbol-keyed + // `[[Get]]` — an accessor there runs user code with the same reach as + // the string-key loop's. `src_raw` keys the attribute side tables and + // `target`/`target_f64` are the write destination, so all three are + // re-derived from their handles each time round. + let src_raw = src_h.get_raw_const_ptr::() as usize; if !crate::symbol::symbol_property_is_enumerable(src_raw, sym_ptr) { continue; } let sym_f64 = f64::from_bits(JSValue::pointer(sym_ptr as *const u8).bits()); + let iter_scope = crate::gc::RuntimeHandleScope::new(); + let sym_h = iter_scope.root_nanbox_f64(sym_f64); // Read the source value through `[[Get]]`, not the raw side-table bits, // so a symbol-keyed accessor's getter runs during `Object.assign` // (test262 assign/strings-and-symbol-order). The earlier string-key // copy already uses `[[Get]]` via `js_object_get_field_by_name`. - let value_f64 = crate::symbol::js_object_get_symbol_property(source_f64, sym_f64); + let value_f64 = + crate::symbol::js_object_get_symbol_property(source_h.get_nanbox_f64(), sym_f64); + let value_h = iter_scope.root_nanbox_f64(value_f64); // Strict `Set` semantics for symbol-keyed writes too. + let target = tgt_h.get_raw_mut_ptr::(); object_assign_throw_if_symbol_set_rejected(target, sym_ptr); - crate::symbol::js_object_set_symbol_property(target_f64, sym_f64, value_f64); + crate::symbol::js_object_set_symbol_property( + crate::value::js_nanbox_pointer(tgt_h.get_raw_mut_ptr::() as i64), + sym_h.get_nanbox_f64(), + value_h.get_nanbox_f64(), + ); } - target_f64 + // The target may have moved under any of the getters above; hand the caller + // the post-collection address, not the `target_f64` captured on entry. The + // native-module arm already does this; the main path did not, so `acc` in a + // chained `Object.assign(t, a, b)` threaded a from-space pointer into the + // next link. + crate::value::js_nanbox_pointer(tgt_h.get_raw_mut_ptr::() as i64) } From fed75ef7dd3f5a555895d186d18b4b8c6020593d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 1 Aug 2026 20:04:01 +0200 Subject: [PATCH 3/8] test(gc): codegen regressions, unrooted-alloca checker mode, corpus registration --- .../tests/temp_root_operand_temporaries.rs | 297 ++++++++++++++++ scripts/gc_root_dominance_check.py | 327 ++++++++++++++++++ test-parity/gc_repsel_corpus.txt | 20 ++ 3 files changed, 644 insertions(+) diff --git a/crates/perry-codegen/tests/temp_root_operand_temporaries.rs b/crates/perry-codegen/tests/temp_root_operand_temporaries.rs index 8e70337ab0..76f9536569 100644 --- a/crates/perry-codegen/tests/temp_root_operand_temporaries.rs +++ b/crates/perry-codegen/tests/temp_root_operand_temporaries.rs @@ -935,3 +935,300 @@ fn the_inline_ctor_result_slot_never_carries_an_instance_address() { ); } } + +/// #7202: the inline-constructor `this` slot must be a **rewritten** root. +/// +/// `lower_call/new.rs` allocates `this_slot` with `alloca_entry` and every +/// `this` read inside the inlined body is a `load` from it. A plain +/// `alloca_entry` is neither a shadow slot nor a temp root, so an evacuating +/// minor at a field initializer's back-edge poll neither marks nor rewrites it, +/// and every `this.x = …` after that collection stores into abandoned +/// from-space memory. +/// +/// #7192 rooted the instance for the CALLER (`instance_root`, re-read by +/// `reload_instance`) precisely because this window collects — so the object +/// survives and MOVES. That made the caller's copy correct and left this one +/// behind: the same address, taken one line later, that nothing rewrites. +/// +/// Binding the alloca is the fix rather than routing `Expr::This` through a +/// temp root, because ~30 readers already `load` from it and +/// `js_shadow_slot_bind` records `slot_ptrs[idx] = alloca`, so evacuation +/// rewrites it in place. +/// +/// Sabotage check: drop the `root_entry_alloca` call in `lower_new_impl_inner` +/// and no bind names the `this` slot. +#[test] +fn the_inline_ctor_this_slot_is_bound_as_a_shadow_slot() { + let ir = String::from_utf8( + compile_module( + &module_with_new_running_ctor("new_inst_this_slot.ts"), + entry_opts(), + ) + .unwrap(), + ) + .expect("LLVM IR should be UTF-8"); + let f = init_ir(&ir); + + // `this` is read by the field-initializer store; find the slot it loads + // from by taking the alloca that a `js_shadow_slot_bind` names AND that is + // stored with a nanboxed instance. Simpler and stronger: assert that every + // `double` alloca which receives a register store and is later loaded is + // covered by a bind. + let bound: std::collections::HashSet = f + .lines() + .filter_map(|l| l.split_once("js_shadow_slot_bind(i32 ")) + .filter_map(|(_, rest)| rest.split_once("ptr ").map(|(_, p)| p)) + .map(|p| p.trim().trim_end_matches(')').to_string()) + .collect(); + assert!( + !bound.is_empty(), + "expected at least one js_shadow_slot_bind in:\n{f}" + ); + + // The instance's nanbox register: walk each `store double %X, ptr %S` + // backwards through the bit-level nanbox ops to see whether `%X` was + // produced by an object allocation. That is the `this` slot. + let def_of: std::collections::HashMap<&str, &str> = f + .lines() + .filter_map(|l| l.trim_start().split_once(" = ")) + .map(|(r, rhs)| (r.trim(), rhs)) + .collect(); + fn reaches_alloc( + reg: &str, + def_of: &std::collections::HashMap<&str, &str>, + depth: usize, + ) -> bool { + if depth == 0 { + return false; + } + let Some(rhs) = def_of.get(reg) else { + return false; + }; + if rhs.contains("@js_object_alloc") { + return true; + } + // Only follow bit-level identity producers, exactly as the dominance + // checker's `provenance` does; anything else is a different value. + if !(rhs.starts_with("or i64") + || rhs.starts_with("bitcast") + || rhs.starts_with("inttoptr") + || rhs.starts_with("ptrtoint")) + { + return false; + } + rhs.split(|c: char| !(c.is_alphanumeric() || c == '%' || c == '.' || c == '_')) + .filter(|w| w.starts_with('%')) + .any(|w| reaches_alloc(w, def_of, depth - 1)) + } + + let this_slot = f + .lines() + .filter(|l| l.trim_start().starts_with("store double %")) + .find_map(|l| { + let (val, rest) = l + .trim_start() + .strip_prefix("store double ")? + .split_once(", ")?; + let slot = rest.strip_prefix("ptr ")?.trim(); + reaches_alloc(val.trim(), &def_of, 8).then(|| slot.to_string()) + }) + .unwrap_or_else(|| panic!("no store of a freshly allocated instance into a slot in:\n{f}")); + + assert!( + bound.contains(&this_slot), + "the inline-ctor `this` slot {this_slot} is a plain entry alloca that \ + the collector neither marks nor rewrites, yet it holds the instance \ + across the whole constructor body — it must be bound as a shadow slot \ + (#7202). Bound slots: {bound:?}\n{f}" + ); + + // The bind contract also requires an `undefined` seed: the bind is hoisted + // to entry setup, so the collector dereferences the alloca before the + // instance store executes. + assert!( + f.lines().any(|l| { + l.trim_start() + .starts_with("store double 0x7FFC000000000001") + && l.trim_end().ends_with(&format!("ptr {this_slot}")) + }), + "the `this` slot must be seeded with `undefined` in entry_allocas \ + before the hoisted bind makes it live to the collector (#7202/#6968) \ + — no such store for {this_slot}:\n{f}" + ); +} + +/// #7200: `Object.assign(t, …sources)` threads its accumulator through a bare +/// SSA register across every source's lowering AND across every +/// `js_object_assign_one` call. +/// +/// The helper reads every own key of the source, so an accessor there runs +/// arbitrary user code *inside* the helper — the route #7198 accepted, having +/// declined "a helper's own allocation initiates a moving collection" on +/// evidence. `Expr::Object` has rooted its accumulator since #6951; this arm +/// never copied it. +/// +/// Sabotage check: drop the `temp_root_push_double`/`temp_root_set_double` pair +/// in the `Expr::ObjectAssign` arm and the accumulator operand stops being a +/// `js_gc_temp_root_get` result. +#[test] +fn the_object_assign_accumulator_is_rooted_across_each_source() { + let module = module_with_init( + "object_assign_acc.ts", + vec![Stmt::Expr(Expr::ObjectAssign { + target: Box::new(Expr::Object(Vec::new())), + // Two sources so the accumulator is provably live across a second + // lowering as well as across the first helper call. + sources: vec![Expr::Object(Vec::new()), Expr::Object(Vec::new())], + })], + ); + let ir = String::from_utf8(compile_module(&module, entry_opts()).unwrap()) + .expect("LLVM IR should be UTF-8"); + let f = init_ir(&ir); + + let def_of: std::collections::HashMap<&str, &str> = f + .lines() + .filter_map(|l| l.trim_start().split_once(" = ")) + .map(|(r, rhs)| (r.trim(), rhs)) + .collect(); + + let calls: Vec<&str> = f + .lines() + .filter(|l| l.contains("@js_object_assign_one(")) + .collect(); + assert_eq!( + calls.len(), + 2, + "expected one js_object_assign_one per source in:\n{f}" + ); + + for call in &calls { + // `call double @js_object_assign_one(double %acc, double %src)` — the + // first operand must be re-derived from the temp root, not carried in + // a register across the previous link. + let acc = call + .split_once("@js_object_assign_one(") + .expect("argument list") + .1 + .split(", ") + .next() + .and_then(|a| a.trim().strip_prefix("double ")) + .unwrap_or_else(|| panic!("no accumulator operand in `{call}`")) + .to_string(); + // Follow the bitcast back to the `js_gc_temp_root_get` that produced it. + let mut reg = acc.clone(); + let mut rooted = false; + for _ in 0..4 { + let Some(rhs) = def_of.get(reg.as_str()) else { + break; + }; + if rhs.contains("@js_gc_temp_root_get") { + rooted = true; + break; + } + let Some(next) = rhs + .split(|c: char| !(c.is_alphanumeric() || c == '%' || c == '.' || c == '_')) + .find(|w| w.starts_with('%')) + else { + break; + }; + reg = next.to_string(); + } + assert!( + rooted, + "the Object.assign accumulator passed to `{call}` must be re-read \ + from its temp root below the previous source's lowering, not \ + carried in a register (#7200):\n{f}" + ); + } + + // And the result of each link must be republished, because the helper now + // returns the target's POST-collection address. + assert!( + f.contains("@js_gc_temp_root_set"), + "each js_object_assign_one result must be written back into the \ + accumulator's root — the helper returns the moved target (#7200):\n{f}" + ); +} + +/// #7201: a `PutValueSet` KEY must be re-derived below the value's evaluation. +/// +/// The dynamic-key write IC lowers `k → v → t`. That ordering is what keeps the +/// TARGET safe — the pointer is materialized below everything that can collect +/// — and the comment that used to sit there extended the claim to the key, on +/// the grounds that "a moved key merely misses by stale bits (identity compare +/// — false negatives only)". That is true of the three way-compares and false +/// of everything below them: the miss falls through to `put.dynic.slow`, which +/// hands the same register to `js_put_value_set_dyn_ic`, which DEREFERENCES it +/// as a `StringHeader*`. +/// +/// A string-literal key is a load of a `__perry_init_strings_*` handle global — +/// a registered root that evacuation REWRITES — so the register loaded above +/// the value names from-space. `this.viaBlock = churn()` inside a +/// `static { … }` block is the shipped shape. +/// +/// Sabotage check: remove the `guard_store_operand`/`reread_store_operand` pair +/// from the `dyn_inline` arm of `Expr::PutValueSet` and the handle load stops +/// being re-emitted below the call. +#[test] +fn a_put_value_set_key_is_re_derived_below_the_value() { + let module = module_with_init( + "put_value_set_key.ts", + vec![ + Stmt::Let { + id: 0, + name: "o".to_string(), + ty: perry_hir::types::Type::Any, + init: Some(Expr::Object(Vec::new())), + mutable: true, + }, + Stmt::Expr(Expr::PutValueSet { + target: Box::new(Expr::LocalGet(0)), + key: Box::new(Expr::String("viaBlock".to_string())), + // A call: the collection point. `Expr::Object` allocates, and + // an allocation is a collection point in this model. + value: Box::new(Expr::Object(Vec::new())), + receiver: Box::new(Expr::LocalGet(0)), + strict: true, + }), + ], + ); + let ir = String::from_utf8(compile_module(&module, entry_opts()).unwrap()) + .expect("LLVM IR should be UTF-8"); + let f = init_ir(&ir); + + let lines: Vec<&str> = f.lines().collect(); + let handle_load_idxs: Vec = lines + .iter() + .enumerate() + .filter(|(_, l)| l.contains("= load double, ptr @") && l.contains(".handle")) + .map(|(i, _)| i) + .collect(); + assert!( + !handle_load_idxs.is_empty(), + "expected a string-literal handle load for the key in:\n{f}" + ); + // The key literal's handle must be loaded AFTER the value's allocation. + // Before the fix there was exactly one such load and it sat above it. + let alloc_idx = lines + .iter() + .position(|l| l.contains("= call i64 @js_object_alloc(i32 0, i32 0)")) + .and_then(|first| { + // the value's allocation is the SECOND `js_object_alloc` (the first + // is the receiver `o`) + lines + .iter() + .enumerate() + .skip(first + 1) + .find(|(_, l)| l.contains("= call i64 @js_object_alloc(i32 0, i32 0)")) + .map(|(i, _)| i) + }) + .unwrap_or_else(|| panic!("no value allocation in:\n{f}")); + assert!( + handle_load_idxs.iter().any(|i| *i > alloc_idx), + "the key literal's handle global must be re-loaded BELOW the value's \ + allocation — a register loaded above it names from-space after an \ + evacuating minor, and the dyn-IC slow path dereferences it as a \ + StringHeader* (#7201/#7114). Handle loads at {handle_load_idxs:?}, \ + value allocation at {alloc_idx}:\n{f}" + ); +} diff --git a/scripts/gc_root_dominance_check.py b/scripts/gc_root_dominance_check.py index be3e39e537..d15e390edc 100755 --- a/scripts/gc_root_dominance_check.py +++ b/scripts/gc_root_dominance_check.py @@ -758,6 +758,244 @@ def scan(blk, lo, hi): """ +# ------------------------------------------------- unrooted-alloca check --- +# +# The third way the invariant breaks (#7202), and the one the bind-anchored +# check above is structurally blind to. +# +# #7184 was a root store whose slot index fell outside the pushed frame. +# #7192 was a root store emitted after a collection point. Both produce a +# `js_shadow_slot_bind` for the checker to anchor on. This one produces NONE: +# the value lives in a plain `alloca_entry` for its whole lifetime, so the +# collector neither marks nor rewrites it, and a scan that starts from binds +# reports the function clean while every load below the collection point names +# from-space. +# +# The shape, from `lower_call/new.rs`'s inline-constructor `this` slot: +# +# %slot = alloca double ; never in a js_shadow_slot_bind +# store double %inst, ptr %slot +# %x = call double @user_fn() ; collects; the instance MOVES +# %t = load double, ptr %slot ; from-space +# +# It reports an alloca when ALL of: +# 1. it is never an operand of `js_shadow_slot_bind` anywhere in the function +# (a bind makes the collector rewrite it, which is the fix); +# 2. some store into it carries a value whose provenance is a heap-value +# SOURCE — an allocation, or a load of a collector-rewritten location; +# 3. a collecting call sits on some CFG path between that store and a LOAD of +# the alloca. +# +# One-sided in the same direction as the bind-anchored check: `NONCOLLECTING` +# is the only place a call is declared safe, so a missing entry costs a false +# positive and never a missed bug. It is reported separately from the +# bind-anchored count because its two populations are disjoint by construction. + +# Loads whose source is a location the collector REWRITES. A register holding +# one of these is stale below a collection point even though the value survives +# — property (2) without property (3), the module-header distinction. +REWRITTEN_LOAD_RE = re.compile( + r"load\s+\S+,\s*ptr\s+@(?:" + r"\w*_\.str\.\d+\.handle" # string-literal handle globals + r"|perry_global_\w+" # module-level variables + r"|perry_class_keys_\w+" # class keys arrays (old-gen, C4b movable) + r")" +) + +# Calls that MATERIALIZE a heap value (a superset of ALLOC_RE: anything that +# hands back an object the collector can move). +HEAP_SOURCE_CALLS = frozenset({ + "js_gc_temp_root_get", "js_shadow_slot_get", "js_closure_get_capture_bits", + "js_box_get_bits", "js_implicit_this_get", "js_new_target_get", + "js_static_this_resolve", "js_get_exception", +}) + +ALLOCA_RE = re.compile(r"^\s*%([\w.$]+)\s*=\s*alloca\s+(.+)$") +LOAD_FROM_RE = re.compile(r"=\s*load\s+[^,]+,\s*ptr\s+%([\w.$]+)") +# Types that can hold a NaN-boxed JS value or a raw heap address. An `i32`/`i1` +# slot is a counter or a flag and cannot name the heap. +GC_CAPABLE_ALLOCA_TYPES = ("double", "i64", "[") + + +class UnrootedAlloca: + def __init__(self, module, func, alloca, store, load, collectors, + poll_reaching=frozenset()): + self.module = module + self.func = func + self.alloca = alloca + self.store = store + self.load = load + self.collectors = collectors + self.poll_reaching = poll_reaching + + @property + def movers(self): + return sorted({c.callee for c in self.collectors + if c.callee == MOVING_POLL or c.callee in self.poll_reaching + or c.callee in POLL_CAPABLE_RUNTIME}) + + @property + def moving(self): + return bool(self.movers) + + +def _is_heap_source(ins): + """Does `ins` materialize a value the collector can move?""" + if ins.callee is not None: + return bool(ALLOC_RE.match(ins.callee)) or ins.callee in HEAP_SOURCE_CALLS + return bool(REWRITTEN_LOAD_RE.search(ins.text)) + + +def check_func_unrooted_allocas(module, f, want_moving_only=False, + poll_reaching=frozenset()): + if not f.blocks: + return [] + + bound = set() + allocas = {} # reg -> Insn + def_of = {} + for b in f.blocks: + for ins in f.insns[b]: + m = BIND_RE.search(ins.text) + if m: + bound.add(m.group(2)) + am = ALLOCA_RE.match(ins.text) + if am and any(am.group(2).strip().startswith(t) + for t in GC_CAPABLE_ALLOCA_TYPES): + allocas[am.group(1)] = ins + if ins.result: + def_of[ins.result] = ins + if not allocas: + return [] + + # Alloca-typed registers that leak their ADDRESS to a call cannot be + # reasoned about locally — the callee may root them. Exclude them rather + # than report a guess. + escaped = set() + for b in f.blocks: + for ins in f.insns[b]: + if ins.callee is None: + continue + for r in operand_regs(ins.text): + if r in allocas: + escaped.add(r) + + idom = dominators(f) + stores = defaultdict(list) # alloca -> [Insn] + loads = defaultdict(list) # alloca -> [Insn] + for b in f.blocks: + for ins in f.insns[b]: + sm = STORE_RE.match(ins.text) + if sm and sm.group(3) in allocas: + stores[sm.group(3)].append(ins) + lm = LOAD_FROM_RE.search(ins.text) + if lm and lm.group(1) in allocas: + loads[lm.group(1)].append(ins) + + def window_hits(A, B): + hits = [] + if A.block == B.block: + return [c for c in f.insns[A.block] + if is_collecting(c.callee) and A.idx < c.idx < B.idx] + hits += [c for c in f.insns[A.block] + if is_collecting(c.callee) and c.idx > A.idx] + hits += [c for c in f.insns[B.block] + if is_collecting(c.callee) and c.idx < B.idx] + for m_blk in between_blocks(f, A.block, B.block): + hits += [c for c in f.insns[m_blk] if is_collecting(c.callee)] + return hits + + out = [] + for reg, alloca_ins in sorted(allocas.items()): + if reg in bound or reg in escaped: + continue + if not stores[reg] or not loads[reg]: + continue + reported = False + for st in stores[reg]: + sm = STORE_RE.match(st.text) + val = sm.group(2).strip() + if not val.startswith("%"): + continue # a constant seed (`undefined`) names no heap + origins = provenance(def_of, val[1:]) + if not any(_is_heap_source(o) for o in origins): + continue + for ld in loads[reg]: + if not dominates(idom, st.block, ld.block): + continue + if st.block == ld.block and st.idx >= ld.idx: + continue + hits = window_hits(st, ld) + if not hits: + continue + v = UnrootedAlloca(module, f.name, alloca_ins, st, ld, hits, + poll_reaching) + if want_moving_only and not v.moving: + continue + out.append(v) + reported = True + break + if reported: + break + return out + + +# The #7202 shape, and its fix. `@unrooted` is `lower_call/new.rs`'s +# inline-constructor `this` slot before this change: allocated, stored, held +# across a user call, loaded after. `@rooted` is byte-identical with the bind +# added — which is the whole fix, because the collector then rewrites the +# alloca in place and the load below the call is correct. +_SELFTEST_UNROOTED = """\ +define double @perry_fn_selftest__unrooted(double %a) { +entry.0: + %slot = alloca double + %inst = call i64 @js_object_alloc_class_inline_keys(i32 1, i32 0, i32 3, i64 0) + %box = bitcast i64 %inst to double + store double %box, ptr %slot + %ret = call double @perry_fn_user__init(double %a) + %this = load double, ptr %slot + ret double %this +} +""" + +_SELFTEST_ROOTED = """\ +define double @perry_fn_selftest__rooted(double %a) { +entry.0: + %slot = alloca double + %inst = call i64 @js_object_alloc_class_inline_keys(i32 1, i32 0, i32 3, i64 0) + %box = bitcast i64 %inst to double + store double %box, ptr %slot + call void @js_shadow_slot_bind(i32 0, ptr %slot) + %ret = call double @perry_fn_user__init(double %a) + %this = load double, ptr %slot + ret double %this +} +""" + + +def _scan_unrooted(paths): + """(violations, n_gc_capable_allocas) over `paths`.""" + parsed = [(os.path.basename(p), parse_file(p)) for p in sorted(paths)] + poll_reaching, _known = compute_poll_reaching( + [f for _m, fs in parsed for f in fs]) + n = 0 + for _m, fs in parsed: + for f in fs: + for b in f.blocks: + for ins in f.insns[b]: + am = ALLOCA_RE.match(ins.text) + if am and any(am.group(2).strip().startswith(t) + for t in GC_CAPABLE_ALLOCA_TYPES): + n += 1 + found = [ + (mod, v) + for mod, fs in parsed + for f in fs + for v in check_func_unrooted_allocas(mod, f, False, poll_reaching) + ] + return found, n + + def _scan(paths, moving_only, anchor): """(violations, n_binds) over `paths`.""" parsed = [(os.path.basename(p), parse_file(p)) for p in sorted(paths)] @@ -834,6 +1072,45 @@ def self_test(): file=sys.stderr) ok = False + # --- the #7202 mode, both directions ------------------------------- + unrooted = os.path.join(td, "unrooted.ll") + rooted = os.path.join(td, "rooted.ll") + for p, text in ((unrooted, _SELFTEST_UNROOTED), (rooted, _SELFTEST_ROOTED)): + with open(p, "w") as fh: + fh.write(text) + + found, n_allocas = _scan_unrooted([unrooted]) + if len(found) != 1: + print(f"self-test FAIL: unrooted-alloca fixture -> {len(found)} " + "violations, expected 1", file=sys.stderr) + ok = False + if n_allocas != 1: + print(f"self-test FAIL: unrooted-alloca fixture -> {n_allocas} " + "gc-capable allocas, expected 1", file=sys.stderr) + ok = False + + found, n_allocas = _scan_unrooted([rooted]) + if found: + print(f"self-test FAIL: rooted control -> {len(found)} violations, " + "expected 0. The ONLY difference from the planted fixture is " + "the js_shadow_slot_bind, so a non-zero count here means the " + "check does not actually model the fix.", file=sys.stderr) + ok = False + if n_allocas != 1: + print(f"self-test FAIL: rooted control -> {n_allocas} gc-capable " + "allocas, expected 1", file=sys.stderr) + ok = False + + # And it must not fire on the bind-anchored fixtures, nor the reverse: + # the two populations are disjoint by construction and a checker that + # double-counts would make both numbers meaningless. + found, _ = _scan_unrooted([planted]) + if found: + print(f"self-test FAIL: the bind-anchored planted fixture has a " + f"bind for every alloca, so the unrooted check must report 0, " + f"got {len(found)}", file=sys.stderr) + ok = False + print("self-test OK" if ok else "self-test FAILED") return 0 if ok else 1 @@ -856,6 +1133,11 @@ def main(): ap.add_argument("--min-binds", type=int, default=1, metavar="N", help="fail unless at least N root stores were seen (default 1). " "A clean verdict over zero root stores proves nothing.") + ap.add_argument("--unrooted-allocas", action="store_true", + help="check the #7202 shape instead: a plain alloca that " + "holds a heap value across a collecting call and is " + "loaded below it, with no js_shadow_slot_bind anywhere. " + "Disjoint from the bind-anchored check by construction.") ns = ap.parse_args() if ns.self_test: @@ -900,6 +1182,51 @@ def main(): if BIND_RE.search(ins.text) ) + if ns.unrooted_allocas: + total = 0 + moving_total = 0 + per_fn = defaultdict(int) + out = [] + n_allocas = 0 + for mod, fs in parsed: + for f in fs: + for b in f.blocks: + for ins in f.insns[b]: + am = ALLOCA_RE.match(ins.text) + if am and any(am.group(2).strip().startswith(t) + for t in GC_CAPABLE_ALLOCA_TYPES): + n_allocas += 1 + for v in check_func_unrooted_allocas(mod, f, moving_only, + poll_reaching): + total += 1 + per_fn[v.func] += 1 + if v.moving: + moving_total += 1 + cs = sorted({c.callee for c in v.collectors}) + out.append( + f"{mod}::{v.func}\n" + f" alloca : {v.alloca.text.strip()}\n" + f" store : {v.store.text.strip()}\n" + f" load : {v.load.text.strip()}\n" + f" between: {', '.join(cs[:8])}" + f"{' (+%d more)' % (len(cs) - 8) if len(cs) > 8 else ''}\n" + f" MOVING : {('YES via ' + ', '.join(v.movers[:3])) if v.moving else 'no'}\n" + ) + if verbose: + print("\n".join(out)) + print(f"=== files: {len(paths)} gc-capable allocas: {n_allocas} " + f"unrooted-alloca violations: {total}" + f" (moving-minor reachable: {moving_total})") + for k, n in sorted(per_fn.items(), key=lambda kv: -kv[1])[:20]: + print(f" {n:6d} {k}") + # Liveness floor: the subject here is the alloca population, not the + # bind population, so `--min-binds` would certify the wrong thing. + if n_allocas < ns.min_binds: + print(f"error: {n_allocas} gc-capable alloca(s) in the corpus, need " + f"at least {ns.min_binds}. Nothing was checked.", file=sys.stderr) + return 2 + return 1 if total else 0 + total = 0 moving_total = 0 per_kind = defaultdict(int) diff --git a/test-parity/gc_repsel_corpus.txt b/test-parity/gc_repsel_corpus.txt index 0498503ae5..59b3808add 100644 --- a/test-parity/gc_repsel_corpus.txt +++ b/test-parity/gc_repsel_corpus.txt @@ -170,3 +170,23 @@ test_gap_gc_pointer_publish # "collect" arms actually bite. Keep it registered and keep it collecting. test_gap_repsel_gc_stress + +# --- The three residual root-store holes (#7200, #7201, #7202) -------------- +# Not representation files, so registered explicitly per the header rule. +# +# ***ALL THREE ARE LIVE ONLY ON THE `requires=move` ARMS, AND THAT IS THE +# POINT.*** Each is clean under the shipped default, because #7161 flipped the +# evacuating minor off pending #7154 — so their `default` / `verify_evac` / +# `cons_scan_off*` cells are UNVER, correctly. `loop_polls` is where they bite +# end to end today; when #7161 is reverted the rest flip to PASS on their own. +# +# Measured on `origin/main` (3a983ca6a), compiled AND run with +# `PERRY_GC_MOVING_LOOP_POLLS=1`: +# spread_accessor_rooting exit=139 (SIGSEGV) 3/3 +# static_block_this_rooting `bad 4` 3/3 (deterministic) +# inline_ctor_this_rooting green — see its own header; it is a STATIC +# gate, pinned by the codegen test and by +# `gc_root_dominance_check.py --unrooted-allocas` +test_gap_gc_spread_accessor_rooting +test_gap_gc_static_block_this_rooting +test_gap_gc_inline_ctor_this_rooting From 5ed856d933943a55fb40ed47b6016cc04b310f80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 1 Aug 2026 20:07:59 +0200 Subject: [PATCH 4/8] docs(changelog): fragment for #7207 --- changelog.d/7207-residual-root-store-holes.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 changelog.d/7207-residual-root-store-holes.md diff --git a/changelog.d/7207-residual-root-store-holes.md b/changelog.d/7207-residual-root-store-holes.md new file mode 100644 index 0000000000..82f6de5527 --- /dev/null +++ b/changelog.d/7207-residual-root-store-holes.md @@ -0,0 +1,40 @@ +### Fixed + +- **codegen/runtime: the three residual root-store holes blocking #7161's revert (#7200, #7201, #7202)**. #7184 fixed a root store whose slot index fell outside the pushed frame; #7192 fixed one emitted after a collection point. These are the three remaining ways the same invariant breaks — **a GC-managed value's root store must dominate every subsequent site that can trigger a collection, and the slot it is stored into must be one the collector actually rewrites** — and #7202 is the general shape behind all of them: a heap value in storage the collector never rewrites, or a register never re-read below the collection point. + + Two of the three issues named the wrong cause. Both premises were taken to the emitted IR before anything was changed, and both corrections are recorded here rather than fixed quietly. + + - **`PutValueSet`'s KEY, not the static-`this` cell (#7201)**. The cell is a red herring: `STATIC_THIS_OVERRIDE` (`object/this_binding.rs:38`) is **already** marked *and* rewritten — `scan_implicit_this_roots_mut` visits it at `this_binding.rs:212-217` via `visit_nanbox_u64_slot` and is registered at `gc/mod.rs:523` — and the static-block body's `this` alloca is shadow-bound at `codegen/method.rs:1385-1390`. What is actually stale is the **key**. `(this as any).viaBlock = churn()` takes the #6812 dynamic-key write IC, which lowers `k → v → t`; that ordering keeps the *target* safe and the in-tree comment extended the claim to the key — "a moved key merely misses by stale bits (identity compare — false negatives only)". True of the three way-compares, false of everything below them: the miss falls through to `put.dynic.slow`, which hands the same register to `js_put_value_set_dyn_ic`, which **dereferences** it as a `StringHeader*`. A string literal's `__perry_init_strings_*` handle is a registered root that evacuation *rewrites* (#7114), so the register loaded above `churn()` names from-space. An identity-compare-only use is not the only use. The key is now re-derived below the value on the inline arm, and both the receiver and the key on the two outlined arms. + + Two sibling holes fell out of it. `reread_store_operand`'s `Reload` arm returned the caller's register unchanged, on the reasoning that "for a literal [the register] is a load from that same global" — it is a load from that global *taken before the collection point*. Its call-operand sibling `RootedOperands::reread` has always re-lowered `Reload` operands (`temp_root.rs:523`); two helper families answering the same question differently is precisely the drift that produced #7114, and they now agree. And a new `guard_store_operand_across` states the window explicitly, because a receiver lowered before both the key and the value is live across *both* while every caller derived `collects` from the value alone — leaving `o[f()] = 1` unguarded. + + - **`js_object_assign_one`, not the `Expr::ObjectAssign` accumulator (#7200)**. `{ ...src, tail: 7 }` does not reach `Expr::ObjectAssign` at all: the #809 IIFE emits a generic `Expr::Call` on `js_object_assign_one` (`lower/expr_object.rs:1159-1163`) whose `__o` is a `Type::Any` local with a shadow slot, marked and rewritten — which is why the lighter variant reports `plain 0 hot 10 tail 0`, destination intact. The SIGSEGV is inside the helper: `js_object_get_field_by_name` short-circuits into `invoke_accessor_getter` → `js_closure_call0`, i.e. **arbitrary user code inside a runtime helper**, and `target`, `src`, `src_keys` and `key_ptr` are raw addresses in Rust locals used *after* it returns — `target` and `key_ptr` by the write funnel on the very next line, `src_keys`/`src` by the next iteration. The function already modelled the fix one branch up (the native-module arm opens a `RuntimeHandleScope`, roots `target`, and returns the handle-reloaded pointer); that treatment now spans both numbered sections, the array-source branch, the proxy-source loop (two traps per key — the widest window in the file), the string-source loop, the symbol tail, and the getter's own return value, and the function returns the post-collection target rather than the `target_f64` captured on entry. The codegen accumulator is a *separate*, real bug (`Object.assign(t, f(), g())`) and is fixed with it: temp-rooted, re-read before every link, and **republished** after each, because the helper now hands back a moved address. + + - **The inline-constructor `this` slot (#7202)**. `lower_call/new.rs:1109` parks the instance in a plain `alloca_entry` for the whole inlined body, and every `this` read is a `load` from it. `force_ctor_call` requires `class.constructor.is_some()`, so `class C { payload = mk() }` and `class C extends B {}` take this path with `PERRY_INLINE_CTOR` unset — and `construction_runs_user_code`, which gates the `instance_root` #7192 added, is true for exactly those. **The function already asserts this window collects, then keeps a second copy of the same address one line later that nothing rewrites.** Binding it — rather than routing `Expr::This` through a temp root — leaves all ~30 `ctx.this_stack.last()` readers untouched, because they already `load` from the alloca and `js_shadow_slot_bind` records `slot_ptrs[idx] = alloca`, so evacuation rewrites it in place. #6968's machinery is generalized to `expr::scalar_slot_root::root_entry_alloca` for it, contract intact: seed `undefined` in `entry_allocas`, bind hoisted to entry setup, root barrier at the store. + + **There is no runtime reproducer for this one, and the shipped test says so.** `test_gap_gc_inline_ctor_this_rooting.ts` is green at base: the class-field inline guard reads `obj_type`/`class_id`/`keys_array` off the stale pointer, the evacuation's forwarding record fails that guard, and the runtime fallback resolves forwarding — so today the stale write still lands correctly. That masking holds only while the from-space block still carries the forwarding record, which is exactly #7154's latency. The claim rests on the static argument and is gated two ways: a codegen regression test and a new checker mode. + +### Added + +- **`gc_root_dominance_check.py --unrooted-allocas`** — the complementary check to the shipped one. The bind-anchored pass reports triples *(alloc, bind, collecting call)*, so a value that is **never bound** produces no triple and the function reports clean; that is why #7192's checker read the whole corpus at 1 violation while `this_slot` was live. The new mode reports a gc-capable alloca that no `js_shadow_slot_bind` names, holds a value whose provenance is a heap source (an allocation, or a load of a collector-rewritten location — a string-literal handle, a module global, a class-keys global), and is loaded below a collecting call on a real CFG path. Allocas whose *address* escapes to a call are excluded rather than guessed at. One-sided in the same direction as its sibling: `NONCOLLECTING` is the only place a call is declared safe. It found `this_slot` independently of any runtime probe. `--self-test` grew four assertions for it in both directions — the planted fixture must report exactly 1, a byte-identical control differing only by the bind must report 0, and the mode must not double-count the bind-anchored fixtures. + +- **The bare-alloca enumeration for #7202.** 140 sites across `crates/perry-codegen` (63 `alloca_entry`, 42 `alloca_entry_array`, 2 `alloca_entry_bytes_aligned`, 19 `blk.alloca`, 14 raw `emit_raw`), classified **23 HAZARD / 117 SAFE**. Only the first is fixed here; the rest are recorded so they stop being invisible: + + | class | sites | disposition | + |---|---|---| + | inline-ctor `this` slot (`lower_call/new.rs:1109`) | 1 | **fixed** — shadow slot | + | closure `this` / `new.target` slots (`codegen/closure.rs:704`, `:687`) | 2 | HAZARD — `closure.rs:589` calls `enable_shadow_frame(m.len())` where `method.rs:316`/`:1344` call `m.len() + 1`; the `+1` *is* the `this` slot | + | `catch (e)` parameter slot (`stmt/try_stmt.rs:114`) | 1 | HAZARD — the slot **is** sized for (`pointer_locals.rs:983-990`) but never bound, and `js_clear_exception` drops the runtime's own reference before the body runs | + | inlined-callee param slots (`new_ctor_args.rs:54`, `let_stmt.rs:974`/`:997`/`:367`) | 4 | HAZARD, structurally unrootable today — their HIR ids belong to another function, so `collect_pointer_typed_locals` cannot see them | + | unrooted cached copy of a registered root (`function.rs:521`, `loops.rs:1505`/`:4766`) | 3 | HAZARD — `function.rs:521` caches a `@perry_class_keys_*` pointer that `string_pool.rs:464-477` documents as C4b-relocatable, and three consumers dereference it | + | staging arrays filled interleaved with lowering (`helpers.rs:1275`, `call_spread.rs:473`, +7) | 9 | HAZARD — arg *i*'s pointer is in the unrooted buffer while arg *i+1* is lowered | + | staging arrays with a call between stores and consumer (`dynamic_dispatch.rs:1315`, +2) | 3 | HAZARD | + | explicitly rooted (`js_shadow_slot_bind` / `root_scalar_replaced_slot*`) | 15 | SAFE | + | no collection window (incl. 28 lower-then-store args buffers, and `expr/closure.rs:304` whose callee roots every word in a `RuntimeHandleScope` before allocating) | 46 | SAFE | + | non-GC (i32/i1/index/counter/handle slots, POD byte buffers, never-written placeholders) | 52 | SAFE | + | PTR into GC heap, safe by a runtime invariant (`function.rs:928`/`:991`, `buffer_views.rs:272`, `let_buffer_views.rs:54`) | 4 | SAFE **conditionally** — liveness via the caller's binding, address stability because typed arrays are old-arena + `TENURED` and `gc_type_is_movable(GC_TYPE_TYPED_ARRAY) == false`. If any admitted construction form ever nursery-allocates, all four become hazards silently | + +### Testing + +- `test-files/test_gap_gc_spread_accessor_rooting.ts`, `test_gap_gc_static_block_this_rooting.ts`, `test_gap_gc_inline_ctor_this_rooting.ts`, all registered in `test-parity/gc_repsel_corpus.txt` with their measured base behaviour. At `origin/main` (`3a983ca6a`) compiled **and** run with `PERRY_GC_MOVING_LOOP_POLLS=1`: the first is `exit=139` (SIGSEGV) 3/3, the second is `bad 4` 3/3 deterministic, the third is green (masked — see above). With this change all three are clean 5/5 under polls, clean 3/3 under the shipped default, and byte-exact against `node --experimental-strip-types` 26.5.1. +- Three codegen regression tests in `crates/perry-codegen/tests/temp_root_operand_temporaries.rs`, each **sabotage-checked in both directions with every conjunct its own red set**: removing the `this`-slot bind reddens one assertion, removing the `undefined` seed reddens the other *independently*, removing the `PutValueSet` key re-read reddens the #7201 test, and removing the accumulator re-read reddens the #7200 test. From 5be61711a8caaa31ef0c316e76ade5935ab5aa36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 1 Aug 2026 20:11:00 +0200 Subject: [PATCH 5/8] fix(gc): gate the this-slot bind on construction_runs_user_code --- crates/perry-codegen/src/lower_call/new.rs | 22 +++++++++--- .../tests/temp_root_operand_temporaries.rs | 35 +++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index ee4d8dca8f..e02135ef3f 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -1133,11 +1133,23 @@ fn lower_new_impl_inner( // in place. The `undefined` seed is required by `root_entry_alloca`'s // contract: the bind is hoisted to entry setup, so the slot is live to the // collector before this store executes. - let undef = crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); - ctx.func - .entry_allocas_push_store(DOUBLE, &undef, &this_slot); - ctx.block().store(DOUBLE, &obj_box, &this_slot); - crate::expr::root_entry_alloca(ctx, &this_slot); + // + // Gated on `instance_root.is_some()`, i.e. on the very same + // `construction_runs_user_code` predicate that decided the instance needed + // a temp root at all. When it is false no user code runs between this store + // and the pop, so nothing in the window can collect and the slot cannot go + // stale — and a class with no constructor, no fields and no heritage keeps + // its previous IR exactly, frame size included. One predicate, one place: + // forking a second one here is how #7114's two predicates diverged. + if instance_root.is_some() { + let undef = crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + ctx.func + .entry_allocas_push_store(DOUBLE, &undef, &this_slot); + ctx.block().store(DOUBLE, &obj_box, &this_slot); + crate::expr::root_entry_alloca(ctx, &this_slot); + } else { + ctx.block().store(DOUBLE, &obj_box, &this_slot); + } ctx.this_stack.push(this_slot); ctx.class_stack.push(class_name.to_string()); diff --git a/crates/perry-codegen/tests/temp_root_operand_temporaries.rs b/crates/perry-codegen/tests/temp_root_operand_temporaries.rs index 76f9536569..fa45571f7f 100644 --- a/crates/perry-codegen/tests/temp_root_operand_temporaries.rs +++ b/crates/perry-codegen/tests/temp_root_operand_temporaries.rs @@ -1232,3 +1232,38 @@ fn a_put_value_set_key_is_re_derived_below_the_value() { value allocation at {alloc_idx}:\n{f}" ); } + +/// The negative half of #7202: a class that runs NO user code during +/// construction must emit no `this`-slot root at all. +/// +/// The bind is gated on the same `construction_runs_user_code` predicate that +/// decides the instance needs a temp root — one predicate, one place. A class +/// with no constructor, no fields and no heritage has nothing in the window +/// that can collect, so the slot cannot go stale and the frame must not grow +/// for it. +/// +/// Sabotage check: drop the `instance_root.is_some()` guard around the bind in +/// `lower_new_impl_inner` and this fails. +#[test] +fn a_collection_free_construction_emits_no_this_slot_root() { + // `module_with_new` is the bare `Pair` class: no fields, no ctor, no + // heritage — the exact shape `construction_runs_user_code` answers `false` + // for. + let ir = ir_for_new("new_inst_inert.ts", Vec::new()); + let f = init_ir(&ir); + assert!( + f.contains("@js_object_alloc"), + "the fixture must actually construct something:\n{f}" + ); + assert!( + !f.contains("@js_gc_temp_root_push"), + "an inert construction runs no user code, so it must emit no instance \ + temp root (#7192) — the `this`-slot bind is gated on the same \ + predicate:\n{f}" + ); + assert!( + !f.contains("@js_shadow_slot_bind"), + "an inert construction must not grow the shadow frame for a `this` \ + slot that cannot go stale (#7202):\n{f}" + ); +} From 26eabde22c52e9cf51056eb36606e773de95b3f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 1 Aug 2026 20:14:59 +0200 Subject: [PATCH 6/8] fix(gc): root the closure-source snapshot in js_object_assign_one too --- crates/perry-runtime/src/object/alloc.rs | 31 ++++++++++++++++++++---- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/crates/perry-runtime/src/object/alloc.rs b/crates/perry-runtime/src/object/alloc.rs index ecaf57339a..92b15c2357 100644 --- a/crates/perry-runtime/src/object/alloc.rs +++ b/crates/perry-runtime/src/object/alloc.rs @@ -1349,22 +1349,43 @@ pub unsafe extern "C" fn js_object_assign_one(target_f64: f64, source_f64: f64) // resource class's enumerable statics like `.extend`/`.method`; without this // the call hung at `import 'stripe'`.) if crate::closure::is_closure_ptr(src_raw) { - for (name, value) in crate::closure::closure_dynamic_props_snapshot(src_raw) { + // #7200: `js_string_from_bytes` and the write funnel both allocate, and + // the snapshot's VALUES are heap references held in a plain `Vec` for + // the whole loop. `src_raw` keys the closure side tables, so it has to + // survive too. No accessor runs here (the snapshot is raw), so this is + // the allocation-only form of the same window rather than user-code + // re-entry — it is fixed for symmetry, and because a snapshot Vec of + // unrooted heap words is a liveness hole as well as a staleness one. + let scope = crate::gc::RuntimeHandleScope::new(); + let tgt_h = scope.root_raw_mut_ptr(target); + let src_h = scope.root_raw_const_ptr(src_raw as *const u8); + let snapshot = crate::closure::closure_dynamic_props_snapshot(src_raw); + let value_handles: Vec<_> = snapshot + .iter() + .map(|(_name, value)| scope.root_nanbox_f64(*value)) + .collect(); + for ((name, _), value_h) in snapshot.iter().zip(value_handles.iter()) { + let src_raw = src_h.get_raw_const_ptr::() as usize; if matches!(name.as_str(), "length" | "name" | "prototype") { continue; } - if crate::closure::closure_is_key_deleted(src_raw, &name) { + if crate::closure::closure_is_key_deleted(src_raw, name) { continue; } - if let Some(attrs) = get_property_attrs(src_raw, &name) { + if let Some(attrs) = get_property_attrs(src_raw, name) { if !attrs.enumerable() { continue; } } let key_ptr = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); - object_assign_set_string_key(target, target_is_array, key_ptr, value); + object_assign_set_string_key( + tgt_h.get_raw_mut_ptr::(), + target_is_array, + key_ptr, + value_h.get_nanbox_f64(), + ); } - return target_f64; + return crate::value::js_nanbox_pointer(tgt_h.get_raw_mut_ptr::() as i64); } let src = src_raw as *const ObjectHeader; From e1bff3a57ffc709f575b1946d63572fce6ddc3c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 1 Aug 2026 20:32:13 +0200 Subject: [PATCH 7/8] fix(gc): treat an imported constructor as user code in construction_runs_user_code --- crates/perry-codegen/src/expr/index_set.rs | 6 ++++++ crates/perry-codegen/src/lower_call/new.rs | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/crates/perry-codegen/src/expr/index_set.rs b/crates/perry-codegen/src/expr/index_set.rs index 9c49ef0535..2d55d7bc96 100644 --- a/crates/perry-codegen/src/expr/index_set.rs +++ b/crates/perry-codegen/src/expr/index_set.rs @@ -1541,6 +1541,12 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { (DOUBLE, &val_double), ], ); + // Inner-to-outer: `temp_root_truncate` drops every slot at and + // above its index, so releasing the receiver first would drop + // the key's as a side effect. Correct today, wrong the moment + // the push order changes — and #7207's `proxy_reflect` sibling + // spells both out for exactly that reason. + super::temp_root::release_store_operand(ctx, key_guard); super::temp_root::release_store_operand(ctx, recv_guard); return Ok(val_double); } diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index e02135ef3f..6e24912b8f 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -39,6 +39,21 @@ use crate::types::{DOUBLE, I32, I64, I8, PTR}; /// `ctx.classes`, so a name this returns `false` for never reaches the push and /// would leave the scope marker as pure overhead. fn construction_runs_user_code(ctx: &FnCtx<'_>, class_name: &str) -> bool { + // #7207: an IMPORTED constructor runs user code while leaving no trace in + // the local class table — `ctx.classes[class_name].constructor` is `None` + // for it. A class that also declares no fields and no heritage therefore + // answered `false` here while `lower_new_impl_inner` went on to dispatch + // `ctx.imported_class_ctors[class_name]` (its `has_imported_ctor` arm, and + // the `Stmt::Return` writer at the tail of this file). That left BOTH + // consumers of this predicate unprotected across a real constructor body: + // #7192's `instance_root`, and the `this`-slot bind added for #7202. + // + // Keeping it ONE predicate rather than two is the point — the consumers + // have to agree by construction, which is what stops the divergence + // #7114's pair of predicates produced. + if ctx.imported_class_ctors.contains_key(class_name) { + return true; + } ctx.classes.get(class_name).is_some_and(|class| { class.constructor.is_some() || !class.fields.is_empty() From 5e613b400ec1aa45f3bafaf8fcb6f50c0582953d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 1 Aug 2026 20:38:38 +0200 Subject: [PATCH 8/8] docs(changelog): record the imported-ctor predicate fix and the bind gate --- changelog.d/7207-residual-root-store-holes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/changelog.d/7207-residual-root-store-holes.md b/changelog.d/7207-residual-root-store-holes.md index 82f6de5527..079e4a2ab5 100644 --- a/changelog.d/7207-residual-root-store-holes.md +++ b/changelog.d/7207-residual-root-store-holes.md @@ -10,8 +10,12 @@ - **`js_object_assign_one`, not the `Expr::ObjectAssign` accumulator (#7200)**. `{ ...src, tail: 7 }` does not reach `Expr::ObjectAssign` at all: the #809 IIFE emits a generic `Expr::Call` on `js_object_assign_one` (`lower/expr_object.rs:1159-1163`) whose `__o` is a `Type::Any` local with a shadow slot, marked and rewritten — which is why the lighter variant reports `plain 0 hot 10 tail 0`, destination intact. The SIGSEGV is inside the helper: `js_object_get_field_by_name` short-circuits into `invoke_accessor_getter` → `js_closure_call0`, i.e. **arbitrary user code inside a runtime helper**, and `target`, `src`, `src_keys` and `key_ptr` are raw addresses in Rust locals used *after* it returns — `target` and `key_ptr` by the write funnel on the very next line, `src_keys`/`src` by the next iteration. The function already modelled the fix one branch up (the native-module arm opens a `RuntimeHandleScope`, roots `target`, and returns the handle-reloaded pointer); that treatment now spans both numbered sections, the array-source branch, the proxy-source loop (two traps per key — the widest window in the file), the string-source loop, the symbol tail, and the getter's own return value, and the function returns the post-collection target rather than the `target_f64` captured on entry. The codegen accumulator is a *separate*, real bug (`Object.assign(t, f(), g())`) and is fixed with it: temp-rooted, re-read before every link, and **republished** after each, because the helper now hands back a moved address. + - **`construction_runs_user_code` and imported constructors**. Found in review of the above. `ctx.classes[class_name].constructor` is `None` for an imported constructor, so a class that also declares no fields and no heritage answered `false` while `lower_new_impl_inner` went on to dispatch `ctx.imported_class_ctors[class_name]`. That left BOTH consumers of the predicate unprotected across a real constructor body — #7192's `instance_root` as well as the new `this`-slot bind — so it is a #7192 hole this change closes rather than one it opened. Fixed in the predicate, not at either call site: keeping it one predicate is what stops the two consumers diverging the way #7114's pair did. + - **The inline-constructor `this` slot (#7202)**. `lower_call/new.rs:1109` parks the instance in a plain `alloca_entry` for the whole inlined body, and every `this` read is a `load` from it. `force_ctor_call` requires `class.constructor.is_some()`, so `class C { payload = mk() }` and `class C extends B {}` take this path with `PERRY_INLINE_CTOR` unset — and `construction_runs_user_code`, which gates the `instance_root` #7192 added, is true for exactly those. **The function already asserts this window collects, then keeps a second copy of the same address one line later that nothing rewrites.** Binding it — rather than routing `Expr::This` through a temp root — leaves all ~30 `ctx.this_stack.last()` readers untouched, because they already `load` from the alloca and `js_shadow_slot_bind` records `slot_ptrs[idx] = alloca`, so evacuation rewrites it in place. #6968's machinery is generalized to `expr::scalar_slot_root::root_entry_alloca` for it, contract intact: seed `undefined` in `entry_allocas`, bind hoisted to entry setup, root barrier at the store. + The bind is gated on `construction_runs_user_code` — the same predicate that decides the instance needs a temp root — so a class with no constructor, no fields and no heritage keeps its previous IR exactly, frame size included. + **There is no runtime reproducer for this one, and the shipped test says so.** `test_gap_gc_inline_ctor_this_rooting.ts` is green at base: the class-field inline guard reads `obj_type`/`class_id`/`keys_array` off the stale pointer, the evacuation's forwarding record fails that guard, and the runtime fallback resolves forwarding — so today the stale write still lands correctly. That masking holds only while the from-space block still carries the forwarding record, which is exactly #7154's latency. The claim rests on the static argument and is gated two ways: a codegen regression test and a new checker mode. ### Added