Skip to content

node:tls: inherit allowHalfOpen from the wrapped socket - #39066

Merged
cirospaciari merged 5 commits into
mainfrom
farm/0471a3cf/tls-wrap-inherit-allow-half-open
Sep 11, 2026
Merged

cirospaciari merged 5 commits into
mainfrom
farm/0471a3cf/tls-wrap-inherit-allow-half-open

Conversation

@robobun

@robobun robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • new tls.TLSSocket(socket, options) and tls.connect({ socket }) hard-code allowHalfOpen: false on the TLS socket (src/js/node/tls.ts:738), whatever the wrapped socket was created with; tls.connect({ socket, allowHalfOpen: true }) conversely turns it on. Node takes the wrapped socket's own value and only honors the option when the TLS socket opens its own connection (wrap.js#L592; connect() passes options.socket as that argument, #L1756-L1757).
  • Effect: a STARTTLS-style server, net.createServer({ allowHalfOpen: true }, raw => new TLSSocket(raw, { isServer: true, key, cert })), gets allowHalfOpen === false sockets, which end themselves the moment the client ends, so the server cannot answer after the client's EOF. Same for sockets injected into a tls.Server (the 'connection' listener in tls.ts) and for tls.connect({ socket }).
  • Node 26.3.0 prints true for every wrapped half-open case below, Bun prints false (and true for tls.connect({ socket: regular, allowHalfOpen: true }), where Node prints false). Full matrix in the details block.

Fix

  • tls.ts: the constructor copies allowHalfOpen from the wrapped socket, taken from the first argument or from options.socket (the tls.connect({ socket }) path); with no wrapped socket the option applies as before. This is the fixing hunk; it matches Node.
  • net.ts: with the value inherited, a wrap over a half-open connection (every plain stream.Duplex is one by default) no longer ends itself when its transport goes away, so the connection closing underneath it has to take it down, which Node does with wrap.on('close', () => this.destroy()) (wrap.js#L739-L741). destroyWhenUpgradedCloses arms that listener at the seven upgrade sites. kCloseRawConnection (Bun's own retirement of an fd-upgraded net.Socket object when the TLS socket gets 'end') removes it first, so the peer's EOF still leaves a half-open wrap alive; when the connection's owner had already destroyed it, the pending 'close' is the real thing and stays armed. Without this hunk renegotiation.test.ts ("exceeds the renegotiation limit over a duplex socket") hangs and the four "wrapped socket closing" tests below time out.
  • Why the JS layer is the right place: Bun's net layer already keeps the native socket half-open and lets the Duplex's allowHalfOpen decide what happens at EOF (see the comment in kConnectTcp), and an adopted fd keeps its native flags through us_socket_adopt, so the constructor was the only point where the wrapped socket's setting was dropped.
  • Scope: on current main the native TLS layer still closes a node:tls socket when the peer's close_notify arrives (ssl_wants_eof_dispatch in openssl.c is uWS-only); node: tls/https v26 compat wave 2 — allowHalfOpen close_notify, fetch setDefaultCACertificates, https.Server setSecureContext/keylog/TLSSocket (+6 tests) #35535 extends it to node:tls sockets. This PR fixes the stream layer (the property, no automatic end(), teardown on transport close). With the flag forced to false, node: tls/https v26 compat wave 2 — allowHalfOpen close_notify, fetch setDefaultCACertificates, https.Server setSecureContext/keylog/TLSSocket (+6 tests) #35535 would not have applied to wrapped sockets at all; with both, a half-open wrap also gets its late writes delivered.
  • The existing test/js/node/tls/node-tls-socket-allow-half-open-option.test.ts asserted false for new TLSSocket(new Duplex(), { allowHalfOpen: true }); Node returns true there (a Duplex defaults to allowHalfOpen: true), so those assertions encoded the bug. The file now asserts the "option is ignored" property in both directions, plus the cases below.
  • Verified with test/js/node/tls/node-tls-socket-allow-half-open-option.test.ts (property matrix for both construction forms, server wrap / injected tls.Server socket / tls.connect({ socket }) staying writable after the peer's EOF, a regular wrap still ending itself, and the four upgrade paths being destroyed when the wrapped socket or duplex closes):
    • bun bd test with src/ stashed: 9 of 11 fail (the 2 that pass are the unchanged-behavior contracts); with the diff: 11 pass.
    • with only the tls.ts hunk: the 4 "wrapped socket closing" tests time out.
    • test/js/node/tls/renegotiation.test.ts: 8 pass.
    • all 245 upstream test-tls-* / test-https-* files in test/js/node/test/parallel pass on the debug build.
    • test/js/node/tls/, test/js/node/net/, test/js/node/http2/, test/js/node/http/: the remaining failures (localhost resolving to a different family than the listener, and 5s timeouts of subprocess tests on the debug+ASAN build) reproduce without the diff.

Background

  • allowHalfOpen is a Duplex option. When it is false, the stream calls end() on itself once its readable side ends (the peer's EOF), so the socket closes by itself; when it is true, the application decides when to end its own side. net.Socket defaults to false, stream.Duplex to true.
  • A "wrap" is TLS over an already established socket (STARTTLS). The two public forms are new TLSSocket(socket, ...) and tls.connect({ socket }). Bun performs either by adopting the connection's fd into the TLS engine (a net.Socket with a handle) or by running the engine over the stream itself (a generic Duplex, TLS over TLS, or a socket with unflushed plain writes); net.ts has one upgrade site per form and path, seven in total.
  • In net.ts, this[kupgraded] is the TLS socket's pointer to the connection it was upgraded over. On the fd path, kCloseRawConnection runs on the TLS socket's 'end' and destroys the original net.Socket object, which no longer owns the fd; that destroy emits a 'close' on the connection that has nothing to do with the transport.
Node vs Bun outputs

Property matrix (node v26.3.0 / bun 1.4.0, before this change):

                                                                    node   bun
new TLSSocket(net.Socket{allowHalfOpen:true}, {allowHalfOpen:false}) true   false
new TLSSocket(net.Socket{}, {allowHalfOpen:true})                    false  false
new TLSSocket(Duplex{}, {allowHalfOpen:false})                       true   false
new TLSSocket(Duplex{allowHalfOpen:false}, {allowHalfOpen:true})     false  false
new TLSSocket()                                                      false  false
new TLSSocket(undefined, {allowHalfOpen:true})                       true   true
tls.connect({socket: net.Socket{allowHalfOpen:true}, allowHalfOpen:false}) true   false
tls.connect({socket: net.Socket{}, allowHalfOpen:true})              false  true
tls.connect({socket: PassThrough, allowHalfOpen:false})              true   false
socket injected into tls.createServer() from net.createServer({allowHalfOpen:true})  true  false

Server wrap over a half-open raw socket, client ends after the handshake, server writes from its 'end' handler:

node:  raw.allowHalfOpen=true tls.allowHalfOpen=true / server end writableEnded=false / server late write cb ok / server finish / client end got="late" / client close
bun:   raw.allowHalfOpen=true tls.allowHalfOpen=false / server end writableEnded=false / client end got="" / client close

(The "late" bytes reaching the client additionally needs #35535; with this PR alone the wrap stays open at the stream layer and closes when the application ends it.)

Close propagation with the diff (TLS socket's events; raw close on the EOF line is Bun's existing retirement of the wrapped object):

                              node                          bun (this PR)
peer EOF, half-open raw       tls end                       tls end | raw close
peer EOF, regular raw         tls end | raw close | tls close   same
raw.destroy(), half-open raw  raw close | tls close         tls end | raw close | tls close
underlying duplex closes      tls end | ... | tls close     tls end | ... | tls close

Before the net.ts hunk, the last two rows never reached tls close.


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

fails on main (without fix)
ASAN without fix: 9 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-socket-allow-half-open-option.test.ts
bun test v1.4.3 (4ff919377)

test/js/node/tls/node-tls-socket-allow-half-open-option.test.ts:
58 |         halfOpenSocket: new TLSSocket(halfOpenSocket, { allowHalfOpen: false }).allowHalfOpen,
59 |         defaultSocket: new TLSSocket(defaultSocket, { allowHalfOpen: true }).allowHalfOpen,
60 |         defaultDuplex: new TLSSocket(defaultDuplex, { allowHalfOpen: false }).allowHalfOpen,
61 |         duplexWithoutRead: new TLSSocket(duplexWithoutRead, { allowHalfOpen: false }).allowHalfOpen,
62 |         halfOpenDisabledDuplex: new TLSSocket(halfOpenDisabledDuplex, { allowHalfOpen: true }).allowHalfOpen,
63 |       }).toEqual({
              ^
error: expect(received).toEqual(expected)

  {
-   "defaultDuplex": true,
+   "defaultDuplex": false,
    "defaultSocket": false,
-   "duplexWithoutRead": true,
+   "duplexWithoutRead": false,
    "halfOpenDisabledDuplex": false,
-   "halfOpenSocket": true,
+   "halfOpenSocket": false,
  }

- Expected  - 3
+ Received  + 3

      at <ano
... (truncated)

release without fix: 9 FAILED
bun test v1.4.3-canary.1 (4ff919377)

test/js/node/tls/node-tls-socket-allow-half-open-option.test.ts:
58 |         halfOpenSocket: new TLSSocket(halfOpenSocket, { allowHalfOpen: false }).allowHalfOpen,
59 |         defaultSocket: new TLSSocket(defaultSocket, { allowHalfOpen: true }).allowHalfOpen,
60 |         defaultDuplex: new TLSSocket(defaultDuplex, { allowHalfOpen: false }).allowHalfOpen,
61 |         duplexWithoutRead: new TLSSocket(duplexWithoutRead, { allowHalfOpen: false }).allowHalfOpen,
62 |         halfOpenDisabledDuplex: new TLSSocket(halfOpenDisabledDuplex, { allowHalfOpen: true }).allowHalfOpen,
63 |       }).toEqual({
              ^
error: expect(received).toEqual(expected)

  {
-   "defaultDuplex": true,
+   "defaultDuplex": false,
    "defaultSocket": false,
-   "duplexWithoutRead": true,
+   "duplexWithoutRead": false,
    "halfOpenDisabledDuplex": false,
-   "halfOpenSocket": true,
+   "halfOpenSocket": false,
  }

- Expected  - 3
+ Received  + 3

      at <anonymous> (/workspace/bun/test/js/node/tls/node-tls-socket-allow-half-open-option.test.ts:63:10)
(fail) TLSSocket allowHalfOpen > new TLSSocket(socket) takes the wrapped socket's allowHalfO
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/tls/node-tls-socket-allow-half-open-option.test.ts
bun test v1.4.3 (4ff919377)

test/js/node/tls/node-tls-socket-allow-half-open-option.test.ts:
(pass) TLSSocket allowHalfOpen > new TLSSocket(socket) takes the wrapped socket's allowHalfOpen, ignoring the option [215.12ms]
(pass) TLSSocket allowHalfOpen > without a socket to wrap, the option is honored [9.46ms]
(pass) TLSSocket allowHalfOpen > tls.connect({ socket }) takes the given socket's allowHalfOpen, ignoring the option [186.77ms]
(pass) TLSSocket allowHalfOpen > over a connection > a server-side wrap of a half-open socket stays writable after the peer ends [873.79ms]
(pass) TLSSocket allowHalfOpen > over a connection > a server-side wrap of a regular socket ends itself after the peer ends, even with allowHalfOpen: true [771.76ms]
(pass) TLSSocket allowHalfOpen > over a connection > a socket injected into a tls.Server keeps its own allowHalfOpen [770.41ms]
(pass) TLSSocket allowHalfOpen > over a connection > tls.connect({ socket }) over a half-open socket stays writabl
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 622ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/21] gen JS modules (bundle-modules)
Preprocess modules (8266ms)
Bundle modules (65ms)
Postprocesss modules (22ms)
Bundle Functions (479ms)
Generate Code (28ms)

[8.87s] Bundled "src/js" for production
  2599 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[1/5] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_runtime v0.0.0 (/workspace/bun/src/runtime)
�[1m�[92m    Finished�[0m `release` profile [optimized + debuginfo] target(s) in 4m 46s
[2/5] link bun-profile
[4/5] strip bun
[4/5] bun-profile --revision
1.4.3-canary.1+10b45d1a0
[build] done
bun test v1.4.3-canary.1 (10b45d1a0)

test/js/node/tls/node-tls-socket-allow-half-open-option.test.ts:
(pass) TLSSocket allowHalfOpen > new TLSSocket(socket) takes the wrapped socket's allowHalfOpen, ignoring the option [6.23ms]
(pass) TLSSocket allowHalfOpen > without a socket to wrap, the option is honored [0.14ms]
(pass) TLSSocket allowHalfOpen > tls.connect({ socket }) takes the given socket's allowHalfO
... (truncated)
diff hotspot
src/js/node/net.ts                                 |  18 +
 src/js/node/tls.ts                                 |  11 +-
 .../node-tls-socket-allow-half-open-option.test.ts | 419 ++++++++++++++++++++-
 3 files changed, 435 insertions(+), 13 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                      reads  edits  tests
src/js/node/net.ts                                            0      0      0
src/js/node/tls.ts                                            0      0      0
…node/tls/node-tls-socket-allow-half-open-option.test.ts      0      0      0

root cause · written by the author bot

TLSSocket discarded the allowHalfOpen setting of the socket it wrapped and had no link back to the underlying transport's close event, so a TLS connection could remain open after its raw socket had been torn down or end prematurely when the peer sent EOF. The fix makes TLSSocket inherit allowHalfOpen from the wrapped socket or options.socket, and registers close tracking across all TLS upgrade paths in net so that the TLS wrapper is destroyed when its underlying connection closes, with a guard against recursive teardown. This matches Node.js behavior while preserving buffered data and half-…

new TLSSocket(socket) and tls.connect({ socket }) forced allowHalfOpen to
false on the TLS socket; node takes the wrapped socket's own value and only
honors the option when the TLS socket opens its own connection.

With the value inherited, a TLS socket over a half-open connection (any
generic Duplex defaults to allowHalfOpen: true) no longer ends itself when
the peer ends, so the connection closing underneath it has to take it down
the way node's wrap 'close' listener does. Arm that at every upgrade site;
the internal retirement of an fd-upgraded net.Socket on 'end' is excluded
so a half-open wrap still survives the peer's EOF.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: a3a8afde-0646-4642-8df5-d51493c7c2ba

📥 Commits

Reviewing files that changed from the base of the PR and between 838da26 and 10b45d1.

📒 Files selected for processing (3)
  • src/js/node/net.ts
  • src/js/node/tls.ts
  • test/js/node/tls/node-tls-socket-allow-half-open-option.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.


Walkthrough

Changes

TLS socket lifecycle

Layer / File(s) Summary
Preserve half-open settings and validate closure behavior
src/js/node/tls.ts, test/js/node/tls/node-tls-socket-allow-half-open-option.test.ts
TLSSocket preserves allowHalfOpen from wrapped sockets and options.socket. Tests cover client, server, injected-socket, half-open, and closure paths.
Track upgraded connection closure
src/js/node/net.ts
Upgraded TLS wrappers are destroyed when their underlying connections close. Cleanup applies across client and server upgrade paths and avoids recursive teardown. TCP instrumentation also starts for successful asynchronous dispatches returning 0.

Suggested reviewers: cirospaciari

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 10b45

This change makes wrapped TLS sockets inherit the half-open setting of the socket they wrap and destroys a TLS socket when its underlying transport closes, matching Node.js behavior. The lifecycle handling preserves buffered data and half-open semantics after peer EOF, and the tracking is applied consistently across all TLS upgrade paths, so the change looks safe to merge.

🚥 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 and concisely describes the primary change: inheriting allowHalfOpen from the wrapped socket.
Description check ✅ Passed The description explains the problem, implementation, scope, compatibility behavior, and verification results. It does not use the exact template headings, but it provides the required information, in…

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

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: merged. CI passed on build #114126 and @cirospaciari approved. Reproduced on the released binary with USE_SYSTEM_BUN=1 bun test test/js/node/tls/node-tls-socket-allow-half-open-option.test.ts (every wrapped socket reported allowHalfOpen === false); the test matrix and Node outputs are in the PR description.

Comment thread src/js/node/net.ts
…uctor

Arm it before the 'end' retirement at the fd upgrade sites as well, so
kCloseRawConnection always finds it set.
Comment thread src/js/node/net.ts Outdated
Comment thread src/js/node/net.ts Outdated
Comment thread src/js/node/net.ts Outdated
Comment thread src/js/node/tls.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for adding the kOnUpgradedClose constructor init in 2431401 — that addresses the earlier nit. I re-reviewed and found no bugs. Given this rewires TLS-wrap teardown across all seven upgrade sites in net.ts and adds a subtle interaction between kCloseRawConnection and the new close listener, a human look would still be worthwhile.

On the four comment-cop flags: those comments are Node-source citations for the compat behavior (wrap.js line refs), not workaround justifications, so they read as false positives to me — but a maintainer should make that call.

What was reviewed:

  • allowHalfOpen inheritance in the TLSSocket constructor for both new TLSSocket(socket, ...) and tls.connect({ socket }) paths against the Node reference.
  • All seven destroyWhenUpgradedCloses call sites and the kupgraded guard in onUpgradedClose.
  • The !connection.destroyed conditional in kCloseRawConnection — removes the listener only when the retirement's own destroy() would emit the spurious close; a real owner-initiated close stays armed.
  • Test file: property matrix, half-open-stays-writable, and the four wrapped-socket-closing cases; error events wired to reject, resources released in finally.
Extended reasoning...

Overview

The PR changes allowHalfOpen handling for wrapped TLS sockets to match Node.js: a TLSSocket built over an existing socket/duplex inherits that socket's allowHalfOpen instead of hard-coding false (or honoring the caller's option on the tls.connect({ socket }) path). Because a half-open wrap no longer auto-ends itself on the peer's EOF, net.ts gains a destroyWhenUpgradedCloses helper that arms a 'close' listener on the wrapped connection at all seven upgrade sites so the TLS socket is torn down when its transport goes away — mirroring Node's wrap.on('close', () => this.destroy()). kCloseRawConnection (Bun's fd-adoption retirement path) is updated to remove that listener before it destroys the retired net.Socket object, so the spurious 'close' from retirement doesn't tear down a half-open wrap; a connection its owner already destroyed keeps the listener armed. The test file is rewritten from a 2-assertion stub (which encoded the bug) into an 11-test suite covering the property matrix, live-connection behavior, and close propagation for each upgrade path.

What changed since the last review

My previous run left one nit (initialize this[kOnUpgradedClose] in the Socket constructor for shape stability). Commit 2431401 addressed it — the diff now includes this[kOnUpgradedClose] = undefined; next to this[kupgraded] = null;. No new bugs were found on this pass.

Security risks

TLS-adjacent but not a security-boundary change: no cert verification, cipher selection, or trust-store logic is touched. The change affects stream-layer half-open semantics and lifecycle teardown. The main risk class is a leaked/hung socket if the close listener is disarmed on the wrong path, or premature teardown if it fires on a spurious close — both are exercised by the new tests (four "wrapped socket closing" cases plus the "stays writable after peer ends" cases).

Level of scrutiny

Moderate-to-high. The tls.ts hunk is small and directly matches the cited Node source. The net.ts changes are the part that warrants human eyes: seven call sites, a new per-instance listener slot, and a conditional in kCloseRawConnection whose correctness depends on understanding Bun's fd-adoption retirement (a Bun-specific mechanism with no Node analogue). The PR description explains this thoroughly and the test coverage is comprehensive (property matrix in both directions, injected tls.Server sockets, all four upgrade-path close scenarios, plus the author reports 245 upstream test-tls-*/test-https-* files passing), but the interaction is subtle enough that a maintainer familiar with the net.ts upgrade machinery should confirm the seven sites are the complete set and that the !connection.destroyed gate is the right discriminator.

Other factors

  • The comment-cop bot flagged four multi-line comments. Reading them, they cite Node source lines for the compat behavior being matched (wrap.js#L592, #L739-L741, #L1756-L1757) rather than justifying a workaround, so they look like heuristic false positives — but that's a maintainer call and they are technically unaddressed.
  • The rewritten test file replaces assertions that encoded the pre-fix (wrong) behavior; the PR description justifies this against Node 26.3.0 output.
  • Tests follow harness conventions: port: 0, error events wired to reject the awaited promise, cleanup in finally, describe.concurrent for the independent connection tests.

Comment thread src/js/node/net.ts Outdated
Comment thread src/js/node/tls.ts Outdated
Comment thread src/js/node/tls.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I reviewed this PR and didn't find any bugs; the earlier nit about initializing this[kOnUpgradedClose] in the Socket constructor is now addressed. Because it changes TLS-wrap lifecycle across all seven upgrade sites in net.ts (plus the !connection.destroyed gate in kCloseRawConnection) and rewrites an existing test file's assertions, a human look would still be worthwhile.

What was reviewed:

  • allowHalfOpen inheritance in the TLSSocket constructor for both the positional-socket and options.socket paths — matches the cited Node wrap.js behavior.
  • All seven destroyWhenUpgradedCloses call sites line up with the upgrade points in Socket.prototype.connect and bunUpgradeServerTLS; the bound listener uses a named module-level function and the new symbol slot is initialized in the constructor.
  • The kCloseRawConnection interplay: removeListener is guarded on !connection.destroyed, and onUpgradedClose re-checks self[kupgraded] === connection before destroying — no stale-listener destroy path found.
  • Test rewrite: the old assertions encoded the bug (new TLSSocket(new Duplex(), { allowHalfOpen: true }).allowHalfOpen === false); the new assertions were checked against the Node v26.3.0 matrix in the PR description.
Extended reasoning...

Overview

The PR fixes Node compat for allowHalfOpen on wrapped TLS sockets. src/js/node/tls.ts changes the TLSSocket constructor to inherit allowHalfOpen from the wrapped socket (positional socket argument or options.socket) instead of hard-coding false. src/js/node/net.ts adds a 'close' → destroy() listener on the wrapped connection at each of the seven TLS-upgrade sites (destroyWhenUpgradedCloses), a new kOnUpgradedClose symbol slot, and a removeListener in kCloseRawConnection gated on !connection.destroyed so Bun's own retirement of the fd-upgraded raw object does not tear the half-open TLS wrap down. The test file is fully rewritten from a two-assertion stub (which asserted the bug) into an 11-test suite covering the property matrix, behavioral half-open tests over real connections, and four wrapped-socket-closing scenarios.

Security risks

None identified. The change is stream-lifecycle semantics, not the TLS handshake, certificate verification, or any crypto path. No security options are read or defaulted differently.

Level of scrutiny

Medium-high. node:tls / node:net socket lifecycle is a critical, subtle code path where event-ordering mistakes surface as hangs or leaked sockets. The change touches seven upgrade sites and introduces a listener whose removal depends on whether connection.destroyed was set before kCloseRawConnection runs — that interplay is well-argued in the PR description and pinned by tests from both directions, but it is not a mechanical change. The test file rewrite also replaces assertions that were previously green; the PR description justifies this against Node v26.3.0 output, and REVIEW.md permits updating tests that encoded a bug, but a human confirming the Node-parity claim is worthwhile.

Other factors

The prior review nit (constructor initialization of kOnUpgradedClose) was addressed in 2431401, and the comment-cop feedback on long comments was addressed in d542407 / ad7dd0d — all inline threads are resolved. Test coverage is thorough (property matrix, four upgrade paths, both directions of the !connection.destroyed gate), and the author reports the 245 upstream test-tls-/test-https- files pass. The bug hunter found nothing this run. Still, the seven-site lifecycle wiring and the kCloseRawConnection gate are the kind of change that benefits from a maintainer familiar with net.ts's upgrade paths signing off.

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:09 PM PT - Sep 10th, 2026

✅ @cirospaciari, your commit 10b45d1a0531281fe7918c6e675285e225d3acd0 passed in Build #114126! 🎉


🧪   To try this PR locally:

bunx bun-pr 39066

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

bun-39066 --bun

@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
cirospaciari merged commit 81f97bb into main Sep 11, 2026
6 checks passed
@cirospaciari
cirospaciari deleted the farm/0471a3cf/tls-wrap-inherit-allow-half-open branch September 11, 2026 04:20
robobun added a commit that referenced this pull request Sep 11, 2026
main gained destroyWhenUpgradedCloses (#39066), which covers the net.ts
half of this branch at all seven upgrade sites. Take main's net.ts; the
staged pre-engine close and the tests stay.
robobun added a commit that referenced this pull request Sep 11, 2026
…e on a wrapped transport's teardown

main's transport 'close' listener (#39066) destroys the TLS socket at
once. Two things were left:

- After the peer's EOF the stream-level engine can close the transport
  while decrypted data is still unread, because it reads the transport
  with no backpressure. The destroy dropped that data: a paused reader
  got 0 of 1 MB, for-await threw ERR_STREAM_PREMATURE_CLOSE, pipeline()
  never settled. Destroy after 'end' in that state instead.
- At the stream-engine sites the listener ran after the engine's own
  'close' thunk. That thunk aborts a pending handshake, so a server wrap
  reported ECONNRESET and never emitted 'close'. Arm the listener first.

Also guard the staged pre-engine close with a test: without it the
queued engine start builds an engine nothing closes, and the native
socket stays strongly referenced.
Jarred-Sumner pushed a commit that referenced this pull request Sep 14, 2026
### Problem

- A server-side wrap of an accepted socket (`new tls.TLSSocket(raw, {
isServer: true })`, `tlsServer.emit('connection', raw)`) no longer
reports a peer RST. The TLS socket emits `'close'` with `hadError ===
false` and no `'error'`. During the handshake the `tls.Server` gets no
`'tlsClientError'`. Bun 1.4.2 and Node report ECONNRESET.
- Regression from #39066: the wrapped socket's `'close'` now destroys
the TLS socket (`onUpgradedClose`, `src/js/node/net.ts:273`). The native
`on_close` (`src/runtime/socket/socket_body.rs:2122`) runs the raw
socket's close callback, drains the tick queue (raw `'close'`, so the
destroy), and only then runs the TLS socket's own close callback with
ECONNRESET.

### Fix

- `on_close` holds the event loop entered (`EventLoop::enter_scope`)
across both close callbacks of an `upgradeTLS` pair. The tick queue
drains after both ran, not between them.
- The TLS socket's own close callback now destroys it with ECONNRESET
before the raw socket's `'close'` event runs. That listener then finds
the socket destroyed. `net.ts` does not change.
- The JS events keep the 1.4.2 order: `raw error, raw close, tls error,
tls close`.
- Verified:
`test/js/node/tls/node-tls-socket-allow-half-open-option.test.ts`, 2 new
tests, both fail on main's `src/`. Also `test/js/node/tls/`, `net/`,
`http2/`, `test/js/bun/net/`, vendored `test-tls-*` and `test-https-*`.

### Background

- A wrap is TLS over an existing socket. For a connected `net.Socket`
Bun adopts the fd: `upgradeTLS` returns a raw handle, which stays on the
wrapped socket, and a TLS handle for the TLS socket.
- Only the TLS handle gets native events. Its `on_close` first calls the
raw handle's close callback (the "twin") as a full dispatch of its own.
- Each dispatch brackets its JS callback with `enter()` and `exit()` on
the event loop. The outermost `exit()` drains `process.nextTick` and
microtasks. The twin's dispatch was an outermost pair, so it drained
before the TLS callback ran.

<details><summary>Notes</summary>

#### Scope

The raw socket needs an `'error'` listener of its own for this to show,
the usual STARTTLS server shape. Without one it is not destroyed on the
reset, so it emits no `'close'`.

`CloseTeardown` is now created ahead of the scope, so it drops after it.
The drain still comes ahead of the teardown (`mark_inactive`, the final
`deref`), as it does for a socket with no twin. The three early returns
lose their explicit `drop(cleanup)` for the same reason: the natural
drop order is the scope first, then the teardown.

The change applies to every `upgradeTLS` pair, also the ones from
`Bun.connect` and `socket.upgradeTLS()`. What moves is when the ticks
queued by the raw socket's `close` handler run: after the TLS socket's
`close` handler, not before it.

An earlier revision of this PR deferred the destroy in `net.ts` with a
`setImmediate` when it saw the state between the two callbacks. Review
asked to fix the ordering at its source, which is this version.

Since #39066 a wrap takes `allowHalfOpen` from the wrapped socket. A
half-open wrap does not end itself after EOF, so the wrapped socket's
`'close'` must destroy it. This change keeps that.

#### Measurements

Linux x64. "main" is a debug+ASAN build of 81f97bb. "before" is bun
1.4.3-canary.1+4ff919377, which predates #39066 and prints what 1.4.2
prints. Node is v26.3.0. The server wraps the accepted socket and also
listens for `'error'` on it. The client does `resetAndDestroy()`.

| case | node | before | main | this PR |
| --- | --- | --- | --- | --- |
| `new TLSSocket(raw)`, after the handshake | tls error ECONNRESET, raw
close, tls close:true | raw error, raw close, tls error ECONNRESET, tls
close:true | raw error, raw close, tls close:false | same as before |
| `tlsServer.emit('connection', raw)`, after the handshake | same | same
| same | same as before |
| `new TLSSocket(raw)`, during the handshake | tls error ECONNRESET, raw
close, tls close:true | raw error, raw close, tls error ECONNRESET, tls
close:true | raw error, raw close, tls close:false | same as before |
| `tlsServer.emit('connection', raw)`, during the handshake |
tlsClientError ECONNRESET | tlsClientError ECONNRESET | nothing |
tlsClientError ECONNRESET |

`tls.connect({ socket })` over a `net.Socket` it dialed itself was not
affected. Its raw socket keeps its handle through the close callback, so
`_destroy` takes the `kAdoptedTLSRaw` branch and `'close'` comes two
check phases later (`closeAdoptedTLSRawNT`). An accepted socket's
callback (`ServerHandlers.close`) detaches the handle first, so
`'close'` comes from `process.nextTick`.

#### The trace

`BUN_DEBUG_Socket=1 BUN_DEBUG_JS=1` on main, after the client's RST:

```
[socket] onClose C            <- TLS handle
[socket] onClose S            <- raw handle, dispatched from inside the first
[net] Bun.Server close        <- raw socket's callback: destroy(ECONNRESET)
[events] Socket.emit error
[events] Socket.emit close    <- onUpgradedClose: tlsSocket.destroy()
[net] Socket.prototype._destroy
[net] Bun.Server close        <- TLS socket's callback, socket already destroyed
```

#### Relation to #42176

#42176 now carries the other face of the #39066 regression: a TLS socket
over a `stream.Duplex` that loses unread data when the duplex closes.
That is the stream-engine path and it changes `onUpgradedClose`. This PR
touches neither that function nor `net.ts`, so the two do not conflict.

</details>

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

---

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

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

```console
ASAN without fix: 2 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-socket-allow-half-open-option.test.ts
bun test v1.4.3 (4ff9193)

test/js/node/tls/node-tls-socket-allow-half-open-option.test.ts:
(pass) TLSSocket allowHalfOpen > new TLSSocket(socket) takes the wrapped socket's allowHalfOpen, ignoring the option [228.65ms]
(pass) TLSSocket allowHalfOpen > without a socket to wrap, the option is honored [10.30ms]
(pass) TLSSocket allowHalfOpen > tls.connect({ socket }) takes the given socket's allowHalfOpen, ignoring the option [207.34ms]
(pass) TLSSocket allowHalfOpen > over a connection > a server-side wrap of a half-open socket stays writable after the peer ends [946.31ms]
(pass) TLSSocket allowHalfOpen > over a connection > a server-side wrap of a regular socket ends itself after the peer ends, even with allowHalfOpen: true [836.51ms]
(pass) TLSSocket allowHalfOpen > over a connection > a socket injected into a tls.Server keeps its own allowHalfOpen [845.82ms]
(pass) TLSSocket allowHalfOpen > over a connection > tls.connect({ socket }) over a half-open socket stays writab
... (truncated)

release without fix: all passed
bun test v1.4.3-canary.1 (00fab6991)

test/js/node/tls/node-tls-socket-allow-half-open-option.test.ts:
(pass) TLSSocket allowHalfOpen > new TLSSocket(socket) takes the wrapped socket's allowHalfOpen, ignoring the option [8.83ms]
(pass) TLSSocket allowHalfOpen > without a socket to wrap, the option is honored [0.16ms]
(pass) TLSSocket allowHalfOpen > tls.connect({ socket }) takes the given socket's allowHalfOpen, ignoring the option [4.39ms]
(pass) TLSSocket allowHalfOpen > a peer reset under a server-side wrap > a socket injected into a tls.Server: a reset during the handshake is a 'tlsClientError' [20.27ms]
(pass) TLSSocket allowHalfOpen > the wrapped socket closing > new TLSSocket(duplex, { isServer }): the duplex closing [36.67ms]
(pass) TLSSocket allowHalfOpen > over a connection > a server-side wrap of a half-open socket stays writable after the peer ends [48.42ms]
(pass) TLSSocket allowHalfOpen > over a connection > a server-side wrap of a regular socket ends itself after the peer ends, even with allowHalfOpen: true [44.45ms]
(pass) TLSSocket allowHalfOpen > over a connection > a socket injected into a tls.Server keeps its own allowHalfOpen [44.65ms]
(pass) TL
... (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-socket-allow-half-open-option.test.ts
bun test v1.4.3 (4ff9193)

test/js/node/tls/node-tls-socket-allow-half-open-option.test.ts:
(pass) TLSSocket allowHalfOpen > new TLSSocket(socket) takes the wrapped socket's allowHalfOpen, ignoring the option [215.09ms]
(pass) TLSSocket allowHalfOpen > without a socket to wrap, the option is honored [9.47ms]
(pass) TLSSocket allowHalfOpen > tls.connect({ socket }) takes the given socket's allowHalfOpen, ignoring the option [206.47ms]
(pass) TLSSocket allowHalfOpen > over a connection > a server-side wrap of a half-open socket stays writable after the peer ends [953.62ms]
(pass) TLSSocket allowHalfOpen > over a connection > a server-side wrap of a regular socket ends itself after the peer ends, even with allowHalfOpen: true [840.71ms]
(pass) TLSSocket allowHalfOpen > over a connection > a socket injected into a tls.Server keeps its own allowHalfOpen [839.34ms]
(pass) TLSSocket allowHalfOpen > over a connection > tls.connect({ socket }) over a half-open socket stays writabl
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 692ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[1/6] 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   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_paths v0.0.0 (/workspace/bun/src/paths)
�[1m�[92m   Compiling�[0m b
... (truncated)
```

</details>

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

```
src/runtime/socket/socket_body.rs                  | 21 +++---
 .../node-tls-socket-allow-half-open-option.test.ts | 77 ++++++++++++++++++++++
 2 files changed, 89 insertions(+), 9 deletions(-)
```

</details>

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

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

```
file                                                      reads  edits  tests
src/runtime/socket/socket_body.rs                             4      1     23
…node/tls/node-tls-socket-allow-half-open-option.test.ts      4      4     23
```

</details>

<!-- robobun:evidence:end -->
usrbinkat pushed a commit to usrbinkat/bun that referenced this pull request Sep 15, 2026
…h#42293)

### Problem

- A server-side wrap of an accepted socket (`new tls.TLSSocket(raw, {
isServer: true })`, `tlsServer.emit('connection', raw)`) no longer
reports a peer RST. The TLS socket emits `'close'` with `hadError ===
false` and no `'error'`. During the handshake the `tls.Server` gets no
`'tlsClientError'`. Bun 1.4.2 and Node report ECONNRESET.
- Regression from oven-sh#39066: the wrapped socket's `'close'` now destroys
the TLS socket (`onUpgradedClose`, `src/js/node/net.ts:273`). The native
`on_close` (`src/runtime/socket/socket_body.rs:2122`) runs the raw
socket's close callback, drains the tick queue (raw `'close'`, so the
destroy), and only then runs the TLS socket's own close callback with
ECONNRESET.

### Fix

- `on_close` holds the event loop entered (`EventLoop::enter_scope`)
across both close callbacks of an `upgradeTLS` pair. The tick queue
drains after both ran, not between them.
- The TLS socket's own close callback now destroys it with ECONNRESET
before the raw socket's `'close'` event runs. That listener then finds
the socket destroyed. `net.ts` does not change.
- The JS events keep the 1.4.2 order: `raw error, raw close, tls error,
tls close`.
- Verified:
`test/js/node/tls/node-tls-socket-allow-half-open-option.test.ts`, 2 new
tests, both fail on main's `src/`. Also `test/js/node/tls/`, `net/`,
`http2/`, `test/js/bun/net/`, vendored `test-tls-*` and `test-https-*`.

### Background

- A wrap is TLS over an existing socket. For a connected `net.Socket`
Bun adopts the fd: `upgradeTLS` returns a raw handle, which stays on the
wrapped socket, and a TLS handle for the TLS socket.
- Only the TLS handle gets native events. Its `on_close` first calls the
raw handle's close callback (the "twin") as a full dispatch of its own.
- Each dispatch brackets its JS callback with `enter()` and `exit()` on
the event loop. The outermost `exit()` drains `process.nextTick` and
microtasks. The twin's dispatch was an outermost pair, so it drained
before the TLS callback ran.

<details><summary>Notes</summary>

#### Scope

The raw socket needs an `'error'` listener of its own for this to show,
the usual STARTTLS server shape. Without one it is not destroyed on the
reset, so it emits no `'close'`.

`CloseTeardown` is now created ahead of the scope, so it drops after it.
The drain still comes ahead of the teardown (`mark_inactive`, the final
`deref`), as it does for a socket with no twin. The three early returns
lose their explicit `drop(cleanup)` for the same reason: the natural
drop order is the scope first, then the teardown.

The change applies to every `upgradeTLS` pair, also the ones from
`Bun.connect` and `socket.upgradeTLS()`. What moves is when the ticks
queued by the raw socket's `close` handler run: after the TLS socket's
`close` handler, not before it.

An earlier revision of this PR deferred the destroy in `net.ts` with a
`setImmediate` when it saw the state between the two callbacks. Review
asked to fix the ordering at its source, which is this version.

Since oven-sh#39066 a wrap takes `allowHalfOpen` from the wrapped socket. A
half-open wrap does not end itself after EOF, so the wrapped socket's
`'close'` must destroy it. This change keeps that.

#### Measurements

Linux x64. "main" is a debug+ASAN build of 81f97bb. "before" is bun
1.4.3-canary.1+4ff919377, which predates oven-sh#39066 and prints what 1.4.2
prints. Node is v26.3.0. The server wraps the accepted socket and also
listens for `'error'` on it. The client does `resetAndDestroy()`.

| case | node | before | main | this PR |
| --- | --- | --- | --- | --- |
| `new TLSSocket(raw)`, after the handshake | tls error ECONNRESET, raw
close, tls close:true | raw error, raw close, tls error ECONNRESET, tls
close:true | raw error, raw close, tls close:false | same as before |
| `tlsServer.emit('connection', raw)`, after the handshake | same | same
| same | same as before |
| `new TLSSocket(raw)`, during the handshake | tls error ECONNRESET, raw
close, tls close:true | raw error, raw close, tls error ECONNRESET, tls
close:true | raw error, raw close, tls close:false | same as before |
| `tlsServer.emit('connection', raw)`, during the handshake |
tlsClientError ECONNRESET | tlsClientError ECONNRESET | nothing |
tlsClientError ECONNRESET |

`tls.connect({ socket })` over a `net.Socket` it dialed itself was not
affected. Its raw socket keeps its handle through the close callback, so
`_destroy` takes the `kAdoptedTLSRaw` branch and `'close'` comes two
check phases later (`closeAdoptedTLSRawNT`). An accepted socket's
callback (`ServerHandlers.close`) detaches the handle first, so
`'close'` comes from `process.nextTick`.

#### The trace

`BUN_DEBUG_Socket=1 BUN_DEBUG_JS=1` on main, after the client's RST:

```
[socket] onClose C            <- TLS handle
[socket] onClose S            <- raw handle, dispatched from inside the first
[net] Bun.Server close        <- raw socket's callback: destroy(ECONNRESET)
[events] Socket.emit error
[events] Socket.emit close    <- onUpgradedClose: tlsSocket.destroy()
[net] Socket.prototype._destroy
[net] Bun.Server close        <- TLS socket's callback, socket already destroyed
```

#### Relation to oven-sh#42176

oven-sh#42176 now carries the other face of the oven-sh#39066 regression: a TLS socket
over a `stream.Duplex` that loses unread data when the duplex closes.
That is the stream-engine path and it changes `onUpgradedClose`. This PR
touches neither that function nor `net.ts`, so the two do not conflict.

</details>

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

---

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

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

```console
ASAN without fix: 2 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-socket-allow-half-open-option.test.ts
bun test v1.4.3 (4ff9193)

test/js/node/tls/node-tls-socket-allow-half-open-option.test.ts:
(pass) TLSSocket allowHalfOpen > new TLSSocket(socket) takes the wrapped socket's allowHalfOpen, ignoring the option [228.65ms]
(pass) TLSSocket allowHalfOpen > without a socket to wrap, the option is honored [10.30ms]
(pass) TLSSocket allowHalfOpen > tls.connect({ socket }) takes the given socket's allowHalfOpen, ignoring the option [207.34ms]
(pass) TLSSocket allowHalfOpen > over a connection > a server-side wrap of a half-open socket stays writable after the peer ends [946.31ms]
(pass) TLSSocket allowHalfOpen > over a connection > a server-side wrap of a regular socket ends itself after the peer ends, even with allowHalfOpen: true [836.51ms]
(pass) TLSSocket allowHalfOpen > over a connection > a socket injected into a tls.Server keeps its own allowHalfOpen [845.82ms]
(pass) TLSSocket allowHalfOpen > over a connection > tls.connect({ socket }) over a half-open socket stays writab
... (truncated)

release without fix: all passed
bun test v1.4.3-canary.1 (00fab6991)

test/js/node/tls/node-tls-socket-allow-half-open-option.test.ts:
(pass) TLSSocket allowHalfOpen > new TLSSocket(socket) takes the wrapped socket's allowHalfOpen, ignoring the option [8.83ms]
(pass) TLSSocket allowHalfOpen > without a socket to wrap, the option is honored [0.16ms]
(pass) TLSSocket allowHalfOpen > tls.connect({ socket }) takes the given socket's allowHalfOpen, ignoring the option [4.39ms]
(pass) TLSSocket allowHalfOpen > a peer reset under a server-side wrap > a socket injected into a tls.Server: a reset during the handshake is a 'tlsClientError' [20.27ms]
(pass) TLSSocket allowHalfOpen > the wrapped socket closing > new TLSSocket(duplex, { isServer }): the duplex closing [36.67ms]
(pass) TLSSocket allowHalfOpen > over a connection > a server-side wrap of a half-open socket stays writable after the peer ends [48.42ms]
(pass) TLSSocket allowHalfOpen > over a connection > a server-side wrap of a regular socket ends itself after the peer ends, even with allowHalfOpen: true [44.45ms]
(pass) TLSSocket allowHalfOpen > over a connection > a socket injected into a tls.Server keeps its own allowHalfOpen [44.65ms]
(pass) TL
... (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-socket-allow-half-open-option.test.ts
bun test v1.4.3 (4ff9193)

test/js/node/tls/node-tls-socket-allow-half-open-option.test.ts:
(pass) TLSSocket allowHalfOpen > new TLSSocket(socket) takes the wrapped socket's allowHalfOpen, ignoring the option [215.09ms]
(pass) TLSSocket allowHalfOpen > without a socket to wrap, the option is honored [9.47ms]
(pass) TLSSocket allowHalfOpen > tls.connect({ socket }) takes the given socket's allowHalfOpen, ignoring the option [206.47ms]
(pass) TLSSocket allowHalfOpen > over a connection > a server-side wrap of a half-open socket stays writable after the peer ends [953.62ms]
(pass) TLSSocket allowHalfOpen > over a connection > a server-side wrap of a regular socket ends itself after the peer ends, even with allowHalfOpen: true [840.71ms]
(pass) TLSSocket allowHalfOpen > over a connection > a socket injected into a tls.Server keeps its own allowHalfOpen [839.34ms]
(pass) TLSSocket allowHalfOpen > over a connection > tls.connect({ socket }) over a half-open socket stays writabl
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 692ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[1/6] 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   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_paths v0.0.0 (/workspace/bun/src/paths)
�[1m�[92m   Compiling�[0m b
... (truncated)
```

</details>

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

```
src/runtime/socket/socket_body.rs                  | 21 +++---
 .../node-tls-socket-allow-half-open-option.test.ts | 77 ++++++++++++++++++++++
 2 files changed, 89 insertions(+), 9 deletions(-)
```

</details>

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

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

```
file                                                      reads  edits  tests
src/runtime/socket/socket_body.rs                             4      1     23
…node/tls/node-tls-socket-allow-half-open-option.test.ts      4      4     23
```

</details>

<!-- robobun:evidence:end -->
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>
Jarred-Sumner added a commit that referenced this pull request Oct 1, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants