Skip to content

node:http: emit 'listening' with no arguments, as node does - #43891

Open
robobun wants to merge 5 commits into
mainfrom
robobun/5a67cfd4/http-listening-no-args
Open

robobun wants to merge 5 commits into
mainfrom
robobun/5a67cfd4/http-listening-no-args

Conversation

@robobun

@robobun robobun commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • http.Server, https.Server and the ws WebSocketServer pass (null, hostname, port) to 'listening' listeners and to the listen() callback. Node passes nothing (emitListeningNT).
  • Valid node code breaks on bun 1.4.2: async.waterfall([cb => server.listen(0, cb), next => ...]) throws TypeError: next is not a function.
  • The cause is one emit, emitListeningNextTick (src/js/node/_http_server.ts:240). Handle errors in node:http better #12641 left // TODO: remove the arguments above it.

Fix

  • emitListeningNextTick calls self.emit("listening"), as net.ts does. The listen() callback is a once('listening') listener, so one emit covers both.
  • Six in-tree test files read the port from the callback. They now use server.address().port.
  • Verified: new cases in test/js/node/http/node-http.test.ts (http, https) and test/js/node/http/node-http-with-ws.test.ts (ws) fail on bun 1.4.2 and on a debug build of main with [null, "localhost", port]. Also ran test/js/node/http/, cluster, express, st, body-parser.
  • Self-reviewed: 2 concerns raised, 1 addressed (the ws test). Rejected: running these tests under real node, which is separate work.

Background

  • https.ts exports the http.Server constructor. The ws shim re-emits its http server's 'listening' with the same arguments (src/js/thirdparty/ws.js:1391). Both follow with no edit.
  • bun's net, tls, http2 and dgram servers already emit no arguments.
  • Considered wrapping only the listen() callback: the event keeps the wrong arguments. The emit is the only producer.

Downsides

  • Breaking change: bun-only code that reads hostname or port from the callback or the event now gets undefined. Use server.address().port. Please add a release-note line.
  • The break is silent: no throw, no warning. Code search finds this shape only in copies of bun's tests. Private code is not searchable.
Notes

Repro:

const http = require("http");
const s = http.createServer(() => {});
s.on("listening", (...args) => console.log("listener", args));
s.listen(0, (...args) => {
  console.log("callback", args);
  s.close();
});
node v26.3.0          bun 1.4.2                                    this PR
listener []           listener [ null, "localhost", 33949 ]        listener []
callback []           callback [ null, "localhost", 33949 ]        callback []

Other shapes, same three runtimes (async 3.2.6, ws 8.18.3 from npm on node, the built-in shim on bun):

                                    node v26.3.0     bun 1.4.2                            this PR
async.waterfall([cb => listen(0, cb), next => ...])  ok   TypeError: next is not a function    ok
util.promisify(server.listen.bind(server))(0)        undefined        "localhost"           undefined
(await events.once(server, "listening")).length      0                3                     0
server.listen(0, (msg = "server started") => ...)    "server started" null                  "server started"
new WebSocketServer({ port: 0 }, (...args) => ...)   []               [null, "localhost", port]   []
wss.on("listening", (...args) => ...)                []               [null, "localhost", port]   []
server.listen(path, (...args) => ...) (unix socket)  []               [null, undefined, undefined] []

Node registers the callback with this.once('listening', cb) (lib/net.js#L2120). bun does the same in Server.prototype.listen since #40041.

History of the arguments:

Test files that read the old arguments, all moved to server.address().port: test/js/node/http/node-http.test.ts (9 sites, including the listen() helper most of the file uses), test/js/node/http/fixtures/http.compress.leak.server.ts, test/js/third_party/express/express.test.ts, test/js/third_party/st/st.fixture.ts, test/js/third_party/body-parser/express-body-parser-test.test.ts, test/js/bun/test/parallel/test-http-10177-...ts (its expect(port).toBeGreaterThan(0) now reads server.address().port). No code under src/js reads them: setupConnectionsTracking and the cluster worker listener take no parameters.

express.test.ts keeps its hang guard for the express hang of #8926 and widens it on debug and ASAN builds: AbortSignal.timeout(isDebug || isASAN ? 5000 : 500). On a debug ASAN build the first request to express takes about 730 ms, so with the fixed 500 ms bound the test failed there with and without this change.

Suites run on a debug build of this branch: node-http.test.ts (165 pass, 1 skip), every other file in test/js/node/http/, test/js/node/cluster.test.ts (36 pass), test/js/bun/test/test-timers.test.ts, test/js/node/http/node-http-with-ws.test.ts, test/js/first_party/ws/ws.test.ts, express, st, body-parser, and 15 upstream files from test/js/node/test/parallel/ (test-http-listening.js, test-http-server-close-all.js, test-https-simple.js, test-http-unix-socket.js, test-cluster-http-pipe.js and others). I found no upstream node test that this change newly enables.

Seen on the debug build and not related to this change:

  • test/js/node/http/node-http-syscall-fault.test.ts > "upRes.pipe(res) with res.destroy() racing a queued drain" needs 5.3 s against the 5 s default timeout. It passes with --timeout 120000.
  • test/js/first_party/ws/ws-upgrade-events.test.ts has concurrent subprocess tests that time out at 5 s on a debug build. A different one fails on each run.
  • test/js/first_party/ws/ws.test.ts followed by node-http.test.ts in one debug-build process panics with assertion failed: !self.body_read_ref.get().has, with and without this change. node:http: release body_read_ref when a response upgrades to a WebSocket #43427 covers that bug. The ws case of this PR therefore lives in node-http-with-ws.test.ts.

Open PR #42614 touches emitListeningNextTick and passes the three arguments through a helper. Whichever lands second needs a small rebase.


[human-review] gate passed · iteration 1 · 8 files touched

fails on main (without fix)
ASAN without fix: 3 failed, 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/http/node-http-with-ws.test.ts test/js/node/http/node-http.test.ts test/js/third_party/body-parser/express-body-parser-test.test.ts test/js/third_party/express/express.test.ts
bun test v1.4.3 (367d939d9)

test/js/node/http/node-http.test.ts:
(pass) node:http > createServer > hello world [636.24ms]
(pass) node:http > createServer > is not marked encrypted (#5867) [76.46ms]
(pass) node:http > createServer > request & response body streaming (large) [148.35ms]
(pass) node:http > createServer > request & response body streaming (small) [91.52ms]
(pass) node:http > createServer > listen should return server [28.58ms]
(pass) node:http > createServer > listen callback should be bound to server [30.10ms]
(pass) node:http > createServer > emits 'listening' on the next tick, before the event loop polls [42.87ms]
186 |           onCallback();
187 |         });
188 |         await called;
189 |         server.close();
190 |         await once(server, "close");
191 |         expect(received).toEqual({ listener: [], callback: [] });
    
... (truncated)

release without fix: 1 skipped
bun test v1.4.3-canary.1 (60b52e9a2)

test/js/node/http/node-http.test.ts:
(pass) node:http > createServer > hello world [13.75ms]
(pass) node:http > createServer > is not marked encrypted (#5867) [3.85ms]
(pass) node:http > createServer > request & response body streaming (large) [5.97ms]
(pass) node:http > createServer > request & response body streaming (small) [2.66ms]
(pass) node:http > createServer > listen should return server [1.10ms]
(pass) node:http > createServer > listen callback should be bound to server [0.94ms]
(pass) node:http > createServer > emits 'listening' on the next tick, before the event loop polls [1.37ms]
(pass) node:http > createServer > http.Server > passes no arguments to the listen() callback or to 'listening' listeners [1.13ms]
(pass) node:http > createServer > https.Server > passes no arguments to the listen() callback or to 'listening' listeners [4.29ms]
(pass) node:http > createServer > emits a listen() error on the next tick, before the event loop polls [1.57ms]
(pass) node:http > createServer > calls the listen() callback after a retry from the EADDRINUSE 'error' handler [1.94ms]
(pass) node:http > createServer > http: closing a s
... (truncated)
passes on PR (with fix)
ASAN with fix: 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/http/node-http-with-ws.test.ts test/js/node/http/node-http.test.ts test/js/third_party/body-parser/express-body-parser-test.test.ts test/js/third_party/express/express.test.ts
bun test v1.4.3 (367d939d9)

test/js/node/http/node-http.test.ts:
(pass) node:http > createServer > hello world [416.22ms]
(pass) node:http > createServer > is not marked encrypted (#5867) [70.90ms]
(pass) node:http > createServer > request & response body streaming (large) [115.35ms]
(pass) node:http > createServer > request & response body streaming (small) [61.83ms]
(pass) node:http > createServer > listen should return server [10.54ms]
(pass) node:http > createServer > listen callback should be bound to server [35.89ms]
(pass) node:http > createServer > emits 'listening' on the next tick, before the event loop polls [37.68ms]
(pass) node:http > createServer > http.Server > passes no arguments to the listen() callback or to 'listening' listeners [25.75ms]
(pass) node:http > createServer > https.Server > passes no arguments to the listen() callback 
... (truncated)

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 833ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/209] gen JS modules (bundle-modules)
Preprocess modules (7561ms)
Bundle modules (81ms)
Postprocesss modules (26ms)
Bundle Functions (617ms)
Generate Code (33ms)

[8.33s] Bundled "src/js" for production
  2597 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[2/90] build.rs build_script_build
[3/90] rustc bun_platform 
[4/90] rustc bun_core 
[5/90] rustc bun_zlib_sys 
[6/90] rustc bun_safety 
[7/90] rustc bun_brotli 
[8/90] rustc bun_picohttp 
[9/90] rustc bun_output 
[10/90] rustc bun_base64 
[11/90] rustc bun_errno 
[12/90] rustc bun_cares_sys 
[13/90] rustc bun_zstd 
[14/90] rustc bun_boringssl_sys 
[15/90] rustc bun_ptr 
[16/90] rustc bun_tcc_sys 
[17/90] rustc bun_lsquic_sys 
[18/90] rustc bun_clap 
[19/90] rustc bun_valkey 
[20/90] rustc bun_paths 
[21/90] rustc bun_shell_parser 
warning: `feature(generic_const_exprs)` is not supported with the next-generation trait solver
 --> src/shell_parser/lib.rs:1:30
  |
1 | #![feature(adt_const_params, generic_const_exprs
... (truncated)
diff hotspot
src/js/node/_http_server.ts                        |  7 ++-
 ...d-not-cause-duplicated-character-or-segfault.ts |  6 +--
 .../http/fixtures/http.compress.leak.server.ts     | 10 ++--
 test/js/node/http/node-http-with-ws.test.ts        | 11 ++++
 test/js/node/http/node-http.test.ts                | 58 ++++++++++++++--------
 .../body-parser/express-body-parser-test.test.ts   | 13 ++---
 test/js/third_party/express/express.test.ts        | 13 ++---
 test/js/third_party/st/st.fixture.ts               | 10 +---
 8 files changed, 68 insertions(+), 60 deletions(-)

gate history · 1 passed · 1 rejected · iteration 1

evidence per changed file
file                                                      reads  edits  tests
src/js/node/_http_server.ts                                   2      1     36
…n1-should-not-cause-duplicated-character-or-segfault.ts      1      0     36
test/js/node/http/fixtures/http.compress.leak.server.ts       1      0     36
test/js/node/http/node-http-with-ws.test.ts                   0      0      7
test/js/node/http/node-http.test.ts                           2      4     21
…hird_party/body-parser/express-body-parser-test.test.ts      1      0     10
test/js/third_party/express/express.test.ts                   2      0     16
test/js/third_party/st/st.fixture.ts                          1      0     36

http.Server and https.Server passed (null, hostname, port) to 'listening'
listeners and to the listen() callback. Node's net.Server emits the event
with no arguments, and so do bun's net, tls, http2 and dgram servers.

Tests that read the port from the callback now use server.address().
@robobun

robobun commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: the fix is pushed and the PR is ready for review.

How I reproduced it:

const http = require("http");
const s = http.createServer(() => {});
s.on("listening", (...args) => console.log("listener", args));
s.listen(0, (...args) => {
  console.log("callback", args);
  s.close();
});
  • node v26.3.0 prints listener [] and callback [].
  • bun 1.4.2 and a debug build of main print [ null, "localhost", <port> ] for both.
  • A debug build of this branch prints the same output as node. The new cases in test/js/node/http/node-http.test.ts and test/js/node/http/node-http-with-ws.test.ts fail without the change and pass with it.

@coderabbitai

coderabbitai Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: bc37e83a-bc90-4911-9423-8e3db68293f7

📥 Commits

Reviewing files that changed from the base of the PR and between 60b52e9 and f756384.

📒 Files selected for processing (1)
  • test/js/node/http/node-http-with-ws.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.


Walkthrough

The HTTP server now emits the listening event without arguments. Tests use zero-argument listen callbacks, obtain bound ports from server.address(), and verify callback arguments for HTTP, HTTPS, and WebSocket servers.

Changes

Listening callback arguments

Layer / File(s) Summary
Listening event contract
src/js/node/_http_server.ts, test/js/node/http/node-http.test.ts, test/js/node/http/node-http-with-ws.test.ts
The server emits listening without arguments. HTTP, HTTPS, and WebSocket tests check that listening listeners and callbacks receive no arguments.
Test server address lookups
test/js/node/http/node-http.test.ts, test/js/bun/test/parallel/*, test/js/node/http/fixtures/*, test/js/third_party/body-parser/*, test/js/third_party/st/*, test/js/third_party/express/*
Test helpers and server callbacks use zero-argument callbacks and obtain the bound port from server.address(). The Express test also sets a longer POST timeout in debug or ASAN builds.

Merge Risk: ⚪ Minimal · up to f7563

This intentionally aligns listening callbacks with Node’s zero-argument behavior. Bun-specific code that relied on the former host or port arguments must use server.address() instead.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely states the main change: node:http now emits the listening event with no arguments, matching Node.js behavior.
Description check ✅ Passed The description explains the problem, fix, compatibility impact, affected APIs, and verification results. It covers both required template areas: what the PR does and how it was verified.

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked the dropped reject(err) branches in test/js/third_party/st/st.fixture.ts and test/js/node/http/fixtures/http.compress.leak.server.ts — on the base branch the callback's first argument was always null (bind failures go through emitListenErrorNextTick as an 'error' event and never reach the listening callback), so no real rejection path was removed. The hard-coded localhost in the shared listen() helper in node-http.test.ts matches what the old callback passed, since this[serverSymbol].hostname was already the requested host rather than the bound address.

Extended reasoning...

The change is a one-emit compat fix in src/js/node/_http_server.ts (drop the Bun-only (null, hostname, port) from 'listening') plus mechanical test migrations to server.address().port; it touches no security-sensitive surface. Inline findings already flag the silent behavior break and the dropped express fetch timeout, so a human decision on the breaking change is needed; this note only records the sibling concerns that were examined and ruled out.

Comment thread src/js/node/_http_server.ts
Comment thread test/js/third_party/express/express.test.ts

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The follow-up commit addresses the express hang-guard point from my earlier pass, and this run found no bugs in the code; since the PR deliberately removes a Bun-only callback signature with no deprecation path, a maintainer should still make the call on shipping that break (and the release-note line the description asks for).

What was reviewed:

  • The single emit change in src/js/node/_http_server.ts and its nextTick call site; grepped src/js for any remaining reader of the old (null, hostname, port) arguments — setupConnectionsTracking, the cluster listeners in internal/cluster/*.ts, and the ws shim's bound re-emit all take no arguments, so nothing in-tree depends on them.
  • Grepped test/ for leftover (err, hostname, port) listen callbacks outside test/js/node/test/ — none remain after the six updated files.
  • The new http/https/WebSocketServer assertions use exact toEqual({ listener: [], callback: [] }) and await close, so they cannot pass vacuously against the old emit.
Extended reasoning...

The production change is two lines in src/js/node/_http_server.ts that make the node:http Server emit 'listening' with no arguments; the other seven files are tests and fixtures rewritten to read the port from server.address() plus three new assertions covering http, https, and the ws shim. It touches no security-sensitive surface. The code is small and I found no correctness issue, and the third commit resolved the express AbortSignal.timeout concern by branching on isDebug/isASAN. It is not approved outright because the change is an intentional silent removal of a long-standing Bun extension to the listen() callback, which is a user-facing API decision (with no warning added) that a maintainer should weigh rather than an automated reviewer.

@robobun

robobun commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:07 AM PT - Sep 24th, 2026

✅ @robobun, your commit f75638446dbe8eaf3d5985fbbc3d7ae2ee4555b1 passed in Build #120333! 🎉


🧪   To try this PR locally:

bunx bun-pr 43891

That installs a local version of the PR into your bun-43891 executable, so you can run:

bun-43891 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the latest push and found no bugs; the express hang guard from the earlier round is now kept and widened per build type, so nothing is outstanding from my side. Because the one production edit is a deliberate break of a Bun-only listen() callback signature, a maintainer should still sign off on the compat decision and the release note.

What was reviewed:

  • The single emit change in src/js/node/_http_server.ts against net.ts and the ws shim: both already forward or emit 'listening' with no arguments, and the cluster/connections-tracking listeners take no parameters.
  • Checked the suite for remaining readers of the old (err, hostname, port) arguments outside the diff — none left; the localhost substitution matches the value the callback previously received for the default host.
  • The new http/https and WebSocketServer cases assert whole {listener, callback} objects with toEqual, so they fail on the base with the three old arguments.
Extended reasoning...

The change drops the (null, hostname, port) arguments from the node:http 'listening' emit in src/js/node/_http_server.ts and rewrites seven test files to read server.address().port, plus adds http, https and ws regression tests. No security-sensitive surface is touched. The production diff is two lines and matches Node and Bun's own net/dgram servers, and the earlier express hang-guard concern was addressed in commit 06fa641. It is not approved outright because it is a silent user-observable breaking change of a long-standing Bun extension, which is a product decision for a maintainer rather than a correctness question.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant