Repository navigation
Conversation
…n the meantime Socket.prototype._destroy defers the handle close to a microtask when _closeAfterHandlingError is set (server-side TLS handshake failures), and closeSocketHandle re-read this._handle when that microtask ran. When the server-side TLS engine runs over a stream (upgradeDuplexToTLS: a wrapped connection with unflushed writes, or a generic Duplex), the engine dispatches its close callback right after the failed handshake, inside the same stream event, and that callback detaches _handle before the microtask runs. The deferred close then found no handle and the wrap never emitted 'close'. Capture the handle in _destroy and pass it to closeSocketHandle, so the deferred close tears down and reports the handle destroy() found regardless of what detached it since. Closing a handle that already closed natively is a no-op.
|
Warning Review limit reached
Next review available in: 14 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
Status: fix and tests pushed (latest 14f056c), waiting on CI. Reproduced on the released build (1.4.0-canary.1) and on a debug build of main: a server-side TLS socket whose handshake is failed while JS is on the stack emits Five of the six cases in |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. The change is small and the analysis is thorough, but it modifies Socket.prototype._destroy — the teardown path every net/tls socket goes through — and removes the null guard in closeSocketHandle, so a maintainer look would still be worthwhile.
What was reviewed:
- Confirmed both
closeSocketHandlecall sites are insideif (this._handle)withcurrentHandlecaptured immediately after, so the removed null guard cannot be reached with a null handle. - Checked the synchronous branch is behavior-identical (
currentHandle === this._handleat that point) andcloseAdoptedTLSRawNTis a separate path that doesn't callcloseSocketHandle. - Looked for double-
'close'risk —_destroyruns once per socket anddetachSocketonly nulls_handlewithout emitting, so the deferred emit remains the sole emitter.
Extended reasoning...
Overview
This PR fixes a missing 'close' event on server-side TLSSocket wraps that use the stream-level TLS engine (i.e., when the underlying connection has unflushed writes or is a generic Duplex) and the handshake fails. The fix is in src/js/node/net.ts: _destroy now passes the already-captured currentHandle into closeSocketHandle instead of having closeSocketHandle re-read self._handle, which may have been nulled by detachSocket before the deferred microtask runs. Four new tests in test/js/node/tls/node-tls-server.test.ts cover the fd-adopting wrap, unflushed-write wrap, generic Duplex wrap, and mid-handshake disconnect.
Security risks
None identified. This is lifecycle/event-emission ordering; no user input parsing, no auth/crypto/permission logic.
Level of scrutiny
High. Socket.prototype._destroy (net.ts:2177-2225) runs for every net.Socket teardown across net, tls, http, and https. The change removes a defensive if (handle) guard in closeSocketHandle and relies on the invariant that both callers pass a non-null captured handle, and that handle.close() on an already-natively-closed handle is a no-op. The PR description argues both convincingly and reports running the full net/tls suites plus 109 vendored Node tests, but socket lifecycle in this file is one of the most historically delicate areas of the codebase.
Other factors
- Verified there are exactly two
closeSocketHandlecallers (net.ts:2205, 2212), both inside theif (this._handle)block withcurrentHandle = this._handlecaptured on line 2184, so the removed null guard is provably unreachable with null. - The synchronous call site (line 2212) is behavior-identical: nothing between capture and call can mutate
_handle. - The tests await the actual
'close'event (not sleeps), wire failure paths to reject, clean up infinally, useport: 0, and cover the variant matrix (adopting/unflushed/Duplex/disconnect) — they meet the repo's test-quality bar. - The only claim I can't verify statically is that
handle.close()on a wrapper whose native side already dispatched close is a safe no-op; the PR description states it is and cites the detached-state check, but that depends on native handle behavior a maintainer should confirm.
|
For the one point left open in the review, the "second
The new engine-path tests exercise exactly this sequence (native close first, then the deferred |
The lost 'close' is not specific to the stream-level TLS engine: the native close callback detaches _handle ahead of the deferred close whenever the failed handshake is dispatched with JS already on the stack, which an asynchronous SNICallback rejection (resumeSNI runs from the user's callback) does on a plain tls.Server and on an fd-adopting wrap as well. Describe that condition at the _destroy branch and add both fd-backed cases to the test matrix. closeAdoptedTLSRawNowNT was the same close-then-report sequence as closeSocketHandle now that the latter takes the handle; use it directly.
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes the net.Socket destroy/close-emit path — lifecycle code that every TCP/TLS connection goes through, with correctness resting on the native-side "close() on a detached handle is a no-op" invariant — a human look would still be worthwhile.
What was reviewed:
- All
closeSocketHandlecallers pass a provably non-null handle (captured inside theif (this._handle)guard), so dropping the null check is safe. closeAdoptedTLSRawNTnow delegates tocloseSocketHandlewithisCleanupPendingdefaulting to false — behavior-identical to the deletedcloseAdoptedTLSRawNowNT, andemitCloseNTremains live (used at net.ts:2218).- Argument reordering of
closeAdoptedTLSRawNTis consistent between call site and definition; no other callers exist.
Extended reasoning...
Overview
The PR changes closeSocketHandle in src/js/node/net.ts to accept the handle as an explicit argument (captured by the caller at destroy time) rather than re-reading self._handle, which can have been nulled by detachSocket between _destroy scheduling the deferred close and the microtask actually running. This fixes server-side TLS wraps on the stream-level engine path never emitting 'close' after a handshake failure. It also folds closeAdoptedTLSRawNowNT into closeSocketHandle (deduplication) and adds six regression tests to test/js/node/tls/node-tls-server.test.ts.
Security risks
None identified. The change is confined to teardown-event ordering; no auth, crypto, input parsing, or trust decisions are touched.
Level of scrutiny
High. Socket.prototype._destroy and closeSocketHandle sit on the teardown path of every net/tls/http connection in Bun. The fix's correctness depends on a native-side invariant (a second close() on a detached socket wrapper is a no-op — the author cited socket_body.rs / uws_sys/socket.rs / keep_alive.rs) and on 'close' being emitted exactly once by whichever _destroy branch runs. That reasoning is well laid out and I found no counterexample, but lifecycle code with cross-layer invariants is exactly where a maintainer's eye is most valuable.
Other factors
- All three
closeSocketHandlecall sites and the onecloseAdoptedTLSRawNTcall site were updated consistently; the argument reorder ofcloseAdoptedTLSRawNTmatches at both ends. No stale callers. - Removing the
if (handle)guard is justified: every caller passescurrentHandle, captured insideif (this._handle). closeAdoptedTLSRawNowNTwas deleted;emitCloseNT(its former dependency) is still referenced by the no-handle branch of_destroy, so no dead code was introduced.- Tests cover the variant matrix (fd-adopting / unflushed-write / Duplex / peer-disconnect / async-SNI on both wrap and
tls.Server), await the actual'close'event, and clean up infinally. The PR description confirms fail-without-fix on the three engine-path cases. - The comment-cop bot's inline feedback about long comments was addressed in follow-up commits and all threads are resolved.
- No CODEOWNERS match the changed files.
|
Follow-up after self-review (c19943c, 26773c6, 14f056c; the net.ts logic is unchanged from the first revision):
The CodeRabbit comments above are its rate-limit notices; there is no review content in them to act on. |
|
Heads-up on an overlap: #42330 also edits |
…ists (#44196) A TLS socket over a `stream.Duplex` (`tls.connect({ socket })`, `new TLSSocket(duplex)`) has no fd. Bun runs a TLS engine over the stream, and a queued task starts that engine after the wrap. "Before the engine" below is the rest of the script, `process.nextTick`, microtasks and promise jobs. The lost EOF needs the close hook of #39066. The freed listener functions need the `pending_close` branch of #42176. No release has either. ### What the TLS socket emits | The transport, before the engine | Node v26.10 | main | This PR | |---|---|---|---| | Client: EOF, close | `end`, ECONNRESET, `close` true | `close` false | as Node | | Server wrap: EOF, close | `end`, `close` false | `close` false | as Node | | Client: bytes, EOF, close | `end`, ECONNRESET, `close` true | `close` false | as Node | | Server wrap: bytes, EOF, close | `end`, `close` false | `close` false | as Node | | Client: non-TLS bytes, EOF | ERR_SSL_HTTP_REQUEST, `close` true | `secureConnect`, `end`, `close` false | `end`, ECONNRESET, `close` true | | Server wrap: a complete ClientHello, EOF | `end`, 1 write | `end`, 1 write | as Node | | Server wrap: a complete ClientHello, EOF, close | `close` false | `close` false | `end`, `close` false | Last row: Node writes its answer to the ended transport, and that failure closes the socket ahead of `end`. The same rows after the engine started do not change. ### What happens to the transport | Case | Node v26.10 | main | This PR | |---|---|---|---| | `tls.connect({ socket: netSocket }).destroy()` in the same tick | raw socket closed, peer gets a FIN | raw socket open, no FIN | as Node | | The socket or the transport is destroyed after the engine started | no `end()`, `writableEnded` false | `end()` on the destroyed transport, `writableEnded` true | as Node | | 16 wraps closed before the engine, a GC, then 8 new wraps | | 7 of 8 never reach `secureConnect` | 8 of 8 answer | Last row: the close left the four listener functions of the context set. The GC freed them, and the task that frees the context then wrote into the freed cells. ### What the transport throws | Case | Node v26.10 | main | This PR | |---|---|---|---| | The `write` getter throws (the ClientHello) | `uncaughtException` | the exception stays pending on the VM (debug build: panic) | `error` on the socket, `close` true | | The `end` getter throws, close after the engine started | `end` is not read | the exception stays pending on the VM (debug build: assertion) | `uncaughtException` | | `end()` throws, close after the engine started | `end()` is not called | dropped | `uncaughtException` | | `end()` or its getter throws, close before the engine | `end()` is not called | `end()` is not called | `uncaughtException` | The `end` rows need a transport that is not ended and not destroyed when the socket closes. ### Behavior changes | Case | Before | After | |---|---|---| | Close before the engine, transport not ended and not destroyed | the transport is left as it is | `end()` is called on it | | `end()` of the transport throws during the close | dropped | uncaught exception | | Close after the engine started, transport destroyed | `end()` is called, `writableEnded` true | no call, `writableEnded` false | | EOF before the engine | held until the engine starts | reported inside the transport's `'end'` | | Client, EOF only, before the engine | 1 write (the ClientHello) | 0 writes (Node: 1) | | Client, bytes ahead of an EOF, before the engine | the engine reads them | not read, so an SSL error in them shows as ECONNRESET | | Server wrap, `socket.end()` in an `'end'` listener | transport `writableEnded` true, `final` not called | `writableEnded` false, `final` not called (Node: true, `final` called, see #42350) | ### Not in this PR - #32929: non-TLS bytes give a client `secureConnect`. - #38058: a server wrap whose handshake fails gets no `'close'`. - #43392: an extra `'end'` ahead of `'close'` when the socket is destroyed after the engine started. - #42350: `socket.end()` ends the transport. - #38028: destroy a wrapped `net.Socket`, as Node does. This PR ends it. ### Tests `node-tls-connect.test.ts` and `node-tls-duplex-close-throw-uaf.test.ts`: 25 fail on main (9f70da0), 0 fail with this PR. `test/js/node/tls` and `test/js/node/http2`: 1077 pass, 0 fail. Debug + ASAN, macOS arm64. <!-- robobun:evidence:begin --> --- **[human-review]** gate passed · iteration 3 · 4 files touched <details><summary>fails on main (without fix)</summary> ```console ASAN without fix: 15 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 test/js/node/tls/node-tls-duplex-close-throw-uaf.test.ts bun test v1.4.3 (367d939) test/js/node/tls/node-tls-duplex-close-throw-uaf.test.ts: (pass) tls.connect({socket: Duplex}) does not read freed Handlers > when a pre-open duplex error races StartTLS [1332.69ms] (pass) tls.connect({socket: Duplex}) does not read freed Handlers > when duplex.end() throws after close [1443.48ms] 25 | const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); 26 | 27 | // On failure stderr carries the ASAN "use-after-poison" report; include 28 | // it in the assertion so the diff shows the crash rather than just an 29 | // empty stdout. 30 | expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ stdout: expected, stderr: "", exitCode: 0 }); ^ error: expect(received).toEqual(expected) { "exitCode": 0, "stderr": "", "stdout": - "the duplex closes, end() calls: 1 - the soc ... (truncated) release without fix: 34 failed, 22 skipped bun test v1.4.3-canary.1 (367d939) test/js/node/tls/node-tls-duplex-close-throw-uaf.test.ts: (skip) tls.connect({socket: Duplex}) does not read freed Handlers > when duplex.end() throws after close (skip) tls.connect({socket: Duplex}) does not read freed Handlers > when a pre-open duplex error races StartTLS (skip) tls.connect({socket: Duplex}) does not read freed Handlers > when duplex.end() throws after a close that comes before StartTLS (skip) tls.connect({socket: Duplex}) does not read freed Handlers > when an EOF listener destroys the socket and throws before StartTLS test/js/node/tls/node-tls-connect.test.ts: (pass) should have checkServerIdentity [0.20ms] (pass) should thow ECONNRESET if FIN is received before handshake [8.07ms] (pass) initializes authorizationError to null in the TLSSocket constructor [0.16ms] (pass) setMaxSendFragment mirrors OpenSSL's [512, 16384] acceptance without throwing [3.07ms] (pass) should be able to grab the JSStreamSocket constructor [0.23ms] (skip) tls.connect > should work with alpnProtocols (pass) tls.connect > Bun.serve() should work with tls and Bun.file() [42.60ms] (pass) tls.connect > should have peer certificate when ... (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 test/js/node/tls/node-tls-duplex-close-throw-uaf.test.ts bun test v1.4.3 (367d939) test/js/node/tls/node-tls-duplex-close-throw-uaf.test.ts: (pass) tls.connect({socket: Duplex}) does not read freed Handlers > when duplex.end() throws after close [2203.98ms] (pass) tls.connect({socket: Duplex}) does not read freed Handlers > when a pre-open duplex error races StartTLS [1670.45ms] (pass) tls.connect({socket: Duplex}) does not read freed Handlers > when duplex.end() throws after a close that comes before StartTLS [2263.36ms] (pass) tls.connect({socket: Duplex}) does not read freed Handlers > when an EOF listener destroys the socket and throws before StartTLS [2238.29ms] test/js/node/tls/node-tls-connect.test.ts: (pass) should have checkServerIdentity [2.07ms] (pass) should thow ECONNRESET if FIN is received before handshake [276.06ms] (pass) initializes authorizationError to null in the TLSSocket constructor [6.64ms] (pass) setMaxSendFragment mirrors OpenSSL's [512, 16384] acceptance without th ... (truncated) release with fix: 22 skipped $ bun scripts/build.ts --profile=release [configured] bun-profile → bun (stripped) target linux-x64-gnu build type Release build dir ./build/release revision 562c022 features lto, baseline 23 deps, 136 codegen, 1176 objects in 4630ms 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 stamps [2/1499] mkdir codegen [3/1499] install /workspace/bun bun install v1.4.3-canary.1 (367d939) Checked 26 installs across 65 packages (no changes) [730.00ms] [4/1499] rustc unicode_ident [5/1499] rustc build_script_build [6/1499] rustc heck [7/1499] install /workspace/bun/packages/bun-error bun install v1.4.3-canary.1 (367d939) Checked 1 install across 2 packages (no changes) [110.00ms] [8/1499] rustc build_script_build [9/1499] rustc unicode_xid [10/1499] rustc build_script_build [11/1499] rustc build_s ... (truncated) ``` </details> <details><summary>diff hotspot</summary> ``` src/runtime/socket/UpgradedDuplex.rs | 41 +-- src/runtime/socket/socket_body.rs | 2 +- test/js/node/tls/node-tls-connect.test.ts | 332 ++++++++++++++++++++- .../tls/node-tls-duplex-close-throw-uaf.test.ts | 103 ++++++- 4 files changed, 451 insertions(+), 27 deletions(-) ``` </details> **gate history** · 1 passed · 3 rejected · iteration 3 <details><summary>evidence per changed file</summary> ``` file reads edits tests src/runtime/socket/UpgradedDuplex.rs 8 6 68 src/runtime/socket/socket_body.rs 7 2 68 test/js/node/tls/node-tls-connect.test.ts 6 5 54 test/js/node/tls/node-tls-duplex-close-throw-uaf.test.ts 2 1 43 ``` </details> <!-- robobun:evidence:end --> --------- Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
|
Superseded by #44618, which consolidates the open TLS pull requests. This fix and its tests are in there, either as written, rewritten smaller, or merged with the other PRs that patched the same cause (see the "By area" list in that PR). Closing in favor of it. |
…ed close ran (#38058) A failed server-side handshake destroys the socket with _closeAfterHandlingError, which closes the handle from a microtask. That microtask re-read self._handle. When the failure is reported under a JS call (the stream-level engine fed from a 'data' listener, resumeSNI() from an asynchronous SNICallback) the native close runs first and detaches the handle, so nothing was closed and 'close' was never emitted. _destroy already holds the handle: pass it. closeAdoptedTLSRawNowNT was the same function, so it is gone.
…ed close ran (#38058) A failed server-side handshake destroys the socket with _closeAfterHandlingError, which closes the handle from a microtask. That microtask re-read self._handle. When the failure is reported under a JS call (the stream-level engine fed from a 'data' listener, resumeSNI() from an asynchronous SNICallback) the native close runs first and detaches the handle, so nothing was closed and 'close' was never emitted. _destroy already holds the handle: pass it. closeAdoptedTLSRawNowNT was the same function, so it is gone.
…ed close ran (#38058) A failed server-side handshake destroys the socket with _closeAfterHandlingError, which closes the handle from a microtask. That microtask re-read self._handle. When the failure is reported under a JS call (the stream-level engine fed from a 'data' listener, resumeSNI() from an asynchronous SNICallback) the native close runs first and detaches the handle, so nothing was closed and 'close' was never emitted. _destroy already holds the handle: pass it. closeAdoptedTLSRawNowNT was the same function, so it is gone.
Problem
'error'(or'tlsClientError'on atls.Server) but never'close', althoughsocket.destroyedbecomestrue. Node emits'error'followed by'close'withhadError === true.new tls.TLSSocket(conn, { isServer: true })running on the stream-level TLS engine (taken whenconnstill has unflushed plain writes, or is a genericDuplex), and the peer then sends non-TLS bytes or disconnects: the engine is fed from a'data'listener.tls.Server(or an fd-adopting wrap) whose asynchronousSNICallbackrejects the connection, the usual shape of an SNI-routing server turning away unknown hostnames:resumeSNIfails the handshake from inside the user's callback._closeAfterHandlingError) makesSocket.prototype._destroy(src/js/node/net.ts:2193) defercloseSocketHandleto a microtask so the'error'listeners can still see the connection, andcloseSocketHandlere-readself._handlewhen it finally ran. Bun's native close callback (ServerHandlers.close->detachSocket) nulls_handle, and on the dispatches above it runs synchronously right after the handshake callback, inside the same native call, before any microtask checkpoint (microtasks drain only when the outermost event-loop entry unwinds,src/jsc/event_loop.rsexit()). The deferred close then found no handle and returned without emitting. A failure dispatched straight from the event loop (for example an fd-adopting wrap fed bad bytes) drains the microtask first, which is why that ordering worked.UNABLE_TO_GET_ISSUER_CERTinstead ofERR_SSL_WRONG_VERSION_NUMBER) is a separate bug fixed by node:tls: report the fatal TLS alert when a handshake over a Duplex fails #32929; this PR leaves it alone and the tests do not assert the code.Fix
_destroypasses the handle it found (currentHandle) intocloseSocketHandle, which closes and reports that handle instead of re-reading_handle.closeAdoptedTLSRawNowNTwas the same close-then-report sequence, so the adopted raw-socket branch now callscloseSocketHandletoo.net.Socketemits'close'exactly once, from whichever teardown branch its_destroytakes. The native close callback deliberately does not emit it (it only detaches the handle and pushes EOF, relying on_destroy), so once_destroyhas run the deferred branch is the only emitter left and must not depend on_handlestill being attached. The synchronous branch already tolerates the detach (_destroyre-checksthis._handleaftercloseSocketHandlereturns, becausehandle.close()dispatches the native close re-entrantly); this gives the deferred branch the same tolerance. It is also what Node'scloseSocketHandleeffectively does: nothing in Node detaches_handlebehind the stream's back, so it always closes the handledestroy()found.src/runtime/socket/socket_body.rs:2057),close()on a detached wrapper does nothing (src/uws_sys/socket.rs:343), and the event-loop unref it performs is idempotent (src/io/keep_alive.rs:43). When nothing detached the handle in between,currentHandle === this._handleand the behaviour is byte-for-byte the old one.test/js/node/tls/node-tls-server.test.ts, describe "server-side handshake failure emits 'close'": six cases (fd-adopting wrap fed bad bytes; unflushed-write wrap fed bad bytes; genericDuplexwrap fed bad bytes; unflushed-write wrap whose peer disconnects; fd-adopting wrap rejected by an asynchronousSNICallback;tls.Serverrejected by an asynchronousSNICallback, asserting'close'on the socket handed totlsClientError). Five wait forever for'close'on the unfixed build; all six pass with the fix; the expected sequences were checked against Node v26.3.0 with the probes below.node-tls-server.test.ts,node-tls-connect.test.ts,node-tls-upgrade.test.ts, thetest/js/node/netsuites, and 89 vendored Nodetest-tls-*/test-net-*/test-https-*close, destroy, SNI and handshake-failure tests (includingtest-tls-socket-closefor the adopted raw-socket branch andtest-tls-sni-optionfor asynchronous SNI), all passing. The only failures seen were pre-existing in this container and reproduce without the diff (tests bindinglocalhostwhile connecting to127.0.0.1, and one 60s leak test).Background
_closeAfterHandlingError: Node's server-side handshake failure path destroys the socket with the error but keeps_handleattached until the next microtask so'error'/'tlsClientError'listeners can still inspect the connection;closeSocketHandlethen closes the handle and emits'close'. Bun mirrors this.detachSocket: Bun's native close callbacks nullsocket._handlebecause the native object is gone. It is the one way_handlecan disappear betweendestroy()and the deferred close, and it never emits'close'itself.resumeSNI,_handle.close()) does not get one until the outer JS frame finishes, so anything native does right after that callback, such as dispatching the close, runs before the microtask.new TLSSocket(conn, { isServer: true })normally adopts the connection's fd into a native TLS socket driven by the C TLS code. When the fd cannot be adopted (pending plain bytes must go out first, or there is no fd),upgradeDuplexToTLSruns a BoringSSL engine over the stream instead, fed by the stream's'data'events.Event logs from the probes (events on the server-side socket)
The
'error'on a mid-handshake disconnect is pre-existing behaviour on both wrap flavours and out of scope; that test only asserts that'close'arrives and that itshadErroragrees with whether an'error'was emitted.net.ts debug trace of an unfixed failing case (
BUN_DEBUG_JS=net), showing the native close detaching the handle before the deferred close runs:The first revision of this PR described the problem as specific to the stream-level engine; review of the dispatch path showed the condition is the nested dispatch above, which the asynchronous
SNICallbackcases also reach on the plain fd path. The code change is the same; the description, comments and test matrix were widened to match.