Skip to content

net: stop surfacing TLS traffic on the socket that tls.connect({ socket }) / new TLSSocket(socket) took over - #36534

Closed
robobun wants to merge 5 commits into
mainfrom
farm/c45965b8/fix-duplex-tls-reemit-32242
Closed

robobun wants to merge 5 commits into
mainfrom
farm/c45965b8/fix-duplex-tls-reemit-32242

Conversation

@robobun

@robobun robobun commented Jul 31, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #32239. Fixes #32242. Fixes #40646. Consolidates #32241, #32243 and #32372 (@Sids15, credited as co-author).

Problem

After tls.connect({ socket }) or new tls.TLSSocket(socket, { isServer: true }) puts a TLS layer on a net.Socket, that socket kept receiving the connection's ciphertext as data: a STARTTLS handler still attached re-enters and upgrades again (Invalid socket, #32239); with no listener (postgres.js) the ciphertext piles up in the socket's readable buffer for the life of the connection (4 GB retained on a 4 GB transfer, #32241).

What Node does

new TLSSocket(sock) keeps the original net.Socket alive (tlsSocket._parent / _parentWrap) with its own handle, for close/destroy/address/ref — but TLSWrap does stream->PushStreamListener(this), so every read on that handle goes to the TLS layer and the parent never sees another byte. Output is held while the parent still has writes queued (wrapHasActiveWriteFromPrevOwner). Anything that is not a handle-backed net.Socket is wrapped in a JSStreamSocket fed from data events; initRead drains whatever the parent had already buffered into the engine.

Fix

The upgrade is now modelled the same way, and almost entirely natively:

  • The wrapped socket keeps its own handle. upgradeTLS no longer creates a second TLSSocket "raw twin" and swaps it in as connection._handle: the original TCPSocket follows the fd into the adopted us_socket_t (BYPASS_TLS: writes skip SSL; close/writable are chained to it from the TLS half). For the public Bun.Socket.upgradeTLS(), raw === socket now (same handlers, same data), and upgrading a raw half again returns undefined. node:net's kAdoptedTLSRaw/kCloseRawConnection bookkeeping is gone; a fdIsTLS getter tells _destroy/connect() the fd belongs to a TLS layer.
  • No ciphertext into the parent. The node:net entry point (jsNodeNetUpgradeTLS) doesn't enable the ssl_raw_tap, so nothing of the TLS stream is dispatched to JS. Whatever the parent had already buffered (every chunk) is handed over as initialData; strings (after setEncoding) fail with ERR_STREAM_WRAP like Node's JSStreamSocket.
  • Pending plaintext writes: TLS output held natively (us_socket_hold_tls_output / us_socket_release_tls_output, node's has_active_write_issued_by_prev_listener_): the fd is adopted immediately; whatever SSL emits (the handshake flight) is kept in a per-SSL buffer instead of written, while input is processed as usual (so garbage or a FIN during the hold is an ordinary mid-handshake failure). node:net queues an empty write behind the plaintext and calls startTLSHandshake() from its callback; release writes the buffer out ahead of anything newer. Plaintext still queued natively drains through the raw half first (the TLS half chains writable to it). A close while held reports ECONNRESET like any close before the handshake finished and puts no TLS bytes on the wire. The stream-level engine gets the same hold (held_output) when its handle-backed transport still has JS writes queued, writes its ciphertext into that transport natively (write_from_engine), and a shutdown() with engine output still parked waits for it.
  • A still-connecting net.Socket is upgraded on 'connect' (TCP, named pipe, or an outer TLS socket alike), as Node's TLSSocket._start waits for 'connect' before _handle.start(); there is no fd to adopt and no transport to write a ClientHello into before that.
  • Stream-level engine transports fed natively. upgradeDuplexToTLS over a handle-backed net.Socket (named pipes, TLS over TLS, Http2SecureServer#emit('connection')) takes transport: connection._handle; NativeCallbacks::TlsTransport routes that socket's data/end/close into the engine (never through JS or the parent's listeners), and the engine's pause/resume reach the transport (backpressure for these paths, previously a TODO). A plain Duplex keeps the data-event wiring, as Node's JSStreamSocket does. kTLSUpgradeSink and the per-chunk sink checks in the four data handlers are gone; internal/net/streamTLS.ts is the one helper for net.ts and _http2_upgrade.ts.
  • Lifecycle parity: TLSSocket._parent is set (idle timeouts on the parent are refreshed by TLS activity; ref()/unref() on either socket count); the TLS socket is destroyed when the wrapped stream closes/errors and the wrapped stream is destroyed with the TLS socket (_wrapHandle/doClose); _final waits for a handle that is still on its way; connect() on a socket whose fd a TLS layer owns fails with EISCONN instead of pulling the fd out from under it; the HTTP/2 raw-socket upgrade destroys the raw socket with the session and pauses the native handle for backpressure.
  • upgrade_tls_impl: initialData is fed before the tap is enabled, so the public upgradeTLS({ initialData }) no longer shows raw those bytes a second time.

Verification

test/js/node/tls/node-tls-upgrade.test.ts (34 cases: STARTTLS repro, client fd adoption immediate / behind a corked write / behind real backpressure, onread, server adoption immediate / behind a pending write / write issued after the wrap, bytes arriving while writes are pending, ClientHello buffered in one and in several chunks, setEncoding → ERR_STREAM_WRAP, TLS over TLS incl. peer close of the outer layer, no-listener retention, reconnect after TLS / EISCONN while wrapped, parent idle timeout, ref()/unref() through the parent, destroy / end() / peer FIN / peer flood while the handshake is held (client and server side), half-open write not cut off), test/js/bun/net/socket.test.ts (raw === socket, double upgrade, close-handler cross-calls), socket-retention.test.ts (raw halves collectable), node-http2-upgrade.test.mts, cluster.test.ts, node-tls-namedpipes.test.ts (Windows). test/js/node/{tls,net,http2}, test/js/bun/net and all vendored test-tls-*/test-https-*/test-net-*/test-http2-* node tests pass as on main (plus test-tls-connect-allow-half-open-option.js, newly vendored). Scripts driving client/server STARTTLS, TLS-over-TLS, pending-write, destroy-while-flushing, multi-chunk ClientHello, parent-dies-before-adoption and reconnect print the same results under this build and Node 26. TLS bulk-write throughput (1 KiB / 16 KiB writes, release build) is unchanged within noise.

Notes

no test proof · iteration 8 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/tls/node-tls-namedpipes.test.ts, test/js/node/cluster.test.ts, test/js/bun/net/socket.test.ts, test/js/bun/net/socket-retention.test.ts

@coderabbitai

coderabbitai Bot commented Jul 31, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

TLS upgrade paths now route incoming socket data through a shared TLS feeder. Buffered and onread data bypass the original stream after takeover. Runtime tap timing prevents duplicate initial bytes. Regression tests cover TCP, named pipes, cluster workers, and HTTP/2.

Changes

Duplex TLS upgrade routing

Layer / File(s) Summary
Direct TLS feeder routing
src/js/internal/net/symbols.ts, src/js/internal/net/tlsFeeder.ts, src/js/node/net.ts
Adds the TLS sink and feeder utilities. Socket handlers send owned bytes to TLS and suppress normal readable delivery.
Upgrade path integration
src/js/node/net.ts, src/js/node/_http2_upgrade.ts
Client, server, and HTTP/2 upgrade paths attach the feeder. TLS pair adoption and cleanup update feeder state.
Initial TLS data timing
src/runtime/socket/socket_body.rs, src/runtime/socket/mod.rs
Node-specific upgrades use the renamed runtime binding. Public upgrades enable raw TLS tapping after initial_data replay.
Upgrade regression coverage
test/js/node/tls/node-tls-upgrade.test.ts, test/js/node/tls/node-tls-namedpipes.test.ts, test/js/node/cluster.test.ts, test/js/node/http2/node-http2-upgrade.test.mts
Tests verify single delivery, buffered input handling, TLS takeover, reconnect behavior, idle timeouts, and no raw-socket emissions or buffered bytes.

Suggested reviewers: alii, cirospaciari, jarred-sumner

Merge Risk: 🔵 Low · up to 139ed

The PR correctly stops the original socket from receiving TLS ciphertext, but a deferred upgrade failure can still surface as an uncaught exception, while one regression test may be flaky or report failures only as timeouts. The change is mergeable with explicit owner follow-up on these bounded risks.

🚥 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 clearly identifies the main change: preventing TLS traffic from surfacing on the original socket after TLS takeover.
Description check ✅ Passed The description explains the problem, implementation, behavior, and verification. It provides the information required by the template, although it uses equivalent headings instead of the exact templa…

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

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. tls.connect({ socket }) fails to intercept stream, causing subsequent data to re-trigger cleartext listeners and throw "Invalid socket" #32239 - tls.connect({ socket }) fails to intercept stream, causing subsequent data to re-trigger cleartext listeners and throw "Invalid socket"

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #32239

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. net: stop re-emitting post-upgrade bytes on the original socket after tls.connect({ socket }) #32241 - Fixes the same post-upgrade byte re-emission bug on the original socket after tls.connect({ socket })
  2. net: stop re-emitting post-upgrade bytes on a named-pipe socket after tls.connect({ socket }) #32243 - Addresses the same re-emission bug on the named-pipe/stream-level TLS path (explicitly superseded by this PR)
  3. stop named-pipe tls.connect({ socket }) re-emitting post-upgrade bytes on the original socket #32372 - Same named-pipe TLS upgrade byte re-emission fix (explicitly adopted/extended by this PR)
  4. tls: buffer duplex data that arrives before the SSL engine is created #32750 - Fixes the same timing window in the duplex TLS upgrade path where bytes arrive before the SSL engine is ready

🤖 Generated with Claude Code

@robobun

robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Relationship to the PRs the bot flagged:

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

Beyond the inline note on the _http2_upgrade.ts sibling site, I also checked whether attachDuplexTLSFeeder could stall the handshake when the underlying native handle is paused — the four raw data handlers it targets only pause via socket.pause() on push() returning false, which the feeder path now bypasses, so no new stall is introduced. Also confirmed the buffered-bytes drain (connection.read()) only fires when readableLength > 0, so it doesn't flip an otherwise-idle socket into flowing mode.

Extended reasoning...

The PR routes post-upgrade bytes on the duplex-TLS path through a stored feeder instead of the public data event, touching all four raw socket data handlers plus four upgradeDuplexToTLS call sites in src/js/node/net.ts. One same-class sibling in _http2_upgrade.ts:386 remains on the public-event wiring (posted inline as a nit; pre-existing, not a regression). Given this modifies the hot data path for every net.Socket read and sits in TLS-adjacent code, deferring to a human reviewer rather than approving.

Comment thread src/js/node/net.ts Resolved
Comment thread src/js/node/_http2_upgrade.ts Resolved
Comment thread src/js/node/_http2_upgrade.ts Resolved
Comment thread src/js/node/net.ts Resolved
Comment thread src/js/node/net.ts Resolved
Comment thread src/js/node/net.ts Resolved
@robobun

robobun commented Jul 31, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:11 PM PT - Sep 1st, 2026

❌ @Jarred-Sumner, your commit 6cad8a0 has 1 failures in Build #109207 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 36534

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

bun-36534 --bun

Comment thread src/js/node/net.ts Resolved
Comment thread src/js/node/net.ts Resolved

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/js/node/net.ts (1)

2402-2431: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add server-side feeder regression coverage.

Existing server tests wrap TLSSocket instances, but they do not assert that the original raw socket receives no TLS ciphertext. The banner test writes through the wrapped socket, so it does not force the deferred hasUnflushedWrites(connection) branch. Add tests that force both server stream-upgrade branches and assert zero TLS ciphertext on raw-socket data listeners.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/node/net.ts` around lines 2402 - 2431, Add server-side regression
tests for the stream-upgrade flow around hasUnflushedWrites(connection),
covering both the normal and deferred upgradeDuplexToTLS branches. Attach a data
listener to the original raw connection, write through the wrapped TLSSocket,
and assert the raw socket emits no TLS ciphertext in either branch; explicitly
queue an unflushed write to exercise the deferred branch.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/node/tls/node-tls-upgrade.test.ts`:
- Around line 9-13: Remove the explanatory regression-test comments, leaving
exactly the issue URL comment at each affected site:
test/js/node/tls/node-tls-upgrade.test.ts lines 9-13, 18-20, 37-40, 47-49,
63-66, and 93-94, plus test/js/node/tls/node-tls-namedpipes.test.ts lines
125-128. Preserve the tests and issue URL comments unchanged.
- Around line 45-52: Move the terminal event listeners for tlsSocket before
raw.uncork() so errors, secureConnect, or close triggered by the flush are
always observed and the test promise settles. Ensure every relevant failure
event rejects or otherwise records failure while preserving the existing leaked
result for acceptable terminal outcomes.

---

Outside diff comments:
In `@src/js/node/net.ts`:
- Around line 2402-2431: Add server-side regression tests for the stream-upgrade
flow around hasUnflushedWrites(connection), covering both the normal and
deferred upgradeDuplexToTLS branches. Attach a data listener to the original raw
connection, write through the wrapped TLSSocket, and assert the raw socket emits
no TLS ciphertext in either branch; explicitly queue an unflushed write to
exercise the deferred branch.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 55904209-0e61-4511-9d0e-1829990e8f21

📥 Commits

Reviewing files that changed from the base of the PR and between 21932c3 and fdba7f3.

📒 Files selected for processing (4)
  • src/js/node/_http2_upgrade.ts
  • src/js/node/net.ts
  • test/js/node/tls/node-tls-namedpipes.test.ts
  • test/js/node/tls/node-tls-upgrade.test.ts

@robobun

robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

CI status on build #86132: the two new tests in node-tls-upgrade.test.ts and the new Windows test in node-tls-namedpipes.test.ts pass on every lane, as does node-http2-upgrade.test.mts.

The red on that build is unrelated to this diff: test-fs-read-stream-pos.js (pre-existing timeout on debian aarch64, also red on main), plus [flaky]-tagged retries in napi.test.ts, setInterval.test.js, fetch-tls-abortsignal-timeout.test.ts, bun-security-scanner-matrix, and 08965.test.ts (each passed alone or on retry). None touch node:net, node:tls, or node:http2.

Ready for review.

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/js/node/_http2_upgrade.ts:390-395 — This site now sets rawSocket[kDuplexTLSFeeder] unconditionally, but Http2SecureServer#emit('connection', socket) (http2.ts:6828) gates only on !(socket instanceof TLSSocket) and per Node's docs accepts any Duplex — for a plain Duplex (or a net.Socket without _handle) nothing ever invokes the feeder after the initial read() drain, so the TLS handshake hangs where the previous rawSocket.on("data", events[0]) worked. attachDuplexTLSFeeder in net.ts already carries the instanceof Socket && connection._handle gate with an .on("data") fallback; this site (and the onTlsClose teardown at line 263) needs the same gate — exporting the helper would keep all five sites in lockstep.

    Extended reasoning...

    What the bug is

    The follow-up commit that covers the fifth upgradeDuplexToTLS site replaced rawSocket.on("data", events[0]) with:

    rawSocket[kDuplexTLSFeeder] = events[0];
    let buffered;
    while ((buffered = rawSocket.read()) !== null) events[0](buffered);

    unconditionally. But kDuplexTLSFeeder is only read by net.ts's four native raw-data handlers (SocketHandlers.data, ServerHandlers.data, SocketHandlers2.data, and the onread variant at net.ts:420/742/1269/1764). Those handlers fire only for a net.Socket backed by a live native _handle. For any other stream, storing the symbol is a no-op — nothing calls it.

    Code path that reaches it

    src/js/node/http2.ts:6825-6829:

    emit(event, ...args) {
      if (event === "connection") {
        const socket = args[0];
        if (socket && !(socket instanceof TLSSocket)) {
          return upgradeRawSocketToH2(connectionListener, this, socket);
        }
      }
      ...
    }

    The gate is only !(socket instanceof TLSSocket). Node's docs for Http2SecureServer's 'connection' event explicitly state: "This event can also be explicitly emitted by users to inject connections into the HTTP server. In that case, any Duplex stream can be passed." The rawSocket: import('node:net').Socket type annotation is compile-time-only and not enforced in built-in modules.

    Why the existing code doesn't cover it

    net.ts's own attachDuplexTLSFeeder (added in this PR at line 1908) handles exactly this distinction:

    function attachDuplexTLSFeeder(connection, onData) {
      if (connection instanceof Socket && connection._handle) {
        connection[kDuplexTLSFeeder] = onData;
        let chunk;
        while ((chunk = connection.read()) !== null) onData(chunk);
      } else {
        connection.on("data", onData);
      }
    }

    The _http2_upgrade.ts site copies only the if-branch body and drops the gate. onTlsClose (line 263) was likewise changed to only clear the symbol, so it also assumes the feeder path.

    Step-by-step proof

    1. User creates h2s = http2.createSecureServer(opts) and a transport that delivers a plain Duplex (e.g. a tunnel, a socket pair, or a net.Socket whose _handle is null because it was constructed from an fd/JSStream wrap).
    2. User calls h2s.emit('connection', duplex) — the documented Node.js pattern.
    3. Http2SecureServer#emit sees !(duplex instanceof TLSSocket) → calls upgradeRawSocketToH2(connectionListener, this, duplex).
    4. upgradeRawSocketToH2 sets duplex[kDuplexTLSFeeder] = events[0] and drains duplex.read() until null (typically nothing yet).
    5. The peer sends its ClientHello. The bytes arrive via duplex.push(buf) → readable buffer. There is no 'data' listener (this PR removed it) and nothing calls read() again, so the stream never flows. net.ts's raw handlers never fire because there is no native _handle.
    6. events[0] is never invoked → the TLS engine receives zero bytes → handshake hangs indefinitely.

    Before this PR, step 4 was rawSocket.on("data", events[0]), which put the stream into flowing mode and delivered every pushed chunk to the feeder — the connection worked.

    Impact

    Regression on a documented Node.js API surface: h2SecureServer.emit('connection', duplex) with a non-native-handle Duplex now hangs where it previously succeeded. Per REVIEW.md's "fix the whole class in the same PR" rule (which this PR already follows at the four net.ts sites), this fifth site should carry the same gate.

    Fix

    Apply the same instanceof Socket && rawSocket._handle gate here (falling back to rawSocket.on("data", events[0])), and mirror it in onTlsClose so the fallback branch removes the listener. The cleanest option is to export attachDuplexTLSFeeder (and a matching detach) from net.ts and reuse it here so all five upgradeDuplexToTLS sites stay in lockstep.

Comment thread src/js/node/_http2_upgrade.ts Resolved
@robobun

robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Re the _http2_upgrade.ts finding: addressed in 8c99304. The feeder is now gated on rawSocket._handle (matching attachDuplexTLSFeeder in net.ts), with on("data") as the fallback; onTlsClose undoes both paths.

For what it's worth the specific case cited (Http2SecureServer#emit('connection', plainDuplex)) also hangs on unmodified main and in Node v26.3.0 with the same driver, so that path was not working before either; the gate here just keeps this PR from changing the wiring for non-handle inputs.

Comment thread src/js/node/_http2_upgrade.ts Resolved

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

Both earlier findings (the missing _http2_upgrade.ts site and the dropped _handle gate) are now addressed and this run found no further issues, but the change reroutes raw-socket data delivery across four hot-path handlers and five upgradeDuplexToTLS sites in net.ts/_http2_upgrade.ts, so it's worth a human look.

What was reviewed:

  • Feeder short-circuit placement in all four data handlers — bytesRead and _unrefTimer still run before the early return; the onread variant correctly bypasses kOnreadTail.
  • Feeder lifetime in net.ts — not cleared on TLS close, but neither was the prior .on('data', events[0]) listener, so no regression; initSocketHandle clears it for reused sockets.
  • Symbol.for cross-module sharing matches the existing ::bunUpgradeServerTLS:: pattern in this file.
  • The _http2_upgrade.ts inline gates on rawSocket._handle (not instanceof Socket && _handle as in attachDuplexTLSFeeder) — a non-Socket Duplex with a truthy _handle would take the feeder path and stall, but that input is not something Node produces.
Extended reasoning...

Overview

The PR fixes #32242 by rerouting post-upgrade ciphertext on the upgradeDuplexToTLS path so it reaches the TLS engine directly instead of surfacing on the wrapped socket's public data event. It touches src/js/node/net.ts (four raw data handlers, four upgrade call sites, a new attachDuplexTLSFeeder helper, a new kDuplexTLSFeeder symbol, and initSocketHandle) and src/js/node/_http2_upgrade.ts (the fifth upgrade site plus onTlsClose cleanup). Two new tests force the stream-level path platform-independently via cork()+write, and one Windows-only test covers real named pipes.

Security risks

The change is security-adjacent (TLS upgrade data routing) but does not touch crypto, certificate validation, or handshake logic — it only changes which callback receives already-arrived ciphertext bytes. Symbol.for("::bunDuplexTLSFeeder::") is globally reachable, so user code could set it to intercept raw bytes on its own socket, but that grants nothing beyond what a data listener already provides, and the pattern matches existing Symbol.for("::bun...::") keys in this file.

Level of scrutiny

High. net.ts is a large, hot-path core file and the four modified data handlers run for every incoming chunk on every socket. The mechanism is small per-site (a 4-line early return), but correctness depends on the feeder being set/cleared at exactly the right points across five call sites and two modules, and on the _handle gate correctly distinguishing native-backed sockets from plain Duplexes. This PR already went through two rounds of my own findings (missing fifth site; dropped _handle gate at that site), both now fixed.

Other factors

  • CI build #86132 passed the new tests on every lane per the author's status comment; unrelated flakes noted.
  • All prior review threads (mine, comment-cop, coderabbit) are resolved.
  • The _http2_upgrade.ts gate uses rawSocket._handle alone rather than instanceof Socket && _handle; the author's rationale (only Bun-native net.Sockets carry _handle) is reasonable for real-world inputs but is a slight divergence from the shared helper — worth a maintainer glance rather than a blocker.
  • Feeder cleanup on the net.ts side mirrors the pre-existing listener lifetime (never removed), so no regression there.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can we simplify the native code parts of this instead?

@robobun

robobun commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

This PR is JS-only (no Rust/C++ changes): src/js/node/net.ts, src/js/node/_http2_upgrade.ts, plus tests. The reason it touches four data handlers and five call sites is that each one currently does connection.on("data", events[0]), and the only way to stop post-upgrade bytes from also reaching a user data listener at the JS layer is to route the native handler's buffer to the feeder before push() runs.

A native alternative would be cleaner: have js_upgrade_duplex_to_tls (src/runtime/socket/socket_body.rs) notice when the connection JSValue's _handle is a Bun-native socket and redirect that handle's raw data delivery into UpgradedDuplex::on_internal_receive_data itself, the way upgradeTLS uses the ssl_raw_tap hook. Then no JS-side feeder symbol is needed, the four net.ts data handlers stay untouched, and the five connection.on("data", events[0]) lines can stay as-is (they would only fire for a plain Duplex, which has no native handle).

Happy to redo it that way if you prefer; it trades the JS plumbing here for a change in socket_body.rs / UpgradedDuplex.rs. Let me know which direction you want.

Comment thread src/js/internal/net/symbols.ts Resolved
Comment thread src/js/internal/net/tlsFeeder.ts Resolved
Comment thread src/js/node/net.ts Resolved
Comment thread src/uws_sys/socket.rs Outdated
Comment on lines +520 to +521
/// Let TLS output held behind the previous owner's writes go
/// (`us_socket_hold_tls_output` / the duplex engine's held output).

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@robobun

robobun commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Windows Server 2019 x64 debug build of 7c78dba: node-tls-upgrade.test.ts 35 pass, node-tls-namedpipes.test.ts 8 pass (with --timeout 60000 for the slow debug-only GC loop in the pre-existing test, as before). Linux debug build of a072706: node-tls-upgrade.test.ts 35 pass twice, node-http2-upgrade and socket-retention pass, lint clean. Build 109142 is running on 7c78dba.

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

Code review found no issues

No high-confidence issues detected in this change.

No-Verification-Needed: comment-only change
Comment on lines +4160 to +4164
/// This socket is the transport under a stream-level TLS engine
/// (`upgradeDuplexToTLS` over a handle-backed net.Socket: named pipes,
/// TLS over TLS, Http2SecureServer's injected sockets). Its bytes and EOF
/// go to that engine, never to the JS handlers — node's TLSWrap taking
/// over the parent's stream. Cleared by the engine's `release_transport`.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

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

Code review found no issues

No high-confidence issues detected in this change.

cirospaciari added a commit that referenced this pull request Sep 11, 2026
… socket (#42265)

Fixes #32239.

### Problem
- Regression from #42213. `tls.connect({ socket })` over a `net.Socket`
built with `readable: false` reports `ERR_STREAM_PUSH_AFTER_EOF` on the
wrapped socket during the handshake. That destroys the connection before
any data arrives.
- Cause: after TLS adopts the fd, the raw half still hands the
ciphertext to the wrapped socket. `pushDataToSocket`
(`src/js/node/net.ts:443`) pushes it into the ended Readable. The same
leak calls a wrapped `onread`, re-enters `'data'` listeners left from
STARTTLS (`Invalid socket`, #32239), and fills a buffer that nothing
reads.
- `net.connect({ readable: false, onread })` never calls `onread`. The
`readableEnded` checks from #42213 keep the handle stopped. Node starts
it whenever an `onread` buffer is set
([net.js#L830-L845](https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L830-L845)).

### Fix
- Data on the adopted raw half (`kAdoptedTLSRaw`) is dropped before the
push and before the `onread` callback. Node's `TLSWrap` takes over the
handle's reads
([wrap.js#L723-L727](https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L723-L727)),
so the wrapped socket never sees the TLS stream. Its idle timer is still
refreshed.
- The `readableEnded` checks in `resume()`, `read()` and
`afterConnect()` exempt a socket with an `onread` buffer.
- Verified: `node-tls-upgrade.test.ts` (`readable: false`, `onread`, no
reader, STARTTLS on both peers) and `node-net.test.ts` (`onread` +
`readable: false`). A build of main's `src/` fails all five.

### Background
- `tls.connect({ socket })` and `new tls.TLSSocket(socket, { isServer:
true })` adopt the connected fd. The native call returns a TLS handle
and a raw handle. The raw handle stays on the original `net.Socket`
(`kAdoptedTLSRaw`) and receives each ciphertext chunk.
- A Duplex built with `readable: false` starts with `endEmitted` set, so
`push()` reports `ERR_STREAM_PUSH_AFTER_EOF`.
- `onread` makes a `net.Socket` read into the caller's buffer and skip
the Readable.

<details><summary>Notes</summary>

**Repro 1, TLS over a `readable: false` socket**

```js
const raw = net.connect({ port, host: "127.0.0.1", readable: false });
raw.on("connect", () => {
  const s = tls.connect({ socket: raw, ca, servername: "localhost" });
  s.on("secureConnect", () => s.write("hi"));
  s.on("data", d => console.log("data", String(d)));
});
```

- node v26.3.0: `secureConnect | data "banner" | data "echo:hi"`
- bun 1.4.2: `secureConnect | data "bannerecho:hi"`
- main: `raw error ERR_STREAM_PUSH_AFTER_EOF | secureConnect | tls end |
raw close | tls close`

**Repro 2, `onread` + `readable: false`.** Node's `read()` / `resume()`
start the handle whenever an `onread` buffer is set, whatever the
Readable state, and `afterConnect` calls `read(0)`. Main reads nothing.

**Results against node v26.3.0**

| | node v26.3.0 | main | this branch |
|---|---|---|---|
| TLS over `readable: false` | `banner`, `echo:hi` |
`ERR_STREAM_PUSH_AFTER_EOF`, no data | `bannerecho:hi` |
| `onread` + `readable: false` | `"banner"` | `""` | `"banner"` |
| wrapped `onread` socket, TLS bytes seen | 0 | 2790 | 0 |
| STARTTLS, `'data'` calls on the wrapped sockets after the wrap | 0 | 4
(client and server) | 0 |
| wrapped socket `readableLength` after a 16 MiB TLS transfer | 0 |
16,802,694 | 0 |
| repro script of #32239 | upgrade succeeds | `Invalid socket` on the
third `'data'` call | upgrade succeeds |

The last three rows also fail on the released 1.4.3. They come from the
same leak and are older than #42213.

**More checks on this branch**

- `bytesRead` of the wrapped socket still counts the TLS bytes on the
push path, like node (the TCP handle counts every byte it reads).
- A 300 ms `setTimeout()` on the wrapped socket does not fire while TLS
traffic arrives every 100 ms, like node (`_unrefTimer()` still runs
before the drop).
- The event order on the wrapped socket and on the TLS socket is the
same as on 1.4.3 in these cases: the peer ends first, the client calls
`end()` first, each with a plain, a `readable: false` and an
`allowHalfOpen` wrapped socket.

**Suites**

- `test/js/node/tls/node-tls-upgrade.test.ts` (5),
`node-tls-connect.test.ts` (55), `node-tls-server.test.ts`,
`test/js/node/net/node-net.test.ts`,
`test/js/node/http2/node-http2-upgrade.test.mts` (15),
`test/js/bun/net/socket-retention.test.ts` (5),
`test/regression/issue/{12117,40401}.test.ts`.
- The 30 vendored `test-tls-*` / `test-https-*` files that wrap a socket
in TLS.
- The author ran `node-http2.test.js` (380 pass) on the first commit.
The second commit changes only tests.

**Related PRs**

- #42262, opened in parallel, returns early in `pushDataToSocket` when
`readableEnded`. That fixes the `ERR_STREAM_PUSH_AFTER_EOF` case. It
does not fix `onread` + `readable: false`, which comes from the
`readableEnded` checks in `resume()` / `read()` / `afterConnect()`. It
also still hands the TLS stream to a wrapped socket whose readable side
is open or that has an `onread` buffer. Both PRs touch the top of
`pushDataToSocket`, so the one that lands second needs a one-line
rebase.
- #36534 reworks the upgrade in native code so that the ciphertext is
never dispatched to JS. This PR drops it in the JS handler, which is
enough for the wrapped socket to see nothing.

**Not addressed here**

- `bytesRead` stays 0 on every socket built with `onread`, wrapped or
not. The `onread` data handler never counted. This is also the case on
1.4.3. It has its own report.
- A wrapped socket that goes through the stream-level TLS engine (a
named pipe on Windows, or a socket with pending writes) feeds that
engine from its `'data'` events. With `readable: false` it emits none.

</details>

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 1 · platform-specific test(s) that do not
run on this machine, deferring to CI, which covers all platforms:
test/js/node/net/node-net.test.ts

<!-- robobun:evidence:end -->

---------

Co-authored-by: robobun <117481402+robobun@users.noreply.github.com>
@robobun

robobun commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

#32239 and #40646 are fixed on main now, by #42265 (commit 158ff6c). It drops the data of the adopted raw half in the JS handlers, so the wrapped socket sees no TLS bytes.

I verified #40646 on a build of main with a grpc-js 1.14.4 client over TLS (70,000 unary RPCs). readableLength of the wrapped socket stays 0 and heapUsed is flat. Bun 1.4.2 buffers 4.6 MB in the same run. The numbers are in #40646 (comment), and I closed that issue.

After the next rebase, the open issue for this PR is #32242 (the Windows named pipe path), which #42265 does not address. The native rework and the lifecycle parity also remain. The branch conflicts with main in src/js/node/net.ts, src/js/node/tls.ts and test/js/node/tls/node-tls-upgrade.test.ts.

@robobun

robobun commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

@Jarred-Sumner this PR conflicts with main again, and this time the conflict is in the design, not only in the text. I pushed nothing. I need a decision from you.

State

  • Head 6cad8a0 (Sep 2) is 425 commits behind main. A trial merge conflicts in 7 files and 15 hunks: net.ts (5), socket_body.rs (3), us_socket_t.rs (2), node-tls-upgrade.test.ts (2), tls.ts, mod.rs, libusockets.h.
  • The branch no longer builds on the current toolchain (it pins clang 21.1.x, main uses 23). Typecheck the built-in modules (src/js) in CI #43649 now typechecks src/js in CI, so internal/net/streamTLS.ts and the new net.ts functions also need types.

Why it is not mechanical. Since Sep 2, main changed the same functions on the old structure:

A merge has to pick one design in each place, and main's new tests pin main's choices.

What main still lacks without this PR (measured today)

Related report. Another session reported that a TLS socket over a net.Socket transport never hears that transport's error. I confirmed it on the canary: after raw.destroy(err) the TLS socket gets no error with queued writes, a synthetic "disconnected before secure TLS connection" error otherwise, and the process dies with an uncaught exception when raw has no error listener. Node emits _tlsError and error on the TLS socket. #42235 covers the Duplex case only. tieToWrappedStream in this PR forwards error for every wrapped stream, so the redesign covers it, but I cannot re-run it because the branch no longer builds. I am routing that report to a standalone fix on main, so it does not wait for this PR.

Options

  1. I re-integrate the redesign onto main: one merge commit, your commits untouched, verified on Linux and Windows as before. For that I need to know which side wins where the two disagree, first of all the close order from node:tls: report a peer reset on a wrap of an accepted socket #42293.
  2. The redesign is parked. Then I close this PR and open small PRs on main's structure for what is still missing, starting with the stream-level leak (tls.connect({ socket }) over a Windows named pipe can re-emit post-upgrade bytes on the original socket #32242).

Which one do you want?

alii added a commit that referenced this pull request Sep 24, 2026
…cket it wraps (#42487)

### Problem

- `new tls.TLSSocket(socket, { isServer: true })` and `tls.connect({
socket })` wrap a connected `net.Socket`. When the connection closes,
the wrapped socket reports events that belong to the TLS socket: `'end'`
and `'finish'` on a close, `'error'` on a peer reset. Node emits only
`'close'` on it.
- Its `'close'` then destroys the TLS socket early. After `raw.end()`,
`finished(tlsSocket)` reports `ERR_STREAM_PREMATURE_CLOSE`. Regression
from #39066 and #42265.
- Cause: the native close calls the wrapped socket's close handler first
(`ServerHandlers.close`, `src/js/node/net.ts:1052`). It reports its own
EOF or read error and closes. Its `'close'` runs `onUpgradedClose`
(`net.ts:413`), which destroys the TLS socket.

### Fix

- A wrapped socket's close handler reports nothing
(`closeWithTLSSocket`). The TLS socket's close handler runs next and
reports the EOF or the error.
- The TLS socket closes the wrapped socket at `'end'`, or from
`_destroy` after it queued its `'error'`. `destroy(err)` gives node's
order: `tls error`, `raw close`, `tls close`.
- Node does the same: `TLSWrap` owns the reads
([wrap.js#L723-L727](https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L723-L727))
and `TLSWrap.close()` destroys the wrapped socket
([wrap.js#L676-L688](https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L676-L688)).
- Verified: two `node:test` files,
`node-tls-wrapped-socket-close.test.ts` (14 cells) and
`node-tls-raw-end.test.ts` (4 tests, by @alii). Node v26.3.0 passes all
18. Bun 1.4.2 fails 11. Main's `src/` fails all 18. This branch passes
all.

### Background

- The TLS socket adopts the fd. The wrapped socket keeps a raw handle
(`kAdoptedTLSRaw`).
- Only the TLS handle gets native events. Its `on_close`
(`src/runtime/socket/socket_body.rs`) calls the raw handle's close
handler, then the TLS socket's.
- A `close_notify` reaches only the TLS socket. These closes have none:
after the TLS socket's own FIN, a peer reset, `destroy()` by the owner.

<details><summary>Notes</summary>

**Scope.** #39066 made the `'close'` of the wrapped socket destroy the
TLS socket. #42265 emptied the buffer of the wrapped socket, so its
`'end'` is no longer held back by unread TLS bytes. Two PRs landed on
main after this one opened and repaired parts of the damage. #42293
removed the tick drain between the two close handlers, so the TLS socket
gets its `'end'` and its reset error again. #42176 keeps unread data
when the wrapped socket closes first. What is left on main: the wrapped
socket still reports events of its own, a reset is still reported on it
first with `hadError=true`, and `finished(tlsSocket)` fails after
`raw.end()`. None of these PRs is in a release.

**Test matrix.** Both files use `node:test` and `node:assert` only.
Under bun, the last test of `node-tls-wrapped-socket-close.test.ts` runs
the same file in Node.js.

| | `node-tls-wrapped-socket-close.test.ts` (14 cells) |
`node-tls-raw-end.test.ts` (4 tests) |
|---|---|---|
| Node.js v26.3.0, `node --test` | 14 pass (25 of 25 runs) | 4 pass |
| Bun 1.4.2, `bun test` | 9 fail, 5 pass | 2 fail, 2 pass |
| main b2ad29d, debug build of its `src/` | 14 fail | 4 fail |
| this branch, debug + ASAN | 14 pass | 4 pass |

The 5 cells that pass on Bun 1.4.2 are the `end()` cells with a TLS peer
or after the handshake, and the unread-data cell. They broke on main
after 1.4.2 (#39066, #42265). The other 9 were never right: 1.4.2
reports a reset on the wrapped socket, emits `raw end` and `raw finish`
on `destroy(err)`, and in 4 cells never closes one of the sockets (those
time out).

**Traces.** Events of the two sockets on the observed side, in order.
This branch prints node's trace in every cell.

| cell | node v26.3.0 and this branch | main b2ad29d |
|---|---|---|
| server wrap, `end()` one tick or one `setImmediate` after the wrap,
TLS or plain peer (4 cells) | `tls finish, tls end, raw close, tls
close` | `tls finish, raw end, tls end, raw close, tls close` |
| server wrap, `end()` after the handshake, peer answers `'end'` with
`destroy()` | same as above | same as above |
| `tls.connect({ socket })`, `end()` after the handshake, peer answers
`'end'` with `destroy()` | same as above | same as above |
| server wrap, peer answers `'end'` with 4 bytes then `destroy()`,
nothing reads until the connection closed | `tls finish, (closed,
unread=4), tls data late, tls end, raw close, tls close` | `tls finish,
raw end, raw finish, raw close, (closed, unread=4), tls data late, tls
end, tls close` |
| server wrap, peer reset before or after the handshake (2 cells) | `tls
error ECONNRESET, raw close, tls close hadError=true` | `raw error
ECONNRESET, raw close hadError=true, tls error ECONNRESET, tls close
hadError=true` |
| `tls.connect({ socket })`, peer reset after the handshake | same as
above | `raw error ECONNRESET, tls error ECONNRESET, tls close
hadError=true, raw close hadError=true` |
| `tlsServer.emit('connection', socket)`, peer reset before the
handshake | `tlsClientError ECONNRESET, raw close, tls close
hadError=true` | `raw error ECONNRESET, raw close hadError=true,
tlsClientError ECONNRESET, tls close hadError=true` |
| server wrap, owner calls `tlsSocket.destroy(err)` before or after the
handshake (2 cells) | `tls error, raw close, tls close hadError=true` |
`raw end, tls error, raw finish, raw close, tls close hadError=true` |
| `tls.connect({ socket })`, owner calls `tlsSocket.destroy(err)` after
the handshake | same as above | `raw end, tls error, raw finish, tls
close hadError=true, raw close` |
| server wrap, owner calls `raw.end()` (`node-tls-raw-end.test.ts`) |
node: `raw finish, tls end, tls finish, finished ok, raw close, tls
close`. This branch: `raw finish, tls end, raw close, tls finish,
finished ok, tls close` | `raw finish, raw end, tls end, raw close, tls
close, finished ERR_STREAM_PREMATURE_CLOSE` |

In the last row this branch still closes the wrapped socket at the
`'end'` of the TLS socket, so `raw close` comes before `tls finish`.
Node closes it when the TLS socket is destroyed. That order is the same
on main and is listed under "Not changed".

**The order for `destroy(err)`.** The first version of this PR closed
the wrapped socket from inside the native close, which runs inside
`_destroy` before `_destroy` queues the `'error'`. The events were `raw
close, tls error, tls close`. Now the close handler of the wrapped
socket only records that the TLS socket owes the close
(`kOwesRawClose`). `_destroy` pays it after `callback(err)`. A
`_destroy` that deferred the close of its handle (a handshake failure
closes it from a microtask) has returned by then, so the next tick pays
it. In both cases the `'error'` is already queued.

**Reset matrix.** Measured on the first commit against main b993710,
before #42293. A matrix of 24 cells of resets. Three shapes: server
wrap, socket injected into a `tls.Server`, client wrap. Two phases:
before and after the handshake. Four placements of the `'error'`
listener: on the wrapped socket, on the TLS socket, on both, on neither.
The peer is always a node process. This branch printed node's trace in
20 cells. The 4 cells that differ are client wraps whose TLS socket has
no `'error'` listener: node throws the ECONNRESET as an uncaught
exception. Bun closes a socket with no `'error'` listener without an
error, on every kind of socket. This PR does not change that.

**Related PRs.**
- #42293 (merged) made the TLS socket report a peer reset. Its two tests
pass on this branch.
- #42176 (merged) changed `onUpgradedClose` so that unread data
survives. This PR does not touch that function. With this PR the wrapped
socket no longer closes first in these cells, so that path is not
reached.
- #38028 moves the teardown of the wrapped socket into the TLS socket's
`_destroy` for every wrap. It has conflicts with main. The `_destroy`
call here covers only a wrapped socket whose fd has already closed.
- #36534 changes the native upgrade so that the raw handle gets no JS
dispatch. It has conflicts with main. If it lands, `closeWithTLSSocket`
can go. The cells still apply to it, because they assert node's events
only.

**Not changed, same as main.**
- `'end'` after a plain `destroy()`: bun emits one on plain `net` and
`tls` sockets too. The fixture of #42181 documents it. A TLS socket that
its owner destroys with no error still shows `tls end`. The wrapped
socket no longer shows `raw end`.
- A wrapped socket that its owner destroys directly (`raw.destroy()`):
`tls end, tls finish, tls close, raw close`. Node: `raw close, tls
close`.
- When the TLS socket gets its `'end'` while the fd is still open (a
peer that closes first with a `close_notify`, or `raw.end()`), `raw
close` comes before `tls finish`. Node has `tls finish` first.
- `destroy()` in the same tick as the wrap, before the fd is adopted,
does not close the wrapped socket. That is #38028.
- `end()` in the same tick as the wrap sends no FIN. That is #42339.

**Platforms.** The first commit (11 cells, as a fixture spawned on bun
and on node) was also built and run on Windows x64: a canary of main
failed the 11 bun cells, the branch passed all. The `destroy(err)` cells
and the `node:test` files were run on Linux x64 only.

**Other suites on this branch.** All of `test/js/node/tls/`,
`node-http2-upgrade.test.mts`, `socket-retention.test.ts`, and 508
vendored `test-tls-*`, `test-https-*`, `test-http2-*` files: 506 pass.
The two others also fail on main: `test-https-timeout.js` (debug build
only) and `test-tls-client-allow-partial-trust-chain.js` (needs the test
runner). Also on main in this container: `node-tls-server.test.ts`
"SNICallback runs even when the requested servername matches the bind
hostname" (`localhost` resolves to `::1` first).

</details>

<!-- robobun:evidence:begin -->

---

**[human-review]** gate passed · iteration 0 · 3 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 14 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/tls/node-tls-upgrade.test.ts
bun test v1.4.3 (09bb546)

test/js/node/tls/node-tls-upgrade.test.ts:
(pass) should be able to upgrade a paused socket and also have backpressure on it #15438 [1654.06ms]
(pass) tls.connect({ socket }) over a net.Socket with readable: false keeps the TLS bytes off the wrapped socket [334.63ms]
(pass) tls.connect({ socket }) over a net.Socket with an onread buffer keeps the TLS bytes off the wrapped socket [113.54ms]
(pass) tls.connect({ socket }) over a net.Socket with no reader keeps the TLS bytes off the wrapped socket [59.53ms]
(pass) a STARTTLS exchange hands no TLS bytes to the 'data' listeners of the wrapped sockets (#32239) [164.21ms]
182 |     ["net", "process.nextTick"],
183 |     ["net", "setImmediate"],
184 |   ])(
185 |     "new TLSSocket(socket, { isServer }) end()s before the handshake completes, %s peer, from %s",
186 |     async (peer, when) => {
187 |       expect(await run("end-before-handshake", peer, when)).toEqual(eof);
                                                     
... (truncated)

release without fix: 14 FAILED
bun test v1.4.3-canary.1 (09bb546)

test/js/node/tls/node-tls-upgrade.test.ts:
(pass) should be able to upgrade a paused socket and also have backpressure on it #15438 [55.44ms]
(pass) tls.connect({ socket }) over a net.Socket with readable: false keeps the TLS bytes off the wrapped socket [6.04ms]
(pass) tls.connect({ socket }) over a net.Socket with an onread buffer keeps the TLS bytes off the wrapped socket [2.90ms]
(pass) tls.connect({ socket }) over a net.Socket with no reader keeps the TLS bytes off the wrapped socket [2.57ms]
(pass) a STARTTLS exchange hands no TLS bytes to the 'data' listeners of the wrapped sockets (#32239) [3.81ms]
182 |     ["net", "process.nextTick"],
183 |     ["net", "setImmediate"],
184 |   ])(
185 |     "new TLSSocket(socket, { isServer }) end()s before the handshake completes, %s peer, from %s",
186 |     async (peer, when) => {
187 |       expect(await run("end-before-handshake", peer, when)).toEqual(eof);
                                                                  ^
error: expect(received).toEqual(expected)

  [
    "tls finish",
-   "tls end",
+   "raw end",
+   "raw finish",
    "raw close hadError=false",
    "tls close
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/tls/node-tls-upgrade.test.ts
bun test v1.4.3 (09bb546)

test/js/node/tls/node-tls-upgrade.test.ts:
(pass) should be able to upgrade a paused socket and also have backpressure on it #15438 [1471.02ms]
(pass) tls.connect({ socket }) over a net.Socket with readable: false keeps the TLS bytes off the wrapped socket [236.85ms]
(pass) tls.connect({ socket }) over a net.Socket with an onread buffer keeps the TLS bytes off the wrapped socket [84.87ms]
(pass) tls.connect({ socket }) over a net.Socket with no reader keeps the TLS bytes off the wrapped socket [75.39ms]
(pass) a STARTTLS exchange hands no TLS bytes to the 'data' listeners of the wrapped sockets (#32239) [188.67ms]
(pass) the close of a connection under a TLS socket and the net.Socket it wraps (bun) > new TLSSocket(socket, { isServer }) end()s before the handshake completes, tls peer, from setImmediate [2170.33ms]
(pass) the close of a connection under a TLS socket and the net.Socket it wraps (bun) > new TLSSocket(socket, { isServer }) end()s before the handshake comp
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1157ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/125] gen JS modules (bundle-modules)
Preprocess modules (8646ms)
Bundle modules (67ms)
Postprocesss modules (24ms)
Bundle Functions (433ms)
Generate Code (28ms)

[9.20s] Bundled "src/js" for production
  2600 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[1/8] cargo bun_runtime → libbun_runtime.a
^[[1m^[[92m   Compiling^[[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
^[[1m^[[92m   Compiling^[[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
^[[1m^[[92m   Compiling^[[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
^[[1m^[[92m   Compiling^[[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
^[[1m^[[92m   Compiling^[[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
^[[1m^[[92m   Compiling^[[0m bun_base64 v0.0.0 (/workspace/bun/src/base64)
^[[1m^[[92m   Compiling^[[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
^[[1m^[[92m   Compiling^[[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
^[[1m^[[92m   Compiling^[[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
^[[1m^[[92m   Compi
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
src/js/node/net.ts                                 |  37 +++-
 test/js/node/tls/node-tls-upgrade.test.ts          | 100 ++++++++++-
 .../node/tls/tls-wrapped-socket-close-fixture.mjs  | 193 +++++++++++++++++++++
 3 files changed, 321 insertions(+), 9 deletions(-)
```

</details>

**gate history** · 2 passed · 0 rejected · iteration 0

<details><summary>evidence per changed file</summary>

```
file                                                   reads  edits  tests
src/js/node/net.ts                                        19     21     51
test/js/node/tls/node-tls-upgrade.test.ts                  9      6     45
test/js/node/tls/tls-wrapped-socket-close-fixture.mjs      4      6     52
```

</details>

<!-- robobun:evidence:end -->

---------

Co-authored-by: Alistair Smith <hi@alistair.sh>
cirospaciari added a commit that referenced this pull request Sep 25, 2026
Builds on #42176, which is merged. The base of this PR is `main`.
Consolidates #42240 (the same fix).

### Problem

- A TLS upgrade over a generic `Duplex` listens for the transport's
`data`, `end`, `drain` and `close`, but not its `error`. So
`transport.destroy(err)` after the upgrade throws `err` as an uncaught
exception. Node emits it on the TLS socket.
- `transport.listenerCount('error')` is 0 in bun and 1 in node v26.3.0.
An `https.request` over such a socket kills the process and does not
fail the request.

### Fix

- `forwardUpgradedError` (`src/js/node/net.ts`) forwards the transport's
`error` through `_emitTLSError`. The four stream-engine attach sites
call it. A client sees `'error'`. A server wrap keeps it on
`'_tlsError'` (`'tlsClientError'` on a `tls.Server`). The transport's
`'close'` then ends the socket (#42176).
- A `net.Socket` transport (a named pipe, unflushed writes, TLS over
TLS) is excluded. Its close paths synthesize a read `ECONNRESET` as soon
as anything listens for `'error'`. See Notes.
- Verified: `test/js/node/tls/node-tls-connect.test.ts`, 5 new tests.
One runs `node-tls-duplex-transport-error-fixture.ts` through `bunRun`
(six wrap cases in one subprocess). All 5 fail on `main`. Also
`test/js/node/tls` and 9 vendored `test-tls-*` tests.

### Background

- A stream with no fd cannot use the kernel TLS path.
`upgradeDuplexToTLS` runs a BoringSSL engine over the stream, driven by
four native thunks on the stream's events. None is an error thunk.
- Node wraps the stream in a `JSStreamSocket`, which re-emits the
stream's `'error'`. `TLSSocket._init` forwards that error with
`_emitTLSError`.
- `_emitTLSError` already exists in `src/js/node/tls.ts`. It emits
`'_tlsError'` always, and `'error'` only once `_releaseControl` has run.
`tls.connect()` releases control at once, a server socket on `'secure'`.

<details><summary>Notes</summary>

#### Measurements

Each case wraps a `Duplex`, then calls `transport.destroy(new
Error("transport failed"))`. Events on the TLS socket (or the request),
linux x64, debug+ASAN. The first six rows are
`node-tls-duplex-transport-error-fixture.ts`, and the node column is
v26.3.0 with the same fixture. The `https.request` row runs in the test
process.

| case | `main` (has #42176) | this branch | node v26.3.0 |
| --- | --- | --- | --- |
| `tls.connect({ socket })`, destroy in the same tick | uncaught
`transport failed`, non-zero exit | `_tlsError`, `error`, `close:false`
| same |
| `tls.connect({ socket })`, destroy after the ClientHello | uncaught,
non-zero exit | `_tlsError`, `error`, `close:false` | same |
| server wrap, destroy in the same tick | uncaught, non-zero exit |
`_tlsError`, `close:false` | same |
| server wrap, destroy after the engine started | uncaught, non-zero
exit | `_tlsError`, `close:false` | same |
| `tlsServer.emit("connection", duplex)`, destroy in the same tick |
uncaught, non-zero exit | `tlsClientError` with the `TLSSocket`,
`close:false` | same |
| `tlsServer.emit("connection", duplex)`, destroy after the engine
started | uncaught, non-zero exit | `tlsClientError` with the
`TLSSocket`, `close:false` | same |
| `https.request` over the wrap | uncaught, non-zero exit | `req.error`,
`req.close` with `req.destroyed === true` | same |

A transport that errors without closing also matches node: the error
reaches the TLS socket and the socket stays alive (`destroyed ===
false`).

Two more cases from #42240, measured by hand on this branch (no test):

| case | bun 1.4.3-canary.1 | this branch | node v26.3.0 |
| --- | --- | --- | --- |
| `tlsSocket.destroy()` over `Duplex.from({ readable, writable })` |
uncaught `ABORT_ERR` | `error:ABORT_ERR`, `close:false` | same |
| transport whose `write` calls back with an error, `s.write(data, cb)`
queued | uncaught `EBOOM`, `cb` never called, no `'close'` |
`error:EBOOM`, `cb(ERR_SOCKET_CLOSED)`, `close:false` | `error:EBOOM`,
`cb(ECANCELED)`, `close:false` |

The callback code in the last row differs because node cancels the
queued write through `JSStreamSocket.doClose`. #35386 covers `ECANCELED`
for cancelled TLS writes.

#### Consolidation of #42240

#42240 carried the same listener with the same `net.Socket` guard. This
branch keeps the test shape the review here asked for (`bunRun`, a
fixture file, a one-line comment plus the node links). The cases that
only #42240 had are now in the fixture: a server wrap at both timings,
`https.request` over the wrap, `'_tlsError'`, and the `'close'`
`hadError` flag.

#42176 dropped the `attachUpgradedDuplex` helper that both PRs had
patched. It now attaches the four thunks inline at each site. So the
listener moved to `forwardUpgradedError`, which each site calls after it
attaches the four thunks.

#### Scope: every `net.Socket` transport is left out

Two arms wrap a `net.Socket`, and neither gets the listener:

- The fd-adoption arm. `tls.connect({ socket: connectedNetSocket })`
with no queued writes hands the fd to a native TLS pair (`upgradeTLS`)
and attaches nothing to the `net.Socket`.
- The stream-level engine over a `net.Socket`: TLS over TLS, a named
pipe, or a socket with queued plain writes. These reach
`forwardUpgradedError` and the `instanceof Socket` check skips them.

Node attaches the same listener in both, so `sock.destroy(err)` on such
a transport is still uncaught in bun. The reason is one mechanism.
`SocketEmitEndNT`, `SocketHandlers2.close`, `failWrite` and
`ServerHandlers.error` turn a peer reset into `destroy(ECONNRESET)` only
when the socket has an `'error'` listener, and stay silent otherwise. A
forwarder counts as a listener. The first push of #42240 attached it to
every transport. `test-tls-inception.js` (TLS over TLS, no `'error'`
listener anywhere) then failed on Windows x64, Windows arm64 and macOS
arm64 with an uncaught `read ECONNRESET`. On Windows x64 the base passed
5 of 5 runs, that push failed 5 of 5, and the narrowed listener passed 6
of 6.

Those arms also have a second route to the same failure (the raw
socket's own dispatch), so they need the de-duplication that #36534 and
#38122 are about.

`new tls.TLSSocket(stream)` with no `connect()` wraps nothing today, so
nothing reaches it there either. That is #37664's subject.
`_http2_upgrade.ts` has a fifth copy of the same four `.on()` calls.
#38124 routes its transport error to the h2 session.

#### Fixture startup

The fixture runs its six cases in one subprocess and takes the key and
the cert from `KEY` and `CERT`, which the test sets. An earlier shape
ran five subprocesses at once, and each imported `harness`. A debug
build needs about 1.5 s to load `node:tls` and about 1 s more for
`harness`, so on a loaded machine (load average 150 on 16 cores) two of
the five reached the 5 s test timeout. The one subprocess takes 2.1 to
2.8 s on the same machine.

#### Suites

`test/js/node/tls/node-tls-connect.test.ts`: 84 pass, 0 fail.

`test/js/node/tls/`: 391 pass, 3 fail, none on the Duplex wrap path.
`SNICallback runs even when the requested servername matches the bind
hostname` binds `localhost` and connects to `127.0.0.1`, and fails the
same way on the released binary in this container. `tls.Server socket
destroySoon > delivers the whole stream when destroySoon follows end`
(64 rounds of 2 MB) times out at 5 s on this debug build, and does the
same with `main`'s `net.ts`. `root certificate initialization >
concurrent Workers all see the same CA certificate lists` times out in
some runs on this loaded machine.

Vendored, all exit 0: `test-tls-inception`, `test-tls-js-stream`,
`test-tls-connect-given-socket`, `test-tls-delayed-attach-error`,
`test-tls-socket-failed-handshake-emits-error`,
`test-tls-over-http-tunnel`, `test-tls-starttls-server`,
`test-tls-destroy-stream`, `test-https-agent-create-connection`.

</details>

<!-- robobun:evidence:begin -->

---

**[human-review]** gate passed · iteration 0 · 3 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 7 failed, 18 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/tls/node-tls-connect.test.ts
bun test v1.4.3 (367d939)

test/js/node/tls/node-tls-connect.test.ts:
(pass) should have checkServerIdentity [4.73ms]
(pass) should thow ECONNRESET if FIN is received before handshake [534.04ms]
(pass) initializes authorizationError to null in the TLSSocket constructor [6.87ms]
(pass) setMaxSendFragment mirrors OpenSSL's [512, 16384] acceptance without throwing [120.38ms]
(pass) should be able to grab the JSStreamSocket constructor [14.03ms]
(skip) tls.connect > should work with alpnProtocols
(pass) tls.connect > Bun.serve() should work with tls and Bun.file() [116.64ms]
(pass) tls.connect > should have peer certificate when using self asign certificate [150.83ms]
(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
(skip) tls.connect > should process port 
... (truncated)

release without fix: 11 failed, 18 skipped
bun test v1.4.3-canary.1 (367d939)

test/js/node/tls/node-tls-connect.test.ts:
(pass) should have checkServerIdentity [0.03ms]
(pass) should thow ECONNRESET if FIN is received before handshake [6.17ms]
(pass) initializes authorizationError to null in the TLSSocket constructor [0.16ms]
(pass) setMaxSendFragment mirrors OpenSSL's [512, 16384] acceptance without throwing [3.30ms]
(pass) should be able to grab the JSStreamSocket constructor [0.21ms]
(skip) tls.connect > should work with alpnProtocols
(pass) tls.connect > Bun.serve() should work with tls and Bun.file() [5.48ms]
(pass) tls.connect > should have peer certificate when using self asign certificate [4.83ms]
(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
(skip) tls.connect > should process port and host correctly
(skip) tls.connect > should process port, host, and callback correctly
(skip) tls.connect > should handle the absence of a callback gracefully
(skip) tls.c
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: 18 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/tls/node-tls-connect.test.ts
bun test v1.4.3 (367d939)

test/js/node/tls/node-tls-connect.test.ts:
(pass) should have checkServerIdentity [3.79ms]
(pass) should thow ECONNRESET if FIN is received before handshake [386.28ms]
(pass) initializes authorizationError to null in the TLSSocket constructor [6.71ms]
(pass) setMaxSendFragment mirrors OpenSSL's [512, 16384] acceptance without throwing [102.58ms]
(pass) should be able to grab the JSStreamSocket constructor [15.93ms]
(skip) tls.connect > should work with alpnProtocols
(pass) tls.connect > Bun.serve() should work with tls and Bun.file() [97.47ms]
(pass) tls.connect > should have peer certificate when using self asign certificate [122.29ms]
(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
(skip) tls.connect > should process port a
... (truncated)

release with fix: 18 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     9098c05
  features     lto, baseline

23 deps, 136 codegen, 1176 objects in 977ms

ninja: Entering directory `/workspace/bun/build/release'
[1/4] fetch lolhtml
[lolhtml] up to date
[2/4] fetch rust-argon2
[rust-argon2] up to date
[2/4] cargo plan → /workspace/bun/build/release/rust-target/plan.json
244 units: 172 lib, 16 proc-macro (host), 19 custom-build (host), 15 run custom-build, 17 lib (host), 4 run custom-build (host), 1 rlib
[3/4] reconfigure
[1/1499] mkdir codegen
[2/1499] mkdir stamps
[3/1499] install /workspace/bun
bun install v1.4.3-canary.1 (367d939)

Checked 26 installs across 65 packages (no changes) [15.00ms]
[4/1499] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (367d939)

Checked 1 install across 2 packages (no changes) [4.00ms]
[5/1499] install /workspace/bun/src/node-fallbacks
bun install v1.4.3-canary.1 (367d939)

Checked 111 installs across 104 packages (no changes) [6.00ms]
[6/1499] rustc unicode_ident 
[7/1499] gen node-fallbacks
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
src/js/node/net.ts                                 | 10 ++++
 test/js/node/tls/node-tls-connect.test.ts          | 45 ++++++++++++++++-
 .../tls/node-tls-duplex-transport-error-fixture.ts | 58 ++++++++++++++++++++++
 3 files changed, 112 insertions(+), 1 deletion(-)
```

</details>

**gate history** · 5 passed · 0 rejected · iteration 0

<details><summary>evidence per changed file</summary>

```
file                                                      reads  edits  tests
src/js/node/net.ts                                           13     12     41
test/js/node/tls/node-tls-connect.test.ts                     3      4     39
…/js/node/tls/node-tls-duplex-transport-error-fixture.ts      1      3     44
```

</details>

<!-- robobun:evidence:end -->

---------

Co-authored-by: robobun <robobun@bun.sh>
Co-authored-by: Ciro Spaciari <ciro.spaciari@gmail.com>
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Superseded by #44618, which takes what still reproduced on main: a buffered ClientHello in several chunks, a paused transport, initialData echoed to the parent, unref() after adoption, and _parent, one test each. #32242 stays open as an issue.

Jarred-Sumner added a commit that referenced this pull request Oct 6, 2026
What is left of #36534 once the fd adoption stopped surfacing ciphertext:

- A ClientHello buffered as several chunks (read(1) + unshift() sniffing) was
  only half handed over, because read() returns one chunk: the handshake hung.
- A transport that is paused when the stream-level engine takes it never
  handshook: on('data') does not resume an explicitly paused stream, node's
  JSStreamSocket does.
- upgradeTLS() enabled the raw tap before it fed initialData, so the bytes the
  raw half had just handed over were shown to it again.
- ref() and unref() on the wrapped socket did nothing after the adoption, where
  node has one handle under both sockets.
- _parent was never set.
Jarred-Sumner added a commit that referenced this pull request Oct 7, 2026
What is left of #36534 once the fd adoption stopped surfacing ciphertext:

- A ClientHello buffered as several chunks (read(1) + unshift() sniffing) was
  only half handed over, because read() returns one chunk: the handshake hung.
- A transport that is paused when the stream-level engine takes it never
  handshook: on('data') does not resume an explicitly paused stream, node's
  JSStreamSocket does.
- upgradeTLS() enabled the raw tap before it fed initialData, so the bytes the
  raw half had just handed over were shown to it again.
- ref() and unref() on the wrapped socket did nothing after the adoption, where
  node has one handle under both sockets.
- _parent was never set.
Jarred-Sumner added a commit that referenced this pull request Oct 8, 2026
What is left of #36534 once the fd adoption stopped surfacing ciphertext:

- A ClientHello buffered as several chunks (read(1) + unshift() sniffing) was
  only half handed over, because read() returns one chunk: the handshake hung.
- A transport that is paused when the stream-level engine takes it never
  handshook: on('data') does not resume an explicitly paused stream, node's
  JSStreamSocket does.
- upgradeTLS() enabled the raw tap before it fed initialData, so the bytes the
  raw half had just handed over were shown to it again.
- ref() and unref() on the wrapped socket did nothing after the adoption, where
  node has one handle under both sockets.
- _parent was never set.
Jarred-Sumner added a commit that referenced this pull request Oct 10, 2026
What is left of #36534 once the fd adoption stopped surfacing ciphertext:

- A ClientHello buffered as several chunks (read(1) + unshift() sniffing) was
  only half handed over, because read() returns one chunk: the handshake hung.
- A transport that is paused when the stream-level engine takes it never
  handshook: on('data') does not resume an explicitly paused stream, node's
  JSStreamSocket does.
- upgradeTLS() enabled the raw tap before it fed initialData, so the bytes the
  raw half had just handed over were shown to it again.
- ref() and unref() on the wrapped socket did nothing after the adoption, where
  node has one handle under both sockets.
- _parent was never set.
Jarred-Sumner added a commit that referenced this pull request Oct 10, 2026
…ps, WebSocket, SQL) (#44618)

### What does this PR do?

Consolidates the open TLS pull requests into one. Each was reproduced on
`main` and, for `node:*` behavior, on Node v26.3.0 first. About a third
are ported as written, the rest are rewritten smaller or merged into one
fix where several PRs patched the same cause. One commit per fix, so it
can be read commit by commit.

Fixes #43520, fixes #31396, fixes #43635, fixes #37193, fixes #43846,
fixes #17932, fixes #41061, fixes #36887, fixes #31810, fixes #35240,
fixes #32234, fixes #44365, fixes #43807, fixes #42280, fixes #44517.
Addresses #41856 (SNI and `servername`; not `checkServerIdentity` for
SQL), #24845 (the spin is gone, shown with fault injection on Linux; not
run on macOS), #19754 (node-fetch forwards the agent's TLS options; the
Kubernetes client itself was not run).

#### The ones that matter most

| | On `main` | PRs |
|---|---|---|
| Client certificate disclosure | `https.request()` with a client
certificate sends it to a server it then refuses (wrong name,
`checkServerIdentity`, `destroy()` in `'secureConnect'`, `terminate()`
in `handshake`). A server can force it with a junk record behind its
Finished | #43946 |
| False `authorized` | Over a Duplex, `secureConnect` with `authorized
=== true` for a peer that failed the key proof; `secureConnect` for a
plaintext peer with `rejectUnauthorized: false` | #44422, #32929 |
| Cleartext https | `https.createServer()` without a usable key/cert
answers plain HTTP | #41672, #33539 |
| Revoked client certificates | An https mTLS server never sees `crl`,
so a revoked client is `authorized` | #41641 |
| Pooled sockets | Requests with different client certificates or CAs
share an `https.Agent` socket and session | #42498 |
| Silent plaintext | `tls: [...]` given to `Bun.listen` / `Bun.connect`
is plain TCP | #41490 |
| Server weakened by a client knob | `NODE_TLS_REJECT_UNAUTHORIZED=0`
turns off a server's client-certificate enforcement | #35245 |
| Pins never checked | `WebSocket` never calls `tls.checkServerIdentity`
and ignores `tls.serverName` | #41648 |
| `verify-full` dropped | `PGSSLMODE=verify-*` is lost next to a `TLS_*`
URL variable; `tls: true` sends no SNI | #44498 |
| Crashes | use-after-free from `destroy()` in `ALPNCallback` over a
Duplex; `abort()` on a late `setSession()`; SIGABRT in `fetch` with an
https proxy from the environment and a `Bun.file()` body | #44462,
#41671, #44458 |
| Stream corruption | A TLS `write()` can lose 16 KiB it reported as
written while another socket on the loop is stalled | #44529 |
| Hangs and spins | 100% CPU on a failing `send()`; a fatal `SSL_write`
leaves the socket open forever; `idleTimeout` never sheds a TLS client
that ignores `close_notify` | #34510, #38176, #42336 |
| Wrong certificate (regression since 1.3.14) | Connections accepted
before `stop()` / `close()` get the default certificate and skip their
entry's `requestCert` / `ca` | #42355 |
| Quadratic Duplex / proxy tunnel | Reading one chunk over a Duplex,
CPU: 8 MB 0.88 s → 0.14 s, 16 MB 3.18 s → 0.23 s, 32 MB 11.75 s → 0.39
s; `fetch` upload through CONNECT: 1.7 s → 0.18 s (debug build) | #44464
|

#### By area

- **fd engine, write path** (`openssl.c`, `socket.c`): #42352, #34510 +
#38176 + #42336 as one change, #44529, #44458, #44192. A rejected
`send()` ends the write side only and closes at the next writable event
unless the peer's bytes are still queued (a 413 sent before a reset is
still read). No new per-socket state. Also, on kqueue, **a FIN no longer
ends a socket that waits in the low-priority queue** (`loop.c`): with
more than 5 TLS handshakes at once, a client that ended right after its
handshake could be reset and its server socket report `socket hang up`,
because the eof that the sentinel read knote reports was acted on ahead
of the unread Finished. That is on `main` too (the macOS entry for
`node-tls-server.test.ts` in `test/flaky-tests.txt`: 7 of 48 recent
builds of other branches), and this branch made it likelier (6 of 8
builds), since Finished now leaves in one segment with the close_notify.
- **Error reporting, both engines**: #44422, #32929, #44516, #37094,
#41272 + #42324 + #44223 as one change, #44021, #37472, #43946, #33630.
One channel: a fatal error on an established session is reported, then
**the engine closes the connection itself**, whatever the owner does
with the report. `test/js/bun/net/tls-fatal-error-closes.test.ts`
asserts closed-and-nothing-delivered for every owner (node:tls,
`Bun.connect`, `Bun.listen`, `fetch` direct and through CONNECT,
`Bun.serve`, `WebSocket` direct and through a proxy, Postgres, MySQL,
Valkey, Duplex).
- **Duplex engine** (`SSLWrapper`, `UpgradedDuplex`): #44462, #43529,
#42332, #44464. #43877 + #44394 were in and are **out again**, see
"Worth a look" 5.
- **node:tls wrap lifecycle** (`net.ts`, `tls.ts`): #38007, #38058,
#38028 + #38122 + #38076 as one change (six copies of the attach code
become two helpers), #38311, #39008, #38154, #42340 + #42343 + #42339 +
#42453 as one change, #43791, #42425, #44085, #42683, #39088, #39040,
#40375, and what was still real of #36534.
- **SNI, ALPN, server contexts**: #43080, #42050, #37195 + #43849 as one
change (**one** SNI matcher for TCP and HTTP/3), #42355, #42285, #33253,
part of #37896, part of #37013. A `tls.Server` has one `SSL_CTX`.
- **Verification and options**: #44738, #41490, #37005 + the cwd pin of
#40984, #31811, #43982, #33483 + #35245, #41810, #32235, #44441, #38092.
- **node:tls API and CA store**: #41671, #38145, #32824, #43594, #39997,
#41696, #33534, #34748, #42991, #42996, #42970.
- **node:https, Agent, `ws`, node-fetch**: #41672 (https half), #41641,
#38261, #42498, #44346, #35609, #31397, #42325.
- **WebSocket client**: #41648, #37487 + #43048 as one change.
- **SQL, Redis**: #33666, #41711, #44498, part of #42054.
- **Tests only**: #41426, #40040, #44395, #44016, #37860, #40591,
#44440, #41424.

Found on the way and fixed here: an upload that a TLS 1.2 server
interrupts with a renegotiation never completes on `main` (0 of 32 runs
over `https.request`, `fetch`, `node:tls` and `Bun.connect`: the
renegotiation ClientHello lands inside an application record that is
still unsent, or the socket gets no `drain` again) and completes here,
with two tests from robobun; the fix for #40653 (final flight and first
write in one segment) stopped working whenever another TLS socket on the
loop was stalled, on `main` too; the `tls.Server` prototype pinned the
last server constructed and every `SSL_CTX` it owned;
`Object.create(process.env).NODE_TLS_REJECT_UNAUTHORIZED = "0"` turned
verification off process-wide once a `SHARE_ENV` worker existed; two
debug panics when wrapping a shut-down or still-connecting socket; a
`fetch` POST through a proxy sent its headers twice when the origin
renegotiated; `BlockList` ignored IPv6 zone ids; a test now ties
`root_certs.der` to `certdata.txt`.

#### Behavior changes

- **A server's `ca` without `requestCert` no longer asks for a client
certificate** (`Bun.serve`, `Bun.listen`, HTTP/3, node:tls). It matches
the docs and Node. On `main` such a server refused clients with no
certificate but served any unrelated self-signed one, so it was never
authentication. **Set `requestCert: true` to require a certificate.** A
matrix test pins that `requestCert: true` still refuses no certificate
and an untrusted one on 8 kinds of server, TLS 1.2 and 1.3, with
`NODE_TLS_REJECT_UNAUTHORIZED` unset and `0`.
- `NODE_TLS_REJECT_UNAUTHORIZED=0` no longer relaxes a server.
- `Bun.connect` / `Bun.listen` hear of a fatal TLS error after the
handshake through `error(socket, err)`. With no `error` handler the
socket just closes.
- HTTP/3 server names match like TCP: `*.` covers exactly one label,
case is ignored, a trailing dot is ignored, the last registration of a
name wins.
- `requestCert` on node:https is `=== true`, as in Node.
- An array where a generated options dictionary is expected throws
(`tls: []`, `jest.useFakeTimers([])`).
- `key` / `cert` arrays serve every identity. A client that can use both
gets ECDSA, where `main` served whichever pair came last.
- `ecdhCurve` is forwarded by node:https, `ws` and node-fetch now, so a
group BoringSSL lacks (`X448`) throws there as it already does in
`tls.createServer`.
- A wrapped socket's error is re-emitted on the TLS socket as in Node,
so `raw.destroy(err)` with a listener on `raw` only is uncaught, as in
Node.
- `sql.options.tls` is always an object, never `true`. `RedisClient`
sends SNI.
- `tls: { secureContext }` alone asks for TLS on `Bun.listen` /
`Bun.connect` (it was plain TCP), and a value that is not a
`SecureContext` throws. The context is served as it is: the
`requestCert` / `rejectUnauthorized` it was created with hold whatever
the options next to it say, and `requestCert` in the options over a
context that does not ask throws at `listen()`.
- `tls.DEFAULT_CIPHERS` reaches every client once assigned (`fetch`,
`WebSocket`, `Bun.connect`, `RedisClient`, `Bun.SQL`, `S3Client`, proxy
tunnels) and servers again. A list that selects no cipher throws
`ERR_SSL_NO_CIPHER_MATCH` at the assignment. `fetch.preconnect()` dials
nothing after an assignment.
- The warning for an unreadable `NODE_EXTRA_CA_CERTS` is Node's one
line, without the `warn:` prefix.
- `BUN_CONFIG_WS_CLOSE_TIMEOUT` (default 30 s): how long a `WebSocket`
client waits for the server to close the connection after the closing
handshake.

#### Worth a look in review

1. **#44529**: the kernel-refused remainder of a TLS write moves from
the loop's one slot onto the connection (in the existing rare struct),
so the write BIO never refuses a sealed record. Nothing is allocated on
an unstalled path (200 writes: 0 appends, same `send()` count as
`main`), memory with 16 stalled writers is lower than on `main` (276 KB
vs 340 KB, which `main` holds inside BoringSSL's buffers), `us_socket_t`
stays 80 bytes. It needs a bound on how long a deferred close waits, or
a peer that stops reading pins the fd past `destroy()`:
`US_SSL_CLOSE_AFTER_SPILL_TIMEOUT` is a fixed 10 s, not re-armed on
progress. Separate commits, but the fix that keeps the client
certificate off the wire beside a stalled socket builds on them.
2. **The default name check of node:tls also runs inside the
handshake**, so a wrong-name server gets no client certificate on TLS
1.2 either. JS still runs it after every successful handshake, so a
difference between the two matchers can only refuse. Error objects are
byte-identical.
3. **#44441** widens trust by design: a self-issued leaf whose
`keyUsage` lacks `keyCertSign` (`dotnet dev-certs`) is its own anchor
when the store holds a byte-identical copy. No BoringSSL change. Expired
pin, same subject with another key, wrong EKU and a pinned intermediate
are tested to fail.
4. **#32235** only adds Ed25519 and ECDSA P-521 to the verify list. A
captured ClientHello shows `main`'s list with the two inserted;
`rsa_pkcs1_sha1` stays.

5. **A stream that a TLS socket wraps, when that TLS socket closes.** An
earlier state of this branch lost data here while CI was green (found by
#44709's report): with the peer closing first, 4 of 8 MiB arrived with
TLS in TLS, 4 of 32 MiB on the http2 `emit("connection")` path, and a
`write()` with no `'error'` listener ended the process. Three
Node-parity changes only hold together: destroying the wrapped stream at
the close (#38028 + #38122 + #38076, #38154) is safe only if every write
has really completed (#43877), which in turn needs Node's handling of
the peer's close_notify, which needs half-open sockets that the GC can
collect. So:
- #43877 + #44394 are reverted and reopened. A write over a stream
completes once the stream has taken the ciphertext, as on `main`.
- Until the verdict on the peer lets the session through, the
application cannot have written over it. There the wrapped stream is
destroyed as in Node, with the sessions below it. That keeps the release
of the connection after a failed handshake, a rejected certificate and
an early `destroy()`. The same for an http2 socket the application never
got, and for `resetAndDestroy()`.
- After that it is `main`'s teardown: a `net.Socket` only gets the
engine's `end()`, closes at its peer's FIN, keeps its own timeout and
reports its own errors. Any other stream is destroyed with the TLS
socket.

The regular suites cannot see any of this (999 files were green on every
broken variant), so it was steered by eleven seeded differential fuzzers
run on this build, `main`, Node v26.3.0 and the earlier state: close,
`end()`, `destroy()`, `destroySoon()`, resets, hung and half-open peers,
paused writers, timeouts, two and three sessions deep, over TCP and over
Duplexes, before, at and after the handshake, and http2 requests. See
"How did you verify".

#### Known limits

- `fetch` with a `checkServerIdentity` function still sends the client
certificate (not the request) to a server the function refuses. On TLS
1.2 any verdict a JS callback gives is too late, as in Node.
- `addContext()` / `SNICallback` still do not apply to a server-side
socket on the stream engine (`emit("connection", duplex)`, TLS in TLS,
unflushed writes, named pipes), as on `main`.
- A CA bundled in a pfx extends an explicit `ca` only, for `ws` /
node-fetch / `WebSocket`: the native `ca` can only replace the default
store, and that store keeps `SSL_CERT_FILE` / `SSL_CERT_DIR`.
- P-521 leaves work on TLS 1.3 only. TLS 1.2 needs secp521r1 in every
ClientHello (`it.todo`).
- Once `tls.DEFAULT_CIPHERS` is assigned, `fetch(url, { protocol:
"http3" })` is `HTTP3Unsupported`, as with an explicit `ciphers`.
- `addCACert()` by hand does not extend the chains of a context with
several identities.
- A throwing `ALPNCallback` sends `no_application_protocol` on both
engines. Node sends nothing and its client sees `ECONNRESET`.
- TLS in TLS, peer FIN while the outer handshake runs: the inner socket
gets one `write EPIPE`, where Node gives `ECONNRESET` (`main` gives it
no error at all).
- `@SECLEVEL` in `ciphers` is dropped by the `ws` / node-fetch shims,
which used to ignore `ciphers`. node:tls keeps throwing
`ERR_SSL_INVALID_COMMAND`.
- Beside a stalled TLS socket only the first record (16 KiB) of the
first write leaves with the handshake flight. The rest goes record by
record, which is what bounds the memory of stalled writers.
- After a fatal error on an established session the socket emits
`'error'` and then `'close'`. Node emits `'error'` and leaves the socket
open.
- A paused reader whose own write the kernel rejects loses what it had
not read yet, with an `EPIPE`, as on Node. `main` reports no error there
and delivers it.
- On `main` too: a `Bun.listen` socket without `allowHalfOpen` that has
unsent ciphertext when the client's `shutdown()` arrives loses that
ciphertext (32 KiB), and over plain TCP `end()` with the peer still
sending is a close over unread input, so a reset.
- Differences from both `main` and Node that the differential runs below
found and that stay, all with a peer that aborts: `ECONNRESET` instead
of a clean `'end'` after the socket's own `'finish'` when the peer
destroyed with unread data; under TLS 1.2, a zero-length `write()`
followed by `destroy()` in `'secureConnection'` leaves the client
without `'secureConnect'` (a plain `destroy()` there matches Node); a
TLS 1.2 client that destroys in `'secureConnect'` gets no `'session'`; a
`ClientRequest` whose handshake fails with an alert emits `'error'` and
`'close'` but no `'finish'` (`writableFinished` is true).
- Once `tls.DEFAULT_CIPHERS` is assigned, `fetch.preconnect()` opens
nothing: `fetch()` then uses a context of its own, and a socket warmed
under the default one would never be picked up.
- A TLS `send()` that the kernel refuses outside a `write()` call (the
drain of unsent ciphertext) is reported with the close, as `read EPIPE`
/ `read ECONNRESET`. Node says `write EPIPE`. `main` does not report it
at all.
- Once the application has a session over a `net.Socket` (TLS in TLS,
http2 `emit("connection")`), a peer that never sends its FIN holds that
socket after the TLS socket closed, as on `main`. Node destroys it. Two
tests of #38154 are `todo` for this. Closing it any earlier (at its
`'finish'`, say) makes the kernel drop what it has not sent yet as soon
as the peer's close_notify arrives.
- Plaintext that was queued on a socket before it was wrapped (STARTTLS
with a backlog) is dropped when the TLS socket is destroyed, or its
handshake fails, before the session is accepted. Node drops it too,
except on `destroySoon()`. `main` sends it.
- Over a stream that is no `net.Socket`, `end()` can still cut what that
stream has buffered, and there is no backpressure, both as on `main`
(#43877).
- `tls.secureContext` (the undocumented door node:tls uses) is not read
by a Windows named pipe listener, which builds its context from the
options. On `upgradeTLS({ isServer: true })` the options next to it are
the policy, as with Node's `SetVerifyMode`.
- `selectServerName()` rebuilds the name tree per ClientHello for
injected sockets of a server with `addContext()` entries: 0.4 µs for 1
entry, 3.7 µs for 10, 41 µs for 100, against 631–1111 µs for a
handshake.

#### Not included

Left open, because they need a decision or are not TLS: #43877 + #44394
(see "Worth a look" 5; #43874 stays open with them), #38548, #38591
(both shrink who is trusted), #41589 (`verify-full` vs
`NODE_TLS_REJECT_UNAUTHORIZED=0`), #37197, #41706, #43216, #33487,
#33545, #36707, #32435, #37255, #28691, #40275, #30314 (features),
#38120 (needs the BoringSSL fork, as did #33517, which the stale bot has
closed since), #38529 (needs a Windows measurement), #34342, #38232,
#43089, #44454, #40451, #42710, #44527, #38088, #38093, #41898. #37896,
#42054 and #37013 stay open for the halves not taken.
`http.createServer({ key, cert })` keeps serving TLS on purpose.

One open question: `tls: {}` (an object that names no TLS option) is
plain TCP on `Bun.listen` / `Bun.connect`, here and on `main`. It is the
same trap as `tls: []`, but changing it changes a Bun default, so it is
left alone.

### How did you verify your code works?

- Every new test fails on `main` for the stated reason and passes here,
except guards that pin existing behavior, each shown to fail when its
clause is removed. `node:*` tests also pass on Node v26.3.0; the few
that cannot say which Node version has the behavior.
- 212 test files that touch TLS, sockets, http, http2, fetch, WebSocket,
SQL, Valkey and workers: 6154 pass, 2 fail. Both are seen on `main` too:
`serve.test.ts` "root range port" (the box runs as root), and
`worker_threads.test.ts` "terminate(): nothing of the worker's runs
after the request", which is flaky there and passed in the run below.
- 58 of those files the way the ASAN lane runs them (LeakSanitizer +
`BUN_JSC_validateExceptionChecks`): 58 files, 48 of them with leak
checking, 4132 pass, 3 fail. All three also fail on `main`:
`serve.test.ts` "root range port", `node-net.test.ts` "should not leak
when connect({path}) fails synchronously on a reused handle" (times out
under this environment), `worker_threads.test.ts` "process.exit() with a
shell cp in flight" (a `ShellCpTask` leak).
- 647 vendored `test-tls-*`, `test-https-*`, `test-net-*`,
`test-http2-*`: the only two failures also fail on `main`.
- The SNI matcher was diffed against both old matchers: 3 seeds × 1.23 M
lookups × 3 registration flavours, every difference in one of the
intended classes, TCP and HTTP/3 identical on every lookup.
- The headline rows were also driven by hand with scripts against this
build, `main` and Node v26.3.0: cleartext https, `crl`, `tls: []` / `{
secureContext }`, the `ca` / `requestCert` matrix, the client
certificate on a wrong-name server, late `setSession()`, `destroy()` in
`ALPNCallback`, `[rsa, ec]` identities with an intermediate from `ca`,
`WebSocket` `checkServerIdentity`, a corrupted record, the Duplex read
above, `tls.DEFAULT_CIPHERS`.
- The `setSession()` guard was checked against the real `abort()` at 43
handshake states.
- `bun run rust:check-all`: 12 of 12 targets. `tsc`, oxlint, source
lints, prettier, rustfmt, mordant clean.
- usockets' `_Nonnull` is compiled out of debug builds, so 105 of those
files were also run on a local release ASAN build with the CI runner's
environment (92 with leak checking): 4595 pass, 1 fail,
`child_process.test.ts` "spawn reports EPERM after dropping privileges",
which cannot pass as root and fails on `main` too.
- The close of a TLS socket over another stream ("Worth a look" 5):
eleven seeded differential fuzzers, 8,424 scenarios compared, each run
on a release ASAN build of this branch, on `main`, on Node v26.3.0 and
on the earlier state of the branch. Against `main`:
- Data that `main` delivers in full is cut in 5 scenarios, and about 150
that `main` cuts arrive in full. Of the 5, in 2 `main` never notices the
peer's close and keeps the socket for good, 2 call `end()` on the middle
one of three sessions over an in-memory Duplex, and 1 does the same on
Node.
- No dead timeout, no silent reset and no uncaught error that `main`
does not have (4 uncaught errors fewer).
- A socket stays open where `main` closes it in 109, and closes where
`main` keeps it in 295. 92 of the 109 do the same on Node or on the
earlier state (a `destroy()` that an in-memory Duplex does not show its
peer, half-open peers). 14 wait for a peer that paused reading and so
does not read the FIN (#42332's backpressure, as in Node); the socket's
own timeout fires there. 3 are left: one on a 5 ms timer, two with three
sessions over an in-memory Duplex.
- The earlier state of the branch cut data in 173 of the 400 scenarios
of one of them, where `main` cuts none and this cuts none.
- 23 new tests pin what they found. Each earlier attempt at this fix
fails the ones that describe it, the earlier state of the branch fails
7, and all pass on Node.
- After that change: 999 test files on the release ASAN build (20,246
pass; the 11 files that fail need a database, Docker, DNS or a non-root
user, or share a temp directory with a parallel run and pass alone), 61
on the debug build.
- TLS over a file descriptor (`openssl.c`, the path of `fetch`,
`Bun.serve`, `tls.connect`, `Bun.connect`) got the same treatment after
the rebase: seeded differential fuzzers on CI's release build of this
branch, on `main` and, for `node:*`, on Node v26.3.0. Every runtime also
against itself for the noise floor, injected faults and known bugs of
`main` as positive controls, and a difference counts only if it shows in
5 of 5 fresh processes.
- `node:tls` over TCP: 11,500 scenarios (one connection with Node as the
oracle line by line; 2 to 60 connections beside stalled neighbours; raw
peers that break the handshake). HTTPS: about 136,000 runs over
`Bun.serve` + `fetch`, `node:https`, `node:http2` and `wss://`, also
with the two ends in different runtimes. `Bun.connect` / `Bun.listen` /
`upgradeTLS`: 11,500 scenarios and 720 slow connections, with writers
driven by what `write()` returns, beside up to 6 stalled, dripping,
closing or resetting neighbours, and plain TCP as a second oracle. No
crash, hang, duplication, reordering or silent truncation, and no change
in time or in connection reuse.
- They found six things that `main` does better, none of which any test
showed. All are fixed, each with a test that fails on the build before:
what the peer sent lost behind a rejected `send()` (23 scenarios, and an
early HTTPS response lost with only `EPIPE`), the same silently for a
paused reader, `server.close()` never calling back on a half-open server
after a ClientHello and a reset (17), `closeAllConnections()` taking 12
s with a stalled client, `end()` losing up to 1.3 of 4 MiB that
`write()` had reported while the peer still uploads, and `end()` a
little after a stall never closing beside other stalled TLS sockets. The
last two fixes also deliver the 1 to 2 MiB that `main` loses there, and
close the socket that `main` keeps for good without such neighbours.
- All of them again after every fix, on CI's release build of it. That
caught one regression of a fix itself (a reader stopped for backpressure
lost 86,385 bytes, 1 of 6,000 scenarios), fixed too. On the last build:
scenarios that lose data where `main` does not 23 → 2, and Node loses it
in both, with the same `EPIPE`; `server.close()` that never calls back
17 → 0; connections held 4 → 0; requests that end in an error only where
`main` has a response 6 → 0. With a Node server in another process, a
request ends in an error only in 8 and 10 of 1,500 scenarios here, 5 and
3 on `main`, 10 with Node as the client.
- `Bun.connect` / `Bun.listen` on the last build against `main`, in
scenarios: hangs 0 against 1,031, sockets and fds never released 0
against 965, corrupted data 0 against 345, `abort()` 0 against 26
(`setSession()` after the handshake), writers that never close 0 against
101 of 720 connections. No kind of failure shows here and not on `main`.
About a third of the slow connections close later than on `main`, in 1
to 16 s instead of at once, waiting for unsent ciphertext or for the
peer's close_notify, and 79 more of them deliver all that `write()`
reported. RSS and time with 16 to 256 stalled writers are the same.
- What they found that `main` does worse: a `WebSocket` that calls
`close()` with sends pending loses messages in 81 of 999 scenarios (0
here), 37 server sockets left open, 10 `server.close()` that never call
back, 20 write callbacks that never run.
- The kqueue fix cannot be run on Linux. The `connectionListener` count
test now says what became of a missing connection, which is how the
cause was found (`'tlsClientError'` "socket hang up", then `read
ECONNRESET` at the client of the same port, after its
`'secureConnect'`). On macOS x64 it failed every attempt of the three
builds before the fix and passed at the first attempt of the build with
it.
- Windows and macOS were only run by CI. Four new tests asserted what
only the Linux kernel does (a FIN read ahead of a reset, unread bytes
surviving a reset, loopback buffer sizes, `fstat()` on a socket) and now
say so per platform.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: robobun <117481402+robobun@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants