Skip to content

node:tls: keep the native handle reading through the handshake when the socket is paused - #35151

Open
robobun wants to merge 7 commits into
mainfrom
farm/cb7e9b8e/tls-pause-before-handshake
Open

node:tls: keep the native handle reading through the handshake when the socket is paused#35151
robobun wants to merge 7 commits into
mainfrom
farm/cb7e9b8e/tls-pause-before-handshake

Conversation

@robobun

@robobun robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

What

Calling socket.pause() on a TLS socket before the TLS handshake completes stalls the handshake forever. In practice this hits three paths:

  • s.pause() inside a tls.Server 'connection' handler
  • tls.createServer({ pauseOnConnect: true })
  • tls.connect({ pauseOnConnect: true })

All three complete in Node.

Repro

import tls from 'node:tls'; import fs from 'node:fs'; import { once } from 'node:events';
const server = tls.createServer({
  key: fs.readFileSync('test/js/node/test/fixtures/keys/agent1-key.pem'),
  cert: fs.readFileSync('test/js/node/test/fixtures/keys/agent1-cert.pem'),
});
server.on('connection', s => s.pause());
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const cli = tls.connect({ port: server.address().port, host: '127.0.0.1', rejectUnauthorized: false });
await once(cli, 'secureConnect'); // completes in node, hangs in bun

Cause

Socket.prototype.pause calls this._handle.pause() → native us_socket_pause, which sets the poll to write-only. For a TLS socket mid-handshake the TLS engine then never sees the ClientHello / server Finished.

In Node the 'connection' event delivers the raw net.Socket and the separate TLSSocket's TLSWrap keeps reading the underlying fd regardless of either socket's stream state, so a pre-handshake pause() only affects delivery of decrypted output; the handshake and FIN/close_notify proceed. In Bun the same TLSSocket object is delivered to 'connection', so its native pause stops the whole read path.

Fix

Gate the native pause()/unref() in Socket.prototype.pause on !this.secureConnecting. While the handshake is in flight only the Duplex layer pauses (readableFlowing = false); the native handle keeps reading so the TLS engine can complete the handshake and observe close_notify. The data handler's if (!self.push(buffer)) socket.pause() applies native backpressure at highWaterMark once application data starts arriving.

ServerHandlers.handshake is changed to honor the handler's stream state: only apply the server's pauseOnConnect/resume default when readableFlowing === null. A pause() (or 'data'/'readable' attach) made inside the 'connection' or 'secureConnection' handler is no longer stomped by the post-emit resume().

Plain TCP (net.createServer, net.connect) is unchanged: secureConnecting is not set on plain sockets.

What matches Node and what does not

The Node-matching observable is that the handshake completes on all three paths (verified against Node v26.3.0). Server pauseOnConnect: true also matches Node's post-handshake readable state (isPaused() === true, readableFlowing === false; Node's own test-tls-server-parent-constructor-options asserts this).

The post-handshake readable state for the other paths reflects a pre-existing architectural divergence this PR does not change: Bun hands the same TLSSocket to both 'connection' and 'secureConnection', and tls.connect returns the same object that pauseOnConnect acts on, while Node uses separate objects. So a pause() made on the 'connection' socket, or client pauseOnConnect, is visible on Bun's secureConnection/returned socket (isPaused() === true) but not on Node's. The custom tests that assert Bun's state say so inline.

Verification

bun bd test test/js/node/tls/tls-pause-handshake.test.ts
bun bd test/js/node/test/parallel/test-tls-server-parent-constructor-options.js

All five hang/time out (or assert the stomped state) on main and pass with this change.

Related

#35148 fixes only the server pauseOnConnect option path at the onconnection call site; this PR fixes it at Socket.prototype.pause so every pre-handshake pause() call site is covered, and subsumes #35148. #35108 covers the plain-TCP sibling of the ServerHandlers.handshake gate.


[review] gate passed · iteration 4 · 3 files touched

fails on main (without fix)
ASAN without fix: 4 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/tls/tls-pause-handshake.test.ts
bun test v1.4.0 (bf584bdc6)

test/js/node/tls/tls-pause-handshake.test.ts:
(fail) pausing a TLS socket before the handshake does not stall it > server: s.pause() inside the 'connection' handler [5000.10ms]
  ^ this test timed out after 5000ms.
 95 |       // (test-tls-server-parent-constructor-options).
 96 |       expect({ paused, flowing }).toEqual({ paused: true, flowing: false });
 97 | 
 98 |       cli.write("hello");
 99 |       await waitFor(() => srv!.readableLength >= 5);
100 |       expect({ flowing: srv.readableFlowing, readableLength: srv.readableLength }).toEqual({
                                                                                         ^
error: expect(received).toEqual(expected)

  {
    "flowing": false,
-   "readableLength": 5,
+   "readableLength": 0,
  }

- Expected  - 1
+ Received  + 1

      at <anonymous> (/workspace/bun/test/js/node/tls/tls-pause-handshake.test.ts:100:84)
(fail) pausing a TLS socket before the handshake does not stall it > server: pauseO
... (truncated)

release without fix: 4 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/node/tls/tls-pause-handshake.test.ts:
(fail) pausing a TLS socket before the handshake does not stall it > server: s.pause() inside the 'connection' handler [5000.01ms]
  ^ this test timed out after 5000ms.
(fail) pausing a TLS socket before the handshake does not stall it > server: pauseOnConnect: true [5000.00ms]
  ^ this test timed out after 5000ms.
131 |       cli.on("error", () => {});
132 |       await once(cli, "secureConnect");
133 |       srv = await accepted.promise;
134 |       // Before the fix the post-emit resume() flipped this back to
135 |       // paused:false / flowing:true.
136 |       expect({ paused: srv.isPaused(), flowing: srv.readableFlowing }).toEqual({ paused: true, flowing: false });
                                                                             ^
error: expect(received).toEqual(expected)

  {
-   "flowing": false,
-   "paused": true,
+   "flowing": true,
+   "paused": false,
  }

- Expected  - 2
+ Received  + 2

      at <anonymous> (/workspace/bun/test/js/node/tls/tls-pause-handshake.test.ts:136:72)
(fail) pausing a TLS socket before the handshake does not stall it > server: s.p
... (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/mechgate.xml" test/js/node/tls/tls-pause-handshake.test.ts
bun test v1.4.0 (bf584bdc6)

test/js/node/tls/tls-pause-handshake.test.ts:
(pass) pausing a TLS socket before the handshake does not stall it > server: s.pause() inside the 'connection' handler [750.07ms]
(pass) pausing a TLS socket before the handshake does not stall it > server: pauseOnConnect: true [133.73ms]
(pass) pausing a TLS socket before the handshake does not stall it > server: s.pause() inside the 'secureConnection' handler [114.90ms]
(pass) pausing a TLS socket before the handshake does not stall it > client: pauseOnConnect: true [121.71ms]

 4 pass
 0 fail
 10 expect() calls
Ran 4 tests across 1 file. [3.42s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 666ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/25] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 237 extern-C blocks audited
[2/25] gen JS modules (bundle-modules)
Preprocess modules (7202ms)
Bundle modules (31ms)
Postprocesss modules (21ms)
Bundle Functions (658ms)
Generate Code (12ms)

[7.94s] Bundled "src/js" for production
  2050 kb
  166 internal modules
  13 native modules
  90 internal functions across 19 files
[3/12] cxx obj/unified/UnifiedSource-src_uws_sys-0.cpp.o
[4/12] cxx obj/unified/UnifiedSource-src_jsc_bindings_node-0.cpp.o
[5/12] cxx obj/unified/UnifiedSource-src_jsc_bindings-5.cpp.o
[6/12] cxx obj/src/jsc/bindings/bindings.cpp.o
[7/12] cxx obj/unified/UnifiedSource-src_jsc_bindings-4.cpp.o
[8/12] cxx obj/unified/UnifiedSource-src_jsc_bindings-3.cpp.o
[9/12] cxx obj/unified/UnifiedSource-src_jsc_bindings-1.cpp.o
[9/12] link bun-profile
[10/12] bun-profile --revision
1.4.0-canary.1+bf584bdc6
[12/12] strip bun
[build] done
bun test v1.4.0-canary.1 (bf584bdc6)

test/js/node/t
... (truncated)
diff hotspot
src/js/node/net.ts                                 |  15 +-
 .../test-tls-server-parent-constructor-options.js  |  68 ++++++++
 test/js/node/tls/tls-pause-handshake.test.ts       | 182 +++++++++++++++++++++
 3 files changed, 262 insertions(+), 3 deletions(-)

gate history · 2 passed · 1 rejected · iteration 4

evidence per changed file
file                                                      reads  edits  tests
src/js/node/net.ts                                           18      9      0
…/parallel/test-tls-server-parent-constructor-options.js      0      1      0
test/js/node/tls/tls-pause-handshake.test.ts                  1      2      0

…en paused

Socket.prototype.pause() called while a TLS handshake is in flight
stopped native reads (us_socket_pause sets the poll write-only), so the
TLS engine never saw the ClientHello / server Finished and the handshake
wedged forever. In Node the TLSWrap reads the underlying fd independently
of the TLSSocket's stream state, so a pre-handshake pause() only affects
delivery of decrypted output; the handshake and FIN/close_notify proceed.

Gate the native pause()/unref() on !secureConnecting so only the Duplex
layer pauses. The data handler applies native backpressure at
highWaterMark once application data starts arriving. Also make
ServerHandlers.handshake honor a handler's stream state (readableFlowing
!== null) instead of unconditionally resume()ing, so a pause() made
inside the 'connection' or 'secureConnection' handler survives.

Covers:
- s.pause() inside a TLS server's 'connection' handler
- tls.createServer({ pauseOnConnect: true })
- tls.connect({ pauseOnConnect: true })
@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:07 AM PT - Jul 23rd, 2026

@robobun, your commit bf584bdc6b31ae5923750ef9bdd2753625cbc134 passed in Build #78456! 🎉


🧪   To try this PR locally:

bunx bun-pr 35151

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

bun-35151 --bun

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

TLS handshake pause and resume handling now preserves stream states selected by connection handlers and avoids pausing native reads during secure connection setup. New tests cover constructor options, server-side pauses, pauseOnConnect, and client-side handshake behavior.

TLS pause and flow control

Layer / File(s) Summary
Preserve TLS handshake flow state
src/js/node/net.ts
Handshake pause/resume operations now respect readableFlowing, and native handle pausing is skipped while TLS is connecting.
Validate TLS server constructor options
test/js/node/test/parallel/test-tls-server-parent-constructor-options.js
Tests verify default and explicit allowHalfOpen and pauseOnConnect values on TLS servers and accepted sockets.
Validate pause behavior during handshakes
test/js/node/tls/tls-pause-handshake.test.ts
Tests cover pauses in connection handlers, secureConnection, server pauseOnConnect, and client pauseOnConnect while confirming handshake and data flow completion.

Possibly related PRs

  • oven-sh/bun#35108: Covers related readableFlowing-based gating of post-handshake resume behavior.
  • oven-sh/bun#35148: Adjusts the same TLS pause-on-connect handshake paths.
  • oven-sh/bun#34285: Modifies related TLS pause/resume handling around readableFlowing.

Suggested reviewers: cirospaciari

🚥 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 summarizes the main TLS handshake pause fix and matches the changeset.
Description check ✅ Passed The description covers the PR purpose and verification, including repro, cause, fix, and test commands.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 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 `@src/js/node/net.ts`:
- Around line 884-888: Condense the inline comments to three lines or fewer
while preserving their durable invariants: shorten the flow-state preservation
explanation in src/js/node/net.ts lines 884-888, the native-read/handshake
invariant in src/js/node/net.ts lines 2120-2126, the regression rationale in
test/js/node/tls/node-tls-server.test.ts lines 1308-1311, and the Node/Bun
flow-state rationale in test/js/node/tls/node-tls-server.test.ts lines
1417-1425; no code behavior changes are required.
🪄 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: d5f97f91-86bf-4257-8086-0bd1579519fc

📥 Commits

Reviewing files that changed from the base of the PR and between 47597ab and e35dbb0.

📒 Files selected for processing (3)
  • src/js/node/net.ts
  • test/js/node/test/parallel/test-tls-server-parent-constructor-options.js
  • test/js/node/tls/node-tls-server.test.ts

Comment thread src/js/node/net.ts Outdated
@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. tls: complete the handshake when pauseOnConnect is set on the server #35148 - Fixes the same TLS handshake stall caused by pauseOnConnect on the server side; node:tls: keep the native handle reading through the handshake when the socket is paused #35151 subsumes it with a broader fix covering pause() inside the 'connection' handler and client-side pauseOnConnect
  2. node:net: honor pause() made inside the 'connection' handler #35108 - Fixes the same resume() stomping a user's pause() in ServerHandlers.handshake by gating on readableFlowing === null, which is one of the two changes node:tls: keep the native handle reading through the handshake when the socket is paused #35151 also makes; node:net: honor pause() made inside the 'connection' handler #35108 covers only the plain-TCP path

🤖 Generated with Claude Code

Comment thread test/js/node/tls/node-tls-server.test.ts Outdated
…ndler coverage, clarify Node-divergence comments

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 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 `@test/js/node/tls/node-tls-server.test.ts`:
- Around line 1314-1316: Introduce a descriptive named constant for the
2000-iteration polling limit used by waitFor, then replace the inline literal in
its loop condition with that constant. Keep waitFor’s existing polling behavior
unchanged.
🪄 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: 4d42c19d-cd2a-42a5-a679-3312232a5dd6

📥 Commits

Reviewing files that changed from the base of the PR and between e35dbb0 and 957e354.

📒 Files selected for processing (2)
  • src/js/node/net.ts
  • test/js/node/tls/node-tls-server.test.ts

Comment thread test/js/node/tls/node-tls-server.test.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.

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

  • 🟡 test/js/node/tls/node-tls-server.test.ts:1346-1349 — nit: this negative-contract guard throws inside a 'data' event callback, which REVIEW.md's test rules explicitly prohibit ("never throw inside event callbacks"). If a regression ever fires 'data' while paused, the throw propagates through emit() back into the native data-dispatch path and surfaces as an uncaught exception rather than a cleanly-attributed test failure. Record the violation into a captured boolean instead — e.g. let firedWhilePaused = false; srv.on('data', d => { if (stopped) firedWhilePaused = true; got += d; }) — and expect(firedWhilePaused).toBe(false) after the waitFor barrier.

    Extended reasoning...

    What the issue is

    The "server: s.pause() inside the 'connection' handler" test guards its negative contract (no 'data' while paused) by throwing from inside the listener:

    srv.on("data", d => {
      if (stopped) throw new Error("data event fired while paused");
      got += d;
    });

    REVIEW.md's test rules are explicit on this: "Wire EVERY failure event … to reject the awaited promise — never throw inside event callbacks." This is a repository-specific check, not a subjective style preference.

    Why throwing here is the wrong failure channel

    If the negative contract is ever violated (e.g. a future regression makes 'data' fire while readableFlowing === false), the throw propagates synchronously through EventEmitter.emit()Readable's flow/push path → the native on_data dispatch wrapper. It surfaces as an uncaught exception on the event loop rather than a rejection of the promise the test is awaiting. The try/finally block in the test only catches exceptions thrown on the awaited chain, so cleanup ordering is not guaranteed relative to the uncaught-exception report, and on persistent CI runners an uncaught exception in one test can poison later tests in the same file.

    There's a second, subtler problem: because the throw happens before got += d, the subsequent expect({ got: '' , … }) assertion would still pass even if data fired while paused — so the throw is effectively the only guard for this contract, and it's wired to the wrong channel.

    Step-by-step: what happens on regression

    1. Suppose a future change causes srv to emit 'data' with "hello" while stopped === true and readableFlowing === false.
    2. The listener runs throw new Error("data event fired while paused").
    3. emit('data', …) does not catch listener throws; the exception unwinds through Readable's internal emit path back into the native socket data callback (Handlers.datarunCallback).
    4. Bun reports it as an uncaught exception. The test's await waitFor(() => srv!.readableLength >= 5) is still pending on a separate microtask chain; the try/finally around it does not observe the throw.
    5. got is still "" (the throw skipped got += d), so if the test does eventually reach the expect({ got: '' }) line it passes — the only failure signal is the out-of-band uncaught exception.

    Fix

    Record the violation and assert it on the awaited chain:

    let stopped = true;
    let firedWhilePaused = false;
    let got = "";
    srv.on("data", d => {
      if (stopped) firedWhilePaused = true;
      got += d;
    });
    cli.write("hello");
    await waitFor(() => srv!.readableLength >= 5);
    expect(firedWhilePaused).toBe(false);
    expect({ got, flowing: srv.readableFlowing, readableLength: srv.readableLength }).toEqual({
      got: "",
      flowing: false,
      readableLength: 5,
    });

    (Alternatively, reject a Promise.withResolvers() promise that the test awaits — either routes the failure through the test's own promise chain so try/finally cleanup runs in order and the failure is attributed to this test.)

    Severity

    This is nit — test hygiene, not a runtime correctness bug. The branch is dead on the happy path (the fix under test makes readableFlowing === false buffer without emitting), and the readableLength: 5 assertion also indirectly covers the contract. It only matters if the contract is later violated, at which point you want a clean, attributed failure rather than an uncaught exception.

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

CI on 6388921: the new test file and test-tls-server-parent-constructor-options.js pass on all lanes. Remaining red is unrelated:

  • test/js/node/test/parallel/test-net-connect-memleak.js is a GC-timing assertion that also fails on recent main builds (77601, 77580); plain net only, not touched by this diff.
  • test/cli/install/migration/complex-workspace.test.ts and test-http-server-connections-checking-leak.js passed on retry.

Ready for review.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun fix the conflicts

@robobun

robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (which now includes #32630). The conflict was in ServerHandlers.handshake: #32630 moved the pauseOnConnect pause to just before the secureConnection emit; kept that placement and made it a Duplex-only pause so close_notify/FIN still arrive and decrypted bytes buffer on the paused socket the way Node does, plus the readableFlowing === null gate on the post-emit resume. All 4 tests in test/js/node/tls/tls-pause-handshake.test.ts fail against main's src/ and pass with the fix.

@robobun

robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Status of this PR against main (72ec6e2) and #39830 (2c6c0bc), from a run of this PR's test file on both:

So #39830 covers the handshake stall. The read-ahead assertion is the one part of this PR that neither main nor #39830 has, and the two PRs pin it in opposite directions. Leaving this open for that decision. The branch also conflicts with main now.

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