From 34d977558a9853e040446b4c4eb3763fbef575e1 Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Sun, 20 Sep 2026 04:25:32 +0000 Subject: [PATCH 1/3] perf(codegen): unary + proves a Number by construction, with no operand condition (#10777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `expr_numeric_by_construction` required `rec(operand)` for `Pos`, the same as for `Neg` and `BitNot`. That was not a soundness guard, it was a missed proof. Unary `+` is ToNumber, which either completes holding a Number or throws: a BigInt and a Symbol both throw a TypeError, an object goes through ToPrimitive and then ToNumber again, `undefined` is NaN, and NaN is a Number. A throw stores no value, so the store-universe question this fixpoint asks is vacuous on that path — there is no input for which `+x` finishes holding something other than a Number. `Neg` and `BitNot` keep their operand condition, because ToNumeric is BigInt-preserving: `-1n` is `-1n` and `~1n` is `-2n`, neither a Number. The missed proof left the ACCUMULATOR unproven, so its add kept a per-iteration tag test: const v = +o.a; for (…) h += v 20 -> 9 Ir/iteration const v = +a[0]; for (…) h += v 20 -> 9 (Float64Array) which is exactly where `o.a * 1` and `o.a - 0` already sat. node is 7.03 and 7.50 on the same fixtures, bun 4.27 and 4.48, so this closes the perry-versus-perry gap and does not reach parity. --- changelog.d/10777-unary-pos-numeric.md | 7 ++++++ .../src/collectors/ptr_shape_numeric.rs | 23 ++++++++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 changelog.d/10777-unary-pos-numeric.md diff --git a/changelog.d/10777-unary-pos-numeric.md b/changelog.d/10777-unary-pos-numeric.md new file mode 100644 index 0000000000..421f220a14 --- /dev/null +++ b/changelog.d/10777-unary-pos-numeric.md @@ -0,0 +1,7 @@ +**Unary `+` now proves a Number by construction.** + +`expr_numeric_by_construction` required `rec(operand)` for `Pos` as though it were a soundness guard. It is not: unary `+` is ToNumber, which either completes holding a Number or throws — BigInt and Symbol throw, an object re-enters ToNumber after ToPrimitive, `undefined` is NaN. A throw stores no value, so the store-universe question the fixpoint asks is vacuous there. + +`Neg` and `BitNot` keep their condition, because ToNumeric is BigInt-preserving (`-1n` is `-1n`). + +The missed proof left the *accumulator* unproven, so its add kept a per-iteration tag test: `const v = +o.a; … h += v` goes **20 → 9 instructions per iteration**, the same figure `o.a * 1` and `o.a - 0` already reached. diff --git a/crates/perry-codegen/src/collectors/ptr_shape_numeric.rs b/crates/perry-codegen/src/collectors/ptr_shape_numeric.rs index 7c1c69c9cd..a29c049847 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape_numeric.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape_numeric.rs @@ -626,9 +626,26 @@ pub(super) fn expr_numeric_by_construction( | Expr::PodLayoutAlignOf { .. } | Expr::PodLayoutOffsetOf { .. } => true, Expr::Unary { op, operand } => match op { - perry_hir::UnaryOp::Neg | perry_hir::UnaryOp::Pos | perry_hir::UnaryOp::BitNot => { - rec(operand) - } + // Unary `+` is ToNumber, and ToNumber either COMPLETES with a + // Number or THROWS — there is no input for which `+x` finishes + // holding something else. A BigInt and a Symbol both throw a + // TypeError, an object goes through ToPrimitive and then ToNumber + // again (so a `valueOf` returning a string yields a Number, and + // one returning a BigInt throws), `undefined` is NaN, and NaN is + // a Number. A throw stores no value, so the store-universe + // question this fixpoint asks is vacuous on that path. + // + // So `Pos` needs no operand condition at all. Requiring + // `rec(operand)` here was not a soundness guard, it was a missed + // proof: `const v = +o.a; for (…) h += v` left the ACCUMULATOR + // unproven, and `h`'s add kept a per-iteration tag test — 20 + // Ir/iteration where `o.a * 1` and `o.a - 0` reach 9 (#10777). + // `const v = +a[0]` on a Float64Array is the same 20 -> 9. + perry_hir::UnaryOp::Pos => true, + // `-x` and `~x` are ToNumeric, which is BigInt-preserving: + // `-1n` is `-1n` and `~1n` is `-2n`, both BigInts, neither a + // Number. They therefore keep their operand condition unchanged. + perry_hir::UnaryOp::Neg | perry_hir::UnaryOp::BitNot => rec(operand), _ => false, }, Expr::Binary { op, left, right } => match op { From 7f59d502d29db14df95d4e6b57b8d80653995cd3 Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Sun, 20 Sep 2026 05:59:02 +0000 Subject: [PATCH 2/3] fix(codegen,runtime): canonicalise NaNs read out of an ArrayBuffer so user bytes cannot forge a boxed value (#10779) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A double whose bit pattern falls inside the NaN-box tag window read back as its payload instead of the NaN it is. `Number.isNaN` then reported false, so the one defensive check a program would use agreed the value was fine. It is not a wrong number. 80 of 144 probe patterns diverge on base and 32 of them SIGSEGV: 0x7FF9... reads back with `typeof === "string"` and 0x7FFD... as `[object Object]` — a pointer forged out of user-controlled bytes. Seeded GC stress with FROMSPACE_SCAN_ABORT=1 survives 0 of 10 seeds on base and 10 of 10 here. A field is a conduit, not a source: the only way a tag-band NaN enters is an ArrayBuffer float read, so canonicalising those is sufficient. Perry already enforces the same invariant for Array on the store side (array/header.rs:841); typed arrays are the one class where it has to be on the read. Rejected, with reasons recorded in the PR: moving the tag band (no NaN-free window exists in NaN space, either sign); fixing it at the decode (0x7FFE... is simultaneously a valid int32 box and a valid NaN); JSC's +/-2^49 offset (charges every double rather than only NaNs); canonicalising at the raw-to-boxed boundary (a perry value IS a double, so that boundary is not a syntactic site); and canonicalising only in-band NaNs, which is unsound — a signalling NaN quiets INTO the band, and fneg/fabs move negative payload NaNs in. Float64Array element read 6.04 instr -0.006% every #10777 and #10761 row 0 h += a[k&255] inline tier 28 -> 30 Float32Array inline tier 41 -> 46 No row where perry beats node regresses. NaN payload bits are no longer preserved through a JS number: 101 of 144 cases differ in bits only, nothing semantic. node preserves them; this matches JSC and SpiderMonkey. It is spec-permitted and unavoidable under any sound design. --- changelog.d/10779-nanbox-canonicalise.md | 7 ++ .../expr/index_get/inline_dyn_typed_array.rs | 12 +++- .../src/expr/index_get_claim_tests.rs | 19 +++++- .../perry-codegen/src/expr/masked_window.rs | 8 ++- crates/perry-codegen/src/expr/mod.rs | 2 +- .../perry-codegen/src/expr/nanbox_inline.rs | 65 ++++++++++++++++++- .../src/expr/proven_view_access.rs | 10 ++- .../src/expr/ta_param_f64_read.rs | 10 ++- .../src/lower_call/buffer_intrinsic.rs | 10 ++- crates/perry-runtime/src/array/header.rs | 26 ++++++++ crates/perry-runtime/src/array/mod.rs | 1 + crates/perry-runtime/src/buffer/dataview.rs | 11 ++-- crates/perry-runtime/src/buffer/numeric.rs | 13 ++-- crates/perry-runtime/src/typedarray/mod.rs | 14 +++- .../perry/src/commands/compile/build_cache.rs | 1 + .../src/commands/compile/object_cache.rs | 10 +++ 16 files changed, 195 insertions(+), 24 deletions(-) create mode 100644 changelog.d/10779-nanbox-canonicalise.md diff --git a/changelog.d/10779-nanbox-canonicalise.md b/changelog.d/10779-nanbox-canonicalise.md new file mode 100644 index 0000000000..0b5a910ac7 --- /dev/null +++ b/changelog.d/10779-nanbox-canonicalise.md @@ -0,0 +1,7 @@ +**A double read out of an `ArrayBuffer` can no longer forge a boxed value.** + +A bit pattern falling inside the NaN-box tag window read back as its payload integer rather than the NaN it is, and `Number.isNaN` reported `false` on it. This is memory safety rather than arithmetic: 80 of 144 probe patterns diverge on base and **32 SIGSEGV** — `0x7FF9…` reads back with `typeof === "string"`, `0x7FFD…` as `[object Object]`, a pointer forged out of user-controlled bytes. Seeded GC stress survives **0 of 10 seeds** on base and 10 of 10 after. + +Canonicalising float reads out of an `ArrayBuffer` is sufficient, because a field is a conduit rather than a source — perry already enforces the same invariant for `Array` on the store side. The `Float64Array` element read costs **−0.006%**, and no row where perry beats node regresses. + +NaN payload bits are no longer preserved through a JS number, matching JSC and SpiderMonkey rather than node. Spec-permitted, and unavoidable under any sound design. diff --git a/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs b/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs index c313c54b93..eec12734d7 100644 --- a/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs +++ b/crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs @@ -367,12 +367,15 @@ pub(super) fn lower_inline_dyn_typed_array_get( ctx.block().cond_br(&is_width2, &ta_w2_label, &ta_w1_label); // Width 8: `Float64Array` is the only non-BigInt kind of this width, so - // the stored lane IS the value. + // the stored lane IS the value — and therefore an arbitrary 64-bit pattern + // the program wrote through some other view. #10779: canonicalise its NaNs + // before the value leaves as a JS value. ctx.current_block = ta_w8_idx; let ta_w8_value = { let blk = ctx.block(); let ptr = blk.inttoptr(I64, &ta_addr); - blk.load(DOUBLE, &ptr) + let lane = blk.load(DOUBLE, &ptr); + crate::expr::nanbox_inline::canonicalize_lane_f64(blk, &lane) }; let ta_w8_end = ctx.block().label.clone(); ctx.block().br(&merge_label); @@ -396,6 +399,11 @@ pub(super) fn lower_inline_dyn_typed_array_get( let as_number = blk.sitofp(I64, &integral, DOUBLE); let as_f32 = blk.bitcast_i32_to_float(&lane); let widened_f32 = blk.fpext(F32, &as_f32, DOUBLE); + // #10779: an f32 NaN widens to an f64 NaN that KEEPS its payload — + // `0x7FFFFFFF` becomes `0x7FFF_FFFF_E000_0000`, a forged string + // pointer. The integer arms of this select cannot be NaN, so + // canonicalising the f32 arm alone is enough. + let widened_f32 = crate::expr::nanbox_inline::canonicalize_lane_f64(blk, &widened_f32); let is_f32 = blk.icmp_eq(I64, &ta_kind, "6"); blk.select(I1, &is_f32, DOUBLE, &widened_f32, &as_number) }; diff --git a/crates/perry-codegen/src/expr/index_get_claim_tests.rs b/crates/perry-codegen/src/expr/index_get_claim_tests.rs index c3dc09104e..f98f78ac6b 100644 --- a/crates/perry-codegen/src/expr/index_get_claim_tests.rs +++ b/crates/perry-codegen/src/expr/index_get_claim_tests.rs @@ -606,8 +606,23 @@ fn unknown_numeric_read_routes_typed_arrays_through_the_single_exit() { ); assert_eq!( w4.matches("select ").count(), - 2, - "signedness and the float form must be `select`s, not branches:\n{w4}" + 3, + "signedness, the #10779 NaN canonicalisation and the float form must be \ + `select`s, not branches:\n{w4}" + ); + // #10779: the third select is the NaN canonicalisation, and it must apply + // to the Float32Array lane only — the two integer forms cannot be NaN, so + // canonicalising them would be pure cost. Pin the operand so a later edit + // cannot quietly move it onto the merged value. + assert!( + w4.contains("fcmp uno double") && w4.contains("double 0x7FF8000000000000"), + "the f32 lane must be canonicalised before the float-form select:\n{w4}" + ); + assert_eq!( + w4.matches("br ").count(), + 1, + "the width-4 block must still end in exactly its unconditional branch \ + — no new control flow:\n{w4}" ); assert!( ir.contains("call double @js_packed_arraylike_index_get("), diff --git a/crates/perry-codegen/src/expr/masked_window.rs b/crates/perry-codegen/src/expr/masked_window.rs index 3d248b848b..cb01e3fea4 100644 --- a/crates/perry-codegen/src/expr/masked_window.rs +++ b/crates/perry-codegen/src/expr/masked_window.rs @@ -104,7 +104,13 @@ fn emit_window_load_f64( } MaskedWindowElem::TaF64 { data_ptr } => { let data_ptr = data_ptr.clone(); - emit_ta_window_load(ctx, &data_ptr, idx_i32, "3", DOUBLE) + let lane = emit_ta_window_load(ctx, &data_ptr, idx_i32, "3", DOUBLE); + // #10779: unlike `PlainF64` above — a JS `Array` raw-f64 + // slot, canonical by the store-side invariant + // (`js_array_numeric_value_to_raw_f64`) — this lane is + // ArrayBuffer-backed and holds whatever bytes the program wrote + // through any view of the buffer. Canonicalise it. + crate::expr::nanbox_inline::canonicalize_lane_f64(ctx.block(), &lane) } } } diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index b1f8ef5174..c8b492b0f2 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -50,7 +50,7 @@ mod helpers; mod i32_fast_path; mod in_presence_ic; mod index; -mod nanbox_inline; +pub(crate) mod nanbox_inline; mod native_memory; mod native_record; mod object_literal; diff --git a/crates/perry-codegen/src/expr/nanbox_inline.rs b/crates/perry-codegen/src/expr/nanbox_inline.rs index 33fcf06248..713e0da74b 100644 --- a/crates/perry-codegen/src/expr/nanbox_inline.rs +++ b/crates/perry-codegen/src/expr/nanbox_inline.rs @@ -3,7 +3,70 @@ use crate::block::LlBlock; use crate::nanbox::{BIGINT_TAG_I64, INT32_TAG_I64, POINTER_TAG_I64, STRING_TAG_I64}; -use crate::types::{I1, I32, I64}; +use crate::types::{DOUBLE, F32, I1, I32, I64}; + +/// The one NaN a Perry value is allowed to be: `0x7FF8_0000_0000_0000`. +/// Emitted as LLVM's hexadecimal double form so the parser cannot round-trip +/// the payload away. +const CANONICAL_QNAN_DOUBLE: &str = "0x7FF8000000000000"; + +/// `PERRY_NANBOX_CANON` gate (#10779). Enabled by default; `=0`/`off`/`false` +/// emits the pre-fix IR byte-for-byte, so the cost of the fix can be measured +/// with ONE compiler binary and no cross-build confound. Keyed into the object +/// cache alongside the other repsel gates; a measurement must still run with +/// `PERRY_NO_CACHE=1`. +pub(crate) fn nanbox_canon_enabled() -> bool { + use std::sync::OnceLock; + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| { + !matches!( + std::env::var("PERRY_NANBOX_CANON").as_deref(), + Ok("0") | Ok("off") | Ok("false") + ) + }) +} + +/// #10779: collapse any NaN in a float lane just loaded from ArrayBuffer-backed +/// memory to the canonical quiet NaN, so it cannot alias a NaN-box tag. +/// +/// The runtime twin is `perry_runtime::array::canonical_raw_f64`, whose doc +/// comment carries the full argument for why EVERY NaN must be collapsed and +/// not just the ones already inside the band (signalling NaNs move into it +/// under arithmetic; negative payload NaNs move into it under `fneg`/`fabs`). +/// +/// Apply this ONLY to a lane read out of an `ArrayBuffer` — a `Float64Array` / +/// `Float32Array` / `Float16Array` element or a `DataView` float read. A plain +/// JS `Array` raw-f64 slot is already canonical by the store-side +/// invariant (`js_array_numeric_value_to_raw_f64` / +/// `canonicalize_array_numeric_store_bits`), so adding it there would be pure +/// cost. Integer element kinds cannot produce a NaN at all. +/// +/// Costs one `fcmp uno` + one `select`, which LLVM lowers to a +/// `vcmpunordsd`/`vblendvpd` pair on x86-64-v3 with the NaN constant hoisted +/// out of any enclosing loop. +pub(crate) fn canonicalize_lane_f64(blk: &mut LlBlock, value: &str) -> String { + if !nanbox_canon_enabled() { + return value.to_string(); + } + // `fcmp` emits no fast-math flags (see `block.rs`), so `uno` survives. + let is_nan = blk.fcmp("uno", value, value); + blk.select(I1, &is_nan, DOUBLE, CANONICAL_QNAN_DOUBLE, value) +} + +/// The `float` twin of [`canonicalize_lane_f64`], for a lane that is still an +/// `f32` in the native lattice and will be `fpext`ed later. An `f32` NaN widens +/// to an `f64` NaN that KEEPS its payload (`0x7FFFFFFF` becomes +/// `0x7FFF_FFFF_E000_0000`, a forged string pointer), so canonicalising before +/// the widen is equivalent and costs the same. LLVM spells a `float` constant +/// in hex using its DOUBLE bit pattern, so the canonical `f32` qNaN +/// `0x7FC00000` is written `0x7FF8000000000000`. +pub(crate) fn canonicalize_lane_f32(blk: &mut LlBlock, value: &str) -> String { + if !nanbox_canon_enabled() { + return value.to_string(); + } + let is_nan = blk.fcmp("uno", value, value); + blk.select(I1, &is_nan, F32, CANONICAL_QNAN_DOUBLE, value) +} /// Inline NaN-box of a raw heap pointer with `POINTER_TAG`. pub(crate) fn nanbox_pointer_inline(blk: &mut LlBlock, ptr_i64: &str) -> String { diff --git a/crates/perry-codegen/src/expr/proven_view_access.rs b/crates/perry-codegen/src/expr/proven_view_access.rs index cc51334c9b..48103214d5 100644 --- a/crates/perry-codegen/src/expr/proven_view_access.rs +++ b/crates/perry-codegen/src/expr/proven_view_access.rs @@ -319,8 +319,14 @@ pub(crate) fn try_lower_proven_view_checked_f64_load( BufferElem::U16 => blk.uitofp(I16, &raw, DOUBLE), BufferElem::I32 => blk.sitofp(I32, &raw, DOUBLE), BufferElem::U32 => blk.uitofp(I32, &raw, DOUBLE), - BufferElem::F32 => blk.fpext(F32, &raw, DOUBLE), - BufferElem::F64 => raw, + // #10779: float lanes are arbitrary user bytes; canonicalise their + // NaNs so they cannot alias a NaN-box tag. Integer lanes cannot be + // NaN and pay nothing. + BufferElem::F32 => { + let widened = blk.fpext(F32, &raw, DOUBLE); + crate::expr::nanbox_inline::canonicalize_lane_f64(blk, &widened) + } + BufferElem::F64 => crate::expr::nanbox_inline::canonicalize_lane_f64(blk, &raw), }; let end = blk.label.clone(); blk.br(&merge_label); diff --git a/crates/perry-codegen/src/expr/ta_param_f64_read.rs b/crates/perry-codegen/src/expr/ta_param_f64_read.rs index 1fb01ac6ac..2be3dabd9f 100644 --- a/crates/perry-codegen/src/expr/ta_param_f64_read.rs +++ b/crates/perry-codegen/src/expr/ta_param_f64_read.rs @@ -299,9 +299,15 @@ fn lower_checked_typed_array_f64_load( let addr = blk.add(I64, &data_base, &off); let ptr = blk.inttoptr(I64, &addr); let raw_elem = blk.load(elem_ty, &ptr); + // #10779: the float kinds are the only ones whose lane can be a NaN, + // and an ArrayBuffer lane is arbitrary user bytes — canonicalise so the + // value cannot alias a NaN-box tag downstream. let val = match conv { - F64Conv::F64 => raw_elem, - F64Conv::F32 => blk.fpext(F32, &raw_elem, DOUBLE), + F64Conv::F64 => crate::expr::nanbox_inline::canonicalize_lane_f64(blk, &raw_elem), + F64Conv::F32 => { + let widened = blk.fpext(F32, &raw_elem, DOUBLE); + crate::expr::nanbox_inline::canonicalize_lane_f64(blk, &widened) + } F64Conv::SInt => blk.sitofp(elem_ty, &raw_elem, DOUBLE), F64Conv::UInt => blk.uitofp(elem_ty, &raw_elem, DOUBLE), }; diff --git a/crates/perry-codegen/src/lower_call/buffer_intrinsic.rs b/crates/perry-codegen/src/lower_call/buffer_intrinsic.rs index 917e86f07a..59fbcaf445 100644 --- a/crates/perry-codegen/src/lower_call/buffer_intrinsic.rs +++ b/crates/perry-codegen/src/lower_call/buffer_intrinsic.rs @@ -420,10 +420,16 @@ pub(super) fn try_emit_buffer_read_intrinsic( "{} = bitcast {} {} to {}", as_float, load_ty, swapped, float_ty )); + // #10779: `buf.readDoubleLE(i)` / `readFloatLE(i)` read arbitrary + // user bytes, exactly like a `Float64Array` lane, so the same + // canonicalisation applies before the value can reach a tag-dispatching + // consumer. Integer `read*` accessors cannot be NaN and pay nothing. if spec.width_bytes == 4 { - LoweredValue::f32(as_float) + let canon = crate::expr::nanbox_inline::canonicalize_lane_f32(blk, &as_float); + LoweredValue::f32(canon) } else { - LoweredValue::f64(as_float) + let canon = crate::expr::nanbox_inline::canonicalize_lane_f64(blk, &as_float); + LoweredValue::f64(canon) } } else { // Integer: keep the raw i32 in the native lattice. Signed reads diff --git a/crates/perry-runtime/src/array/header.rs b/crates/perry-runtime/src/array/header.rs index 17c3fa7baa..44e47191da 100644 --- a/crates/perry-runtime/src/array/header.rs +++ b/crates/perry-runtime/src/array/header.rs @@ -874,6 +874,32 @@ pub extern "C" fn js_array_numeric_value_to_raw_f64(value: f64) -> f64 { value_bits_to_number(value.to_bits()).unwrap_or(f64::NAN) } +/// Collapse ANY NaN to the single canonical quiet NaN. +/// +/// #10779: Perry's tag band is the positive qNaN range `0x7FF8..=0x7FFF`, so a +/// genuine IEEE-754 `f64` NaN whose high mantissa nibble is >= 8 is +/// bit-indistinguishable from a NaN-boxed string / pointer / int32 / singleton. +/// The band cannot be moved — every 64-bit pattern with `exp == 0x7FF` and a +/// non-zero mantissa is a NaN some program may legitimately store — so the +/// invariant has to be established at the SOURCE: the only NaN allowed to enter +/// a NaN-boxed slot is this one. +/// +/// Collapsing EVERY NaN (not only the ones already inside the band) is load +/// bearing, and two hardware behaviours are why: +/// +/// * a **signalling** NaN quiets under arithmetic by setting mantissa bit 51, +/// so `0x7FF7_0000_FFFF_FFFF * 1` becomes `0x7FFF_…` — a forged +/// `StringHeader*`. Every positive sNaN in `0x7FF1..=0x7FF7` maps into the +/// band this way. +/// * `fneg` / `fabs` clear the sign bit, so a NEGATIVE payload NaN such as +/// `0xFFFE_0000_1234_5678` becomes `0x7FFE_…` — a forged int32 — under `-x` +/// or `Math.abs(x)`. +/// +/// With every source canonicalised the only NaN in circulation is +/// `0x7FF8_0000_0000_0000`; quieting it is a no-op and negating it gives +/// `0xFFF8_…`, both outside the band. The property then holds inductively, +/// which is exactly the contract +/// `perry-codegen::type_analysis::expr_produces_canonical_raw_f64` documents. #[inline] pub(crate) fn canonical_raw_f64(value: f64) -> f64 { if value.is_nan() { diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index c44149cf53..9cbef2b459 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -141,6 +141,7 @@ pub(crate) use self::generic_object::{ object_pop as generic_object_pop, object_shift as generic_object_shift, object_sort, object_splice, }; +pub(crate) use self::header::canonical_raw_f64; pub(crate) use self::header::{ array_has_arguments_object_flag, array_window_is_numeric_raw_f64_allow_holes, js_array_is_numeric_f64_layout_resolved, mark_array_as_arguments_object, diff --git a/crates/perry-runtime/src/buffer/dataview.rs b/crates/perry-runtime/src/buffer/dataview.rs index b33f5be9bd..6cbda5bef6 100644 --- a/crates/perry-runtime/src/buffer/dataview.rs +++ b/crates/perry-runtime/src/buffer/dataview.rs @@ -302,21 +302,24 @@ pub fn js_data_view_get(buf_f64: f64, offset_value: f64, kind: DataViewKind, lit u32::from_be_bytes(b) as f64 } } + // #10779: same reasoning as `typedarray::load_at` — these two are + // the only `get*` kinds that can return a NaN, and the bytes are + // whatever the program wrote. DataViewKind::Float32 => { let b = read_bytes::<4>(buf, offset); - if little { + crate::array::canonical_raw_f64(if little { f32::from_le_bytes(b) as f64 } else { f32::from_be_bytes(b) as f64 - } + }) } DataViewKind::Float64 => { let b = read_bytes::<8>(buf, offset); - if little { + crate::array::canonical_raw_f64(if little { f64::from_le_bytes(b) } else { f64::from_be_bytes(b) - } + }) } DataViewKind::BigInt64 => { let b = read_bytes::<8>(buf, offset); diff --git a/crates/perry-runtime/src/buffer/numeric.rs b/crates/perry-runtime/src/buffer/numeric.rs index 56982ee6dc..0dd2a34cc1 100644 --- a/crates/perry-runtime/src/buffer/numeric.rs +++ b/crates/perry-runtime/src/buffer/numeric.rs @@ -214,28 +214,33 @@ pub extern "C" fn js_buffer_read_int32_le(buf_ptr: f64, offset: i32) -> f64 { pub extern "C" fn js_buffer_read_float_be(buf_ptr: f64, offset: i32) -> f64 { let buf = unbox_buffer_ptr(buf_ptr.to_bits()) as *const BufferHeader; let s = buffer_slice_at_or_throw(buf, offset, 4); - f32::from_be_bytes([s[0], s[1], s[2], s[3]]) as f64 + // #10779: buffer bytes are arbitrary; see `array::canonical_raw_f64`. + crate::array::canonical_raw_f64(f32::from_be_bytes([s[0], s[1], s[2], s[3]]) as f64) } #[no_mangle] pub extern "C" fn js_buffer_read_float_le(buf_ptr: f64, offset: i32) -> f64 { let buf = unbox_buffer_ptr(buf_ptr.to_bits()) as *const BufferHeader; let s = buffer_slice_at_or_throw(buf, offset, 4); - f32::from_le_bytes([s[0], s[1], s[2], s[3]]) as f64 + crate::array::canonical_raw_f64(f32::from_le_bytes([s[0], s[1], s[2], s[3]]) as f64) } #[no_mangle] pub extern "C" fn js_buffer_read_double_be(buf_ptr: f64, offset: i32) -> f64 { let buf = unbox_buffer_ptr(buf_ptr.to_bits()) as *const BufferHeader; let s = buffer_slice_at_or_throw_bounds(buf, offset, 8); - f64::from_be_bytes([s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]]) + crate::array::canonical_raw_f64(f64::from_be_bytes([ + s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7], + ])) } #[no_mangle] pub extern "C" fn js_buffer_read_double_le(buf_ptr: f64, offset: i32) -> f64 { let buf = unbox_buffer_ptr(buf_ptr.to_bits()) as *const BufferHeader; let s = buffer_slice_at_or_throw_bounds(buf, offset, 8); - f64::from_le_bytes([s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]]) + crate::array::canonical_raw_f64(f64::from_le_bytes([ + s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7], + ])) } #[no_mangle] diff --git a/crates/perry-runtime/src/typedarray/mod.rs b/crates/perry-runtime/src/typedarray/mod.rs index acba1838fb..a687f003f5 100644 --- a/crates/perry-runtime/src/typedarray/mod.rs +++ b/crates/perry-runtime/src/typedarray/mod.rs @@ -1258,9 +1258,17 @@ pub(crate) unsafe fn load_at(ta: *const TypedArrayHeader, idx: usize) -> f64 { KIND_UINT16 => *(base.add(off) as *const u16) as f64, KIND_INT32 => *(base.add(off) as *const i32) as f64, KIND_UINT32 => *(base.add(off) as *const u32) as f64, - KIND_FLOAT16 => f16_bits_to_f64(*(base.add(off) as *const u16)), - KIND_FLOAT32 => *(base.add(off) as *const f32) as f64, - KIND_FLOAT64 => *(base.add(off) as *const f64), + // #10779: an ArrayBuffer lane is arbitrary user bytes, so a float + // kind is the one element kind whose value can land inside Perry's + // NaN-box tag band. Canonicalise here — this is the single runtime + // choke point every `ta[i]` read funnels through — so the value that + // leaves is a number for every consumer that tag-dispatches it. + // Integer kinds cannot produce a NaN and are left untouched. + KIND_FLOAT16 => { + crate::array::canonical_raw_f64(f16_bits_to_f64(*(base.add(off) as *const u16))) + } + KIND_FLOAT32 => crate::array::canonical_raw_f64(*(base.add(off) as *const f32) as f64), + KIND_FLOAT64 => crate::array::canonical_raw_f64(*(base.add(off) as *const f64)), // BigInt kinds return a NaN-boxed BigInt (not a plain Number), so // `ta[i]` round-trips as a `bigint`. The raw slot bits are the BigInt's // low limb; widen via the signed/unsigned constructor for `> i64::MAX`. diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index 199ece7b6a..ec86d4d650 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -152,6 +152,7 @@ const BUILD_CACHE_ENV_VARS: &[&str] = &[ "PERRY_GC_MOVING_LOOP_POLLS", "PERRY_CANONICAL_I32_LOCALS", "PERRY_CANONICAL_STR_LOCALS", + "PERRY_NANBOX_CANON", "PERRY_CONCAT_SITE_CACHE", "PERRY_CODEGEN_UNITS", "PERRY_CODEGEN_UNIT_BYTES", diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 21868bf7f4..7d18b4fe10 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -1297,6 +1297,16 @@ fn compute_object_cache_key_with_env( .as_deref() .unwrap_or(""), ); + // #10779 — NaN canonicalisation on ArrayBuffer float-lane reads. + // `=0`/`off`/`false` drops the `fcmp uno` + `select` pair from every + // Float64Array / Float32Array / DataView float read, which changes the + // emitted IR / .o bytes — a warm cache must not serve an object built + // under the other setting. It exists so the fix's cost can be measured + // with one compiler binary; it is not a supported runtime configuration. + h.field( + "env_nanbox_canon", + env_var("PERRY_NANBOX_CANON").as_deref().unwrap_or(""), + ); // Representation-selection Phase 3a — canonical string locals // (tagged-at-rest): `=0`/`off`/`false` reverts the lowerings that consult a // SELECTED `Str` local (`+=` tag-dispatch, direct string compares, the From 76aebd2fd5b29ce7284b213c4664313ce2be7389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 20 Sep 2026 08:22:51 +0200 Subject: [PATCH 3/3] chore: release merge train 235 as v0.5.1614 --- CLAUDE.md | 2 +- Cargo.lock | 136 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 70 insertions(+), 70 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b8bd5d2b0e..acb4699f85 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1613 +**Current Version:** 0.5.1614 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 322b6d3b65..daae3b7ca6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5565,7 +5565,7 @@ checksum = "1473d470930ed48574515a25df34900f3af89c6fa422d903e019121312a9f13e" [[package]] name = "perry" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "anyhow", "base64 0.22.1", @@ -5629,7 +5629,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "perry-dispatch", "serde", @@ -5637,7 +5637,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "cc", "libc", @@ -5646,7 +5646,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "aho-corasick", "anyhow", @@ -5663,7 +5663,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "anyhow", "perry-hir", @@ -5671,7 +5671,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "anyhow", "perry-hir", @@ -5679,7 +5679,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "anyhow", "perry-dispatch", @@ -5688,7 +5688,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "anyhow", "perry-hir", @@ -5696,7 +5696,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "anyhow", "base64 0.22.1", @@ -5708,7 +5708,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "anyhow", "perry-hir", @@ -5716,7 +5716,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "async-trait", "clap", @@ -5740,14 +5740,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "serde", "serde_json", @@ -5755,7 +5755,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1613" +version = "0.5.1614" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5766,7 +5766,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "anyhow", "clap", @@ -5781,7 +5781,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "block2", "objc2", @@ -5791,7 +5791,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "argon2", "perry-ffi", @@ -5800,7 +5800,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "bcrypt", "perry-ffi", @@ -5808,7 +5808,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "perry-ffi", "rusqlite", @@ -5816,7 +5816,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "perry-ffi", "scraper", @@ -5824,7 +5824,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "chrono", "cron", @@ -5834,7 +5834,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "perry-ffi", "rust_decimal", @@ -5842,7 +5842,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5850,7 +5850,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "perry-ffi", "perry-runtime", @@ -5858,14 +5858,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fetch" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "bytes", "lazy_static", @@ -5878,7 +5878,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "base64 0.22.1", "bytes", @@ -5910,7 +5910,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "lazy_static", "perry-ffi", @@ -5920,7 +5920,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "chrono", "perry-ffi", @@ -5928,7 +5928,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "bson", "futures-util", @@ -5940,7 +5940,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "chrono", "perry-ffi", @@ -5952,7 +5952,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "bytes", "perry-ffi", @@ -5967,7 +5967,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "const-oid 0.10.2", "der 0.8.2", @@ -5986,7 +5986,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "lettre", "perry-ffi", @@ -5996,7 +5996,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "notify", "perry-ffi", @@ -6008,7 +6008,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "perry-ffi", "printpdf", @@ -6016,7 +6016,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "perry-ffi", "sqlx", @@ -6025,7 +6025,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "fast_image_resize", "image", @@ -6036,7 +6036,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "lazy_static", "perry-ffi", @@ -6045,7 +6045,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "anyhow", "perry-ffi", @@ -6065,7 +6065,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "perry-ffi", "perry-runtime", @@ -6074,7 +6074,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "futures-util", "lazy_static", @@ -6087,7 +6087,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "brotli", "flate2", @@ -6097,7 +6097,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6107,7 +6107,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "anyhow", "perry-api-manifest", @@ -6127,11 +6127,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1613" +version = "0.5.1614" [[package]] name = "perry-parser" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "anyhow", "perry-diagnostics", @@ -6144,7 +6144,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "perex", "regex", @@ -6152,7 +6152,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "ahash", "base64 0.22.1", @@ -6210,14 +6210,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6301,21 +6301,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "dirs", "perry-ffi", @@ -6325,7 +6325,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "base64 0.22.1", "jni", @@ -6340,7 +6340,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "rand 0.10.2", "serde", @@ -6350,7 +6350,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6373,7 +6373,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "base64 0.22.1", "block2", @@ -6390,7 +6390,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "base64 0.22.1", "block2", @@ -6407,7 +6407,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1613" +version = "0.5.1614" [[package]] name = "perry-ui-test" @@ -6418,11 +6418,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1613" +version = "0.5.1614" [[package]] name = "perry-ui-tvos" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "base64 0.22.1", "block2", @@ -6439,7 +6439,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "base64 0.22.1", "block2", @@ -6456,7 +6456,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "block2", "libc", @@ -6470,7 +6470,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "base64 0.22.1", "libc", @@ -6489,7 +6489,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "base64 0.22.1", "libc", @@ -6502,7 +6502,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "anyhow", "base64 0.22.1", @@ -6517,7 +6517,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1613" +version = "0.5.1614" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index c35ee8b61b..111481374b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -321,7 +321,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1613" +version = "0.5.1614" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"