From 79ff9b8b4433ffa451ec78041a489d585dfebae9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:34:45 +0000 Subject: [PATCH 1/8] repro: TLS writable spin when send() returns a fatal errno us_socket_raw_write (packages/bun-usockets/src/socket.c) has no errno classification: any send() -1 sets last_write_failed and re-arms the writable poll. The SSL spill-drain path (us_internal_ssl_on_writable -> ssl_drain_spill -> us_socket_raw_write) therefore loops as long as the kernel keeps the fd writable and send() keeps failing. Fixture arms fault injection to return -1 EPIPE for N consecutive send() calls on a TLS client fd after the handshake completes, issues one write (which spills), and waits for the server to receive the data. On main, all N failures are silently consumed by the writable re-arm loop and the server receives; the socket never errors or closes. N=50000: SPIN received=true errored=false closed=false N=50000 cpu=2305ms wall=1342ms Same result for ECONNRESET and EINVAL. Plain TCP (node:net) does not spin because its write path uses us_socket_write_check_error, which has bounded retry plus peer-gone classification. --- .../js/bun/net/tls-send-errno-spin-fixture.ts | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 test/js/bun/net/tls-send-errno-spin-fixture.ts diff --git a/test/js/bun/net/tls-send-errno-spin-fixture.ts b/test/js/bun/net/tls-send-errno-spin-fixture.ts new file mode 100644 index 000000000000..322a9aab816b --- /dev/null +++ b/test/js/bun/net/tls-send-errno-spin-fixture.ts @@ -0,0 +1,85 @@ +// Demonstrates the TLS writable-dispatch spin: us_socket_raw_write treats any +// send() failure (including fatal errnos) as "partial write, re-arm writable", +// so ssl_drain_spill re-enters on every poll tick and send() is called in a +// tight loop for as long as the kernel keeps the fd writable. +// +// Method: arm fault injection so the next N send() calls on the client fd +// return -1 EPIPE, issue one TLS write (which spills because the ciphertext +// send "fails"), then wait for the server to receive the bytes. The server +// only receives once the fault disarms (after N failed sends) and the real +// send() goes through. +// +// - If the loop SPINS, all N failures are consumed immediately (the writable +// poll re-fires back-to-back) and the server receives the data almost at +// once even for large N. The socket never errors or closes. +// - If the loop is BOUNDED (the desired behavior), a fatal errno surfaces as +// an error/close after a small constant number of retries and the server +// never receives the data. +// +// Prints one line: " received= errored= closed= +// cpu= wall=" + +import { socketFaultInjection as fault } from "bun:internal-for-testing"; +import tls from "node:tls"; +import { tls as cert } from "harness"; + +if (!fault.available()) { + console.log("SKIP fault injection not available"); + process.exit(0); +} + +let serverGotData: (b: Buffer) => void = () => {}; +const serverData = new Promise(r => (serverGotData = r)); + +const server = tls.createServer({ key: cert.key, cert: cert.cert }, s => { + s.on("error", () => {}); + s.once("data", d => serverGotData(d)); +}); +await new Promise(r => server.listen(0, "127.0.0.1", r)); +const port = (server.address() as any).port; + +const sock = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false }); +let closed = false; +let errored = false; +sock.on("error", () => (errored = true)); +sock.on("close", () => (closed = true)); +await new Promise((res, rej) => { + sock.once("secureConnect", res); + sock.once("error", rej); +}); + +const fd: number = (sock as any)._handle?.fd; +if (typeof fd !== "number" || fd < 0) { + console.log("SETUP no fd"); + process.exit(2); +} + +const N = 50_000; +fault.set({ syscall: "send", action: "errno", errno: "EPIPE", repeat: N, fd }); + +const cpu0 = process.cpuUsage(); +const t0 = process.hrtime.bigint(); +sock.write(Buffer.alloc(100, "x")); + +const received = await Promise.race([ + serverData.then(() => true), + new Promise(r => sock.once("close", () => r(false))), + new Promise(r => setTimeout(() => r(false), 3000)), +]); + +const wallMs = Number(process.hrtime.bigint() - t0) / 1e6; +const cpu = process.cpuUsage(cpu0); +const cpuMs = (cpu.user + cpu.system) / 1000; + +fault.clear(); +sock.destroy(); +server.close(); + +// Spin verdict: the server received data (all N failures were silently +// retried) AND CPU ≈ wall (loop was hot the whole time). +const verdict = received && !errored && !closed ? "SPIN" : "OK"; +console.log( + `${verdict} received=${received} errored=${errored} closed=${closed} ` + + `N=${N} cpu=${cpuMs.toFixed(0)}ms wall=${wallMs.toFixed(0)}ms`, +); +process.exit(verdict === "SPIN" ? 1 : 0); From c1b8d28a51ac0f1aa945a6adea31101638ea0ca9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:22:15 +0000 Subject: [PATCH 2/8] usockets: classify send() errno in every write helper so a TLS socket cannot spin on a dead fd us_socket_raw_write (and us_socket_write / us_socket_raw_writev / us_socket_write2 / us_socket_ipc_write_fd) treated any -1 from bsd_send as a partial write: set last_write_failed, re-arm the writable poll, return 0. The SSL ciphertext spill path (us_internal_ssl_on_writable -> ssl_drain_spill -> us_socket_raw_write) therefore re-entered on every writable event for as long as send() kept failing and the kernel kept the fd writable, pinning a core at 100%. Observed on macOS as idle long-lived processes spinning in us_internal_ssl_on_writable -> __sendto with the kernel logging from inside every sosend, consistent with a Network Extension content filter returning an errno on a socket it has not closed. Reproduced on Linux and macOS via fault injection (50000 consecutive EPIPE failures were silently retried and the data eventually delivered once the fault disarmed; the socket never errored or closed). Fix: factor the errno classification from us_socket_write_check_error into us_internal_on_short_write() and call it from every write helper. A peer-gone errno, or an unclassified errno that persists past the existing bounded retry window, latches fatal_send_errno on the socket; loop.c closes the socket with that errno after the writable dispatch instead of re-arming for another retry. Would-block, transient ENOBUFS/ENOMEM, and bounded unclassified errnos still re-arm. fatal_send_errno lives in the pad-to-pointer gap before group, so struct us_socket_t does not grow. --- packages/bun-usockets/src/context.c | 2 + packages/bun-usockets/src/internal/internal.h | 6 + packages/bun-usockets/src/loop.c | 13 ++ packages/bun-usockets/src/socket.c | 121 ++++++++++++------ test/js/bun/net/socket-syscall-fault.test.ts | 86 ++++++++++++- .../js/bun/net/tls-send-errno-spin-fixture.ts | 85 ------------ 6 files changed, 188 insertions(+), 125 deletions(-) delete mode 100644 test/js/bun/net/tls-send-errno-spin-fixture.ts diff --git a/packages/bun-usockets/src/context.c b/packages/bun-usockets/src/context.c index 66eba3028bb6..218c57668141 100644 --- a/packages/bun-usockets/src/context.c +++ b/packages/bun-usockets/src/context.c @@ -347,6 +347,7 @@ static void us_internal_init_listen_socket(struct us_listen_socket_t *ls, s->flags.adopted = 0; s->flags.allow_half_open = (options & LIBUS_SOCKET_ALLOW_HALF_OPEN); s->unclassified_send_failures = 0; + s->fatal_send_errno = 0; s->next = 0; s->prev = 0; s->connect_state = NULL; @@ -491,6 +492,7 @@ static inline void us_internal_init_connect_socket(struct us_socket_t *s, s->flags.adopted = 0; s->flags.last_write_failed = 0; s->unclassified_send_failures = 0; + s->fatal_send_errno = 0; s->connect_state = NULL; s->connect_next = NULL; } diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index 27148b318f55..560941601051 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -310,6 +310,12 @@ struct us_socket_t { * the sweep escalates via SO_ERROR when the peer later resets). */ unsigned char unclassified_send_failures : 7; unsigned char fin_deferred : 1; + /* Nonzero when a send() from the writable dispatch failed with an errno + * that cannot recover (peer-gone, or the unclassified retry ceiling). The + * write helpers that hit it stop re-arming the writable poll; loop.c closes + * the socket with this code after the dispatch returns. POSIX errnos fit in + * a byte, and this lives in the existing pad-to-pointer gap. */ + unsigned char fatal_send_errno; struct us_socket_group_t *group; /* NULL for plain TCP. Direct BoringSSL `SSL*`; set by us_internal_ssl_attach diff --git a/packages/bun-usockets/src/loop.c b/packages/bun-usockets/src/loop.c index 987120f6bda6..78f44982f5a6 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -533,6 +533,7 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in s->flags.adopted = 0; s->flags.last_write_failed = 0; s->unclassified_send_failures = 0; + s->fatal_send_errno = 0; /* We always use nodelay */ bsd_socket_nodelay(client_fd, 1); @@ -600,6 +601,18 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in return; } + /* A send() from one of the us_socket_*write* helpers returned a + * fatal errno (peer-gone, or the unclassified retry ceiling hit). + * Without this, the TLS spill path re-armed the writable poll on + * every failure and spun at 100% for as long as the kernel kept + * the fd writable. Same close shape as the poll-error branch. */ + if (UNLIKELY(s->fatal_send_errno)) { + int send_errno = s->fatal_send_errno; + s->fatal_send_errno = 0; + s = us_internal_socket_close_raw(s, send_errno > 2 ? send_errno : ECONNRESET, NULL); + return; + } + /* If we have no failed write or if we shut down, then stop polling for more writable */ if (!s->flags.last_write_failed || us_socket_is_shut_down(s)) { us_poll_change(&s->p, loop, us_poll_events(&s->p) & LIBUS_SOCKET_READABLE); diff --git a/packages/bun-usockets/src/socket.c b/packages/bun-usockets/src/socket.c index 63af324915ea..6a7885272bf9 100644 --- a/packages/bun-usockets/src/socket.c +++ b/packages/bun-usockets/src/socket.c @@ -411,6 +411,82 @@ static void us_internal_rearm_writable(struct us_socket_t *s) { LIBUS_SOCKET_WRITABLE | (s->flags.is_paused ? 0 : LIBUS_SOCKET_READABLE)); } +#ifndef _WIN32 +/* send() errnos that mean the peer or the path to it is gone: no retry can + * ever succeed, so they are reported to the caller immediately (libuv fails + * the write request for these in uv__try_write, unix/stream.c; only the + * EAGAIN/ENOBUFS class waits for another writable event). Mirrored by the + * blanket `result < -1` fatal handling in h2_frame_parser's + * is_transport_fatal_write_result - classification lives here only. */ +static int us_internal_send_errno_is_peer_gone(int e) { + switch (e) { + case EPIPE: + case ECONNRESET: + case ECONNABORTED: + case ENOTCONN: + case ETIMEDOUT: + case ENETDOWN: + case ENETUNREACH: + case EHOSTUNREACH: + return 1; + default: + return 0; + } +} + +/* One retry runs per writable dispatch (one event-loop iteration), so 32 + * consecutive failures is far beyond any observed transient race window + * (the macOS EPROTOTYPE race resolves within a dispatch or two) while still + * surfacing a genuinely wedged transport within microseconds. */ +#define US_UNCLASSIFIED_SEND_RETRY_LIMIT 32 + +/* Classify a -1 from bsd_send/bsd_writev/bsd_write2/bsd_sendmsg. Returns 0 + * when the caller should treat it like backpressure (re-arm the writable poll + * and wait), or the errno when the transport is dead and re-arming would spin. + * Applied by every us_socket_*write* helper so the TLS spill path (which + * routes through us_socket_raw_write from ssl_drain_spill) cannot busy-loop + * the writable dispatch on a socket whose send() keeps failing. */ +static int us_internal_classify_send_error(struct us_socket_t *s) { + if (bsd_would_block() || bsd_send_is_transient_error()) { + return 0; + } + if (!us_internal_send_errno_is_peer_gone(errno) && + s->unclassified_send_failures < US_UNCLASSIFIED_SEND_RETRY_LIMIT) { + s->unclassified_send_failures++; + return 0; + } + return errno ? errno : EPIPE; +} +#endif + +/* Common tail for the us_socket_*write* helpers after a short write. On POSIX + * a -1 is routed through errno classification so a fatal errno latches + * fatal_send_errno (loop.c closes the socket after the writable dispatch) + * instead of re-arming for a retry that can never succeed. A partial write, + * would-block, transient exhaustion, or a still-bounded unclassified errno + * re-arms for a retry like before. The fatal path still re-arms once: the + * write may have been issued outside the writable dispatch, and loop.c's + * fatal_send_errno check runs there, so one more writable event is what + * delivers the close. It does not spin because the check precedes the retry. */ +static void us_internal_on_short_write(struct us_socket_t *s, int written) { +#ifndef _WIN32 + if (written < 0) { + int fatal = us_internal_classify_send_error(s); + if (fatal) { + s->fatal_send_errno = (unsigned char)fatal; + us_internal_rearm_writable(s); + return; + } + } else { + s->unclassified_send_failures = 0; + } +#else + (void)written; +#endif + s->flags.last_write_failed = 1; + us_internal_rearm_writable(s); +} + int us_socket_write2(struct us_socket_t *s, const char *header, int header_length, const char *payload, int payload_length) { if (us_socket_is_closed(s) || us_socket_is_shut_down(s)) { return 0; @@ -418,7 +494,7 @@ int us_socket_write2(struct us_socket_t *s, const char *header, int header_lengt int written = bsd_write2(us_poll_fd(&s->p), header, header_length, payload, payload_length); if (written != header_length + payload_length) { - us_internal_rearm_writable(s); + us_internal_on_short_write(s, written); } return written < 0 ? 0 : written; @@ -450,6 +526,7 @@ struct us_socket_t *us_socket_from_fd(struct us_socket_group_t *group, unsigned s->flags.adopted = 0; s->flags.last_write_failed = 0; s->unclassified_send_failures = 0; + s->fatal_send_errno = 0; s->connect_state = NULL; /* We always use nodelay */ @@ -491,43 +568,12 @@ int us_socket_write(struct us_socket_t *s, const char *data, int length) { int written = bsd_send(us_poll_fd(&s->p), data, length); if (written != length) { - s->flags.last_write_failed = 1; - us_internal_rearm_writable(s); + us_internal_on_short_write(s, written); } return written < 0 ? 0 : written; } -#ifndef _WIN32 -/* send() errnos that mean the peer or the path to it is gone: no retry can - * ever succeed, so they are reported to the caller immediately (libuv fails - * the write request for these in uv__try_write, unix/stream.c; only the - * EAGAIN/ENOBUFS class waits for another writable event). Mirrored by the - * blanket `result < -1` fatal handling in h2_frame_parser's - * is_transport_fatal_write_result - classification lives here only. */ -static int us_internal_send_errno_is_peer_gone(int e) { - switch (e) { - case EPIPE: - case ECONNRESET: - case ECONNABORTED: - case ENOTCONN: - case ETIMEDOUT: - case ENETDOWN: - case ENETUNREACH: - case EHOSTUNREACH: - return 1; - default: - return 0; - } -} - -/* One retry runs per writable dispatch (one event-loop iteration), so 32 - * consecutive failures is far beyond any observed transient race window - * (the macOS EPROTOTYPE race resolves within a dispatch or two) while still - * surfacing a genuinely wedged transport within microseconds. */ -#define US_UNCLASSIFIED_SEND_RETRY_LIMIT 32 -#endif - int us_socket_write_check_error(struct us_socket_t *s, const char *data, int length, int *fatal_write_error) { if (fatal_write_error) *fatal_write_error = 0; if (us_socket_is_closed(s) || us_socket_is_shut_down(s)) { @@ -612,8 +658,7 @@ int us_socket_raw_writev(struct us_socket_t *s, const struct us_iovec_t *iov, in ssize_t written = bsd_writev(us_poll_fd(&s->p), iov, count); if (written != (ssize_t)total) { - s->flags.last_write_failed = 1; - us_internal_rearm_writable(s); + us_internal_on_short_write(s, written < 0 ? -1 : 0); } return written < 0 ? 0 : (int)written; @@ -631,8 +676,7 @@ int us_socket_raw_write(struct us_socket_t *s, const char *data, int length) { int written = bsd_send(us_poll_fd(&s->p), data, length); if (written != length) { - s->flags.last_write_failed = 1; - us_internal_rearm_writable(s); + us_internal_on_short_write(s, written); } return written < 0 ? 0 : written; @@ -668,8 +712,7 @@ int us_socket_ipc_write_fd(struct us_socket_t *s, const char *data, int length, int sent = bsd_sendmsg(us_poll_fd(&s->p), &msg, 0); if (sent != length) { - s->flags.last_write_failed = 1; - us_internal_rearm_writable(s); + us_internal_on_short_write(s, sent); } return sent < 0 ? 0 : sent; diff --git a/test/js/bun/net/socket-syscall-fault.test.ts b/test/js/bun/net/socket-syscall-fault.test.ts index 836c39af47b2..bc9fb8f5b2ad 100644 --- a/test/js/bun/net/socket-syscall-fault.test.ts +++ b/test/js/bun/net/socket-syscall-fault.test.ts @@ -1,9 +1,10 @@ import { socketFaultInjection as fault } from "bun:internal-for-testing"; import { afterEach, describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isWindows } from "harness"; +import { bunEnv, bunExe, isWindows, tls as tlsCert } from "harness"; import { once } from "node:events"; import http2 from "node:http2"; import net from "node:net"; +import nodeTls from "node:tls"; import { join } from "node:path"; const skip = !fault.available() || isWindows; @@ -223,3 +224,86 @@ describe.skipIf(skip)("h2 client under injected unclassified send errno (EPROTOT expect(h.client.closed || h.client.destroyed).toBe(true); }); }); + +// The TLS write path routes ciphertext through us_socket_raw_write (via +// ssl_flush_write_batch / ssl_drain_spill), which had no errno classification: +// any send() -1 set last_write_failed and re-armed the writable poll. A +// persistent non-EAGAIN errno (macOS content filter, a racy EPROTOTYPE that +// never resolved, a peer-gone errno on a socket the kernel still reports +// writable) therefore looped the writable dispatch at full speed - observed as +// 100% CPU in us_internal_ssl_on_writable -> __sendto on idle long-lived +// processes holding a TLS keepalive. The tests assert the two halves of the +// fix: a peer-gone errno closes immediately, and an unclassified errno closes +// after the bounded retry window instead of burning through the fault budget. +describe.skipIf(skip)("TLS client under injected fatal send errno (us_socket_raw_write path)", () => { + afterEach(() => fault.clear()); + + async function tlsPair() { + let serverReceived = 0; + const server = nodeTls.createServer({ key: tlsCert.key, cert: tlsCert.cert }, s => { + s.on("error", () => {}); + s.on("data", d => (serverReceived += d.length)); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const sock = nodeTls.connect({ + port: (server.address() as net.AddressInfo).port, + host: "127.0.0.1", + rejectUnauthorized: false, + }); + sock.on("error", () => {}); + await once(sock, "secureConnect"); + const fd = (sock as any)._handle?.fd ?? -1; + expect(fd).toBeGreaterThanOrEqual(0); + + return { + sock, + fd, + serverReceived: () => serverReceived, + [Symbol.dispose]() { + fault.clear(); + sock.destroy(); + server.close(); + }, + }; + } + + test("sustained EPIPE closes the TLS socket instead of spinning the writable dispatch", async () => { + using pair = await tlsPair(); + fault.set({ syscall: "send", action: "errno", errno: "EPIPE", repeat: -1, fd: pair.fd }); + + let errorCode: string | undefined; + pair.sock.on("error", (e: NodeJS.ErrnoException) => (errorCode = e.code ?? e.message)); + // Not events.once(): that rejects on 'error', and error-before-close is + // the expected teardown ordering here. + const closed = new Promise(r => pair.sock.once("close", () => r())); + pair.sock.write(Buffer.alloc(100, 0x78)); + + // On main the socket never closed: the spill path re-armed writable every + // tick and the test timed out. With the fix, the first writable dispatch + // after the spill latches fatal_send_errno and the socket closes with the + // send errno. + await closed; + expect({ + closed: (pair.sock as any).destroyed, + errorCode, + serverReceived: pair.serverReceived(), + }).toEqual({ closed: true, errorCode: "EPIPE", serverReceived: 0 }); + }); + + test("unclassified errno is retried through the bounded window then closes", async () => { + using pair = await tlsPair(); + // Budget well above the retry ceiling (32) so a regression to the unbounded + // re-arm drains the rule and delivers the bytes instead of closing. + fault.set({ syscall: "send", action: "errno", errno: "EINVAL", repeat: 200, fd: pair.fd }); + const closed = new Promise(r => pair.sock.once("close", () => r())); + pair.sock.write(Buffer.alloc(100, 0x78)); + + await closed; + expect({ + closed: (pair.sock as any).destroyed, + serverReceived: pair.serverReceived(), + }).toEqual({ closed: true, serverReceived: 0 }); + }); +}); diff --git a/test/js/bun/net/tls-send-errno-spin-fixture.ts b/test/js/bun/net/tls-send-errno-spin-fixture.ts deleted file mode 100644 index 322a9aab816b..000000000000 --- a/test/js/bun/net/tls-send-errno-spin-fixture.ts +++ /dev/null @@ -1,85 +0,0 @@ -// Demonstrates the TLS writable-dispatch spin: us_socket_raw_write treats any -// send() failure (including fatal errnos) as "partial write, re-arm writable", -// so ssl_drain_spill re-enters on every poll tick and send() is called in a -// tight loop for as long as the kernel keeps the fd writable. -// -// Method: arm fault injection so the next N send() calls on the client fd -// return -1 EPIPE, issue one TLS write (which spills because the ciphertext -// send "fails"), then wait for the server to receive the bytes. The server -// only receives once the fault disarms (after N failed sends) and the real -// send() goes through. -// -// - If the loop SPINS, all N failures are consumed immediately (the writable -// poll re-fires back-to-back) and the server receives the data almost at -// once even for large N. The socket never errors or closes. -// - If the loop is BOUNDED (the desired behavior), a fatal errno surfaces as -// an error/close after a small constant number of retries and the server -// never receives the data. -// -// Prints one line: " received= errored= closed= -// cpu= wall=" - -import { socketFaultInjection as fault } from "bun:internal-for-testing"; -import tls from "node:tls"; -import { tls as cert } from "harness"; - -if (!fault.available()) { - console.log("SKIP fault injection not available"); - process.exit(0); -} - -let serverGotData: (b: Buffer) => void = () => {}; -const serverData = new Promise(r => (serverGotData = r)); - -const server = tls.createServer({ key: cert.key, cert: cert.cert }, s => { - s.on("error", () => {}); - s.once("data", d => serverGotData(d)); -}); -await new Promise(r => server.listen(0, "127.0.0.1", r)); -const port = (server.address() as any).port; - -const sock = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false }); -let closed = false; -let errored = false; -sock.on("error", () => (errored = true)); -sock.on("close", () => (closed = true)); -await new Promise((res, rej) => { - sock.once("secureConnect", res); - sock.once("error", rej); -}); - -const fd: number = (sock as any)._handle?.fd; -if (typeof fd !== "number" || fd < 0) { - console.log("SETUP no fd"); - process.exit(2); -} - -const N = 50_000; -fault.set({ syscall: "send", action: "errno", errno: "EPIPE", repeat: N, fd }); - -const cpu0 = process.cpuUsage(); -const t0 = process.hrtime.bigint(); -sock.write(Buffer.alloc(100, "x")); - -const received = await Promise.race([ - serverData.then(() => true), - new Promise(r => sock.once("close", () => r(false))), - new Promise(r => setTimeout(() => r(false), 3000)), -]); - -const wallMs = Number(process.hrtime.bigint() - t0) / 1e6; -const cpu = process.cpuUsage(cpu0); -const cpuMs = (cpu.user + cpu.system) / 1000; - -fault.clear(); -sock.destroy(); -server.close(); - -// Spin verdict: the server received data (all N failures were silently -// retried) AND CPU ≈ wall (loop was hot the whole time). -const verdict = received && !errored && !closed ? "SPIN" : "OK"; -console.log( - `${verdict} received=${received} errored=${errored} closed=${closed} ` + - `N=${N} cpu=${cpuMs.toFixed(0)}ms wall=${wallMs.toFixed(0)}ms`, -); -process.exit(verdict === "SPIN" ? 1 : 0); From b0498d676b0b44beec32917604e0892528e577d1 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:24:45 +0000 Subject: [PATCH 3/8] [autofix.ci] apply automated fixes --- test/js/bun/net/socket-syscall-fault.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/bun/net/socket-syscall-fault.test.ts b/test/js/bun/net/socket-syscall-fault.test.ts index bc9fb8f5b2ad..62a246202d98 100644 --- a/test/js/bun/net/socket-syscall-fault.test.ts +++ b/test/js/bun/net/socket-syscall-fault.test.ts @@ -4,8 +4,8 @@ import { bunEnv, bunExe, isWindows, tls as tlsCert } from "harness"; import { once } from "node:events"; import http2 from "node:http2"; import net from "node:net"; -import nodeTls from "node:tls"; import { join } from "node:path"; +import nodeTls from "node:tls"; const skip = !fault.available() || isWindows; From 1e38d8e5a51dcf154120ef5c8a7844d11fe20209 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:45:24 +0000 Subject: [PATCH 4/8] usockets: reset unclassified_send_failures on a fully-successful send Review follow-up: us_internal_on_short_write is only called on written != length, so its counter reset never ran for a full write and the counter measured lifetime (not consecutive) unclassified errnos on the TLS spill path. Reset it on written == length in each write helper, matching us_socket_write_check_error. Also refactor us_socket_write_check_error's POSIX errno branch to use the shared us_internal_classify_send_error so the classification lives in one place. New test asserts 50 rounds of one-off EPROTOTYPE each recovered by a successful drain leave the socket open (it closed at round 33 before the reset). --- packages/bun-usockets/src/socket.c | 48 +++++++++----------- test/js/bun/net/socket-syscall-fault.test.ts | 25 ++++++++++ 2 files changed, 46 insertions(+), 27 deletions(-) diff --git a/packages/bun-usockets/src/socket.c b/packages/bun-usockets/src/socket.c index 6a7885272bf9..0296970d28c9 100644 --- a/packages/bun-usockets/src/socket.c +++ b/packages/bun-usockets/src/socket.c @@ -495,6 +495,8 @@ int us_socket_write2(struct us_socket_t *s, const char *header, int header_lengt int written = bsd_write2(us_poll_fd(&s->p), header, header_length, payload, payload_length); if (written != header_length + payload_length) { us_internal_on_short_write(s, written); + } else { + s->unclassified_send_failures = 0; } return written < 0 ? 0 : written; @@ -569,6 +571,8 @@ int us_socket_write(struct us_socket_t *s, const char *data, int length) { int written = bsd_send(us_poll_fd(&s->p), data, length); if (written != length) { us_internal_on_short_write(s, written); + } else { + s->unclassified_send_failures = 0; } return written < 0 ? 0 : written; @@ -586,6 +590,17 @@ int us_socket_write_check_error(struct us_socket_t *s, const char *data, int len int written = bsd_send(us_poll_fd(&s->p), data, length); if (written < 0) { +#ifndef _WIN32 + int fatal = us_internal_classify_send_error(s); + if (!fatal) { + s->flags.last_write_failed = 1; + us_internal_rearm_writable(s); + return 0; + } + /* Fatal send error: report the errno to callers that opt in and stop + * polling writable - retrying can never succeed. */ + if (fatal_write_error) *fatal_write_error = fatal; +#else /* bsd_send already retries EINTR; bsd_would_block() reads errno on * POSIX and WSAGetLastError() on Windows. ENOBUFS/ENOMEM are * transient kernel resource exhaustion on a healthy connection - @@ -596,33 +611,6 @@ int us_socket_write_check_error(struct us_socket_t *s, const char *data, int len us_internal_rearm_writable(s); return 0; } -#ifndef _WIN32 - /* Anything else that is not a known peer-gone errno gets a BOUNDED - * retry through the same rearm/writable machinery as would-block. - * Kernels return racy, non-terminal errnos from send() on sockets - * that are still perfectly usable - macOS EPROTOTYPE while the - * connection is concurrently mutating is the canonical case, and - * libuv retries it (RETRY_ON_WRITE_ERROR, - * https://github.com/libuv/libuv/blob/v1.x/src/unix/stream.c) - - * so failing the write on first sight killed live connections. - * But the retry must not be unbounded: an errno that persists across - * many consecutive writable dispatches with no successful send in - * between means the transport is dead in a way the kernel will not - * name on the write side, and silently re-arming forever jams the - * connection (buffered bytes never drain and no error ever - * surfaces). After the limit, report the errno like the peer-gone - * class so the caller can fail the write. */ - if (!us_internal_send_errno_is_peer_gone(errno) && - s->unclassified_send_failures < US_UNCLASSIFIED_SEND_RETRY_LIMIT) { - s->unclassified_send_failures++; - s->flags.last_write_failed = 1; - us_internal_rearm_writable(s); - return 0; - } - /* Fatal send error: report the errno to callers that opt in and stop - * polling writable - retrying can never succeed. */ - if (fatal_write_error) *fatal_write_error = errno; -#else /* Windows: report the raw (positive) WSA code. The JS layer already * traffics in raw negative WSA values on this platform (see * SocketEmitEndNT / failWrite, which shape unnameable ones as @@ -659,6 +647,8 @@ int us_socket_raw_writev(struct us_socket_t *s, const struct us_iovec_t *iov, in ssize_t written = bsd_writev(us_poll_fd(&s->p), iov, count); if (written != (ssize_t)total) { us_internal_on_short_write(s, written < 0 ? -1 : 0); + } else { + s->unclassified_send_failures = 0; } return written < 0 ? 0 : (int)written; @@ -677,6 +667,8 @@ int us_socket_raw_write(struct us_socket_t *s, const char *data, int length) { int written = bsd_send(us_poll_fd(&s->p), data, length); if (written != length) { us_internal_on_short_write(s, written); + } else { + s->unclassified_send_failures = 0; } return written < 0 ? 0 : written; @@ -713,6 +705,8 @@ int us_socket_ipc_write_fd(struct us_socket_t *s, const char *data, int length, if (sent != length) { us_internal_on_short_write(s, sent); + } else { + s->unclassified_send_failures = 0; } return sent < 0 ? 0 : sent; diff --git a/test/js/bun/net/socket-syscall-fault.test.ts b/test/js/bun/net/socket-syscall-fault.test.ts index 62a246202d98..28b8980df4fb 100644 --- a/test/js/bun/net/socket-syscall-fault.test.ts +++ b/test/js/bun/net/socket-syscall-fault.test.ts @@ -306,4 +306,29 @@ describe.skipIf(skip)("TLS client under injected fatal send errno (us_socket_raw serverReceived: pair.serverReceived(), }).toEqual({ closed: true, serverReceived: 0 }); }); + + test("sporadic unclassified errnos that recover do not accumulate into a lifetime close", async () => { + using pair = await tlsPair(); + // 50 rounds of [one EPROTOTYPE, then a real send that drains the spill]. + // A successful send must reset unclassified_send_failures, so the counter + // never reaches the 32-cap and the socket stays open throughout. If the + // reset is missing, round 33's errno latches fatal_send_errno and the + // socket closes with EPROTOTYPE instead. + let sawClose = false; + pair.sock.on("close", () => (sawClose = true)); + for (let i = 0; i < 50; i++) { + fault.set({ syscall: "send", action: "errno", errno: "EPROTOTYPE", repeat: 1, fd: pair.fd }); + pair.sock.write(Buffer.alloc(8, 0x61 + (i % 26))); + // wait until the server has received this round's bytes (spill drained + // after the one-off errno), so each round is: errno -> full success. + while (pair.serverReceived() < 8 * (i + 1) && !sawClose) { + await new Promise(r => setImmediate(r)); + } + if (sawClose) break; + } + expect({ sawClose, serverReceived: pair.serverReceived() }).toEqual({ + sawClose: false, + serverReceived: 400, + }); + }); }); From 7ab3ec2cb8a463f4f14e337e8aa38cdaa334bbac Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:09:12 +0000 Subject: [PATCH 5/8] usockets: fix stale field comment on fatal_send_errno The comment said the write helpers stop re-arming on fatal, but us_internal_on_short_write re-arms once so loop.c's check delivers the close. Match the (correct) socket.c comment. --- packages/bun-usockets/src/internal/internal.h | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index 560941601051..6143e321ee40 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -310,11 +310,10 @@ struct us_socket_t { * the sweep escalates via SO_ERROR when the peer later resets). */ unsigned char unclassified_send_failures : 7; unsigned char fin_deferred : 1; - /* Nonzero when a send() from the writable dispatch failed with an errno - * that cannot recover (peer-gone, or the unclassified retry ceiling). The - * write helpers that hit it stop re-arming the writable poll; loop.c closes - * the socket with this code after the dispatch returns. POSIX errnos fit in - * a byte, and this lives in the existing pad-to-pointer gap. */ + /* Nonzero when a send() from a us_socket_*write* helper failed with an + * errno that cannot recover (peer-gone, or the unclassified retry ceiling). + * loop.c closes the socket with this code on the next writable dispatch. + * POSIX errnos fit in a byte; lives in the pad-to-pointer gap. */ unsigned char fatal_send_errno; struct us_socket_group_t *group; From 32441db51a631eaf4d3d2224cf2419155b1530f9 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Fri, 17 Jul 2026 18:06:58 -0700 Subject: [PATCH 6/8] usockets: fold the deferred-close latches into one pending_action enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit us_socket_t had grown four independent "do X later" fields — ssl_shutdown_after_spill, ssl_close_after_spill, ssl_pending_detach plus ssl_pending_close_code, and fatal_send_errno. They are mutually exclusive by construction (a close outranks a shutdown, and a close that must wait for the SSL call on the stack to unwind outranks all), so they collapse into one 3-bit pending_action enum ordered by priority plus a single payload byte, latched through us_internal_socket_set_pending which never downgrades a parked close. Impossible combinations (shutdown && close both latched, a close code with nothing pending) are now unrepresentable. Also drop ssl_is_server: written once in ssl_attach and read nowhere; the role lives in BoringSSL (SSL_set_accept_state / connect_state) and in the Rust TLS_SERVER_ROLE flag. --- packages/bun-usockets/src/context.c | 6 +- packages/bun-usockets/src/crypto/openssl.c | 52 ++++++++---------- packages/bun-usockets/src/internal/internal.h | 55 +++++++++++-------- packages/bun-usockets/src/loop.c | 9 +-- packages/bun-usockets/src/socket.c | 24 ++++---- src/runtime/socket/socket_body.rs | 2 +- 6 files changed, 79 insertions(+), 69 deletions(-) diff --git a/packages/bun-usockets/src/context.c b/packages/bun-usockets/src/context.c index 218c57668141..3dacd4ea98c2 100644 --- a/packages/bun-usockets/src/context.c +++ b/packages/bun-usockets/src/context.c @@ -347,7 +347,8 @@ static void us_internal_init_listen_socket(struct us_listen_socket_t *ls, s->flags.adopted = 0; s->flags.allow_half_open = (options & LIBUS_SOCKET_ALLOW_HALF_OPEN); s->unclassified_send_failures = 0; - s->fatal_send_errno = 0; + s->pending_action = US_PENDING_ACTION_NONE; + s->pending_code = 0; s->next = 0; s->prev = 0; s->connect_state = NULL; @@ -492,7 +493,8 @@ static inline void us_internal_init_connect_socket(struct us_socket_t *s, s->flags.adopted = 0; s->flags.last_write_failed = 0; s->unclassified_send_failures = 0; - s->fatal_send_errno = 0; + s->pending_action = US_PENDING_ACTION_NONE; + s->pending_code = 0; s->connect_state = NULL; s->connect_next = NULL; } diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 886859d90cba..d3404682d38e 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -526,7 +526,8 @@ static int BIO_s_custom_write(BIO *bio, const char *data, int length) { * whatever BoringSSL tries to flush (typically the fatal alert). The bytes * are reported as written so the SSL state machine completes its error * path instead of retrying. */ - if (loop_ssl_data->ssl_socket && loop_ssl_data->ssl_socket->ssl_pending_detach) { + if (loop_ssl_data->ssl_socket && + loop_ssl_data->ssl_socket->pending_action == US_PENDING_ACTION_DETACH) { BIO_clear_retry_flags(bio); return length; } @@ -1334,13 +1335,10 @@ void us_internal_ssl_attach(struct us_socket_t *s, SSL_CTX *ctx, s->ssl_read_wants_write = 0; s->ssl_fatal_error = 0; s->ssl_raw_tap = 0; - s->ssl_shutdown_after_spill = 0; - s->ssl_close_after_spill = 0; s->ssl_end_delivered = 0; s->ssl_in_use = 0; - s->ssl_pending_detach = 0; - s->ssl_pending_close_code = 0; - s->ssl_is_server = is_client ? 0 : 1; + /* pending_action is deliberately not reset: adopt_tls attaches to a live + * socket, and a CLOSE_RAW parked by a fatal send must still fire. */ } void us_internal_ssl_detach(struct us_socket_t *s) { @@ -1354,8 +1352,9 @@ void us_internal_ssl_detach(struct us_socket_t *s) { /* SSL_do_handshake/SSL_read is on the stack (a JS callback run from * inside it destroyed the socket); freeing now would leave BoringSSL * working on freed memory when control returns. The driver frees it - * when the call unwinds. */ - s->ssl_pending_detach = 1; + * when the call unwinds; keep any close code already parked. */ + us_internal_socket_set_pending(s, US_PENDING_ACTION_DETACH, + s->pending_action == US_PENDING_ACTION_DETACH ? s->pending_code : 0); return; } SSL_free(s_ssl(s)); @@ -1615,18 +1614,17 @@ struct us_socket_t *us_internal_ssl_close(struct us_socket_t *s, int code, void * driver's epilogue (the same protocol close_raw and ssl_detach honor), * releasing the spill now so the re-issued close cannot itself defer. */ ssl_release_spill(s->group->loop, s); - s->ssl_pending_detach = 1; - s->ssl_pending_close_code = (unsigned char) code; + us_internal_socket_set_pending(s, US_PENDING_ACTION_DETACH, (unsigned char) code); return s; } /* node's `_handle.close()` (FAST_SHUTDOWN, no reason) must not cut off spilled * ciphertext already reported as written: SSL sealed it, so it can only be - * delivered, never re-sent. Mirror ssl_shutdown_after_spill; defer at most once. */ + * delivered, never re-sent. Mirror the deferred shutdown; defer at most once. */ if (code == LIBUS_SOCKET_CLOSE_CODE_FAST_SHUTDOWN && !reason - && !s->ssl_close_after_spill && !s->ssl_fatal_error && !us_socket_is_closed(s)) { + && s->pending_action != US_PENDING_ACTION_CLOSE && !s->ssl_fatal_error && !us_socket_is_closed(s)) { struct loop_ssl_data *loop_ssl_data = (struct loop_ssl_data *)s->group->loop->data.ssl_data; if (loop_ssl_data && !ssl_drain_spill(loop_ssl_data, s)) { - s->ssl_close_after_spill = 1; + us_internal_socket_set_pending(s, US_PENDING_ACTION_CLOSE, 0); return s; } } @@ -1696,11 +1694,11 @@ static void ssl_update_handshake(struct us_socket_t *s) { s->ssl_in_use = 1; int result = SSL_do_handshake(s_ssl(s)); s->ssl_in_use = ssl_was_in_use; - if (!ssl_was_in_use && s->ssl_pending_detach) { + if (!ssl_was_in_use && s->pending_action == US_PENDING_ACTION_DETACH) { /* A callback run from inside the handshake destroyed this socket; perform * the deferred close now and do not touch the SSL again. */ - s->ssl_pending_detach = 0; - us_socket_close(s, s->ssl_pending_close_code, NULL); + s->pending_action = US_PENDING_ACTION_NONE; + us_socket_close(s, s->pending_code, NULL); return; } @@ -1824,13 +1822,13 @@ struct us_socket_t *us_internal_ssl_on_writable(struct us_socket_t *s) { if (loop_ssl_data && !ssl_drain_spill(loop_ssl_data, s)) { return s; } - if (s->ssl_shutdown_after_spill) { - s->ssl_shutdown_after_spill = 0; + if (s->pending_action == US_PENDING_ACTION_SHUTDOWN) { + s->pending_action = US_PENDING_ACTION_NONE; us_internal_ssl_shutdown(s); if (ssl_gone(s)) return s; } - if (s->ssl_close_after_spill) { - s->ssl_close_after_spill = 0; + if (s->pending_action == US_PENDING_ACTION_CLOSE) { + s->pending_action = US_PENDING_ACTION_NONE; return us_internal_ssl_close(s, LIBUS_SOCKET_CLOSE_CODE_FAST_SHUTDOWN, NULL); } } @@ -1909,11 +1907,11 @@ struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, i loop_ssl_data->ssl_read_output + LIBUS_RECV_BUFFER_PADDING + read, LIBUS_RECV_BUFFER_LENGTH - read); s->ssl_in_use = ssl_was_in_use; - if (!ssl_was_in_use && s->ssl_pending_detach) { + if (!ssl_was_in_use && s->pending_action == US_PENDING_ACTION_DETACH) { /* A callback run from inside this read destroyed the socket; perform * the deferred close now and stop processing. */ - s->ssl_pending_detach = 0; - return us_socket_close(s, s->ssl_pending_close_code, NULL); + s->pending_action = US_PENDING_ACTION_NONE; + return us_socket_close(s, s->pending_code, NULL); } if (just_read <= 0) { @@ -2182,7 +2180,7 @@ void us_internal_ssl_shutdown(struct us_socket_t *s) { struct loop_ssl_data *loop_ssl_data = (struct loop_ssl_data *)s->group->loop->data.ssl_data; if (loop_ssl_data && loop_ssl_data->ssl_spill_owner == s) { if (!ssl_drain_spill(loop_ssl_data, s)) { - s->ssl_shutdown_after_spill = 1; + us_internal_socket_set_pending(s, US_PENDING_ACTION_SHUTDOWN, 0); return; } } @@ -2266,8 +2264,7 @@ void us_socket_sni_resolve(struct us_socket_t *s, struct ssl_ctx_st *ctx, int er * the handshake_failure alert BoringSSL queues for select_cert_error and * the epilogue closes the socket; the client just sees the connection go * away ("disconnected before secure TLS connection was established"). */ - s->ssl_pending_detach = 1; - s->ssl_pending_close_code = 0; + us_internal_socket_set_pending(s, US_PENDING_ACTION_DETACH, 0); } else { pending->state = 2; pending->resolved_ctx = ctx; /* may be NULL = default ctx */ @@ -2452,8 +2449,7 @@ static enum ssl_select_cert_result_t us_select_cert_cb(const SSL_CLIENT_HELLO *h * deferred-close + BIO-swallow path, same as sni_cb). */ struct loop_ssl_data *lsd = (struct loop_ssl_data *)BIO_get_data(SSL_get_wbio(ssl)); if (lsd && lsd->ssl_socket) { - lsd->ssl_socket->ssl_pending_detach = 1; - lsd->ssl_socket->ssl_pending_close_code = 0; + us_internal_socket_set_pending(lsd->ssl_socket, US_PENDING_ACTION_DETACH, 0); } return ssl_select_cert_error; } diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index 6143e321ee40..4b8bae2852c7 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -259,6 +259,23 @@ struct us_socket_flags { } __attribute__((packed)); +/* The one deferred action a socket carries, run at its trigger point. Higher + * values supersede lower (a close outranks a shutdown; a close that must wait + * for the SSL call on the stack to unwind outranks all). */ +enum us_socket_pending_action { + US_PENDING_ACTION_NONE = 0, + /* TLS shutdown once the ciphertext spill drains (writable event). */ + US_PENDING_ACTION_SHUTDOWN = 1, + /* TLS close (FAST_SHUTDOWN) once the ciphertext spill drains (writable). */ + US_PENDING_ACTION_CLOSE = 2, + /* send() failed with fatal errno `pending_code`: close_raw on the next + * writable dispatch (loop.c) instead of re-arming a retry that spins. */ + US_PENDING_ACTION_CLOSE_RAW = 3, + /* Close with code `pending_code` once ssl_in_use unwinds; the BIO swallows + * writes meanwhile so BoringSSL's error path completes. */ + US_PENDING_ACTION_DETACH = 4, +}; + struct us_socket_t { alignas(LIBUS_EXT_ALIGNMENT) struct us_poll_t p; unsigned char timeout; @@ -266,41 +283,31 @@ struct us_socket_t { struct us_socket_flags flags; /* enum SocketKind. Selects the static dispatch arm in us_dispatch_*. */ unsigned char kind; - /* SSL state. These 6 bits live in the pad-to-pointer gap before `group`, so - * they cost nothing on epoll/kqueue (poll=4 + 4×u8 + 1 byte bits = 9, padded + /* SSL state. These bits live in the pad-to-pointer gap before `group`, so + * they cost nothing on epoll/kqueue (poll=4 + 4×u8 + the bytes below, padded * to 16 anyway for the pointer). Per-socket reneg counters and SNI userdata * hang off SSL ex_data, allocated on first use only. */ unsigned char ssl_handshake_state : 2; unsigned char ssl_write_wants_read : 1; unsigned char ssl_read_wants_write : 1; unsigned char ssl_fatal_error : 1; - unsigned char ssl_is_server : 1; /* If set, us_internal_ssl_on_data() first dispatches the still-encrypted * bytes via us_dispatch_ssl_raw_tap() before feeding them to SSL_read. * Used by Bun's `socket.upgradeTLS()` so the returned [raw, tls] pair's * `raw` half can observe ciphertext (node:net Duplex.ondata semantics). */ unsigned char ssl_raw_tap : 1; - /* A graceful TLS shutdown arrived while batched ciphertext was still - * spilled (see ssl_flush_write_batch); the shutdown re-runs once the - * spill drains so those records are not cut off by our FIN/close_notify. */ - unsigned char ssl_shutdown_after_spill : 1; - /* Same as ssl_shutdown_after_spill but for us_internal_ssl_close: the - * close re-runs from the writable event once the spill drains. */ - unsigned char ssl_close_after_spill : 1; /* The plaintext EOF (peer close_notify or the raw TCP FIN behind it) was * already dispatched to the user layer; both EOF paths can fire for one * connection, and the end handler must run once. */ unsigned char ssl_end_delivered : 1; /* Set while SSL_do_handshake/SSL_read is on the stack: JS run from inside * those calls (ALPN/SNI/keylog callbacks) may destroy the socket, and the - * SSL must not be freed under BoringSSL's feet - the detach is deferred to - * the driver's epilogue via ssl_pending_detach. */ + * SSL must not be freed under BoringSSL's feet - the close is parked as + * US_PENDING_ACTION_DETACH and performed by the driver's epilogue. */ unsigned char ssl_in_use : 1; - unsigned char ssl_pending_detach : 1; - /* The close code passed to the deferred close (e.g. a reset requested from - * inside a handshake callback must still RST, not FIN, when it is finally - * performed). */ - unsigned char ssl_pending_close_code; + /* enum us_socket_pending_action, and its payload (close code or errno). */ + unsigned char pending_action : 3; + unsigned char pending_code; /* Consecutive send() failures with an errno that is neither * would-block/transient nor a known peer-gone error (see * us_socket_write_check_error). Reset by any send that makes progress. @@ -310,11 +317,6 @@ struct us_socket_t { * the sweep escalates via SO_ERROR when the peer later resets). */ unsigned char unclassified_send_failures : 7; unsigned char fin_deferred : 1; - /* Nonzero when a send() from a us_socket_*write* helper failed with an - * errno that cannot recover (peer-gone, or the unclassified retry ceiling). - * loop.c closes the socket with this code on the next writable dispatch. - * POSIX errnos fit in a byte; lives in the pad-to-pointer gap. */ - unsigned char fatal_send_errno; struct us_socket_group_t *group; /* NULL for plain TCP. Direct BoringSSL `SSL*`; set by us_internal_ssl_attach @@ -329,6 +331,15 @@ struct us_socket_t { _Static_assert(sizeof(struct us_socket_flags) == 1, "us_socket_flags grew"); #endif +/* Latch a pending action. A higher (or equal) action supersedes; a lower one + * never downgrades a close already parked. */ +static inline void us_internal_socket_set_pending(struct us_socket_t *s, unsigned char action, unsigned char code) { + if (action >= s->pending_action) { + s->pending_action = action; + s->pending_code = code; + } +} + struct us_connecting_socket_t { alignas(LIBUS_EXT_ALIGNMENT) struct addrinfo_request *addrinfo_req; struct us_socket_group_t *group; diff --git a/packages/bun-usockets/src/loop.c b/packages/bun-usockets/src/loop.c index 78f44982f5a6..9fd9aa9b174b 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -533,7 +533,8 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in s->flags.adopted = 0; s->flags.last_write_failed = 0; s->unclassified_send_failures = 0; - s->fatal_send_errno = 0; + s->pending_action = US_PENDING_ACTION_NONE; + s->pending_code = 0; /* We always use nodelay */ bsd_socket_nodelay(client_fd, 1); @@ -606,9 +607,9 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in * Without this, the TLS spill path re-armed the writable poll on * every failure and spun at 100% for as long as the kernel kept * the fd writable. Same close shape as the poll-error branch. */ - if (UNLIKELY(s->fatal_send_errno)) { - int send_errno = s->fatal_send_errno; - s->fatal_send_errno = 0; + if (UNLIKELY(s->pending_action == US_PENDING_ACTION_CLOSE_RAW)) { + int send_errno = s->pending_code; + s->pending_action = US_PENDING_ACTION_NONE; s = us_internal_socket_close_raw(s, send_errno > 2 ? send_errno : ECONNRESET, NULL); return; } diff --git a/packages/bun-usockets/src/socket.c b/packages/bun-usockets/src/socket.c index 0296970d28c9..63144c986317 100644 --- a/packages/bun-usockets/src/socket.c +++ b/packages/bun-usockets/src/socket.c @@ -273,8 +273,7 @@ struct us_socket_t *us_internal_socket_close_raw(struct us_socket_t *s, int code * releases context state BoringSSL is still reading on the stack; defer * the close to the SSL driver's epilogue instead, preserving the close * code so a requested reset still resets. */ - s->ssl_pending_detach = 1; - s->ssl_pending_close_code = (unsigned char) code; + us_internal_socket_set_pending(s, US_PENDING_ACTION_DETACH, (unsigned char) code); return s; } if (!us_socket_is_closed(s)) { @@ -460,20 +459,20 @@ static int us_internal_classify_send_error(struct us_socket_t *s) { #endif /* Common tail for the us_socket_*write* helpers after a short write. On POSIX - * a -1 is routed through errno classification so a fatal errno latches - * fatal_send_errno (loop.c closes the socket after the writable dispatch) - * instead of re-arming for a retry that can never succeed. A partial write, - * would-block, transient exhaustion, or a still-bounded unclassified errno - * re-arms for a retry like before. The fatal path still re-arms once: the - * write may have been issued outside the writable dispatch, and loop.c's - * fatal_send_errno check runs there, so one more writable event is what - * delivers the close. It does not spin because the check precedes the retry. */ + * a -1 is routed through errno classification so a fatal errno parks + * US_PENDING_ACTION_CLOSE_RAW (loop.c closes the socket after the writable + * dispatch) instead of re-arming for a retry that can never succeed. A + * partial write, would-block, transient exhaustion, or a still-bounded + * unclassified errno re-arms for a retry like before. The fatal path still + * re-arms once: the write may have been issued outside the writable + * dispatch, and loop.c's pending-action check runs there, so one more + * writable event delivers the close. No spin: the check precedes the retry. */ static void us_internal_on_short_write(struct us_socket_t *s, int written) { #ifndef _WIN32 if (written < 0) { int fatal = us_internal_classify_send_error(s); if (fatal) { - s->fatal_send_errno = (unsigned char)fatal; + us_internal_socket_set_pending(s, US_PENDING_ACTION_CLOSE_RAW, (unsigned char)fatal); us_internal_rearm_writable(s); return; } @@ -528,7 +527,8 @@ struct us_socket_t *us_socket_from_fd(struct us_socket_group_t *group, unsigned s->flags.adopted = 0; s->flags.last_write_failed = 0; s->unclassified_send_failures = 0; - s->fatal_send_errno = 0; + s->pending_action = US_PENDING_ACTION_NONE; + s->pending_code = 0; s->connect_state = NULL; /* We always use nodelay */ diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 48c7630c89ea..57275661ca82 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -3085,7 +3085,7 @@ impl NewSocket { // that arms SO_LINGER{1,0} → RST and drops any data still in the kernel send // buffer, which `destroy()` after `write()` must not do. The SSL layer may // briefly defer this close behind its own ciphertext write spill - // (`ssl_close_after_spill`); that waits only on our fd, not the peer. + // (`US_PENDING_ACTION_CLOSE`); that waits only on our fd, not the peer. socket.close(uws::CloseCode::FastShutdown); this.socket.set(SocketHandler::::DETACHED); let _ = global; From d3dd45e275d056eed9fd30a615d62254103d2750 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:25:00 +0000 Subject: [PATCH 7/8] usockets: keep a nonzero pending_code when the same action is re-latched us_internal_socket_set_pending with action == pending_action overwrote pending_code unconditionally. The SNI-abort path (openssl.c:2267, :2452) re-latches DETACH(0) after a JS callback may already have parked DETACH(RESET) via us_internal_socket_close_raw, which dropped the close code. Equal-action re-latch now only writes the code when the new code is nonzero or no code was parked. Drops the inline workaround at us_internal_ssl_detach that did the same thing manually. Fixes the tls.Server destroySoon test that 32441db5 regressed. --- packages/bun-usockets/src/crypto/openssl.c | 5 ++--- packages/bun-usockets/src/internal/internal.h | 9 ++++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index d3404682d38e..97c7c733c951 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -1352,9 +1352,8 @@ void us_internal_ssl_detach(struct us_socket_t *s) { /* SSL_do_handshake/SSL_read is on the stack (a JS callback run from * inside it destroyed the socket); freeing now would leave BoringSSL * working on freed memory when control returns. The driver frees it - * when the call unwinds; keep any close code already parked. */ - us_internal_socket_set_pending(s, US_PENDING_ACTION_DETACH, - s->pending_action == US_PENDING_ACTION_DETACH ? s->pending_code : 0); + * when the call unwinds. */ + us_internal_socket_set_pending(s, US_PENDING_ACTION_DETACH, 0); return; } SSL_free(s_ssl(s)); diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index 4b8bae2852c7..e42e19bc045e 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -331,12 +331,15 @@ struct us_socket_t { _Static_assert(sizeof(struct us_socket_flags) == 1, "us_socket_flags grew"); #endif -/* Latch a pending action. A higher (or equal) action supersedes; a lower one - * never downgrades a close already parked. */ +/* Latch a pending action. A higher action supersedes; an equal action keeps a + * nonzero code already parked (the SNI-abort path re-latches DETACH(0) after + * the JS callback may have parked DETACH(RESET)); a lower action is ignored. */ static inline void us_internal_socket_set_pending(struct us_socket_t *s, unsigned char action, unsigned char code) { - if (action >= s->pending_action) { + if (action > s->pending_action) { s->pending_action = action; s->pending_code = code; + } else if (action == s->pending_action && (code || !s->pending_code)) { + s->pending_code = code; } } From 9dc972cf382a80d2768c01f63bd2787f100bbab8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:40:16 +0000 Subject: [PATCH 8/8] test: drop stale fatal_send_errno references from test comments The field was folded into pending_action in 32441db5. --- test/js/bun/net/socket-syscall-fault.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/js/bun/net/socket-syscall-fault.test.ts b/test/js/bun/net/socket-syscall-fault.test.ts index 28b8980df4fb..c953195c7881 100644 --- a/test/js/bun/net/socket-syscall-fault.test.ts +++ b/test/js/bun/net/socket-syscall-fault.test.ts @@ -282,7 +282,7 @@ describe.skipIf(skip)("TLS client under injected fatal send errno (us_socket_raw // On main the socket never closed: the spill path re-armed writable every // tick and the test timed out. With the fix, the first writable dispatch - // after the spill latches fatal_send_errno and the socket closes with the + // after the spill latches a pending close and the socket closes with the // send errno. await closed; expect({ @@ -312,7 +312,7 @@ describe.skipIf(skip)("TLS client under injected fatal send errno (us_socket_raw // 50 rounds of [one EPROTOTYPE, then a real send that drains the spill]. // A successful send must reset unclassified_send_failures, so the counter // never reaches the 32-cap and the socket stays open throughout. If the - // reset is missing, round 33's errno latches fatal_send_errno and the + // reset is missing, round 33's errno latches a pending close and the // socket closes with EPROTOTYPE instead. let sawClose = false; pair.sock.on("close", () => (sawClose = true));