Skip to content

fix(runtime): an Array subclass in a base-typed binding was read as a raw header (#7574) - #7603

Merged
proggeramlug merged 2 commits into
mainfrom
fix/7574-array-subclass-raw-paths
Aug 7, 2026
Merged

fix(runtime): an Array subclass in a base-typed binding was read as a raw header (#7574)#7603
proggeramlug merged 2 commits into
mainfrom
fix/7574-array-subclass-raw-paths

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #7574.

The bug

class MyArr<T> extends Array<T> {}

const a: number[] = new MyArr<number>();
a.push(1);
a.push(2);          // <-- SIGSEGV, exit 139
console.log(a.length, a[0]);

Reproduced on main: exit 139, one line of (already wrong) output. The
fault address is 0x3ff0000000000000 — the IEEE bit pattern of the double
1.0, i.e. the value the first push stored, later dereferenced as a pointer.

Sibling of #7570 (fixed by #7573) on a different family, and the same premise:
a declared TypeScript type is a hint, never a layout fact (CLAUDE.md,
Known Limitations). All five binding forms are affected — const, parameter,
class field, return type, and as number[] cast.

Verified root cause

An Array-subclass instance is a plain ObjectHeader
(array/subclass.rs:1-7; js_array_subclass_init installs a length own
property and keeps the elements as ordinary indexed object properties — there
is no ArrayHeader backing). ObjectHeader and ArrayHeader overlay field
for field:

ArrayHeader offset actually reads on an ObjectHeader
length: u32 0 object_type (= 1)
capacity: u32 4 class_id
elements[0] 8 parent_class_idfield_count
elements[1] 16 keys_array (*mut ArrayHeader)
elements[2] 24 meta (*mut ObjectMeta)

1 <= class_id sails through clean_arr_ptr's length <= capacity <= 100M
sanity check, so the forged header is accepted. a.push(1) stores 1.0 at
handle + 8 + length*8 = handle + 16over keys_array, a live GC child
edge
— and writes length + 1 over object_type. The second push then walks
that forged pointer and faults. Element reads are the same hazard in the other
direction: they hand keys_array / meta out to user code as doubles.

The tiers, checked on current main

The issue named three; the sweep found two more, and one of its three turned
out to be a false positive:

tier status on main
expr/array_push.rs:301 — the inline Expr::ArrayPush store (fwd-flag test only, then raw slot store + length bump) UNSAFE — this is the SIGSEGV. Not named in the issue.
expr/index_get.rs:531 lower_bounded_array_index_get (tests GC_TYPE_LAZY_ARRAY, GC_FLAG_FORWARDED, OBJ_FLAG_ARRAY_DESCRIPTORS, then raw gep+load double) UNSAFE, as filed
array/header.rs:511 clean_arr_ptr — the funnel behind ~190 js_array_* entries UNSAFE: no obj_type test, only length/capacity sanity
typed_feedback.rs:2413 js_typed_feedback_array_index_set_fallback_boxed safe, but no Array-exotic length step. Not named in the issue.
expr/property_get.rs:236 — inline arr.length already brand-checks (obj_type == 1 || == 3, then js_value_length_f64, whose GC_TYPE_OBJECT arm reads the own length). The issue's claim that it is "a bare safe_load_i32_from_ptr" does not hold on current main.
expr/index.rs:79 lower_index_set_fast inline tier, and both typed-feedback guards already test obj_type == GC_TYPE_ARRAY
expr/ptr_numarray_access.rs guard-free Ptr<NumArray> get/set unreachable: the proof requires the local be initialized by exactly one new Array(<static n>) or [], which new MyArr() is not
expr/index_get/guarded_array.rs:96 already correct (the model)

Fix

Runtime funnel first, for memory safety. clean_arr_ptr now refuses a
GC_TYPE_OBJECT / GC_TYPE_CLOSURE allocation, so all ~190 call sites become
fail-closed at once
— each degrades through its existing null branch instead
of dereferencing a forged header. It reuses the obj_type byte the surrounding
block already loads for the forwarding/lazy checks, so a genuine array pays one
extra compare; the registry probes that rule out header-less buffers/typed
arrays are in the cold arm only. Same "resolve at the shared runtime funnel, not
at one codegen predicate at a time" shape as #7573.

Then correctness, at the entry points the declared-type tiers actually reach.
Unlike Map/Set there is nothing to redirect to — an Array subclass has no
hidden backing. But perry already has a complete spec-generic array-like engine
(array/generic.rs, generic_object.rs) that operates on exactly this
representation and is the path the unannotated form has always used through
js_native_call_method. So the entry points re-enter through their existing
null branch and run the operation there.

Per-entry-point inventory (everything reachable from the three tiers):

entry point disposition
js_array_push_f64 resolverun_object_mutator(recv, "push"), returns the ORIGINAL receiver so codegen's realloc write-back keeps the binding (returning a fresh empty array is what made the push look silently dropped)
js_array_set_f64_extend (…_strict) resolve → object [[Set]] + Array-exotic length
js_array_get_f64, js_array_get_f64_unchecked resolveal_get
js_array_set_length_strict resolve → Array-exotic Set(O,"length",n,true) (deletes truncated indices)
js_array_pop_f64, js_array_shift_f64 already funnel (plain_object_value → generic engine)
js_array_length already funnels (its GC_TYPE_OBJECT arm reads the own length)
js_array_map/filter/forEach/some/every/find*/reduce*/join/slice/indexOf/lastIndexOf/includes/at (21 sites) already funnel via normalize_array_receiver, which materializes an array-like object into a dense snapshot
js_array_forEach funnels, plus the receiver override — see below
js_array_concat_variadic already funnels (obj_type re-dispatch + append_concat_arg's subclass snapshot arm); its #6386 dense bulk path needed a guard — see below
js_array_clone_for_spread, js_get_iterator, for…of already funnel (array_from_spread_value / symbol/iterator.rs subclass arms)
js_array_numeric_*, js_array_is_numeric_f64_layout, js_template_raw unreachable / benign: null now means "not a numeric-layout array", which is the correct answer
the guard-free Ptr<NumArray> tiers unreachable (provenance proof excludes new MyArr())

Three sites needed more than the funnel, all analogues of #7573's extra four:

  • js_array_forEach's 3rd argument. normalize_array_receiver hands the
    loop a dense snapshot, so the callback saw the snapshot and self === sub
    was false. It now passes the original receiver through, gated on a one-load
    GC_TYPE_OBJECT header test (addr_class::try_read_gc_header) so a genuine
    array never enters the registry probes, and rooted across the callbacks.
  • length is written, not just read. sub[3] = v on a real Array runs the
    exotic [[DefineOwnProperty]] and leaves length == 4; a plain object's does
    not. Pre-fix sub[0] = 10; sub.length read back 0on the unannotated
    path too
    — which then made the next sub.push(v) append at index 0 and
    overwrite the element. The step is applied after the store at the three
    generic funnels a subclass index-write can reach (js_put_value_set,
    js_object_set_index_polymorphic,
    js_typed_feedback_array_index_set_fallback_boxed), gated on the class chain
    reaching Array so an object literal short-circuits on class_id == 0.
    sub.length = n likewise routes to the exotic setter, which deletes the
    truncated indices.
  • clean_arr_ptr returning null now MEANS something new at sites that read
    null as "an empty array". concat's [perf] DataView accessors, Array.concat, and regex match-with-groups are 4–30x slower than Hermes #6386 all-dense bulk path did exactly
    that: peek_plain_array_len / dense_concat_array_source answered
    Some(0), the bulk path claimed the copy, and the spec-shaped
    append_concat_arg flow (which has the subclass snapshot arm) never ran —
    [1,2].concat(sub) yielded 1,2. This was a regression my own funnel
    introduced, caught by the family A/B below, not by the new test.
    Both now
    classify the receiver before the null shortcut and answer None
    ("un-peekable"), restoring the pre-fix routing. I swept the other
    null-means-empty sites (js_template_raw,
    js_array_{mark,is}_numeric_f64_layout, js_array_numeric_set_f64_unboxed);
    for those, null is the correct answer.

Two codegen guards are unavoidable. Unlike #7573, two of the three tiers
emit no runtime call at all — they are inline LLVM IR — so no runtime funnel
can reach them. Both now test obj_type == GC_TYPE_ARRAY and route a miss to
the slow call they already had (which then resolves through the funnel above).
Both are strictly more restrictive than what they replaced, so no receiver
that used to take the slow path now takes the fast one:

  • lower_bounded_array_index_get: icmp eq gc_type, 9 (GC_TYPE_LAZY_ARRAY)
    icmp ne gc_type, 1. Lazy arrays are 9, so the new test subsumes the old
    one — and it is one instruction cheaper (an icmp replaces icmp + or).
  • inline Expr::ArrayPush: the existing gc_flags & FORWARDED predicate gains
    || obj_type != GC_TYPE_ARRAY; a miss takes the apush.fwd arm, which is
    already a js_array_push_f64 call.

Why not give an Array subclass a hidden backing the way #7573 did for
Map/Set: its elements are its own indexed properties, and Object.keys /
for…in / JSON.stringify / the whole generic engine read them there. A second
storage would have to be kept in sync with every one of those paths — a far
larger and riskier change than making the raw entries resolve.

Validation (local; CI has a deep backlog, so local is what this rests on)

  • test-files/test_gap_7574_array_subclass_declared_base_type.ts
    byte-identical to node --experimental-strip-types (v26.5.1), exit 0
    , 39
    lines. Covers all five binding forms × index get/set, .length read AND
    write (including truncation), push/pop/shift, the bounded-index loop tier,
    for…of/spread/Array.from/destructuring, map/filter/slice/join/
    indexOf/includes/reduce, forEach receiver identity, an indirect
    subclass, a subclass with its own constructor and fields, and non-subclass
    controls (a real array in the same forms, and a plain object merely
    annotated number[]).
  • Sabotage, both directions, on a cleared object cache. With
    crates/perry-runtime/ and crates/perry-codegen/ reverted in full to
    main and the test file untouched, the same file exits 139 after one
    line of output (push1 0; node prints push1 1). With both restored it exits
    0, byte-identical. Recorded separately: the runtime half alone does not
    stop the crash — the inline push tier never calls into the runtime — which is
    why the two codegen guards are in this PR.
  • Fast path proven still live. On a plain-array-only program the emitted
    LLVM IR is byte-identical apart from the guard predicate: after
    normalizing SSA numbering, probe/plain.ts differs by 15 lines (3 push
    sites × the 4 new instructions + the or), and probe/plain2.ts — which
    reaches the bounded-index tier — by exactly 3 lines, one icmp eq …, 9
    icmp ne …, 1 per site, with identical instruction counts (3970/3970). The
    apush.inbounds and bidx.fast blocks and their raw slot loads are
    unchanged, and both programs produce identical output on both arms. The unit
    test a_genuine_array_takes_the_fast_path_and_is_never_redirected asserts
    clean_arr_ptr returns a real ArrayHeader unchanged and that the
    redirect answers None for it, so the identity cannot have come from a
    redirect that happened to agree.
  • Six sabotage-shaped unit tests in array/subclass_tests.rs: each first
    asserts the bytes the pre-fix code misread are still sitting there
    (ArrayHeader.length == 1 aliasing object_type, capacity == class_id,
    both passing the old sanity check) and only then that the entry point refuses
    or resolves — so a green run proves the brand check fired.
  • Array-family A/B, 141 test-files/test_*{array,spread,iter,foreach,for_of, slice,splice,sort,concat,flat,push,pop,destructur}*.ts, run on both arms:
    the failure set is identical (2 pre-existing COMPILE_FAIL, 8
    NODE_FAIL where node itself cannot run the file, 1 pre-existing
    RUN_FAIL), except that test_gap_7574_… fails 139 on the reverted arm
    and passes on this one. test_gap_6232_class_extends_array also passes on
    both — its concat line is what caught the bulk-path regression above, which
    is fixed here.
  • cargo test -p perry-runtime: 1850 passed, 0 failed.
    cargo test -p perry-codegen --lib: 671 passed, 0 failed.
  • python3 scripts/raw_handle_debt.py: 998 (baseline 998), per-module
    ceilings held. python3 scripts/addr_class_inventory.py,
    python3 scripts/class_id_collisions.py, ./scripts/check_file_size.sh,
    cargo fmt --all -- --check: all clean.

Known gap left in place

ArraySpeciesCreate on a subclass: node's sub.map(f) returns a MyArr,
perry returns a plain Array. That is pre-existing and identical on the
unannotated path
, so it is out of scope here; the gap test compares element
CONTENT (via join) rather than the container's console formatting, and says
so inline. Worth its own issue.

No version bump (maintainer bumps at merge).

Summary by CodeRabbit

  • Bug Fixes

    • Fixed crashes and incorrect behavior when Array subclasses are used through array-typed bindings.
    • Improved support for indexed access, push, concat, iteration, spread, stack operations, and length updates.
    • Prevented plain objects and other non-array values from being misinterpreted as arrays.
    • Preserved subclass receivers during callbacks and array-like operations.
  • Tests

    • Added regression coverage for array subclasses, invalid array-like values, indexing, iteration, and method behavior.
  • Documentation

    • Updated the documented version to 0.5.1343.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6ef69ee8-2606-4af5-85e7-53c940d651a6

📥 Commits

Reviewing files that changed from the base of the PR and between aaea8b8 and 008e65d.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (17)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/7603-array-subclass-declared-base-type.md
  • crates/perry-codegen/src/expr/array_push.rs
  • crates/perry-codegen/src/expr/index_get.rs
  • crates/perry-runtime/src/array/from_concat.rs
  • crates/perry-runtime/src/array/header.rs
  • crates/perry-runtime/src/array/indexing.rs
  • crates/perry-runtime/src/array/iter_methods.rs
  • crates/perry-runtime/src/array/mod.rs
  • crates/perry-runtime/src/array/push_pop.rs
  • crates/perry-runtime/src/array/subclass.rs
  • crates/perry-runtime/src/array/subclass_tests.rs
  • crates/perry-runtime/src/object/polymorphic_index.rs
  • crates/perry-runtime/src/proxy/put_value.rs
  • crates/perry-runtime/src/typed_feedback.rs
  • test-files/test_gap_7574_array_subclass_declared_base_type.ts

📝 Walkthrough

Walkthrough

Array subclass instances stored in array-typed bindings now avoid forged ArrayHeader access. Runtime operations use generic array-like dispatch, code-generation fast paths validate GC types, and regression tests cover indexing, mutation, iteration, concat, and length behavior.

Changes

Array subclass safety

Layer / File(s) Summary
Receiver validation and classification
crates/perry-runtime/src/array/header.rs, crates/perry-runtime/src/array/subclass.rs, crates/perry-runtime/src/array/subclass_tests.rs, crates/perry-runtime/src/array/mod.rs
clean_arr_ptr now rejects unregistered object and closure allocations. Runtime helpers classify Array subclasses and validate receiver values. Tests cover forged headers, real arrays, subclasses, ordinary objects, and invalid values.
Subclass array operations
crates/perry-runtime/src/array/subclass.rs, crates/perry-runtime/src/array/indexing.rs, crates/perry-runtime/src/array/push_pop.rs, crates/perry-runtime/src/array/iter_methods.rs, crates/perry-runtime/src/object/polymorphic_index.rs, crates/perry-runtime/src/proxy/put_value.rs, crates/perry-runtime/src/typed_feedback.rs
Array methods, indexed reads and writes, iteration callbacks, and length assignments now preserve and dispatch through Array subclass receivers. Indexed writes update Array-exotic length.
Fast-path guards and regression coverage
crates/perry-codegen/src/expr/array_push.rs, crates/perry-codegen/src/expr/index_get.rs, crates/perry-runtime/src/array/from_concat.rs, test-files/test_gap_7574_array_subclass_declared_base_type.ts, changelog.d/7603-array-subclass-declared-base-type.md, CLAUDE.md, Cargo.toml
Push, bounded index access, and concat fast paths now require genuine arrays. The regression test covers declared bindings, array methods, iteration, spread, length changes, and plain-object casts. Version metadata changes to 0.5.1343.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TypedBinding
  participant ArrayRuntime
  participant ArraySubclassObject
  participant GenericArrayLikeEngine
  TypedBinding->>ArrayRuntime: invoke array operation
  ArrayRuntime->>ArrayRuntime: validate GC type
  ArrayRuntime->>ArraySubclassObject: resolve subclass receiver
  ArrayRuntime->>GenericArrayLikeEngine: dispatch array-like operation
  GenericArrayLikeEngine->>ArraySubclassObject: access indexed properties
  ArraySubclassObject-->>TypedBinding: return result
Loading

Possibly related PRs

  • PerryTS/perry#7573: Uses similar receiver resolution and subclass-aware dispatch for base-typed Map and Set subclasses.
  • PerryTS/perry#7501: Also changes array push code generation and runtime array handling.
  • PerryTS/perry#6810: Also modifies array indexing runtime code, but addresses a different fast path.

Suggested labels: bug, parity

Suggested reviewers: thehypnoo

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/7574-array-subclass-raw-paths

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@proggeramlug
proggeramlug force-pushed the fix/7574-array-subclass-raw-paths branch from 0fc7d7e to 008e65d Compare August 7, 2026 22:23
@proggeramlug
proggeramlug merged commit 87b5d39 into main Aug 7, 2026
@proggeramlug
proggeramlug deleted the fix/7574-array-subclass-raw-paths branch August 7, 2026 22:23
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit before merge — verified, merged as v0.5.1343

Gap test byte-identical to node (39 lines, exit 0) on my own build, rebased
past #7600/#7602 (both of which touched the same array_push.rs).

Sabotage decisive in both directions: neutering just the new inline-push
brand check (icmp_ne gc_type, gc_type — always false) brings back exit 139
with one line of output
, the exact SIGSEGV; restored, exit 0 byte-identical.
This confirms the report's most important structural finding — the runtime
funnel alone cannot stop this crash, because the inline Expr::ArrayPush tier
never calls into the runtime. The two codegen guards are load-bearing, not
belt-and-braces.

Fast-path preservation: a_genuine_array_takes_the_fast_path_and_is_never_redirected
green, full suites 1,857/0 runtime + 677/0 codegen, all four lint gates + fmt
clean.

Three things from the report worth keeping visible:

  1. Two of the issue's three citations were wrong and the two real tiers were
    unnamed.
    property_get.rs:236 already brand-checks; the Ptr<NumArray>
    tiers are unreachable by provenance proof; the actual crash was the inline
    push store plus the typed-feedback boxed fallback. Verifying the filing
    rather than inheriting it is what made this fix land on the right sites.
  2. The funnel itself introduced a regression — [1,2].concat(sub) silently
    dropping elements — caught by the 141-test family A/B, not by the new gap
    test.
    That is the strongest argument this repo has for family A/Bs on
    memory-safety fixes: the new test guards the bug you knew about, the A/B
    guards the bug you just created.
  3. The deliberate leftovers (ArraySpeciesCreate on subclasses, static from
    as a GET, sub instanceof MyArr) are named, pre-existing, and need their
    own issues — same triage discipline as fix(runtime): a Map/Set subclass in a base-typed binding was read as a raw header (#7570) #7573's.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

class X extends Array in an T[]-annotated binding takes the raw ArrayHeader fast paths (sibling of #7570)

1 participant