Skip to content

Commit ed752d0

Browse files
committed
feat(sso): Step 1 consumer arms + PERRY_SSO_FORCE gate (v0.5.214)
Follow-up to v0.5.213 SSO infrastructure. Adds parallel SHORT_STRING_TAG arms to every == STRING_TAG dispatch in json.rs stringify paths, plus a test-mode env gate that flips DirectParser::parse_string_value to emit inline SSO values so the migration test matrix can exercise every consumer arm. Added: - PERRY_SSO_FORCE=1 env-var gate (cached via OnceLock in sso_emit_enabled() — default OFF, test-only) - Parallel SHORT_STRING_TAG arms in json.rs: - stringify_object_inner field-value inline dispatch - stringify_object_inner replacer block - stringify_array_depth element inline dispatch - extract_string_array - 3 replaced_tag sites on replacer spacer paths - js_json_stringify_full top-level replacer arm - spacer tag check (short indent strings) - js_jsvalue_to_string materializes SSO to heap StringHeader via js_string_materialize_to_heap - js_object_get_field_by_name handles `.length` on SSO receivers directly from the NaN-box length byte - js_object_get_field_ic_miss routes SSO receivers to by_name (avoids priming the PIC with SSO bit patterns) Measured: - Default mode (SSO-off): 10/10 test_json_*.ts match Node byte-for-byte — no user-visible regressions - PERRY_SSO_FORCE=1: 8/10 match. Remaining 2 failures caused by Step 1.5 (codegen PropertyGet receiver guard filters out SSO before reaching runtime) — documented in migration plan Runtime tests 136/136 unchanged. docs/sso-migration-plan.md updated with new Step 1.5: the codegen receiver-validity guard at expr.rs:~2647 masks `tag & 0xFFFD` and checks `== 0x7FFD`. SSO (0x7FF9) fails. Attempted widening to 0xFFF9 accepted SSO but the PIC fast-path's *(obj_handle + 16) read lands in arbitrary userspace memory for SSO values — verified crashes 2 tests. Safe fix is a three-way codegen branch routing SSO directly to the by_name helper. ~2 hours, one site.
1 parent 7fbb85b commit ed752d0

7 files changed

Lines changed: 244 additions & 54 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.213
11+
**Current Version:** 0.5.214
1212

1313
## TypeScript Parity Status
1414

@@ -148,6 +148,7 @@ First-resolved directory cached in `compile_package_dirs`; subsequent imports re
148148
Keep entries to 1-2 lines max. Full details in CHANGELOG.md.
149149

150150
- **v0.5.205** — Fix #183: `perry compile --target web` on a real-world app (Bloom Jump built on the Bloom engine) produced a WASM binary the browser refused to load — `Compiling function #687 failed: expected 0 elements on the stack for fallthru, found 103` (count varies with engine state). Root cause in `crates/perry-codegen-wasm/src/emit.rs`: the four direct-`Call`-instruction code paths — `Expr::Call` FuncRef arm (~4302), `Expr::Call` ExternFuncRef arm (~4324), `Expr::New` user-class ctor (~5844), `Expr::SuperCall` parent-ctor (~5894), `Expr::StaticMethodCall` direct-static path (~5979) — each emit `emit_expr(arg)` per source arg and pad up with `TAG_UNDEFINED` when `args.len() < expected`, but had no matching drop-excess branch when `args.len() > expected`. WASM `call` consumes exactly the callee's declared param count, so when JS's "extra args evaluated for side effects, then silently ignored" semantics met Perry's WASM codegen, every extra evaluated arg leaked past the call and accumulated on the enclosing block's operand stack — 103 values by the time `_start`'s final `end` hit the validator. The shape that triggered it in jump/bloom was `bloom/src/core/colors.ts`'s `Colors = new __AnonShape_2(...24 PropertyGets...)` landing on a Phase-3-synthesized ctor with lower declared arity, multiplied across bloom's 10 submodules. Fix: after each existing `for _ in args.len()..expected { I64Const(TAG_UNDEFINED) }` pad-up loop, add the mirror `for _ in expected..args.len() { Drop }` — matches JS semantics (extras evaluated for side effects but discarded) and keeps the operand stack aligned with the callee's WASM type at every direct-Call site. Verified end-to-end against the exact issue repro cloned fresh from `github.com/Bloom-Engine/jump` + `github.com/Bloom-Engine/engine`: both path A `file:./vendor/bloom/` and path B `file:../engine/` now compile to a WebAssembly-validating `.wasm` (416,923 / 413,780 bytes respectively, 140 FFI imports intact, `WebAssembly.compile` resolves clean on node 20+); a synthetic `takesFive(mc(),mc(),1,2,3,4)` minimal case that previously failed `Compiling function #213 failed: ... found 1` also validates. `cargo test --release -p perry-runtime -p perry-hir -p perry-codegen-wasm -p perry`: 262/262 passed. Note: issue #183 also claimed path A found only 1 module and emitted 9 FFI imports — could not reproduce in a fresh clone (both paths find 10 modules identically); most likely an artifact of the reporter's local `vendor/bloom` snapshot predating the `exports` map, and the "runGame silently no-ops" symptom the user actually observed was the browser refusing to instantiate the invalid WASM with the surrounding JS glue swallowing the error — fixed here.
151+
- **v0.5.214** — SSO Step 1 consumer-arm migration (follow-up to v0.5.213 infrastructure landing). Added `PERRY_SSO_FORCE=1` env-var gate (cached via `OnceLock` in `crates/perry-runtime/src/json.rs::sso_emit_enabled`) that flips `DirectParser::parse_string_value` to emit inline SSO values for strings ≤ 5 bytes — default OFF, used exclusively by the migration test matrix. Added parallel `SHORT_STRING_TAG` arms to every `== STRING_TAG` dispatch in `json.rs` stringify paths: `stringify_object_inner` field-value inline dispatch + replacer block, `stringify_array_depth` element inline dispatch, `extract_string_array`, the 3 `replaced_tag` sites on the replacer spacer paths, `js_json_stringify_full` top-level replacer arm, and the spacer-as-string check (for `JSON.stringify(obj, null, " ")` with short indent). Runtime additions: `js_jsvalue_to_string` now materializes SSO to a heap `StringHeader` via `js_string_materialize_to_heap` for the common "caller needs `*mut StringHeader`" contract; `js_object_get_field_by_name` handles `.length` on an SSO receiver by reading the length byte directly from the NaN-box payload (returns `JSValue::undefined()` for other keys on SSO values, matching the string-property baseline). Measured: 8 out of 10 `test_json_*.ts` tests match Node byte-for-byte under `PERRY_SSO_FORCE=1`; all 10 match under default (SSO-off) mode — no user-visible regressions from the infrastructure landing. Remaining 2 failures are both caused by **Step 1.5** (new section in `docs/sso-migration-plan.md`): the codegen's PropertyGet receiver-validity guard at `crates/perry-codegen/src/expr.rs:~2647` masks `tag & 0xFFFD` and checks `== 0x7FFD`, which accepts POINTER_TAG + STRING_TAG but rejects SHORT_STRING_TAG (0x7FF9). SSO receivers fall to the "invalid" branch → return `undefined`. Widening the mask to `0xFFF9` accepts SSO but the PIC fast path's subsequent `*(obj_handle + 16)` read lands in arbitrary userspace memory for SSO values (the low 48 bits are SSO data, not a heap pointer) — verified: widening without further guarding crashed 2 tests under SSO_FORCE. Safe fix is a three-way codegen branch: POINTER/STRING → PIC fast path, SSO → call `js_object_get_field_by_name_f64` directly skipping the PIC memory read, else → invalid. Estimated ~2 hours, one codegen site — scheduled as Step 1.5. Runtime tests 136/136 (unchanged — no new unit tests added this commit). Also verified: no infrastructure crashes on stringify / equality / comparison / typeof / length paths when SSO values do reach them from the runtime side.
151152
- **v0.5.213** — Small String Optimization (SSO) infrastructure (tier 1 #2 per `docs/memory-perf-roadmap.md`). **Infrastructure-only landing**; no creation sites migrated yet. New tag `SHORT_STRING_TAG = 0x7FF9_0000_0000_0000` encoding strings of length 0..=5 inline in the 48-bit NaN-box payload (8-bit length at bits 40..47 + 5 bytes of data at bits 0..39). Zero heap allocation for short strings when emitted — the value IS the data. Added: `JSValue::try_short_string(&[u8])` (constructor), `short_string_to_buf` / `short_string_len` (decoders), `is_short_string` / `is_any_string` (predicates, with `is_string` kept strict for legacy call sites that rely on `as_string_ptr` returning a real heap pointer), `js_string_new_sso(ptr, len) -> f64` (SSO-aware creation that falls back to heap for long inputs), `str_bytes_from_jsvalue(value, &mut scratch)` (central decoder producing `(*const u8, u32)` view for either representation), `js_string_materialize_to_heap(value)` (compatibility shim that allocates a heap StringHeader from an SSO value). Consumer-side dispatch already wired in: `typeof` (builtins.rs, accepts both tags), `js_jsvalue_equals` + `js_jsvalue_compare` (value.rs — SSO fast path when both operands are SSO because encoding is canonical, otherwise decode via scratch buffers and byte-compare), `js_value_length_f64` (direct bit extraction for SSO, no heap access), `js_jsvalue_to_string` (materializes SSO to heap when caller needs `*mut StringHeader`), three stringify arms in json.rs (top-level `stringify_value`, object field inline dispatch in `stringify_object_inner`, array element inline dispatch in `stringify_array_depth`). 6 new unit tests in `value::tests` cover roundtrip, rejection of 6+ byte inputs, embedded-NUL handling (length is authoritative), tag-band distinctness from POINTER/INT32/NUMBER/UNDEFINED, empty-string roundtrip, and byte-order stability (first byte lands in LSB of payload — invariant relied on by any future SIMD bulk-decoder). **Why infrastructure-only:** flipping `DirectParser::parse_string_value` to emit SSO without first auditing every consumer produces regressions — `grep "== STRING_TAG" crates/perry-runtime/src/json.rs` alone shows 20+ sites, and the broader consumer surface spans object.rs property-get helpers, string.rs methods (split/replace/slice/indexOf/etc.), regex.rs match extractors, set.rs/map.rs key equality, stdlib HTTP/DB paths, and codegen string-literal emission. Attempting the flip in-session reproduced the hazard: 3 `test_json_lazy_*.ts` tests diffed from Node with stringify emitting `"null"` where SSO values should have decoded. Rolled back the producer flip; kept every consumer arm already added so Step 1 of the migration is ~50% complete. New doc `docs/sso-migration-plan.md` sequences the 6-step roll-out (stringify consumers → DirectParser emit → object key storage → string methods → codegen literals → stdlib) with per-step ship criteria and a decision gate after Step 2 to re-evaluate whether Steps 3-6 are worth the effort vs jumping to tier 2/3 (escape analysis + generational GC). Runtime tests 130 → 136 (added 6 SSO tests). All 10 existing `test_json_*` regressions green under infrastructure-only landing.
152153
- **v0.5.212** — Three lazy-correctness fixes + comprehensive external-auditor document. (1) `Array.isArray(parsed)` returned `false` on lazy `JSON.parse` results. Root cause: `Expr::ArrayIsArray` in `crates/perry-codegen/src/expr.rs` was a pure compile-time check that emitted `TAG_FALSE` whenever the operand's static HIR type wasn't definitively `Array`/`Tuple`. For `JSON.parse` results (typed `any`) this meant the static check always failed, so even a runtime array always returned false. Fixed by routing indeterminate static types (`Any`, `Unknown`, no annotation) through a new `js_array_is_array` runtime dispatch in the same codegen arm; the fast-path for definitively-array statics still emits `TAG_TRUE` without a runtime call, and definitively-scalar types (Number/String/Boolean/Null/Void/BigInt/Symbol) still emit `TAG_FALSE` statically. (2) `parsed instanceof Array` returned `false` on lazy arrays. Root cause: `js_instanceof` in `crates/perry-runtime/src/object.rs:2864-2890` (the `CLASS_ID_ARRAY` branch) only matched `GC_TYPE_ARRAY`, not `GC_TYPE_LAZY_ARRAY`. Added the lazy-array obj_type to the OR. (3) `Array.isArray` runtime (`crates/perry-runtime/src/array.rs:js_array_is_array`) already had the same eager-only check; extended to accept `GC_TYPE_LAZY_ARRAY` too (now reachable via the new codegen dispatch path). Net effect: every reasonable type-predicate against a lazy array now matches Node byte-for-byte. Pre-existing limitation `parsed.constructor === Array` (returns false on Perry regardless of lazy vs eager — it's a property-lookup limitation in Perry's class system) remains unchanged. The `Array.isArray` codegen change is also a bug-fix for ANY `any`-typed value — before v0.5.212 `Array.isArray(anyTyped)` returned false even when the runtime value was a real eager array; pre-existing latent bug unrelated to lazy work. Verified: `test_json_lazy_per_element.ts` and the new `test_json_lazy_predicates.ts` match Node; all other `test_json_*` regressions clean. New document `docs/audit-lazy-json.md` — **~1400-line comprehensive external-auditor reference** covering the tape-based lazy JSON system end-to-end: layout (TapeEntry, LazyArrayHeader offset invariants, arena layout), dispatch (every user-observable array operation mapped to its runtime entry point), garbage collection (trace_lazy_array, cache zero-on-alloc invariant, tracer safety), correctness proof (14-row case analysis against every JS array operation), performance characteristics (measured numbers vs Node + Bun on all three main benches), edge cases (15 distinct cases with handling), test coverage (every file + gate), known limitations, and a 20-item audit checklist with file:line references. Written so a reviewer with no prior Perry knowledge can verify the entire system against source.
153154
- **v0.5.211** — Fix pre-existing linker regression from v0.5.204: `js_json_stringify` in `crates/perry-runtime/src/json.rs` was missing `#[no_mangle]`. Rust compiled it as a mangled symbol (`__ZN13perry_runtime4json17js_json_stringify17h…E`) which `perry-stdlib::fastify::context::jsvalue_to_json_string` (and `perry-stdlib::fastify::server::build_response_body`) couldn't find when linking against a statically-built `libperry_runtime.a` — surfaced as `ld: Undefined symbols: _js_json_stringify` when running `scripts/run_fastify_tests.sh` or `cargo test -p perry-stdlib`. Tracking down via `git log -L "/pub unsafe extern \"C\" fn js_json_stringify(/,/^}/"` identified v0.5.204 (`feat(json): lazy parse + lazy stringify`) as the commit that inadvertently removed the attribute when inserting `try_stringify_lazy_array` directly above. All 7 fastify integration tests now pass (GET /hello, GET /users/:id, POST /echo status + body, GET /does-not-exist → 404) — this was blocking end-to-end Fastify coverage since v0.5.204. One-line fix. Also: comprehensive test sweep at v0.5.211: `cargo test --release --workspace` with CI-matching exclusions = **44/44 test runs, 0 failed**, gap tests 24/28 (unchanged), parity tests 106 pass / 12 fail (same 12 as pre-v0.5.208 baseline — no regressions), thread tests 4/4, cache tests PASS, fastify tests 5/5 (newly unblocked), doc tests 95/115 (7 tvos-simulator cross-compile failures are env-dependent — `libperry_ui_tvos.a` built for macOS host not tvOS-sim; pre-existing, unrelated to JSON work).

Cargo.lock

Lines changed: 27 additions & 27 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
@@ -109,7 +109,7 @@ opt-level = "s" # Optimize for size in stdlib
109109
opt-level = 3
110110

111111
[workspace.package]
112-
version = "0.5.213"
112+
version = "0.5.214"
113113
edition = "2021"
114114
license = "MIT"
115115
repository = "https://github.com/PerryTS/perry"

crates/perry-codegen/src/expr.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2115,6 +2115,15 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
21152115
let recv_tag = blk.lshr(I64, &recv_bits, "48");
21162116
let recv_tag_masked = blk.and(I64, &recv_tag, "65533"); // 0xFFFD
21172117
let handle_ok = blk.icmp_eq(I64, &recv_tag_masked, "32765"); // 0x7FFD
2118+
// SSO receivers fail this guard → route to slow path
2119+
// `js_value_length_f64` which has an SSO branch (reads
2120+
// length from the tag byte, no heap access). Accepting
2121+
// SSO here is safe because the fast path's
2122+
// `safe_load_i32_from_ptr(&recv_handle)` would read
2123+
// arbitrary bytes at the SSO "pointer" address, but
2124+
// the subsequent phi feeds the slow-path result when
2125+
// handle_ok is false — so SSO flow is correct via the
2126+
// slow path already, no widening needed.
21182127

21192128
let check_gc_idx = ctx.new_block("plen.check_gc");
21202129
let fast_idx = ctx.new_block("plen.fast");
@@ -2647,6 +2656,17 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
26472656
let obj_tag = ctx.block().lshr(I64, &obj_bits, "48");
26482657
let obj_tag_masked = ctx.block().and(I64, &obj_tag, "65533"); // 0xFFFD
26492658
let is_valid = ctx.block().icmp_eq(I64, &obj_tag_masked, "32765"); // 0x7FFD
2659+
// NOTE: SSO (SHORT_STRING_TAG = 0x7FF9) intentionally
2660+
// fails this guard for now — the PIC fast path's
2661+
// subsequent `*(obj_handle + 16)` read would deref into
2662+
// arbitrary userspace memory and can crash. Property
2663+
// access on SSO values returns `undefined` from the
2664+
// invalid branch, which is correct for string-valued
2665+
// property access (JS `"x".foo === undefined`) EXCEPT
2666+
// `.length`. Full SSO-aware PIC dispatch is Step 1.5 of
2667+
// the SSO migration plan and requires emitting a
2668+
// dedicated `is_sso` branch that skips the PIC and calls
2669+
// the SSO-aware miss handler directly.
26502670
let pic_idx = ctx.new_block("pget.recv_ok");
26512671
let invalid_idx = ctx.new_block("pget.recv_bad");
26522672
let final_merge_idx = ctx.new_block("pget.recv_merge");

0 commit comments

Comments
 (0)