Skip to content

Commit 29cbd45

Browse files
committed
fix(runtime): NaN equality semantics — indexOf(NaN) returns -1 (v0.5.823)
Pre-fix `[1, NaN, 3].indexOf(NaN)` returned 1 (the NaN's index). Per ECMA-262, strict equality (===) follows IEEE 754 where NaN !== NaN, but SameValueZero (used by includes/Map/Set keys) considers NaN === NaN. Perry used one helper for both and got the strict-equality side wrong. The fast path `if abits == bbits { return 1; }` matched two identical NaN bit patterns (0x7FF8000000000000). The fast-path comment assumed "Perry doesn't produce raw IEEE NaN as a user value" but a literal `NaN` in source lowers to exactly f64::NAN. Fix: - js_jsvalue_equals: check top16 == 0x7FF8 BEFORE the bits-match fast path and return 0 for NaN-vs-NaN. - New js_jsvalue_same_value_zero that returns 1 for both-NaN. - js_array_includes_jsvalue routed through SameValueZero so [NaN].includes(NaN) keeps returning true. Verified byte-identical to Bun across 15 cases (===, includes, indexOf, Set-of-NaN, Map-with-NaN-key). 26/28 gap parity tests pass. Probed while loop-testing real-app patterns.
1 parent 0d9e6b9 commit 29cbd45

6 files changed

Lines changed: 103 additions & 74 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
Detailed changelog for Perry. See CLAUDE.md for concise summaries.
44

5+
## v0.5.823 — fix(runtime): NaN equality semantics — `arr.indexOf(NaN)` now returns -1 (was: the NaN's index), `[NaN].includes(NaN)` keeps returning `true`, and `[NaN] === [NaN]` element comparison is now spec-compliant. Pre-fix `[1, NaN, 3].indexOf(NaN)` returned `1` because `js_jsvalue_equals`'s `abits == bbits` fast path matched two identical NaN bit patterns and reported equal. Per ECMA-262: strict equality (`===`) follows IEEE 754 where `NaN === NaN` is `false`, but SameValueZero (used by `includes` / Map / Set keys) considers `NaN` equal to itself. Pre-fix Perry used one helper for both, getting one of the two semantics wrong depending on which way the bug got fixed. The comment on the fast path admitted the gap ("Perry doesn't produce raw IEEE NaN as a user value, so this is safe") but a literal `NaN` in source code lowers to exactly `f64::NAN` (`0x7FF8000000000000`), so the assumption was wrong. **Fix** in `crates/perry-runtime/src/value.rs`: (1) `js_jsvalue_equals` now checks `top16 == 0x7FF8` (the canonical quiet-NaN bit pattern) on both sides BEFORE the `abits==bbits` fast path, returning 0 for NaN-vs-NaN. NaN-boxed tagged values (top16 0x7FFA–0x7FFF) never collide. (2) New `js_jsvalue_same_value_zero` that adds the inverse — returns 1 when both are raw IEEE NaN, otherwise delegates to `js_jsvalue_equals`. (3) `js_array_includes_jsvalue` in `crates/perry-runtime/src/array.rs` switched to the SameValueZero helper. **Validation**: 15-case `/tmp/probe_nan_eq.ts` covers `NaN === NaN`, `NaN === 5`, `5 === 5`, string/object/array reference equality, `includes(NaN)`, `indexOf(NaN)`, Set-with-NaN-keys (dedup to 1), Map-with-NaN-key — all byte-identical to Bun. 26/28 gap parity tests pass (baseline preserved — the 2 failures are the pre-existing console.time + lookbehind-regex categorical gaps). Probed while loop-testing real-app patterns. **Out of scope (separate followups, still on the backlog)**: `Array.from(string)` returns garbage f64s, `Array.from(arraylike)` returns 1 element, `Array.from(_, mapFn)` ignores mapFn, `flat(Infinity)` stops early, `copyWithin` is a no-op, `.finally()` closure mutation doesn't propagate, `arr[Symbol.iterator]()` (no ArrayIterator), `string.match(rx).groups` (no side-table), tagged-template `.raw`, async-fn throw-after-await escaping as uncaught.
6+
57
## v0.5.822 — fix(codegen,runtime): #687 `ClassRef.staticMethod(...)` dispatch — Effect Schema.ts `BigIntFromSelf.pipe(...)` no longer throws `(number).pipe is not a function`. **Symptom (#687 / #321 Effect end-to-end).** At v0.5.815, `import {} from "effect"` from the bare repro crashed deep inside `effect/src/Schema.ts__init` (offset +54448 in the init body) with `TypeError: (number).pipe is not a function`. The throw fired from `js_native_call_method`'s primitive-receiver catch-all because the receiver passed to `js_native_call_method(..., "pipe", ...)` decoded as `0x7FFE_0000_0000_0324` — an INT32-NaN-boxed value whose payload `0x324 = 804` matched class id `BigIntFromSelf`. The class was being used AS a value (its NaN-boxed class id) where an INSTANCE pointer was expected. **Root cause.** Effect's `Schema.ts` declares `export class BigIntFromSelf extends make<bigint>(AST.bigIntKeyword) {}` — the parent is a CALL expression returning an anonymous `class SchemaClass { static pipe() { … } static annotations() { … } … }`. perry's HIR lowering (`crates/perry-hir/src/lower_decl.rs:1576`) only tracks `extends_name` when the super-class is an `Ident` or `Member` — a `CallExpr` super-class lands in the `(None, None, None)` fallback, so `BigIntFromSelf.extends_name = None` and the inheritance chain for static-method dispatch is broken. The call site `BigIntFromSelf.pipe(...)` then lowered to `Expr::Call { callee: Expr::PropertyGet { object: Expr::ClassRef("BigIntFromSelf"), property: "pipe" } }`. Codegen's `has_static_method` check returned `false` (BigIntFromSelf has no OWN static methods; the inherited `pipe` lives on the unnamed parent), so the call fell through to the dynamic-instance-dispatch tower in `lower_call.rs::needs_dynamic_dispatch`. The tower's `js_object_get_class_id(handle=804)` returned 0 (804 < `GC_HEADER_SIZE + 0x1000` early-return guard), no implementor matched, and the default branch invoked `js_native_call_method(<INT32 NaNbox 0x324>, "pipe", …)` — primitive-receiver throw. The earlier #685 fix (defensive skip of `init_static_fields` when init references out-of-scope locals) avoided a different bogus emit but didn't address the "inner-class-hoisting + static-inheritance-through-CallExpr-super-class" architectural gap. **Fix — codegen side (`crates/perry-codegen/src/lower_call.rs::lower_call`).** New arm inserted before the dynamic-instance-dispatch path: when the receiver is `Expr::ClassRef(cls_name)`, walk `cls_name`'s own static methods + `extends_name` chain looking for `property`. If found, emit a direct call to the registered `perry_static_<modprefix>__<class>__<method>` symbol with `js_implicit_this_set(<ClassRef>)` bracketing the call so the method body's `this` references the class (Effect's `static pipe() { return pipeArguments(this, arguments) }` needs the class itself as `this`). If no static method matches across the visible chain (Effect's BigIntFromSelf case — its parent is unnamed so the chain is empty), fall back to lowering the args for side effects and returning the ClassRef itself: chainable `.pipe()`/`.annotations()` in module-init then propagate the class ref forward, keeping init alive past the previously-fatal site. The returned value is NOT semantically equivalent to Effect's transformed schema, but it unblocks `Schema.ts__init` for the #321 DoD repro. **Fix — runtime side (`crates/perry-runtime/src/object.rs::js_native_call_method`).** Parallel defensive: when `js_native_call_method` is reached with an INT32-NaN-boxed receiver whose payload is in `REGISTERED_CLASS_IDS` (the same registry that `js_value_typeof` consults to distinguish class refs from real int32 numerics, populated by `js_register_class_id` at module init), short-circuit and return the receiver. Catches the chained shape that the codegen-side fix misses — `Schema.NonNegative.pipe(int()).annotations({...})` produces a ClassRef out of the first `.pipe()` via the codegen-side defensive return, then the chained `.annotations(...)` reaches the runtime with that ClassRef as the receiver but no longer matches the static `Expr::ClassRef` pattern at codegen time (the receiver is now an `Expr::Call` result). The runtime check uses `REGISTERED_CLASS_IDS.read().contains(&payload)` so only real perry class refs short-circuit; primitive numeric receivers (`(42).pipe is not a function`) still throw. **Linking gotcha hit during validation.** `target/release/libperry_jsruntime.a` bundles its own copy of perry_runtime as an rlib dependency; rebuilding perry-runtime alone via `cargo build -p perry-runtime` updates `target/release/libperry_runtime.a` but the `js_native_call_method` symbol inside libperry_jsruntime.a stays stale, and on macOS the linker takes the first-definition-wins from jsruntime (listed first in `link.rs::link_executable` for the V8 lazy-load workaround). Must rebuild `perry-jsruntime` whenever perry-runtime changes for these symbols. The auto-optimize separately builds `target/perry-auto-<hash>/release/libperry_runtime.a` from source each invocation, but that's listed SECOND on the link line and only fills gaps. **Validation.** `effect-321` bare repro: v0.5.815 crashed at `Schema_ts__init+54448` with `(number).pipe is not a function`; v0.5.822 advances past every `.pipe()` / `.annotations()` site in Schema.ts and now crashes much deeper, inside `perry_closure_node_modules_effect_src_Schema_ts__212+2000` (called from closure 214 from `Schema_ts__init+12272`) with `TypeError: pipe is not a function` — a real-object-receiver throw from the issue-#648 catch-all, indicating a DIFFERENT inner-closure shape whose receiver isn't a ClassRef. That deeper failure is tracked as a #687 follow-up. Gap parity tests: 26/28 PASS (the 2 failures — `test_gap_console_methods`, `test_gap_regexp_advanced` — are pre-existing categorical gaps tracked in CLAUDE.md). No regressions surfaced from the new ClassRef dispatch path on any of the gap suite. **What's left (followups to #687).** The pragmatic chainable-no-op return is a parity-blocker workaround, not a semantic fix: Effect's `pipe()` returns a NEW transformed schema, not the original class. A proper resolution either (a) tracks `extends <CallExpr>` parent-class identity by inspecting the callee function's return statements at HIR lowering (works for `make()` returning `class SchemaClass {…}`; brittle for higher-order factories), or (b) registers each class's static methods in a runtime by-name registry (parallel to `js_register_class_method` for instance methods) and adds a `js_class_static_method_call(class_id, name, args)` dispatcher that walks a runtime parent chain populated by codegen at class-extends-CallExpr evaluation. The chainable-no-op also doesn't preserve `.pipe()` argument side effects beyond the first call's args lowering — fine for module-init constant chains, problematic if the args carry expensive expressions. The new "pipe is not a function" failure at `Schema_ts__212+2000` is the next blocker on the #321 DoD path.
68

79
## v0.5.821 — fix(codegen,runtime): `Array.prototype.fill(value, start, end)` 2- and 3-arg forms now compile and apply the spec's negative-index + clamp rules. Pre-fix `arr.fill(0, 1, 4)` failed at compile time with `perry-codegen: Array.fill expects 1 arg, got 3` — the codegen arm in `crates/perry-codegen/src/lower_array_method.rs:479` only accepted the 1-arg whole-array form. Real-app code commonly uses `fill(v, s, e)` to reset a slice (e.g. `buf.fill(0, 0, sz)` to zero a prefix, `cells.fill(false, fromIdx, toIdx)` to clear a board region) — the broken codegen path forced users to choose between a manual `for` loop or refactoring to materialize a fresh array. **Fix.** Codegen: extend the `"fill"` arm to dispatch on arg count: 1-arg keeps the existing `js_array_fill` whole-array fast path; 2- and 3-arg lower start (and optional end, defaulting to `+Infinity`) to doubles and call the new `js_array_fill_range`. Runtime: new `js_array_fill_range(arr, value, start, end)` in `crates/perry-runtime/src/array.rs` (~+50 LOC) applies ECMA-262 §23.1.3.6: negative indices count from the end (`len + idx`), then clamp to `[0, len]`; `end > len` clamps to `len`; `start ≥ end` is no-op; NaN coerces to 0; `+Infinity` end clamps to length (used as the implicit default for the 2-arg form). **Validation.** `/tmp/probe_arr_ops.ts::fill-range` (`[1,2,3,4,5].fill(0, 1, 4)`) now emits `[1,0,0,0,5]` matching Bun (was: compile-error). 26/28 gap parity tests pass (baseline preserved). Probed while loop-testing real-app patterns. **Out of scope (separate followups, surfaced in the same probe).** `Array.from("hello")` returns garbage f64 values instead of `["h","e","l","l","o"]` — perry's `Array.from` treats StringHeader as if it were ArrayHeader and reads bytes as f64 elements. `Array.from({length: N}, mapFn)` ignores the map fn and `length`-property-iteration. `Array.from({length, 0:..., 1:..., 2:...})` (array-like) only returns 1 element. `arr.indexOf(NaN)` returns the NaN index instead of -1 (spec: indexOf uses ===; NaN !== NaN). `arr.flat(Infinity)` stops one level short — Infinity depth treated as 1 or 2. `arr.copyWithin(0, 3)` is a no-op (returns the array unchanged); should overwrite indices `[0, len)` with the slice starting at index 3.

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
88

99
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.
1010

11-
**Current Version:** 0.5.822
11+
**Current Version:** 0.5.823
1212

1313

1414
## TypeScript Parity Status

0 commit comments

Comments
 (0)