Skip to content

Reassigning a new Int32Array binding to a plain array makes every element READ return 0 (silent miscompile; sole reason gc-stress is red) #8100

Description

@proggeramlug

test_gap_specabi_reassign has been red on every main nightly since 2026-08-10. It
is a silent wrong-answer miscompile in the shipped default configuration, and it is
the only remaining reason gc-stress is red — which is what blocks that gate's
promotion to a required context (see the linked follow-up).

Reproducer (5 lines)

let P: Int32Array = new Int32Array(1);
P[0] = 123;
console.log("ta:", P[0], P.length);
P = [99, 101] as any;
console.log("plain:", P[0], P[1], P.length);
perry:  ta: 123 1     plain: 0 0 2
node:   ta: 123 1     plain: 99 101 2      (v26.5.1, the .node-version pin)

Exit status is 0 on both sides. P.length is correct — the binding really does
hold the plain array. Only the ELEMENT READS are wrong, and they are wrong by
returning 0.

The Int32Array annotation is not load-bearing: let P: any = new Int32Array(1) and
let P = new Int32Array(1) both reproduce. The new Int32Array(...) initializer
is what stamps the binding, and the reassignment never invalidates it.

Reproduced at 0d7fe21b0, --profile perry-dev, --no-auto-optimize --no-cache,
PERRY_RUNTIME_DIR pinned to a freshly built perry-dev archive pair. CI reproduces
it at --release with auto-optimize on.

It is not a representation tier

Every repsel kill switch leaves it broken:

PERRY_SPECIALIZED_ABI=0        -> plain: 0 0 2
PERRY_PTR_NUMARRAY_LOCALS=0    -> plain: 0 0 2
PERRY_PTR_SHAPE_LOCALS=0       -> plain: 0 0 2
PERRY_INT_VALUED_LOCALS=0      -> plain: 0 0 2
PERRY_CANONICAL_I32_LOCALS=0   -> plain: 0 0 2

Which matches CI exactly: scripts/gc_repsel_matrix.sh fails this row in 22 of 22
arms, shipped_default included, while every other row is PASS or UNVER.

Root cause

--trace llvm on the reduction, min_ts.ll:3418 and :3441:

%r228 = call double @js_typed_array_get(i64 %r226, i32 %r227)

emitted unguarded for the plain-array reads.

That is deliberate, and documented. crates/perry-codegen/src/expr/index_get.rs:51-72
(#7494) explains why is_width_tracked_typed_array_receiver uses local_type_hint
which keeps the kind for a reassigned local — rather than receiver_class_name, which
drops it:

… or a genuinely dynamic runtime call (js_typed_array_set, js_typed_array_get,
js_typed_array_index_{get,set}_dynamic) that re-validates the object's actual GC
kind before touching memory
, exactly like js_array_push_f64 does for a non-array
receiver (#7574).

The helper re-validates. It does not dispatch.
crates/perry-runtime/src/typedarray/access.rs:28:

pub extern "C" fn js_typed_array_get(ta: *const TypedArrayHeader, index: i32) -> f64 {
    let ta = clean_ta_ptr(ta);
    if ta.is_null() {
        return 0.0;          // <- a plain array lands here
    }

clean_ta_ptr rejects the plain array, and the helper answers 0.0. Memory-safe,
semantically wrong. The codegen comment's premise is half true: validation happens,
fallback does not.

Scope: the two READ helpers, not the stores

path status
js_typed_array_get (access.rs:28), constant index broken — returns 0
js_typed_array_index_get_dynamic (access.rs:192), variable key broken — returns 0
element STORE after reassignment correct

The store is fine because codegen's store side consults buffer_view_slots, which
invalidates on assignment:

let P: Int32Array = new Int32Array(1);
P = [1, 2] as any;
P[0] = 99;
console.log(JSON.stringify(P));   // perry [99,2] == node [99,2]
console.log(P[0], P[1]);          // perry 0 0    != node 99 2

The write lands. Only the read back cannot see it.

Suggested fix — the exact mirror of #8090

#8090 (0d7fe21b0, landed 2026-08-14) fixed the other direction of this same family:
four Array.prototype mutators whose TypedArray delegation sat AFTER clean_arr_ptr
and was therefore unreachable. Its fix was array/header.rs:710 typed_array_receiver()
— ask the typed-array question from the RAW, possibly NaN-boxed argument before the
clean rejects it.

This needs the reverse: when clean_ta_ptr rejects, ask whether the raw address is an
ordinary array/object and delegate to the generic [[Get]]
(js_dyn_index_get, crates/perry-runtime/src/value/dyn_index.rs:151) instead of
returning 0.0. Every internal Rust caller of js_typed_array_get
(node_stream_readwrite.rs, array/search.rs, typedarray_props.rs,
native_arena.rs) passes an already-clean ta, so none of them reaches the new arm.

Alternatively the codegen side could stop trusting local_type_hint for a reassigned
local — but #7494's comment argues at length against that, because it is what sent a
real typed array through is_array_expr's plain-array layout (element 0 at byte 8
instead of the data region at byte 16). Fixing the helper keeps that reasoning intact.

Regression window

nightly test.yml run main sha matrix
31295652054 2026-08-09 a853135aa FAIL=0 (job red for an unrelated stale-registry liveness entry)
31358183869 2026-08-10 caaab6b98 specabi_reassign FAIL — first appearance
… through 31772993217 2026-08-14 601a02d23 still FAIL, 22/22 arms

49 commits in a853135aa..caaab6b98; not bisected (each hop is a full compiler +
runtime staticlib rebuild).

Why it went unnoticed

test_gap_specabi_reassign is not in test-parity/known_failures.json and has no
issue. The only CI job that runs it on main is gc-stress, which is not a required
context
— so it has reported this red for five days without blocking anything.
CLAUDE.md's "four ways a gate can be unable to fail", hazard 2.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugConfirmed defect or regression

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions