Repository navigation
Nine fused js_array_* helpers read a Buffer-backed Uint8Array receiver as garbage (no Buffer arm) #8137
Description
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-313Measured 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.
-
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. -
That re-dispatch asks only
lookup_typed_array_kind. Perry's
new Uint8Array([…])is aBufferHeader, not aTypedArrayHeader
(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 #8041 — normalize_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 notSo one of these has to be chosen deliberately:
- (a) a live
TypedArrayHeaderview over the Buffer's bytes — no copy,
correct 3rd argument. Needs a view type that aliases rather than owns;
typed_array_to_array_buffergoes 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_methodwithmap/filter/forEach/find/
findIndex/some/every/reduce/reduceRightand have the generic
helpers delegate to it, mirroring whatjs_array_copy_withinalready does
forcopyWithin(array/immutable.rs:370). This also fixes the
statically-typedreduceRightandfindLastholes 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.
Activity
docs(changelog): add the #8173 fragment for the #8137 Buffer-receiver…
fix(array): resolve a Buffer-backed Uint8Array receiver in the fused …
fix(hir,runtime): `Uint8Array<ArrayBuffer>`-annotated receivers no lo…