Skip to content

node:tls: handle destroy() on a TLSSocket wrapping an unconnected stream - #35842

Closed
robobun wants to merge 4 commits into
mainfrom
farm/06a5928f/tls-socket-destroy-unconnected
Closed

robobun wants to merge 4 commits into
mainfrom
farm/06a5928f/tls-socket-destroy-unconnected

Conversation

@robobun

@robobun robobun commented Jul 26, 2026 •

Copy link
Copy Markdown
Collaborator

Reproduction

import tls from 'node:tls'; import net from 'node:net';
const s = new tls.TLSSocket(new net.Socket());
s.destroy();
setTimeout(() => { console.log('SURVIVED destroyed=' + s.destroyed); process.exit(0); }, 200);

Node v26.3.0: SURVIVED destroyed=true, exit 0.

Bun on main (44f6469):

TypeError: handle.close is not a function. (In 'handle.close()', 'handle.close' is undefined)
      at closeSocketHandle (node:net:1999:17)
      at _destroy (node:net:1024:24)

The error is uncaught and kills the process. Same for new tls.TLSSocket(new Duplex()).

Cause

A client-side new tls.TLSSocket(duplex) stores the wrapped stream directly as this._handle (tls.ts this._handle = socket); until a connect/upgrade runs there is no native handle. Socket.prototype._destroy sees _handle is truthy and routes it to closeSocketHandle, which calls handle.close(cb) unconditionally. A net.Socket/Duplex has no close method.

Node wraps such a stream in a JSStreamSocket, whose close() destroys the underlying stream, so handle.close() always exists there.

Fix

closeSocketHandle falls back to handle.destroy() when handle.close is not a function (i.e. _handle is a wrapped stream, not a native handle). This both stops the uncaught TypeError and destroys the wrapped stream like Node does: with the fix, the wrapped socket's 'close' fires and raw.destroyed === true, matching Node.

Verification

$ bun bd test test/js/node/tls/node-tls-connect.test.ts -t "before connect"
(pass) new TLSSocket(net.Socket).destroy() before connect > destroys both sockets and emits 'close'
(pass) new TLSSocket(Duplex).destroy() before connect > destroys both sockets and emits 'close'

Both tests throw handle.close is not a function without the fix. Vendored test-tls-on-empty-socket, test-tls-destroy-whilst-write, test-tls-js-stream, test-tls-connect-given-socket still pass.


[stamp-90s] gate passed · iteration 1 · 2 files touched

fails on main (without fix)
ASAN without fix: 2 failed, 18 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/tls/node-tls-connect.test.ts
bun test v1.4.0 (bd462795d)

test/js/node/tls/node-tls-connect.test.ts:
(pass) should have checkServerIdentity [2.33ms]
(pass) should thow ECONNRESET if FIN is received before handshake [407.14ms]
(pass) initializes authorizationError to null in the TLSSocket constructor [17.86ms]
(pass) setMaxSendFragment mirrors OpenSSL's [512, 16384] acceptance without throwing [144.74ms]
(pass) should be able to grab the JSStreamSocket constructor [33.04ms]
TypeError: handle.close is not a function. (In 'handle.close(onSocketHandleClosed)', 'handle.close' is undefined)
      at closeSocketHandle (node:net:3116:17)
      at _destroy (node:net:1685:24)
      at _destroy (internal:streams/destroy:85:18)
      at destroy (internal:streams/destroy:55:13)
      at internal:streams/writable:800:16
      at /workspace/bun/test/js/node/tls/node-tls-connect.test.ts:323:9
      at /workspace/bun/test/js/node/tls/node-tls-connect.test.ts:318:14
(fail) new TLSSocket(net.Socket).destroy() before connect > destroys both s
... (truncated)

release without fix: 18 skipped
bun test v1.4.0-canary.1 (bd462795d)

test/js/node/tls/node-tls-connect.test.ts:
(pass) should have checkServerIdentity [0.05ms]
(pass) should thow ECONNRESET if FIN is received before handshake [7.81ms]
(pass) initializes authorizationError to null in the TLSSocket constructor [0.13ms]
(pass) setMaxSendFragment mirrors OpenSSL's [512, 16384] acceptance without throwing [4.42ms]
(pass) should be able to grab the JSStreamSocket constructor [0.30ms]
(pass) new TLSSocket(net.Socket).destroy() before connect > destroys both sockets and emits 'close' [1.69ms]
(pass) new TLSSocket(Duplex).destroy() before connect > destroys both sockets and emits 'close' [0.38ms]
(skip) tls.connect > should work with alpnProtocols
(pass) tls.connect > Bun.serve() should work with tls and Bun.file() [3.46ms]
(pass) tls.connect > should have peer certificate when using self asign certificate [3.55ms]
(skip) tls.connect > should have peer certificate
(skip) tls.connect > getCipher, getProtocol, getEphemeralKeyInfo, getSharedSigalgs, getSession, exportKeyingMaterial and isSessionReused should work
(skip) tls.connect > should process options correctly when connect is called with only options
(
... (truncated)
passes on PR (with fix)
ASAN with fix: 18 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/tls/node-tls-connect.test.ts
bun test v1.4.0 (bd462795d)

test/js/node/tls/node-tls-connect.test.ts:
(pass) should have checkServerIdentity [2.55ms]
(pass) should thow ECONNRESET if FIN is received before handshake [448.49ms]
(pass) initializes authorizationError to null in the TLSSocket constructor [17.16ms]
(pass) setMaxSendFragment mirrors OpenSSL's [512, 16384] acceptance without throwing [153.33ms]
(pass) should be able to grab the JSStreamSocket constructor [29.75ms]
(pass) new TLSSocket(net.Socket).destroy() before connect > destroys both sockets and emits 'close' [56.65ms]
(pass) new TLSSocket(Duplex).destroy() before connect > destroys both sockets and emits 'close' [35.34ms]
(skip) tls.connect > should work with alpnProtocols
(pass) tls.connect > Bun.serve() should work with tls and Bun.file() [90.95ms]
(pass) tls.connect > should have peer certificate when using self asign certificate [96.45ms]
(skip) tls.connect > should have peer certificate
(skip) tls.connect > getCipher, getProtocol, getEphemeralKeyInfo, get
... (truncated)

release with fix: 18 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 823ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/21] gen JS modules (bundle-modules)
Preprocess modules (10045ms)
Bundle modules (60ms)
Postprocesss modules (682ms)
Bundle Functions (1084ms)
Generate Code (32ms)

[11.92s] Bundled "src/js" for production
  2570 kb
  193 internal modules
  13 native modules
  90 internal functions across 19 files
[build] done
bun test v1.4.0-canary.1 (bd462795d)

test/js/node/tls/node-tls-connect.test.ts:
(pass) should have checkServerIdentity [0.06ms]
(pass) should thow ECONNRESET if FIN is received before handshake [8.77ms]
(pass) initializes authorizationError to null in the TLSSocket constructor [0.17ms]
(pass) setMaxSendFragment mirrors OpenSSL's [512, 16384] acceptance without throwing [4.81ms]
(pass) should be able to grab the JSStreamSocket constructor [0.40ms]
(pass) new TLSSocket(net.Socket).destroy() before connect > destroys both sockets and emits 'close' [1.48ms]
(pass) new TLSSocket(Duplex).destroy() before connect > destroys both sockets and emits 'close' [1.70ms]
(skip) tls.connect > should work with alp
... (truncated)
diff hotspot
src/js/node/net.ts                        |  7 ++++++-
 test/js/node/tls/node-tls-connect.test.ts | 31 +++++++++++++++++++++++++++++--
 2 files changed, 35 insertions(+), 3 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                       reads  edits  tests
src/js/node/net.ts                             5      2      0
test/js/node/tls/node-tls-connect.test.ts      2      2      0

new tls.TLSSocket(new net.Socket()).destroy() threw an uncaught
'handle.close is not a function' from closeSocketHandle. A client-side
TLSSocket wrapping a Duplex that has not yet connected stores the stream
itself as _handle (there is no native handle yet). Node wraps that stream
in a JSStreamSocket whose close() destroys it; do the equivalent in
closeSocketHandle by falling back to handle.destroy() when the handle has
no close method.
@coderabbitai

coderabbitai Bot commented Jul 26, 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: 8 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: ffa17b1f-eebb-4935-8cc1-8dccaa38b194

📥 Commits

Reviewing files that changed from the base of the PR and between 44f6469 and bd46279.

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

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

@robobun

robobun commented Jul 26, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: diff is green; CI blocked on unrelated build-queue infra.

Reproduced with:

bun -e 'import tls from "node:tls"; import net from "node:net"; new tls.TLSSocket(new net.Socket()).destroy(); setTimeout(() => console.log("SURVIVED"), 100);'

Main throws uncaught TypeError: handle.close is not a function; with this branch it prints SURVIVED and exits 0, matching Node v26.3.0.

CI: builds #81831 and #81933 both stalled because the *-build-cpp jobs never left scheduled, so their paired *-build-bun jobs timed out at "Waiting for build-cpp to finish" after the Rust compile already succeeded. Every test lane that actually ran passed (alpine x64/aarch64, darwin-14-x64, debian-13-x64-asan, windows-11-aarch64). Main builds over the same window are also failing. Ready for review/merge; the red is infra, not this diff.

@robobun

robobun commented Jul 26, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:57 PM PT - Jul 25th, 2026

@robobun, your commit bd46279 is building: #81933

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.

No bugs found, but this touches the shared closeSocketHandle path in node:net and changes how wrapped-stream handles are torn down, so it's worth a maintainer glance.

What was reviewed:

  • Verified native handles (which have .close) hit the original branch unchanged; only the previously-throwing wrapped-Duplex case takes the new destroy() fallback.
  • Checked _destroy call sites: kAdoptedTLSRaw is only set on native upgrade handles, so a wrapped Duplex always reaches closeSocketHandle.
  • Tests await both 'close' events (no sleeps), cover net.Socket + Duplex, and the added .destroy() calls on the two existing detached-wrap tests are now safe because of this fix.
Extended reasoning...

Overview

Five-line change to closeSocketHandle in src/js/node/net.ts: instead of unconditionally calling handle.close(cb), it checks typeof handle.close === 'function' and falls back to handle.destroy?.() (guarded by !handle.destroyed). This handles the case where tls.TLSSocket stores the wrapped Duplex directly as _handle before any connect/upgrade — previously .destroy() on such a socket threw an uncaught TypeError. Two new describe.each cases cover net.Socket and plain Duplex; two existing tests now call .destroy() on their detached wraps (which this fix makes possible).

Security risks

None. No auth/crypto/permission surface; this is teardown-path plumbing. The typeof check and optional-chained destroy?.() don't call user-overridable prototype methods any more than the previous handle.close() did.

Level of scrutiny

Medium-high. The change itself is small and the native-handle branch is byte-identical to before, but closeSocketHandle is on the destroy path for every net/tls socket, and the underlying design question — whether tls.ts should keep storing a bare Duplex as _handle vs. wrapping it in something with close() (as Node's JSStreamSocket does) — was raised by comment-cop and answered by the author, but a maintainer should confirm they're OK with the probe-and-fallback approach living in net.ts rather than a proper wrapper in tls.ts.

Other factors

Tests are well-structured (event-driven, no sleeps, both variants covered, assert both sockets' destroyed and both 'close' events). I traced _destroy: kAdoptedTLSRaw is only set on native upgrade results, so the Duplex case reliably reaches the modified helper. onSocketHandleClosed (skipped in the fallback branch) is a no-op, so nothing is lost by not passing it. The handle.onread = noop in the cleanup path is harmless on a Duplex.

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

LGTM — targeted fix for an uncaught TypeError: handle.close is not a function when destroying a never-connected TLSSocket wrapping a Duplex.

What was reviewed:

  • closeSocketHandle: native handles still hit handle.close(cb) unchanged; the new branch only fires when _handle is the wrapped stream tls.ts assigns for JSStreamSocket compat, and mirrors Node's JSStreamSocket.close() (destroy the wrapped stream).
  • Tests await the actual 'close' events on both the TLS wrapper and the raw stream for net.Socket and Duplex; the added .destroy() calls on pre-existing detached-wrapper tests now exercise the same path.
  • Checked the other closeSocketHandle caller (_closeAfterHandlingError microtask) and the follow-on handle.onread = noop — both are harmless on a Duplex.
Extended reasoning...

Overview

The PR fixes new tls.TLSSocket(duplex).destroy() before any connect, which currently throws an uncaught TypeError: handle.close is not a function and kills the process. Bun's tls.ts intentionally stores the wrapped Duplex directly as this._handle (for http2-wrapper's JSStreamSocket-constructor compat), but Socket.prototype._destroy → closeSocketHandle assumed _handle was always a native handle with .close(). The fix makes closeSocketHandle fall back to handle.destroy() when close is not a function, guarded by !handle.destroyed to avoid double-destroy. Tests cover both net.Socket and generic Duplex wraps and assert that both the TLS wrapper and the raw stream emit 'close' and end up destroyed. Two existing tests that previously left never-connected wrappers un-destroyed now call .destroy(), which additionally exercises the fixed path.

Security risks

None. This is destroy-path cleanup for a client-side TLSSocket that was never connected. No trust decisions, credentials, or verification logic are touched; the change is strictly additive and only reachable when _handle lacks a close method (i.e., it is a JS stream, not a native handle).

Level of scrutiny

Moderate. closeSocketHandle is on the shared socket destroy path, but the change is defensive: the native-handle path (typeof handle.close === "function") is byte-identical to before, so all existing sockets are unaffected. The new branch matches Node semantics — Node's JSStreamSocket.close() also just destroys the wrapped stream — and the follow-on handle.onread = noop / self._handle = null cleanup is harmless on a Duplex. I also checked the second caller (the _closeAfterHandlingError microtask at net.ts:2225) — it flows through the same guarded branch.

Other factors

The comment-cop feedback was addressed (comment tightened to one line). The PR description shows the full test file passing on both debug/ASAN and release builds and failing on main, and lists the vendored Node TLS tests that still pass. The tests await real events (no sleeps), wire failure via once(), and cover the variant matrix (net.Socket and Duplex). One could argue a fuller JSStreamSocket wrapper in tls.ts would be the "deeper" fix, but tls.ts already deliberately uses the Duplex as _handle for ecosystem compat, and this fallback is exactly what Node's wrapper's close() does — so this is the right layer.

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Related: #37664 removes the root cause this works around. The client-side TLSSocket constructor no longer stores the wrapped stream as _handle (it upgrades it instead), so closeSocketHandle always sees a native handle, and it adds tests for both scenarios from this PR (new TLSSocket(new net.Socket()).destroy() and new TLSSocket(new Duplex()).destroy() destroying both and emitting 'close'). If #37664 lands first, the fallback here becomes unreachable.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Still reproduces on current main (732491c), and this diff also covers the connected-socket shape of the same bug (client STARTTLS teardown, which is how the mysql package hits it):

const raw = net.connect(port, "127.0.0.1", () => {
  const wrap = new tls.TLSSocket(raw, { rejectUnauthorized: false });
  wrap.destroy();
});

main: the wrap emits error (or _tlsError when no error listener is attached) with handle.close is not a function, never emits close, and raw.destroyed stays false.

With the closeSocketHandle hunk from this PR applied on top of main, both this shape and the plain Duplex variant emit only close (raw close first, then the wrap's) and leave raw.destroyed === true, identical to Node v26.3.0 output.

@robobun

robobun commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: #42330 covers this.

#42330 fixes end() and destroySoon() on the same client-side wrap (a regression from #42181: socket.shutdown is not a function). destroySoon() calls destroy() on 'finish', so that PR needs the closeSocketHandle change from here and carries it, with one difference. It tests handle instanceof Duplex where this PR tests typeof handle.close === "function". A wrapped stream can have a close() of its own with another signature (an http2 stream takes close(code, callback)), and the typeof form passes the completion callback to it and leaves the stream alive.

The coverage from here is in #42330 too: destroy() on a wrap of an unconnected net.Socket and of a Duplex (both sockets destroyed, 'close' emitted, same report as node v26.3.0), and the destroy() calls on the detached wraps in the two existing tests.

@robobun robobun closed this Sep 11, 2026
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.

2 participants