Skip to content

runtime: iteration over typed arrays (Uint8Array etc.) yields raw NaN-box bits, not byte values #578

@proggeramlug

Description

@proggeramlug

Repro

const a = new Uint8Array([10, 20, 30, 40]);

console.log(a[0], a[1], a[2], a[3]);          // works: 10 20 30 40

const collected: number[] = [];
for (const b of a) collected.push(b);
console.log("for…of:", collected);

console.log("Array.from:", Array.from(a));

bun main.ts:

10 20 30 40
for…of: [ 10, 20, 30, 40 ]
Array.from: [ 10, 20, 30, 40 ]

perry main.ts:

10 20 30 40
for…of: [ 3.325357495e-315, 0, 0, 0 ]
Array.from: [ 3.325357495e-315, 0, 0, 0 ]

a[i] indexing returns the correct byte values. for…of, Array.from, and the spread operator ([...a]) all produce a 4-element array where the first element is a NaN-boxed float with the raw bit pattern of the underlying NaN-box tag, and subsequent elements are 0.

The denormal 3.325357495e-315 is 0x000000000000A reinterpreted as f64 — the bit pattern of the actual byte values squeezed into the low bits of an f64 with no NaN-box tag at all. So the iterator is reading the raw memory backing the typed array as f64s rather than calling the per-element accessor that does the byte→NaN-box conversion.

Why this matters

Discovered while running @bradenmacdonald/s3-lite-client post #575 + #576 fix (#551). The package's bin2hex helper:

export function bin2hex(binary: Uint8Array) {
  return Array.from(binary).map((b) => b.toString(16).padStart(2, "0")).join("");
}

Converts an HMAC output to a hex signature. With this bug, Array.from produces garbage so the hex signature is wrong — the produced URL is structurally identical to bun's but with X-Amz-Signature=0000… (or a similarly wrong value), and would fail authentication against real S3.

Beyond s3-lite, this breaks every package that iterates a Uint8Array (or any typed array) — file I/O, hashing, encryption, base64, anything binary.

Affected APIs (all same root cause, same observed output)

  • for (const b of typedArray) ...
  • Array.from(typedArray)
  • [...typedArray] (spread)
  • Likely also: typedArray.values() / .entries() iteration via the iterator protocol
  • Likely also: passing typed arrays to new Set(typedArray), new Map(...), Promise.all(typedArray.map(...)) — anything that calls Symbol.iterator

Index-based access (a[i], for (let i=0; i<a.length; i++)) is fine — that's the workaround until this lands.

Suggested implementation

The typed-array iterator likely just casts the underlying byte buffer's words directly as f64s without going through the byte→NaN-box conversion that a[i] indexing applies. Search crates/perry-runtime/src/typedarray.rs for the Symbol.iterator / keys / values / entries impl and align with what js_typed_array_index_get (or whatever the indexing path is) does.

Likely a 5-10 line patch.

Acceptance

The repro prints [10, 20, 30, 40] for both for…of and Array.from. Plus regressions:

  • Uint16Array, Int32Array, Float32Array, Float64Array — all produce element-typed values via iteration
  • Spread in array literal: [0, ...new Uint8Array([1,2,3]), 99][0, 1, 2, 3, 99]
  • Spread in function call: Math.max(...new Uint8Array([1,2,3]))3
  • new Set(uint8array) / Array.from(uint8array, fn) / [...uint8array.values()]

Refs #551

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