You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
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
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.
consta=newFloat64Array(1);constview=newBigUint64Array(a.buffer);view[0]=0x7FF8000012345678n;// a NaN whose payload is 0x12345678console.log(typeofa[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.
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:
constf=newFloat64Array(1);newUint32Array(f.buffer).set([0x12345678,0x7FFE0000]);o.a=f[0];console.log(typeofo.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.
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.
⚠️ 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.
Activity
proggeramlug commentedon Sep 20, 2026
This reproduces on the class-field path too, and it now blocks a performance fix
The report above used a
Float64Arrayto 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 consumedPtr<Shape>proof, behaves identically: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 aPropertyGetwhose 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_fieldsslot is user-writable, so this bug sits directly in front of the arm: admitting thePropertyGetwhile 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.
fix(codegen,runtime): canonicalise NaNs read out of an ArrayBuffer so…
fix(codegen): give `fcmp` an operand type — the f32 NaN canonicaliser…
fix(codegen): canonicalise NaNs arriving from native code, not just f…
fix(codegen): give `fcmp` an operand type — the f32 NaN canonicaliser…
fix(codegen): canonicalise NaNs arriving from native code, not just f…
changelog: fragment for the PerryTS#10779 follow-up
proggeramlug commentedon Sep 20, 2026
Buffer.readFloatLEdoes not compile onmainThe canonicaliser from PR #10785 is in
mainas of merge train 235 (v0.5.1614). A follow-up fix is not, and without it any module callingBuffer.readFloatLEorreadFloatBEfails to compile:A hard codegen failure, not a wrong value. Fix is in #10789.
canonicalize_lane_f32(nanbox_inline.rs:63) is reached frombuffer_intrinsic.rs:428on theBuffer.readFloat*path and emits anfcmpon anf32lane, whileLlBlock::fcmprenders its operand type asdoubleunconditionally.I opened #10785 without catching this. The reason it got through is worth recording: the read-shape harness had
readDoubleLEand noreadFloat*, 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 carriesreadFloatLE,readFloatBEandreadDoubleBE; 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
floatreturn SIGSEGVs on the forgedStringHeader*and a Cdoublereturn prints305419896.fix(codegen): give `fcmp` an operand type — the f32 NaN canonicaliser…
fix(codegen): canonicalise NaNs arriving from native code, not just f…
changelog: fragment for the #10779 follow-up
perf(codegen): a property read on a Ptr<Shape> receiver with a numeri…