node:tls: inherit allowHalfOpen from the wrapped socket - #39066
Conversation
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.
|
Warning Review limit reached
Next review available in: 6 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 (3)
Comment |
|
Status: fix pushed, waiting for CI. Reproduced on the released binary with Completeness of the Follow-up commits: Fix: #39066 (this PR). |
…uctor Arm it before the 'end' retirement at the fd upgrade sites as well, so kCloseRawConnection always finds it set.
There was a problem hiding this comment.
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:
allowHalfOpeninheritance in the TLSSocket constructor for bothnew TLSSocket(socket, ...)andtls.connect({ socket })paths against the Node reference.- All seven
destroyWhenUpgradedClosescall sites and thekupgradedguard inonUpgradedClose. - The
!connection.destroyedconditional inkCloseRawConnection— removes the listener only when the retirement's owndestroy()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 infinally,describe.concurrentfor the independent connection tests.
There was a problem hiding this comment.
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:
allowHalfOpeninheritance in the TLSSocket constructor for both the positional-socket andoptions.socketpaths — matches the cited Node wrap.js behavior.- All seven
destroyWhenUpgradedClosescall sites line up with the upgrade points inSocket.prototype.connectandbunUpgradeServerTLS; the bound listener uses a named module-level function and the new symbol slot is initialized in the constructor. - The
kCloseRawConnectioninterplay:removeListeneris guarded on!connection.destroyed, andonUpgradedClosere-checksself[kupgraded] === connectionbefore 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.
|
Updated 11:05 AM PT - Aug 15th, 2026
❌ @robobun, your commit d542407 has some failures in 🧪 To try this PR locally: bunx bun-pr 39066That installs a local version of the PR into your bun-39066 --bun |
Problem
new tls.TLSSocket(socket, options)andtls.connect({ socket })hard-codeallowHalfOpen: falseon 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()passesoptions.socketas that argument, #L1756-L1757).net.createServer({ allowHalfOpen: true }, raw => new TLSSocket(raw, { isServer: true, key, cert })), getsallowHalfOpen === falsesockets, which end themselves the moment the client ends, so the server cannot answer after the client's EOF. Same for sockets injected into atls.Server(the'connection'listener in tls.ts) and fortls.connect({ socket }).truefor every wrapped half-open case below, Bun printsfalse(andtruefortls.connect({ socket: regular, allowHalfOpen: true }), where Node printsfalse). Full matrix in the details block.Fix
tls.ts: the constructor copiesallowHalfOpenfrom the wrapped socket, taken from the first argument or fromoptions.socket(thetls.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 plainstream.Duplexis 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 withwrap.on('close', () => this.destroy())(wrap.js#L739-L741).destroyWhenUpgradedClosesarms that listener at the seven upgrade sites.kCloseRawConnection(Bun's own retirement of an fd-upgradednet.Socketobject 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 hunkrenegotiation.test.ts("exceeds the renegotiation limit over a duplex socket") hangs and the four "wrapped socket closing" tests below time out.allowHalfOpendecide what happens at EOF (see the comment inkConnectTcp), and an adopted fd keeps its native flags throughus_socket_adopt, so the constructor was the only point where the wrapped socket's setting was dropped.ssl_wants_eof_dispatchinopenssl.cis 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 automaticend(), teardown on transport close). With the flag forced tofalse, 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.test/js/node/tls/node-tls-socket-allow-half-open-option.test.tsassertedfalsefornew TLSSocket(new Duplex(), { allowHalfOpen: true }); Node returnstruethere (a Duplex defaults toallowHalfOpen: true), so those assertions encoded the bug. The file now asserts the "option is ignored" property in both directions, plus the cases below.test/js/node/tls/node-tls-socket-allow-half-open-option.test.ts(property matrix for both construction forms, server wrap / injectedtls.Serversocket /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 testwithsrc/stashed: 9 of 11 fail (the 2 that pass are the unchanged-behavior contracts); with the diff: 11 pass.tls.tshunk: the 4 "wrapped socket closing" tests time out.test/js/node/tls/renegotiation.test.ts: 8 pass.test-tls-*/test-https-*files intest/js/node/test/parallelpass 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
allowHalfOpenis a Duplex option. When it isfalse, the stream callsend()on itself once its readable side ends (the peer's EOF), so the socket closes by itself; when it istrue, the application decides when to end its own side.net.Socketdefaults tofalse,stream.Duplextotrue.new TLSSocket(socket, ...)andtls.connect({ socket }). Bun performs either by adopting the connection's fd into the TLS engine (anet.Socketwith 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.tshas one upgrade site per form and path, seven in total.net.ts,this[kupgraded]is the TLS socket's pointer to the connection it was upgraded over. On the fd path,kCloseRawConnectionruns on the TLS socket's'end'and destroys the originalnet.Socketobject, 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):Server wrap over a half-open raw socket, client ends after the handshake, server writes from its
'end'handler:(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 closeon the EOF line is Bun's existing retirement of the wrapped object):Before the
net.tshunk, the last two rows never reachedtls close.