fix(fastify,runtime): #1113 #1114 — bidirectional WS upgrade on app.server + setInterval/async CPU-wedge regression - #1144
Merged
Conversation
…erver + setInterval/async CPU-wedge regression #1114: v0.5.1009 regressed when #1066 made app.listen() non-blocking, so shop-admin's post-listen setInterval+async-MySQL JobLoop now runs and exposes a latent two-part defect (only at ~68-file scale): (1) the fastify pump (now called every event-loop AND every await-poll iteration) allocated a Vec<Handle> per call -> GC madvise thrash; reuse a per-thread scratch buffer (bundled + ext-fastify, zero steady-state alloc). (2) js_wait_for_event's budget==0 path returned without sleeping, so a pinned-past deadline spun a core forever and starved the request pump (HTTP wedge); add an adaptive spin-throttle (caps a sustained budget-0 spin at ~1kHz, untouched fast path, PERRY_SPIN_THROTTLE=0 escape hatch). New event_pump unit test; all 4 serialized on shared global pump state. #1113: finish the v0.5.1011 boot-fix follow-up by porting perry-ext-http-server's #577 Phase-4 model into perry-ext-fastify — hyper .with_upgrades(), native tungstenite handshake, register_external_ws_stream, drain upgrades in the pump and fire FastifyApp::upgrade_handlers with (req, wsId, head). perry-ext-ws gains noServer + js_ws_handle_upgrade; codegen/manifest wired. Verified: issue's exact pattern boots clean, 101 + correct accept key, upgrade/handleUpgrade/connection all fire, /healthz unaffected. Version 0.5.1012; CHANGELOG + CLAUDE.md version bumped.
scripts/regen_api_docs.sh output for the new ws.handleUpgrade NATIVE_MODULE_TABLE/API_MANIFEST entry — keeps the api-docs-drift CI check green (entry count 936->937 + the handleUpgrade row).
# Conflicts: # CHANGELOG.md
6 tasks
proggeramlug
added a commit
that referenced
this pull request
May 20, 2026
…1164) e538caa (#1144) fixed the fastify half of #1114 but `@perryts/mysql` is a pure-TS driver whose bytes ride `net.Socket`, not fastify — so the JobLoop's `setInterval` + async-MySQL tick still wedged on v0.5.1014. `js_net_process_pending` had the *exact* `Vec::drain(..).collect()` shape the fastify pump had pre-e538caa7: a fresh `Vec<PendingNetEvent>` heap alloc on EVERY generated-event-loop iteration AND every inline `await`-poll iteration via `js_stdlib_process_pending`. Under the ~1 kHz spin-throttle ceiling, that's the GC `madvise` page-churn shape the original report's `sample` profile leaf showed. Same `drain(..).collect()` exists unfixed in `js_ws_process_pending` (bundled-ws) and `js_http_process_pending` (bundled-http client), both reachable from shop-admin via the realtime broker + outbound HTTP. Mirror the e538caa pattern in each pump: thread_local! { static SCRATCH: RefCell<Vec<...>> = const { RefCell::new(Vec::new()) }; } let mut events = SCRATCH.with(|s| std::mem::take(&mut *s.borrow_mut())); events.clear(); { let mut g = X.lock().unwrap(); events.append(&mut *g); } for ev in events.drain(..) { /* dispatch */ } SCRATCH.with(|s| { let mut slot = s.borrow_mut(); if events.capacity() >= slot.capacity() { *slot = events; } }); `mem::take` makes a re-entrant pump (a user callback that inline-awaits back into the loop) safe — it gets a fresh empty Vec, and the outer call restores whichever buffer ended up larger. `Vec::append` moves the queue contents into our scratch buffer without allocating, where `drain+collect` always materialised a fresh Vec for the collect target. Files: - crates/perry-stdlib/src/net/mod.rs — bundled-net path - crates/perry-ext-net/src/lib.rs — well-known-flip net path - crates/perry-stdlib/src/ws.rs — bundled-ws - crates/perry-stdlib/src/http.rs — bundled-http client Validation: perry-stdlib unit tests 63/63 green, perry-ext-net 3/3, perry-runtime event_pump 4/4 (including e538caa's spin-throttle test). Full reproduction in shop-admin still requires the user's ~68-file unit (synthetic repros under /tmp/repro1114_real didn't trigger even with real MySQL — scale-bound trigger documented in v0.5.1013's CHANGELOG). This targets the documented signature at the source: the per-tick heap alloc in the net pump that survived e538caa. Refs #1114.
This was referenced May 20, 2026
Closed
Async early-return + unreached await-in-for-loop allocates ~200 MB/call (was #1114 root cause)
#1190
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1113. Closes #1114.
Two issues filed against shop-admin on v0.5.1009. Version bumped to 0.5.1012; CHANGELOG + CLAUDE.md updated.
#1114 —
setInterval+async loop pegs CPU 99% and wedges Fastify HTTP (regression v0.5.1008→v0.5.1009)Root cause. v0.5.1009 bundled #1066 (
634e1f58), which madejs_fastify_listennon-blocking. Pre-#1066,await app.listen(...)blocked the main thread forever, so shop-admin'snew JobLoop().start()(the next line) never ran. #1066 is correct — but now the JobLoop'ssetInterval(() => void this.tick(), 1000)actually runs, exposing a latent two-part defect that only manifests at scale (the minimal repro never triggered it, matching the user's and v0.5.1011's investigation):js_fastify_process_pending(now called every event-loop iteration and every inlineawait-poll iteration) allocated aVec<Handle>per call → the GCmadvisepage-churn the profile showed. → Reuse a per-thread scratch buffer (re-entrancy-safe, capacity retained, zero steady-state alloc) in both the bundledperry-stdlib::fastifyandperry-ext-fastifypumps.js_wait_for_event'sbudget_ms == 0path returns without sleeping; a deadline pinned in the past spins a core forever and starves the once-per-iteration request pump (every route times out while TCP still accepts — the exact wedge). → Adaptive spin-throttle: a sustained budget-0 streak (>1024) sleeps 1ms/call (caps a runaway at ~1kHz, ≤1ms added latency); any notify or real wait resets the streak so the sub-µs async hot path and transient budget-0 are untouched. Escape hatchPERRY_SPIN_THROTTLE=0.New
event_pumpunit testsustained_budget_zero_spin_is_throttled(deterministic retry-until-clean single-call measurement; all 4 event_pump tests serialized on shared global pump state). Verified no regression on the minimal fastify+setInterval shape: CPU 0.0%,/healthz200. The scale-specific shop-admin wedge needs the real ~68-file unit to reproduce (scripts/bisect_1114.shfrom v0.5.1011 remains for that); this targets the documented signature at the source.#1113 — bidirectional WebSocket upgrade dispatch on
app.serverv0.5.1011 shipped the boot-unblocking shape but 501'd on a real
Upgrade:request. This ports the proven perry-ext-http-server #577 Phase 4 model into perry-ext-fastify:+perry-ext-ws+tokio-tungstenite, newupgrademodule, hyper.with_upgrades(), synchronous101+ spawnedhyper::upgrade::on→ tungstenite handshake →register_external_ws_stream→ per-server upgrade channel drained by the pump → firesFastifyApp::upgrade_handlerswith(req, wsId, head).WebSocketServer({ noServer: true })no longer binds; newjs_ws_handle_upgradeshim wires the wsId to the server, invokes the callback, queues aconnectionevent. Codegen NATIVE_MODULE_TABLE + runtime_decls + API_MANIFEST (Compile-time error for unimplemented Node / Web APIs #463 manifest-consistency green).Verified e2e: the issue's exact pattern compiles, boots clean, returns
HTTP/1.1 101with correct RFC-6455sec-websocket-accept; logs showupgrade fired (typeof req = object)→WS upgraded→wss connection;/healthzunaffected by.with_upgrades().Honest caveats:
typeof app.server.on(bare property read) still reportsundefined—.on(...)is a codegen-routed call, not a bound-method value (pre-existing v0.5.1011 shape; separate cosmetic follow-up).noServerdetection is a positional heuristic. Persistent-WS-client message round-trips not exercised here; the dispatch chain is proven.Tests
event_pump4/4,manifest_consistency4/4,perry-ext-fastify10/10,perry-ext-ws4/4cargo build --releaseclean at 0.5.1012