Skip to content

fix(runtime): Array.prototype read-only methods accept array-like receivers#4231

Merged
proggeramlug merged 1 commit into
mainfrom
fix-array-prototype-arraylike
Jun 3, 2026
Merged

fix(runtime): Array.prototype read-only methods accept array-like receivers#4231
proggeramlug merged 1 commit into
mainfrom
fix-array-prototype-arraylike

Conversation

@proggeramlug
Copy link
Copy Markdown
Contributor

What

Array.prototype.<method>.call(arrayLikeObject, ...) now works for the read-only
/ returning Array methods (map, filter, forEach, find, findIndex, findLast,
findLastIndex, some, every, flatMap, reduce, reduceRight, indexOf, lastIndexOf,
includes, slice, at, join). Previously these treated a generic array-like object
receiver as a raw ArrayHeader and read garbage or threw TypeError.

Why

The single largest test262 cluster: ~1,618 built-ins/Array failures, spread
evenly across every prototype method — one shared root cause: array-like object
receivers weren't materialized into real arrays.

How

Two orthogonal fixes:

  1. HIR (primary): try_builtin_prototype_method_apply_call rewrote
    Array.prototype.map.call(like, fn)like.map(fn), which for an
    Any/object receiver lowered to a dynamic lookup that found no map and threw
    "value is not a function". Now, for a known read-only method, the receiver is
    materialized via Expr::ArrayFrom (→ js_array_from_value/js_array_clone,
    which already detect {length, 0:…} array-likes) before building the dedicated
    array-method node. This also fixes inlined-codegen methods (e.g. forEach).
  2. Runtime (defense-in-depth): normalize_array_receiver replaces
    clean_arr_ptr at the read-only method entries. It reads the GC-header
    obj_type; ONLY GC_TYPE_OBJECT (a plain array-like) is materialized via
    js_array_from_arraylike. Real arrays (GC_TYPE_ARRAY) fall straight through
    — one warm header read + an int compare, no materialization — so
    [1,2,3].map(...) is not regressed.

Verification

  • test262 built-ins/Array: 1618 → 1161 (503 fixed; net −457).
  • Byte-identical to node --experimental-strip-types under BOTH
    PERRY_NO_AUTO_OPTIMIZE=1 and default auto-optimize: array-like map/indexOf/
    reduce/slice/join/includes, real-array map/filter/reduce/slice/indexOf, for-of,
    spread, TypedArray .map, chained filter→map→reduce.
  • perry-runtime suite: 974/974.
  • fmt + file-size gates clean. Rebased on current main.

Known: 46 newly-surfaced (pre-existing) failures → #4230

The correct materialization exposes ~46 pre-existing latent bugs the old
garbage-read path masked. Confirmed identical on clean main (not
regressions): Array iteration doesn't skip holes ([1,,3].map calls cb 3× vs
Node's 2×) and [].find() returns NaN vs undefined. Tracked in #4230 for a
dedicated hole-skipping pass (which would recover these 46 + fix real sparse
arrays). Net effect of this PR remains strongly positive (−457).

Scope

Mutators (push/pop/shift/unshift/splice/sort/reverse/fill/copyWithin) and
concat are intentionally NOT changed — they need spec write-back / have
different array-like semantics; left for a follow-up.

Array.prototype.<method>.call(arrayLikeObject, ...) was broken across the
whole read-only Array method surface (map/filter/reduce/reduceRight/some/
every/forEach/find/findIndex/findLast/findLastIndex/at/indexOf/lastIndexOf/
includes/slice/join/flatMap). A generic array-like receiver — a plain object
with a `length` property and indexed keys, e.g. {length:3, 0:"a", 1:"b"} —
was treated as a raw ArrayHeader: methods read garbage (8.48e-314) or threw
"value is not a function" / TypeError.

Two orthogonal root causes, both fixed:

1. HIR (primary): `try_builtin_prototype_method_apply_call` rewrote
   `Array.prototype.map.call(like, fn)` into `like.map(fn)`, which the normal
   member-call fast path only routes to `js_array_*` when the receiver is
   statically array-typed; for an Any/object receiver it lowered to a dynamic
   method lookup that found no `map` field and threw. Now, when the resolved
   method is a known read-only Array method, the receiver is materialized into
   a REAL array via `Expr::ArrayFrom` (→ js_array_from_value → js_array_clone,
   which already detects `{length, 0:…}` array-likes) before the dedicated
   `Expr::Array*` variant is built. This also fixes the methods whose codegen
   is INLINED (e.g. forEach reads `(*arr).length`/`arr[i]` directly rather than
   calling js_array_forEach) — the inlined loop now sees a genuine ArrayHeader.

2. Runtime (defense-in-depth): add `normalize_array_receiver`, used at the
   entry of the read-only `js_array_*` helpers in place of `clean_arr_ptr`. It
   reads the GC-header obj_type; for GC_TYPE_OBJECT (a plain array-like) it
   materializes via `js_array_from_arraylike`, otherwise it falls straight
   through to `clean_arr_ptr`. Real arrays (GC_TYPE_ARRAY) pay only one
   already-warm header-byte read + an integer compare — the registry lookups
   and materialization are reached only for plain-object receivers, so the
   `[1,2,3].map(...)` hot path is unchanged. This catches array-like receivers
   that reach the runtime through indirection the HIR fold can't see statically.

Mutators (push/pop/shift/unshift/splice/sort/reverse/fill/copyWithin) and
concat are intentionally left as-is: mutators need spec write-back of indexed
props + length to the receiver (out of scope for this pass), and concat's
array-like receiver has non-spreading single-element semantics that differ
from the array algorithm.

Verified byte-identical to `node --experimental-strip-types` across all
read-only methods on a {length:3,0,1,2} array-like, plus regression cases on
real arrays (map/filter/reduce/for-of/spread/slice/indexOf) and a TypedArray
(both PERRY_NO_AUTO_OPTIMIZE and default auto-optimize builds). 974/974
perry-runtime lib tests pass.
@proggeramlug proggeramlug merged commit 1fff17c into main Jun 3, 2026
11 checks passed
@proggeramlug proggeramlug deleted the fix-array-prototype-arraylike branch June 3, 2026 08:17
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.

1 participant