#43082·bun

tls.Server: a listen() that fails while it loads addContext() entries leaves the listener bound and accepting

Author: robobunCreated Sep 17, 2026Updated Sep 17, 2026

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.listening is true and server.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

  1. addContext("") before listen(). The native side throws hostname pattern cannot be empty during listen(). Node throws ERR_TLS_REQUIRED_SERVER_NAME from addContext() itself. #42050 adds that check. It removes this input, but not the half-open state.
  2. 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_remove and sni_find stop 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 throws Failed to register SNI for '...'. A name with more than 10 labels also never matches a handshake, because sni_find gives 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.