From 40162dc6507c5d967e42d04e86fcf280a4f50606 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:06:31 +0000 Subject: [PATCH 1/5] usockets: count the TLS spill in getBufferedAmount(); drain a large https response after peer FIN us_internal_ssl_write() seals plaintext into 16 KB TLS records and flushes them to the kernel in ~128 KB batches. A partial kernel write parks the remainder of the batch in loop_ssl_data->ssl_spill and returns the full plaintext count as written, so uWS's AsyncSocket::getBufferedAmount() (only AsyncSocketData::buffer) reports 0 while up to one batch of ciphertext is still in userspace. Every shouldCloseConnection() close gate in HttpResponse/HttpContext then fires early; us_internal_ssl_close(code=0) does one best-effort drain and frees the rest. Separately, HttpContext::onOpen only set allow_half_open for non-TLS node:http sockets, so a client FIN on a node:https connection made us_internal_ssl_on_end force-close the socket before the buffered response drained (#35034 fixed the plain-TCP case). - openssl.c/socket.c/libusockets.h: add us_socket_ssl_spill_pending(), the ciphertext bytes already sealed for this socket still waiting on a writable event. - AsyncSocket::getBufferedAmount(): add the spill for SSL sockets so the close-after-drain gates wait for it. - HttpContext::onOpen: set allow_half_open for TLS too. onEnd's existing getBufferedAmount()/onWritable defer and onWritable's close gate are now accurate for both transports. Bun.serve (!IsNodeHttp) TLS half-close stays with #35088; with the spill counted in getBufferedAmount() that PR's onEnd defer is safe to un-gate from !SSL. --- packages/bun-usockets/src/crypto/openssl.c | 10 +++ packages/bun-usockets/src/internal/internal.h | 1 + packages/bun-usockets/src/libusockets.h | 5 ++ packages/bun-usockets/src/socket.c | 7 ++ packages/bun-uws/src/AsyncSocket.h | 12 ++- packages/bun-uws/src/HttpContext.h | 15 ++-- .../node/http/node-http-backpressure.test.ts | 79 +++++++++++++++++++ 7 files changed, 119 insertions(+), 10 deletions(-) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 412381c3bbc3..b8e71ddbd88e 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -2098,6 +2098,16 @@ void *us_internal_ssl_get_native_handle(struct us_socket_t *s) { return s->ssl ? s_ssl(s) : NULL; } +/* Ciphertext bytes already sealed for `s` and counted as written by + * us_internal_ssl_write, still waiting on a writable event to reach the + * kernel. uWS's getBufferedAmount() adds this so its close-after-drain + * gates do not fire while the last batch is still in userspace. */ +unsigned int us_internal_ssl_spill_pending(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) return 0; + return loop_ssl_data->ssl_spill_len - loop_ssl_data->ssl_spill_off; +} + int us_internal_ssl_write(struct us_socket_t *s, const char *data, int length) { if (us_socket_is_closed(s) || us_internal_ssl_is_shut_down(s) || length == 0) return 0; diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index 27148b318f55..b785d3938487 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -224,6 +224,7 @@ int us_internal_ssl_handshake_callback_has_fired(us_socket_r s); int us_internal_ssl_is_shut_down(us_socket_r s); void us_internal_ssl_shutdown(us_socket_r s); int us_internal_ssl_write(us_socket_r s, const char *data, int length); +unsigned int us_internal_ssl_spill_pending(us_socket_r s); void *us_internal_ssl_get_native_handle(us_socket_r s); struct us_bun_verify_error_t us_internal_ssl_verify_error(us_socket_r s); void *us_internal_ssl_sni_userdata(us_socket_r s); diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index 7a7d72016b01..e763eb8c73f6 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -632,6 +632,11 @@ int us_socket_is_closed(us_socket_r s) nonnull_fn_decl; int us_socket_is_tls(us_socket_r s) nonnull_fn_decl; int us_socket_is_ssl_handshake_finished(us_socket_r s) nonnull_fn_decl; int us_socket_ssl_handshake_callback_has_fired(us_socket_r s) nonnull_fn_decl; +/* TLS ciphertext bytes already sealed for this socket and reported as + * written by us_socket_write(), still waiting on a writable event to reach + * the kernel (the loop-wide spill slot owned by this socket). 0 for + * plain-TCP sockets and for TLS sockets with nothing spilled. */ +unsigned int us_socket_ssl_spill_pending(us_socket_r s) nonnull_fn_decl; struct us_socket_t *us_socket_close(us_socket_r s, int code, void *reason) __attribute__((nonnull(1))); diff --git a/packages/bun-usockets/src/socket.c b/packages/bun-usockets/src/socket.c index 63af324915ea..25f798b1a017 100644 --- a/packages/bun-usockets/src/socket.c +++ b/packages/bun-usockets/src/socket.c @@ -157,6 +157,13 @@ int us_socket_ssl_handshake_callback_has_fired(struct us_socket_t *s) { return 1; } +unsigned int us_socket_ssl_spill_pending(struct us_socket_t *s) { + if (s->ssl) { + return us_internal_ssl_spill_pending(s); + } + return 0; +} + int us_connecting_socket_is_closed(struct us_connecting_socket_t *c) { return c->closed; } diff --git a/packages/bun-uws/src/AsyncSocket.h b/packages/bun-uws/src/AsyncSocket.h index b8a3db918195..6a449e77aff4 100644 --- a/packages/bun-uws/src/AsyncSocket.h +++ b/packages/bun-uws/src/AsyncSocket.h @@ -184,8 +184,16 @@ struct AsyncSocket { /* Returns the user space backpressure. */ size_t getBufferedAmount() { /* Unsent bytes; already-written bytes sitting behind the head cursor - * are not backpressure (maxBackpressure checks, drain progress). */ - return getAsyncSocketData()->buffer.length(); + * are not backpressure (maxBackpressure checks, drain progress). + * For TLS, us_socket_write() can report a batch as written while its + * ciphertext still sits in the loop's spill slot (openssl.c + * ssl_flush_write_batch); count that so the close-after-drain gates + * in HttpResponse/HttpContext/WebSocketContext wait for it. */ + size_t buffered = getAsyncSocketData()->buffer.length(); + if constexpr (SSL) { + buffered += us_socket_ssl_spill_pending((us_socket_t *) this); + } + return buffered; } /* Returns the text representation of an IPv4 or IPv6 address */ diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index 0e6f21dc88dc..c8d58401b4c8 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -206,14 +206,13 @@ struct HttpContext { /* A peer FIN must not tear the connection down at the loop level: * onEnd() below decides whether to close right away (idle) or to * keep writing the responses that are still in flight / pipelined - * (Node's socketOnEnd semantics). Without this flag the loop - * force-closes the socket right after dispatching onEnd. TLS - * (openssl.c us_internal_ssl_on_end) does not consult this flag - * and force-closes on FIN regardless, so this half of the compat - * block is http-only for now. */ - if constexpr (!SSL) { - s->flags.allow_half_open = 1; - } + * (Node's socketOnEnd semantics). Without this flag the loop (and + * openssl.c us_internal_ssl_on_end for TLS) force-closes the + * socket right after dispatching onEnd, discarding the buffered + * response bytes. getBufferedAmount() counts the TLS spill, so + * onEnd's defer and onWritable's close gate are accurate for both + * transports. */ + s->flags.allow_half_open = 1; } if(!SSL) { diff --git a/test/js/node/http/node-http-backpressure.test.ts b/test/js/node/http/node-http-backpressure.test.ts index c34c788e6909..3052a02c6e4f 100644 --- a/test/js/node/http/node-http-backpressure.test.ts +++ b/test/js/node/http/node-http-backpressure.test.ts @@ -6,9 +6,13 @@ * A handful of older tests do not run in Node in this file. These tests should be updated to run in Node, or deleted. */ import { once } from "node:events"; +import { readFileSync } from "node:fs"; import http from "node:http"; +import https from "node:https"; import type { AddressInfo } from "node:net"; import net from "node:net"; +import path from "node:path"; +import nodeTls from "node:tls"; describe("backpressure", () => { // Writes `total` bytes to `res` in `chunk`-sized pieces, waiting for "drain" @@ -197,6 +201,81 @@ describe("backpressure", () => { // rejects the second write (socketOnEnd already called socket.end()); Bun // currently accepts and drains it. Both are consistent: the client sees // either the first write only, or both, never a torn second write. + // Same scenario over TLS: the server's TLS write-batch spill (up to one + // 128 KiB ciphertext batch the kernel did not fully accept) must be + // counted as buffered and drained before the post-FIN close gate fires. + // Cycling several times proves the on_writable drain loop, not just the + // first kernel-accepted write. + describe("https", () => { + const keysDir = path.join(import.meta.dirname, "..", "test", "fixtures", "keys"); + const tlsOptions = { + cert: readFileSync(path.join(keysDir, "agent1-cert.pem")), + key: readFileSync(path.join(keysDir, "agent1-key.pem")), + }; + + async function halfCloseTlsRequestBodyBytes( + port: number, + request: string, + halfClose: boolean, + ): Promise<{ body: number; ended: boolean }> { + const socket = nodeTls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false }); + let body = 0; + let head = ""; + let gotHead = false; + let ended = false; + socket.on("data", chunk => { + if (!gotHead) { + head += chunk.toString("latin1"); + const i = head.indexOf("\r\n\r\n"); + if (i >= 0) { + gotHead = true; + body = Buffer.byteLength(head.slice(i + 4), "latin1"); + } + } else { + body += chunk.length; + } + }); + socket.on("end", () => (ended = true)); + socket.on("error", () => {}); + await once(socket, "secureConnect"); + if (halfClose) socket.end(request); + else socket.write(request); + await once(socket, "close"); + return { body, ended }; + } + + it.each([ + ["client half-close, res.write() then res.end()", "write-end", true], + ["client half-close, res.end(payload)", "end", true], + ["client half-close, httpAllowHalfOpen, res.end() after drain", "drain", true], + ] as const)("%s", async (_name, endMode, halfClose) => { + await using server = https.createServer(tlsOptions, (req, res) => { + res.writeHead(200, { "Content-Length": String(BODY) }); + if (endMode === "end") { + res.end(payload); + } else { + res.write(payload); + if (endMode === "write-end") res.end(); + else res.once("drain", () => res.end()); + } + }); + if (endMode === "drain") server.httpAllowHalfOpen = true; + await once(server.listen(0, "127.0.0.1"), "listening"); + const port = (server.address() as AddressInfo).port; + // Loop a few times: the bug is an ordering race between the final + // spilled TLS batch and the close gate, so a single pass on a fast + // loopback may not catch the last-batch truncation. + for (let i = 0; i < 5; i++) { + const { body, ended } = await halfCloseTlsRequestBodyBytes( + port, + "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n", + halfClose, + ); + expect({ body, ended }).toEqual({ body: BODY, ended: true }); + } + }); + }); + it("res.write() from 'drain' after client FIN is not torn mid-write", async () => { await using server = http.createServer((req, res) => { res.writeHead(200, { "Content-Length": String(BODY * 2) }); From f3fa327815e80e682d1e275e392f92827a324755 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:35:11 +0000 Subject: [PATCH 2/5] usockets(win): drain the kernel recv buffer on the eof hint before raw-closing a half-closed socket poll_cb (libuv.c) maps AFD DISCONNECT to the eof hint for a socket whose write side we already shut down. AFD reports DISCONNECT while the tail of the peer's stream is still queued in the kernel, but the Windows branch of loop.c's read loop only did one extra recv (the RST probe) before falling through to the is_shut_down raw-close, discarding the rest. POSIX already drains on eof; do the same on Windows. Also address review: move the https describe after the torn-write it() so the existing comment stays attached to its test, and drop the dead halfClose parameter. --- packages/bun-usockets/src/loop.c | 13 ++++ .../node/http/node-http-backpressure.test.ts | 72 ++++++++----------- 2 files changed, 44 insertions(+), 41 deletions(-) diff --git a/packages/bun-usockets/src/loop.c b/packages/bun-usockets/src/loop.c index 987120f6bda6..441bdad08f69 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -757,6 +757,19 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in } #undef LOOP_ISNT_VERY_BUSY_THRESHOLD #else + /* Windows eof-drain, same as the POSIX branch above: + * poll_cb maps AFD DISCONNECT to the eof hint for a + * socket whose write side we already shut down, and + * AFD reports DISCONNECT with the tail of the peer's + * stream still queued in the kernel. Stopping here + * lets the is_shut_down raw-close below discard it + * (a half-closed TLS/net client dropping the end of + * a large response on Windows only). recv() returning + * 0 or WSAEWOULDBLOCK ends the loop, so this is + * bounded by the kernel receive buffer. */ + if (s && !us_socket_is_closed(s) && !s->flags.is_paused && (eof || error)) { + continue; + } /* Windows AFD_POLL_ABORT is not level-triggered the way * epoll's EPOLLHUP|EPOLLERR are: a peer RST that lands * while this poll_cb is on the stack — typically when an diff --git a/test/js/node/http/node-http-backpressure.test.ts b/test/js/node/http/node-http-backpressure.test.ts index 3052a02c6e4f..197deba5ad66 100644 --- a/test/js/node/http/node-http-backpressure.test.ts +++ b/test/js/node/http/node-http-backpressure.test.ts @@ -201,11 +201,30 @@ describe("backpressure", () => { // rejects the second write (socketOnEnd already called socket.end()); Bun // currently accepts and drains it. Both are consistent: the client sees // either the first write only, or both, never a torn second write. - // Same scenario over TLS: the server's TLS write-batch spill (up to one - // 128 KiB ciphertext batch the kernel did not fully accept) must be - // counted as buffered and drained before the post-FIN close gate fires. - // Cycling several times proves the on_writable drain loop, not just the - // first kernel-accepted write. + it("res.write() from 'drain' after client FIN is not torn mid-write", async () => { + await using server = http.createServer((req, res) => { + res.writeHead(200, { "Content-Length": String(BODY * 2) }); + res.write(payload); + res.once("drain", () => { + res.write(payload); + res.end(); + }); + res.on("error", () => {}); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + const { body, ended } = await halfCloseRequestBodyBytes(server); + expect(ended).toBe(true); + expect([BODY, BODY * 2]).toContain(body); + }); + + // TLS variants of the it.each above: the server's TLS write-batch spill + // (up to one 128 KiB ciphertext batch the kernel did not fully accept) is + // reported as written by us_socket_write() while it sits in userspace, so + // getBufferedAmount() must count it before the post-FIN close gate fires. + // Looped a few times so the on_writable drain cycle is exercised past the + // first kernel-accepted write. This is also the client-side regression + // test for the Windows eof-drain (a half-closed client must read out the + // kernel receive buffer when AFD DISCONNECT is mapped to eof). describe("https", () => { const keysDir = path.join(import.meta.dirname, "..", "test", "fixtures", "keys"); const tlsOptions = { @@ -213,11 +232,7 @@ describe("backpressure", () => { key: readFileSync(path.join(keysDir, "agent1-key.pem")), }; - async function halfCloseTlsRequestBodyBytes( - port: number, - request: string, - halfClose: boolean, - ): Promise<{ body: number; ended: boolean }> { + async function halfCloseTlsRequestBodyBytes(port: number): Promise<{ body: number; ended: boolean }> { const socket = nodeTls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false }); let body = 0; let head = ""; @@ -238,17 +253,16 @@ describe("backpressure", () => { socket.on("end", () => (ended = true)); socket.on("error", () => {}); await once(socket, "secureConnect"); - if (halfClose) socket.end(request); - else socket.write(request); + socket.end("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); await once(socket, "close"); return { body, ended }; } it.each([ - ["client half-close, res.write() then res.end()", "write-end", true], - ["client half-close, res.end(payload)", "end", true], - ["client half-close, httpAllowHalfOpen, res.end() after drain", "drain", true], - ] as const)("%s", async (_name, endMode, halfClose) => { + ["client half-close, res.write() then res.end()", "write-end"], + ["client half-close, res.end(payload)", "end"], + ["client half-close, httpAllowHalfOpen, res.end() after drain", "drain"], + ] as const)("%s", async (_name, endMode) => { await using server = https.createServer(tlsOptions, (req, res) => { res.writeHead(200, { "Content-Length": String(BODY) }); if (endMode === "end") { @@ -262,35 +276,11 @@ describe("backpressure", () => { if (endMode === "drain") server.httpAllowHalfOpen = true; await once(server.listen(0, "127.0.0.1"), "listening"); const port = (server.address() as AddressInfo).port; - // Loop a few times: the bug is an ordering race between the final - // spilled TLS batch and the close gate, so a single pass on a fast - // loopback may not catch the last-batch truncation. for (let i = 0; i < 5; i++) { - const { body, ended } = await halfCloseTlsRequestBodyBytes( - port, - "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n", - halfClose, - ); - expect({ body, ended }).toEqual({ body: BODY, ended: true }); + expect(await halfCloseTlsRequestBodyBytes(port)).toEqual({ body: BODY, ended: true }); } }); }); - - it("res.write() from 'drain' after client FIN is not torn mid-write", async () => { - await using server = http.createServer((req, res) => { - res.writeHead(200, { "Content-Length": String(BODY * 2) }); - res.write(payload); - res.once("drain", () => { - res.write(payload); - res.end(); - }); - res.on("error", () => {}); - }); - await once(server.listen(0, "127.0.0.1"), "listening"); - const { body, ended } = await halfCloseRequestBodyBytes(server); - expect(ended).toBe(true); - expect([BODY, BODY * 2]).toContain(body); - }); }); it("should handle backpressure with INT_MAX bytes", async () => { From fe40534e6337f4d0ae1a391d9b5588f569a24057 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:11:29 +0000 Subject: [PATCH 3/5] uWS: gate the HTTP close-after-drain on hasFullyDrained() instead of getBufferedAmount() getBufferedAmount() stays the plaintext AsyncSocketData::buffer count (WebSocket maxBackpressure and the JS-exposed bufferedAmount keep their existing semantics). The new hasFullyDrained() adds the TLS spill so the HttpResponse/HttpContext close gates wait for it. Also: release a zero-progress spill in us_internal_ssl_on_writable once the peer's readable side has ended, so a FIN-then-RST client cannot wedge the writable dispatch before the close gate is reached. Covered by a new closes-promptly test. --- packages/bun-usockets/src/crypto/openssl.c | 16 +++++++++++- packages/bun-uws/src/AsyncSocket.h | 23 ++++++++++------ packages/bun-uws/src/HttpContext.h | 6 ++--- packages/bun-uws/src/HttpResponse.h | 6 ++--- .../node/http/node-http-backpressure.test.ts | 26 +++++++++++++++++++ 5 files changed, 62 insertions(+), 15 deletions(-) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index b8e71ddbd88e..6e556ee15b98 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -1821,8 +1821,22 @@ struct us_socket_t *us_internal_ssl_on_writable(struct us_socket_t *s) { struct loop_ssl_data *loop_ssl_data = (struct loop_ssl_data *)s->group->loop->data.ssl_data; /* Ciphertext from a partial batch flush goes out before anything else; * while it is pending nothing new may be written for this socket. */ + unsigned int spill_off_before = loop_ssl_data ? loop_ssl_data->ssl_spill_off : 0; if (loop_ssl_data && !ssl_drain_spill(loop_ssl_data, s)) { - return s; + /* A writable event that moves zero spill bytes after the peer's + * readable side has already ended means the peer is gone (send() + * hit EPIPE/ECONNRESET, folded to 0 by us_socket_raw_write) and + * this spill will never drain. Returning here without reaching + * us_dispatch_writable would spin the re-armed writable poll on + * kqueue until idle timeout, since neither EPOLLERR nor the uWS + * layer's zero-progress-after-FIN close guard can fire. Release + * the dead bytes so the dispatch below proceeds and that guard + * closes the connection. */ + if (s->ssl_end_delivered && loop_ssl_data->ssl_spill_off == spill_off_before) { + ssl_release_spill(s->group->loop, s); + } else { + return s; + } } if (s->ssl_shutdown_after_spill) { s->ssl_shutdown_after_spill = 0; diff --git a/packages/bun-uws/src/AsyncSocket.h b/packages/bun-uws/src/AsyncSocket.h index 6a449e77aff4..5e2fafae7eb6 100644 --- a/packages/bun-uws/src/AsyncSocket.h +++ b/packages/bun-uws/src/AsyncSocket.h @@ -184,16 +184,23 @@ struct AsyncSocket { /* Returns the user space backpressure. */ size_t getBufferedAmount() { /* Unsent bytes; already-written bytes sitting behind the head cursor - * are not backpressure (maxBackpressure checks, drain progress). - * For TLS, us_socket_write() can report a batch as written while its - * ciphertext still sits in the loop's spill slot (openssl.c - * ssl_flush_write_batch); count that so the close-after-drain gates - * in HttpResponse/HttpContext/WebSocketContext wait for it. */ - size_t buffered = getAsyncSocketData()->buffer.length(); + * are not backpressure (maxBackpressure checks, drain progress). */ + return getAsyncSocketData()->buffer.length(); + } + + /* Whether every byte handed to us_socket_write() has reached the kernel. + * For TLS, us_socket_write() can report a batch as written while its + * ciphertext still sits in the loop's spill slot (openssl.c + * ssl_flush_write_batch); the close-after-drain gates in HttpResponse / + * HttpContext must wait for that too. Kept separate from + * getBufferedAmount() so WebSocket's maxBackpressure policy and the + * JS-exposed bufferedAmount stay a plaintext count. */ + bool hasFullyDrained() { + if (getAsyncSocketData()->buffer.length()) return false; if constexpr (SSL) { - buffered += us_socket_ssl_spill_pending((us_socket_t *) this); + return us_socket_ssl_spill_pending((us_socket_t *) this) == 0; } - return buffered; + return true; } /* Returns the text representation of an IPv4 or IPv6 address */ diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index c8d58401b4c8..8c7d7a1a8f2d 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -611,7 +611,7 @@ struct HttpContext { /* We need to check if we should close this socket here now */ if (httpResponseData->shouldCloseConnection()) { if ((httpResponseData->state & HttpResponseData::HTTP_RESPONSE_PENDING) == 0) { - if (((AsyncSocket *) s)->getBufferedAmount() == 0) { + if (((AsyncSocket *) s)->hasFullyDrained()) { ((AsyncSocket *) s)->shutdown(); /* We need to force close after sending FIN since we want to hinder * clients from keeping to send their huge data */ @@ -741,7 +741,7 @@ struct HttpContext { responseDone = true; } } - if (responseDone && asyncSocket->getBufferedAmount() == 0) { + if (responseDone && asyncSocket->hasFullyDrained()) { asyncSocket->shutdown(); /* We need to force close after sending FIN since we want to hinder * clients from keeping to send their huge data */ @@ -799,7 +799,7 @@ struct HttpContext { * callback is still draining) must not be discarded by the close() * below; the connection shuts down from the shouldCloseConnection() * gates once they have flushed. */ - bool hasQueuedOutgoing = asyncSocket->getBufferedAmount() > 0 + bool hasQueuedOutgoing = !asyncSocket->hasFullyDrained() || httpResponseData->onWritable != nullptr; bool responseInFlight = httpResponseData->nodeHttpQueuedPipelinedCount > 0 || (httpResponseData->state & HttpResponseData::HTTP_RESPONSE_PENDING); diff --git a/packages/bun-uws/src/HttpResponse.h b/packages/bun-uws/src/HttpResponse.h index 080a200bbf4b..7155dfd60015 100644 --- a/packages/bun-uws/src/HttpResponse.h +++ b/packages/bun-uws/src/HttpResponse.h @@ -191,7 +191,7 @@ struct HttpResponse : public AsyncSocket { if (!Super::isCorked()) { if (httpResponseData->shouldCloseConnection()) { if ((httpResponseData->state & HttpResponseData::HTTP_RESPONSE_PENDING) == 0) { - if (((AsyncSocket *) this)->getBufferedAmount() == 0) { + if (((AsyncSocket *) this)->hasFullyDrained()) { ((AsyncSocket *) this)->shutdown(); /* We need to force close after sending FIN since we want to hinder * clients from keeping to send their huge data */ @@ -257,7 +257,7 @@ struct HttpResponse : public AsyncSocket { if (!Super::isCorked()) { if (httpResponseData->shouldCloseConnection()) { if ((httpResponseData->state & HttpResponseData::HTTP_RESPONSE_PENDING) == 0) { - if (((AsyncSocket *) this)->getBufferedAmount() == 0) { + if (((AsyncSocket *) this)->hasFullyDrained()) { ((AsyncSocket *) this)->shutdown(); /* We need to force close after sending FIN since we want to hinder * clients from keeping to send their huge data */ @@ -861,7 +861,7 @@ struct HttpResponse : public AsyncSocket { HttpResponseData *httpResponseData = getHttpResponseData(); if (httpResponseData->shouldCloseConnection()) { if ((httpResponseData->state & HttpResponseData::HTTP_RESPONSE_PENDING) == 0) { - if (((AsyncSocket *) this)->getBufferedAmount() == 0) { + if (((AsyncSocket *) this)->hasFullyDrained()) { ((AsyncSocket *) this)->shutdown(); /* We need to force close after sending FIN since we want to hinder * clients from keeping to send their huge data */ diff --git a/test/js/node/http/node-http-backpressure.test.ts b/test/js/node/http/node-http-backpressure.test.ts index 197deba5ad66..ec271fcb66d6 100644 --- a/test/js/node/http/node-http-backpressure.test.ts +++ b/test/js/node/http/node-http-backpressure.test.ts @@ -280,6 +280,32 @@ describe("backpressure", () => { expect(await halfCloseTlsRequestBodyBytes(port)).toEqual({ body: BODY, ended: true }); } }); + + // allow_half_open defers the close to the writable drain; a peer that + // FINs then resets must not wedge that drain on a spill send() that + // keeps failing (us_internal_ssl_on_writable releases a zero-progress + // spill after EOF so the dispatch reaches the close gate). A wedge + // would leave the server-side socket open past the test timeout. + it("closes promptly when the client half-closes then resets mid-drain", async () => { + const closed = Promise.withResolvers(); + await using server = https.createServer(tlsOptions, (req, res) => { + req.socket.on("close", () => closed.resolve()); + res.writeHead(200, { "Content-Length": String(BODY) }); + res.end(payload); + res.on("error", () => {}); + }); + server.requestTimeout = 0; + server.headersTimeout = 0; + await once(server.listen(0, "127.0.0.1"), "listening"); + const port = (server.address() as AddressInfo).port; + const sock = nodeTls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false }); + sock.on("error", () => {}); + await once(sock, "secureConnect"); + sock.end("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); + await once(sock, "data"); + sock.destroy(); + await closed.promise; + }); }); }); From 3d1c56be5761e492a54c9d6dfd15de7c0c873b59 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:34:04 +0000 Subject: [PATCH 4/5] docs: reference hasFullyDrained() in the spill comments, not getBufferedAmount() --- packages/bun-usockets/src/crypto/openssl.c | 5 +++-- packages/bun-uws/src/HttpContext.h | 6 +++--- test/js/node/http/node-http-backpressure.test.ts | 10 +++++----- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 6e556ee15b98..6e31e6a4785a 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -2114,8 +2114,9 @@ void *us_internal_ssl_get_native_handle(struct us_socket_t *s) { /* Ciphertext bytes already sealed for `s` and counted as written by * us_internal_ssl_write, still waiting on a writable event to reach the - * kernel. uWS's getBufferedAmount() adds this so its close-after-drain - * gates do not fire while the last batch is still in userspace. */ + * kernel. uWS's AsyncSocket::hasFullyDrained() checks this so the HTTP + * close-after-drain gates do not fire while the last batch is still in + * userspace. */ unsigned int us_internal_ssl_spill_pending(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) return 0; diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index 8c7d7a1a8f2d..a6e243d947e9 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -209,9 +209,9 @@ struct HttpContext { * (Node's socketOnEnd semantics). Without this flag the loop (and * openssl.c us_internal_ssl_on_end for TLS) force-closes the * socket right after dispatching onEnd, discarding the buffered - * response bytes. getBufferedAmount() counts the TLS spill, so - * onEnd's defer and onWritable's close gate are accurate for both - * transports. */ + * response bytes. onEnd's defer and onWritable's close gate use + * hasFullyDrained(), which accounts for the TLS ciphertext spill, + * so they are accurate for both transports. */ s->flags.allow_half_open = 1; } diff --git a/test/js/node/http/node-http-backpressure.test.ts b/test/js/node/http/node-http-backpressure.test.ts index ec271fcb66d6..af2ff4c9a463 100644 --- a/test/js/node/http/node-http-backpressure.test.ts +++ b/test/js/node/http/node-http-backpressure.test.ts @@ -220,11 +220,11 @@ describe("backpressure", () => { // TLS variants of the it.each above: the server's TLS write-batch spill // (up to one 128 KiB ciphertext batch the kernel did not fully accept) is // reported as written by us_socket_write() while it sits in userspace, so - // getBufferedAmount() must count it before the post-FIN close gate fires. - // Looped a few times so the on_writable drain cycle is exercised past the - // first kernel-accepted write. This is also the client-side regression - // test for the Windows eof-drain (a half-closed client must read out the - // kernel receive buffer when AFD DISCONNECT is mapped to eof). + // the post-FIN close gate (hasFullyDrained()) must wait for it. Looped a + // few times so the on_writable drain cycle is exercised past the first + // kernel-accepted write. This is also the client-side regression test for + // the Windows eof-drain (a half-closed client must read out the kernel + // receive buffer when AFD DISCONNECT is mapped to eof). describe("https", () => { const keysDir = path.join(import.meta.dirname, "..", "test", "fixtures", "keys"); const tlsOptions = { From 41c2fbba30631a6de0ecae3d14537a2de37d69bd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:03:21 +0000 Subject: [PATCH 5/5] openssl: mark the SSL fatal on a zero-progress post-EOF spill so the close is O(1) Releasing the spill and falling through re-sealed the whole buffered response 128 KB at a time before the hasFullyDrained() gate fired. Setting ssl_fatal_error makes us_internal_ssl_write return 0, so the uWS flushed==0-after-FIN guard (or hasFullyDrained() when nothing is buffered) closes on the first dispatch. Dispatch directly to bypass the is_shut_down gate that ssl_fatal_error would otherwise trip. --- packages/bun-usockets/src/crypto/openssl.c | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 6e31e6a4785a..21d12c0398e2 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -1826,17 +1826,19 @@ struct us_socket_t *us_internal_ssl_on_writable(struct us_socket_t *s) { /* A writable event that moves zero spill bytes after the peer's * readable side has already ended means the peer is gone (send() * hit EPIPE/ECONNRESET, folded to 0 by us_socket_raw_write) and - * this spill will never drain. Returning here without reaching - * us_dispatch_writable would spin the re-armed writable poll on - * kqueue until idle timeout, since neither EPOLLERR nor the uWS - * layer's zero-progress-after-FIN close guard can fire. Release - * the dead bytes so the dispatch below proceeds and that guard - * closes the connection. */ + * this spill will never drain. Returning here would spin the + * re-armed writable poll on kqueue, since EPOLLERR is not delivered + * there. Mark the SSL fatal so us_internal_ssl_write returns 0 (the + * uWS layer's flushed==0-after-FIN guard, or hasFullyDrained() when + * nothing is buffered, then closes the connection on this dispatch) + * and dispatch directly, bypassing the is_shut_down gate below that + * ssl_fatal_error would otherwise trip. */ if (s->ssl_end_delivered && loop_ssl_data->ssl_spill_off == spill_off_before) { ssl_release_spill(s->group->loop, s); - } else { - return s; + s->ssl_fatal_error = 1; + return us_dispatch_writable(s); } + return s; } if (s->ssl_shutdown_after_spill) { s->ssl_shutdown_after_spill = 0;