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.
test_gap_specabi_reassignhas been red on everymainnightly since 2026-08-10. Itis a silent wrong-answer miscompile in the shipped default configuration, and it is
the only remaining reason
gc-stressis red — which is what blocks that gate'spromotion to a required context (see the linked follow-up).
Reproducer (5 lines)
Exit status is 0 on both sides.
P.lengthis correct — the binding really doeshold the plain array. Only the ELEMENT READS are wrong, and they are wrong by
returning
0.The
Int32Arrayannotation is not load-bearing:let P: any = new Int32Array(1)andlet P = new Int32Array(1)both reproduce. Thenew Int32Array(...)initializeris what stamps the binding, and the reassignment never invalidates it.
Reproduced at
0d7fe21b0,--profile perry-dev,--no-auto-optimize --no-cache,PERRY_RUNTIME_DIRpinned to a freshly builtperry-devarchive pair. CI reproducesit at
--releasewith auto-optimize on.It is not a representation tier
Every repsel kill switch leaves it broken:
Which matches CI exactly:
scripts/gc_repsel_matrix.shfails this row in 22 of 22arms,
shipped_defaultincluded, while every other row is PASS or UNVER.Root cause
--trace llvmon the reduction,min_ts.ll:3418and:3441: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_receiveruseslocal_type_hint—which keeps the kind for a reassigned local — rather than
receiver_class_name, whichdrops it:
The helper re-validates. It does not dispatch.
crates/perry-runtime/src/typedarray/access.rs:28:clean_ta_ptrrejects the plain array, and the helper answers0.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
js_typed_array_get(access.rs:28), constant index0js_typed_array_index_get_dynamic(access.rs:192), variable key0The store is fine because codegen's store side consults
buffer_view_slots, whichinvalidates on assignment:
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.prototypemutators whose TypedArray delegation sat AFTERclean_arr_ptrand 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_ptrrejects, ask whether the raw address is anordinary array/object and delegate to the generic
[[Get]](
js_dyn_index_get,crates/perry-runtime/src/value/dyn_index.rs:151) instead ofreturning
0.0. Every internal Rust caller ofjs_typed_array_get(
node_stream_readwrite.rs,array/search.rs,typedarray_props.rs,native_arena.rs) passes an already-cleanta, so none of them reaches the new arm.Alternatively the codegen side could stop trusting
local_type_hintfor a reassignedlocal — 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 8instead of the data region at byte 16). Fixing the helper keeps that reasoning intact.
Regression window
test.ymlrunmainsha312956520542026-08-09a853135aaFAIL=0(job red for an unrelated stale-registry liveness entry)313581838692026-08-10caaab6b98specabi_reassignFAIL — first appearance317729932172026-08-14601a02d2349 commits in
a853135aa..caaab6b98; not bisected (each hop is a full compiler +runtime staticlib rebuild).
Why it went unnoticed
test_gap_specabi_reassignis not intest-parity/known_failures.jsonand has noissue. The only CI job that runs it on
mainisgc-stress, which is not a requiredcontext — 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.