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.912 — fix(fastify): #748 — replace 1-second polling timeout in `wait_for_promise` with a condvar wait, and surface rejected promises as `HTTP 500` instead of body `0`. **Symptom (reported as #748).** A native-compiled Fastify route doing `await checkSignup(); await getUserByEmail(); await hashPassword(); await createUser(); await createAccount(); await addMember(); await issueSession(); …` against `@perryts/mysql` returned the wire byte `0x30` (literal ASCII `0`) with HTTP 200 instead of the explicit `return { accessToken, … }`. The first INSERT committed; every subsequent `await pool.exec(...)` silently no-op'd (no error, no log, no DB row). The `tsx` baseline returned the correct JSON with HTTP 201 against the same code + same DB. **Root cause.** Both `crates/perry-ext-fastify/src/server.rs::wait_for_promise()` and the in-tree mirror at `crates/perry-stdlib/src/fastify/server.rs::wait_for_promise()` polled `js_promise_state` 10000 × 100 µs (≈ 1 s) and then returned regardless of state. After return, `js_promise_value(ptr)` (initialized to `0.0` in `Promise::new` per `crates/perry-runtime/src/promise.rs`) gave Rust `f64 0.0` for any Pending or Rejected promise. `build_response_body` then serialized that as wire byte `0x30` with the default `status_code = 200` (the route's `reply.code(201)` never ran). The orphaned chain was also abandoned: once the dispatcher returned, microtasks stopped pumping, so every queued INSERT after the timeout silently no-op'd — matching the "first INSERT commits, rest don't" symptom. The bug only surfaced once the await chain exceeded the 1 s budget, which is why the user's argon2 + JWT + multiple mysql writes hit it but minimal Fastify probes (object/string/number returns, two sequential `await delay()`s) didn't. **Fix.** (1) Replace the polling/sleep loop with a condvar-based wait via `js_wait_for_event` — the same primitive the codegen-emitted `await` body already uses. No fixed iteration limit; wakes the moment any stdlib worker calls `js_notify_main_thread` (which `js_promise_resolve` / `reject` already do). Drives timer ticks + stdlib pump + microtasks every iteration so chained timers / mysql / fetch awaits all keep moving. (2) After `wait_for_promise` returns, branch on `js_promise_state`: state == 2 (Rejected) now produces an HTTP 500 response with a `{ "error": <reason> }` JSON envelope (via new `render_rejection_body`) instead of letting `js_promise_value` return `0.0` and masquerade as a successful response. **Validation.** `/tmp/repro748_fastify.ts` — Fastify handler with 8 × 300 ms sequential awaits (2.4 s total chain). Pre-fix would return HTTP 200 body `0`; post-fix returns the full JSON body + HTTP 201 after ≈ 2.46 s. Existing `test_fastify_integration.ts` still passes; 10 fastify-ext unit tests + 22 fastify-stdlib unit tests all pass. Gap suite unchanged (33 pass / 2 pre-existing failures). **Caveat.** Could not exactly reproduce the user's "first INSERT commits, rest silently no-op" symptom in a synthetic repro without `@perryts/mysql` linkage — the 1 s timeout path is the only place `js_promise_value(0.0_initial)` reaches the response writer and matches every observable symptom (wire byte `0x30`, HTTP 200, dropped explicit return, abandoned subsequent awaits). Worth re-running skelpo-shop-admin against this build to confirm; if it still repros, `PERRY_MT_PROFILE=1` will narrow the stall point. Closes #748.

## v0.5.911 — fix(jsruntime): #755 — register `fs/promises` and sibling Node-builtin subpath aliases. **Symptom.** A Colyseus demo compiled cleanly but failed at runtime with `[js_load_module] FAILED to load '.../colyseus/build/index.mjs': Cannot find module 'fs/promises' in node_modules`. **Root cause.** `crates/perry-jsruntime/src/modules.rs::is_node_builtin` was an exact-string `matches!` list that recognized `fs` but not Node's separately-exposed `fs/promises` subpath alias (nor `stream/promises`, `stream/consumers`, `dns/promises`, `timers/promises`, `readline/promises`, `util/types`, `assert/strict`, `process`). Specifiers that fell through hit `resolve_from_node_modules`, which then looked for a literal package directory named `fs/promises` and failed. Real packages (colyseus, but anything pulling in `fs/promises` directly) couldn't resolve the import. **Fix.** Added the missing specifiers — both bare and `node:`-prefixed forms — to `is_node_builtin`, plus matching real stubs in `get_builtin_stub` so the import site doesn't downstream-fail on `Cannot read properties of undefined` when consumers reach for e.g. `fsp.readFile`. **Validation.** Two new unit tests in `crates/perry-jsruntime/src/modules.rs::tests` — `test_is_node_builtin_promise_subpaths` enumerates every added specifier (bare + `node:`) and asserts recognition, `test_get_builtin_stub_promise_subpaths` asserts each new stub returns a real body (not the empty-fallback `export default {}`). `cargo test --release -p perry-jsruntime --lib modules::` green (13/0/0). Synthetic Perry-compiled `.ts` that `import`s the five promise subpaths resolves and exports the expected functions; gap suite unchanged (33 pass / 2 pre-existing failures). **Out of scope.** The secondary `Unexpected strict mode reserved word at express/lib/express.js:158:14` in #755's report is a separate CJS-wrap / strict-mode collision and is not addressed here. Closes #755.

## v0.5.910 — fix(transform): #764 — `State()` at module-init level no longer breaks `stateOnChange` / `stateBindTextfield` / `stateBindToggle`. **Symptom (reported as #763).** `const cell = State(""); stateOnChange(cell, cb); cell.set("v")` at module top level (NOT inside `function main()`) updated `cell.value` correctly but never fired `cb`. The same shape inside a function worked. The HIR-level `try_desugar_reactive_text` pass (which lowers `Text(\`...${state.value}...\`)` into an IIFE that registers `stateOnChange`) inherits the same failure when its host module declares the state at module-init level, because the desugar emits exactly the kind of `stateOnChange(cell, ...)` call that gets stranded. **Root cause.** `crates/perry-transform/src/state_desugar.rs::collect_state_bindings` (issue #535, extended in #612 to also catch capital-`S` `State`) was rewriting *every* module-init-level `State(initial)` / `state(initial)` declaration into the synthetic keyed runtime: `let cell = undefined` + `__state_init("__state_N", initial)`, with downstream `cell.set(v)` → `__state_set("__state_N", v)` and `cell.value` → `__state_get("__state_N")`. The two-state-systems-in-one-namespace problem: the synthetic `__state_*` runtime is registry/key-keyed (one HashMap keyed by string id), the public handle-based API (`perry_ui_state_create` → integer 1-based handle → `perry_ui_state_set` → `perry_ui_state_on_change`) is *separate*. After the rewrite, `stateOnChange(cell, cb)` saw `cell = undefined` (the `Let { init: Undefined }` placeholder the desugar leaves behind to keep the LocalId addressable), `perry_ui_state_on_change(handle: i64, callback: f64)` extracted garbage as its handle, and the subsequent `__state_set("__state_N", v)` wrote to a completely different store — so the handle-based subscriber registry stayed empty and the callback never fired. The reactive-Text desugar's `textSetString(__h, fresh_concat)` thus only ran when something else triggered it (`stateBindTextfield` two-way binding wakes the textfield up at type-time, but nothing wakes up the standalone `Text`). **Fix.** Gate `state_desugar` so it only rewrites a binding when *no* handle-based state API consumes it. After `collect_state_bindings`, a new `collect_handle_based_state_uses(module)` walks all of `module.init`, every function body, and every class method/ctor body looking for `NativeMethodCall { module: "perry/ui", method: "stateOnChange" | "stateBindTextfield" | "stateBindToggle" | "stateBindSlider" | "stateBindVisibility" | "stateBindTextNumeric", args[0]: LocalGet(id) }`. Any `id` it finds is removed from the rewrite map — the binding stays as the original `State(...)` constructor call, the handle-based runtime owns it end-to-end, and `stateOnChange` / friends register against the real integer handle. The `set`/`value`/`get`/`text` / `NavStack(state, routes)` / `ForEach(state, render)` rewrites are unaffected (they already had complete keyed-side implementations) and lowercase `state(...)` still works exactly as #535 designed when the user goes purely keyed-API. **Why not just remove the uppercase-`State` branch from `collect_state_bindings`?** #612's NavStack(state, routes) flow specifically needs the synth_id machinery for the `__navstack_register_route` call, and `state` and `State` are both documented as the constructor for that flow. Pulling uppercase out would re-break #612. The gate restores the v0.5.112+ handle-based contract for `State` without disturbing #535 / #612. **Validation.** (1) Four new unit tests in `crates/perry-transform/src/state_desugar.rs::tests` lock in the gate: module-init uppercase + `stateOnChange` is skipped; module-init lowercase + `stateBindTextfield` is skipped; module-init State with *only* `.set` is still rewritten (NavStack / ForEach path unaffected); a handle use inside a function body still gates the module-init binding (matches the #763 shape where `State()` is at module-init and `stateOnChange` is inside `main()`). (2) New integration fixture `test-files/test_issue_763_reactive_textfield.ts` exercises the original repro under AppleScript automation on macOS — typing into the TextField and clicking the "set hello world" button both reactively update the standalone `Text(\`current state for text: ${text.value}\`)` widget (verified via System Events' static-text enumeration). (3) NavStack regression: `test-files/test_issue_640_navstack_textfield.ts` (the original #612 repro) still routes from A→B correctly. `cargo test --release -p perry-transform` green (23/0/0); workspace test suite (excluding cross-host UI crates per CLAUDE.md) green. **Issue #764 context.** Originally filed to track auto-binding template literals over state cells to `perry_ui_state_bind_text_template` — but that auto-bind has actually shipped since v0.5.112 (HIR `try_desugar_reactive_text`); it emits an IIFE + `stateOnChange` + `textSetString` rather than calling the template-binding FFI directly, which works correctly when state is in function scope. The user-reported repro in #763 was actually exposing this separate module-init regression; this commit fixes that path. The "use `perry_ui_state_bind_text_template` directly" optimization is left as a future architectural improvement (it would centralize re-render in the runtime instead of one closure-per-state on the JS side).
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.911
**Current Version:** 0.5.912


## TypeScript Parity Status
Expand Down
Loading
Loading