Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

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

## v0.5.922 — fix(transform): #858 — closure-captured numeric (or otherwise literal-trivial) params now reach an object-literal method body intact. **Symptom (reported as #858, downstream as #859).** The 12-line repro `function makeDT(y: number) { return { toDate(): Date { return new Date(Date.UTC(y, 4, 15, 17, 29, 35, 402)); } } } const d = makeDT(2026).toDate(); console.log("INLINE:", d.getTime())` printed `INLINE: -62155492224598` (Node prints `1778866175402`). Only the captured numeric `y` was corrupt — the other `Date.UTC` literal args were intact, and the value `-62155492224598` is exactly `Date.UTC(0, 4, 15, ...)`, confirming `y` reads as `0` inside the method body. The same shape blew up as a SIGBUS in `@perryts/mysql`'s `MyDateTime.toDate()` (#859 — `function makeMyDateTime(y, mo, d, h, ...) { return { toDate(): Date { return new Date(Date.UTC(y, mo-1, d, h, ...)); } } }`). **Root cause.** `crates/perry-transform/src/inline.rs`'s call-site inliners (`try_inline_simple_call` Pattern 1 / Pattern 2 / single-Return method / void-method, and `try_inline_call`'s fn + method branches) collectively short-circuited "literal-trivial" args (Integer / Number / Bool / String / Null / Undefined / WtfString / GlobalGet, per `is_trivial_expr`) into `param_map.insert(param.id, arg.clone())`, then ran `substitute_locals` over the cloned function body. `substitute_locals`'s `Expr::Closure` arm faithfully recurses into the closure body and substitutes captured-param `LocalGet(id)` with the literal — *and* removes that id from the closure's `captures` list (the `retain_mut` branch that drops orphan captures when the replacement isn't itself a `LocalGet`). At the call site (init), the inlined `Expr::Closure` therefore ended up with `body: [...Integer(2026)...]` and `captures: []`. But closure bodies are compiled exactly once by `compile_closure`, keyed by `func_id`, and `collect_closures_in_stmts` registers the **first** occurrence it sees per `func_id` — that's the original (uninlined) Closure inside `makeDT`'s body, which still has `body: [...LocalGet(3)...]` and `captures: [3]`. The compiled body therefore expects capture slot 0 to hold `y`. At the call site, `lower_expr`'s `Expr::Closure` arm + `compute_auto_captures` consult the inlined expression's body/captures and see no captured local — so the closure object is allocated with zero capture slots. Reading slot 0 in the compiled body returns whatever uninitialized bits live past the allocation header — usually 0.0, which `Date.UTC` happily accepts as year zero. The mismatch is invisible to type checking and to codegen warnings because both sides ARE internally consistent — just with each other disagreeing on the func_id ↔ capture-shape contract. **Fix.** Before substituting, pre-walk the callee's body for any `Expr::Closure`'s `captures` / `mutable_captures` lists (new helper `collect_closure_captured_local_ids` in `inline.rs`). For each call-site arg whose `is_trivial_expr` would normally allow in-place substitution, force-materialize a fresh `Let` instead when the corresponding param appears in that set AND the arg isn't already a plain `LocalGet` (a `LocalGet → LocalGet` substitution is benign — the closure body still references *a* local, just renumbered). Applied symmetrically to all six call-site code paths in `inline.rs` that drive `substitute_locals` / `substitute_locals_in_stmts` over closure-bearing inlined bodies: `try_inline_simple_call` fn-call Pattern 1, fn-call Pattern 2 (Let-then-Return), single-Return method-call, void-method-call, and `try_inline_call`'s fn-call + method-call paths. The closure body now keeps its `LocalGet(fresh_id)` reference, its `captures: [fresh_id]` stays non-empty, and `lower_expr`'s closure-creation site + `compile_closure`'s body emission agree on slot index 0. The fresh `Let` lives in setup_stmts hoisted before the call site, so the literal value is bound to a real local that the closure-creation site can read and forward into the capture array. **Why this also closes (or unblocks) #859.** `@perryts/mysql`'s `makeMyDateTime(y, mo, d, h, mi, s, ms)` is the exact same shape — a top-level helper that returns `{ toDate(): Date { return new Date(Date.UTC(y, mo-1, d, h, mi, s, ms)) } }` — called with literal args (and from a hot ORM hot path). Pre-fix, each numeric param ended up routed through the same substitute-into-closure-body bug; post-fix they all read correctly. The downstream SIGBUS in shop-admin is consistent with the all-zero `Date.UTC` argument vector producing a NaN / out-of-range Date that's then handed to a subsequent native-method dispatch expecting a valid date pointer. Not separately verified end-to-end in shop-admin (the test rig isn't part of this worktree), so the issue is described as "likely closes #859" rather than confirmed-closed — file a follow-up if the original SIGBUS still reproduces post-merge. **Validation.** New regression test `test-files/test_issue_858_closure_numeric_capture.ts` covers seven shapes: numeric/string/multi-primitive captures inside method shorthand, arrow-returning shape, arrow nested inside an object literal value, `Array.prototype.map` callback capture, and the multi-stmt Pattern 2 path. All seven byte-identical to `node --experimental-strip-types`. Full parity suite (`./run_parity_tests.sh`) — 345/372 pass, all 27 parity failures + all 18 compile failures pre-listed in `test-parity/known_failures.json` (zero new failures). `cargo test --release --workspace` (excluding cross-host UI crates per CLAUDE.md) green. **Files touched.** `crates/perry-transform/src/inline.rs` (new helper + six call-site updates), `test-files/test_issue_858_closure_numeric_capture.ts` (new regression test). Closes #858. Likely closes #859 (downstream — shop-admin SIGBUS site has identical shape). Refs #793.

## v0.5.921 — fix(codegen + runtime): #489 — `.then` / `.catch` / `.finally` on a Promise returned from a class field/method now actually resumes the await chain. **Symptom (#489 followup).** After v0.5.920 closed the link error, the perry-compiled drizzle + `@perryts/mysql` program reached MySQL and sent the INSERT — but `await db.insert(users).values(...)` never settled. The proxy callback returned `{rows: [{insertId, affectedRows}]}`, the SQL landed at the wire, but execution exited cleanly with code 0 instead of continuing to the line after the await. Two distinct gaps fed the same symptom: (1) at compile time, perry's `is_promise_expr` didn't recognize `obj.field(args)` / `obj.method(args)` as a Promise even when the field is typed `(…) => Promise<T>` or the method is `async`, so `<expr>.then(cb)` chained on it fell out of the `js_promise_then` fast-path; (2) at runtime, `js_native_call_method` (the dynamic-dispatch fallback that ate the missed fast-path) had no arm for `then` / `catch` / `finally` on a Promise receiver, so the call returned undefined and the await chain's resolver was never wired up to the underlying promise's settlement queue. **Fix part 1 — type-aware Promise recognition (`crates/perry-codegen/src/type_analysis.rs::is_promise_expr`).** New `Expr::Call { callee: PropertyGet { … } }` arms: (a) consult `static_type_of(callee)` — when it resolves to `HirType::Function(ft)` with `ft.is_async` true or `ft.return_type` being `Promise<…>` / `Generic { base: "Promise" }`, the call result is a Promise (covers class-field arrows typed as `(…) => Promise<T>`); (b) when the callee is a `PropertyGet` whose receiver resolves to a known class, walk the class's `methods` Vec (and parent chain) — if the method is declared `async` or returns `Promise`, the call is a Promise. Parallel arm added to the `Expr::LocalGet` callee branch: a local typed `HirType::Function(ft)` with `is_async`/return-Promise also yields true. Together these cover the drizzle shapes — `this.client(...)` on a `RemoteCallback`-typed field, `this.pre.execute()` on an async method, `this.session.all(q)` on a method whose return is `Promise<T>`. **Fix part 2 — runtime intrinsic for `.then` / `.catch` / `.finally` on a Promise (`crates/perry-runtime/src/object.rs::js_native_call_method`).** Prepended a check: when `method_name` is `"then"` / `"catch"` / `"finally"` and `js_value_is_promise(object)` is non-zero, unbox the promise handle from the NaN-boxed receiver and call `js_promise_then` / `js_promise_catch` / `js_promise_finally` directly, NaN-boxing the returned promise pointer. The closure-arg extractor handles **both** ABIs perry uses for callbacks crossing this boundary: NaN-boxed `POINTER_TAG | (ptr & 0x0000_FFFF_FFFF_FFFF)` from `js_closure_alloc_singleton` callsites, **and** raw `*ClosureHeader` bit-cast to `f64` from `js_assimilate_thenable` (see `promise.rs:2438-2442`: when the await wrapper calls a user-defined `then(resolve, reject)` method via the vtable, it passes `resolve_f64 = f64::from_bits(resolve_closure as u64)` — a bare pointer in a double slot — and when that user method propagates the params through to an inner `e.then(onF, onR)`, perry's dispatch tower stores them straight into the args buffer for `js_native_call_method` without re-tagging). Falsy / out-of-range / TAG_UNDEFINED slots become null `ClosurePtr` so `js_promise_then` treats the handler as missing per spec. **Validation.** New `/tmp/probe489/entry_subset.ts` — a perry-compiled drizzle + `@perryts/mysql` + `drizzle-orm/mysql-proxy` program — runs INSERT, UPDATE, DELETE against real MySQL 9.6 on `127.0.0.1:3306` and produces output **byte-for-byte identical** to the `tsx` baseline; MySQL row state after the perry run matches the baseline run (alice → alice2 rename committed, bob deleted). The original 50-line `entry.ts` advances past the `.then` chain — INSERT/UPDATE/DELETE all complete — but `db.select().from(users)` is **still blocked** by drizzle's `applyMixins(MySqlSelectBase, [QueryPromise])` runtime prototype-copy pattern (perry's static class table doesn't see methods added via `Object.defineProperty(baseClass.prototype, ...)` at module init time). That's a separate compat gap — filed as a #489 followup. **Other test suites.** `cargo test --release -p perry` (223/223 ok), `cargo test --release -p perry-runtime` (250/250 ok). Pre-existing drizzle-sqlite + hono-basic fixture link failures on main are unchanged by this patch (separate top-level-const re-export bug). **Remaining for #489 acceptance.** (a) `applyMixins` runtime-mixin pattern blocks the `db.select()` arm; needs prototype-copy modeling or a perry-side workaround that recognizes the call chain. (b) `crypto.publicEncrypt` (#463) still needed for `caching_sha2_password` first-time auth on non-TLS connections (workaround: warm server auth cache via `mysql` CLI + `PERRY_ALLOW_UNIMPLEMENTED=1` at compile time). Both filed as separate follow-ups under #489.

## v0.5.920 — fix(codegen): #489 — transitive parent-class closure picks the canonical defining path instead of the first BTreeMap match by name. **Symptom.** Compiling a 50-line drizzle + `@perryts/mysql` program (issue #489 acceptance) against MySQL fails at link time with `Undefined symbols: _perry_method_node_modules_drizzle_orm_index_js__QueryPromise__then, referenced from: _perry_method_node_modules_drizzle_orm_mysql_proxy_session_js__MySqlRemoteSession__all`. drizzle's `mysql-proxy/session.js` calls `this.client(...).then(({ rows }) => rows)` — synchronous `.then` chaining on the Promise returned by the proxy callback (#488/pg-proxy uses `async all() { await … }`, sidestepping this). `MySqlPreparedQuery` (imported by session.js) reaches `QueryPromise` through the transitive parent-class closure. **Root cause.** `crates/perry/src/commands/compile.rs` (line 4451 pre-fix) walked parent-class refs by scanning `exported_classes` with a name-only `find`. The BTreeMap is keyed `(path, name)`, and the `Export::ExportAll` / `Export::ReExport` propagation loop above stamps each re-exporter's path under the same name — so a class re-exported via `export * from "./query-promise.js"` in `index.js` lands as `(drizzle-orm/index.js, "QueryPromise")` *and* `(drizzle-orm/query-promise.js, "QueryPromise")`. BTreeMap iteration is alphabetical, `index_js` sorts before `query_promise_js`, the closure picks the barrel path. The downstream codegen then emits `perry_method_<barrel>__QueryPromise__then` extern declarations + dispatch references in session.js's object file; the actual symbol is defined under `perry_method_<query_promise>__QueryPromise__then` and the link fails. Same shape as #83 / #678 / #785 (cross-module method dispatch picking the wrong owning module), but for the `export *` star-re-export path through the transitive parent closure instead of the consumer's own import. The namespace-import enumeration path at the same file (line 4046-4072) already avoids this by iterating `ctx.native_modules` directly — the comment there explicitly warns "`exported_classes` gets alias entries stamped under every re-exporter's path … iterating it would hand us the class keyed by `index.ts` when it was actually compiled under `pool.ts`". The parent-closure path had not been hardened the same way. **Fix.** New side map `class_canonical_path: HashMap<ClassId, String>` populated only from each module's own `hir_module.classes` Vec (the *defining* file, never a re-export). In the transitive parent-closure search, prefer the BTreeMap entry whose `(path, name)` key matches the canonical path for `class.id`; fall back to the old first-match behavior when the class id has no canonical record (defensive — for classes that exist only via re-exports). The fix is a 20-line localized change in `compile.rs` that doesn't touch codegen or the existing namespace-import logic. **Validation.** Drizzle + `@perryts/mysql` + `drizzle-orm/mysql-proxy` 50-line program now compiles clean (binary size 6.4 MB) and reaches MySQL: TCP connect ✓, handshake ✓, schema DDL ✓, prepared INSERT lands at the wire with the correct SQL + params (`insert into users_489 (id, name) values (default, ?)` with `["alice"]`). Pre-existing drizzle-sqlite fixture `_perry_wrap_perry_fn_…_utils_js__textDecoder` link failure on main is unchanged by this patch (separate top-level-const re-export bug, not the parent-closure path). `cargo build --release -p perry` clean. **Remaining blockers for #489 acceptance.** (a) Insert callback resolves cleanly but the `await db.insert(...).values(...)` promise never settles in the perry binary — the proxy callback's `{rows: [{insertId, affectedRows}]}` return value isn't flowing back through `queryWithCache → execute → QueryPromise.then` and the program exits with code 0 before the next line runs. Separate runtime / Promise-plumbing bug, not the codegen issue this patch closes. (b) `crypto.publicEncrypt` (#463) is needed for `caching_sha2_password` first-time auth on non-TLS connections; the test workaround is to warm the server's auth cache via the `mysql` CLI before running perry and compile with `PERRY_ALLOW_UNIMPLEMENTED=1`. Both filed as follow-ups under #489.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

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.

**Current Version:** 0.5.921
**Current Version:** 0.5.922


## TypeScript Parity Status
Expand Down
Loading
Loading