diff --git a/test/js/bun/net/tcp-server.test.ts b/test/js/bun/net/tcp-server.test.ts index f82d0d3dbcce..f2e99e4847d9 100644 --- a/test/js/bun/net/tcp-server.test.ts +++ b/test/js/bun/net/tcp-server.test.ts @@ -312,3 +312,28 @@ it("should not leak memory", async () => { expect(stdout).toBe(""); expect(exitCode).toBe(0); }); + +// stop(true) from alpnCallback or serverName closes the socket whose SSL call +// is on the stack, so that close waits until the callback returns. stop(true) +// once spun on that socket and never returned. The fixture runs in a child +// process, because such a spin also blocks the event loop that times a test +// out. The cases are not concurrent: the runner kills a child that a timed-out +// test leaves behind only when that test runs alone. +describe("TLS listener: stop(true) from inside a selection callback", () => { + it.each(["alpnCallback", "serverName"])("%s: stop(true) returns and the connection closes", async hook => { + await using proc = Bun.spawn({ + cmd: [bunExe(), join(import.meta.dir, "tls-stop-from-selection-fixture.ts"), hook], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + events: ["returned", "handshake:false", "close"], + clientConnected: false, + serverClosedFirst: true, + }); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/bun/net/tls-stop-from-selection-fixture.ts b/test/js/bun/net/tls-stop-from-selection-fixture.ts new file mode 100644 index 000000000000..2ac36cb3e41d --- /dev/null +++ b/test/js/bun/net/tls-stop-from-selection-fixture.ts @@ -0,0 +1,63 @@ +// listener.stop(true) called from alpnCallback or serverName. +// +// Both hooks run inside the SSL read that processes the ClientHello, so the +// accepted socket's SSL call is still on the stack when stop(true) closes every +// connection. That close waits until the SSL call returns, and the socket stays +// in the group until then. stop(true) must return without it, and the socket +// must close when the callback returns. +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { connect } from "node:tls"; + +const hook = process.argv[2]; +const fixtures = join(import.meta.dir, "../../node/tls/fixtures"); +const events: string[] = []; +const serverClosed = Promise.withResolvers(); + +const server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls: { + key: readFileSync(join(fixtures, "agent1-key.pem")), + cert: readFileSync(join(fixtures, "agent1-cert.pem")), + }, + socket: { + [hook]() { + server.stop(true); + events.push("returned"); + return hook === "alpnCallback" ? "x/1" : undefined; + }, + handshake(_socket, success) { + events.push(`handshake:${success}`); + }, + data() {}, + close() { + events.push("close"); + serverClosed.resolve(); + }, + }, +}); + +// The handshake never completes, so the client does not verify the certificate. +const client = connect({ + port: server.port, + host: "127.0.0.1", + servername: "localhost", + ALPNProtocols: ["x/1"], + rejectUnauthorized: false, +}); +const clientConnected = await new Promise(resolve => { + client.on("secureConnect", () => resolve(true)); + client.on("error", () => resolve(false)); + client.on("close", () => resolve(false)); +}); +// stop(true) has to close the server side by itself. Its close handler runs +// when the callback returns, before the client can see any outcome, so a close +// that only the cleanup below causes does not count. +const serverClosedFirst = events.includes("close"); +// If the server left the connection open, this closes it, so the events are +// printed and the test fails on them and not on a timeout. +client.destroy(); +await serverClosed.promise; + +console.log(JSON.stringify({ events, clientConnected, serverClosedFirst }));