node:tls: let connect() re-dial a TLSSocket whose connection is still open - #39008
node:tls: let connect() re-dial a TLSSocket whose connection is still open#39008robobun wants to merge 1 commit into
Conversation
… open
TLSSocket.prototype[buntls] returned `socket: this._handle` so that
`new TLSSocket(stream).connect()` wraps the stream parked in _handle.
Once a socket is connected, _handle is the native handle of the current
connection, and a second connect() fed that handle to the wrap path,
which rejected it with "socket must be an instance of net.Socket or
Duplex". Only forward _handle when it is a Duplex; a tls.connect(port)
socket then takes the same handle-reuse path as net.Socket and re-dials.
A TLSSocket running over a transport the caller supplied
(tls.connect({ socket }), new TLSSocket(stream)) has no connection of its
own to re-dial, and its handle cannot go through the native reuse path,
so connect() on it now destroys the socket with an EISCONN connect error,
the way node reports connect() on an already connected socket.
|
Warning Review limit reached
Next review available in: 10 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 (3)
Comment |
|
Status: reproduced and fixed, waiting on CI. Reproduced on bun 1.4.0 and a debug build of main with a With this branch the |
|
Re the |
Problem
socket.connect(port, host)on a socket returned bytls.connect()throws synchronously while the previous connection is still open (still connected, or half-closed afterend()):TypeError: socket must be an instance of net.Socket or Duplex(src/js/node/net.ts:1982 on main). Once the socket has been destroyed the same call reconnects fine, and a plainnet.Socketre-dials in this situation (node:net: handle socket.connect() on a socket that still has a live native handle #32739).TLSSocket.prototype[buntls]returnssocket: this._handle(src/js/node/tls.ts:1121) so thatnew TLSSocket(stream).connect()can wrap the stream the constructor parked in_handle. After a connect,_handleis the native handle of the current connection;Socket.prototype.connecttakes it as the stream to wrap (net.ts:1969) and thenet.Socket/Duplexcheck rejects it.connect()on a TLSSocket built over a caller-supplied stream (tls.connect({ socket: duplex }),new TLSSocket(netSocket).connect(port));tls.connect({ socket: netSocket })instead emitted a bareError: Invalid socketfrom trying to adopt the same net.Socket a second time.Fix
[buntls]only forwards_handleas the stream to wrap when it is aDuplex. Everything the wrap path accepts is a Duplex (net.Socket included), sonew TLSSocket(stream).connect()is unchanged; a native handle is simply not offered any more.tls.connect(port)socket therefore falls through to the handle-reuse pathnet.Socketalready uses (net.ts:2135,doConnect(this._handle, ...)): the native side closes the previous connection (detach_for_reconnect, src/runtime/socket/socket_body.rs:1299) and dials again on the same wrapper, andconnect()has already reset the handshake state (net.ts:1986), so the socket emitssecureConnect(and runs the connect listener) for the new connection. This is the same code that serves TCP since node:net: handle socket.connect() on a socket that still has a live native handle #32739 and that TLS reconnect-after-destroy already goes through; only the misrouting in front of it was TLS specific.this[kupgraded]set and the handle still live) that is asked toconnect()without a newsocketoption is destroyed on the next tick withExceptionWithHostPort(UV_EISCONN, "connect", host, port)instead of falling through.detach_for_reconnectreturns without detaching (no ext slot), which tripsconnect_finish'sdebug_assert!(prev.socket.get().is_detached())(src/runtime/socket/Listener.rs:1557) and in release would alias two connections onto one wrapper; for an adopted-fd pair it would close the TLS half without the raw twin ever seeing the close (socket_body.rs:2064). Before this PR those sockets never reached that code because of the TypeError; without the guard they would.connect()on a socket that is already connected (async'error'withcode: "EISCONN",syscall: "connect", then'close'withhadError), and the object is built the wayinternalConnectbuilds its connect errors (net.ts:3161), so the three constructions now fail the same way. An explicitconnect({ socket })on such a socket keeps its current behavior.end()then reconnect: the reconnect itself works with this PR (secureConnect, data from the new connection is readable); the first write on it still fails withERR_STREAM_WRITE_AFTER_ENDbecause the writable side was finished. That reset is node:net: reset the stream state when connect() reuses a half-closed socket #38980's (net-level) change; with its net.ts hunk applied on top of this branch the write goes through as well.describe("connect() on a TLSSocket whose previous connection is still open"): re-dial to a second server with the new arguments (greeting from the second server, echo over the new connection, connect listener), re-dial afterend()against anallowHalfOpenserver, and EISCONN fortls.connect({ socket: net.Socket }),tls.connect({ socket: Duplex })andnew TLSSocket(net.Socket).connect(port). Without the src change four of them throw the TypeError above and the net.Socket one getsInvalid socket; with it all five pass.::1environment ones and fail identically without this change); and 51 ported node tests (test-tls-connect-, test-tls-socket-, test-tls-wrap-econnreset-, test-tls-starttls-server, test-tls-delayed-attach, test-https-agent-*, ...), all passing.Background
[buntls](Symbol.for("::buntls::")) is the method net.ts's sharedSocket.prototype.connectcalls on a TLSSocket to get the TLS options for an attempt. Itssocketfield is a private channel meaning "run the handshake over this stream instead of dialing"; it exists fornew TLSSocket(stream), whose constructor stores the stream in_handleuntilconnect()wraps it._handleis otherwise the native socket wrapper the JS socket owns.doConnect(handle, opts)passes it as the previous socket:connect_finishcloses whatever connection it still holds and connects the same wrapper again. So in bunconnect()on a live socket replaces the connection, where node re-runsuv_tcp_connecton the existing fd and gets EISCONN (on Linux the first retry reports a spurious'connect'instead, see details).upgradeTLS; the raw half stays attached to the caller's net.Socket as the TLS half's twin), or a stream-engine handle (upgradeDuplexToTLS) that moves bytes through the Duplex. Every such path stores the transport inkupgraded, which net.ts already consults inopen(),pause()and_destroyto treat these sockets differently.Node v26.3.0 behavior, related open PRs, and observations left as is
Node, same scripts:
tls.connect(port)socket,connect()afterend():'error'connect EISCONN 127.0.0.1:port - Local (...), then'close'withhadError = true.connect()while fully open: on Linux the kernel reports the completion of the earlier non-blocking connect once more, so node emits a second'connect'on the same connection (same local port, the server sees one connection); a furtherconnect()gets EISCONN. No new connection in either case.tls.connect({ socket: netSocket })thenconnect():ERR_INTERNAL_ASSERTIONinafterConnect(Linux quirk above).tls.connect({ socket: duplex })thenconnect(): nothing happens, the socket staysconnecting.Related open PRs:
socket:line this PR changes; whichever lands second has a one-line conflict. It does not cover reconnects, and withtls.socketgone it would route the transport-backed cases into the reuse path, which is what the net.ts guard here prevents. If it lands first, thenew TLSSocket(net.Socket).connect(port)case in the new tests becomes a reconnect of an already-wrapped socket (nosecureConnectfor a standalone wrap) and needs to be rewritten in those terms.end()half, see above). node:net: reject connect() while a connect is already in flight with EALREADY #32812 adds EALREADY inkConnectDispatch; with this PR a TLS socket reconnected mid-connect reaches that check too. No hunk overlap with either.connect()registeronConnectEndonce. Today every connect registers it twice and the handshake removes one, so a socket re-dialed in a loop keeps one extra inert'end'listener per connect and hitsMaxListenersExceededWarningon the 10th re-dial (pre-existing; before this PR the re-dial threw instead). With node:tls: attach the onConnectEnd 'end' listener once per connect() #38007's net.ts change applied on top of this branch the loop holds exactly one copy at eachsecureConnectand no warning; it applies without touching this PR's hunks and the tests here still pass, so that fix stays in node:tls: attach the onConnectEnd 'end' listener once per connect() #38007.Left as is:
connect()ed as a client (it takes the reuse path), which is what a server-acceptednet.Socketalready does.tls.connect({ socket })socket destroyed and thenconnect(port): unchanged, it re-enters the wrap path for the dead transport and never connects (node makes a plain TCP connection there). Not reachable through anything this PR changes.