From 26ba39eafaca9fb93573451a32af21d477b22be4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 8 Jul 2026 13:49:41 +0200 Subject: [PATCH] fix(runtime): ToInt32/ToUint32 for the dynamic bitwise helpers and Math.clz32 The six dynamic bitwise/shift helpers (js_dynamic_{shr,shl,bitand,bitor,bitxor, ushr}) and Math.clz32 converted their f64 operand with `x as i64 as i32/u32`, which SATURATES for |x| >= 2^63 instead of ECMAScript ToInt32/ToUint32 (truncate toward zero, reduce modulo 2^32). So on the fully-dynamic (any-typed operand) path, e.g. `(1e20 as any) | 0`, `(1e20 as any) >>> 0`, and Math.clz32(1e20) produced wrong results (0/-1 instead of 1661992960 / 1). Route all seven through shared dyn_to_int32/dyn_to_uint32 helpers using the same rem_euclid form already proven correct in js_dynamic_bitnot and js_math_to_int32. Adds test_gap_toint32_dynamic_bitops (byte-matched vs node). --- crates/perry-runtime/src/math.rs | 7 ++- .../perry-runtime/src/value/dynamic_arith.rs | 59 ++++++++++++------- test-files/test_gap_toint32_dynamic_bitops.ts | 14 +++++ 3 files changed, 56 insertions(+), 24 deletions(-) create mode 100644 test-files/test_gap_toint32_dynamic_bitops.ts diff --git a/crates/perry-runtime/src/math.rs b/crates/perry-runtime/src/math.rs index eb4f532430..811310b2b7 100644 --- a/crates/perry-runtime/src/math.rs +++ b/crates/perry-runtime/src/math.rs @@ -217,11 +217,12 @@ pub extern "C" fn js_math_f16round(value: f64) -> f64 { /// Math.clz32(x) -> number — count leading zeros of 32-bit integer #[no_mangle] pub extern "C" fn js_math_clz32(x: f64) -> f64 { - // JS spec: convert to UInt32 first - let n = if x.is_nan() || x.is_infinite() { + // JS spec: ToUint32(x) first. `x as i64 as u32` SATURATES for |x| >= 2^63 + // (Math.clz32(1e20) came out 0 instead of 1); use truncate-mod-2^32. + let n = if !x.is_finite() { 0u32 } else { - x as i64 as u32 + x.trunc().rem_euclid(4_294_967_296.0) as u32 }; n.leading_zeros() as f64 } diff --git a/crates/perry-runtime/src/value/dynamic_arith.rs b/crates/perry-runtime/src/value/dynamic_arith.rs index e9673679ad..e9f9c90ace 100644 --- a/crates/perry-runtime/src/value/dynamic_arith.rs +++ b/crates/perry-runtime/src/value/dynamic_arith.rs @@ -524,12 +524,30 @@ pub unsafe extern "C" fn js_dynamic_bitnot(a: f64) -> f64 { // toward zero and reduce modulo 2^32. `as i64` is NOT equivalent — it // saturates for |v| >= 2^63, so `~(1e20)` came out as `~(-1)` == 0 // instead of -1661992961 (CodeRabbit review on #5466). - let a_i32 = if a_num.is_nan() || !a_num.is_finite() { - 0i32 + (!dyn_to_int32(a_num)) as f64 +} + +/// ES ToInt32 (7.1.6): truncate toward zero, reduce modulo 2^32, reinterpret as +/// signed. NaN / ±0 / ±Infinity map to 0. `v as i64 as i32` is WRONG — Rust's +/// float→int cast SATURATES for |v| >= 2^63, so e.g. ToInt32(1e20) came out as +/// -1 instead of 1661992960 (#6079). +#[inline] +fn dyn_to_int32(v: f64) -> i32 { + if !v.is_finite() { + 0 } else { - a_num.trunc().rem_euclid(4294967296.0) as u32 as i32 - }; - (!a_i32) as f64 + (v.trunc().rem_euclid(4_294_967_296.0) as u32) as i32 + } +} + +/// ES ToUint32 (7.1.7): as ToInt32 but reinterpreted as unsigned. +#[inline] +fn dyn_to_uint32(v: f64) -> u32 { + if !v.is_finite() { + 0 + } else { + v.trunc().rem_euclid(4_294_967_296.0) as u32 + } } /// Dynamic right shift: BigInt >> if either operand is BigInt, else i32 >> for numbers. @@ -540,10 +558,9 @@ pub unsafe extern "C" fn js_dynamic_shr(a: f64, b: f64) -> f64 { if both_bigint_or_throw(a, b) { return dynamic_bigint_binary_op(a, b, crate::bigint::js_bigint_shr); } - // JS ToInt32: f64 -> i64 -> i32 (wrapping), NOT f64 -> i32 (saturating). - // Rust `f64 as i32` saturates at i32::MAX for values >= 2^31, but JS wraps. - let ai = (a as i64) as i32; - let bi = ((b as i64) as i32) & 0x1f; + // JS ToInt32(a); ToUint32(b) & 0x1F for the shift count (#6079). + let ai = dyn_to_int32(a); + let bi = dyn_to_uint32(b) & 0x1f; (ai >> bi) as f64 } @@ -555,9 +572,9 @@ pub unsafe extern "C" fn js_dynamic_shl(a: f64, b: f64) -> f64 { if both_bigint_or_throw(a, b) { return dynamic_bigint_binary_op(a, b, crate::bigint::js_bigint_shl); } - // JS ToInt32: f64 -> i64 -> i32 (wrapping), NOT f64 -> i32 (saturating). - let ai = (a as i64) as i32; - let bi = ((b as i64) as i32) & 0x1f; + // JS ToInt32(a); ToUint32(b) & 0x1F for the shift count (#6079). + let ai = dyn_to_int32(a); + let bi = dyn_to_uint32(b) & 0x1f; (ai << bi) as f64 } @@ -571,8 +588,8 @@ pub unsafe extern "C" fn js_dynamic_bitand(a: f64, b: f64) -> f64 { if both_bigint_or_throw(a, b) { return dynamic_bigint_binary_op(a, b, crate::bigint::js_bigint_and); } - // JS ToInt32: f64 -> i64 -> i32 (wrapping), NOT f64 -> i32 (saturating). - (((a as i64) as i32) & ((b as i64) as i32)) as f64 + // JS ToInt32 both operands (#6079). + (dyn_to_int32(a) & dyn_to_int32(b)) as f64 } /// Dynamic bitwise OR: BigInt | if either operand is BigInt, else i32 | for numbers. @@ -583,8 +600,8 @@ pub unsafe extern "C" fn js_dynamic_bitor(a: f64, b: f64) -> f64 { if both_bigint_or_throw(a, b) { return dynamic_bigint_binary_op(a, b, crate::bigint::js_bigint_or); } - // JS ToInt32: f64 -> i64 -> i32 (wrapping), NOT f64 -> i32 (saturating). - (((a as i64) as i32) | ((b as i64) as i32)) as f64 + // JS ToInt32 both operands (#6079). + (dyn_to_int32(a) | dyn_to_int32(b)) as f64 } /// Dynamic bitwise XOR: BigInt ^ if either operand is BigInt, else i32 ^ for numbers. @@ -595,8 +612,8 @@ pub unsafe extern "C" fn js_dynamic_bitxor(a: f64, b: f64) -> f64 { if both_bigint_or_throw(a, b) { return dynamic_bigint_binary_op(a, b, crate::bigint::js_bigint_xor); } - // JS ToInt32: f64 -> i64 -> i32 (wrapping), NOT f64 -> i32 (saturating). - (((a as i64) as i32) ^ ((b as i64) as i32)) as f64 + // JS ToInt32 both operands (#6079). + (dyn_to_int32(a) ^ dyn_to_int32(b)) as f64 } /// Dynamic exponentiation: `BigInt ** BigInt` when both operands are BigInt @@ -628,9 +645,9 @@ pub unsafe extern "C" fn js_dynamic_ushr(a: f64, b: f64) -> f64 { let err = crate::error::js_typeerror_new(s); crate::exception::js_throw(js_nanbox_pointer(err as i64)); } - // JS ToUint32 then logical shift, count masked to 5 bits. - let ai = (a as i64) as u32; - let bi = ((b as i64) as i32 as u32) & 0x1f; + // JS ToUint32(a) then logical shift; ToUint32(b) & 0x1F count (#6079). + let ai = dyn_to_uint32(a); + let bi = dyn_to_uint32(b) & 0x1f; (ai >> bi) as f64 } diff --git a/test-files/test_gap_toint32_dynamic_bitops.ts b/test-files/test_gap_toint32_dynamic_bitops.ts new file mode 100644 index 0000000000..0eefaf24f4 --- /dev/null +++ b/test-files/test_gap_toint32_dynamic_bitops.ts @@ -0,0 +1,14 @@ +// ToInt32/ToUint32 for |x| >= 2^63 must truncate-mod-2^32, not saturate. +// Force the dynamic (any-typed) bitwise/shift helpers + Math.clz32. #6079. +const big: any = 1e20; +const neg: any = -1e20; +const b2: any = 5e9; +const b3: any = 3e9; +console.log(Math.clz32(big), Math.clz32(1e40), Math.clz32(0), Math.clz32(-1), Math.clz32(2 ** 32)); +console.log(big | 0, neg | 0, big >>> 0); +console.log(big & 0xffff, big | 1, big ^ 0); +console.log(big >> 4, big << 4, big >>> 4); +console.log(b2 | 0, b2 >>> 0, b2 & 7, b3 | 0); +// sanity: small any-typed operands unchanged +const s: any = 5; +console.log(s | 2, s & 3, s ^ 1, s << 1, s >> 1, s >>> 1, ~s);