Repository navigation
Conversation
Bun's SSL BIO writes handshake records straight to the socket fd, so by the time the on_handshake callback runs the client's Finished is already in the kernel send buffer. A tls.connect() whose rejectUnauthorized check then destroys the socket cannot recall those bytes, and the server's handshake completes: it emits 'secureConnection' for a connection that is about to be torn down. Node's TLSWrap seals records into a memory BIO and runs JS from the SSL_CB_HANDSHAKE_DONE info callback before EncOut copies enc_out_ to the TCP stream, so destroy() there drops the flight and the server reports tlsClientError ECONNRESET instead. Batch the BIO output around SSL_do_handshake / SSL_read while the handshake is pending, and when it completes park the batched flight in the spill slot with a discard-on-close flag before dispatching on_handshake(success=1). A close from inside the callback frees the spill without draining (and marks the SSL fatal so no close_notify leaks under keys the peer has not installed); any write the callback issues drains it first so wire order is kept when the connection is accepted. Unblocks wrapping the server 'secureConnection' emit for uncaughtException routing (test-tls-close-error.js's mustNotCall handler was being hit and its assertion swallowed by the native error path).
|
Updated 2:45 AM PT - Jul 24th, 2026
✅ @robobun, your commit 7beee20b03f83d5185027806d34839ad749b222d passed in 🧪 To try this PR locally: bunx bun-pr 35378That installs a local version of the PR into your bun-35378 --bun |
|
Reproduced on main with Self-review findings addressed:
|
|
Warning Review limit reached
Next review available in: 17 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 (5)
WalkthroughChangesThe TLS OpenSSL path now batches handshake-final ciphertext through callback and close handling, preserving correct TLS handshake batching
Possibly related issues
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 2 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (2 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/bun-usockets/src/crypto/openssl.c`:
- Around line 1619-1626: Update ssl_trigger_handshake_success so
ssl_flush_write_batch is only called when loop_ssl_data exists, has a nonzero
ssl_write_batch_len, and has no ssl_spill_owner. When another socket owns the
spill slot, leave the write batch pending and avoid triggering a flush that
could overwrite it.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 9c4132d0-2f29-48a9-b53b-a125264663f3
📒 Files selected for processing (2)
packages/bun-usockets/src/crypto/openssl.ctest/js/node/tls/node-tls-server.test.ts
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
Matches us_internal_ssl_write: when another socket's partial write occupies the spill, a later ssl_flush_write_batch short-write would overwrite it. With the gate the hold is best-effort (writes through per record when spill is busy, the pre-change behaviour) and the spill cannot be clobbered.
|
Re the duplicate check for #34598: that PR addresses the same The two PRs will conflict in |
The handshake batching flag is now on while the SNI/ALPN select-cert callbacks dispatch user JS. us_internal_ssl_loop_state_save already saves ssl_socket and the read window for this re-entrancy; extend it to also save ssl_write_batching and zero it for the callback's duration, so a close_notify or write on a different socket from inside the callback reaches that socket's fd via the per-record BIO path instead of being appended to the interrupted handshake's batch buffer and later flushed to the wrong peer. Widens the save array to 6 slots at both call sites.
The reject/allow subprocess now resolves each dial on both the client close and the server's tlsClientError/secureConnection, so the positive tlsClientError assertion is gated on the event itself rather than two setImmediate ticks. Moves bunExe/bunEnv to the existing top-level harness import.
The entry-time !ssl_spill_owner gate on handshake batching is a snapshot: SNI/ALPN JS (now running with batching zeroed) can issue a write on another socket that short-writes into the spill slot, and the outer flush would then overwrite that spill on its own short-write. Guard at the sink: when the slot is already taken, mark this socket fatal (its sealed remainder is undeliverable) rather than clobbering the other socket's pending ciphertext. Same disposition as the existing out-of-memory branch immediately below.
There was a problem hiding this comment.
All three inline findings from earlier passes are addressed (f89a705, 4b07675, 7beee20) and this run found nothing new. Deferring to a human anyway: this reworks the TLS handshake write-ordering in openssl.c — per-loop batch/spill state, re-entrancy across SNI/ALPN JS, and a new discard-on-close mode — and it took three iterations to close the cross-socket spill-clobber gaps, so a maintainer should sign off on the final shape. There's also the overlap with #34598 to resolve.
What was reviewed:
ssl_flush_write_batchnow refuses to overwrite a live spill (7beee20) — verified this covers thessl_trigger_handshake_successfallback and thehs_batchingflushes inus_internal_ssl_on_data.us_internal_ssl_loop_state_save/restorewidened to 6 slots on both call sites (openssl.cus_select_cert_cband Rust ALPN insocket_body.rs);ssl_write_batchingis saved+zeroed so cross-socket close_notify from inside SNI/ALPN goes per-record.ssl_release_spilldiscard-on-close path setsssl_fatal_errorso no close_notify goes out under uninstalled keys;us_internal_ssl_closeFAST_SHUTDOWN skips the deferred-close-after-spill when the spill is a held flight.- New test awaits the server terminal event (secureConnection/tlsClientError) and client close, no sleeps.
Extended reasoning...
Overview
The PR changes how Bun's TLS client emits its final handshake flight: instead of writing handshake records straight to the socket fd via BIO_s_custom_write, it batches them around SSL_do_handshake/SSL_read and parks the batched flight in the loop's spill slot with a new ssl_spill_discard_on_close flag before dispatching on_handshake(success=1). If the JS callback destroys the socket (verify-reject), the spill is freed without draining and ssl_fatal_error is set so no close_notify follows; otherwise the spill drains normally after the callback returns. Files touched: packages/bun-usockets/src/crypto/openssl.c (core change), internal.h + socket_body.rs + tls_socket_functions.rs (widen the SNI/ALPN save/restore array from 5→6 slots), and a new subprocess test in node-tls-server.test.ts.
Security risks
This is TLS handshake wire-ordering and per-loop shared mutable state (loop_ssl_data->ssl_write_batch*, ssl_spill*) in C. The three earlier review rounds each found a real memory-safety-class bug in exactly this area — spill overwrite/leak when another socket owns the slot, cross-socket batch contamination via SNI/ALPN re-entrancy, and a TOCTOU between the entry-time gate and re-entrant JS producing a spill. All are now closed (the last defensively at the sink), and I don't see a fourth, but the interaction surface is large enough that I'm not confident a bot review is sufficient.
Level of scrutiny
High. openssl.c is the shared BIO/write path for every TLS socket on the loop; the new state (ssl_spill_discard_on_close) and the batching-around-handshake behaviour are visible to every SSL write, close, and shutdown path. REVIEW.md flags native memory safety as the most-blocked category and specifically calls out "anything that can run user JS can synchronously free your state — re-validate liveness guards after every callback," which is precisely the class of bug the follow-up commits fixed.
Other factors
- The duplicate-PR bot flagged #34598, which addresses the same symptom via a different mechanism (BIO-level swallow keyed on X509 verify result). The author noted this PR also covers JS-side rejections like
checkServerIdentityand offered to rebase either way — a maintainer should decide which approach lands. - Regression sweeps in the description look thorough (test-tls-, test-https-, test-http2-*, node-http2.test.js) with no new failures vs main.
- The new test asserts the exact event sequence for both
rejectUnauthorized: trueandfalse, awaits real terminal events, and would fail on main.
…ect write in one segment (#40655) ### Problem - A TLS 1.3 `tls.connect()` client whose certificate the server rejects reports `error: read ECONNRESET` and `close(true)`. A Node client in the same exchange reports a clean `end` and `close(false)` (#40653, reproduced 5/5 on Linux, 20/20 by the reporter on macOS). - The client sends its final handshake flight and the write from `secureConnect` as two TCP segments: the custom BIO (`BIO_s_custom_write`, `packages/bun-usockets/src/crypto/openssl.c`) raw-writes the flight from inside `SSL_read`, before the handshake event reaches JS. The server rejects the certificate after reading segment one and destroys the socket with segment two unread. Linux then sends an RST instead of a FIN, and the RST is a real ECONNRESET at the client. ### Fix - Batch the records `SSL_read` seals while the handshake is pending. When the handshake completes in that read, hold the batch across the `on_handshake` dispatch. A write the callback issues appends to the held flight, and the existing flush in `us_internal_ssl_write` sends flight plus first application data in one kernel write. This is the segment shape Node produces through its memory BIO. - Any other outcome of the read flushes the batch at once. The close and shutdown paths flush a held batch before their close_notify/FIN. An error teardown or a destroy from inside the handshake drops it. A new `ssl_write_batch_owner` field pins the shared per-loop batch to one socket, and the loop-state save/zero keeps re-entrant JS (SNI/ALPN callbacks) from mixing another socket's records into a held flight. The snapshot size is now exported and debug-asserted at the Rust call site. - Verified: four new tests in `test/js/node/tls/node-tls-connect.test.ts`. The segment-count test fails on main (3 chunks, expected 2). The cross-socket-write and end-from-handshake tests cover the shared-slot hard paths. The issue's script prints `secureConnect -> write -> end -> close:false` 10/10 with the debug build. Also ran `test/js/node/tls/`, `node-http2.test.js`, `fetch.tls.test.ts`, `bun-serve-ssl.test.ts`, and the upstream `test-tls-*`/`test-https-*` suites. ### Background - usockets gives every TLS socket on a loop the same BIO pair. `loop_ssl_data->ssl_socket` routes `BIO_s_custom_write` output to the right fd. `ssl_write_batching` already existed for `us_internal_ssl_write`: it collects sealed records and writes them once. - The spill slot holds ciphertext a partial batch flush could not deliver. SSL counts those bytes as written, so they must reach that exact socket, in order. Batching is gated on a free spill slot for that reason. - In TLS 1.3 the client finishes first: it sees `secureConnect` one round trip before the server decides on the client certificate. That is why the rejection lands after the client already wrote application data. <details><summary>Notes</summary> - Diagnosis: an `LD_PRELOAD` shim logging `send`/`recv` shows Node's client writes flight + app data as one `write(1253)` while Bun issues `send(1152)` then `send(57)`. The server (Node in both runs) reads 1253 in one chunk from the Node client, but only 1152 from the Bun client before it destroys. - The RST path needs the second segment to arrive inside the window between the server reading the flight and destroying the socket. Release builds hit it almost always. Debug builds are slow enough that the server's FIN usually wins, and Linux masks the late RST (`SOCK_DONE` is checked before `sk_err` in `tcp_recvmsg`). The gate-facing test therefore counts the client's segments through a raw in-test TCP proxy. The count discriminates reliably on an unloaded machine; under heavy CPU load the two unfixed sends can coalesce in the kernel, so a green run on a loaded machine is weaker evidence than the idle fail-before recorded here (20/20 fail on the unfixed build when idle). The end-to-end Node-server repro is kept as a second test, `skipIf(!nodeExe())`. - Related, not the same bug: #40412 covers the send side of a destroyed TLS socket (bare FIN, no close_notify). #35378 holds the flight so a verify-rejecting client's destroy can recall it; it parks the flight in the spill slot and drains it before the callback's write, so it still produces two segments and does not fix #40653. This PR's batch-owner hold subsumes most of that machinery. #35378 should be restacked on top of this as a flush-vs-release policy decision at `us_internal_ssl_close` rather than rebased mechanically. - Follow-up direction for this file: a per-socket outbound-ciphertext buffer would delete the shared batch/spill slots, both owner fields, the loop-state save slots for routing, and the cross-socket double-spill fatal path. Worth doing before more features extend the shared-slot surface. - Pre-existing failures seen during the sweep, identical on an unmodified build: `node-tls-server.test.ts` "SNICallback runs even when the requested servername matches the bind hostname" (ECONNREFUSED in this environment), `test/js/bun/net/` hostname-resolution failures (13, same set on system bun), `test-tls-client-allow-partial-trust-chain.js` (node:test runner), and `test-https-timeout.js` (hangs on a main debug build too). </details> <!-- robobun:evidence:begin --> --- **[review]** gate passed · iteration 0 · 5 files touched <details><summary>fails on main (without fix)</summary> ```console ASAN without fix: 1 failed, 18 skipped $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/tls/node-tls-connect.test.ts bun test v1.4.1 (731aa92) test/js/node/tls/node-tls-connect.test.ts: (pass) should have checkServerIdentity [3.82ms] (pass) should thow ECONNRESET if FIN is received before handshake [346.19ms] (pass) initializes authorizationError to null in the TLSSocket constructor [7.78ms] (pass) setMaxSendFragment mirrors OpenSSL's [512, 16384] acceptance without throwing [106.76ms] (pass) should be able to grab the JSStreamSocket constructor [12.46ms] (skip) tls.connect > should work with alpnProtocols (pass) tls.connect > Bun.serve() should work with tls and Bun.file() [82.50ms] (pass) tls.connect > should have peer certificate when using self asign certificate [77.93ms] (skip) tls.connect > should have peer certificate (skip) tls.connect > getCipher, getProtocol, getEphemeralKeyInfo, getSharedSigalgs, getSession, exportKeyingMaterial and isSessionReused should work (skip) tls.connect > should process options correctly when connect is called with only options (skip) tls.connect > should process port a ... (truncated) release without fix: 2 failed, 18 skipped bun test v1.4.1-canary.1 (731aa92) test/js/node/tls/node-tls-connect.test.ts: (pass) should have checkServerIdentity [0.03ms] (pass) should thow ECONNRESET if FIN is received before handshake [6.30ms] (pass) initializes authorizationError to null in the TLSSocket constructor [0.15ms] (pass) setMaxSendFragment mirrors OpenSSL's [512, 16384] acceptance without throwing [3.27ms] (pass) should be able to grab the JSStreamSocket constructor [0.22ms] (skip) tls.connect > should work with alpnProtocols (pass) tls.connect > Bun.serve() should work with tls and Bun.file() [4.43ms] (pass) tls.connect > should have peer certificate when using self asign certificate [3.11ms] (skip) tls.connect > should have peer certificate (skip) tls.connect > getCipher, getProtocol, getEphemeralKeyInfo, getSharedSigalgs, getSession, exportKeyingMaterial and isSessionReused should work (skip) tls.connect > should process options correctly when connect is called with only options (skip) tls.connect > should process port and host correctly (skip) tls.connect > should process port, host, and callback correctly (skip) tls.connect > should handle the absence of a callback gracefully (skip) tls.c ... (truncated) ``` </details> <details><summary>passes on PR (with fix)</summary> ```console ASAN with fix: 18 skipped $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/tls/node-tls-connect.test.ts bun test v1.4.1 (731aa92) test/js/node/tls/node-tls-connect.test.ts: (pass) should have checkServerIdentity [2.71ms] (pass) should thow ECONNRESET if FIN is received before handshake [362.37ms] (pass) initializes authorizationError to null in the TLSSocket constructor [8.06ms] (pass) setMaxSendFragment mirrors OpenSSL's [512, 16384] acceptance without throwing [115.84ms] (pass) should be able to grab the JSStreamSocket constructor [13.31ms] (skip) tls.connect > should work with alpnProtocols (pass) tls.connect > Bun.serve() should work with tls and Bun.file() [87.82ms] (pass) tls.connect > should have peer certificate when using self asign certificate [85.23ms] (skip) tls.connect > should have peer certificate (skip) tls.connect > getCipher, getProtocol, getEphemeralKeyInfo, getSharedSigalgs, getSession, exportKeyingMaterial and isSessionReused should work (skip) tls.connect > should process options correctly when connect is called with only options (skip) tls.connect > should process port a ... (truncated) release with fix: 18 skipped $ bun scripts/build.ts --profile=release [configured] bun-profile → bun (stripped) in 863ms (unchanged) ninja: Entering directory `/workspace/bun/build/release' [1/25] cc obj/packages/bun-usockets/src/bsd.c.o [2/25] cc obj/packages/bun-usockets/src/context.c.o [3/25] cc obj/packages/bun-usockets/src/crypto/openssl.c.o [4/25] cxx obj/unified/UnifiedSource-packages_bun_usockets_src_crypto-0.cpp.o [5/25] cc obj/packages/bun-usockets/src/eventing/libuv.c.o [6/25] cxx obj/unified/UnifiedSource-src_uws_sys-0.cpp.o [7/25] cc obj/packages/bun-usockets/src/eventing/epoll_kqueue.c.o [8/25] cc obj/packages/bun-usockets/src/fault_inject.c.o [9/25] cxx obj/src/jsc/bindings/bindings.cpp.o [10/25] cxx obj/unified/UnifiedSource-src_jsc_bindings-5.cpp.o [11/25] cxx obj/unified/UnifiedSource-src_jsc_bindings_node-0.cpp.o [12/25] cc obj/packages/bun-usockets/src/loop.c.o [13/25] cc obj/packages/bun-usockets/src/quic.c.o [14/25] cc obj/packages/bun-usockets/src/node_quic_shim.c.o [15/25] cc obj/packages/bun-usockets/src/udp.c.o [16/25] cc obj/packages/bun-usockets/src/socket.c.o [17/25] cxx obj/unified/UnifiedSource-src_jsc_bindings-4.cpp.o [18/25] cxx obj/unified/UnifiedSource-src_j ... (truncated) ``` </details> <details><summary>diff hotspot</summary> ``` packages/bun-usockets/src/crypto/openssl.c | 149 +++++++++++++- packages/bun-usockets/src/internal/internal.h | 11 +- src/runtime/socket/socket_body.rs | 9 +- src/runtime/socket/tls_socket_functions.rs | 9 +- test/js/node/tls/node-tls-connect.test.ts | 276 +++++++++++++++++++++++++- 5 files changed, 436 insertions(+), 18 deletions(-) ``` </details> **gate history** · 1 passed · 0 rejected · iteration 0 <details><summary>evidence per changed file</summary> ``` file reads edits tests packages/bun-usockets/src/crypto/openssl.c 9 21 0 packages/bun-usockets/src/internal/internal.h 2 3 0 src/runtime/socket/socket_body.rs 2 2 0 src/runtime/socket/tls_socket_functions.rs 2 3 0 test/js/node/tls/node-tls-connect.test.ts 2 9 0 ``` </details> <!-- robobun:evidence:end --> --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
|
Closing this PR. Main now covers the scenario that it tests, and its mechanism no longer fits main.
If the scenario of this PR still reproduces on a current build, please reopen it or comment on #43807. |
Problem
A
tls.connect()client whose server-certificate verification fails (rejectUnauthorized: truewith an untrusted cert) tears the connection down from its on_handshake callback, but the server still sees a completed handshake and emits'secureConnection'for a socket that is about to close. Node's server emits'tlsClientError'(ECONNRESET) and never'secureConnection'.This is a client-side bug: a Bun server talking to a Node client already behaves correctly (
tlsClientError), and a Node server talking to a Bun client sees the spurioussecureConnection. It is TLS-1.3-specific; under TLS 1.2 the server's handshake completes before the client's by protocol design and both runtimes emitsecureConnectionthere.It surfaces as
test/js/node/test/parallel/test-tls-close-error.jspassing only because itscommon.mustNotCall()secureConnection handler's assertion throw is silently routed through the native socket error handler. That blocks wrappingserver.emit('secureConnection', ...)for uncaughtException routing (the TLS sibling of the'connection'wrap in #35347, deferred there and in #29762).Cause
Bun's SSL BIO (
BIO_s_custom_write) writes handshake records straight to the socket fd viaus_socket_raw_write. WhenSSL_readprocesses the server's flight and seals the client's Finished, those bytes are already in the kernel send buffer by the timeon_handshakedispatches.self.destroy(verifyError)inSocketHandlers.handshakecannot recall them; the server receives Finished and its handshake completes.Node's
TLSWrapwrites handshake records into a memory BIO (enc_out_) and runs JS from theSSL_CB_HANDSHAKE_DONEinfo callback insideSSL_do_handshake, beforeEncOut()copiesenc_out_to the underlying stream.destroy()there drops the buffer and the server never sees Finished.Fix
packages/bun-usockets/src/crypto/openssl.c: batch the BIO output aroundSSL_do_handshakeandSSL_readwhile the handshake is pending. When the handshake completes,ssl_trigger_handshake_successparks the batched flight in the loop's spill slot with a newssl_spill_discard_on_closeflag set, then dispatcheson_handshake(success=1):close_notifyis sent under keys the peer has not installed). The peer sees the TCP close mid-handshake and reports ECONNRESET, matching Node.us_internal_ssl_writefrom inside the callback drains the spill first, so the held flight precedes any application data when the connection is kept.When the spill slot is already occupied by another socket's partial write, the batch is flushed immediately (the pre-change behaviour) rather than held.
Verification
New test in
test/js/node/tls/node-tls-server.test.tsrecords the exact server-side event sequence for bothrejectUnauthorized: true(must see['tlsClientError','ECONNRESET'], nosecureConnection) andrejectUnauthorized: false(must see['secureConnection', false]and the client'ssecureConnect). Output matches Node v26.3.0 exactly; the reject case fails on main with['secureConnection', false]in place oftlsClientError.Regression sweeps, failures match main:
test/js/node/test/parallel/test-tls-*.js: 172/175 pass, no diff vs maintest/js/node/test/parallel/test-https-*.js: 58/59, no difftest/js/node/test/parallel/test-http2-*.js(first 30): no difftest/js/node/http2/node-http2.test.js: 307 pass / 0 failtest/js/node/tls/*.test.ts,test/js/bun/net/socket.test.ts: only pre-existing failurestest/js/node/test/parallel/test-tls-close-error.js: continues to pass (now for the right reason)