Skip to content

perf: run TLS on a bridge to the socket instead of a DuplexPair - #1796

Open
arthurschreiber wants to merge 1 commit into
masterfrom
claude/tls-bridge
Open

arthurschreiber wants to merge 1 commit into
masterfrom
claude/tls-bridge

Conversation

@arthurschreiber

Copy link
Copy Markdown
Collaborator

With encrypt: true, TDS negotiates TLS inside PRELOGIN messages, so the TLS layer can't run on the socket directly. Until now it ran on one end of a DuplexPair. Once the handshake completed, the pair's other end was piped to and from the socket, which added two extra stream hops for every byte sent or received.

Changes

  • New src/tls-bridge.ts (TlsBridge). A single Duplex between tls.connect and the socket.
    • During the handshake: TLS data written in one tick is sent as one PRELOGIN message, and the payload of the server's response is pushed back to TLS. Data written while a response is outstanding is sent after that response arrives.
    • After secureConnect (startPassthrough()): data passes between TLS and the socket unchanged, with backpressure in both directions. If the TLS layer stops reading, the socket is paused. If the socket doesn't drain, the bridge holds back the write callback.
    • When the socket closes, the bridge closes too, so a write waiting for drain can't stall.
  • MessageIO.startTls uses the bridge. The onReadable loop and its re-arming are gone. securePair is replaced by tlsSocket, which is only used internally for setMaxSendFragment.
  • native-duplexpair is removed from the dependencies, along with its type stub in types/. The server side of the startTls unit tests now uses Node's built-in stream.duplexPair(), which exists on every Node version in the CI matrix. Production code doesn't use it.

benchmarks/package-lock.json still lists native-duplexpair as a dependency of file:... That lockfile is already stale in other ways, and regenerating it changes ~160 unrelated lines, so I left it alone.

Performance

Selecting a 1 MB nvarchar(max) value over TLS with packetSize: 16384. Medians of 6 interleaved runs of 300 queries each:

per query client CPU per query
master 11,886 µs 5,435 µs
this PR 11,238 µs 5,194 µs

That's about 240 µs less client CPU per MB received over TLS.

Tests

  • New test/unit/tls-bridge-test.ts. It covers:
    • batching the handshake data written in one go;
    • sending data written while a response is outstanding;
    • a failing exchange destroying the bridge;
    • leaving the socket alone during the handshake;
    • passthrough in both directions;
    • backpressure in both directions;
    • passing on the socket's end;
    • closing when the socket closes, including with a write that's waiting for drain (this test times out if the bridge doesn't listen for close).
  • The existing MessageIO#startTls unit tests pass unchanged, apart from the duplexPair switch.
  • npm run lint and npm test pass (556 passing).
  • Locally, integration tests against SQL Server 2022 with the default encrypt: true: 469 passing. The one failure (should not leave any dangling sockets after connection timeout) fails the same way on master in this environment, because the sandbox rejects connections to 192.0.2.1 instead of letting them time out.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HGJsu1u44CHnt4nkkQ5ias


Generated by Claude Code

With `encrypt: true`, TDS negotiates TLS inside `PRELOGIN` messages, so the
TLS layer can not run on the socket directly. It used to run on one end of
a `DuplexPair`, whose other end was piped to and from the socket once the
handshake completed - two extra stream hops for every byte.

`TlsBridge` is a single `Duplex` between the TLS layer and the socket.
During the handshake, it sends the TLS data written in one go as a
`PRELOGIN` message and hands the server's response back to TLS. Afterwards,
it passes the data between TLS and the socket as is, with backpressure in
both directions, and closes once the socket closed.

This removes the `native-duplexpair` dependency. The tests use Node's
built-in `stream.duplexPair()` instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGJsu1u44CHnt4nkkQ5ias
@codecov

codecov Bot commented Sep 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 84.58%. Comparing base (7c3b004) to head (16cd713).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1796      +/-   ##
==========================================
+ Coverage   84.38%   84.58%   +0.19%     
==========================================
  Files          95       96       +1     
  Lines        4702     4754      +52     
  Branches      880      888       +8     
==========================================
+ Hits         3968     4021      +53     
+ Misses        473      472       -1     
  Partials      261      261              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants