Repository navigation
Conversation
A graceful close (code 0) of a TLS socket sends close_notify and reads on for the peer's. A second code-0 close closed the fd at once. When the peer still sends, that close is a reset, and the kernel drops bytes that write() reported. us_internal_ssl_close now returns early for a code-0 close while one already waits, for the kernel to take unsent ciphertext or for the peer's close_notify. The peer's close_notify or FIN, a close with code 1 or 2, us_socket_close_now and the 10 s deadline end the wait. The owners that got "close now" from the second ask now say so: - us_socket_group_close_all ends a waiting close as its deadline does. - Http2Context::closeAll uses us_socket_close_now. - The HTTP/1 idle sweep sends its FIN: shutdown, then close. - Http2Connection::touch leaves the deadline of a waiting close alone, and Http2Context::closeIdle counts a connection once.
The row "TLS end() while the peer is still sending delivers a write that the kernel took at once" fails on the windows 11 aarch64 lane in about 1 of 10 builds of main and passes on retry. This commit repeats that row 60 times on the Windows lanes, with the shape of the ender's write in the failure message. It is reverted before the merge.
Collaborator
Author
|
Updated 5:05 PM PT - Oct 21st, 2026
❌ @robobun, your commit d1dec4b has some failures in 🧪 To try this PR locally: bunx bun-pr 46349That installs a local version of the PR into your bun-46349 --bun |
Collaborator
Author
|
Status: draft. #46349 How it was reproduced:
The commit "test: temporary lane probe" repeats that row 60 times on the Windows lanes. It is reverted once those lanes have run with both parts. |
A graceful close of a TLS socket closed the fd at once when the kernel refused the close_notify. With input of the peer unread, that close is a reset, and the kernel drops what it has not sent. On Windows a write() followed by end() gets there: the kernel takes the write and refuses the next send. ssl_handle_shutdown no longer reports a spilled close_notify as a finished shutdown. The alert stays in the spill like any other record, the loop keeps the poll for writable of a shut-down TLS socket that still holds a spill, and the writable event sends it. That event is not dispatched to an owner whose close waits for the peer: the owner let go, and fetch answers such an event with a reset. A forced close does not wait for the alert. This reverses "close now without it" from #30368, which #44618 kept. The reason was that nothing sent the alert later.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
end()on a TLS socket can close the fd while the peer still sends. That close is a reset: the peer loses bytes thatwrite()reported. CI: "TLS end() while the peer is still sending",["close: ECONNRESET"].packages/bun-usockets/src/crypto/openssl.c:2127), and when it was asked again (:2104).Fix
us_internal_ssl_closereturns early for a code-0 close while one waits. The peer's close_notify or FIN, a forced close or the 10 s deadline ends the wait.test/js/bun/net/socket.test.ts(16 rows fail on main). Other suites: Notes.us_socket_timeoutguard (+11 instructions per HTTP request), a ruling on plain TCP first.Background
us_socket_close(s, 0)) sends it and reads on for the peer's.NewSocket::internal_flush: it leaves the refused close_notify and the sweeps.Downsides
closeIdleConnections()closed in 19 ms, now the deadline does (11.5 s).stop(true)has the costs of Bun.serve: stop(true), an HTTP/2 idle timeout with a stream open and a refused client certificate close a TLS connection at once #45990.sizetext: +669 bytes.Notes
What changes. A graceful close (code 0) of a TLS socket waits for the peer's close_notify. Two verdicts ended that wait early, and both are gone:
return ret == 1 || s->ssl_spilled). usockets: don't defer TLS close when close_notify flush fails #30368 chose "close now without it" and tls: consolidate the open TLS fixes (both engines, node:tls, node:https, WebSocket, SQL) #44618 kept it, because a socket that is shut down lost its poll for writable and nothing sent the alert later. Now the alert stays in the per-socket spill like any other record,loop.ckeeps the poll for writable while a shut-down TLS socket holds a spill, andssl_on_writablesends it. That event is not dispatched to the owner while its close waits: fetch answers a writable event on a socket that it closed with a reset. A close with code 1 or 2 does not wait for the alert.us_internal_ssl_closenow returns for a code-0 close while one waits.Three owners got "close now" from the second ask and now say so.
us_socket_group_close_all_exends a waiting close as the deadline does.Http2Context::closeAllusesus_socket_close_now(the same line as #45990).HttpResponse::closeIfIdlesends its FIN before the close (shutdown(); close();, as the other closes of a finished HTTP/1 connection).Http2Connection::touchleaves the deadline of a waiting close alone, andHttp2Context::closeIdlecounts a connection once.Evidence
end()with nothing read after it: the refused close_notify. On windows 2019 x64 three of the new rows fail in every attempt the same way: the write ends in a drain event,end()follows, and the peer misses the last 131072 bytes.end(), nothing injected: the release canary 1.4.4-canary.1 (9b4284f) resets the peer or cuts its upload in 32 of 3,749 runs, from 4 MiB up (20 of 586 at 4 MiB, 0 of 100 at 1 MiB). Under gdb all 41 early closes of a debug build are the second ask: a refusedsend()leaves the poll for writable armed,end()runs outside a writable event, a data event attaches the JS wrapper again, and the writable event reachesNewSocket::on_writable->internal_flush->us_socket_close(s, 0). None of 1,400 closes had a refused close_notify there.["end", "close: ECONNRESET"], the server has read 49152 of 8388608 bytes). The branch: 0 of 40.Measured on Linux x64, main 2d6d445 against this branch. Objects are compiled with the release flags (x64, -O3) and no LTO.
us_socket_t: no new field and no new bit.openssl.othree functions differ:us_internal_ssl_close(195 -> 228 instructions),ssl_handle_shutdown(60 -> 58) andssl_on_writable(126 -> 130). Two are new:us_internal_ssl_close_waits(39) andssl_close_awaits_peer(21). Inloop.oonly the block behind a failed write inus_internal_dispatch_ready_polldiffers.socket.ois identical (llvm-objdump).us_internal_ssl_close, then 7, 7 and 12 inus_socket_is_closed,us_internal_poll_typeandSSL_get_shutdown, +2 for the spill check, -2 inssl_handle_shutdown). A close with code 1 or 2: +6. A plain TCP close: +0.ssl_on_writable, 21 inssl_close_awaits_peer, 7 and 12 in the two callees). A writable event with no failed write runs nothing new inloop.c.end()): sendto 5, recvfrom 4, epoll_ctl 5, close 2, shutdown 0 on both builds (gdbcatch syscall, slope over 10 and 30 connections).Http2Connection::touch()before a GOAWAY. HTTP/1 request path: +0.us_socket_group_close_all_ex: 82 -> 96 instructions, and one call tous_internal_ssl_close_waitsper TLS socket./proc/net/snmp), with 0 client errors. AConnection: closeresponse on main draws the same RST (20 of 20). Plain HTTP sweep: 0 -> 0.sizetext:openssl.o+282,loop.o+29,context.o+48, the uWS C API unit (src/uws_sys) +310.node-https-checkServerIdentity.test.ts, the test that found the leak behind usockets: don't defer TLS close when close_notify flush fails #30368.Closes of a TLS connection whose client never answers close_notify. The client is node v26.3.0 with
allowHalfOpen. The time is from the last ask to the close of the accepted fd, on debug builds.closeIdleConnections()twicestop()close()close()thencloseIdleConnections()closeIdleConnections()twicestop()thencloseIdleConnections()closeIdleConnections()in an abort listeneridleTimeout: 0stop()stop()thenstop(true)stop(true)On main an HTTP/2 connection closes at once when something asks twice, and after 11.8 s when it is asked once. On this branch it waits for the client or the deadline in both cases. A client that answers close_notify closes at once on both builds.
The code-0 closes of a TLS socket, and what a second one did
NewSocket::internal_flushafterend(): the writable event,flush(), a secondend(),[Symbol.dispose](), the other half of anupgradeTLS()pairus_internal_ssl_on_data: the peer's close_notify while the close waits for the kernelus_socket_group_close_all_ex:stop(true)of a listener, teardownHttp2Context::closeAll:stop(true),free()us_socket_close_nowHttpResponse::closeIfIdle: the HTTP/1 idle sweepHttp2Context::closeIdle,sweep,epilogueAsyncSocket::close()atHttpContext.h:506(a pipelined request while a response is pending) or:577(no route)HTTPClient::failtwice (WebSocket upgrade through an https proxy)The other code-0 callers ask once: fetch (
HTTPContext::close_socket), the node:http2 transport (h2_frame_parser.rs:3056), the closes after a FIN (shutdown(); close();inHttpContext.handHttpResponse.h), and the closes on the peer's FIN.Tests. Linux x64, debug builds of main 2d6d445 and of this branch.
test/js/bun/net/socket.test.ts: 16 rows fail on main, 12 of them with no fault injection. The peer sends until the ender has read after itsend(), so the rows do not depend on buffer sizes, and each row asserts that the peer heard of theend()before the ender closed: a close at the deadline also delivers, 10 s late. Five rows start the close behind unsent ciphertext. The existing fixed-deadline test now asks again every 3 s.stop(true)rows (us_socket_group_close_all_ex), the fetch row (no writable dispatch while a close waits: without it the server reads 81769 of 1048576 bytes, 3 of 3), "in a drain event" (loop.c: without it the close comes at the deadline), "close() ... closes at once" (the spill check inus_internal_ssl_close), and the exit row under LeakSanitizer.test/js/bun/http/serve-http2.test.ts: the count row and the HTTP/1.1stop()row fail on main. Thestop(true)afterstop()row and the abort listener row pass on main and fail withoutcloseAlland thetouch()guard.test/js/bun/http/serve-syscall-fault.test.ts: two sweeps behind a response that the kernel refused. Main delivers 0 of 65536 bytes.test/js/node/http/node-http-server-close-drain.test.ts:https.Server.close(cb)with a silent client.Suites. 91 test files on a debug build with a 30 s test timeout:
test/js/node/tls,test/js/node/http2,test/js/bun/net, theserve*,bun-server,node-http*,node-https*andnode-net*files, fetch over TLS, WebSocket andws, and eight*-syscall-faultfiles. 5,483 tests pass. 12 tests in 6 files fail, and the same 12 fail on a build of main in this container: they need the public network or a non-root user, or time out in a debug build.Not fixed here
us_internal_ssl_shutdown(socket.shutdown(), node:tlsend()) still drops a close_notify that the kernel refuses, and sends a bare FIN.loop.c, the close afteron_end).EPIPE(measured, main and this branch).end()closes at once. In the row's scenario the peer gets 65536 of 1048576 bytes with cleanendandcloseevents, 3 of 3, on main and on this branch. Bun.serve http2: close a connection gracefully with a FIN, not with close(2) #44874 asks where an HTTP/2 close lingers, and Bun.connect: make end()/shutdown() a write-side half-close; fix timeout() docs #35947 (closed with no ruling) did it for Bun sockets. A maintainer ruling on one place for both transports would replace the TLS-only predicate of this PR.Http2Connection::touchno longer does.socket.timeout(0)in a data event afterend(), and an HTTP/1 response that resets its timeout after the close atHttpContext.h:506, still leave the socket open until the peer closes, as on main. A guard inus_socket_timeoutandus_socket_long_timeoutcovers every owner at +11 instructions per HTTP request and +17 per HTTPS request (measured in the self-review, no LTO).Self-review. It asked for two PRs, the repeat-ask part first. The Windows lanes of build 130170 show that the rows of that part need the other part. The PR is not stacked on #45990: the shared line is identical, and
git merge-treereports no conflict.Related open PRs
Http2Context::closeAllline, and two more sites, tous_socket_close_now. Its rows coverstop(true)with nostop()before it, and its Downsides hold for that line here: an answering TLS peer draws 1 RST, and unsent ciphertext is dropped (82 to 213 KB in its measurement).Http2Context.h. With it the HTTP/2 rows of Downsides go away.socket.timeout()above 240 s fires #45614 replacesus_socket_t.timeout. No conflict: this branch does not read the field.