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

Closed

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.

Activity

  1. proggeramlug commented on Aug 14, 2026

    @proggeramlug
    ContributorAuthor

    Cross-linking #8006, which reported this same divergence on 2026-08-13 from a full local gap run — same test, same plain: 0 0 2 vs 99 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.

  2. added a commit that references this issue on Aug 14, 2026
  3. proggeramlug commented on Aug 14, 2026

    @proggeramlug
    ContributorAuthor

    Correction: the gap_snapshot.json framing in this issue is inverted

    I 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:

    Lists every gap test that is NOT passing; a test absent from tests is expected to pass. CI fails on any divergence in EITHER direction (new failure, or a listed test that now passes).

    So absence is the tracking state. Adding an entry for test_gap_specabi_reassign would 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 running gap_snapshot.py check against a report where the test fails: it prints REGRESSIONS — test_gap_specabi_reassign: pass -> parity_fail and 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 == 3 puts it in shard 4 of conformance-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-smoke is 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.

  4. added a commit that references this issue on Aug 14, 2026
    a997324
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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