node:tls: report a wrapped socket's connect failure on the TLSSocket - #38122
node:tls: report a wrapped socket's connect failure on the TLSSocket#38122robobun wants to merge 3 commits into
Conversation
tls.connect({ socket }) over a net.Socket that has not connected yet only
waited for the socket's 'connect' before upgrading it. If that connect
failed, the plain socket emitted 'error' and 'close' but the TLSSocket
never emitted anything, so callers waiting on it hung. Node re-emits the
wrapped socket's 'error' on the TLSSocket and destroys the TLSSocket when
the wrapped socket closes; do the same while waiting for 'connect', and
drop the listeners once the upgrade happens.
|
Warning Review limit reached
Next review available in: 26 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
Updated 2:38 PM PT - Aug 13th, 2026
✅ @robobun, your commit 9c8e832106d8afac57a1903601520e1227c9946a passed in 🧪 To try this PR locally: bunx bun-pr 38122That installs a local version of the PR into your bun-38122 --bun |
|
Status: reproduced and fixed, waiting on CI.
|
There was a problem hiding this comment.
LGTM — focused Node compat fix wiring the wrapped socket's error/close to the waiting TLSSocket.
What was reviewed:
- Traced listener lifecycle:
onConnectremoves error+close before upgrading;onCloseremoves connect+error then destroys;onErroris absorbed (no re-emit) once the TLSSocket is already destroyed — no leaked listeners on any path. - Confirmed
_emitTLSErroris onTLSSocket.prototypeand_releaseControl()runs intls.connectbefore these can fire, so the error reaches user listeners. - Checked the four new tests: hermetic (127.0.0.1,
refusedPort()holds a live local port so no listen(0) race), await the actualclose, and cover connect-in-flight / connect-after-wrap / raw.destroy() / tlssock.destroy() variants.
Extended reasoning...
Overview
Adds error and close listeners on the wrapped net.Socket for the wait-for-connect arm of Socket.prototype.connect in src/js/node/net.ts (~22 lines), so a failed underlying connect reaches the TLSSocket instead of leaving it pending or surfacing as an uncaught exception. Four new tests in test/js/node/tls/node-tls-connect.test.ts assert the exact event sequences node v26.3.0 produces.
Security risks
None. This is error/close event forwarding during the pre-handshake window; it does not touch certificate verification, rejectUnauthorized, trust stores, or any handshake logic. The only new call is to the existing _emitTLSError, which routes through node's _tlsError/_controlReleased semantics already in tls.ts.
Level of scrutiny
Moderate — TLS-adjacent networking code, but the change is a small, additive event-listener wiring in a single well-understood branch. The already-connected (upgradeTLSDeferred) and duplex (upgradeDuplexToTLS) arms are untouched; the PR description correctly notes those already have equivalent linkage. I traced each path: on connect the new listeners are removed before the (unchanged) upgrade runs; on close the connect/error listeners are removed and this.destroy() is called (idempotent if already destroyed); onError is guarded by !this.destroyed so a post-destroy connect failure is absorbed but not re-emitted, matching node and the fourth test. The arrow functions correctly bind this to the TLSSocket, and this branch is only reachable when bunTlsSymbol is a function (so _emitTLSError exists on the prototype).
Other factors
- Tests follow the repo's guidance: no sleeps, await the observable
close, wire failure paths explicitly (closed()helper avoidsevents.oncerejecting on the expectederror),await usingdisposes the port holder. TherefusedPort()helper avoids the bind-then-release race by using a held ephemeral local port. - PR description reports all four tests fail on main / with
USE_SYSTEM_BUN=1and pass with the fix over 10 repeated runs, plus a broad set of adjacent tls/net tests remain green. - No CODEOWNERS entry for
src/js/node/net.ts. Matches local style (plain.on/.once/.removeListener, same as neighboring lines 1999–2002).
There was a problem hiding this comment.
Automated review ran and found no bugs. The fix is small and well-tested, but it adds interacting event listeners to the TLS wrap's wait-for-connect state machine in src/js/node/net.ts — a human look at the socket-lifecycle interactions would still be worthwhile.
Checked: listener add/remove pairing (onConnect/onClose each remove the others; .once self-removes), _emitTLSError exists on TLSSocket.prototype and matches node's _controlReleased gating, this.destroy() in onClose is idempotent when the TLSSocket was already destroyed, and .on (not .once) for error matches node's _init. The five new tests cover the variant matrix (connect-in-flight, connect-after-wrap, raw.destroy(), client.destroy(), reconnect-after-failure) and each awaits an actual close rather than sleeping.
Extended reasoning...
Overview
The PR fixes tls.connect({ socket }) when the wrapped net.Socket is still connecting: previously only a connect listener was registered on the underlying connection, so a failed connect (ECONNREFUSED) never reached the TLSSocket — callers waiting on secureConnect/error/close would hang, and the plain socket's error could become an uncaught exception. The fix adds error (routed through _emitTLSError) and close (destroys the TLSSocket) listeners for the waiting period, each removing the others when it fires. This mirrors node's internal/tls/wrap.js _init/_wrapHandle. Two files touched: ~15 net lines in src/js/node/net.ts:2030-2091 and five new tests in test/js/node/tls/node-tls-connect.test.ts.
Security risks
None identified. The change is error-propagation plumbing during the pre-handshake wait; it doesn't touch certificate verification, rejectUnauthorized, or any crypto path. The onError guard (if (!this.destroyed)) prevents emitting on a dead socket rather than suppressing anything security-relevant.
Level of scrutiny
Moderate-to-high. src/js/node/net.ts is core networking infrastructure that underpins TLS/HTTP/HTTPS. The change itself is small and the listener add/remove pairing checks out, but socket-lifecycle state machines in the TLS wrap are subtle — the interaction between onConnect removing onClose, onClose removing onConnect, and the this.destroyed guard in onError deserves a look from someone with deep knowledge of bun's upgrade paths (upgradeTLSDeferred / upgradeDuplexToTLS). Not a mechanical config/typo change.
Other factors
- The PR description is unusually thorough: cites the exact node source lines being mirrored, explains why listeners are scoped to the waiting period (post-upgrade the native handle already reports errors), and documents which existing tests were re-run.
- Test coverage is strong: five cases covering connect-in-flight, connect-after-wrap,
raw.destroy(),client.destroy(), and socket reuse after failure. Tests await realcloseevents (not sleeps), use arefusedPort()helper that holds a live connection's local port to guaranteeECONNREFUSED, and assert exact event sequences includinghadErrorvalues matched against node v26.3.0. - The comment-cop bot's feedback about long comments was addressed in 9c8e832 and both threads are resolved.
- No CODEOWNERS entry covers these paths.
- Verified
_emitTLSErrorexists onTLSSocket.prototype(src/js/node/tls.ts:853) and its semantics (emits_tlsError, thenerrorif_controlReleased) match what the PR description claims.
Problem
tls.connect({ socket })over anet.Socketwhose connect is still in flight (net.connect(...)passed straight in, or anew net.Socket()connected after being wrapped) never reports a failed connect: the plain socket emitserror+close, the TLSSocket emits nothing, and a caller waiting on itssecureConnect/error/closehangs.ECONNREFUSEDis an uncaught exception instead. Same aftertls.connect({ socket }).destroy()while the socket was still connecting.connectlistener also stays armed after the failure: anet.Socketthat is reconnected afterwards (retry logic reusing the socket) is upgraded to TLS by the dead wrap, so a plain server gets a ClientHello and the orphaned TLSSocket reportsERR_SSL_WRONG_VERSION_NUMBER.Socket.prototype.connect(src/js/node/net.ts, around line 2030) only registersconnection.once("connect", ...). Nothing links the two sockets until that upgrade runs, so the connection'serror/closego nowhere. The already-connected arm (upgradeTLSDeferred) and the duplex arm (upgradeDuplexToTLSwiresclose) do not have this gap.raw error ECONNREFUSED,tls error ECONNREFUSED,raw close,tls closefor the repro; bun printed only the tworawlines.Fix
connect, also listen for the connection'serror(re-emitted on the TLSSocket through_emitTLSError) andclose(destroys the TLSSocket).connectremoves both listeners before upgrading;closeremoves theconnectlistener, which is what stops the dead wrap from upgrading a later reconnect._initdoeswrap.on('error', err => this._emitTLSError(err))and_wrapHandledoeswrap.on('close', () => this.destroy())(lib/internal/tls/wrap.js L977 / L740 in v26.3.0). Going through_emitTLSErrorkeeps node's_controlReleasedsemantics, and the resulting events (errorcarrying the connection's own error object, thenclosewithhadError === false) are exactly node's.net.Socketcan be reconnected after it closes, so listeners left behind would act on a TLSSocket that has moved on.erroris ignored once the TLSSocket is already destroyed: bun keeps the connection connecting untilconnectfires aftertls.connect({ socket }).destroy(), and its failure must not surface as anerrorafter the TLSSocket'sclose. The listener still absorbs it, so it is no longer an uncaught exception either.new tls.TLSSocket(socket)and then explicitlyconnect({ socket })ed keeps the stream as its_handle, anddestroy()on such a socket throwshandle.close is not a functionon main today (node:tls: handle destroy() on a TLSSocket wrapping an unconnected stream #35842 fixes that);tls.connect({ socket }), the path this PR is about, has no_handlewhile waiting.describe("tls.connect({ socket }) over a net.Socket whose connect has not completed yet")): connect in flight, connect started after wrapping with listeners on the TLSSocket only,raw.destroy()while connecting,tlsSocket.destroy()while connecting, and reconnecting the socket after the failure. All five fail without the src change (two time out, two die on the unhandledECONNREFUSED, the reconnect one getsERR_SSL_WRONG_VERSION_NUMBER; checked withUSE_SYSTEM_BUN=1for all five and with abun bdbuild of main for the first four), pass with the fix, and stayed green over repeated runs.localhostto::1for the server and127.0.0.1for the client; node behaves the same here).Background
tls.connect({ socket })builds a TLSSocket over a transport the caller owns instead of opening its own TCP connection. In bun the TLS layer is attached inSocket.prototype.connect, in one of three ways: a connectednet.Sockethas its native handle adopted on the spot (upgradeTLSDeferred), a generic duplex / named pipe gets a stream-level TLS engine (upgradeDuplexToTLS), and anet.Socketthat has no handle yet or is still connecting waits for itsconnectevent and then takes one of the first two paths. Only that third, waiting state had no link between the two sockets._emitTLSErroris node's routing for errors that happen on a TLSSocket's underlying transport: it emits_tlsErrorand, once_releaseControl()has handed the socket to the user (whichtls.connect()does right away), emitserror. It does not destroy the socket; in node the socket is destroyed by the transport'sclose, which is why the TLSSocket'sclosecarrieshadError === falsein this flow.Repro and node / bun output
node v26.3.0 and bun with this change:
bun 1.4.0 (and a debug build of main):
The same probe with
raw.destroy()right after wrapping printsraw close false/tls close falseon node and with this change, onlyraw close falsebefore. Withc.destroy()right after wrapping and no listeners onraw, bun before this change exits with an uncaughtECONNREFUSED; node and bun with this change printtls close falseand exit cleanly.