Skip to content

node: server.listen(handle) takes the handle of a listening net.Server - #47725

Draft
robobun wants to merge 6 commits into
mainfrom
robobun/af6263b0/listen-on-server-handle
Draft

robobun wants to merge 6 commits into
mainfrom
robobun/af6263b0/listen-on-server-handle

Conversation

@robobun

@robobun robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Part of #13451

Draft until #46522 and #46528 merge (maintainer's rule), then cut again on top. Notes: one open question.

Problem

  • http.createServer().listen(netServer) ignores the handle: Bun binds a new port, and the port of netServer gets no answer. Karma 6.4.4 listens this way. net, tls and http2 servers throw ERR_INVALID_ARG_VALUE.
  • listen() in _http_server.ts and net.ts has no arm for a handle.

Fix

  • listen() takes the _handle of a listening TCP net.Server, as Node does (lib/net.js:2127-2136). Both servers share one listen socket: the last to listen gets the connections, and either close() closes it.
  • net, tls and http2 servers repoint the hooks of the handle in JS (listenOnHandle).
  • http and https: the uWS app writes its accept parameters to the listen socket (us_listen_socket_set_accept), which the Bun.listen listener still owns.
  • Verified: test/js/node/net/node-net-listen-handle.test.ts (merge base fails 74 of 75, Node.js v26.3.0 passes).

Background

  • The _handle of a net.Server is the Bun.listen listener. An http.Server runs on Bun.serve, where uWS parses HTTP natively.
  • usockets reads the socket kind, SSL_CTX and ext size from the listen socket at each accept.
  • Considered a JS arm that gives each socket to the JS HTTP parser: no native code, but slower parsing.

Downsides

  • http.Server#listen({}) (no port, path, fd or tls) now throws ERR_INVALID_ARG_VALUE, as in Node. Before, it bound a random port.
  • Size: +7,732 bytes of text (Linux x64), 0 to +64 KB per CI target. require("node:http"): +36,970 instructions (+0.10%).
  • Not covered (Notes): cluster workers, an http.Server that bound its own port, a tls.Server handle for non-http servers.
Notes

Order

Origin

No user reported this. It came out of the Karma work on #13451. Unmodified Karma also needs #47678.

Open question: which arm for http.Server and https.Server

On the branch of #46528, connectionListener in _http_server.ts gives every socket that uWS did not accept to Node's connection listener (:340-356 at 001958b). That makes a second shape possible after the merge:

  1. This PR. The uWS app takes the accept parameters of the listen socket. Requests stay on the native HTTP path. It costs about 450 lines of native code: us_listen_socket_set_accept, uWS::ListenForward, NewServer::attach_to_listener, Listener::attach_http.
  2. JS only. listenOnHandle serves all six classes, and the http server gets each accepted net.Socket through connectionListenerInternal. No native change. Every request of such a server goes through the JS HTTP parser. I did not build or measure this shape.

The tests of this PR describe what Node does, so they hold for both shapes. A maintainer decides. If the answer is shape 2, the native part leaves this PR.

How it works

  • listen(arg) reads arg._handle || arg.handle || arg, as Node does. A value with an onconnection function is a handle of node:net. takeListenHandle (native) says what it is: a TCP listener that listens, a closed one (listen EINVAL), a unix socket (listen EEXIST), or a TLS listener.
  • net, tls and http2: listenOnHandle sets handle[owner_symbol], handle.onconnection and handle.data to the new server. These are the hooks that node:net already reads for each accepted socket.
  • http and https: isListenHandle says whether the value is a listener. node:http then names it in the options of its one Bun.serve call under a private name ($listenHandle), as node:dgram names its shared descriptor. NewServer::attach_to_listener borrows the listen socket (ServerFlags::BORROWED_LISTENER) and binds nothing. The listener's socket group gets a vtable (uWS::ListenForward) whose on_open moves each accepted socket into the group of the uWS app (us_socket_adopt) and runs HttpContext::onOpen.
  • close() of an http server that has a handle stops the listener, so the handle closes for every server that has it. The listener tells the http server before the socket goes away for any other reason (on_listener_gone).
  • address(), ref() and unref() of an http server that has a handle act on the handle. Listener#getsockname returns UV_EBADF for a listener that is stopped, so address() throws EBADF once another server closed the handle, as in Node.
  • The ERR_INVALID_ARG_VALUE messages of listen() now show at most 128 characters of the options, as Node does. A server that is passed as options has its TLS key further in.
  • A server on a Windows named pipe gives no handle, as in Node, where the fd of a pipe is -1 on win32: listen() throws ERR_INVALID_ARG_VALUE.

Not covered

  • An http.Server that bound its own port has no Bun.listen listener. listen() with it throws ERR_INVALID_ARG_VALUE. Node shares its handle.
  • The handle of a tls.Server decrypts what it accepts. http.Server and https.Server take it. net.Server, tls.Server and the http2 servers throw ERR_INVALID_ARG_VALUE. Node gives the TCP handle to each of them.
  • { fd } for http.Server is Bun.serve({ fd }) and node:http listen({ fd }): serve on an inherited socket #44013.
  • A cluster worker. With round-robin scheduling the handle of the worker is no listener, and listen() throws as in Node. With SCHED_NONE the new server serves. Its close() closes the copy of the socket in the worker and does not tell the primary. The primary holds the port until the worker closes the first server or exits.
  • Bun.ModuleGraph. No rule is set for a handle that crosses a graph. dispose() of a graph closes the socket of the host if an http server of the graph has it. A net, tls or http2 server of the graph keeps the connections.
  • backlog in listen(handle, backlog) is read and not applied. Node calls listen(2) again with it.

Measurements

Release builds of the merge base (3f3a9ff) and of this PR, Linux x64. Instruction counts are user-mode perf_event_open counts, base and PR interleaved.

base this PR
text (size) 140,313,877 140,321,609 (+7,732)
size_of Listener, NewServer, ServerConfig 240, 1976, 1080 the same
per request, http.listen(0), keep-alive, JIT off 317,910 318,021
per request, http.listen(0), new connection, JIT off 562,264 563,794
per request, http.listen(0), keep-alive, JIT on 22,481 22,677
per request, http.listen(0), new connection, JIT on 41,796 41,721
per Bun.listen() + stop() 9,557 9,561
per Bun.serve() + stop() 73,905 73,949
per net.createServer().listen(0) + close() 100,149 100,132
per http.createServer().listen(0) + close() 193,234 193,789
bun -e 0 3,381,383 3,384,886
require("node:http") 36,487,901 36,524,871 (+36,970, +0.10%)
require("node:net") 23,170,428 23,211,977 (+41,549, +0.18%)
property reads of listen(options), http 5 8, and 1 in
property reads of listen(options), net 12 15
socket, bind, listen, epoll_ctl, setsockopt calls of http.listen(netServer) over the net.Server alone +4, +2, +1, +1, +3 0
a koa server built with --aot, a round of 8 requests: calls into the run time 87 87
the same: bytes that compiled code allocates 61,437.28 61,437.28
  • Per request: medians of 7 runs with the JIT off and of 5 runs with it on. The ranges of base and PR overlap in all four rows.
  • On the PR build, a request to http.listen(netServer) takes 22,435 (keep-alive) and 41,056 (new connection) instructions with the JIT on. http.listen(0) takes 22,525 and 41,953 in the same run.
  • Listen and close: medians of 7 runs with the JIT off. The ranges overlap except for http (+555). That path has three more property reads in listen() and one lookup of a private name in Bun.serve. loop.c is unchanged.
  • Startup: medians of 21 runs. The ranges of base and PR overlap for bun -e 0, and by 10,314 for require("node:http"). They do not overlap for require("node:net").
  • File size by target, from CI (build 136869, against main at 271b65e, which is the merge base and one docs commit): bun-linux-x64 +4.0 KB, bun-linux-x64-musl +8.0 KB, bun-linux-aarch64 and bun-linux-aarch64-musl 0, bun-windows-x64 +6.5 KB, bun-windows-aarch64 +19.0 KB, bun-darwin-x64 +16.0 KB, bun-darwin-aarch64 +32.3 KB, bun-freebsd-x64 +8.0 KB, bun-freebsd-aarch64 +12.0 KB, bun-linux-x64-android +12.0 KB, bun-linux-aarch64-android +64.0 KB.
  • The text and startup rows are of the head 5a64d4b. The other rows are of 660e1a7, one commit before it. That commit changes Listener::attach_http and one assignment in listen(handle), which the other rows do not run.
  • The koa rows are test/aot/servers/budgets-of-koa.test.ts. The node, express and fastify servers of that suite have the numbers of the base too, or lower ones. An earlier shape of this PR called a second entry in place of Bun.serve. The compiler then lost how the request handler is called, and the koa server made 110 calls into the run time in a round. AOT: front end, test runs and tests for the rewritten compiler #47942 removed that suite from main, so the koa rows hold for the merge base only.
  • +2 host functions (isListenHandle, takeListenHandle), both with rows in the feature tables of --trim. test/bundler/compile-aot-size.test.ts in CI reports 25,961,444 mapped bytes for a --trim hello world on main (build 136408) and 25,969,637 at 82c302f of this PR (build 136849): +8,193 bytes. Without --trim it reports 68,121,572 and 68,125,669: +4,097 bytes.

Self-review

16 concerns raised, 13 addressed. Open:

  • the cluster worker and Bun.ModuleGraph cases above
  • uWS::ListenForward is a heap vtable in a slot that Rust types as &'static. A typed field on Listener with a static vtable is the alternative (+16 bytes per Listener). It belongs to the decision about shape 1.

Tests

  • test/js/node/net/node-net-listen-handle.test.ts: 77 tests, 2 of them only on Windows. Debug build: 75 pass. Release build of the merge base: 74 fail. Node v26.3.0: 71 pass, and the 4 that it skips are the 2 Windows tests and 2 cases where Bun differs (listed under "Not covered").
  • CI ran the file at the head 5a64d4b (build 136869). It has 75 pass and 2 skip on macOS x64 and arm64, Debian x64 and arm64, the ASAN lane, Ubuntu x64 and arm64, and Alpine x64 and arm64. On Windows x64 and arm64 it has 74 pass, and the 3 POSIX cases are skipped.
  • The snapshot of test/js/node/visible-surface.test.ts records the length of Server.prototype[kRealListen], which is 6 now. A default value would keep it at 5, but it gives every closure of that function one more scope to walk: 228 more instructions for a keep-alive request with the JIT off.
  • test/leaksan.supp gets leak:request_pool. NewServer::init makes a pool of request contexts once a thread, and the end of a Worker does not free it. The file says so already for two other stacks. The new test starts a server from a callback in a Worker, and a plain http.listen(0) there reports the same leak.
  • test/aot/parallel/node-http/server-listens-on-the-handle-of-a-net-server.cjs: the second text of _http_server.ts under bun build --compile --aot. It prints what Node prints. It found that the table of built-ins declared undefined for the result of Listener#getsockname, so a compiled address() did not throw.
  • Also run: node-http.test.ts, node-net-server.test.ts, node-net.test.ts (2 tests need DNS for www.example.com, which this machine does not have), test/js/node/cluster, child_process_ipc_handle.test.ts, node-http-proxy-traps.test.ts, test/internal/second-texts-of-modules.test.ts, test/internal/builtins-table, test/internal/trim, test/internal/source-lints, bun run rust:test -p bun_aot (2987 tests), bun run rust:check-all (12 targets), bun run rust:clippy, and bun run rust:mordant (3 targets, no finding over the baseline).
  • 1033 of Node's own tests for net, http, https, tls, http2, cluster and child_process (test/js/node/test): the same 4 fail on the merge base and on this PR. They are node:test files that need bun test.
  • Karma 6.4.4, karma start, with $inject lines on two of its functions (the workaround for the parameter comments of transpiler: print a parameter list that holds a comment as it is written #47678): the page and the socket.io poll answer 200, and SIGINT ends it with exit code 0, as under Node. The merge base answers nothing.

no test proof · iteration 2 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/net/node-net-listen-handle.test.ts

http.Server#listen(handle) ignored the handle of a listening net.Server:
it bound a second socket on a random port, and requests to the port of
the net.Server got no answer. net.Server, tls.Server and the http2
servers threw ERR_INVALID_ARG_VALUE for the same argument.

As in Node.js, both servers now have the one handle. The server that
listened on it last gets its connections, and close() of either server
closes it.

- node:net re-points the owner of the handle, for net, tls and http2
  servers.
- node:http gives the listen socket of the net.Server the accept
  parameters of its uWS app (us_listen_socket_set_accept). The group of
  the listener gets a vtable whose on_open moves each accepted socket to
  the app, so requests stay on the native HTTP path and no second socket
  is bound.
- address(), ref() and unref() of an http.Server that has a handle act
  on the handle, whichever server has its connections.
- Listener#getsockname returns UV_EBADF for a listener that is stopped,
  as the handle of Node.js does. So address() throws EBADF once another
  server closed the handle.
- An object that names no handle, port or path throws
  ERR_INVALID_ARG_VALUE in http.Server#listen, as in Node.js. It bound a
  random port before.
@github-actions github-actions Bot added the claude label Nov 2, 2026
@robobun

robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:21 AM PT - Nov 2nd, 2026

❌ @robobun, your commit 5a64d4b has 40 failures in Build #136869 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47725

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

bun-47725 --bun

@robobun

robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: draft. It waits for #46522 and #46528, and then I cut it again on top (see "Order" in the Notes of the description).

Checked on 2026-11-11: #46522 and #46528 are open. #46522 merged main today (head b4b68fe) and has no conflict with it. #46528 has its head of 2026-11-02 and conflicts with main. Main moved 28 commits since the merge base. This branch now conflicts with main in three files, one hunk each: src/js/node/net.ts, and features-bun.builtins.ts and operand-changes.builtins.ts in src/codegen/builtins/host/. The cut on top resolves that. Main (89452d2) and the two PRs still have no handle arm in listen().

CI at 5a64d4b: the GitHub checks pass. Buildkite build 136869 is complete, and no red test comes from this diff:

No test that this PR adds or changes failed or needed a retry. test/js/node/net/node-net-listen-handle.test.ts passes on the 11 test platforms of that build: Linux with glibc and with musl, the ASAN lane, macOS and Windows, x64 and arm64.

How to reproduce

const net = require("node:net");
const http = require("node:http");

const bound = net.createServer();
bound.listen(0, "127.0.0.1", () => {
  const { port } = bound.address();
  const server = http.createServer((req, res) => res.end("ok from http"));
  server.listen(bound, () => {
    console.log("same port:", server.address().port === port, server.address().address);
    const request = http.get({ host: "127.0.0.1", port, agent: false, timeout: 2000 }, res => {
      res.setEncoding("utf8");
      res.on("data", body => console.log(res.statusCode, body));
      res.on("end", () => server.close());
    });
    request.on("timeout", () => {
      console.log("no answer on the port of the net.Server");
      process.exit(1);
    });
  });
});
prints exit code
Node v26.3.0 same port: true 127.0.0.1, 200 ok from http 0
Bun 1.4.3, and main at 3f3a9ff same port: false ::, no answer on the port of the net.Server 1
this PR same port: true 127.0.0.1, 200 ok from http 0

The handle parameter gets a default value, so the `length` of the
function stays what a program can read today
(test/js/node/visible-surface.test.ts).
Comment thread src/js/internal/shared.ts Resolved
Comment thread src/js/internal/shared.ts Resolved
Comment thread src/js/node/_http_server.ts Resolved
Comment thread src/js/node/_http_server.ts Resolved
Comment thread src/js/node/_http_server.ts Resolved
Comment thread src/js/node/_http_server.ts Resolved
Comment thread src/js/node/net.ts Resolved
Comment thread src/js/node/net.ts Resolved
Comment thread src/runtime/api/BunObject.rs Resolved
Comment thread src/runtime/server/mod.rs Resolved
Comment thread src/runtime/server/mod.rs Resolved
Comment thread src/runtime/server/mod.rs Resolved
Comment thread src/runtime/server/mod.rs Resolved
Comment thread src/runtime/server/mod.rs Resolved
Comment thread src/runtime/socket/Listener.bind.ts Resolved
Comment thread src/runtime/socket/Listener.rs Resolved
Comment thread src/runtime/socket/Listener.rs Resolved
Comment thread src/runtime/socket/Listener.rs Resolved
Comment thread src/runtime/socket/Listener.rs Resolved
Comment thread src/runtime/socket/Listener.rs Resolved
Comment thread src/runtime/socket/Listener.rs Resolved
Comment thread src/runtime/socket/Listener.rs Resolved
Comment thread src/runtime/socket/Listener.rs Resolved
Comment thread src/runtime/socket/Listener.rs Resolved
Comment thread src/uws_sys/App.rs Resolved
Comment thread src/uws_sys/ListenSocket.rs Resolved
Comment thread src/uws_sys/SocketGroup.rs Resolved
Comment thread src/uws_sys/SocketGroup.rs Resolved
Comment thread src/uws_sys/libuwsockets.cpp Resolved
No code changes. The hash of the first text in the second text of
_http_server.ts follows.
The handle reached Bun.serve through a second entry that node:http called
in place of Bun.serve. A program compiled with --aot then no longer saw
how the request handler is called: a koa server made 23 more calls into
the run time in a round of its requests and allocated 144 more bytes
(test/aot/servers/budgets-of-koa.test.ts).

node:http now makes its one direct Bun.serve call again and names the
handle under a private name, as node:dgram names its shared descriptor.
Listener.bind.ts gets isListenHandle, and serveOnListenHandle is gone.
The numbers of that test are those of main again.

test/leaksan.supp: the pool of request contexts that NewServer::init
makes once a thread is not freed at the end of a Worker. The new test
starts a server from a callback in a Worker, which no line covered.
…Listen

- The default value made the parameter list of kRealListen non-simple.
  `tls` is captured by the request handler, so JavaScriptCore then gave
  every closure of kRealListen one more scope to walk. With the JIT off,
  a keep-alive request ran 228 more instructions. The snapshot of
  test/js/node/visible-surface.test.ts records the length of the
  function, which is 6 now.
- The second text passed an object with an optional `$listenHandle` to
  $putByIdDirectPrivate, which takes a required one. The compiler then
  read _http_server.ts without its types
  (compile::tests::every_module_of_bun_keeps_its_types).
…tener::attach_http is not generic

`ServerConfig::set_borrowed_listener_address` writes `address` for a node:http
server that serves on a handle. `attach_to_listener` assigned the field from
outside the type, past the check that `ServerConfig::from_js` makes on it.

`Listener::attach_http` takes the attach step as `&mut dyn FnMut`. As a generic
function it was compiled once for each of the four server types, and half of
its body did not use the parameter.

`bun run rust:mordant` reported both over its baseline.

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