Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions changelog.d/6990-typed-array-ctor-source-rooting.md
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 31 additions & 9 deletions crates/perry-codegen/src/codegen/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 10 additions & 2 deletions crates/perry-codegen/src/codegen/spec_abi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading
Loading