Skip to content

Commit b6d3cb9

Browse files
committed
fix(web): #133 five compounding --target web (WASM) bugs (v0.5.158)
(1) String methods unrouted — `str.charCodeAt(i)` and any String.prototype method the codegen doesn't special-case fell through __classDispatch and returned undefined. Added a typeof fast-path at the top of __classDispatch in wasm_runtime.js that dispatches to String/Number/Array prototypes. (2) Math.sin/cos/tan/atan2/exp/etc. returned undefined on web. HIR lowers to Expr::Math* nodes but the WASM codegen had no handlers for the trig family — all fell through to I64Const(TAG_UNDEFINED). Added handlers routing through emit_memcall("math_<fn>", N), added Expr::MathExp variant (HIR + JS + LLVM targets) for the one Math function not already represented, and added matching __memDispatch entries + bridge_names intern list entries. (3) xs.push(1); xs.length === 0 on any top-level const xs: number[] = []. Expr::ArrayPush/Pop/Shift/Unshift/PushSpread/Splice resolved the receiver via local_map only, missing top-level lets that live in module_let_globals. Added emit_local_or_global_get helper mirroring Expr::LocalGet's lookup order; replaced the 7 ad-hoc lookups. (4) Firefox NaN canonicalization at the JS↔WASM boundary stripped STRING/POINTER/INT32 tag payloads when Numbers crossed a JS function boundary. Split namespace wrapping: rt keeps legacy wrapForI64 (async- funcimpl compat); new wrapFfiForI64 for ffi namespace decodes BigInt args via __bitsToJsValue. Both honour fn.__rawBigint escape hatch. Exposed __perryJsValueToBits / __perryBitsToJsValue as BigInt-safe companions to the existing f64 helpers. (5) INT32-tagged constants crashed wasm-bindgen with TypeError. Added INT32_TAG decoding to both __bitsToJsValue instances (sign-extended 32-bit Number via BigInt.asIntN(32, low)). Item 2 from the issue (imported Key.ENTER resolves to TAG_UNDEFINED) is deferred — needs a concrete repro against Bloom's module structure.
1 parent 1353551 commit b6d3cb9

8 files changed

Lines changed: 273 additions & 69 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 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.157
11+
**Current Version:** 0.5.158
1212

1313
## TypeScript Parity Status
1414

@@ -153,6 +153,7 @@ First-resolved directory cached in `compile_package_dirs`; subsequent imports re
153153

154154
Keep entries to 1-2 lines max. Full details in CHANGELOG.md.
155155

156+
- **v0.5.158** — Fix #133: five compounding `--target web` (WASM) bugs reported while porting a Bloom Engine game. (1) **String methods unrouted** — `"hello".charCodeAt(i)` / `str.codePointAt(i)` / any `String.prototype` method the codegen doesn't special-case fell through `__classDispatch` (primitives have no `__class__`, aren't in `__uiMethodMap`) and returned `undefined`. Added a typeof fast-path at the top of `__classDispatch` in `crates/perry-codegen-wasm/src/wasm_runtime.js` that dispatches to `String.prototype` / `Number.prototype` / `Array.prototype` for any method whose `typeof obj` matches — strings/numbers now get the full JS prototype surface for free, arrays get the same as a fallback for methods not already routed through `__memDispatch`. (2) **`Math.sin`/`cos`/`tan`/`atan2`/`exp`/etc. returned `undefined`** — HIR lower.rs desugars `Math.sin(x)` to `Expr::MathSin(x)` (etc.) but the WASM codegen only handled `MathFloor/Ceil/Sqrt/Log/Pow/Min/Max/Log2/Log10/Imul/Cbrt/Fround/Clz32/Expm1/Log1p/Sinh/Cosh/Tanh/Asinh/Acosh/Atanh`, so `MathSin/Cos/Tan/Asin/Acos/Atan/Atan2/MathExp` (+ variadic `MathHypot`) all hit the catch-all in `emit_expr` that emits `I64Const(TAG_UNDEFINED)`. Added explicit `Expr::Math*` handlers in `emit.rs` that route each through `emit_memcall("math_<fn>", N)` (Firefox-NaN-safe bridge), added a new `Expr::MathExp` variant to `perry-hir/src/ir.rs` + `lower.rs` lowering for `Math.exp` (the one trig-family function that wasn't already represented) + matching handlers in the JS and LLVM emitters (`emit_math_unary("Math.exp", x)` / `blk.call("llvm.exp.f64", …)`), and added the JS-side `math_sin/cos/tan/asin/acos/atan/atan2/sinh/cosh/tanh/asinh/acosh/atanh/exp/expm1/log1p/cbrt/hypot/fround/clz32` entries to `__memDispatch` + the `bridge_names` pre-intern list so `mem_call`'s name→fn lookup succeeds. Native-LLVM and pure-JS targets were already covered for most of these; this just closes the WASM gap. (3) **`xs.push(1); xs.length === 0`** on any top-level `const xs: number[] = []` — root cause was `Expr::ArrayPush / ArrayPop / ArrayShift / ArrayUnshift / ArrayPushSpread / ArraySplice` all doing `self.local_map.get(array_id).map_or(TAG_UNDEFINED, LocalGet)` to resolve the receiver, which only checks WASM locals. But top-level `let`/`const` in a module compiles to a WASM *global* (via `module_let_globals`), not a local — so the array-ref lookup missed, emitted `I64Const(TAG_UNDEFINED)` actually (wait: it went through the fall-through path and ended up zero because of a subsequent `LocalSet` chain; debug logs showed `mem_call IN array_push arg 0 bits= 0`). `array_push(0, 1)` saw a non-array arg 0, silently no-op'd, returned the input unchanged; `array_length(0)` returned 0. Introduced `emit_local_or_global_get(func, &id)` helper that mirrors the existing `Expr::LocalGet` lookup order (`module_let_globals` first, then `local_map`, then `TAG_UNDEFINED`), replaced the 7 duplicated ad-hoc lookups in the Array*/ArraySplice handlers with a single call. Verified end-to-end: smoke test `/tmp/test_array.ts` now prints `xs.length=3` after three pushes + correct indexed reads, Node shim + Perry web runtime. (4) **NaN-box tag canonicalization on Firefox/Safari** — `wrapForI64` decoded WASM i64 (BigInt) args to f64 via `_u64[0] = a; return _f64[0]`, handing a NaN Number to the callee. On SpiderMonkey, Numbers crossing a JS function-call boundary are collapsed to canonical `0x7FF8_0000_0000_0000`, stripping our STRING_TAG (0x7FFF) / POINTER_TAG (0x7FFD) / INT32_TAG (0x7FFE) payload — handles that originated in FFI glue (`__perryFromJsValue` → NaN-boxed f64 → passed to a WASM i64 export) got canonicalized at the `ffi` namespace boundary, arrived in Perry as plain NaN, and Perry's downstream `string_len` / `isPointer` / `getHandle` helpers treated them as invalid (`.length` → 0, array mutations no-op'd). Split namespace wrapping into a new `wrapFfiForI64` for the `ffi` namespace that decodes BigInt args via `__bitsToJsValue` (UNDEFINED/NULL/BOOL → JS equivalents, STRING_TAG → `stringTable[id]`, POINTER_TAG → `handleStore.get(id)`, INT32_TAG → `Number(BigInt.asIntN(32, low))`, other → f64 reinterpret) — skipping the canonicalizing f64 round-trip entirely; `rt` namespace keeps the legacy `wrapForI64` (unchanged for async-function-impl compatibility, which relies on the f64 NaN-box calling convention). Both wrappers honour an `fn.__rawBigint = true` escape hatch for callees that want raw BigInt. Also exposed `__perryJsValueToBits(v) -> BigInt` and `__perryBitsToJsValue(bits) -> any` as the Firefox-safe companion to the existing `__perryFromJsValue` / `__perryToJsValue` f64 helpers — FFI glue that wants to thread values through WASM exports should prefer the BigInt pair and avoid f64 entirely. (5) **INT32-tagged constants crashed wasm-bindgen** — `const FILTER_NEAREST = 1` NaN-boxed as `0x7FFE_0000_0000_0001`; passed to a wasm-bindgen FFI import it failed with `TypeError: Cannot convert BigInt value to a number` because the bindgen wrapper's implicit coercion couldn't handle the tagged BigInt. `__bitsToJsValue` didn't decode INT32_TAG (it only handled STRING/POINTER + plain numbers), so the tagged bits fell through to `_u64[0] = bits; _f64[0]` → NaN. Added INT32_TAG decoding to both `__bitsToJsValue` instances (main + worker): `tag === INT32_TAG ? Number(BigInt.asIntN(32, bits & 0xFFFFFFFFn)) : …` — with the new `wrapFfiForI64` running every ffi arg through `__bitsToJsValue`, INT32-tagged values now arrive at wasm-bindgen as plain Numbers (sign-extended 32-bit). Item 2 from the issue (imported `Key.ENTER` from `bloom/core` resolving to TAG_UNDEFINED) is deferred — likely a module-init-order or cross-module-property-lookup issue in HIR, needs a concrete repro against Bloom's module structure. Verified via node shim against `/tmp/test_web_issue133.ts`: all five fixes land, smoke test passes end-to-end.
156157
- **v0.5.157** — Fix #128: `obj.field` reads return NaN on `--target android`. The codegen PIC (issue #51) and `.length` fast-path (issue #73) both guarded the receiver with a Darwin mimalloc heap-window check — `handle > 2 TB && handle < 128 TB` — calibrated empirically against macOS mimalloc ASLR placement (3-5 TB range). On aarch64-linux-android, Bionic's Scudo allocator places heap allocations far below 2 TB (often < 128 GB on 39-bit-VA devices, scattered across the full range on 48-bit-VA devices), so every real object pointer failed the `above_floor` compare. The PIC path's invalid-receiver branch returns NaN-boxed undefined → `obj.x` read as NaN for any numeric field → FFI calls taking `obj.field` as `f64` got NaN (invisible text/audio/textures in Bloom Jump), pure-TS `a.x < b.x` collision checks evaluated NaN-vs-NaN as `false` (player couldn't collect coins), and `.length` fast-path silently took the 10x-slower `js_value_length_f64` fallback. Replaced both range checks with platform-independent NaN-box tag check: `((obj_bits >> 48) & 0xFFFD) == 0x7FFD` — collapses POINTER_TAG (0x7FFD) and STRING_TAG (0x7FFF) to the same value, rejects undefined/null/bool/int32/bigint/plain-f64/corrupt-bit-patterns (all have different high-16 bits). Two LLVM ops (`lshr` + `and`) + one `icmp`, identical cost to the old pair of `icmp_ugt`/`icmp_ult` + `and`, branch-predicted taken on the hot path. Runtime-side clean_arr_ptr / is_valid_object_ptr / is_valid_string_ptr were already fixed with `#[cfg(target_os=...)]` — this closes the codegen side. Root cause was the original comment literally admitting the assumption: *"Tighten to the observed mimalloc heap window on Darwin"*. All repro cases from #128 (Rect overlap check, drawText color destructure, beginMode2D with Camera2D struct, module-scope `ATLAS_ID = loadTexture("atlas").id`, flat-array workarounds in Bloom Jump) now compile to a guard that accepts real Android heap pointers.
157158
- **v0.5.156** — Fix the `await-tests` gate added in v0.5.155. First release under the new gate failed publish: Tests (run 24762486365) succeeded at 06:24:35Z and Simulator Tests (run 24762486389) succeeded at 06:05:15Z on the exact tag SHA, but the poller logged `no run found yet for "Tests" on <sha> — waiting` for the entire 45-minute deadline and timed out, skipping every build/publish leg (Homebrew, apt, npm, GH release assets). Root cause: the original script used `gh run list --workflow "Tests" --commit "$SHA" ... 2>/dev/null || echo '[]'`, which does a name→workflow-id resolution that silently returns `[]` in the `release`-event context on CI runners, AND the `2>/dev/null || echo '[]'` fallback made a real `gh` error indistinguishable from a genuinely-empty result. Rewrote to query by workflow *filename* directly: `gh api /repos/$REPO/actions/workflows/{test.yml,simctl-tests.yml}/runs?head_sha=$SHA&per_page=1` — no name resolution, no swallowed errors. A `gh api` non-zero exit now logs stderr, retries once after 10s, then fails the gate loudly instead of burning the 45-min budget. Verified locally that `gh api /repos/PerryTS/perry/actions/workflows/test.yml/runs?head_sha=ed1bde0...&per_page=1` returns the Tests run's `status=completed conclusion=success` — the exact payload the old query swallowed. `workflow_dispatch` bypass preserved for emergency republish. Tag `v0.5.155` stays as cosmetic noise (public tag, GH release body, no binaries); npm/brew/apt catch up on v0.5.156.
158159
- **v0.5.155** — Gate `release-packages.yml` on green `Tests` + `Simulator Tests (iOS)` for the exact tagged commit. New `await-tests` job polls `gh run list --commit <sha> --workflow <name>` every 30s (45-min total deadline) for each gated workflow; fails the release if either workflow failed, succeeds only when both reach `completed`+`success`. `build` now `needs: await-tests` so every downstream publishing leg (Homebrew bottle, apt `.deb`, npm tarballs) transitively waits. Also added `tags: ['v*']` to `test.yml`'s push trigger so the Tests workflow actually runs on the tag that the /release skill pushes — it was previously only on `branches: [main]`, which meant release-packages had no Tests run to gate against on the exact tag-commit SHA. `workflow_dispatch` bypass still works (`if EVENT=workflow_dispatch → exit 0`) for the "first-run / emergency republish" lever that's been in the workflow since v0.5.107. Net effect: `/release` on the dev side is unchanged (still interactive, still drives the tag push), but a bad commit now has to survive all PR-blocking CI *plus* simctl-tests before npm/brew see anything.

Cargo.lock

Lines changed: 26 additions & 26 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ opt-level = "s" # Optimize for size in stdlib
104104
opt-level = 3
105105

106106
[workspace.package]
107-
version = "0.5.157"
107+
version = "0.5.158"
108108
edition = "2021"
109109
license = "MIT"
110110
repository = "https://github.com/PerryTS/perry"

crates/perry-codegen-js/src/emit.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1423,6 +1423,7 @@ impl JsEmitter {
14231423
Expr::MathAsinh(x) => { self.emit_math_unary("Math.asinh", x); }
14241424
Expr::MathAcosh(x) => { self.emit_math_unary("Math.acosh", x); }
14251425
Expr::MathAtanh(x) => { self.emit_math_unary("Math.atanh", x); }
1426+
Expr::MathExp(x) => { self.emit_math_unary("Math.exp", x); }
14261427
Expr::MathHypot(args) => { self.emit_math_variadic("Math.hypot", args); }
14271428
Expr::MathPow(base, exp) => {
14281429
self.output.push_str("Math.pow(");

0 commit comments

Comments
 (0)