From 99a935914082a4fdcf4b54eda719db21c39046ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 14 May 2026 19:29:48 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20#748=20=E2=80=94=20wait=5Ffor=5Fpromise?= =?UTF-8?q?=20condvar-based=20wait=20instead=20of=201s=20polling=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #748 root cause: in process_request (both perry-ext-fastify and perry-stdlib fastify), when a handler returns a Promise the dispatcher calls wait_for_promise() which polled `js_promise_state` 10000 times × 100us sleep (~1s budget) then bailed regardless of whether the promise settled. After the wait, `js_promise_value(ptr)` runs unconditionally — and that helper returns `(*promise).value` which stays at its initialized 0.0 for both Pending and Rejected promises. So a chain that needed >1s (rate-limiter + argon2 hash + multiple DB roundtrips in skelpo-shop-admin signup) produced the literal ASCII byte 0x30 ('0') as the response body with HTTP 200 (default `status_code` — reply.code(201) never ran). The orphaned chain was also abandoned: the dispatcher stops pumping microtasks, so every operation after the timeout silently no-op'd — explaining 'first INSERT commits, rest don't'. Fix: - Replace the polling/sleep loop with a condvar-based wait via js_wait_for_event() (the same primitive the codegen-emitted `await` body uses). 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. No fixed iteration limit. - After wait_for_promise returns, check js_promise_state. If Rejected (state == 2), surface a 500 response with { error: } JSON instead of letting js_promise_value return 0.0 and serialize as the literal byte '0'. Applied to both perry-ext-fastify/src/server.rs and perry-stdlib/src/fastify/server.rs. Validated via /tmp/repro748_fastify.ts: a handler with ~1.4s of sequential setTimeout delays now returns the explicit JSON body + HTTP 201 instead of the literal '0' + HTTP 200 that the pre-fix runtime produced. --- CHANGELOG.md | 2 + CLAUDE.md | 2 +- Cargo.lock | 136 ++++++++--------- Cargo.toml | 2 +- crates/perry-ext-fastify/src/server.rs | 173 ++++++++++++++++++++-- crates/perry-stdlib/src/fastify/server.rs | 155 +++++++++++++++++-- 6 files changed, 371 insertions(+), 99 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4ef77996..df4b71385 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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": }` 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). diff --git a/CLAUDE.md b/CLAUDE.md index a6aa6226c..56074e1b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 74a044bdd..149471364 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4790,7 +4790,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.911" +version = "0.5.912" dependencies = [ "anyhow", "base64", @@ -4845,14 +4845,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.911" +version = "0.5.912" dependencies = [ "serde", ] [[package]] name = "perry-codegen" -version = "0.5.911" +version = "0.5.912" dependencies = [ "anyhow", "log", @@ -4865,7 +4865,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.911" +version = "0.5.912" dependencies = [ "anyhow", "perry-hir", @@ -4874,7 +4874,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.911" +version = "0.5.912" dependencies = [ "anyhow", "perry-hir", @@ -4882,7 +4882,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.911" +version = "0.5.912" dependencies = [ "anyhow", "perry-dispatch", @@ -4892,7 +4892,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.911" +version = "0.5.912" dependencies = [ "anyhow", "perry-hir", @@ -4901,7 +4901,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.911" +version = "0.5.912" dependencies = [ "anyhow", "base64", @@ -4914,7 +4914,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.911" +version = "0.5.912" dependencies = [ "anyhow", "perry-hir", @@ -4922,7 +4922,7 @@ dependencies = [ [[package]] name = "perry-diagnostics" -version = "0.5.911" +version = "0.5.912" dependencies = [ "serde", "serde_json", @@ -4930,7 +4930,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.911" +version = "0.5.912" [[package]] name = "perry-doc-fixture-my-bindings" @@ -4941,7 +4941,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.911" +version = "0.5.912" dependencies = [ "anyhow", "clap", @@ -4956,7 +4956,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.911" +version = "0.5.912" dependencies = [ "argon2", "perry-ffi", @@ -4964,7 +4964,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.911" +version = "0.5.912" dependencies = [ "perry-ffi", "reqwest", @@ -4973,7 +4973,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.911" +version = "0.5.912" dependencies = [ "bcrypt", "perry-ffi", @@ -4981,7 +4981,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.911" +version = "0.5.912" dependencies = [ "perry-ffi", "rusqlite", @@ -4989,7 +4989,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.911" +version = "0.5.912" dependencies = [ "perry-ffi", "scraper", @@ -4997,14 +4997,14 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.911" +version = "0.5.912" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-cron" -version = "0.5.911" +version = "0.5.912" dependencies = [ "chrono", "cron", @@ -5013,7 +5013,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.911" +version = "0.5.912" dependencies = [ "chrono", "perry-ffi", @@ -5021,7 +5021,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.911" +version = "0.5.912" dependencies = [ "perry-ffi", "rust_decimal", @@ -5029,7 +5029,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.911" +version = "0.5.912" dependencies = [ "perry-ffi", "serde_json", @@ -5037,7 +5037,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.911" +version = "0.5.912" dependencies = [ "perry-ffi", "rand 0.8.6", @@ -5045,21 +5045,21 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.911" +version = "0.5.912" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.911" +version = "0.5.912" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.911" +version = "0.5.912" dependencies = [ "bytes", "http-body-util", @@ -5073,7 +5073,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.911" +version = "0.5.912" dependencies = [ "lazy_static", "perry-ffi", @@ -5084,7 +5084,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.911" +version = "0.5.912" dependencies = [ "lazy_static", "perry-ext-http-server", @@ -5096,7 +5096,7 @@ dependencies = [ [[package]] name = "perry-ext-http-server" -version = "0.5.911" +version = "0.5.912" dependencies = [ "bytes", "http-body-util", @@ -5115,7 +5115,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.911" +version = "0.5.912" dependencies = [ "lazy_static", "perry-ffi", @@ -5125,7 +5125,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.911" +version = "0.5.912" dependencies = [ "base64", "jsonwebtoken", @@ -5136,7 +5136,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.911" +version = "0.5.912" dependencies = [ "lru", "perry-ffi", @@ -5144,7 +5144,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.911" +version = "0.5.912" dependencies = [ "chrono", "perry-ffi", @@ -5152,7 +5152,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.911" +version = "0.5.912" dependencies = [ "bson", "futures-util", @@ -5164,7 +5164,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.911" +version = "0.5.912" dependencies = [ "chrono", "perry-ffi", @@ -5174,7 +5174,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.911" +version = "0.5.912" dependencies = [ "nanoid", "perry-ffi", @@ -5183,7 +5183,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.911" +version = "0.5.912" dependencies = [ "perry-ffi", "rustls", @@ -5194,7 +5194,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.911" +version = "0.5.912" dependencies = [ "lettre", "perry-ffi", @@ -5204,7 +5204,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.911" +version = "0.5.912" dependencies = [ "perry-ffi", "sqlx", @@ -5213,7 +5213,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.911" +version = "0.5.912" dependencies = [ "governor", "perry-ffi", @@ -5221,7 +5221,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.911" +version = "0.5.912" dependencies = [ "base64", "image", @@ -5230,14 +5230,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.911" +version = "0.5.912" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.911" +version = "0.5.912" dependencies = [ "lazy_static", "perry-ffi", @@ -5245,7 +5245,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.911" +version = "0.5.912" dependencies = [ "perry-ffi", "uuid", @@ -5253,7 +5253,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.911" +version = "0.5.912" dependencies = [ "perry-ffi", "regex", @@ -5263,7 +5263,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.911" +version = "0.5.912" dependencies = [ "futures-util", "lazy_static", @@ -5274,7 +5274,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.911" +version = "0.5.912" dependencies = [ "flate2", "perry-ffi", @@ -5282,7 +5282,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.911" +version = "0.5.912" dependencies = [ "dashmap 6.1.0", "once_cell", @@ -5291,7 +5291,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.911" +version = "0.5.912" dependencies = [ "anyhow", "perry-api-manifest", @@ -5305,7 +5305,7 @@ dependencies = [ [[package]] name = "perry-jsruntime" -version = "0.5.911" +version = "0.5.912" dependencies = [ "anyhow", "deno_core", @@ -5325,7 +5325,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.911" +version = "0.5.912" dependencies = [ "anyhow", "perry-diagnostics", @@ -5337,7 +5337,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.911" +version = "0.5.912" dependencies = [ "anyhow", "base64", @@ -5361,7 +5361,7 @@ dependencies = [ [[package]] name = "perry-stdlib" -version = "0.5.911" +version = "0.5.912" dependencies = [ "aes", "aes-gcm", @@ -5429,7 +5429,7 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.911" +version = "0.5.912" dependencies = [ "anyhow", "perry-hir", @@ -5439,7 +5439,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.911" +version = "0.5.912" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -5447,11 +5447,11 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.911" +version = "0.5.912" [[package]] name = "perry-ui-android" -version = "0.5.911" +version = "0.5.912" dependencies = [ "itoa", "jni", @@ -5466,7 +5466,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.911" +version = "0.5.912" dependencies = [ "rand 0.8.6", "serde", @@ -5476,7 +5476,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.911" +version = "0.5.912" dependencies = [ "cairo-rs", "dirs 5.0.1", @@ -5495,7 +5495,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.911" +version = "0.5.912" dependencies = [ "block2", "libc", @@ -5510,7 +5510,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.911" +version = "0.5.912" dependencies = [ "block2", "libc", @@ -5528,11 +5528,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.911" +version = "0.5.912" [[package]] name = "perry-ui-tvos" -version = "0.5.911" +version = "0.5.912" dependencies = [ "block2", "libc", @@ -5547,7 +5547,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.911" +version = "0.5.912" dependencies = [ "block2", "libc", @@ -5562,7 +5562,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.911" +version = "0.5.912" dependencies = [ "block2", "libc", @@ -5575,7 +5575,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.911" +version = "0.5.912" dependencies = [ "libc", "perry-runtime", @@ -5589,7 +5589,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.911" +version = "0.5.912" dependencies = [ "base64", "ed25519-dalek", @@ -5603,7 +5603,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.911" +version = "0.5.912" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index a319c4d8f..6d01454c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -190,7 +190,7 @@ opt-level = "s" # Optimize for size in stdlib opt-level = 3 [workspace.package] -version = "0.5.911" +version = "0.5.912" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/crates/perry-ext-fastify/src/server.rs b/crates/perry-ext-fastify/src/server.rs index 3d81625a2..ced788aea 100644 --- a/crates/perry-ext-fastify/src/server.rs +++ b/crates/perry-ext-fastify/src/server.rs @@ -75,6 +75,22 @@ extern "C" { /// tokio worker stacks. Same call perry-stdlib's fastify makes /// to dodge issue #31. fn js_gc_enter_unsafe_zone(); + + /// Condvar-based wait for the next event (timer fire, notify from a + /// tokio worker, or 1 s idle cap). Used by `wait_for_promise` so the + /// handler dispatcher blocks on real events instead of burning the + /// CPU in a 100 us-poll loop. Wakes the moment any stdlib worker + /// calls `js_notify_main_thread`, including the per-promise wake + /// fired by `js_promise_resolve` / `js_promise_reject`. + fn js_wait_for_event(); + + /// Drive timer callback dispatch (matches the codegen-emitted await + /// wait body). Without these the `await new Promise(r => setTimeout(r, n))` + /// shape would never advance inside `wait_for_promise`, only inside + /// directly-compiled `await` sites. + fn js_timer_tick() -> i32; + fn js_callback_timer_tick() -> i32; + fn js_interval_timer_tick() -> i32; } /// Opaque marker for the runtime's Promise struct. We never read its @@ -463,7 +479,38 @@ fn process_request(app_handle: Handle, pending: FastifyPendingRequest) { let ptr = jsv.as_pointer::(); if !ptr.is_null() && unsafe { js_is_promise(ptr) } != 0 { wait_for_promise(ptr); - final_result = unsafe { js_promise_value(ptr) }; + // Read state AFTER the wait — `js_promise_value` + // returns `(*promise).value` unconditionally, and + // that field stays at its initial `0.0` for rejected + // promises (which set `reason`, not `value`) and + // pending promises (which are never reached after + // the unbounded `wait_for_promise` returns, but + // defending here keeps us robust against future + // changes to `wait_for_promise`'s contract). Without + // this branch, an unhandled rejection inside a + // route handler would serialize the literal byte + // `0` as the response body — exactly the issue + // #748 symptom for the cases where the chain + // rejected instead of stalling. + let st = unsafe { js_promise_state(ptr) }; + if st == 2 { + // Rejected — translate to a 500 response with + // the rejection reason rendered to JSON. The + // dispatcher's fallback `build_response_body` + // already JSON-stringifies pointer values, so + // wrap the reason in a `{ error: }` + // envelope to avoid spilling raw stack traces + // into the wire. Mirrors `fastify`'s default + // error handler shape. + if let Some(ctx) = perry_ffi::get_handle_mut::(ctx_handle) { + ctx.status_code = 500; + let reason = unsafe { js_promise_reason(ptr) }; + ctx.response_body = Some(unsafe { render_rejection_body(reason) }); + } + final_result = f64::from_bits(TAG_UNDEFINED); + } else { + final_result = unsafe { js_promise_value(ptr) }; + } } } } @@ -524,31 +571,127 @@ fn call_hook_awaiting(hook: ClosurePtr, ctx_f64: f64, ctx_handle: Handle) -> boo .unwrap_or(false) } -/// Spin until a promise resolves — bounded to avoid infinite loops if -/// the handler chain stalls. Polls microtasks and the stdlib pump every -/// iteration so awaited values get a chance to settle. The codegen- -/// emitted `await` body already pumps stdlib inside compiled functions -/// (`perry-codegen/src/expr.rs:9500`), so the common case is covered -/// without this — but a route handler that returns a Promise resolved -/// directly by an external mechanism (no inner `await` loop pumping -/// stdlib) would otherwise stall here. Matches `perry-stdlib/src/ -/// fastify/server.rs:432`, which has had this call all along; the -/// perry-ext-fastify port (v0.5.572) dropped it. +/// Wait until a promise settles, driving microtasks, the stdlib pump, +/// and timer ticks every iteration. Blocks on `js_wait_for_event` (a +/// condvar with a 1 s idle cap) instead of `thread::sleep`, so the +/// dispatcher wakes the moment any stdlib worker calls +/// `js_notify_main_thread` — the same wake that `js_promise_resolve` +/// and `js_promise_reject` fire when the awaited chain advances. +/// +/// Mirrors the codegen-emitted `await` body in +/// `crates/perry-codegen/src/expr.rs` (the "=== wait ===" block at +/// lines ~9645-9665): no fixed iteration limit, condvar-based wait. +/// +/// ### Why the old polling loop is wrong (issue #748) +/// +/// The previous implementation looped 10_000 × 100 us = ~1 s and then +/// returned regardless of whether the promise had settled. Callers +/// then read `js_promise_value(ptr)` which returns `(*promise).value` +/// — `0.0` for a still-Pending Promise (it's initialized to zero in +/// `Promise::new` and only overwritten by `js_promise_resolve`). The +/// dispatcher serialized that `0.0` as the response body, yielding the +/// literal ASCII byte `0x30` ("0") with HTTP 200 (default +/// `status_code` — `reply.code(201)` was never reached because the +/// handler chain hadn't returned). The signup-style route in #748 +/// runs many awaits (rate-limiter, argon2 hash, multiple `pool.exec` +/// round-trips, JWT signing) which routinely exceeds 1 s on a cold +/// connection pool; every operation after the timeout silently +/// no-op'd because the dispatcher returned and stopped pumping +/// microtasks for the orphaned chain. fn wait_for_promise(promise_ptr: *mut Promise) { - use std::time::Duration; - for _ in 0..10000 { + // First pump synchronously — handles the already-settled case + // (e.g. `async () => 42` whose promise is fulfilled before this + // function is even called) without entering the wait path. + unsafe { + js_run_stdlib_pump(); + js_promise_run_microtasks(); + } + let mut state = unsafe { js_promise_state(promise_ptr) }; + if state != 0 { + return; + } + loop { unsafe { + // Drive timers, the stdlib pump, and microtasks every tick + // — mirrors the codegen-emitted await body. `js_timer_tick` + // & friends are no-ops when there's nothing to dispatch. + let _ = js_timer_tick(); + let _ = js_callback_timer_tick(); + let _ = js_interval_timer_tick(); js_run_stdlib_pump(); js_promise_run_microtasks(); + // Condvar wait: blocks until a notify arrives or the 1 s + // idle cap elapses, whichever is first. `js_promise_resolve` + // / `js_promise_reject` fire `js_notify_main_thread`, so + // the wake happens the instant the chain advances. + js_wait_for_event(); } - let state = unsafe { js_promise_state(promise_ptr) }; + state = unsafe { js_promise_state(promise_ptr) }; if state != 0 { return; } - std::thread::sleep(Duration::from_micros(100)); } } +/// Render a Promise rejection reason as a `{ "error": ... }` JSON body +/// for the 500 response surfaced by `process_request`. Falls back to a +/// generic envelope if the reason can't be stringified (e.g. opaque +/// pointer that JSON.stringify rejects). +unsafe fn render_rejection_body(reason: f64) -> Vec { + // Strings: wrap the user's message verbatim. + let jsv = JsValue::from_bits(reason.to_bits()); + if jsv.is_string() { + let s = jsvalue_to_response_body(reason); + // s is the raw string bytes; embed as a JSON string literal. + let mut out = b"{\"error\":".to_vec(); + out.push(b'"'); + for b in s { + match b { + b'"' => out.extend_from_slice(b"\\\""), + b'\\' => out.extend_from_slice(b"\\\\"), + b'\n' => out.extend_from_slice(b"\\n"), + b'\r' => out.extend_from_slice(b"\\r"), + b'\t' => out.extend_from_slice(b"\\t"), + 0x00..=0x1f => out.extend_from_slice(format!("\\u{:04x}", b).as_bytes()), + _ => out.push(b), + } + } + out.push(b'"'); + out.push(b'}'); + return out; + } + if jsv.is_pointer() { + let str_ptr = js_json_stringify(reason, 0); + if !str_ptr.is_null() { + let len = (*str_ptr).byte_len as usize; + let data_ptr = (str_ptr as *const u8).add(std::mem::size_of::()); + let inner = std::slice::from_raw_parts(data_ptr, len).to_vec(); + let mut out = b"{\"error\":".to_vec(); + out.extend_from_slice(&inner); + out.push(b'}'); + return out; + } + } + // Numbers/bools/null/undefined: best-effort stringification. + let body = jsvalue_to_response_body(reason); + let mut out = b"{\"error\":".to_vec(); + if body.is_empty() { + out.extend_from_slice(b"null"); + } else { + out.push(b'"'); + for b in body { + match b { + b'"' => out.extend_from_slice(b"\\\""), + b'\\' => out.extend_from_slice(b"\\\\"), + _ => out.push(b), + } + } + out.push(b'"'); + } + out.push(b'}'); + out +} + /// Render the handler return value as response bytes. Handlers can /// return strings (used as-is), objects/arrays (JSON-stringified), /// numbers/bools (toString), or `undefined` (empty `{}`). diff --git a/crates/perry-stdlib/src/fastify/server.rs b/crates/perry-stdlib/src/fastify/server.rs index 615cbda34..2a35dc7a2 100644 --- a/crates/perry-stdlib/src/fastify/server.rs +++ b/crates/perry-stdlib/src/fastify/server.rs @@ -360,10 +360,37 @@ fn event_loop(app_handle: Handle, request_rx: &mut mpsc::Receiver(ctx_handle) + } { + ctx.status_code = 500; + let reason = unsafe { + perry_runtime::promise::js_promise_reason( + ptr as *mut perry_runtime::Promise, + ) + }; + ctx.response_body = Some(render_rejection_body(reason)); + } + final_result = f64::from_bits(0x7FFC_0000_0000_0001); + } else { + final_result = unsafe { + perry_runtime::js_promise_value( + ptr as *mut perry_runtime::Promise, + ) + }; + } } } } @@ -428,23 +455,123 @@ unsafe fn call_hook_awaiting(hook: ClosurePtr, ctx_f64: f64, ctx_handle: Handle) } } -/// Wait for a promise to resolve/reject +/// Wait until a promise settles, driving microtasks, the stdlib pump, +/// and timer ticks every iteration. Blocks on +/// `perry_runtime::event_pump::js_wait_for_event` (a condvar with a 1 s +/// idle cap) instead of `thread::sleep`, so the dispatcher wakes the +/// moment any stdlib worker calls `js_notify_main_thread` — the same +/// wake `js_promise_resolve` / `js_promise_reject` fire when the +/// awaited chain advances. +/// +/// Mirrors the codegen-emitted `await` body in +/// `crates/perry-codegen/src/expr.rs` (the "=== wait ===" block at +/// lines ~9645-9665): no fixed iteration limit, condvar-based wait. +/// +/// ### Why the old polling loop is wrong (issue #748) +/// +/// The previous implementation looped 10_000 × 100 us = ~1 s and then +/// returned regardless of whether the promise had settled. Callers +/// then read `js_promise_value(ptr)` which returns `(*promise).value` +/// — `0.0` for a still-Pending Promise (it's initialized to zero in +/// `Promise::new` and only overwritten by `js_promise_resolve`). The +/// dispatcher serialized that `0.0` as the response body, yielding the +/// literal ASCII byte `0x30` ("0") with HTTP 200 (default +/// `status_code` — `reply.code(201)` was never reached because the +/// handler chain hadn't returned). Routes that do many awaits +/// (argon2 hashing + multi-step DB writes, etc.) routinely exceed +/// 1 s on a cold connection pool; every operation after the timeout +/// silently no-op'd because the dispatcher returned and stopped +/// pumping microtasks for the orphaned chain. fn wait_for_promise(promise_ptr: *mut perry_runtime::Promise) { - // Poll until promise is settled - for _ in 0..10000 { - // Process pending operations - unsafe { crate::common::js_stdlib_process_pending() }; + // First pump synchronously — handles the already-settled case + // (e.g. `async () => 42` whose promise is fulfilled before this + // function is even called) without entering the wait path. + unsafe { crate::common::js_stdlib_process_pending() }; + perry_runtime::js_promise_run_microtasks(); + let mut state = unsafe { perry_runtime::js_promise_state(promise_ptr) }; + if state != 0 { + return; + } + loop { + unsafe { + // Drive timers, the stdlib pump, and microtasks every tick + // — mirrors the codegen-emitted await body. + let _ = perry_runtime::timer::js_timer_tick(); + let _ = perry_runtime::timer::js_callback_timer_tick(); + let _ = perry_runtime::timer::js_interval_timer_tick(); + crate::common::js_stdlib_process_pending(); + } perry_runtime::js_promise_run_microtasks(); - - // Check if promise is settled (state != 0 means not pending) - let state = unsafe { perry_runtime::js_promise_state(promise_ptr) }; + // Condvar wait: blocks until a notify arrives or the 1 s + // idle cap elapses. `js_promise_resolve` / `js_promise_reject` + // fire `js_notify_main_thread`, so the wake happens the + // instant the chain advances. + perry_runtime::event_pump::js_wait_for_event(); + state = unsafe { perry_runtime::js_promise_state(promise_ptr) }; if state != 0 { - break; + return; } + } +} - // Small sleep to avoid busy-waiting - std::thread::sleep(std::time::Duration::from_micros(100)); +/// Render a Promise rejection reason as a `{ "error": ... }` JSON body +/// for the 500 response surfaced by the dispatcher on async-handler +/// rejection (issue #748). +fn render_rejection_body(reason: f64) -> Vec { + let jsv = JSValue::from_bits(reason.to_bits()); + if jsv.is_string() { + let s = build_response_body(reason); + let mut out = b"{\"error\":".to_vec(); + out.push(b'"'); + for b in s { + match b { + b'"' => out.extend_from_slice(b"\\\""), + b'\\' => out.extend_from_slice(b"\\\\"), + b'\n' => out.extend_from_slice(b"\\n"), + b'\r' => out.extend_from_slice(b"\\r"), + b'\t' => out.extend_from_slice(b"\\t"), + 0x00..=0x1f => out.extend_from_slice(format!("\\u{:04x}", b).as_bytes()), + _ => out.push(b), + } + } + out.push(b'"'); + out.push(b'}'); + return out; + } + if jsv.is_pointer() { + extern "C" { + fn js_json_stringify(value: f64, type_hint: u32) -> *mut StringHeader; + } + unsafe { + let str_ptr = js_json_stringify(reason, 0); + if !str_ptr.is_null() { + let len = (*str_ptr).byte_len as usize; + let data_ptr = (str_ptr as *const u8).add(std::mem::size_of::()); + let inner = std::slice::from_raw_parts(data_ptr, len).to_vec(); + let mut out = b"{\"error\":".to_vec(); + out.extend_from_slice(&inner); + out.push(b'}'); + return out; + } + } + } + let body = build_response_body(reason); + let mut out = b"{\"error\":".to_vec(); + if body.is_empty() { + out.extend_from_slice(b"null"); + } else { + out.push(b'"'); + for b in body { + match b { + b'"' => out.extend_from_slice(b"\\\""), + b'\\' => out.extend_from_slice(b"\\\\"), + _ => out.push(b), + } + } + out.push(b'"'); } + out.push(b'}'); + out } /// Build response body from handler return value