From c53965e02132f8125783ce4ae320a9c165031a56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 29 Jul 2026 12:34:08 +0200 Subject: [PATCH 1/2] fix(gc): typed-array constructor sources are precise roots (#6981) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `new Int32Array([7, 8])` silently produced a LENGTH-0 array under a relocating minor with precise roots: `a[0]` read `undefined`, no crash. That is the failure in #6981's minimal reproducer, `test_gap_specabi_reassign.ts`. The mechanism is NOT the spec-ABI `TaPtr` shortcut the issue analysis proposed. Measured: the reproducer emits no specialized entry at all (`nm` finds no spec symbol) and fails identically with `PERRY_SPECIALIZED_ABI=0`. The never-reassigned proof in `collectors/spec_abi_sites.rs` correctly rejects `P` — `GlobalSet` puts it in `ModuleScan::writes`, so `judge_arg` returns `Boxed` and the tuple is not viable. The real defect is one layer out and on the runtime side: a typed-array constructor SOURCE reaches `js_typed_array_new` only as a bare NaN-boxed C-ABI argument, which is not a precise root, and the helper allocates before it ever dereferences the source. Instrumented ordering shows the collection landing inside the source-classification chain (the `is_registered_map || is_registered_set || is_builtin_iterator_class_id || js_util_types_is_generator_object` step), after which `clean_arr_ptr` nulls the swept source and the constructor falls through to `typed_array_alloc(kind, 0)`. Fix: root the observed value in a `RuntimeHandleScope` and re-read it after every allocating step, in `js_typed_array_new`'s heap-source arm, `js_typed_array_new_from_array`, `typed_array_from_source_raw_values` and `typed_array_plain_object_values`. The handle is a SNAPSHOT of the argument, never the caller's binding, so re-reading recovers the post-move address without ever observing a later reassignment of the source variable — the distinction that matters for a reproducer whose whole point is `P = new Int32Array([7, 8])`. Also corrects the two comments that made the `TaPtr` shortcut look settled (`codegen/function.rs`, `codegen/spec_abi.rs`). Their conclusion holds but their stated reason was wrong: what is passed and hoisted through is the typed-array HEADER, which is an object. The address is stable because `typed_array_alloc` puts header + inline payload in the OLD arena with `GC_FLAG_TENURED` (never relocated by the nursery copying minor, skipped by old-page defrag since `gc_type_is_movable` is false), not because "typed-array storage is non-movable". Measured on the evacuating precise-roots arm (`PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off`), oracle = pinned Node 26.5.0: representation corpus 14 FAIL -> 12 FAIL test_gap_specabi_reassign FAIL -> PASS (9 670 copied) test_gap_specabi_polymorphic_coexist FAIL -> PASS (9 648 copied) Every cell above relocated thousands of objects, so the arm was live. The PR-gated arms (default, verify_evac, cons_scan_off, shipped_default) are 21/21 green. --- crates/perry-codegen/src/codegen/function.rs | 40 ++- crates/perry-codegen/src/codegen/spec_abi.rs | 12 +- .../perry-runtime/src/typedarray/construct.rs | 239 ++++++++++++------ .../test_gap_gc_ta_ctor_source_rooting.ts | 82 ++++++ test-parity/gc_repsel_corpus.txt | 11 + 5 files changed, 289 insertions(+), 95 deletions(-) create mode 100644 test-files/test_gap_gc_ta_ctor_source_rooting.ts diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 62a74ae71a..b0dab1814c 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -485,12 +485,29 @@ pub(super) fn compile_function( let boxed = crate::expr::nanbox_pointer_inline(blk, &arg_name); let slot = blk.alloca(DOUBLE); blk.store(DOUBLE, &boxed, &slot); - // No callee-side shadow binding: every route into this - // entry is a Tier-A call whose argument is a proven - // never-reassigned rooted binding (module-global root or - // caller-frame slot) that stays live for the whole call, - // and typed-array storage is non-movable — the callee - // root would be redundant TLS traffic on the hot path. + // No callee-side shadow binding. Both halves of that + // argument are load-bearing, and the second one is NOT + // "typed-array storage is non-movable" — the value passed + // is the HEADER, and a header is an object (#6981): + // + // 1. Liveness — every route into this entry is a Tier-A + // call (`lower_call/func_ref.rs`) whose argument is a + // pre-pass-proven, never-reassigned, non-closure- + // referenced binding: a module-global root, or the + // caller's own shadow-bound frame slot. That root + // keeps the header live for the whole call. + // 2. Address stability — the header does not MOVE, + // because `typed_array_alloc` puts the whole + // allocation (header + inline payload) in the OLD + // arena with `GC_FLAG_TENURED`. The nursery copying + // minor only relocates nursery objects, and old-page + // defrag is the one consumer of `gc_type_is_movable`, + // which is `false` for `GC_TYPE_TYPED_ARRAY`. + // + // Both together are what make the callee root redundant + // TLS traffic. Neither generalizes: an ordinary + // `GC_TYPE_OBJECT` IS movable and IS nursery-allocated, + // so any new raw-pointer rep must argue (2) afresh. map.insert(p.id, slot); continue; } @@ -901,9 +918,14 @@ pub(super) fn compile_function( // live through the CALLER's proven never-reassigned rooted binding (a // module-global root or the caller's own frame slot — the only routes into // this entry are Tier-A calls whose args carry that proof); the hoisted - // data pointer stays valid because typed-array storage is non-movable - // (`gc/types.rs`: `GC_TYPE_TYPED_ARRAY`/`GC_TYPE_BUFFER` `movable: false`) - // and a non-view typed array cannot be detached or resized. + // data pointer stays valid because the HEADER itself never moves — + // `typed_array_alloc` allocates header + inline payload in the OLD arena + // (`arena_alloc_gc_old`, `GC_FLAG_TENURED`), which the nursery copying + // minor never relocates, and old-page defrag skips it because + // `gc_type_is_movable(GC_TYPE_TYPED_ARRAY)` is `false`. Note the reason is + // header residency, not "storage is non-movable": the value in `%arg` is + // the header, and hoisting data+length reads THROUGH it (#6981). A + // non-view typed array also cannot be detached or resized. if let Some(plan) = spec_entry { for (p, rep) in f.params.iter().zip(plan.reps.iter()) { let crate::collectors::SpecParamRep::TaPtr { kind, const_len } = rep else { diff --git a/crates/perry-codegen/src/codegen/spec_abi.rs b/crates/perry-codegen/src/codegen/spec_abi.rs index 26767c5236..5d630372c8 100644 --- a/crates/perry-codegen/src/codegen/spec_abi.rs +++ b/crates/perry-codegen/src/codegen/spec_abi.rs @@ -10,8 +10,16 @@ //! The public boxed entry always exists and stays the permanent ABI. //! 2. **`TaPtr` params.** A proven typed-array param binds at entry as a //! proven `BufferViewSlot` (data pointer + length hoisted ONCE from the -//! header — sound because typed-array storage never moves and a non-view -//! typed array cannot be detached or resized), so element accesses lower +//! header). That hoist is sound because the typed-array HEADER itself never +//! moves — `typed_array_alloc` places header + inline payload in the OLD +//! arena (`GC_FLAG_TENURED`), which the nursery copying minor never +//! relocates and old-page defrag skips (`gc_type_is_movable` is `false` +//! for `GC_TYPE_TYPED_ARRAY`) — and because a non-view typed array cannot +//! be detached or resized. The older phrasing here ("typed-array storage +//! never moves") named the wrong object: what is passed and hoisted THROUGH +//! is the header, which is an object; only its old-arena residency makes +//! the address stable (#6981). Do not carry this shortcut to any other +//! representation without re-arguing it. So element accesses lower //! through the strong bare-load machinery with bounds checks against the //! entry-hoisted length — NEVER through the per-site guarded fast paths //! (measured to LOSE on unrolled bodies: 834 → 2732 ms). diff --git a/crates/perry-runtime/src/typedarray/construct.rs b/crates/perry-runtime/src/typedarray/construct.rs index a49b48e27a..cba70e3c7c 100644 --- a/crates/perry-runtime/src/typedarray/construct.rs +++ b/crates/perry-runtime/src/typedarray/construct.rs @@ -39,65 +39,16 @@ pub extern "C" fn js_typed_array_new(kind: i32, val: f64) -> *mut TypedArrayHead crate::collection_iter::throw_type_error("Cannot convert a Symbol value to a number"); } if top16 == 0x7FFD { - // POINTER_TAG — existing array pointer; copy its elements. - let arr = (bits & 0x0000_FFFF_FFFF_FFFF) as *const crate::array::ArrayHeader; - // Issue #654: a NaN-boxed pointer can also point at a registered - // typed array (e.g. when the source flowed through a path that - // re-applied POINTER_TAG). Detect via the registry and copy - // through `typed_array_to_typed_array` so element values stay - // numeric instead of being read as f64-NaN-boxed bits. - let raw_addr = (bits & 0x0000_FFFF_FFFF_FFFF) as usize; - if lookup_typed_array_kind(raw_addr).is_some() { - return typed_array_copy_from_typed_array( - kind as u8, - raw_addr as *const TypedArrayHeader, - ); - } - if crate::buffer::is_registered_buffer(raw_addr) { - if crate::buffer::is_any_array_buffer(raw_addr) { - let undefined = f64::from_bits(crate::value::TAG_UNDEFINED); - return crate::typedarray_view::js_typed_array_view( - kind, val, undefined, undefined, - ); - } - return bigint::copy_from_uint8_buffer( - kind as u8, - raw_addr as *const crate::buffer::BufferHeader, - ); - } - // A plain object that is neither a typed array nor a buffer is consumed - // per the spec's `new TypedArray(object)` path: if it exposes a - // *callable* `@@iterator` it is iterated (InitializeTypedArrayFromList); - // a non-callable non-nullish `@@iterator` is a TypeError; otherwise it - // is read as an array-like (`ToLength(Get(obj, "length"))` then each - // indexed element). Registered Maps/Sets keep the shared `Array.from` - // materialization (their `@@iterator` is native, not a stored symbol - // property). Functions are valid array-like/iterable sources too — - // previously they were reinterpreted as an `ArrayHeader` (crash). - if crate::map::is_registered_map(raw_addr) - || crate::set::is_registered_set(raw_addr) - || crate::array::is_builtin_iterator_class_id(raw_addr) - || crate::object::js_util_types_is_generator_object(val).to_bits() - == crate::value::TAG_TRUE - { - // Built-in iterables whose `@@iterator` is native (not a stored - // symbol property): Maps/Sets, builtin iterator objects, and - // generator objects (Perry generators carry own `next`/`return` - // closures and no `@@iterator` symbol prop). The shared - // `Array.from` materialization drives these correctly. - let materialized = crate::array::js_array_from_value(val); - return js_typed_array_new_from_array(kind, materialized); - } - if crate::closure::is_closure_ptr(raw_addr) { - return unsafe { typed_array_from_plain_object(kind as u8, val) }; - } - if raw_addr >= crate::gc::GC_HEADER_SIZE + 0x1000 { - let gc_hdr = (raw_addr - crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; - if unsafe { (*gc_hdr).obj_type } == crate::gc::GC_TYPE_OBJECT { - return unsafe { typed_array_from_plain_object(kind as u8, val) }; - } - } - return js_typed_array_new_from_array(kind, arr); + // POINTER_TAG — a heap source. `val` reaches this helper ONLY in a + // register / C-ABI stack slot, which is not a precise root, and the + // source-classification probes below allocate (#6981). Root the + // NaN-boxed value for the whole classification so the source survives + // a collection AND the collector rewrites the slot when it relocates. + // The handle holds the value the CALL observed, so re-reading it can + // never observe a later reassignment of the caller's binding. + let scope = crate::gc::RuntimeHandleScope::new(); + let rooted = scope.root_nanbox_f64(val); + return typed_array_new_from_heap_source(kind, &rooted); } if top16 == 0x7FFE { // INT32_TAG — lower 32 bits are the signed length. @@ -137,6 +88,92 @@ pub extern "C" fn js_typed_array_new(kind: i32, val: f64) -> *mut TypedArrayHead typed_array_alloc(kind as u8, len) } +/// The POINTER_TAG arm of [`js_typed_array_new`], with the source value held +/// in a runtime-handle root for the whole classification (#6981). +/// +/// **Every** step below re-reads the boxed value from `rooted` instead of +/// caching a raw address, because several of the probes allocate: +/// `js_util_types_is_generator_object` walks own properties, +/// `js_array_from_value` materializes, and the plain-object arm runs user +/// `@@iterator` / `valueOf` code. Before this, a collection landing in that +/// window left the classifier holding a pre-move (or already-swept) address — +/// `clean_arr_ptr` then nulled it and `new Int32Array([7, 8])` silently +/// produced a length-0 array. +fn typed_array_new_from_heap_source( + kind: i32, + rooted: &crate::gc::RuntimeHandle<'_>, +) -> *mut TypedArrayHeader { + // Re-read the (possibly rewritten) box; never cache the address across a + // potentially-allocating probe. + let cur = || { + let val = rooted.get_nanbox_f64(); + let addr = (val.to_bits() & 0x0000_FFFF_FFFF_FFFF) as usize; + (val, addr) + }; + + // Issue #654: a NaN-boxed pointer can also point at a registered + // typed array (e.g. when the source flowed through a path that + // re-applied POINTER_TAG). Detect via the registry and copy + // through `typed_array_to_typed_array` so element values stay + // numeric instead of being read as f64-NaN-boxed bits. + let (_, raw_addr) = cur(); + if lookup_typed_array_kind(raw_addr).is_some() { + return typed_array_copy_from_typed_array(kind as u8, raw_addr as *const TypedArrayHeader); + } + if crate::buffer::is_registered_buffer(raw_addr) { + if crate::buffer::is_any_array_buffer(raw_addr) { + let undefined = f64::from_bits(crate::value::TAG_UNDEFINED); + let (val, _) = cur(); + return crate::typedarray_view::js_typed_array_view(kind, val, undefined, undefined); + } + return bigint::copy_from_uint8_buffer( + kind as u8, + raw_addr as *const crate::buffer::BufferHeader, + ); + } + // A plain object that is neither a typed array nor a buffer is consumed + // per the spec's `new TypedArray(object)` path: if it exposes a + // *callable* `@@iterator` it is iterated (InitializeTypedArrayFromList); + // a non-callable non-nullish `@@iterator` is a TypeError; otherwise it + // is read as an array-like (`ToLength(Get(obj, "length"))` then each + // indexed element). Registered Maps/Sets keep the shared `Array.from` + // materialization (their `@@iterator` is native, not a stored symbol + // property). Functions are valid array-like/iterable sources too — + // previously they were reinterpreted as an `ArrayHeader` (crash). + let (val, raw_addr) = cur(); + if crate::map::is_registered_map(raw_addr) + || crate::set::is_registered_set(raw_addr) + || crate::array::is_builtin_iterator_class_id(raw_addr) + || crate::object::js_util_types_is_generator_object(val).to_bits() == crate::value::TAG_TRUE + { + // Built-in iterables whose `@@iterator` is native (not a stored + // symbol property): Maps/Sets, builtin iterator objects, and + // generator objects (Perry generators carry own `next`/`return` + // closures and no `@@iterator` symbol prop). The shared + // `Array.from` materialization drives these correctly. + let (val, _) = cur(); + let materialized = crate::array::js_array_from_value(val); + return js_typed_array_new_from_array(kind, materialized); + } + // `js_util_types_is_generator_object` above walks own properties and can + // collect, so re-derive before every remaining dereference. + let (_, raw_addr) = cur(); + if crate::closure::is_closure_ptr(raw_addr) { + let (val, _) = cur(); + return unsafe { typed_array_from_plain_object(kind as u8, val) }; + } + let (_, raw_addr) = cur(); + if raw_addr >= crate::gc::GC_HEADER_SIZE + 0x1000 { + let gc_hdr = (raw_addr - crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; + if unsafe { (*gc_hdr).obj_type } == crate::gc::GC_TYPE_OBJECT { + let (val, _) = cur(); + return unsafe { typed_array_from_plain_object(kind as u8, val) }; + } + } + let (_, raw_addr) = cur(); + js_typed_array_new_from_array(kind, raw_addr as *const crate::array::ArrayHeader) +} + /// `new TA(object)` for a plain object / function source (ES2024 §23.2.5.1 /// step 6.b.iii, InitializeTypedArrayFromList / InitializeTypedArrayFromArrayLike). /// @@ -161,30 +198,45 @@ unsafe fn typed_array_from_plain_object(kind: u8, val: f64) -> *mut TypedArrayHe /// validation/iteration, the `ToLength(Get(obj, "length"))` coercion, and /// each indexed `Get` all run here and may throw. unsafe fn typed_array_plain_object_values(val: f64) -> Vec { + // #6981: this arm runs USER code (`@@iterator`, `length`/index getters), + // so `val` must survive and follow relocation across every step. Root the + // observed value; the handle is a snapshot, never the caller's binding. + let scope = crate::gc::RuntimeHandleScope::new(); + let rooted = scope.root_nanbox_f64(val); let undefined = f64::from_bits(crate::value::TAG_UNDEFINED); let iter_wk = crate::symbol::well_known_symbol("iterator"); let using_iter = if iter_wk.is_null() { undefined } else { let sym = f64::from_bits(crate::value::JSValue::pointer(iter_wk as *const u8).bits()); - crate::symbol::js_object_get_symbol_property(val, sym) + crate::symbol::js_object_get_symbol_property(rooted.get_nanbox_f64(), sym) }; let ub = using_iter.to_bits(); if ub != crate::value::TAG_UNDEFINED && ub != crate::value::TAG_NULL { - let fn_raw = crate::value::js_nanbox_get_pointer(using_iter) as usize; + let using_iter_rooted = scope.root_nanbox_f64(using_iter); + let fn_raw = + crate::value::js_nanbox_get_pointer(using_iter_rooted.get_nanbox_f64()) as usize; if fn_raw < 0x10000 || !crate::closure::is_closure_ptr(fn_raw) { throw_type_error(b"object is not iterable"); } - let bound = crate::closure::clone_closure_rebind_this(using_iter.to_bits(), val); - let iter = crate::closure::js_native_call_value(f64::from_bits(bound), ptr::null(), 0); + let bound = crate::closure::clone_closure_rebind_this( + using_iter_rooted.get_nanbox_f64().to_bits(), + rooted.get_nanbox_f64(), + ); + let bound_rooted = scope.root_nanbox_u64(bound); + let iter = + crate::closure::js_native_call_value(bound_rooted.get_nanbox_f64(), ptr::null(), 0); + let iter_rooted = scope.root_nanbox_f64(iter); let mut raw: Vec = Vec::new(); - while let Some(v) = crate::collection_iter::iterator_next_value(iter) { + while let Some(v) = + crate::collection_iter::iterator_next_value(iter_rooted.get_nanbox_f64()) + { raw.push(v); } return raw; } // Array-like path. - let len_val = object_like_get(val, "length"); + let len_val = object_like_get(rooted.get_nanbox_f64(), "length"); let n = jsvalue_to_f64(len_val); // ToLength: NaN / negative → 0, clamp to 2^53-1. let len = if n.is_nan() || n <= 0.0 { @@ -200,7 +252,7 @@ unsafe fn typed_array_plain_object_values(val: f64) -> Vec { let len = len as u32; let mut raw: Vec = Vec::with_capacity(len as usize); for k in 0..len { - raw.push(object_like_get(val, &k.to_string())); + raw.push(object_like_get(rooted.get_nanbox_f64(), &k.to_string())); } raw } @@ -212,33 +264,43 @@ unsafe fn typed_array_plain_object_values(val: f64) -> Vec { /// Maps, Sets, iterators, generators, buffers) goes through the shared /// `Array.from` materialization. pub(crate) unsafe fn typed_array_from_source_raw_values(val: f64) -> Vec { - let bits = val.to_bits(); - if (bits >> 48) == 0x7FFD { - let raw_addr = (bits & 0x0000_FFFF_FFFF_FFFF) as usize; - let special = crate::map::is_registered_map(raw_addr) - || crate::set::is_registered_set(raw_addr) - || crate::array::is_builtin_iterator_class_id(raw_addr) - || crate::object::js_util_types_is_generator_object(val).to_bits() + // #6981: `val` and the materialized `arr` below are register-only values. + // The classification probes (`js_util_types_is_generator_object` walks own + // properties) and `js_array_from_value` both allocate, so under precise + // roots a collection in that window sweeps or relocates the source. Root + // the value the call observed and re-read it after each allocating step. + let scope = crate::gc::RuntimeHandleScope::new(); + let rooted = scope.root_nanbox_f64(val); + let cur_addr = || (rooted.get_nanbox_f64().to_bits() & 0x0000_FFFF_FFFF_FFFF) as usize; + if (val.to_bits() >> 48) == 0x7FFD { + let special = crate::map::is_registered_map(cur_addr()) + || crate::set::is_registered_set(cur_addr()) + || crate::array::is_builtin_iterator_class_id(cur_addr()) + || crate::object::js_util_types_is_generator_object(rooted.get_nanbox_f64()).to_bits() == crate::value::TAG_TRUE - || lookup_typed_array_kind(raw_addr).is_some() - || crate::buffer::is_registered_buffer(raw_addr) - || crate::symbol::js_is_symbol(val) != 0; + || lookup_typed_array_kind(cur_addr()).is_some() + || crate::buffer::is_registered_buffer(cur_addr()) + || crate::symbol::js_is_symbol(rooted.get_nanbox_f64()) != 0; if !special { - if crate::closure::is_closure_ptr(raw_addr) { - return typed_array_plain_object_values(val); + if crate::closure::is_closure_ptr(cur_addr()) { + return typed_array_plain_object_values(rooted.get_nanbox_f64()); } + let raw_addr = cur_addr(); if raw_addr >= crate::gc::GC_HEADER_SIZE + 0x1000 { let gc_hdr = (raw_addr - crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; if (*gc_hdr).obj_type == crate::gc::GC_TYPE_OBJECT { - return typed_array_plain_object_values(val); + return typed_array_plain_object_values(rooted.get_nanbox_f64()); } } } } - let arr = crate::array::js_array_from_value(val); - let len = crate::array::js_array_length(arr); + let arr = crate::array::js_array_from_value(rooted.get_nanbox_f64()); + // `js_array_from_value` allocated; re-root on the RESULT, which is the + // value the element reads below must observe. + let arr_rooted = scope.root_raw_const_ptr(arr); + let len = crate::array::js_array_length(arr_rooted.get_raw_const_ptr()); (0..len) - .map(|i| crate::array::js_array_get_f64(arr, i)) + .map(|i| crate::array::js_array_get_f64(arr_rooted.get_raw_const_ptr(), i)) .collect() } @@ -332,10 +394,19 @@ pub extern "C" fn js_typed_array_new_from_array( // `new Float32Array(arr)` saw a garbage element count while // `arr.length` and indexed reads (which follow the chain) stayed // correct. It also validates the header and materializes lazy arrays. - let arr = crate::array::clean_arr_ptr(arr); + // #6981: `arr` is a raw pointer in a register — not a precise root. Both + // `clean_arr_ptr` (lazy-array materialization) and the element reads below + // can allocate, and a collection there would sweep or relocate the source + // out from under this pointer. Root the SNAPSHOT of the incoming value so + // the collector keeps it alive and rewrites the slot on relocation, then + // re-read the (possibly moved) address after each allocating step. + let scope = crate::gc::RuntimeHandleScope::new(); + let rooted = scope.root_raw_const_ptr(arr); + let arr = crate::array::clean_arr_ptr(rooted.get_raw_const_ptr::()); if arr.is_null() { return typed_array_alloc(kind, 0); } + rooted.set_raw_const_ptr(arr); unsafe { let len = (*arr).length; // Snapshot the raw source values BEFORE any coercion. Per spec the @@ -347,7 +418,7 @@ pub extern "C" fn js_typed_array_new_from_array( // raw values first also keeps the snapshot ahead of the `typed_array_alloc` // GC point (#871). let raw: Vec = (0..len) - .map(|i| crate::array::js_array_get_f64(arr, i)) + .map(|i| crate::array::js_array_get_f64(rooted.get_raw_const_ptr::(), i)) .collect(); let vals: Vec = raw .into_iter() diff --git a/test-files/test_gap_gc_ta_ctor_source_rooting.ts b/test-files/test_gap_gc_ta_ctor_source_rooting.ts new file mode 100644 index 0000000000..e3d409f3c9 --- /dev/null +++ b/test-files/test_gap_gc_ta_ctor_source_rooting.ts @@ -0,0 +1,82 @@ +// GC rooting for typed-array CONSTRUCTOR SOURCES (#6981). +// +// `new Int32Array(src)` / `Int32Array.from(src)` hand the source to a runtime +// helper as a bare NaN-boxed argument. That argument lives only in a register / +// C-ABI stack slot, which is NOT a precise root, and the helper's own +// source-classification probes allocate before it ever dereferences the +// source (measured: the collection lands inside +// `js_util_types_is_generator_object`, which walks own properties). Under +// `PERRY_CONSERVATIVE_STACK_SCAN=off` a collection there swept the source and +// `clean_arr_ptr` nulled it, so `new Int32Array([7, 8])` silently produced a +// LENGTH-0 array — `a[0]` read `undefined` with no crash. +// +// Every construction below must survive a collection landing between the +// helper's entry and its first read of the source. Run under the evacuating +// precise-roots arm to gate it: +// PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off + +function first(a: any) { + return a[0]; +} + +// 1. Array-literal temporary: the source has NO named binding at all, so the +// C-ABI argument is its only reference for the whole classification. +const fromLiteral = new Int32Array([7, 8]); +console.log("literal:", fromLiteral.length, first(fromLiteral), fromLiteral[1]); + +// 2. Reassigned binding (the #6981 minimal reproducer shape). The binding is +// a root, but the value the CALL observed is what must survive. +let P = new Int32Array(4); +P[0] = 42; +console.log("before:", first(P)); +P = new Int32Array([7, 8]); +console.log("after:", first(P), P.length); + +// 3. Named plain-array source through a rooted binding. +const src = [11, 22, 33]; +const fromNamed = new Int32Array(src); +console.log("named:", fromNamed.length, fromNamed[0], fromNamed[2], src.length); + +// 4. `%TypedArray%.from` over a literal, one per width. +const f8 = Int8Array.from([-5, 100, -128, 7]); +const f16 = Int16Array.from([-5, 30000, -32768, 7]); +const f32 = Float32Array.from([1.5, -2.5, 3.5]); +const f64 = Float64Array.from([1.5, 2.25, -3.75]); +console.log("from:", f8.length, f8[2], f16[1], f32[0], f64[2]); + +// 5. Array-like object source: runs the `length` / index Get path, which +// allocates key strings while the source must stay live. +const arrayLike = { length: 3, 0: 5, 1: 6, 2: 7 }; +const fromArrayLike = new Int32Array(arrayLike as any); +console.log("arraylike:", fromArrayLike.length, fromArrayLike[0], fromArrayLike[2]); + +// 6. Iterable object source: runs USER code (`@@iterator`) with the source +// live across every `next()`. +const iterable = { + *[Symbol.iterator]() { + yield 3; + yield 1; + yield 4; + yield 1; + }, +}; +const fromIterable = new Int32Array(iterable as any); +console.log("iterable:", fromIterable.length, fromIterable[0], fromIterable[3]); + +// 7. Typed-array source (copy construction). +const fromTa = new Int32Array(fromIterable); +console.log("ta:", fromTa.length, fromTa[2]); + +// 8. Sustained churn: build the same shapes repeatedly so a collection lands +// inside the classification window many times over, not just once. +let acc = 0; +for (let i = 0; i < 400; i++) { + const t = new Int32Array([i, i + 1, i + 2]); + acc = (acc + t[0] + t[2] + t.length) | 0; + const g = Uint16Array.from([i & 0xff, (i + 5) & 0xff]); + acc = (acc + g[1]) | 0; + const h = new Float64Array([i * 0.5, i * 1.5]); + acc = (acc + h[1]) | 0; +} +console.log("churn:", acc); +console.log("still:", first(fromLiteral), fromNamed[1], f64[0], fromIterable[1]); diff --git a/test-parity/gc_repsel_corpus.txt b/test-parity/gc_repsel_corpus.txt index 919d548ee5..a8d9d04ed4 100644 --- a/test-parity/gc_repsel_corpus.txt +++ b/test-parity/gc_repsel_corpus.txt @@ -48,6 +48,17 @@ test_gap_repsel_p4b_field_store_elision # was failing on main for every PR until this line landed. test_gap_repsel_proven_this_frozen +# --- Typed-array constructor source rooting (#6981) -------------------------- +# Not a representation file, so the UNREGISTERED gate does not auto-detect it: +# registered explicitly per the header rule above. Gates the runtime-side half +# of #6981 -- a typed-array constructor SOURCE is only a C-ABI argument, and the +# classification/materialization inside the helper allocates. Verified to FAIL +# on the `evac_minor` arm before the fix (`literal: 0 undefined undefined`, +# 9 675 objects copied) and pass after. It is green on the PR arm set, because +# `cons_scan_off` alone does not reach the evacuating minor -- it starts gating +# per-PR when `evac_minor` joins PR_ARMS. +test_gap_gc_ta_ctor_source_rooting + # --- The GC-live member ------------------------------------------------------ # Every file above performs ZERO collections (measured, #6950), which makes the # GC arms inert against them. This one holds each representation's local live From 2b8758d064a40556c86faa13c8a446940acbb474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 29 Jul 2026 12:35:34 +0200 Subject: [PATCH 2/2] changelog: fragment for #6990 --- .../6990-typed-array-ctor-source-rooting.md | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 changelog.d/6990-typed-array-ctor-source-rooting.md diff --git a/changelog.d/6990-typed-array-ctor-source-rooting.md b/changelog.d/6990-typed-array-ctor-source-rooting.md new file mode 100644 index 0000000000..a65eb1693d --- /dev/null +++ b/changelog.d/6990-typed-array-ctor-source-rooting.md @@ -0,0 +1,60 @@ +### Fixed + +- **GC: a typed-array constructor source is now a precise root (#6981).** + `new Int32Array([7, 8])` silently produced a **length-0** array under a + relocating minor with precise roots — `a[0]` read `undefined`, no crash. + That is the failure in #6981's minimal reproducer, + `test_gap_specabi_reassign.ts`, and it is not what the issue's analysis + proposed. + + **It is not the spec-ABI `TaPtr` shortcut.** The reproducer emits no + specialized entry at all (`nm` finds no spec symbol) and fails identically + with `PERRY_SPECIALIZED_ABI=0`. The never-reassigned proof in + `collectors/spec_abi_sites.rs` is correct *and* is consulted on this route: + `P = new Int32Array([7, 8])` is a `GlobalSet`, so `P` lands in + `ModuleScan::writes`, never enters `ta_bindings`, and `judge_arg` returns + `Boxed`. The test's own header comment says as much — it is a *negative* + test for the spec-ABI that happened to be failing for an unrelated reason. + + **Root cause, one layer out and on the runtime side.** A constructor source + reaches `js_typed_array_new` only as a bare NaN-boxed C-ABI argument, which + is not a precise root, and the helper allocates before it ever dereferences + the source. Instrumented ordering puts the collection inside the + source-classification chain (`is_registered_map || is_registered_set || + is_builtin_iterator_class_id || js_util_types_is_generator_object`); + `clean_arr_ptr` then nulls the swept source and the constructor falls + through to `typed_array_alloc(kind, 0)`. + + The fix roots the observed value in a `RuntimeHandleScope` and re-reads it + after every allocating step, across `js_typed_array_new`'s heap-source arm, + `js_typed_array_new_from_array`, `typed_array_from_source_raw_values` and + `typed_array_plain_object_values`. The handle is a **snapshot of the + argument**, never the caller's binding — re-deriving from `P`'s slot after a + safepoint would hand the constructor the *new* array and convert a + stale-pointer bug into a silent wrong-answer one. + + Measured on the evacuating precise-roots arm (`PERRY_GC_HEAP_LIMIT=8 + PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off`, oracle = pinned + Node 26.5.0): the representation corpus goes from **14 red to 12**, with + `test_gap_specabi_reassign` (9 670 objects copied) and + `test_gap_specabi_polymorphic_coexist` (9 648 copied) repaired. Both cells + relocated thousands of objects, so the arm was live rather than inert. The + PR-gated arms are 21/21 green. + + New gate: `test-files/test_gap_gc_ta_ctor_source_rooting.ts`, registered in + `test-parity/gc_repsel_corpus.txt`, verified to **fail** on the unfixed + build (`literal: 0 undefined undefined`, 9 675 objects copied) and pass + after. + +### Changed + +- **Docs: the `TaPtr` spec-ABI no-shadow-bind comments now state the real + invariant** (`codegen/function.rs`, `codegen/spec_abi.rs`). Their conclusion + holds, but the stated reason — "typed-array storage is non-movable" — named + the wrong object: what is passed and hoisted through is the typed-array + *header*, which is an object. The address is stable because + `typed_array_alloc` places header + inline payload in the OLD arena with + `GC_FLAG_TENURED`, which the nursery copying minor never relocates and + old-page defrag skips (`gc_type_is_movable(GC_TYPE_TYPED_ARRAY)` is + `false`). Both comments now also say explicitly that this does not + generalize to any other raw-pointer representation.