Skip to content

node:tls: destroy the wrapped net.Socket when tls.connect({ socket }) is destroyed - #34507

Closed
robobun wants to merge 4 commits into
mainfrom
farm/3ff7cda6/tls-connect-socket-destroy-on-error
Closed

robobun wants to merge 4 commits into
mainfrom
farm/3ff7cda6/tls-connect-socket-destroy-on-error

Conversation

@robobun

@robobun robobun commented Jul 17, 2026 •

Copy link
Copy Markdown
Collaborator

Reproduction

import net from "node:net"; import tls from "node:tls";
const srv = net.createServer(c => { c.on("error", () => {}); c.on("data", () => c.write("NOT TLS AT ALL\r\n")); });
srv.listen(0, "127.0.0.1", () => {
  const ns = net.connect({ host: "127.0.0.1", port: srv.address().port });
  ns.on("error", () => {});
  const k = tls.connect({ socket: ns, servername: "localhost" }); // peer speaks non-TLS -> handshake fails
  k.on("error", e => console.log("tlsSock error:", e.code));
  ns.on("close", () => console.log("netSock close event"));
  setTimeout(() => { console.log("after 700ms: netSock.destroyed =", ns.destroyed); process.exit(0); }, 700);
});

Node v26.3.0 and pre-rewrite Bun:

tlsSock error: ERR_SSL_WRONG_VERSION_NUMBER
netSock close event
after 700ms: netSock.destroyed = true

Bun on main (a215285):

tlsSock error: ERR_SSL_WRONG_VERSION_NUMBER
after 700ms: netSock.destroyed = false

The kernel fd is released (no fd leak), but the JS net.Socket is stranded with destroyed === false and its 'close' handlers never run. The socket: option exists for STARTTLS-style upgrades where callers own that socket and key their cleanup on its 'close', so every failed upgrade leaked one zombie Socket.

Cause

After tls.connect({ socket: ns }), the wrapped ns has exactly two release paths in net.ts:

  • this.once("end", this[kCloseRawConnection]): only fires on a graceful close; a handshake failure is error -> close with no end.
  • Socket.prototype._destroy's this[kupgraded] teardown: gated on !(upgraded instanceof Socket), so it only ever tore down generic duplexes.

Neither fires on the error path for a real net.Socket, so it is never destroyed.

Fix

_destroy drops the !(upgraded instanceof Socket) guard and calls upgraded.destroy?.() for any wrapped transport. The teardown runs after this._handle is closed so resetAndDestroy()'s terminate() stays the first close on the shared us_socket_t (us_socket_close is first-close-wins, socket.c:280); the raw twin's own close() then no-ops on is_closed. On the upgradeDuplexToTLS path (Windows named pipe) upgraded._handle owns its own fd and its _destroy closes it.

This also covers tlsSock.destroy() mid-stream and the server-side new TLSSocket(socket, { isServer: true }) wrap.

Verification

$ bun bd test test/js/node/tls/node-tls-upgrade.test.ts
(pass) tls.connect({ socket }) with a failing handshake destroys the wrapped net.Socket (connecting)
(pass) tls.connect({ socket }) with a failing handshake destroys the wrapped net.Socket (already connected)
(pass) tls.connect({ socket }).resetAndDestroy() destroys the wrapped net.Socket
(pass) should be able to upgrade a paused socket and also have backpressure on it #15438

The failing-handshake tests time out without the fix (waiting on the wrapped socket's 'close'). Vendored Node tests test-tls-connect-given-socket, test-tls-destroy-whilst-write, test-tls-inception, test-tls-on-empty-socket, test-tls-net-socket-keepalive, test-tls-js-stream all pass.


[review] gate passed · iteration 1 · 2 files touched

fails on main (without fix)
ASAN without fix: 2 FAILED
$ 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-upgrade.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (0e4cd7da1)

test/js/node/tls/node-tls-upgrade.test.ts:
(fail) tls.connect({ socket }) with a failing handshake destroys the wrapped net.Socket (connecting) > destroys the wrapped socket and fires its 'close' [5005.66ms]
  ^ this test timed out after 5000ms.
(fail) tls.connect({ socket }) with a failing handshake destroys the wrapped net.Socket (already connected) > destroys the wrapped socket and fires its 'close' [5007.02ms]
  ^ this test timed out after 5000ms.
(pass) tls.connect({ socket }).resetAndDestroy() destroys the wrapped net.Socket [258.33ms]
(pass) should be able to upgrade a paused socket and also have backpressure on it #15438 [3785.04ms]

 2 pass
 2 fail
 4 expect() calls
Ran 4 tests across 1 file. [14.36s]
er
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (44ca65fc9)

test/js/node/tls/node-tls-upgrade.test.ts:
(pass) tls.connect({ socket }) with a failing handshake destroys the wrapped net.Socket (connecting) > destroys the wrapped socket and fires its 'close' [91.53ms]
(pass) tls.connect({ socket }) with a failing handshake destroys the wrapped net.Socket (already connected) > destroys the wrapped socket and fires its 'close' [82.36ms]
(pass) tls.connect({ socket }).resetAndDestroy() destroys the wrapped net.Socket [5.64ms]
(pass) should be able to upgrade a paused socket and also have backpressure on it #15438 [104.36ms]

 4 pass
 0 fail
 6 expect() calls
Ran 4 tests across 1 file. [661.00ms]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ 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-upgrade.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (0e4cd7da1)

test/js/node/tls/node-tls-upgrade.test.ts:
(pass) tls.connect({ socket }) with a failing handshake destroys the wrapped net.Socket (connecting) > destroys the wrapped socket and fires its 'close' [1193.87ms]
(pass) tls.connect({ socket }) with a failing handshake destroys the wrapped net.Socket (already connected) > destroys the wrapped socket and fires its 'close' [1022.60ms]
(pass) tls.connect({ socket }).resetAndDestroy() destroys the wrapped net.Socket [307.54ms]
(pass) should be able to upgrade a paused socket and also have backpressure on it #15438 [3576.15ms]

 4 pass
 0 fail
 6 expect() calls
Ran 4 tests across 1 file. [9.06s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 1872ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/137] gen cpp.rs (cppbind)
[2/137] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 244 extern-C blocks audited
[3/137] gen JS modules (bundle-modules)
Preprocess modules (11079ms)
Bundle modules (111ms)
Postprocesss modules (162ms)
Bundle Functions (1151ms)
Generate Code (147ms)

[12.71s] Bundled "src/js" for production
  2035 kb
  165 internal modules
  13 native modules
  90 internal functions across 19 files
[3/137] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: com
... (truncated)
diff hotspot
src/js/node/net.ts                        | 14 ++++----
 test/js/node/tls/node-tls-upgrade.test.ts | 58 ++++++++++++++++++++++++++++++-
 2 files changed, 64 insertions(+), 8 deletions(-)

gate history · 3 passed · 0 rejected · iteration 1

evidence per changed file
file                                       reads  edits  tests
src/js/node/net.ts                            18     21      0
test/js/node/tls/node-tls-upgrade.test.ts      3      5      0

… is destroyed

A net.Socket passed to tls.connect({ socket }) whose handshake failed was
left with destroyed=false and never emitted 'close'. The wrapped socket's
only release paths were once('end', kCloseRawConnection) and the _destroy
teardown that was gated on !(upgraded instanceof Socket), so a handshake
that failed via error -> close (no 'end') orphaned the caller's socket.

_destroy now tears down a wrapped net.Socket the same way kCloseRawConnection
does on the graceful path: null the raw half (its fd is released with the
TLS handle) and destroy the JS Socket so its 'close' fires.
@robobun

robobun commented Jul 17, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced on main (a215285): tls.connect({ socket: ns }) with a failing handshake left ns.destroyed === false and never emitted 'close'. Fixed in Socket.prototype._destroy by dropping the !(upgraded instanceof Socket) guard and moving the wrapped-socket teardown to after this._handle's close, so terminate() stays first-close on the shared us_socket_t and the raw twin's close() no-ops on is_closed. New tests in test/js/node/tls/node-tls-upgrade.test.ts time out without the fix and pass with it.

CI on 0e4cd7d: the diff-touched lanes are green. Remaining red is unrelated: test-http2-reset-flood.js (!exception() JSC scope assertion on debian x64-asan; the test is plain net.connect with no kupgraded wrap so this change is a no-op there, 5/5 local runs pass with BUN_JSC_validateExceptionChecks=1, already owned by another fix session), plus retry-passed flakes (complex-workspace, pglite). Ready for review.

@robobun

robobun commented Jul 17, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:05 AM PT - Jul 18th, 2026

❌ @robobun, your commit 0e4cd7d has 1 failures in Build #74881 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34507

That installs a local version of the PR into your bun-34507 executable, so you can run:

bun-34507 --bun

@coderabbitai

coderabbitai Bot commented Jul 17, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The socket teardown path now safely destroys upgraded net.Socket instances by clearing their handle first. TLS upgrade tests cover failed handshakes for connected and unconnected sockets, including destruction and close-event behavior.

TLS socket teardown

Layer / File(s) Summary
Upgraded socket destruction
src/js/node/net.ts
Socket._destroy clears an upgraded Socket’s _handle before destruction while retaining optional destruction for other upgraded transports.
TLS handshake failure coverage
test/js/node/tls/node-tls-upgrade.test.ts
Parameterized tests verify that failed TLS handshakes destroy and close both the wrapped net socket and TLS socket.

Suggested reviewers: cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately summarizes the main TLS socket destruction change and is concise and specific.
Description check ✅ Passed The description covers reproduction, root cause, fix, and verification, though it doesn't use the template's exact headings.

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

The wrapped net.Socket's close + destroyed is the regression being tested;
checking tlsSock close afterwards added a needless event-ordering dependency.
Comment thread src/js/node/net.ts Resolved
Nulling upgraded._handle before destroy is only correct for the upgradeTLS
path (where _handle is the raw twin of this._handle). For a duplex-wrapped
net.Socket (Windows named pipe), _handle is the original live pipe handle
and nulling it would orphan that handle.

Closing the raw twin via its own _destroy is safe: us_socket_close() guards
on is_closed, so the subsequent closeSocketHandle(this) is a no-op on the
shared us_socket_t. This also makes the netSock 'close' / tlsSock 'close'
ordering match Node's (both via setImmediate, netSock first).

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

44ca65f addresses my earlier note — dropping _handle = null and relying on us_socket_close()'s is_closed guard (socket.c:280) is cleaner and keeps the named-pipe path correct. I didn't find anything further, but the raw/tls twin-handle close ordering in _destroy is subtle enough that someone who owns this path should confirm before merge.

What was reviewed:

  • Verified us_internal_socket_close_raw guards on is_closed before touching the fd or the loop's closed_head list, so the raw twin's close ahead of closeSocketHandle(this) is idempotent at the usockets layer.
  • Checked kCloseRawConnection (still nulls _handle) vs. this new error path (does not) — the divergence is benign given the guard, and upgraded.destroyed prevents double-destroy when both paths could run.
  • Confirmed onSocketHandleClosed is a no-op, so calling handle.close() on both halves has no JS-side callback duplication.
Extended reasoning...

Overview

The PR removes the !(upgraded instanceof Socket) guard from Socket.prototype._destroy in src/js/node/net.ts, so a net.Socket wrapped via tls.connect({ socket }) is destroyed (and emits 'close') when the TLS socket is torn down on the error path — previously only generic Duplex streams were torn down here, and real net.Socket instances relied solely on the 'end'-listener path that never fires on a failed handshake. Two new parametrized tests in test/js/node/tls/node-tls-upgrade.test.ts cover the connecting and already-connected cases; both time out on main and pass with the fix.

My earlier review on 18bb2fd flagged that the intermediate revision's upgraded._handle = null would orphan a Windows named-pipe handle on the upgradeDuplexToTLS path. The author responded in 44ca65f by dropping the nulling entirely and instead relying on us_socket_close() idempotency for the upgradeTLSDeferred twin-handle case. That inline thread is resolved.

Security risks

None identified. This is teardown-path lifecycle plumbing; no auth, crypto config, or user-input parsing is touched.

Level of scrutiny

Medium-high. node:net / node:tls socket teardown is a heavily-exercised Node compat path where handle-lifecycle bugs surface as intermittent hangs, double-frees, or leaked fds under load. The runtime change itself is a one-clause guard removal, but its correctness now depends on native-level ordering: netSock._destroy synchronously calls raw.close() (which per us_internal_socket_close_raw can dispatch on_close back into JS) while tlsSock._destroy is still mid-execution and hasn't yet called closeSocketHandle(this) on the tls half. I verified the is_closed guard the author cites, and tlsSock.destroyed is already true so re-entrant destroys short-circuit — but I'd want a maintainer who owns the raw/tls twin-handle pair (upgrade_tls_impl in socket_body.rs) to confirm the raw half's synchronous on_close dispatch doesn't disturb the tls half before its own close runs.

Other factors

  • The graceful path (kCloseRawConnection, net.ts:1691) still nulls _handle before destroying; this new error path does not. That divergence is now intentional per the author's reply, and looks safe given the usockets guard, but it does mean two paths now release the wrapped socket via different mechanics.
  • Evidence block: ASAN debug fails without the fix and passes with it. The "release without fix: all passed" line is a canary binary at a different revision (1498d7b), not a locally-built release from the same checkout, so it doesn't undermine the fails-without-fix claim.
  • Vendored Node tests (test-tls-connect-given-socket, test-tls-destroy-whilst-write, etc.) are reported passing.
  • Tests use await using server, once() for every awaited event, port: 0, and assert the specific error code — they meet the harness conventions.

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

44ca65f resolves the earlier named-pipe concern — dropping the _handle = null means a duplex-wrapped net.Socket keeps its live pipe handle and closes it in its own _destroy. Beyond the inline nit, I also re-checked the upgradeTLSDeferred double-close question: the raw twin's close() and this socket's closeSocketHandle both land in us_internal_socket_close_raw, which guards on is_closed, so no double-free of the shared us_socket_t.

Extended reasoning...

The author addressed my prior review by removing the _handle = null line in 44ca65f, so both upgrade paths (upgradeTLSDeferred and upgradeDuplexToTLS/named-pipe) now go through the wrapped socket's own _destroy with its handle intact. The remaining inline nit (resetAndDestroy → FIN instead of RST because upgraded.destroy() runs before the resetAndClosing branch) is niche but real; leaving the approve/merge call to a human given this is TLS socket-lifecycle code.

Comment thread src/js/node/net.ts Resolved
Comment thread src/js/node/net.ts Resolved
us_socket_close is first-close-wins on the shared us_socket_t, and
SO_LINGER{1,0} is only armed when that first close carries the reset code.
With the teardown block before the resetAndClosing branch, upgraded.destroy()
reached raw.close() first and terminate() became a no-op. Moved the block to
after this._handle is closed so terminate() stays first-close.

Added a resetAndDestroy() case asserting the wrapped socket is still
destroyed.

@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 new issues found on 0e4cd7d. Deferring for a human sign-off since Jarred is already engaged and the resetAndDestroy test question (peer ECONNRESET assertion vs. the weaker destroyed-only check) is still open on his end.

What was reviewed:

  • The moved kupgraded block now runs after this._handle's close/terminate, so resetAndDestroy() keeps terminate() as first-close on the shared us_socket_t.
  • The _closeAfterHandlingError branch (queueMicrotask'd closeSocketHandle) — upgraded.destroy() runs first there, but that path is error-only and never combines with resetAndClosing, so no RST is expected anyway.
  • Generic-duplex teardown is preserved: the old !(upgraded instanceof Socket) branch's behavior is subsumed by the new unconditional upgraded.destroy?.().
  • The Windows named-pipe (upgradeDuplexToTLS) path — upgraded._handle is left intact and its own _destroy closes the pipe.
Extended reasoning...

Overview

The PR changes Socket.prototype._destroy in src/js/node/net.ts so that a wrapped transport (tls.connect({ socket })) is destroyed on every teardown path, not just for generic duplexes. The block moved from before the _handle close to after it (per maintainer feedback) so terminate() remains the first close on the shared us_socket_t and resetAndDestroy() still arms SO_LINGER{1,0}. Three new tests cover failing-handshake teardown (connecting / already-connected) and resetAndDestroy() on a wrapped socket.

Security risks

None. This is JS-side stream lifecycle bookkeeping — no auth, crypto, or untrusted-input parsing is touched. The underlying fd was already being released; the fix is about the JS net.Socket object's destroyed flag and 'close' event.

Level of scrutiny

High. Socket.prototype._destroy is a shared teardown path for every net/tls socket, and this PR has already been through two rounds of subtle ordering bugs (the named-pipe _handle = null orphan and the RST-vs-FIN first-close race), both caught in review. The final shape looks correct, but a maintainer (Jarred-Sumner) is actively reviewing and has an open item.

Other factors

Jarred requested three changes: move the block (done in 0e4cd7d), add a peer-observes-ECONNRESET test (author explained it isn't observable on loopback because us_internal_ssl_close queues an alert before the RST fires, and Node throws ERR_INVALID_HANDLE_TYPE for the same call — a weaker destroyed-only test was added instead), and drop the ordering claim from the PR body (done). Whether the weaker test is acceptable is Jarred's call, so I'm not auto-approving over an in-progress human review.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #38028, which moves the wrapped-socket teardown into _destroy on top of the shared-fd twin handling (kAdoptedTLSRaw) that landed after this was opened, and covers the server-side new TLSSocket(socket, { isServer: true }) / emit('connection') wraps as well as tls.connect({ socket }).

robobun added a commit that referenced this pull request Aug 13, 2026
…t({ socket })

Adds the two teardown paths of a wrap over an already connected socket
that the describe block did not exercise: destroy() right after wrapping
(the adopted fd has to be closed and the plain socket released, the case
from #34507) and a graceful end() after the handshake, whose release used
to come from the removed 'end' listener. Both assert node's event order.
@robobun

robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #38028. Verified on its branch: the three tests from this PR and the reproduction above pass there (the wrapped socket emits 'close' with hadError === false and ends up destroyed === true, the same output as node). With main's net.ts the reproduction still prints netSock.destroyed = false and the two failing-handshake tests still time out. The caller-initiated teardown case from this PR is now also part of #38028's node-tls-upgrade.test.ts block.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Heads up: #38232 makes resetAndDestroy() throw ERR_INVALID_HANDLE_TYPE on TLS sockets (node's behavior, as noted in the review thread here). The "tls.connect({ socket }).resetAndDestroy() destroys the wrapped net.Socket" test in this PR calls it on the wrapper and will throw once that lands; whichever PR merges second needs that test switched to destroy() (or to expect the throw).

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