Repository navigation
Conversation
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.
|
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: 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. 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);
});
});
});
|
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).
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.
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 ofnetServergets no answer. Karma 6.4.4 listens this way. net, tls and http2 servers throwERR_INVALID_ARG_VALUE.listen()in_http_server.tsandnet.tshas no arm for a handle.Fix
listen()takes the_handleof a listening TCPnet.Server, as Node does (lib/net.js:2127-2136). Both servers share one listen socket: the last to listen gets the connections, and eitherclose()closes it.listenOnHandle).us_listen_socket_set_accept), which theBun.listenlistener still owns.test/js/node/net/node-net-listen-handle.test.ts(merge base fails 74 of 75, Node.js v26.3.0 passes).Background
_handleof anet.Serveris theBun.listenlistener. Anhttp.Serverruns onBun.serve, where uWS parses HTTP natively.SSL_CTXand ext size from the listen socket at each accept.Downsides
http.Server#listen({})(noport,path,fdortls) now throwsERR_INVALID_ARG_VALUE, as in Node. Before, it bound a random port.require("node:http"): +36,970 instructions (+0.10%).http.Serverthat bound its own port, atls.Serverhandle for non-http servers.Notes
Order
src/js/node/net.ts, andfeatures-bun.builtins.tsandoperand-changes.builtins.tsinsrc/codegen/builtins/host/. The cut on top resolves that.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.Serverandhttps.ServerOn the branch of #46528,
connectionListenerin_http_server.tsgives every socket that uWS did not accept to Node's connection listener (:340-356at 001958b). That makes a second shape possible after the merge:us_listen_socket_set_accept,uWS::ListenForward,NewServer::attach_to_listener,Listener::attach_http.listenOnHandleserves all six classes, and the http server gets each acceptednet.SocketthroughconnectionListenerInternal. 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)readsarg._handle || arg.handle || arg, as Node does. A value with anonconnectionfunction is a handle ofnode: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.listenOnHandlesetshandle[owner_symbol],handle.onconnectionandhandle.datato the new server. These are the hooks thatnode:netalready reads for each accepted socket.isListenHandlesays whether the value is a listener. node:http then names it in the options of its oneBun.servecall under a private name ($listenHandle), as node:dgram names its shared descriptor.NewServer::attach_to_listenerborrows the listen socket (ServerFlags::BORROWED_LISTENER) and binds nothing. The listener's socket group gets a vtable (uWS::ListenForward) whoseon_openmoves each accepted socket into the group of the uWS app (us_socket_adopt) and runsHttpContext::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()andunref()of an http server that has a handle act on the handle.Listener#getsocknamereturnsUV_EBADFfor a listener that is stopped, soaddress()throwsEBADFonce another server closed the handle, as in Node.ERR_INVALID_ARG_VALUEmessages oflisten()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.fdof a pipe is -1 on win32:listen()throwsERR_INVALID_ARG_VALUE.Not covered
http.Serverthat bound its own port has noBun.listenlistener.listen()with it throwsERR_INVALID_ARG_VALUE. Node shares its handle.tls.Serverdecrypts what it accepts.http.Serverandhttps.Servertake it.net.Server,tls.Serverand the http2 servers throwERR_INVALID_ARG_VALUE. Node gives the TCP handle to each of them.{ fd }forhttp.Serveris Bun.serve({ fd }) and node:http listen({ fd }): serve on an inherited socket #44013.listen()throws as in Node. WithSCHED_NONEthe new server serves. Itsclose()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.backloginlisten(handle, backlog)is read and not applied. Node callslisten(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_opencounts, base and PR interleaved.size)size_ofListener,NewServer,ServerConfighttp.listen(0), keep-alive, JIT offhttp.listen(0), new connection, JIT offhttp.listen(0), keep-alive, JIT onhttp.listen(0), new connection, JIT onBun.listen()+stop()Bun.serve()+stop()net.createServer().listen(0)+close()http.createServer().listen(0)+close()bun -e 0require("node:http")require("node:net")listen(options), httpinlisten(options), netsocket,bind,listen,epoll_ctl,setsockoptcalls ofhttp.listen(netServer)over thenet.Serveralone--aot, a round of 8 requests: calls into the run timehttp.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.http(+555). That path has three more property reads inlisten()and one lookup of a private name inBun.serve.loop.cis unchanged.bun -e 0, and by 10,314 forrequire("node:http"). They do not overlap forrequire("node:net").bun-linux-x64+4.0 KB,bun-linux-x64-musl+8.0 KB,bun-linux-aarch64andbun-linux-aarch64-musl0,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.Listener::attach_httpand one assignment inlisten(handle), which the other rows do not run.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 ofBun.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.isListenHandle,takeListenHandle), both with rows in the feature tables of--trim.test/bundler/compile-aot-size.test.tsin CI reports 25,961,444 mapped bytes for a--trimhello world on main (build 136408) and 25,969,637 at 82c302f of this PR (build 136849): +8,193 bytes. Without--trimit reports 68,121,572 and 68,125,669: +4,097 bytes.Self-review
16 concerns raised, 13 addressed. Open:
Bun.ModuleGraphcases aboveuWS::ListenForwardis a heap vtable in a slot that Rust types as&'static. A typed field onListenerwith a static vtable is the alternative (+16 bytes perListener). 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").test/js/node/visible-surface.test.tsrecords thelengthofServer.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.suppgetsleak:request_pool.NewServer::initmakes 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 plainhttp.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.tsunderbun build --compile --aot. It prints what Node prints. It found that the table of built-ins declaredundefinedfor the result ofListener#getsockname, so a compiledaddress()did not throw.node-http.test.ts,node-net-server.test.ts,node-net.test.ts(2 tests need DNS forwww.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, andbun run rust:mordant(3 targets, no finding over the baseline).test/js/node/test): the same 4 fail on the merge base and on this PR. They arenode:testfiles that needbun test.karma start, with$injectlines 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