Skip to content

fix(ext-http-server): defer 'listening' emit + listen callback to the event-loop tick; bind this=server in request handlers (#4903)#4924

Merged
proggeramlug merged 1 commit into
mainfrom
worktree-issue-4903-server-address
Jun 10, 2026
Merged

fix(ext-http-server): defer 'listening' emit + listen callback to the event-loop tick; bind this=server in request handlers (#4903)#4924
proggeramlug merged 1 commit into
mainfrom
worktree-issue-4903-server-address

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Summary

Fixes #4903 — the keystone of the node:http/https runtime-fail tail (#2132).

Node never invokes the listen(port, cb) callback synchronously from inside listen(): the 'listening' event is emitted on a later event-loop tick, after the current synchronous script segment finishes. Perry fired it inline, so the canonical corpus shape

const server = http.createServer().listen(0, () => {
  const port = server.address().port;   // `server` still unassigned → TypeError
});

ran the callback before the const server assignment completed and threw Cannot read properties of undefined (reading 'address'). The address() method itself and the synchronous ephemeral-port bind were already correct since #2132 — the issue's "address() returns undefined" was really "server is undefined".

Changes (crates/perry-ext-http-server/, http + https + http2 listen paths)

  • listen() now records a pending 'listening' emit and registers the callback as a once 'listening' listener — Node's exact mechanism, so the emit order vs. listeners added before/after listen() matches (pre-registered → listen callback → late-registered), and 'listening' listeners registered after listen() returned still fire.
  • The main-thread pump (js_node_http_server_process_pending) drains the pending emit at the top of each tick, firing every callback with implicit this bound to the server. The queue is detached before any callback runs (re-entrant listen() can't double-fire), server_is_active keeps the event loop alive while an emit is queued, and the GC root scanner pins the queued closure pointers.
  • 'request' listeners and the createServer(handler) handler are now also invoked with this = server (Node's emitter semantics), so the corpus function (req, res) { this.address().port } handler idiom works. The binding wraps only the synchronous call; microtasks run outside it.

Validation

Of the 18 corpus tests listed in #4903 (Node v22 test/parallel, run via the scripts/node_core_subset.py staging):

A full http+https corpus sweep against the pinned Node v22 corpus is running; I'll post the pass-delta vs the #2132 baseline (38/448) as a follow-up comment.

Per the external-metadata convention, no version bump / changelog entry in this PR — to be folded in at merge time.

…nt-loop tick; bind this=server in request handlers (#4903)

Node never invokes the listen(port, cb) callback synchronously from
inside listen() — 'listening' is emitted on a later event-loop tick,
after the current synchronous script segment finishes. Perry fired it
inline, so the canonical corpus shape

  const server = http.createServer().listen(0, () => {
    const port = server.address().port;
  });

ran the callback before the `const server` assignment completed and
threw "Cannot read properties of undefined (reading 'address')". The
address() method itself and the synchronous ephemeral-port bind were
already correct (#2132) — the report's "address() returns undefined"
was really "`server` is undefined".

Fix, across the http / https / http2 listen paths:

- listen() now records a pending 'listening' emit and registers the
  callback as a once 'listening' listener (Node's exact mechanism, so
  emit order vs. listeners added before/after listen() matches, and
  listeners registered after listen() returned still fire).
- The main-thread pump (js_node_http_server_process_pending) drains
  the pending emit at the top of each tick, firing callbacks with
  implicit `this` bound to the server. The queue is detached before
  any callback runs (re-entrant listen() can't double-fire),
  server_is_active keeps the loop alive while an emit is queued, and
  the GC root scanner pins the queued closure pointers.
- 'request' listeners and the createServer(handler) handler are now
  also invoked with `this` = server (Node emitter semantics), so the
  corpus `function (req, res) { this.address().port }` idiom works.
  Binding wraps only the synchronous call; microtasks run outside.

Of the 18 corpus tests in #4903: test-http-agent-null,
test-http-client-encoding, test-http-host-headers now pass
byte-for-byte; the rest get past the listen/address wall and fail on
documented sibling-ticket classes (#4904/#4905/#4906/#4909/#4910).
The pre-existing chained + async-res.end exit-hang reproduces on
main's binary too and stays with #4909.

New e2e regression test covers the chained shape, deferred-emit
ordering, late 'listening' registration, and this-binding in both the
listen callback and the request handler.

Fixes #4903
@proggeramlug proggeramlug force-pushed the worktree-issue-4903-server-address branch from 5484d0d to a8ee4d3 Compare June 10, 2026 14:31
@proggeramlug proggeramlug merged commit 986ee18 into main Jun 10, 2026
12 of 13 checks passed
@proggeramlug proggeramlug deleted the worktree-issue-4903-server-address branch June 10, 2026 14:41
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.

node:http/https: server.address() returns undefined in listen() callback (blocks listen(0) port)

1 participant