Skip to content

fix(jsruntime/http): V8 listen keepalive + express handler smoke#997

Merged
proggeramlug merged 1 commit into
mainfrom
fix/v8-listen-keepalive-express-smoke
May 18, 2026
Merged

fix(jsruntime/http): V8 listen keepalive + express handler smoke#997
proggeramlug merged 1 commit into
mainfrom
fix/v8-listen-keepalive-express-smoke

Conversation

@proggeramlug
Copy link
Copy Markdown
Contributor

Summary

Three bug fixes that unblock the express end-to-end smoke against the V8-fallback http.createServer bridge from #994:

  • V8 bind keepalive: JsRuntimeState::last_poll_was_pending is set by poll_v8_event_loop_once whenever deno_core reports refed ops in flight, and jsruntime_has_active_handles returns 1 while it's set. Pre-fix, app.listen(port, cb) returned to the codegen-emitted outer event loop while op_perry_http_listen's TcpListener::bind future was still suspended on the multi-thread runtime, the outer loop saw no active source, and the program exited before bind ever resolved.
  • Express handler polyfills: added setImmediate / clearImmediate globals (express's router uses them to break middleware recursion) and Buffer.allocUnsafeSlow (so safe-buffer, used by express/lib/response.js, doesn't fall through to a for...in copyProps that silently drops non-enumerable static methods like Buffer.isBuffer). Without these, every (_req, res) => res.send('pong') threw and the http shim returned 500.
  • Cb ordering + async op_perry_fetch: Server.listen now schedules the listening callback via Promise.resolve().then(() => cb()) so the JS accept loop registers its first op_perry_http_accept(sid) op before the trampoline-driven callback gets a chance to block the V8 thread. op_perry_fetch is now an async op wrapping the blocking ureq call in tokio::task::spawn_blocking, with the polyfill updated to await core.ops.op_perry_fetch(...).

External clients (curl http://127.0.0.1:port/...) hit a Perry-compiled express server and get the route body. The self-fetch case (async () => { await fetch('http://127.0.0.1:port/...') } inside the listen-cb) still deadlocks because Perry's native async lowering busy-waits on the V8 thread inside trampolines, blocking the IIFE's accept loop continuation — documented in CHANGELOG as a deferred issue gated on real Future-style state-machine lowering.

Verification

  • New regression test-files/test_issue_997_v8_listen_keepalive.ts: synchronous listen-cb that closes the server immediately. Pre-fix hangs forever under timeout; post-fix prints listening and exits 0.
  • Existing test-files/test_http_createserver_v8.ts still passes (200 hello + exit 0).
  • Express smoke (app.get('/ping', (_req, res) => res.send('pong'))) returns pong via external curl. Confirmed locally.
  • Fastify end-to-end smoke ({"msg":"pong"}) still works through perry-stdlib's bundled fastify path.

Test plan

  • CI lint passes
  • CI cargo-test passes
  • CI parity passes
  • CI compile-smoke passes
  • Local: target/release/perry test-files/test_issue_997_v8_listen_keepalive.ts -o /tmp/t && timeout 5 /tmp/t prints listening and exits 0
  • Local: target/release/perry test-files/test_http_createserver_v8.ts -o /tmp/t2 && timeout 10 /tmp/t2 prints 200 hello

Post-#994 the V8-fallback `http.createServer().listen(port, cb)` smoke
was hanging in three ways. Fixes the first three (bind keepalive,
express handler polyfills, cb ordering / async fetch op); the
self-fetch deadlock inside `async () => await fetch(...)` listen
callbacks is documented as a known limitation gated on real native
async lowering.

- `last_poll_was_pending` on `JsRuntimeState` so
  `jsruntime_has_active_handles` keeps the outer event loop alive
  while deno_core has refed ops in flight, e.g. an in-progress
  `TcpListener::bind` from `op_perry_http_listen`. Pre-fix the
  outer loop exited before bind resolved and "listening" never
  printed.
- `setImmediate` / `clearImmediate` polyfill and
  `Buffer.allocUnsafeSlow` added so express's
  router + safe-buffer can run. Without these every
  `(_req, res) => res.send(...)` handler threw and the http shim
  returned 500.
- `op_perry_fetch` made async (wraps blocking ureq in
  `spawn_blocking`), and `Server.listen` now schedules the
  user-supplied listening callback via `Promise.resolve().then(cb)`
  so the JS accept loop registers its first
  `op_perry_http_accept(sid)` op before the trampoline-driven
  callback gets a chance to block the V8 thread.

Validated: `test_issue_997_v8_listen_keepalive.ts` (new) +
`test_http_createserver_v8.ts` (existing) both pass; an external
`curl http://127.0.0.1:port/ping` against a Perry-compiled
`app.get('/ping', ...)` express server returns `pong` with 200.
@proggeramlug proggeramlug force-pushed the fix/v8-listen-keepalive-express-smoke branch from 6691168 to 541638b Compare May 18, 2026 07:11
@proggeramlug proggeramlug merged commit 9f9f78b into main May 18, 2026
@proggeramlug proggeramlug deleted the fix/v8-listen-keepalive-express-smoke branch May 18, 2026 07:11
proggeramlug added a commit that referenced this pull request May 18, 2026
…1009)

PR #973 lowered bare built-in idents (`Promise`, `Array`, `Date`, ...)
as `PropertyGet { GlobalGet(0), name }` so they route through the
globalThis singleton closure path. Two codegen call sites that
specialize `.then()` dispatch were still pattern-matching the legacy
`Expr::GlobalGet(_)` shape only:

- `type_analysis::is_promise_expr` for `Promise.resolve/reject/all/race/
  allSettled/any(...)` and `Array.fromAsync(...)`.
- `lower_call.rs`'s fused `Promise.resolve(x).then(cb)` fast path that
  routes to `js_promise_resolved_then`.

When `is_promise_expr` returned false, `.then(cb)` fell through to the
generic native method dispatch which doesn't enqueue the callback —
microtask-02..07 and edge-promises went silent in compile-smoke's
Native no-fallback gate, and on Linux V8 surfaced the same shape as
`TypeError: then is not a function`. The Native gate had been red on
every PR since #973 (admin-bypassed on #997 / #1000 / #1003 / #1004).

Extract `type_analysis::is_global_builtin_named(expr, name)` that
matches both shapes (legacy `GlobalGet(_)` and the post-#973
`PropertyGet { GlobalGet(0), name }`) and route both call sites
through it.

Validation: `scripts/run_native_no_fallback_tests.sh` — 35 passed, 0
failed (was 28/7 pre-fix).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant