node:net: emit 'close' on a server TLS wrap whose handshake fails on the stream-level engine - #38058
node:net: emit 'close' on a server TLS wrap whose handshake fails on the stream-level engine#38058robobun wants to merge 4 commits into
Conversation
…n the meantime Socket.prototype._destroy defers the handle close to a microtask when _closeAfterHandlingError is set (server-side TLS handshake failures), and closeSocketHandle re-read this._handle when that microtask ran. When the server-side TLS engine runs over a stream (upgradeDuplexToTLS: a wrapped connection with unflushed writes, or a generic Duplex), the engine dispatches its close callback right after the failed handshake, inside the same stream event, and that callback detaches _handle before the microtask runs. The deferred close then found no handle and the wrap never emitted 'close'. Capture the handle in _destroy and pass it to closeSocketHandle, so the deferred close tears down and reports the handle destroy() found regardless of what detached it since. Closing a handle that already closed natively is a no-op.
|
Warning Review limit reached
Next review available in: 14 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 |
|
Status: fix and tests pushed (latest 14f056c), waiting on CI. Reproduced on the released build (1.4.0-canary.1) and on a debug build of main: a server-side TLS socket whose handshake is failed while JS is on the stack emits Five of the six cases in |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. The change is small and the analysis is thorough, but it modifies Socket.prototype._destroy — the teardown path every net/tls socket goes through — and removes the null guard in closeSocketHandle, so a maintainer look would still be worthwhile.
What was reviewed:
- Confirmed both
closeSocketHandlecall sites are insideif (this._handle)withcurrentHandlecaptured immediately after, so the removed null guard cannot be reached with a null handle. - Checked the synchronous branch is behavior-identical (
currentHandle === this._handleat that point) andcloseAdoptedTLSRawNTis a separate path that doesn't callcloseSocketHandle. - Looked for double-
'close'risk —_destroyruns once per socket anddetachSocketonly nulls_handlewithout emitting, so the deferred emit remains the sole emitter.
Extended reasoning...
Overview
This PR fixes a missing 'close' event on server-side TLSSocket wraps that use the stream-level TLS engine (i.e., when the underlying connection has unflushed writes or is a generic Duplex) and the handshake fails. The fix is in src/js/node/net.ts: _destroy now passes the already-captured currentHandle into closeSocketHandle instead of having closeSocketHandle re-read self._handle, which may have been nulled by detachSocket before the deferred microtask runs. Four new tests in test/js/node/tls/node-tls-server.test.ts cover the fd-adopting wrap, unflushed-write wrap, generic Duplex wrap, and mid-handshake disconnect.
Security risks
None identified. This is lifecycle/event-emission ordering; no user input parsing, no auth/crypto/permission logic.
Level of scrutiny
High. Socket.prototype._destroy (net.ts:2177-2225) runs for every net.Socket teardown across net, tls, http, and https. The change removes a defensive if (handle) guard in closeSocketHandle and relies on the invariant that both callers pass a non-null captured handle, and that handle.close() on an already-natively-closed handle is a no-op. The PR description argues both convincingly and reports running the full net/tls suites plus 109 vendored Node tests, but socket lifecycle in this file is one of the most historically delicate areas of the codebase.
Other factors
- Verified there are exactly two
closeSocketHandlecallers (net.ts:2205, 2212), both inside theif (this._handle)block withcurrentHandle = this._handlecaptured on line 2184, so the removed null guard is provably unreachable with null. - The synchronous call site (line 2212) is behavior-identical: nothing between capture and call can mutate
_handle. - The tests await the actual
'close'event (not sleeps), wire failure paths to reject, clean up infinally, useport: 0, and cover the variant matrix (adopting/unflushed/Duplex/disconnect) — they meet the repo's test-quality bar. - The only claim I can't verify statically is that
handle.close()on a wrapper whose native side already dispatched close is a safe no-op; the PR description states it is and cites the detached-state check, but that depends on native handle behavior a maintainer should confirm.
|
For the one point left open in the review, the "second
The new engine-path tests exercise exactly this sequence (native close first, then the deferred |
The lost 'close' is not specific to the stream-level TLS engine: the native close callback detaches _handle ahead of the deferred close whenever the failed handshake is dispatched with JS already on the stack, which an asynchronous SNICallback rejection (resumeSNI runs from the user's callback) does on a plain tls.Server and on an fd-adopting wrap as well. Describe that condition at the _destroy branch and add both fd-backed cases to the test matrix. closeAdoptedTLSRawNowNT was the same close-then-report sequence as closeSocketHandle now that the latter takes the handle; use it directly.
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes the net.Socket destroy/close-emit path — lifecycle code that every TCP/TLS connection goes through, with correctness resting on the native-side "close() on a detached handle is a no-op" invariant — a human look would still be worthwhile.
What was reviewed:
- All
closeSocketHandlecallers pass a provably non-null handle (captured inside theif (this._handle)guard), so dropping the null check is safe. closeAdoptedTLSRawNTnow delegates tocloseSocketHandlewithisCleanupPendingdefaulting to false — behavior-identical to the deletedcloseAdoptedTLSRawNowNT, andemitCloseNTremains live (used at net.ts:2218).- Argument reordering of
closeAdoptedTLSRawNTis consistent between call site and definition; no other callers exist.
Extended reasoning...
Overview
The PR changes closeSocketHandle in src/js/node/net.ts to accept the handle as an explicit argument (captured by the caller at destroy time) rather than re-reading self._handle, which can have been nulled by detachSocket between _destroy scheduling the deferred close and the microtask actually running. This fixes server-side TLS wraps on the stream-level engine path never emitting 'close' after a handshake failure. It also folds closeAdoptedTLSRawNowNT into closeSocketHandle (deduplication) and adds six regression tests to test/js/node/tls/node-tls-server.test.ts.
Security risks
None identified. The change is confined to teardown-event ordering; no auth, crypto, input parsing, or trust decisions are touched.
Level of scrutiny
High. Socket.prototype._destroy and closeSocketHandle sit on the teardown path of every net/tls/http connection in Bun. The fix's correctness depends on a native-side invariant (a second close() on a detached socket wrapper is a no-op — the author cited socket_body.rs / uws_sys/socket.rs / keep_alive.rs) and on 'close' being emitted exactly once by whichever _destroy branch runs. That reasoning is well laid out and I found no counterexample, but lifecycle code with cross-layer invariants is exactly where a maintainer's eye is most valuable.
Other factors
- All three
closeSocketHandlecall sites and the onecloseAdoptedTLSRawNTcall site were updated consistently; the argument reorder ofcloseAdoptedTLSRawNTmatches at both ends. No stale callers. - Removing the
if (handle)guard is justified: every caller passescurrentHandle, captured insideif (this._handle). closeAdoptedTLSRawNowNTwas deleted;emitCloseNT(its former dependency) is still referenced by the no-handle branch of_destroy, so no dead code was introduced.- Tests cover the variant matrix (fd-adopting / unflushed-write / Duplex / peer-disconnect / async-SNI on both wrap and
tls.Server), await the actual'close'event, and clean up infinally. The PR description confirms fail-without-fix on the three engine-path cases. - The comment-cop bot's inline feedback about long comments was addressed in follow-up commits and all threads are resolved.
- No CODEOWNERS match the changed files.
|
Follow-up after self-review (c19943c, 26773c6, 14f056c; the net.ts logic is unchanged from the first revision):
The CodeRabbit comments above are its rate-limit notices; there is no review content in them to act on. |
Problem
'error'(or'tlsClientError'on atls.Server) but never'close', althoughsocket.destroyedbecomestrue. Node emits'error'followed by'close'withhadError === true.new tls.TLSSocket(conn, { isServer: true })running on the stream-level TLS engine (taken whenconnstill has unflushed plain writes, or is a genericDuplex), and the peer then sends non-TLS bytes or disconnects: the engine is fed from a'data'listener.tls.Server(or an fd-adopting wrap) whose asynchronousSNICallbackrejects the connection, the usual shape of an SNI-routing server turning away unknown hostnames:resumeSNIfails the handshake from inside the user's callback._closeAfterHandlingError) makesSocket.prototype._destroy(src/js/node/net.ts:2193) defercloseSocketHandleto a microtask so the'error'listeners can still see the connection, andcloseSocketHandlere-readself._handlewhen it finally ran. Bun's native close callback (ServerHandlers.close->detachSocket) nulls_handle, and on the dispatches above it runs synchronously right after the handshake callback, inside the same native call, before any microtask checkpoint (microtasks drain only when the outermost event-loop entry unwinds,src/jsc/event_loop.rsexit()). The deferred close then found no handle and returned without emitting. A failure dispatched straight from the event loop (for example an fd-adopting wrap fed bad bytes) drains the microtask first, which is why that ordering worked.UNABLE_TO_GET_ISSUER_CERTinstead ofERR_SSL_WRONG_VERSION_NUMBER) is a separate bug fixed by node:tls: report the fatal TLS alert when a handshake over a Duplex fails #32929; this PR leaves it alone and the tests do not assert the code.Fix
_destroypasses the handle it found (currentHandle) intocloseSocketHandle, which closes and reports that handle instead of re-reading_handle.closeAdoptedTLSRawNowNTwas the same close-then-report sequence, so the adopted raw-socket branch now callscloseSocketHandletoo.net.Socketemits'close'exactly once, from whichever teardown branch its_destroytakes. The native close callback deliberately does not emit it (it only detaches the handle and pushes EOF, relying on_destroy), so once_destroyhas run the deferred branch is the only emitter left and must not depend on_handlestill being attached. The synchronous branch already tolerates the detach (_destroyre-checksthis._handleaftercloseSocketHandlereturns, becausehandle.close()dispatches the native close re-entrantly); this gives the deferred branch the same tolerance. It is also what Node'scloseSocketHandleeffectively does: nothing in Node detaches_handlebehind the stream's back, so it always closes the handledestroy()found.src/runtime/socket/socket_body.rs:2057),close()on a detached wrapper does nothing (src/uws_sys/socket.rs:343), and the event-loop unref it performs is idempotent (src/io/keep_alive.rs:43). When nothing detached the handle in between,currentHandle === this._handleand the behaviour is byte-for-byte the old one.test/js/node/tls/node-tls-server.test.ts, describe "server-side handshake failure emits 'close'": six cases (fd-adopting wrap fed bad bytes; unflushed-write wrap fed bad bytes; genericDuplexwrap fed bad bytes; unflushed-write wrap whose peer disconnects; fd-adopting wrap rejected by an asynchronousSNICallback;tls.Serverrejected by an asynchronousSNICallback, asserting'close'on the socket handed totlsClientError). Five wait forever for'close'on the unfixed build; all six pass with the fix; the expected sequences were checked against Node v26.3.0 with the probes below.node-tls-server.test.ts,node-tls-connect.test.ts,node-tls-upgrade.test.ts, thetest/js/node/netsuites, and 89 vendored Nodetest-tls-*/test-net-*/test-https-*close, destroy, SNI and handshake-failure tests (includingtest-tls-socket-closefor the adopted raw-socket branch andtest-tls-sni-optionfor asynchronous SNI), all passing. The only failures seen were pre-existing in this container and reproduce without the diff (tests bindinglocalhostwhile connecting to127.0.0.1, and one 60s leak test).Background
_closeAfterHandlingError: Node's server-side handshake failure path destroys the socket with the error but keeps_handleattached until the next microtask so'error'/'tlsClientError'listeners can still inspect the connection;closeSocketHandlethen closes the handle and emits'close'. Bun mirrors this.detachSocket: Bun's native close callbacks nullsocket._handlebecause the native object is gone. It is the one way_handlecan disappear betweendestroy()and the deferred close, and it never emits'close'itself.resumeSNI,_handle.close()) does not get one until the outer JS frame finishes, so anything native does right after that callback, such as dispatching the close, runs before the microtask.new TLSSocket(conn, { isServer: true })normally adopts the connection's fd into a native TLS socket driven by the C TLS code. When the fd cannot be adopted (pending plain bytes must go out first, or there is no fd),upgradeDuplexToTLSruns a BoringSSL engine over the stream instead, fed by the stream's'data'events.Event logs from the probes (events on the server-side socket)
The
'error'on a mid-handshake disconnect is pre-existing behaviour on both wrap flavours and out of scope; that test only asserts that'close'arrives and that itshadErroragrees with whether an'error'was emitted.net.ts debug trace of an unfixed failing case (
BUN_DEBUG_JS=net), showing the native close detaching the handle before the deferred close runs:The first revision of this PR described the problem as specific to the stream-level engine; review of the dispatch path showed the condition is the nested dispatch above, which the asynchronous
SNICallbackcases also reach on the plain fd path. The code change is the same; the description, comments and test matrix were widened to match.