Skip to content

node:net: emit 'close' on a server TLS wrap whose handshake fails on the stream-level engine - #38058

Open
robobun wants to merge 4 commits into
mainfrom
farm/464c52af/tls-engine-wrap-close
Open

node:net: emit 'close' on a server TLS wrap whose handshake fails on the stream-level engine#38058
robobun wants to merge 4 commits into
mainfrom
farm/464c52af/tls-engine-wrap-close

Conversation

@robobun

@robobun robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • A server-side TLS socket whose handshake fails emits 'error' (or 'tlsClientError' on a tls.Server) but never 'close', although socket.destroyed becomes true. Node emits 'error' followed by 'close' with hadError === true.
  • It happens whenever the failure is reported while JS is already on the stack. Two ways users hit that:
    • new tls.TLSSocket(conn, { isServer: true }) running on the stream-level TLS engine (taken when conn still has unflushed plain writes, or is a generic Duplex), and the peer then sends non-TLS bytes or disconnects: the engine is fed from a 'data' listener.
    • a tls.Server (or an fd-adopting wrap) whose asynchronous SNICallback rejects the connection, the usual shape of an SNI-routing server turning away unknown hostnames: resumeSNI fails the handshake from inside the user's callback.
  • Cause: the handshake-failure destroy (_closeAfterHandlingError) makes Socket.prototype._destroy (src/js/node/net.ts:2193) defer closeSocketHandle to a microtask so the 'error' listeners can still see the connection, and closeSocketHandle re-read self._handle when 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.rs exit()). 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.
  • The error code reported on the engine path (UNABLE_TO_GET_ISSUER_CERT instead of ERR_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

  • _destroy passes the handle it found (currentHandle) into closeSocketHandle, which closes and reports that handle instead of re-reading _handle. closeAdoptedTLSRawNowNT was the same close-then-report sequence, so the adopted raw-socket branch now calls closeSocketHandle too.
  • Why this is correct: a net.Socket emits 'close' exactly once, from whichever teardown branch its _destroy takes. The native close callback deliberately does not emit it (it only detaches the handle and pushes EOF, relying on _destroy), so once _destroy has run the deferred branch is the only emitter left and must not depend on _handle still being attached. The synchronous branch already tolerates the detach (_destroy re-checks this._handle after closeSocketHandle returns, because handle.close() dispatches the native close re-entrantly); this gives the deferred branch the same tolerance. It is also what Node's closeSocketHandle effectively does: nothing in Node detaches _handle behind the stream's back, so it always closes the handle destroy() found.
  • Closing the captured handle after the native side closed it is a no-op: the close dispatch puts the wrapper into the detached state before calling into JS (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._handle and the behaviour is byte-for-byte the old one.
  • Verified with 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; generic Duplex wrap fed bad bytes; unflushed-write wrap whose peer disconnects; fd-adopting wrap rejected by an asynchronous SNICallback; tls.Server rejected by an asynchronous SNICallback, asserting 'close' on the socket handed to tlsClientError). 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.
  • Also run with the fix: the rest of node-tls-server.test.ts, node-tls-connect.test.ts, node-tls-upgrade.test.ts, the test/js/node/net suites, and 89 vendored Node test-tls-* / test-net-* / test-https-* close, destroy, SNI and handshake-failure tests (including test-tls-socket-close for the adopted raw-socket branch and test-tls-sni-option for asynchronous SNI), all passing. The only failures seen were pre-existing in this container and reproduce without the diff (tests binding localhost while connecting to 127.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 _handle attached until the next microtask so 'error' / 'tlsClientError' listeners can still inspect the connection; closeSocketHandle then closes the handle and emits 'close'. Bun mirrors this.
  • detachSocket: Bun's native close callbacks null socket._handle because the native object is gone. It is the one way _handle can disappear between destroy() and the deferred close, and it never emits 'close' itself.
  • Nested dispatch: Bun drains microtasks when the outermost native-to-JS entry returns. A socket callback invoked straight from the I/O loop gets a microtask checkpoint as soon as it returns; one invoked from inside a JS-initiated native call (the stream engine's listener thunks, 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.
  • Server wrap paths: 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), upgradeDuplexToTLS runs 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)
case                                            Bun before                              Bun after                                  Node v26.3.0
unflushed-write wrap, non-TLS bytes             error                                   error, close:true                          error, close:true
unflushed-write wrap, peer disconnects          end, error(ECONNRESET)                  end, error(ECONNRESET), close:true         end, close:false
generic Duplex wrap, non-TLS bytes              error                                   error, close:true                          error, close:true
fd-adopting wrap, async SNICallback rejects     error                                   error, close:true                          error, close:true
tls.Server, async SNICallback rejects           tlsClientError                          tlsClientError, close:true                 tlsClientError, close:true
fd-adopting wrap, non-TLS bytes                 error, close:true                       unchanged                                  error, close:true

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 its hadError agrees 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:

[net] Socket.prototype._destroy          <- ServerHandlers.handshake(false): destroy(err), close deferred to a microtask
[net] Bun.Server close                   <- native close callback, detachSocket() nulls _handle
[events] TLSSocket.emit error
[net] closeSocketHandle true true false  <- _handle already null: nothing closed, no 'close'

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 SNICallback cases also reach on the plain fd path. The code change is the same; the description, comments and test matrix were widened to match.

…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.
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 14 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8e2066f9-8bd2-4700-9efa-1fed2e112bcf

📥 Commits

Reviewing files that changed from the base of the PR and between 04148c8 and 14f056c.

📒 Files selected for processing (2)
  • src/js/node/net.ts
  • test/js/node/tls/node-tls-server.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:56 AM PT - Aug 13th, 2026

@robobun, your commit 14f056c is building: #94711

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

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 'error' / 'tlsClientError' but never 'close'. Triggers used: new tls.TLSSocket(conn, { isServer: true }) over a corked connection or a generic Duplex that is fed non-TLS bytes (or whose peer disconnects), and a tls.Server or fd-adopting wrap whose asynchronous SNICallback rejects. Node v26.3.0 emits 'close' with hadError === true in every case.

Five of the six cases in test/js/node/tls/node-tls-server.test.ts ("server-side handshake failure emits 'close'") wait forever for 'close' without the src/js/node/net.ts change and pass with it; the sixth (a failure dispatched straight from the event loop) passes both ways and pins the unchanged ordering.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 closeSocketHandle call sites are inside if (this._handle) with currentHandle captured immediately after, so the removed null guard cannot be reached with a null handle.
  • Checked the synchronous branch is behavior-identical (currentHandle === this._handle at that point) and closeAdoptedTLSRawNT is a separate path that doesn't call closeSocketHandle.
  • Looked for double-'close' risk — _destroy runs once per socket and detachSocket only nulls _handle without 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 closeSocketHandle callers (net.ts:2205, 2212), both inside the if (this._handle) block with currentHandle = this._handle captured 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 in finally, use port: 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.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

For the one point left open in the review, the "second close() is a no-op" claim, here is the native code it rests on (all on main, none of it changed by this PR):

  • The native close dispatch puts the wrapper into the detached state before it calls into JS: src/runtime/socket/socket_body.rs:2057 (this.socket.set(DETACHED) in on_close; the no-handlers early return at :2043 does the same). This is what has already happened when the deferred closeSocketHandle runs on the engine path.
  • _handle.close() is the close host function at socket_body.rs:3101. It calls socket.close(FastShutdown) (:3132), which on a detached wrapper is the detached => {} arm of NewSocketHandler::close in src/uws_sys/socket.rs:343; it then sets the already-detached state again and unrefs the loop via KeepAlive::unref (src/io/keep_alive.rs:43), which returns early unless the ref is still active. The is_semi_connect deref at :3122 only triggers for a live connected us_socket_t, and InternalSocket::get() returns None for the detached and duplex arms.

The new engine-path tests exercise exactly this sequence (native close first, then the deferred close() on the captured handle) under the ASAN debug build, which is also where a double release would show up.

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.
Comment thread src/js/node/net.ts Outdated
Comment thread src/js/node/net.ts Outdated
Comment thread src/js/node/net.ts Outdated
Comment thread src/js/node/net.ts
Comment thread src/js/node/net.ts Outdated
Comment thread src/js/node/net.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 closeSocketHandle callers pass a provably non-null handle (captured inside the if (this._handle) guard), so dropping the null check is safe.
  • closeAdoptedTLSRawNT now delegates to closeSocketHandle with isCleanupPending defaulting to false — behavior-identical to the deleted closeAdoptedTLSRawNowNT, and emitCloseNT remains live (used at net.ts:2218).
  • Argument reordering of closeAdoptedTLSRawNT is 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 closeSocketHandle call sites and the one closeAdoptedTLSRawNT call site were updated consistently; the argument reorder of closeAdoptedTLSRawNT matches at both ends. No stale callers.
  • Removing the if (handle) guard is justified: every caller passes currentHandle, captured inside if (this._handle).
  • closeAdoptedTLSRawNowNT was 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 in finally. 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.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up after self-review (c19943c, 26773c6, 14f056c; the net.ts logic is unchanged from the first revision):

  • The lost 'close' is not specific to the stream-level engine. The condition is a handshake failure dispatched while JS is already on the stack, and an asynchronous SNICallback rejection reaches it on the plain fd path too (resumeSNI fails the handshake from inside the user's callback, and the native close follows before the microtask). Verified on the released build: a tls.Server and an fd-adopting wrap with an async SNICallback that rejects both emit 'tlsClientError' / 'error' and never 'close'; with this change both emit 'close' with hadError === true, as Node does. The PR description, the comment at the _destroy branch and the test matrix now describe that condition; two cases covering the fd path were added (six total, five failing without the fix).
  • closeAdoptedTLSRawNowNT was the same close-then-report sequence as closeSocketHandle once the latter takes the handle, so the adopted raw-socket branch now calls closeSocketHandle directly (test-tls-socket-close still passes).
  • The comment-bot threads were addressed by trimming the added comments; the three-line note at the _destroy branch is kept deliberately, see the thread replies.

The CodeRabbit comments above are its rate-limit notices; there is no review content in them to act on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant