Repository navigation
Conversation
…uplex, named pipe, proxy tunnel)
SSLWrapper::update_handshake_state took the SSL_ERROR_ZERO_RETURN branch
for a first handshake and ran no callback. The owner got no handshake
report and no close, so the connection stayed open for as long as the
peer held the stream: tls.connect({ socket: duplex }) emitted nothing,
and fetch and WebSocket through a CONNECT proxy waited for their timers.
The branch now reports the handshake as failed, with the ECONNRESET
report that openssl.c uses for a peer that leaves mid-handshake, and
then closes. node:tls over a Duplex emits 'end', 'error' ECONNRESET and
'close', as Node does. The http2 upgrade of an injected socket maps the
report to "socket hang up", like tls.Server.
Collaborator
Author
|
Updated 11:59 PM PT - Oct 2nd, 2026
✅ @robobun, your commit daebdb13def226338d6881a703f91802e620eab3 passed in 🧪 To try this PR locally: bunx bun-pr 44516That installs a local version of the PR into your bun-44516 --bun |
Collaborator
Author
|
Status: reproduced with the script in the Notes of the description. A client runs TLS over a |
Collaborator
|
Superseded by #44618, which consolidates the open TLS pull requests. This fix and its tests are in there, either as written, rewritten smaller, or merged with the other PRs that patched the same cause (see the "By area" list in that PR). Closing in favor of it. |
Jarred-Sumner
added a commit
that referenced
this pull request
Oct 6, 2026
…#44516, #44517) A peer that answers the ClientHello with close_notify and keeps the stream open: - SSLWrapper (Duplex, named pipe, proxy tunnel) ran no callback at all for SSL_ERROR_ZERO_RETURN in a first handshake, so nothing was ever reported; - openssl.c drives the handshake from SSL_read, which turns SSL_do_handshake() == 0 into SSL_R_SSL_HANDSHAKE_FAILURE, so the fd engine reported ERR_SSL_SSL_HANDSHAKE_FAILURE where Node reports ECONNRESET. Both now give the report of a FIN mid-handshake, (0, ECONNRESET), then close. The fetch tunnel maps that report like a direct connection does.
Jarred-Sumner
added a commit
that referenced
this pull request
Oct 7, 2026
…#44516, #44517) A peer that answers the ClientHello with close_notify and keeps the stream open: - SSLWrapper (Duplex, named pipe, proxy tunnel) ran no callback at all for SSL_ERROR_ZERO_RETURN in a first handshake, so nothing was ever reported; - openssl.c drives the handshake from SSL_read, which turns SSL_do_handshake() == 0 into SSL_R_SSL_HANDSHAKE_FAILURE, so the fd engine reported ERR_SSL_SSL_HANDSHAKE_FAILURE where Node reports ECONNRESET. Both now give the report of a FIN mid-handshake, (0, ECONNRESET), then close. The fetch tunnel maps that report like a direct connection does.
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.
Draft until its self-review returns.
Problem
close_notifyalert and keeps the stream open.tls.connect({ socket: duplex })emits no event and stays writable. Proxiedfetchandwsswait for their timers.SSL_ERROR_ZERO_RETURNbranch ofSSLWrapper::update_handshake_state(src/uws/lib.rs:1061) runs no callback for a first handshake.Fix
ECONNRESETreport ofopenssl.c, then closes.end,error ECONNRESET,close, as Node does. Proxiedfetchandwssfail withEPROTOand 1015, as direct connections do.test/js/node/tls/node-tls-duplex-end-verify.test.tsand four other files. 14 fail on main. Node v26.3.0 passes the 9 client cases.Background
openssl.cdrives sockets with a file descriptor.SSLWrapperdrives TLS over a Duplex, a named pipe or a proxy tunnel, and reports throughon_handshakeandon_close.close_notifyis the alert that ends a peer's write side. BoringSSL returnsSSL_ERROR_ZERO_RETURNfor it.fetchwould reportConnectionClosedand a server wrap nothing.Downsides
ERR_SOCKET_CLOSEDforclose_notifythen FIN. Main givesECONNRESET. node:net: keep 'end' off a destroyed socket and report the read error behind a write in flight #43392 changes that order.error ECONNRESETforclose_notifythen FIN. Main emitsend..text+256 B,.rodata+0 B. Per TLS record: 31 instructions before and after, 0 differing.Notes
Repro (Node v26.3.0 prints
end, error ECONNRESET, close; Bun on main prints nothing and the socket stays open)What changes, per entry point (debug builds, main against this change, peer sends the alert and keeps the stream open)
tls.connect({ socket: duplex }),rejectUnauthorizedtrue or falseend,error ECONNRESET,close(true)write(), readable beforetls.connect(), or in two readsend()on the client, then the alertsocketis aTLSSocket)fetchthrough a CONNECT proxyEPROTOWebSocket(wss) through a CONNECT proxyTLS handshake failed, close 1015new TLSSocket(duplex, { isServer: true })error ECONNRESET"socket hang up"ERR_SSL_UNEXPECTED_MESSAGEbefore a ClientHello,endafter onetls.Serverand http2 secure server, injected connectiontlsClientError ECONNRESET"socket hang up"tlsClientError ERR_SSL_UNEXPECTED_MESSAGEThe server rows cannot equal Node. BoringSSL reads a
close_notifyahead of the ClientHello as the close of the peer. OpenSSL refuses it as an unexpected message.Measurements (release builds of base
4b02e1031dand of this change, unless a line says debug)update_handshake_state(llvm-objdump -lonbun-profile). TheHTTPClientandUpgradedDuplexinstances are one body after identical code folding.traffic_pass(80 instructions) andhandle_traffic(33) do not differ. The new branch ran 0 times in 1,000 echoed records and in one proxiedfetch(gdb breakpoint, debug build), and 1 time in the repro..text, +0 bytes.rodata(llvm-size). Strippedbun: 80,848,456 bytes before and after.update_handshake_state+62 bytes andtrigger_handshake_callback+38 bytes per copy.SSLWrapperInner<T>: 224 -> 224 bytes.node:net: +0 bytes. http2 upgrade module: +213 bytes.on_handshakeand 1on_closeat each of 5 entry points (main: 0 and 0). Scoped debug logs, 3 runs each.TLSSocket::on_handshake(gdb, debug build, libcmalloc): 2 for this report, the stored code and reason. A clean handshake: 162 on main, 162 with this change.https.requestover a supplied Duplex (it queues its headers), and an untrusted chain withrejectUnauthorized(the existing inline reject).A write queued before the handshake
write cb ERR_SOCKET_CLOSED,error ERR_SOCKET_CLOSED,close(true)end,error ECONNRESET,write cb ECANCELED,close(true)write cb ERR_SOCKET_CLOSED,end,error ECONNRESET,close(true)write cb ERR_SOCKET_CLOSED,error ERR_SOCKET_CLOSED,close(true)write cb ERR_SOCKET_CLOSED,end,error ECONNRESET,close(true)The
ERR_SOCKET_CLOSEDform is what a TCP socket gives on main for a FIN with a queued write. It comes from the close handler innet.ts, which fails the queued write before'end'reaches the listener that reportsECONNRESET. Issue #43381 tracks it. #43392 and #43250 change that handler, so this change leaves it alone.Not in this change
ERR_SSL_SSL_HANDSHAKE_FAILUREfor the same alert, where Node reportsECONNRESET. Issue node:tls: a close_notify alert during the handshake on a TCP socket reports ERR_SSL_SSL_HANDSHAKE_FAILURE, Node reports ECONNRESET #44517 tracks it.'close'after a failed handshake, for any failure, also on main. node:net: emit 'close' on a server TLS wrap whose handshake fails on the stream-level engine #38058 has that change.node-tls-namedpipes.test.ts, 2 cases) are not run locally. The named pipe owner uses the same generic code, andcargo check --target x86_64-pc-windows-msvcpasses.Open pull requests that touch the same function
cargo check --workspacefor linux-x64 and windows-x64. Two conflicts:_http2_upgrade.ts(take the line of node:tls: report the fatal TLS alert when a handshake over a Duplex fails #32929, itstlsHandshakeErroralready mapsECONNRESETto "socket hang up") andnode-http2-upgrade.test.mts(keep both blocks).HandshakeOutcomeand a constructor tous_bun_verify_error_t. Keep both.Test runs
node-tls-duplex-end-verify.test.ts(9,node:test, also run on Node),node-tls-connect.test.ts(3, server wraps),node-http2-upgrade.test.mts(1),proxy.test.ts(1),websocket-proxy.test.ts(1),node-tls-namedpipes.test.ts(2, Windows only).faac63e6d4: the five Linux files pass in full (40, 142, 16, 98 and 44 tests),test/js/node/tls/passes (499 tests), and 244 of 245 vendoredtest-tls-*andtest-https-*scripts exit 0. The other one needsbun testand fails the same way on main. The rebase onto the current main changed none of the four source files.bun run rust:check-all x86_64-pc-windows-msvc aarch64-apple-darwinpasses.