Skip to content

Commit 5be1745

Browse files
committed
fix(runtime): Array.from(string) splits by Unicode codepoint (v0.5.825)
Pre-fix `Array.from("hello")` returned five mantissa-shaped garbage numbers because `js_array_clone` had per-receiver arms for Set, Map, typed arrays, and Buffer but no detection for string sources. The array-memcpy fall-through reinterpreted the StringHeader's UTF-8 bytes as f64 elements. Same path was reached by `[..."abc"]` spread. Fix: detect string source at the top of js_array_clone (NaN-box STRING_TAG or GcHeader.obj_type == GC_TYPE_STRING) and route to a new helper that iterates via `str::chars()` (one Unicode codepoint per element, surrogate pairs collapse to one) and allocates a single-char StringHeader per element. Verified byte-identical to Bun across 7 cases (ASCII, BMP, surrogate- pair emoji, empty, single-char, spread, typeof). 26/28 gap parity tests pass. Probed while loop-testing real-app patterns.
1 parent 5b1c520 commit 5be1745

5 files changed

Lines changed: 144 additions & 69 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.825 — fix(runtime): `Array.from(string)` splits by Unicode codepoint instead of emitting garbage f64s. Pre-fix `Array.from("hello")` returned five mantissa-shaped numbers like `[2.5e-323, 1.9e+214, ...]` — `js_array_clone` fell through to the array-memcpy path on a StringHeader source, reading the underlying UTF-8 bytes as 8-byte f64 slots. Same path was reached by `[..."abc"]` spread and `for (const c of someString)` iteration. **Root cause.** `crates/perry-runtime/src/array.rs::js_array_clone` had per-receiver early dispatch arms for Set, Map, typed arrays, and Buffer/Uint8Array, but no detection for `GC_TYPE_STRING` or `top16==STRING_TAG` sources. StringHeader and ArrayHeader both start with a u32 length, so the array-memcpy path read what looked like a sensible `length` value (the UTF-16 code-unit count) and then `ptr::copy_nonoverlapping` 8 bytes per element from the string's data region — UTF-8 bytes reinterpreted as IEEE 754 doubles. **Fix.** New string-source arm at the top of `js_array_clone` that detects either a NaN-boxed STRING_TAG (top16=0x7FFF) or a raw pointer whose preceding GcHeader has `obj_type == GC_TYPE_STRING`, then routes to a new private helper `js_array_from_string_codepoints` that iterates the source by Unicode codepoint via `str::chars()` and emits each as a 1-codepoint heap string. Pre-counts the codepoints to size the result exactly, then encodes each `char` back to UTF-8 via `encode_utf8` into a 4-byte scratch buffer and allocates a per-element StringHeader via `js_string_from_bytes`. The element is NaN-boxed with `js_nanbox_string` so the storage slot carries the STRING_TAG. Surrogate-pair handling: `str::chars()` yields one `char` per scalar value, so UTF-16 surrogate pairs (like an emoji's `0xD83C 0xDF89`) materialize as a single element — matches the spec's String Iterator behavior (ECMA-262 §22.1.5). **Validation.** 7-case `/tmp/probe_arr_from_str.ts` (ASCII "hello", BMP "aéz", surrogate-pair "a🎉b", empty, single-char, `[..."abc"]` spread, `typeof arr[0]`) all byte-identical to Bun. 26/28 gap parity tests pass (baseline preserved). Probed while loop-testing real-app patterns; same probe surfaced other Array.from variants (`Array.from(arraylike)`, `Array.from(_, mapFn)`) which remain on the followup backlog. **Out of scope (still on the backlog)**: `Array.from({length, 0:..., 1:...})` (array-like), `Array.from(iter, mapFn)` (map-fn ignored), `flat(Infinity)`, `copyWithin`, `.finally()` closure mutation, `arr[Symbol.iterator]()`, `string.match(rx).groups`, tagged-template `.raw`, async-fn throw-after-await.
6+
57
## v0.5.824 — fix(api-manifest): catch up 14 dispatch-table entries that lacked API_MANIFEST counterparts. `cargo test --release --workspace` was red on `every_dispatch_entry_has_manifest_counterpart` (`crates/perry-codegen/tests/manifest_consistency.rs:50`) after the v0.5.815 release-prep sweep. Drift accumulated during the ~100 patch versions since the last manifest catch-up: `fastify::type` (request type accessor), `better-sqlite3::raw` (raw-output statement mode), and 12 perry/tui rows from the v0.5.810 #679 ink-API ergonomics work — `boxSetPaddingEach`, `boxSetFlexShrink`, `boxSetFlexBasis`, `boxSetFlexBasisPct`, `boxSetWidthPct`, `boxSetHeightPct`, `TextStyled`, `Table`, `Tabs`, `InputAt`, `AnimatedSpinner`, `useStateTuple`. Added all 14 rows to `crates/perry-api-manifest/src/entries.rs` next to existing per-module blocks; tightened 5 of them where the dispatch-table param shape was richer than `&[p_any]` would imply (TextStyled = 4 params, Table/Tabs = 3 each, InputAt = 2, AnimatedSpinner = 2). All 4 `manifest_consistency` tests now pass. Pure metadata catch-up; no runtime impact. Surfaced by `scripts/release_sweep.sh` tier 1 during release prep for the v0.5.815 stability checkpoint; the gate is in service of the #463 unimplemented-API check.
68

79
## 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.

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.824
11+
**Current Version:** 0.5.825
1212

1313

1414
## TypeScript Parity Status

0 commit comments

Comments
 (0)