Skip to content

correctness: a Float64Array element whose bits land in the NaN-box window reads back as its payload integer, and Number.isNaN then reports false #10779

Description

@proggeramlug

Summary

A Float64Array element whose bit pattern falls inside perry's NaN-box tag window reads back as its payload integer instead of the double it was written as. No error; the value is silently wrong.

const a = new Float64Array(1);
const view = new BigUint64Array(a.buffer);
view[0] = 0x7FF8000012345678n;          // a NaN whose payload is 0x12345678
console.log(typeof a[0], Number.isNaN(a[0]), a[0]);
// node : number  true  NaN
// perry: number  false 305419896

305419896 is 0x12345678 — the NaN payload, surfaced as an integer. perry also reports Number.isNaN(a[0]) as false for a value that is a NaN by IEEE-754 definition.

A typed array is raw memory with a defined bit-level interpretation: any 64-bit pattern may legitimately be stored and must read back as the Float64 it denotes. Perry's NaN-boxing uses the NaN space for tagged values, and a genuine f64 NaN with a payload collides with it.

How this arises in ordinary code

It does not need a BigUint64Array to construct. Any of these can put an arbitrary bit pattern into a Float64Array:

  • reading binary data — a file, a socket, a WASM memory, a GPU buffer
  • new Float64Array(someArrayBuffer) over bytes from anywhere
  • deserialising a struct written by C, Rust or another language
  • signalling NaNs produced by numeric code that encodes information in the payload

In each case the value is silently replaced by its payload integer, and Number.isNaN then agrees that it is not a NaN — so a downstream isNaN check cannot catch it either.

Two related divergences found alongside

Number.isNaN returns false for a NaN in the case above. That is the same root cause surfacing through a different API, but worth stating separately because Number.isNaN is the standard defensive check and it is the one thing a program would use to defend itself here.

A module-global BigInt64Array read throws a TypeError where node returns the value. Minimal case: declare a BigInt64Array at module scope and read an element from inside a function.

Provenance

Found while investigating #10777, on base — no change of ours is involved, and all three reproduce on origin/main plus only unrelated open PRs. Compared against node v26.8.1 on Linux x86-64.

I am filing the first two together because they share a root cause; the BigInt64Array one is separate and may well be unrelated, but it was found in the same sweep and would otherwise go unrecorded.

Activity

  1. proggeramlug commented on Sep 20, 2026

    @proggeramlug
    ContributorAuthor

    This reproduces on the class-field path too, and it now blocks a performance fix

    The report above used a Float64Array to construct the bit pattern. It is not confined to typed arrays — the same value stored into an ordinary object field, on the path where perry has a consumed Ptr<Shape> proof, behaves identically:

    const f = new Float64Array(1);
    new Uint32Array(f.buffer).set([0x12345678, 0x7FFE0000]);
    o.a = f[0];
    console.log(typeof o.a, Number.isNaN(o.a), o.a);
    // node : number true  NaN
    // perry: number false 305419896

    That widens the blast radius: any code path that moves a double from binary data into an object field carries it.

    It is also now a blocker for #10777

    The perf fix for property access is one ~10-line arm in expr_produces_canonical_raw_f64, admitting a PropertyGet whose receiver's shape already records the field as numeric. The information exists, is reachable and is already consumed by codegen — the fix is to stop dropping the label.

    That predicate's contract is not "is a Number"; it is "any NaN it produces must carry a non-tag payload." A numeric_fields slot is user-writable, so this bug sits directly in front of the arm: admitting the PropertyGet while a field can hold a tag-window NaN would turn a silent wrong answer into a silently wrong unboxed answer.

    So fixing this unblocks a measured performance fix as well as closing a correctness hole. It is on the critical path for #10761's operations 1–3.

  2. proggeramlug commented on Sep 20, 2026

    @proggeramlug
    ContributorAuthor

    ⚠️ The fix for this landed in train 235 with a regression — Buffer.readFloatLE does not compile on main

    The canonicaliser from PR #10785 is in main as of merge train 235 (v0.5.1614). A follow-up fix is not, and without it any module calling Buffer.readFloatLE or readFloatBE fails to compile:

    '%r1' defined with type 'float' but expected 'double'
    

    A hard codegen failure, not a wrong value. Fix is in #10789.

    canonicalize_lane_f32 (nanbox_inline.rs:63) is reached from buffer_intrinsic.rs:428 on the Buffer.readFloat* path and emits an fcmp on an f32 lane, while LlBlock::fcmp renders its operand type as double unconditionally.

    I opened #10785 without catching this. The reason it got through is worth recording: the read-shape harness had readDoubleLE and no readFloat*, and none of the 153 programs across the three suites calls one. The guard that PR added had zero coverage on one of its two arms — untested by construction, not by oversight. The harness now carries readFloatLE, readFloatBE and readDoubleBE; on the pre-fix runtime the first two SIGSEGV.

    #10789 also closes the last raw float source — NaNs arriving from native code — which is witnessed: before, a C float return SIGSEGVs on the forged StringHeader* and a C double return prints 305419896.

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

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