Skip to content

Nine fused js_array_* helpers read a Buffer-backed Uint8Array receiver as garbage (no Buffer arm) #8137

Description

@proggeramlug

Summary

Nine fused js_array_* callback/reduce entry points return garbage values
not empty, not an error — when the receiver is a Buffer-backed Uint8Array and
codegen could not statically prove the receiver type.

const holder = { u: new Uint8Array([3, 1, 2]) };

holder.u.map((x) => x * 2)
// node:  [6,2,4]
// perry: [1.297723e-318, 1.69759671889e-312, 1.2731974749e-313]

holder.u.reduce((a, b) => a + b, 0)
// node:  6
// perry: 9.1245888205e-313

Measured against node v26.5.1 (matches .node-version) on main @ 0a1e78e5f.

Full list

entry point measured node
js_array_map [1.29e-318,1.69e-312,1.27e-313] [6,2,4]
js_array_filter [] [3,2]
js_array_find undefined 1
js_array_findIndex -1 1
js_array_some false true
js_array_every false true
js_array_reduce 9.12e-313 6
js_array_reduce_right "z|4e-323|1.46e-311|6.48e-319" "z|2|1|3"
js_array_forEach (1-arg) visits garbage 3;1;2;

js_array_reduce_right is the widest: it is wrong even for a statically
typed
const u = new Uint8Array([3,1,2]), because dispatch_buffer_method
has no reduceRight arm either, so both dispatch paths land on the generic
helper.

Root cause

Two facts compose.

  1. normalize_array_receiver (array/header.rs:844) is permissive, not
    rejecting: for a registered typed array or Buffer it returns the raw
    address
    rather than null (if is_typed_array || is_buffer { return raw_addr … }). So the re-dispatch each of these helpers performs afterwards
    IS reached — this is not the fix(gc): install array growth forwarding for low-address arenas #8041 ordering bug.

  2. That re-dispatch asks only lookup_typed_array_kind. Perry's
    new Uint8Array([…]) is a BufferHeader, not a TypedArrayHeader
    (buffer::js_uint8array_new), so it is absent from the typed-array registry
    and the probe never answers for it. There is no Buffer arm.

The helper then reads the BufferHeader as an ArrayHeader. Both share the
{length: u32, capacity: u32} prefix, so length is correct while the
elements — read as boxed f64 slots at base + 8 + i*8 over a payload that is
one byte per element — are raw bytes reinterpreted. Correct length, garbage
values. It is also an out-of-bounds read of length * 7 bytes past the
buffer's real payload; the values observed differ run to run, which is how you
can tell it is reading uninitialized memory rather than mis-decoding.

This predates #8041normalize_array_receiver was not touched by that
commit — so it is a standing gap, not the regression #8090/#8109/#8119/#8120/#8130
have been closing. It is the same registry gap
buffer_receiver_as_uint8_typed_array (#8096) documents, in nine more places.

Reachability

Only the non-provable receiver reaches these helpers:

holder.u.map(cb)                     -> @js_array_map                          BROKEN
const u = new Uint8Array(…); u.map(cb)
                                     -> js_typed_feedback_native_call_method_by_id
                                        -> dispatch_buffer_method              correct
opaque(new Uint8Array(…)).map(cb)    -> dynamic tower                          correct

Verified per method with --trace llvm, grepping call sites only
(^\s+(%… = )?(tail )?call) — grepping the whole .ll is useless because every
runtime symbol is declared in every module.

A registry typed array (Int32Array, Float64Array) is correct on every
one of these, because it is in the typed-array registry and the existing
re-dispatch fires. Only the Buffer-backed Uint8Array shape is affected.

Why this is not a one-line fix — the decision needed

The obvious move is buffer_receiver_as_uint8_typed_array(), which #8119 added
for toSorted/toReversed/with. But that helper returns a copy, and its
own doc scopes it to immutable methods. These nine pass the receiver to a user
callback as the 3rd argument (js_typed_array_map does
js_closure_call3(callback, v, i, recv), typedarray/iterate.rs:42), so
delegating through a copy would hand the callback a different object than
node does, and a write through it would be silently lost:

u.forEach((v, i, arr) => { arr[0] = 9; });   // node mutates u; a copy would not

So one of these has to be chosen deliberately:

  • (a) a live TypedArrayHeader view over the Buffer's bytes — no copy,
    correct 3rd argument. Needs a view type that aliases rather than owns;
    typed_array_to_array_buffer goes the other direction.
  • (b) a Buffer arm in each helper that reads elements via js_buffer_get
    and passes the original Buffer as the 3rd argument. Nine small edits, no new
    machinery, but nine places to keep in step.
  • (c) extend dispatch_buffer_method with map/filter/forEach/find/
    findIndex/some/every/reduce/reduceRight and have the generic
    helpers delegate to it, mirroring what js_array_copy_within already does
    for copyWithin (array/immutable.rs:370). This also fixes the
    statically-typed reduceRight and findLast holes for free, since those are
    the same missing arms.

(c) looks strongest: it is the pattern already in the tree, it is one place,
and it closes the static-receiver cases too. But it is a real design call, not
a mechanical reorder, which is why this is an issue rather than part of the
#8135 PR.

Testing note for whoever takes this — a vacuous probe to avoid

holder.u.every((x) => x > 0) returns true under both node and perry,
because the garbage values (1.29e-318 &c.) are also > 0. A probe of that
shape reports PASS on the broken path. The discriminating predicate is
(x) => x === 3 || x === 1 || x === 2, which measures false. Any every /
some receiver test here must use value identity, never a sign or truthiness
test.

Context

Found by the exhaustive Array.prototype receiver sweep that produced #8135.
Full table of all 33 Array.prototype-reachable entry points, with the
funnel-ordering verdict and the measured node diff for each, is in the #8135
PR description.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions