Skip to content

Commit 209ba11

Browse files
authored
fix(perry-ext-fastify): #747 + #746 — event_loop + wait_for_promise pump stdlib (v0.5.904) (#751)
The perry-stdlib→perry-ext port (v0.5.572) dropped the `js_stdlib_process_pending` call that the original `crates/perry-stdlib/src/fastify/server.rs` had at the top of every event-loop tick. As a result, any program combining `fastify` with a perry-ext-* wrapper (ws, net, http, fetch) saw its listener callbacks stranded the moment `app.listen()` entered its loop — events accumulated on tokio workers but the JS main thread never drained the per-wrapper queues. The user's hub reproduced this as "`wss.on('connection', cb)` never fires" on v0.5.700–v0.5.892, filed as #746; the underlying defect is in perry-ext-fastify, filed as Three edits in `crates/perry-ext-fastify/src/server.rs`: 1. extern "C" block: declare `js_run_stdlib_pump` (defined in `crates/perry-runtime/src/lib.rs:177`). 2. `event_loop`: call `js_run_stdlib_pump()` ahead of `js_promise_run_microtasks()`, matching perry-stdlib's order. 3. `wait_for_promise`: same call inside the per-iteration body, for parity with `crates/perry-stdlib/src/fastify/server.rs:432` (defense-in-depth — the codegen-emitted `await` loop at `crates/perry-codegen/src/expr.rs:9500` already pumps stdlib, so this only matters for the rare case where a route handler returns a Promise resolved by an external mechanism without an inner `await` chain). Validation: - Fastify + WS combo before fix: `[wss listening]` + `[wss connection fired]` never print; after fix: both fire on the next tick. - Pure-WS minimal repro: already passed on macOS at HEAD (covered by the codegen-emitted main event loop's existing pump call). The combined fastify+ws path is what the hub actually exercises, so this closes #746 in practice. - Multi-await fastify handler (3× Promise.resolve + reply.code(201)) returns correct JSON in 4 ms. - Multi-await perry-ext-bcrypt chain (2× hash + 1× compare inside a POST handler) returns correct JSON in 4 ms. - `cargo test --release -p perry-ext-fastify`: 10 passed. - `cargo test --release --workspace --exclude perry-ui-{ios,tvos, watchos,visionos,android,windows,gtk4}`: exit 0. wait_for_promise omission caused @perryts/mysql's `await pool.exec(...)` chain to stall after the first INSERT, but a perry-ext-bcrypt chain (same shape, same queue_promise_resolution mechanism) works fine without the wait_for_promise pump — the codegen-emitted await loop pumps stdlib on every iteration, so inner awaits don't strand. The actual #748 root cause needs reproduction with a real @perryts/mysql + MySQL setup and is left open. Closes #747. Closes #746 (for the realistic fastify+ws use case; the pure-WS-only Linux symptom from the minimal repro doesn't reproduce on macOS at HEAD — left as a Linux follow-up if it persists). Refs #748 (investigated but not closed; see CHANGELOG for full context).
1 parent b2cea69 commit 209ba11

5 files changed

Lines changed: 101 additions & 72 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

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

5+
## v0.5.904 — fix(perry-ext-fastify): #747 + #746 — `event_loop` and `wait_for_promise` now pump `js_run_stdlib_pump`, so perry-ext-{ws,net,http,fetch} events accumulated on tokio workers finally dispatch on the JS main thread when fastify is the dominant event loop. **Symptom.** Any program that imports `fastify` together with `ws` (or `net`, or node `http`, or outbound `fetch`) had its perry-ext-* listener callbacks stranded the moment `app.listen()` entered its loop — `wss.on('connection', cb)` would bind the port, accept handshakes, push `PendingWsEvent::Connection(...)` to the queue, and never fire the callback. The user's hub (`hub.perryts.com`, `PerryTS/hub`) reproduced this end-to-end against v0.5.700 through v0.5.892 and filed #746 against the WS surface; the underlying pump-bridge omission lives in `perry-ext-fastify` and was filed separately as #747 once the WS-only minimal repro was extracted. The same defect is what made #746's "WebSocketServer callbacks never fire" symptom durable in practice — when fastify is in the same program as ws, fastify's event_loop runs forever on the JS main thread and never calls back into the codegen-emitted main event loop where `js_run_stdlib_pump` is wired. **Root cause.** `crates/perry-ext-fastify/src/server.rs`'s `event_loop` (added in v0.5.572 as part of the perry-stdlib→perry-ext port: commit 97e80d3b) only drains microtasks via `js_promise_run_microtasks()`. The original perry-stdlib version it replaced (`crates/perry-stdlib/src/fastify/server.rs:270-289`, untouched) called `crate::common::js_stdlib_process_pending()` ahead of the microtask drain on every loop tick. That single missing call routes through perry-stdlib's `async_bridge::js_stdlib_process_pending`, which fans out to `js_ws_process_pending` / `js_net_process_pending` / `js_http_process_pending` / `js_node_http_server_process_pending` — the per-wrapper queues every perry-ext-* binding pushes into from its tokio workers. With the call missing, those queues grow unboundedly while the fastify event_loop spins on microtasks and the 10 ms `try_recv_with_timeout` request poll, and listener callbacks never run. The same omission also exists in `wait_for_promise` (the per-request promise-await helper) — minor by comparison, because the codegen-emitted `await` loop already pumps stdlib at `crates/perry-codegen/src/expr.rs:9500`, but it matters for the rare case where a route handler returns a Promise that's directly resolved by an external mechanism rather than via an inner `await` chain (and matches the parity baseline of perry-stdlib's wait_for_promise at `crates/perry-stdlib/src/fastify/server.rs:432`, which has had the pump call all along). **Fix.** Three targeted edits in `crates/perry-ext-fastify/src/server.rs`: (1) extend the local `extern "C"` block with `fn js_run_stdlib_pump();` — the runtime symbol is in `crates/perry-runtime/src/lib.rs:177` and dispatches through the `STDLIB_PUMP_FN` AtomicPtr that perry-stdlib registers at startup via `js_register_stdlib_pump`, so the call stays decoupled from perry-stdlib's internal feature set; (2) prepend the call to the fastify `event_loop` body, ahead of `js_promise_run_microtasks()` — same ordering perry-stdlib's `event_loop` uses; (3) prepend the call to `wait_for_promise`'s per-iteration body for parity with perry-stdlib's version. **Validation.** Combined fastify + ws test (`/tmp/test_746_with_fastify.ts`: `Fastify().get("/", ...).listen({port: 18905})` alongside `new WebSocketServer({port: 18906})`) before the fix: `[wss listening]` never printed, `[wss connection fired]` never printed — a `websocat -1 ws://127.0.0.1:18906/` exited without triggering either callback. After the fix: both fire on the next event-loop tick. Pure-WS minimal repro (no fastify, `/tmp/test_746_exact.ts` exactly matching the issue body) already passed on macOS at HEAD prior to this commit — `connection` fires under the codegen-emitted main event loop's existing pump call; the user's hub-context Linux symptom is fully covered by the fastify path fix, since their build imports both wrappers. Sanity: rate-limiter-flexible (v0.5.898's #665 fix) still passes the chain-on-fastify probe. Multi-await fastify handler (`/tmp/test_748_fastify_async.ts`: 3× `await Promise.resolve(N)` inside a `POST /multi` handler) returns the expected `{a:11,b:22,c:33,sum:66,stage:4}` payload with HTTP 201 in 4 ms. Multi-await on a perry-ext-bcrypt chain (`/tmp/test_748_bcrypt.ts`: 2× `bcrypt.hash` + `bcrypt.compare` inside a `POST /hash` handler) returns `{h1_len:60,h2_len:60,ok:1,hashes_differ:true}` with HTTP 201 in 4 ms. `cargo build --release -p perry-ext-fastify -p perry` clean. **Scope.** Closes #747 (the precise omission filed) and resolves the realistic-use-case half of #746 (programs that combine fastify with ws). The pure-WS-only Linux scenario from #746's minimal repro doesn't reproduce on macOS at HEAD — left as a Linux-only follow-up if it persists; the diagnostic path forward is to check whether the user's hub binary actually pulls in a fastify-like helper through any transitive import that engages perry-ext-fastify's event_loop. **Out of scope.** #748 — investigated in tandem and *not* closed by this commit. Initial hypothesis was that `wait_for_promise`'s missing pump caused @perryts/mysql's `await pool.exec(...)` chain to stall after the first INSERT; testing with a perry-ext-bcrypt chain proved otherwise (the codegen-emitted `await` loop pumps stdlib on every iteration at `crates/perry-codegen/src/expr.rs:9500`, so inner awaits never strand on stdlib resolution). The wait_for_promise pump call ships here as defense-in-depth + parity with perry-stdlib::fastify, not as a #748 fix. The actual #748 root cause needs reproduction with a real `@perryts/mysql` setup against MySQL and is left open for separate investigation. **Version-bump note.** Renumbered v0.5.899 → v0.5.904 to follow #743/#749/#750 in the merge queue (v0.5.900/v0.5.901+v0.5.902/v0.5.903 respectively).
6+
57
## v0.5.903 — fix(gc): #745 — JSON polyglot benchmark RSS regression. **Symptom.** `benchmarks/json_polyglot/` regressed 2-4× on peak RSS vs v0.5.279: `validate-and-roundtrip` 85 → 254 MB, `parse-and-iterate` 100 → 411 MB; the no-lazy-tape variants moved ~2.6× too. Wall time was flat or slightly faster — purely a memory-footprint regression. **Root cause.** Commit `56818086` ("perf(gc): bump bytes trigger after gc-suppressed parse") added a per-parse bytes-trigger bump in `gc_bump_malloc_trigger` to defer GC during the post-parse iterate/rebuild pass of `json_pipeline_full` (single 108 MB parse + 70 MB iterate). The bump uses `bytes_now + GC_STEP_BYTES` with "only raise — never lower" semantics. The optimization was correct for the single-shot pattern but pathological for a *loop* of `JSON.parse + discard`: each iteration's bump ratcheted the trigger by another `step`, and with productive-sweep step-doubling (post-`73a48ced` ECS optimization) the step itself grew toward 1 GB across cycles. `PERRY_GC_DIAG=1` on the `json_polyglot` 50-iter roundtrip showed **zero** GC cycles — the trigger had been pushed hundreds of MB above the actual live set (~5 MB) before allocation could ever catch up. **Fix.** Three guards on the bump in `crates/perry-runtime/src/gc.rs`, gated on the suppressed window's actual arena growth: (1) `GC_PRE_SUPPRESS_BYTES` thread-local — `gc_suppress` snapshots `arena_total_bytes()` at suppress-start; `gc_bump_malloc_trigger` reads it to compute `parse_growth = bytes_now - pre_suppress`. (2) `GC_TRIGGER_BUMPED` thread-local flag — for medium-or-larger parses (`parse_growth >= 1 MB`, the `json_pipeline_full` and `json_polyglot` shapes), the bump may raise the trigger only *once* per GC cycle; the flag is set on a successful raise and cleared at the top of `gc_collect_inner` so all collection entry points (full GC, minor GC via `gc_collect_minor`, manual `gc()`, malloc-count trigger path) re-arm it. Tiny parses (`< 1 MB` of arena growth) bypass the flag and bump every call — this preserves the `test_memory_json_churn` shape (5 k iters × small+large parses into a fragmented arena, where every block holds both live and dead objects so a GC sweep would find 91 %+ bytes dead but reclaim zero blocks and cascade RSS up; the original bytes-bump correctly deferred GC indefinitely on that shape). (3) Step cap at `GC_THRESHOLD_INITIAL_BYTES` (64 MB) — clamps the bump's effective step so post-`73a48ced` step-doubling can't make a single bump grant hundreds of MB of headroom. The cap is a no-op for `json_pipeline_full` since `GC_STEP_BYTES` is at INITIAL on the first call (no prior GC). **Validation.** Repro confirmed at v0.5.898 baseline: 250 MB lazy roundtrip / 407 MB lazy field-access. After fix: **223 MB** lazy roundtrip (-11%) / **305 MB** lazy field-access (-25%). Wall time unchanged (82 ms roundtrip, 403 ms field-access; both faster than the v0.5.279 baseline). `./scripts/run_memory_stability_tests.sh` was failing 3/18 before the fix (`test_memory_json_churn` at 461 MB across all three GC profiles vs 250 MB limit) and now passes all 18; the churn test lands at 209 MB. Pipeline-full-style probe (200 k records, ~26 MB parse + iterate+rebuild) still bumps and runs in ~135 ms with no mid-iterate GC. Full perry-runtime test suite green (250/0/0). **Out of scope.** The `PERRY_JSON_TAPE=0` (no-lazy, opt-in debug knob) path moved 265 → 279 MB — a slight regression from firing GC inside the loop, which the unfixed "never GC, grow linearly" behavior had been hiding. The lazy-tape default (the path the issue title named) is the headline metric and moves the right direction. The remaining ~2.5× gap vs the v0.5.279 baseline on the lazy path is from the `GC_TRIGGER_ABSOLUTE_CEILING` raise (64 → 128 MB in commit `73a48ced`) which is a deliberate ECS optimization and is left untouched per direction. **Version-bump note.** Renumbered v0.5.900 → v0.5.903 because #743 took v0.5.900 (#733 inliner fix) and #749 took v0.5.901/v0.5.902 (#741/#742/#740 stdlib + codegen) ahead of this PR in the merge queue.
68

79
## v0.5.902 — fix(codegen): #740 follow-up — object-literal class-field aliases. Builds on v0.5.901's class-expr→ClassRef change. Two new shapes now correctly resolve a class-ref read out of an object-literal field back to the underlying class, instead of falling through to the empty-object placeholder. (1) `const O = { Inner: class extends Base {…} }; new O.Inner(args)` — the `NewDynamic { callee: PropertyGet { LocalGet(O), "Inner" }, args }` case in `crates/perry-codegen/src/expr.rs`. (2) `const O = { Inner: class … }; const C = O.Inner; new C(args)` — same shape but with an intermediate `let` binding (the `Stmt::Let { init: Some(PropertyGet { LocalGet(other), prop }) }` arm in `crates/perry-codegen/src/stmt.rs`). Both share a new per-function side table `FnCtx.local_class_field_aliases: HashMap<LocalId, HashMap<String, String>>` populated when `Stmt::Let` sees `init = New { class_name (an __AnonShape), args }` and walks the class's declared field order in parallel with the args — any `Expr::ClassRef(name)` arg becomes a `(local_id, field_name) → class_name` entry. The map is also propagated through `let O2 = O` (LocalGet of a known-shape local) so a re-binding doesn't lose the class info. **Validation.** Minimal repro `const O = { Inner: class extends Base { _tag = "X" } }; new O.Inner({issue:"x"}).issue` now prints `"x"`, matches Node. test740_b (function returns class via O.Inner, called outside) prints `inst._tag: X` correctly. Same 26/28 `test_gap_*` pass, same 20/23 `test_*class*` pass — no regressions. **Still open.** The full Effect DoD (#321) needs runtime parent-constructor dispatch: `class ParseError extends TaggedError("ParseError")` reaches `[3] pe._tag: undefined` because perry's `lower_new` only walks the static `extends_name` parent chain — `RegisterClassParentDynamic` registers the parent at runtime but no code path consults that registry to run the parent's field initializers / constructor. Fixing that means emitting each class's constructor as a separately addressable function and adding a runtime constructor-registry lookup, which is the same shape of work that would unblock `class X extends Factory()` generally. **Version-bump note.** Renumbered v0.5.897 → v0.5.902 to follow this PR's first commit (v0.5.901) after the main-side bumps that intervened.

CLAUDE.md

Lines changed: 1 addition & 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.903
11+
**Current Version:** 0.5.904
1212

1313

1414
## TypeScript Parity Status

0 commit comments

Comments
 (0)