From d20041abf4968e468712d3704144fb2eb27df1f0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:02:32 +0000 Subject: [PATCH 1/9] usockets: bound the accepts of one listener readiness event The accept loop ran the handlers of each accepted socket inline: on_open, and with TCP_DEFER_ACCEPT also the first read, the request handler and the response. A client that was answered could be back in the queue before the next accept(), so the loop did not end while such clients kept coming. Timers, other sockets and the free of closed sockets waited for it. One readiness event now accepts at most 32 connections. The poll of the listener is level-triggered and reports what is still queued on the next iteration. --- packages/bun-usockets/src/loop.c | 9 +- .../bun/net/accept-queue-clients-fixture.ts | 50 ++++ test/js/bun/net/tcp-server.test.ts | 218 +++++++++++++++++- test/js/bun/net/tls-parked-timeout-fixture.ts | 77 +++++++ 4 files changed, 351 insertions(+), 3 deletions(-) create mode 100644 test/js/bun/net/accept-queue-clients-fixture.ts create mode 100644 test/js/bun/net/tls-parked-timeout-fixture.ts diff --git a/packages/bun-usockets/src/loop.c b/packages/bun-usockets/src/loop.c index 2e254029f256..8eaeaf5b42b3 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -334,6 +334,12 @@ void us_internal_timer_sweep(struct us_loop_t *loop) { * easier on CPU */ static const int MAX_LOW_PRIO_SOCKETS_PER_LOOP_ITERATION = 5; +/* The handlers of an accepted socket run inside the accept loop, so a client they answer can be + * back in the queue before the next accept(). Without a bound the loop does not end while such + * clients keep coming, and nothing else on the event loop runs. The poll of the listener is + * level-triggered: it reports what is still queued on the next iteration. */ +static const int MAX_ACCEPTS_PER_READINESS_EVENT = 32; + void us_internal_handle_low_priority_sockets(struct us_loop_t *loop) { struct us_internal_loop_data_t *loop_data = &loop->data; struct us_socket_t *s; @@ -519,6 +525,7 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in /* Todo: stop timer if any */ + int accepted = 0; do { struct us_poll_t *accepted_p = us_create_poll(loop, 0, sizeof(struct us_socket_t) - sizeof(struct us_poll_t) + listen_socket->socket_ext_size); us_poll_init(accepted_p, client_fd, POLL_TYPE_SOCKET); @@ -578,7 +585,7 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in break; } - } while ((client_fd = bsd_accept_socket(us_poll_fd(p), &addr)) != LIBUS_SOCKET_ERROR); + } while (++accepted < MAX_ACCEPTS_PER_READINESS_EVENT && (client_fd = bsd_accept_socket(us_poll_fd(p), &addr)) != LIBUS_SOCKET_ERROR); } } break; diff --git a/test/js/bun/net/accept-queue-clients-fixture.ts b/test/js/bun/net/accept-queue-clients-fixture.ts new file mode 100644 index 000000000000..ac7a2847ab07 --- /dev/null +++ b/test/js/bun/net/accept-queue-clients-fixture.ts @@ -0,0 +1,50 @@ +// Client half of "one readiness event of a listener does not empty a long queue" in +// tcp-server.test.ts. It runs in a Worker, so that it can connect while the thread of the +// listener is blocked: the connections then all wait in the listener's queue. +import { connect, type Socket } from "bun"; + +declare var self: Worker; + +let sockets: Socket[] = []; + +self.onmessage = async ({ data }) => { + if (data === "close") { + for (const socket of sockets) socket.terminate(); + sockets = []; + self.postMessage("closed"); + return; + } + + const { target, count, first, signal } = data; + const view = new Int32Array(signal); + try { + sockets = await Promise.all( + Array.from({ length: count }, () => { + const { promise, resolve, reject } = Promise.withResolvers(); + connect({ + ...target, + socket: { + open(socket) { + // A listener with deferred accept queues the connection when these bytes arrive. + if (first && socket.write(first) !== first.length) reject(new Error("short write")); + else resolve(socket); + }, + connectError(socket, error) { + reject(error); + }, + data() {}, + error() {}, + }, + }).catch(reject); + return promise; + }), + ); + Atomics.store(view, 0, 1); + } catch (error) { + console.error(error); + Atomics.store(view, 0, 2); + } + Atomics.notify(view, 0); +}; + +self.postMessage("ready"); diff --git a/test/js/bun/net/tcp-server.test.ts b/test/js/bun/net/tcp-server.test.ts index f82d0d3dbcce..ef5b4150d8e9 100644 --- a/test/js/bun/net/tcp-server.test.ts +++ b/test/js/bun/net/tcp-server.test.ts @@ -1,7 +1,13 @@ import { connect, listen, SocketHandler, TCPSocketListener } from "bun"; -import { describe, expect, it } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { getEventLoopStats } from "bun:internal-for-testing"; +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { bunEnv, bunExe, isWindows, tempDir, tls as cert } from "harness"; +import { once } from "node:events"; +import http from "node:http"; +import https from "node:https"; +import net from "node:net"; import { join } from "node:path"; +import tls from "node:tls"; type Resolve = (value?: unknown) => void; type Reject = (reason?: any) => void; @@ -312,3 +318,211 @@ it("should not leak memory", async () => { expect(stdout).toBe(""); expect(exitCode).toBe(0); }); + +it("a TLS socket that waits in the low-priority queue times out", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), join(import.meta.dir, "tls-parked-timeout-fixture.ts")], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: JSON.stringify({ opened: 20, timedOut: 20 }), + stderr: "", + exitCode: 0, + }); +}, 30_000); // The fixture blocks for the 4 s between two timeout sweeps. + +describe("one readiness event of a listener does not empty a long queue", () => { + // More than MAX_ACCEPTS_PER_READINESS_EVENT in packages/bun-usockets/src/loop.c. + const count = 100; + const request = "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + // The first byte of a TLS handshake record. The server waits for the rest of the record. + const handshakeStart = "\x16"; + + type Arrived = (label?: string) => void; + type Listener = { target: object; first?: string; stop: () => unknown }; + + let clients: Worker; + let pending: PromiseWithResolvers; + // The next message of the Worker. Call it before the loop runs again. + const reply = () => (pending = Promise.withResolvers()).promise; + beforeAll(async () => { + clients = new Worker(join(import.meta.dir, "accept-queue-clients-fixture.ts")); + clients.onmessage = event => pending.resolve(event.data); + clients.onerror = event => pending.reject(event); + expect(await reply()).toBe("ready"); + }); + afterAll(() => clients.terminate()); + + // No JS runs when an HTTP listener with TLS accepts, and its handshakes have a budget per + // iteration of their own. What an accept changes for JS to see is the number of polls of the + // loop, read once per iteration. The clients never finish their handshake. + function watchPolls(arrived: Arrived) { + let last = getEventLoopStats().numPolls; + let seen = 0; + (function sample() { + for (const { numPolls } = getEventLoopStats(); last < numPolls; last++, seen++) arrived(); + if (seen < count) setImmediate(sample); + })(); + } + + async function nodeServer(server: net.Server, unix: string | undefined, first?: string): Promise { + if (unix) server.listen(unix); + else server.listen(0, "127.0.0.1"); + await once(server, "listening"); + return { + target: unix ? { unix } : { hostname: "127.0.0.1", port: (server.address() as net.AddressInfo).port }, + first, + stop() { + server.close(); + (server as http.Server).closeAllConnections?.(); + }, + }; + } + + function netServer(arrived: Arrived, unix?: string, options: net.ServerOpts = {}) { + const sockets: net.Socket[] = []; + const server = net.createServer(options, socket => { + socket.on("error", () => {}); + sockets.push(socket); + arrived(socket.isPaused() ? "paused" : "c"); + }); + server.on("drop", () => arrived("d")); + server.on("close", () => sockets.forEach(socket => socket.destroy())); + return { server, listening: nodeServer(server, unix) }; + } + + const address = (server: { port?: number }, unix?: string) => + unix ? { unix } : { hostname: "127.0.0.1", port: server.port }; + + function serve(arrived: Arrived, unix?: string, secure = false): Listener { + const server = Bun.serve({ + ...(unix ? { unix } : { port: 0, hostname: "127.0.0.1" }), + tls: secure ? cert : undefined, + fetch() { + arrived(); + return new Response("hello"); + }, + }); + return { target: address(server, unix), first: secure ? handshakeStart : request, stop: () => server.stop(true) }; + } + + function bunListen(arrived: Arrived, unix?: string, secure = false): Listener { + const server = Bun.listen({ + ...(unix ? { unix } : { port: 0, hostname: "127.0.0.1" }), + tls: secure ? cert : undefined, + socket: { + // With TLS this is still the accept: `handshake` is what waits for the peer. + open: () => arrived(), + handshake() {}, + data() {}, + error() {}, + }, + }); + return { target: address(server, unix), stop: () => server.stop(true) }; + } + + const kinds: Record Listener | Promise> = { + "Bun.serve": arrived => serve(arrived), + "Bun.serve unix": (arrived, unix) => serve(arrived, unix), + "Bun.serve tls": arrived => serve(arrived, undefined, true), + "Bun.listen": arrived => bunListen(arrived), + "Bun.listen unix": (arrived, unix) => bunListen(arrived, unix), + "Bun.listen tls": arrived => bunListen(arrived, undefined, true), + "node:http": arrived => + nodeServer( + http.createServer((req, res) => (arrived(), res.end("hello"))), + undefined, + request, + ), + "node:http unix": (arrived, unix) => + nodeServer( + http.createServer((req, res) => (arrived(), res.end("hello"))), + unix, + request, + ), + "node:https": arrived => + nodeServer( + https.createServer(cert, (req, res) => (arrived(), res.end("hello"))).on("tlsClientError", () => {}), + undefined, + handshakeStart, + ), + "node:net": arrived => netServer(arrived).listening, + "node:net unix": (arrived, unix) => netServer(arrived, unix).listening, + "node:net pauseOnConnect": arrived => netServer(arrived, undefined, { pauseOnConnect: true }).listening, + // The first client holds the one connection the server allows: 'drop' for each of the rest. + "node:net maxConnections": arrived => { + const { server, listening } = netServer(arrived); + server.maxConnections = 1; + return listening; + }, + // 'connection' is the accept of a tls.Server too. 'secureConnection' is the handshake. + "node:tls": arrived => + nodeServer( + tls + .createServer(cert) + .on("tlsClientError", () => {}) + .on("connection", socket => (socket.on("error", () => {}), arrived())), + undefined, + handshakeStart, + ), + }; + + // Starts a listener and puts `count` connections into its queue. Nothing accepts them before + // this returns: the thread of the listener is blocked while the Worker connects them. + async function queued(start: (typeof kinds)[string], arrived: Arrived) { + // Short names: the path of a unix socket has about 100 bytes. + const dir = tempDir("accept", {}); + const listener = await start(arrived, join(String(dir), "s.sock")); + const signal = new Int32Array(new SharedArrayBuffer(4)); + clients.postMessage({ target: listener.target, count, first: listener.first ?? "", signal: signal.buffer }); + Atomics.wait(signal, 0, 0, 30_000); + return { + connected: Atomics.load(signal, 0) === 1, + async [Symbol.asyncDispose]() { + listener.stop(); + clients.postMessage("close"); + await reply(); + dir[Symbol.dispose](); + }, + }; + } + + // The handlers of these two run after a handshake, which the clients do not finish. + const acceptsWithoutJS = ["Bun.serve tls", "node:https"]; + // On Windows a path is a named pipe, which has an accept of its own, and numPolls is the + // count of active libuv handles, not of the polls of the loop. + const skipped = (kind: string) => isWindows && (kind.endsWith("unix") || acceptsWithoutJS.includes(kind)); + + it.each(Object.keys(kinds).filter(kind => !skipped(kind)))("%s", async kind => { + const order: string[] = []; + const { promise: done, resolve } = Promise.withResolvers(); + let accepted = 0; + const arrived: Arrived = (label = "c") => { + const id = ++accepted; + order.push(label + id); + setImmediate(() => { + order.push("i" + id); + if (id === count) resolve(); + }); + }; + const watched = acceptsWithoutJS.includes(kind); + + await using listener = await queued(kinds[kind], watched ? () => {} : arrived); + expect(listener.connected).toBe(true); + if (watched) watchPolls(arrived); + await done; + + const label = (id: number) => { + if (kind === "node:net pauseOnConnect") return "paused" + id; + return (kind === "node:net maxConnections" && id > 1 ? "d" : "c") + id; + }; + expect(order.filter(entry => !entry.startsWith("i"))).toEqual( + Array.from({ length: count }, (_, i) => label(i + 1)), + ); + // The loop ran the immediate of the first connection before the listener took the last one. + expect(order.indexOf("i1")).toBeLessThan(order.indexOf(label(count))); + }); +}); diff --git a/test/js/bun/net/tls-parked-timeout-fixture.ts b/test/js/bun/net/tls-parked-timeout-fixture.ts new file mode 100644 index 000000000000..bfdafe26b894 --- /dev/null +++ b/test/js/bun/net/tls-parked-timeout-fixture.ts @@ -0,0 +1,77 @@ +// A TLS socket that waits in the low-priority queue times out like any other socket. +// +// uSockets runs 5 TLS handshakes per loop iteration. It parks the other sockets that became +// readable in the low-priority queue, where they are in no group list, and the timeout sweep +// visits them there. This fixture parks sockets in the iteration of a sweep: +// +// 1. N raw clients connect. The server pauses each socket in `open`. Each client then writes +// the first byte of a handshake record, which stays unread in the kernel. +// 2. In one turn the server gives each socket a timeout that ends at the next sweep and +// resumes it. Then it blocks for longer than the time between two sweeps. +// 3. The next loop iteration has N readable sockets. 5 of them run, N - 5 are parked, and the +// sweep is due at the end of that same iteration. +// +// Prints { opened, timedOut }. timedOut counts the sockets whose `timeout` handler ran. +import type { Socket } from "bun"; +import { tls } from "harness"; +import net from "node:net"; + +const N = 20; +// LIBUS_TIMEOUT_GRANULARITY in packages/bun-usockets/src/libusockets.h, in milliseconds. +const SWEEP_INTERVAL_MS = 4000; + +const opened: Socket[] = []; +let timedOut = 0; +const allOpened = Promise.withResolvers(); +const allTimedOut = Promise.withResolvers(); + +const server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls, + socket: { + open(socket) { + socket.pause(); + if (opened.push(socket) === N) allOpened.resolve(); + }, + timeout(socket) { + socket.terminate(); + if (++timedOut === N) allTimedOut.resolve(); + }, + handshake() {}, + data() {}, + error() {}, + }, +}); + +const clients: net.Socket[] = []; +const written: Promise[] = []; +for (let i = 0; i < N; i++) { + const { promise, resolve, reject } = Promise.withResolvers(); + const client = net.connect(server.port, "127.0.0.1", () => client.write("\x16", () => resolve())); + client.on("error", reject); + clients.push(client); + written.push(promise); +} +await Promise.all([allOpened.promise, ...written]); +for (const client of clients) client.removeAllListeners("error").on("error", () => {}); + +// An immediate runs between two iterations. From a socket handler, the sweep at the end of the +// same iteration would run before any socket is readable again. +await new Promise(resolve => setImmediate(resolve)); +for (const socket of opened) { + socket.timeout(1); + socket.resume(); +} +Bun.sleepSync(SWEEP_INTERVAL_MS + 100); + +// No event tells that a sweep passed a socket by, so this waits for the sweep after the one that +// is due now. It only runs to its end when a socket did not time out. +const nextSweep = setTimeout(() => allTimedOut.resolve(), 2 * SWEEP_INTERVAL_MS + 1000); +await allTimedOut.promise; +clearTimeout(nextSweep); + +console.log(JSON.stringify({ opened: opened.length, timedOut })); +for (const client of clients) client.destroy(); +server.stop(true); +process.exit(0); From be24e8c42ca1d6652bad1fc84a159d0c5c22e6f7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:02:32 +0000 Subject: [PATCH 2/9] bench: add a server snippet for connection-per-request load No benchmark in bench/ opens a connection per request. This snippet serves Bun.serve, Bun.listen, node:http or node:net for a client such as ab without -k, and prints the CPU time of the server per request. --- .../snippets/http-connection-per-request.mjs | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 bench/snippets/http-connection-per-request.mjs diff --git a/bench/snippets/http-connection-per-request.mjs b/bench/snippets/http-connection-per-request.mjs new file mode 100644 index 000000000000..114bf5a9cfeb --- /dev/null +++ b/bench/snippets/http-connection-per-request.mjs @@ -0,0 +1,75 @@ +// A server for connection-per-request load: each client opens a connection, sends one +// request and closes it. `ab` does this when it runs without -k. +// +// bun http-connection-per-request.mjs [Bun.serve | Bun.listen | node:http | node:net] +// node http-connection-per-request.mjs [node:http | node:net] +// ab -n 100000 -c 64 http://127.0.0.1:3000/ +// +// Each second with requests it prints the count and the CPU time of the server per request. +import http from "node:http"; +import net from "node:net"; + +const kind = process.argv[2] ?? (typeof Bun === "undefined" ? "node:http" : "Bun.serve"); +const port = Number(process.env.PORT ?? 3000); +const body = "Hello, World!"; +const response = `HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n${body}`; + +let requests = 0; + +switch (kind) { + case "Bun.serve": + Bun.serve({ + port, + fetch() { + requests++; + return new Response(body); + }, + }); + break; + case "Bun.listen": + Bun.listen({ + hostname: "0.0.0.0", + port, + socket: { + data(socket) { + requests++; + socket.end(response); + }, + }, + }); + break; + case "node:http": + http + .createServer((req, res) => { + requests++; + res.end(body); + }) + .listen(port); + break; + case "node:net": + net + .createServer(socket => { + socket.on("error", () => {}); + socket.on("data", () => { + requests++; + socket.end(response); + }); + }) + .listen(port); + break; + default: + throw new Error(`unknown server "${kind}"`); +} + +console.log(`${kind} on port ${port}`); + +let last = { requests, cpu: process.cpuUsage() }; +setInterval(() => { + const cpu = process.cpuUsage(); + const count = requests - last.requests; + if (count) { + const micros = cpu.user - last.cpu.user + (cpu.system - last.cpu.system); + console.log(`${count} requests, ${(micros / count).toFixed(1)} us of CPU per request`); + } + last = { requests, cpu }; +}, 1000); From 2143b8a12d10cfa59d15b5b8b565445cdce7dd3d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:40:02 +0000 Subject: [PATCH 3/9] [autofix.ci] apply automated fixes --- test/js/bun/net/tcp-server.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/bun/net/tcp-server.test.ts b/test/js/bun/net/tcp-server.test.ts index ef5b4150d8e9..5803981d9057 100644 --- a/test/js/bun/net/tcp-server.test.ts +++ b/test/js/bun/net/tcp-server.test.ts @@ -1,7 +1,7 @@ import { connect, listen, SocketHandler, TCPSocketListener } from "bun"; import { getEventLoopStats } from "bun:internal-for-testing"; import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, isWindows, tempDir, tls as cert } from "harness"; +import { bunEnv, bunExe, tls as cert, isWindows, tempDir } from "harness"; import { once } from "node:events"; import http from "node:http"; import https from "node:https"; From 08a69693639f08d12dc1b871c0129a2eeec36888 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:03:32 +0000 Subject: [PATCH 4/9] bench: answer in the raw servers when the request head is complete A request head can arrive in more than one chunk. Bun.listen and node:net counted and answered on the first chunk. They now wait for the blank line, as the HTTP servers do. --- .../snippets/http-connection-per-request.mjs | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/bench/snippets/http-connection-per-request.mjs b/bench/snippets/http-connection-per-request.mjs index 114bf5a9cfeb..fc4d90367cad 100644 --- a/bench/snippets/http-connection-per-request.mjs +++ b/bench/snippets/http-connection-per-request.mjs @@ -16,6 +16,13 @@ const response = `HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: let requests = 0; +// The head of a request can arrive in more than one chunk. The raw servers answer when it is +// complete, as the HTTP servers do. `head` is null after the answer. +function complete(head, chunk) { + head += chunk.toString("latin1"); + return head.includes("\r\n\r\n") ? null : head; +} + switch (kind) { case "Bun.serve": Bun.serve({ @@ -31,7 +38,13 @@ switch (kind) { hostname: "0.0.0.0", port, socket: { - data(socket) { + open(socket) { + socket.data = ""; + }, + data(socket, chunk) { + if (socket.data === null) return; + socket.data = complete(socket.data, chunk); + if (socket.data !== null) return; requests++; socket.end(response); }, @@ -49,8 +62,12 @@ switch (kind) { case "node:net": net .createServer(socket => { + let head = ""; socket.on("error", () => {}); - socket.on("data", () => { + socket.on("data", chunk => { + if (head === null) return; + head = complete(head, chunk); + if (head !== null) return; requests++; socket.end(response); }); From e57577bb32fb94d81eff9373114f00b0bc2304e3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:24:38 +0000 Subject: [PATCH 5/9] test: drop the low-priority queue test, bound the poll sampler At a bound of 32 the burst of the existing test still parks TLS sockets in the low-priority queue, so that test keeps its coverage and this PR does not need a second one that blocks for 4 s. The sampler of the two TLS kinds stops after 10 samples per expected accept and rejects, so a run that lost a client fails at once. --- test/js/bun/net/tcp-server.test.ts | 27 ++----- test/js/bun/net/tls-parked-timeout-fixture.ts | 77 ------------------- 2 files changed, 8 insertions(+), 96 deletions(-) delete mode 100644 test/js/bun/net/tls-parked-timeout-fixture.ts diff --git a/test/js/bun/net/tcp-server.test.ts b/test/js/bun/net/tcp-server.test.ts index 5803981d9057..2856263fb1a7 100644 --- a/test/js/bun/net/tcp-server.test.ts +++ b/test/js/bun/net/tcp-server.test.ts @@ -319,21 +319,6 @@ it("should not leak memory", async () => { expect(exitCode).toBe(0); }); -it("a TLS socket that waits in the low-priority queue times out", async () => { - await using proc = Bun.spawn({ - cmd: [bunExe(), join(import.meta.dir, "tls-parked-timeout-fixture.ts")], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ - stdout: JSON.stringify({ opened: 20, timedOut: 20 }), - stderr: "", - exitCode: 0, - }); -}, 30_000); // The fixture blocks for the 4 s between two timeout sweeps. - describe("one readiness event of a listener does not empty a long queue", () => { // More than MAX_ACCEPTS_PER_READINESS_EVENT in packages/bun-usockets/src/loop.c. const count = 100; @@ -359,12 +344,16 @@ describe("one readiness event of a listener does not empty a long queue", () => // No JS runs when an HTTP listener with TLS accepts, and its handshakes have a budget per // iteration of their own. What an accept changes for JS to see is the number of polls of the // loop, read once per iteration. The clients never finish their handshake. - function watchPolls(arrived: Arrived) { + function watchPolls(arrived: Arrived, fail: (error: Error) => void) { let last = getEventLoopStats().numPolls; let seen = 0; + let samples = 0; (function sample() { for (const { numPolls } = getEventLoopStats(); last < numPolls; last++, seen++) arrived(); - if (seen < count) setImmediate(sample); + if (seen >= count) return; + // Every client is in the queue already, so the accepts come in consecutive iterations. + if (++samples > 10 * count) return fail(new Error(`${seen} of ${count} accepts in ${samples} iterations`)); + setImmediate(sample); })(); } @@ -498,7 +487,7 @@ describe("one readiness event of a listener does not empty a long queue", () => it.each(Object.keys(kinds).filter(kind => !skipped(kind)))("%s", async kind => { const order: string[] = []; - const { promise: done, resolve } = Promise.withResolvers(); + const { promise: done, resolve, reject } = Promise.withResolvers(); let accepted = 0; const arrived: Arrived = (label = "c") => { const id = ++accepted; @@ -512,7 +501,7 @@ describe("one readiness event of a listener does not empty a long queue", () => await using listener = await queued(kinds[kind], watched ? () => {} : arrived); expect(listener.connected).toBe(true); - if (watched) watchPolls(arrived); + if (watched) watchPolls(arrived, reject); await done; const label = (id: number) => { diff --git a/test/js/bun/net/tls-parked-timeout-fixture.ts b/test/js/bun/net/tls-parked-timeout-fixture.ts deleted file mode 100644 index bfdafe26b894..000000000000 --- a/test/js/bun/net/tls-parked-timeout-fixture.ts +++ /dev/null @@ -1,77 +0,0 @@ -// A TLS socket that waits in the low-priority queue times out like any other socket. -// -// uSockets runs 5 TLS handshakes per loop iteration. It parks the other sockets that became -// readable in the low-priority queue, where they are in no group list, and the timeout sweep -// visits them there. This fixture parks sockets in the iteration of a sweep: -// -// 1. N raw clients connect. The server pauses each socket in `open`. Each client then writes -// the first byte of a handshake record, which stays unread in the kernel. -// 2. In one turn the server gives each socket a timeout that ends at the next sweep and -// resumes it. Then it blocks for longer than the time between two sweeps. -// 3. The next loop iteration has N readable sockets. 5 of them run, N - 5 are parked, and the -// sweep is due at the end of that same iteration. -// -// Prints { opened, timedOut }. timedOut counts the sockets whose `timeout` handler ran. -import type { Socket } from "bun"; -import { tls } from "harness"; -import net from "node:net"; - -const N = 20; -// LIBUS_TIMEOUT_GRANULARITY in packages/bun-usockets/src/libusockets.h, in milliseconds. -const SWEEP_INTERVAL_MS = 4000; - -const opened: Socket[] = []; -let timedOut = 0; -const allOpened = Promise.withResolvers(); -const allTimedOut = Promise.withResolvers(); - -const server = Bun.listen({ - hostname: "127.0.0.1", - port: 0, - tls, - socket: { - open(socket) { - socket.pause(); - if (opened.push(socket) === N) allOpened.resolve(); - }, - timeout(socket) { - socket.terminate(); - if (++timedOut === N) allTimedOut.resolve(); - }, - handshake() {}, - data() {}, - error() {}, - }, -}); - -const clients: net.Socket[] = []; -const written: Promise[] = []; -for (let i = 0; i < N; i++) { - const { promise, resolve, reject } = Promise.withResolvers(); - const client = net.connect(server.port, "127.0.0.1", () => client.write("\x16", () => resolve())); - client.on("error", reject); - clients.push(client); - written.push(promise); -} -await Promise.all([allOpened.promise, ...written]); -for (const client of clients) client.removeAllListeners("error").on("error", () => {}); - -// An immediate runs between two iterations. From a socket handler, the sweep at the end of the -// same iteration would run before any socket is readable again. -await new Promise(resolve => setImmediate(resolve)); -for (const socket of opened) { - socket.timeout(1); - socket.resume(); -} -Bun.sleepSync(SWEEP_INTERVAL_MS + 100); - -// No event tells that a sweep passed a socket by, so this waits for the sweep after the one that -// is due now. It only runs to its end when a socket did not time out. -const nextSweep = setTimeout(() => allTimedOut.resolve(), 2 * SWEEP_INTERVAL_MS + 1000); -await allTimedOut.promise; -clearTimeout(nextSweep); - -console.log(JSON.stringify({ opened: opened.length, timedOut })); -for (const client of clients) client.destroy(); -server.stop(true); -process.exit(0); From 69cae5c78f736fbb8b5345e9cca5239ad7884580 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:03:39 +0000 Subject: [PATCH 6/9] test: require the poll count to rise by the client count and stay The two TLS kinds of the accept test read the polls of the loop, since no JS runs when their listener accepts. The sampler now rejects when the count rises by more than the clients, and resolves after 10 iterations without a change. --- test/js/bun/net/tcp-server.test.ts | 34 ++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/test/js/bun/net/tcp-server.test.ts b/test/js/bun/net/tcp-server.test.ts index 2856263fb1a7..309c4da445af 100644 --- a/test/js/bun/net/tcp-server.test.ts +++ b/test/js/bun/net/tcp-server.test.ts @@ -343,21 +343,33 @@ describe("one readiness event of a listener does not empty a long queue", () => // No JS runs when an HTTP listener with TLS accepts, and its handshakes have a budget per // iteration of their own. What an accept changes for JS to see is the number of polls of the - // loop, read once per iteration. The clients never finish their handshake. - function watchPolls(arrived: Arrived, fail: (error: Error) => void) { + // loop, read once per iteration. The clients never finish their handshake, so the number + // rises by one per client and then stays: the promise rejects for more and for fewer. + function watchPolls(arrived: Arrived) { + const { promise, resolve, reject } = Promise.withResolvers(); let last = getEventLoopStats().numPolls; let seen = 0; let samples = 0; + let quiet = 0; (function sample() { - for (const { numPolls } = getEventLoopStats(); last < numPolls; last++, seen++) arrived(); - if (seen >= count) return; + for (const { numPolls } = getEventLoopStats(); last < numPolls; last++, seen++, quiet = 0) { + if (seen === count) return reject(new Error(`more than ${count} new polls`)); + arrived(); + } + if (seen === count && ++quiet === 10) return resolve(); // Every client is in the queue already, so the accepts come in consecutive iterations. - if (++samples > 10 * count) return fail(new Error(`${seen} of ${count} accepts in ${samples} iterations`)); + if (++samples > 10 * count) return reject(new Error(`${seen} of ${count} accepts in ${samples} iterations`)); setImmediate(sample); })(); + return promise; } - async function nodeServer(server: net.Server, unix: string | undefined, first?: string): Promise { + async function nodeServer( + server: net.Server, + unix: string | undefined, + first?: string, + sockets: net.Socket[] = [], + ): Promise { if (unix) server.listen(unix); else server.listen(0, "127.0.0.1"); await once(server, "listening"); @@ -367,6 +379,8 @@ describe("one readiness event of a listener does not empty a long queue", () => stop() { server.close(); (server as http.Server).closeAllConnections?.(); + // close() leaves the connections of a net.Server open. + for (const socket of sockets) socket.destroy(); }, }; } @@ -379,8 +393,7 @@ describe("one readiness event of a listener does not empty a long queue", () => arrived(socket.isPaused() ? "paused" : "c"); }); server.on("drop", () => arrived("d")); - server.on("close", () => sockets.forEach(socket => socket.destroy())); - return { server, listening: nodeServer(server, unix) }; + return { server, listening: nodeServer(server, unix, undefined, sockets) }; } const address = (server: { port?: number }, unix?: string) => @@ -487,7 +500,7 @@ describe("one readiness event of a listener does not empty a long queue", () => it.each(Object.keys(kinds).filter(kind => !skipped(kind)))("%s", async kind => { const order: string[] = []; - const { promise: done, resolve, reject } = Promise.withResolvers(); + const { promise: done, resolve } = Promise.withResolvers(); let accepted = 0; const arrived: Arrived = (label = "c") => { const id = ++accepted; @@ -501,8 +514,7 @@ describe("one readiness event of a listener does not empty a long queue", () => await using listener = await queued(kinds[kind], watched ? () => {} : arrived); expect(listener.connected).toBe(true); - if (watched) watchPolls(arrived, reject); - await done; + await Promise.all([done, watched ? watchPolls(arrived) : undefined]); const label = (id: number) => { if (kind === "node:net pauseOnConnect") return "paused" + id; From 580200da23171a9778ca7bc997ad1d720e699314 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:21:39 +0000 Subject: [PATCH 7/9] test: destroy the accepted sockets of the node:tls kind in stop() close() of a net.Server or tls.Server leaves its connections open, and 'close' fires after the last one is gone. stop() destroys them. --- test/js/bun/net/tcp-server.test.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/test/js/bun/net/tcp-server.test.ts b/test/js/bun/net/tcp-server.test.ts index 309c4da445af..be74d2966c06 100644 --- a/test/js/bun/net/tcp-server.test.ts +++ b/test/js/bun/net/tcp-server.test.ts @@ -461,15 +461,16 @@ describe("one readiness event of a listener does not empty a long queue", () => return listening; }, // 'connection' is the accept of a tls.Server too. 'secureConnection' is the handshake. - "node:tls": arrived => - nodeServer( - tls - .createServer(cert) - .on("tlsClientError", () => {}) - .on("connection", socket => (socket.on("error", () => {}), arrived())), - undefined, - handshakeStart, - ), + "node:tls": arrived => { + const sockets: net.Socket[] = []; + const server = tls.createServer(cert).on("tlsClientError", () => {}); + server.on("connection", socket => { + socket.on("error", () => {}); + sockets.push(socket); + arrived(); + }); + return nodeServer(server, undefined, handshakeStart, sockets); + }, }; // Starts a listener and puts `count` connections into its queue. Nothing accepts them before From 4802a753248119c44d501a2996adfbad854eab21 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:38:27 +0000 Subject: [PATCH 8/9] bench: read the CPU baseline after the report line The next interval counted the CPU time of the log line of the one before it. --- bench/snippets/http-connection-per-request.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bench/snippets/http-connection-per-request.mjs b/bench/snippets/http-connection-per-request.mjs index fc4d90367cad..29281b48e97d 100644 --- a/bench/snippets/http-connection-per-request.mjs +++ b/bench/snippets/http-connection-per-request.mjs @@ -88,5 +88,6 @@ setInterval(() => { const micros = cpu.user - last.cpu.user + (cpu.system - last.cpu.system); console.log(`${count} requests, ${(micros / count).toFixed(1)} us of CPU per request`); } - last = { requests, cpu }; + // Read after the report, so that the next interval does not pay for this one's log line. + last = { requests, cpu: process.cpuUsage() }; }, 1000); From 47c8d50c1b6c9d5086f82001c6bfa9441ad9cb2d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:54:12 +0000 Subject: [PATCH 9/9] test: report the listener kinds that Windows skips The kinds were filtered out of the list, so a run on Windows showed no trace of them. Each kind is now registered with skipIf. --- test/js/bun/net/tcp-server.test.ts | 58 +++++++++++++++--------------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/test/js/bun/net/tcp-server.test.ts b/test/js/bun/net/tcp-server.test.ts index be74d2966c06..9a4ffa800579 100644 --- a/test/js/bun/net/tcp-server.test.ts +++ b/test/js/bun/net/tcp-server.test.ts @@ -499,32 +499,34 @@ describe("one readiness event of a listener does not empty a long queue", () => // count of active libuv handles, not of the polls of the loop. const skipped = (kind: string) => isWindows && (kind.endsWith("unix") || acceptsWithoutJS.includes(kind)); - it.each(Object.keys(kinds).filter(kind => !skipped(kind)))("%s", async kind => { - const order: string[] = []; - const { promise: done, resolve } = Promise.withResolvers(); - let accepted = 0; - const arrived: Arrived = (label = "c") => { - const id = ++accepted; - order.push(label + id); - setImmediate(() => { - order.push("i" + id); - if (id === count) resolve(); - }); - }; - const watched = acceptsWithoutJS.includes(kind); - - await using listener = await queued(kinds[kind], watched ? () => {} : arrived); - expect(listener.connected).toBe(true); - await Promise.all([done, watched ? watchPolls(arrived) : undefined]); - - const label = (id: number) => { - if (kind === "node:net pauseOnConnect") return "paused" + id; - return (kind === "node:net maxConnections" && id > 1 ? "d" : "c") + id; - }; - expect(order.filter(entry => !entry.startsWith("i"))).toEqual( - Array.from({ length: count }, (_, i) => label(i + 1)), - ); - // The loop ran the immediate of the first connection before the listener took the last one. - expect(order.indexOf("i1")).toBeLessThan(order.indexOf(label(count))); - }); + for (const kind of Object.keys(kinds)) { + it.skipIf(skipped(kind))(kind, async () => { + const order: string[] = []; + const { promise: done, resolve } = Promise.withResolvers(); + let accepted = 0; + const arrived: Arrived = (label = "c") => { + const id = ++accepted; + order.push(label + id); + setImmediate(() => { + order.push("i" + id); + if (id === count) resolve(); + }); + }; + const watched = acceptsWithoutJS.includes(kind); + + await using listener = await queued(kinds[kind], watched ? () => {} : arrived); + expect(listener.connected).toBe(true); + await Promise.all([done, watched ? watchPolls(arrived) : undefined]); + + const label = (id: number) => { + if (kind === "node:net pauseOnConnect") return "paused" + id; + return (kind === "node:net maxConnections" && id > 1 ? "d" : "c") + id; + }; + expect(order.filter(entry => !entry.startsWith("i"))).toEqual( + Array.from({ length: count }, (_, i) => label(i + 1)), + ); + // The loop ran the immediate of the first connection before the listener took the last one. + expect(order.indexOf("i1")).toBeLessThan(order.indexOf(label(count))); + }); + } });