From edf4e47293ab2ae08bd9edab543459a128e302d2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 04:11:33 +0000 Subject: [PATCH 1/2] tls: don't flag a failed write on SSL_ERROR_WANT_READ during the handshake ssl_update_handshake set last_write_failed for both WANT_READ and WANT_WRITE. For WANT_READ no write failed: progress comes from the read side, and the flag kept EPOLLOUT (level-triggered) or the kqueue EVFILT_WRITE one-shot armed on an always-writable socket. Every tick re-entered the handshake with zero progress: 100% CPU whenever writable interest existed while the handshake stalled. us_socket_pause() arms writable, so pause() before the handshake finished spun until resume(); a peer that stalls its flight behind a blocked write spun the server until the idle timeout. WANT_WRITE keeps the flag, though the blocked BIO write normally sets it itself inside us_socket_raw_write. Measured with the new fixture: 2798ms CPU over a 2s stall window before, quiet after. --- packages/bun-usockets/src/crypto/openssl.c | 14 ++++++- test/js/bun/net/socket.test.ts | 7 ++++ .../net/tls-handshake-pause-spin-fixture.ts | 42 +++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 test/js/bun/net/tls-handshake-pause-spin-fixture.ts diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index efc29f92b1a5..9a96902d8987 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -2032,7 +2032,19 @@ static void ssl_update_handshake(struct us_socket_t *s) { } s->ssl_handshake_state = HANDSHAKE_PENDING; s->ssl_write_wants_read = 1; - s->flags.last_write_failed = 1; + /* Only a blocked write justifies keeping writable interest armed. For + * WANT_READ no write failed — progress comes from the read side — and + * setting last_write_failed here kept EPOLLOUT / the EVFILT_WRITE + * one-shot re-armed on an always-writable socket: every tick re-entered + * this function with zero progress, spinning the loop at 100% CPU + * whenever writable interest existed while the handshake stalled + * (us_socket_pause arms writable, so pause() mid-handshake spun until + * resume). WANT_WRITE's blocked BIO write already set the flag inside + * us_socket_raw_write; keep this for the BIO retry paths that buffer + * without issuing a send. */ + if (err == SSL_ERROR_WANT_WRITE) { + s->flags.last_write_failed = 1; + } return; } diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index f9e7ae1f55c1..886f8566e3c6 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -362,6 +362,13 @@ describe.concurrent("socket", () => { expect(await bunRun(fileURLToPath(new URL("./kqueue-filter-coalesce-fixture.ts", import.meta.url)))).toSpawn(); }); + // ssl_update_handshake set last_write_failed on SSL_ERROR_WANT_READ, so a + // paused (writable-armed) socket with a stalled handshake re-fired writable + // every tick at 100% CPU until the peer's handshake bytes arrived. + it("paused TLS socket with a stalled handshake must not spin the event loop", async () => { + expect(await bunRun(fileURLToPath(new URL("./tls-handshake-pause-spin-fixture.ts", import.meta.url)))).toSpawn(); + }); + it("reload() should preserve active_connections (no UAF / counter underflow)", async () => { await using proc = Bun.spawn({ cmd: [bunExe(), fileURLToPath(new URL("./socket-reload-fixture.ts", import.meta.url))], diff --git a/test/js/bun/net/tls-handshake-pause-spin-fixture.ts b/test/js/bun/net/tls-handshake-pause-spin-fixture.ts new file mode 100644 index 000000000000..4d8c097222ec --- /dev/null +++ b/test/js/bun/net/tls-handshake-pause-spin-fixture.ts @@ -0,0 +1,42 @@ +// A paused mid-handshake TLS socket must not spin the event loop. +// +// ssl_update_handshake used to set last_write_failed on SSL_ERROR_WANT_READ, +// so any writable interest on a socket whose handshake stalled (pause() arms +// writable) re-fired every tick: writable -> SSL_do_handshake -> WANT_READ -> +// keep writable armed -> immediately writable again, at 100% CPU until the +// peer's handshake bytes arrived. With reads paused they never do. +// +// The server here is plain TCP and never sends a ServerHello, so the client +// handshake stays pending; pause() in open() then holds the stall window. + +using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { open() {}, data() {}, end() {}, error() {}, close() {} }, +}); + +const client = await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + tls: { rejectUnauthorized: false }, + socket: { open(s) { s.pause(); }, handshake() {}, data() {}, end() {}, error() {}, close() {} }, +}); + +// Let the pause-time writable event and handshake kick settle first. +await Bun.sleep(100); + +const before = process.cpuUsage(); +await Bun.sleep(2000); +const delta = process.cpuUsage(before); +const cpuMs = (delta.user + delta.system) / 1000; + +console.log(`cpu over 2000ms stall window: ${Math.round(cpuMs)}ms`); +client.terminate(); + +// Spinning burns roughly the whole window; a quiet loop stays far below this +// even under debug+ASAN on a loaded CI machine. +if (cpuMs > 800) { + console.error(`SPIN: ${Math.round(cpuMs)}ms CPU while a paused TLS handshake idled`); + process.exit(1); +} +process.exit(0); From bf96c93469ebc9c1c09d45db152f67b71d3de18e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 04:31:42 +0000 Subject: [PATCH 2/2] test: guard the stall precondition and sync the warm-up on open Review feedback: the fixture now records handshake/end/error/close firing before the CPU sample (precondition breakage fails loudly instead of passing vacuously), and the warm-up waits for open() plus one macrotask instead of a fixed 100ms. Also trims the openssl.c comment. --- packages/bun-usockets/src/crypto/openssl.c | 16 +++----- .../net/tls-handshake-pause-spin-fixture.ts | 38 +++++++++++++++++-- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 9a96902d8987..6451412eb1e0 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -2032,16 +2032,12 @@ static void ssl_update_handshake(struct us_socket_t *s) { } s->ssl_handshake_state = HANDSHAKE_PENDING; s->ssl_write_wants_read = 1; - /* Only a blocked write justifies keeping writable interest armed. For - * WANT_READ no write failed — progress comes from the read side — and - * setting last_write_failed here kept EPOLLOUT / the EVFILT_WRITE - * one-shot re-armed on an always-writable socket: every tick re-entered - * this function with zero progress, spinning the loop at 100% CPU - * whenever writable interest existed while the handshake stalled - * (us_socket_pause arms writable, so pause() mid-handshake spun until - * resume). WANT_WRITE's blocked BIO write already set the flag inside - * us_socket_raw_write; keep this for the BIO retry paths that buffer - * without issuing a send. */ + /* Keep writable interest only for a blocked write. Setting this for + * WANT_READ too kept the always-writable socket's writable event firing + * every tick with zero progress (100% CPU) whenever writable interest + * existed while the handshake stalled, e.g. pause() mid-handshake. + * WANT_WRITE's blocked BIO write normally sets it in us_socket_raw_write + * already; kept for BIO retry paths that buffer without a send. */ if (err == SSL_ERROR_WANT_WRITE) { s->flags.last_write_failed = 1; } diff --git a/test/js/bun/net/tls-handshake-pause-spin-fixture.ts b/test/js/bun/net/tls-handshake-pause-spin-fixture.ts index 4d8c097222ec..a5186042f857 100644 --- a/test/js/bun/net/tls-handshake-pause-spin-fixture.ts +++ b/test/js/bun/net/tls-handshake-pause-spin-fixture.ts @@ -15,15 +15,41 @@ using server = Bun.listen({ socket: { open() {}, data() {}, end() {}, error() {}, close() {} }, }); +// Any of these firing means the stall precondition broke and the CPU check +// below would be measuring the wrong state. +let preconditionFailure: string | undefined; +const opened = Promise.withResolvers(); + const client = await Bun.connect({ hostname: "127.0.0.1", port: server.port, tls: { rejectUnauthorized: false }, - socket: { open(s) { s.pause(); }, handshake() {}, data() {}, end() {}, error() {}, close() {} }, + socket: { + open(s) { + s.pause(); + opened.resolve(); + }, + handshake() { + preconditionFailure ??= "handshake completed"; + }, + data() {}, + end() { + preconditionFailure ??= "socket ended"; + }, + error(_s, err) { + preconditionFailure ??= `socket error: ${err}`; + }, + close() { + preconditionFailure ??= "socket closed"; + }, + }, }); -// Let the pause-time writable event and handshake kick settle first. -await Bun.sleep(100); +// Wait for open (where pause() armed writable), then yield one macrotask so +// the pause-time writable dispatch and handshake kick land outside the +// measurement window. +await opened.promise; +await Bun.sleep(0); const before = process.cpuUsage(); await Bun.sleep(2000); @@ -31,8 +57,14 @@ const delta = process.cpuUsage(before); const cpuMs = (delta.user + delta.system) / 1000; console.log(`cpu over 2000ms stall window: ${Math.round(cpuMs)}ms`); +const failure = preconditionFailure; client.terminate(); +if (failure) { + console.error(`TLS stall precondition failed: ${failure}`); + process.exit(1); +} + // Spinning burns roughly the whole window; a quiet loop stays far below this // even under debug+ASAN on a loaded CI machine. if (cpuMs > 800) {