tls.Server: a listen() that fails while it loads addContext() entries leaves the listener bound and accepting
What happens
tls.Server#listen() creates the native listener first. Then it loads the addContext() entries into the listener (the addServerName loop at the end of kRealListen in src/js/node/net.ts). If one entry throws, listen() emits 'error', but nothing stops the listener. The server stays in a half-open state:
- There is no
'listening'event. server.listeningistrueandserver.address()returns the port.- The port completes TLS handshakes with the default certificate.
Repro
Run from the repo root. The fixtures are in test/js/node/tls/fixtures/.
import tls from "node:tls";
import fs from "node:fs";
import { once } from "node:events";
const dir = "test/js/node/tls/fixtures/";
const pem = n => ({ key: fs.readFileSync(dir + n + "-key.pem", "utf8"), cert: fs.readFileSync(dir + n + "-cert.pem", "utf8") });
const server = tls.createServer(pem("agent1"), s => s.end());
const name = "a.b.c.d.e.f.g.h.i.j.k.example"; // more than 10 labels
server.addContext(name, pem("agent2"));
server.addContext(name + ".", pem("agent3")); // same node in the native SNI tree
server.listen(0, "127.0.0.1");
console.log(await new Promise(resolve => {
server.once("listening", () => resolve("'listening' event"));
server.once("error", err => resolve("'error' event: " + err.message));
}));
console.log("server.listening:", server.listening, "port:", typeof server.address()?.port);
const c = tls.connect({ port: server.address().port, host: "127.0.0.1", rejectUnauthorized: false });
await once(c, "secureConnect");
console.log("handshake ok, CN =", c.getPeerCertificate().subject.CN);
process.exit(0);
bun 1.4.3-canary.1+c6b7fcb5b, Linux x64:
'error' event: Failed to register SNI for 'a.b.c.d.e.f.g.h.i.j.k.example.'
server.listening: true port: number
handshake ok, CN = agent1
node v26.3.0:
'listening' event
server.listening: true port: number
handshake ok, CN = agent1
Inputs that reach it
addContext("")beforelisten(). The native side throwshostname pattern cannot be emptyduringlisten(). Node throwsERR_TLS_REQUIRED_SERVER_NAMEfromaddContext()itself. #42050 adds that check. It removes this input, but not the half-open state.- Two names with more than 10 labels that land on one node of the native SNI tree, as in the repro.
sni_add(packages/bun-usockets/src/crypto/sni_tree.cpp) has no label limit.sni_removeandsni_findstop at 10 labels.Listener::add_server_name(src/runtime/socket/Listener.rs) removes, then adds. For such a name the remove does nothing, the add reports a duplicate, and the call throwsFailed to register SNI for '...'. A name with more than 10 labels also never matches a handshake, becausesni_findgives up.
Expected
A listen() that fails leaves the server closed: the listener is stopped, server._handle is null, server.listening is false, and only 'error' fires. The chmod failure path in kRealListen already stops the listener in this way.
The cluster worker path needs the same care. There kRealListen adopts the file descriptor that the primary sent. When the loop throws, the catch in listenInCluster closes that file descriptor through handle.close(), while the native listener that adopted it still exists. In a worker the same repro ends with server.listening === true and server.address() equal to {}.
Found during the review of #43080. That PR keeps addContext() entries made while the server listens, so such entries can now reach this loop on a later listen() too.
Source: oven-sh/bun