Skip to content

fix(fastify,runtime): #1113 #1114 — bidirectional WS upgrade on app.server + setInterval/async CPU-wedge regression - #1144

Merged
proggeramlug merged 4 commits into
mainfrom
fix/1113-1114-fastify-server-gc-regression
May 20, 2026
Merged

fix(fastify,runtime): #1113 #1114 — bidirectional WS upgrade on app.server + setInterval/async CPU-wedge regression#1144
proggeramlug merged 4 commits into
mainfrom
fix/1113-1114-fastify-server-gc-regression

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Closes #1113. Closes #1114.

Two issues filed against shop-admin on v0.5.1009. Version bumped to 0.5.1012; CHANGELOG + CLAUDE.md updated.

#1114setInterval+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 made js_fastify_listen non-blocking. Pre-#1066, await app.listen(...) blocked the main thread forever, so shop-admin's new JobLoop().start() (the next line) never ran. #1066 is correct — but now the JobLoop's setInterval(() => 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):

  1. Per-tick heap alloc in the fastify pump. js_fastify_process_pending (now called every event-loop iteration and every inline await-poll iteration) allocated a Vec<Handle> per call → the GC madvise page-churn the profile showed. → Reuse a per-thread scratch buffer (re-entrancy-safe, capacity retained, zero steady-state alloc) in both the bundled perry-stdlib::fastify and perry-ext-fastify pumps.
  2. Unbounded budget-0 spin. js_wait_for_event's budget_ms == 0 path 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 hatch PERRY_SPIN_THROTTLE=0.

New event_pump unit test sustained_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%, /healthz 200. The scale-specific shop-admin wedge needs the real ~68-file unit to reproduce (scripts/bisect_1114.sh from v0.5.1011 remains for that); this targets the documented signature at the source.

#1113 — bidirectional WebSocket upgrade dispatch on app.server

v0.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-fastify: +perry-ext-ws +tokio-tungstenite, new upgrade module, hyper .with_upgrades(), synchronous 101 + spawned hyper::upgrade::on → tungstenite handshake → register_external_ws_stream → per-server upgrade channel drained by the pump → fires FastifyApp::upgrade_handlers with (req, wsId, head).
  • perry-ext-ws: WebSocketServer({ noServer: true }) no longer binds; new js_ws_handle_upgrade shim wires the wsId to the server, invokes the callback, queues a connection event. 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 101 with correct RFC-6455 sec-websocket-accept; logs show upgrade fired (typeof req = object)WS upgradedwss connection; /healthz unaffected by .with_upgrades().

Honest caveats: typeof app.server.on (bare property read) still reports undefined.on(...) is a codegen-routed call, not a bound-method value (pre-existing v0.5.1011 shape; separate cosmetic follow-up). noServer detection is a positional heuristic. Persistent-WS-client message round-trips not exercised here; the dispatch chain is proven.

Tests

…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).
@proggeramlug
proggeramlug merged commit e538caa into main May 20, 2026
@proggeramlug
proggeramlug deleted the fix/1113-1114-fastify-server-gc-regression branch May 20, 2026 05:48
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant