Repository navigation
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
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 likejs_array_push_f64does 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 2The 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.
Activity
proggeramlug commentedon Aug 14, 2026
Cross-linking #8006, which reported this same divergence on 2026-08-13 from a full local gap run — same test, same
plain: 0 0 2vs99 101 2. It had the sighting but not the root cause; I have closed it as superseded so this stays the single home.Its one durable observation, worth keeping: the test is absent from
test-parity/gap_snapshot.json, so nothing was tracking it and nothing could go red on it. Adding it there is part of fixing this, otherwise the same blind spot returns after the miscompile is fixed.docs(8109): changelog fragment for the #8100 element-read receiver fix
proggeramlug commentedon Aug 14, 2026
Correction: the
gap_snapshot.jsonframing in this issue is invertedI repeated this issue's claim that the test being "absent from
test-parity/gap_snapshot.json, so nothing is tracking it and nothing can go red on it" was why the failure stayed invisible. That is backwards, and the snapshot's own schema says so verbatim:So absence is the tracking state. Adding an entry for
test_gap_specabi_reassignwould have declared it expected-to-fail — disarming the gate — and would then have failed the gate itself the moment the fix landed, as a listed test that now passes. Verified by runninggap_snapshot.py checkagainst a report where the test fails: it printsREGRESSIONS — test_gap_specabi_reassign: pass -> parity_failand exits 1. The gate was armed the whole time.The real reason nothing went red
The test is index 484 of the sorted set, and
483 % 8 == 3puts it in shard 4 ofconformance-smoke— and recent runs cancelled shard 4 early (run 31838286790's journal stops at 56 results). The failure was invisible because its shard never finished, not because the snapshot lacked an entry.That is worth connecting to #8092:
conformance-smokeis currently failing 8 of 8 shards on completed PRs, which is not the flake signature CLAUDE.md describes. A gate whose shards get cancelled or fail wholesale is not distinguishing anything, and this is a concrete case of a real miscompile hiding behind it for days.Credit where due: I briefed the fix agent to add the snapshot entry, and it declined and measured instead of complying. That was the right call.
fix(typedarray): dispatch, not just validate, when the element-read r…
fix(typedarray): dispatch, not drop, when a Uint8Array-specialized el…