Skip to content

fix(ext/node): send TLS close_notify on JS stream-backed socket shutdown#35582

Merged
bartlomieju merged 5 commits into
denoland:mainfrom
tomas-zijdemans:tlsCloseNotify
Jun 29, 2026
Merged

fix(ext/node): send TLS close_notify on JS stream-backed socket shutdown#35582
bartlomieju merged 5 commits into
denoland:mainfrom
tomas-zijdemans:tlsCloseNotify

Conversation

@tomas-zijdemans

Copy link
Copy Markdown
Contributor

For JS-backed streams (a TLSSocket wrapping a non-net.Socket Duplex
the mssql/tedious DuplexPair pattern, also used by Prisma), calling .end()
on the TLS socket did not propagate EOF to the peer.

net.Socket's shutdown invokes the shutdown op, which produces the TLS
close_notify into the pull-based pending_enc_out buffer. But on the JS
stream path, do_enc_out_action(WriteJs) leaves that data buffered (it never
calls the JS flush), and UnderlyingStream::Js::shutdown() is a no-op — the uv
path relies on uv_shutdown, which doesn't exist here. As a result the
close_notify was never written and the underlying stream was never ended, so
the peer never observed EOF and hung. In tedious/Prisma against MSSQL this
surfaces as Connection lost - socket hang up.

Wrap shutdown for JS-backed streams so that after the native op runs we flush
the buffered encrypted output (including close_notify) through the existing
pump and then end() the underlying stream once drained, mirroring
uv_shutdown on the native path.

Adds a regression test: a server-side TLSSocket over a back-to-back Duplex
pair must propagate close_notify/EOF on .end().

Fixes #32271. Even though it is marked as Closed, I have confirmed that it is still valid on the latest code.

I used Cursor (Claude) to help investigate and write this change.

@bartlomieju bartlomieju left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for the careful writeup and the regression test. I confirmed the diagnosis against the Rust side: the shutdown op sends close_notify + do_enc_out_action (which buffers for WriteJs) and then calls underlying.shutdown(), which is a no-op for JS streams (tls_wrap.rs:842), while req.oncomplete(0) fires synchronously — so _final always completed but the close_notify/EOF never reached the wire. The fix is well-targeted and using .end() (half-close) rather than destroy() correctly matches uv_shutdown semantics so the peer can still respond.

My one substantive concern is the .end()-during-handshake case (inline below). I'd also like to see a test for the symmetric client-side .end() and for the handshake-not-yet-complete path, since that's the scenario most likely to regress from this change. Inline notes follow.

Comment thread ext/node/polyfills/internal_binding/tls_wrap.ts
Comment thread ext/node/polyfills/internal_binding/tls_wrap.ts Outdated
Comment thread ext/node/polyfills/internal_binding/tls_wrap.ts
Comment thread tests/unit_node/tls_test.ts Outdated
@deno-cla-assistant

deno-cla-assistant Bot commented Jun 28, 2026

Copy link
Copy Markdown

Deno Individual Contributor License Agreement

All contributors have signed the CLA. Thank you!

Re-run CLA check


This is an automated message from CLA Assistant

@tomas-zijdemans

tomas-zijdemans commented Jun 28, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review.

Deferred close_notify (main concern): fixed. The terminal end() of the underlying stream is now gated on the handshake being complete (res._owner?._secureEstablished), so .end() mid-handshake no longer tears
the transport down before the deferred close_notify is produced.
The ordering holds up: in dispatch_clear_out_callbacks, _secureEstablished is set (via onhandshakedone) before the deferred send_close_notify(), so the pump drains/writes the close_notify first and only ends the stream once secure.

Tests: added the symmetric client-side .end() and the end()-during-handshake (deferred) cases.

Worth flagging: writing those tests showed my first versions didn't actually reproduce the bug, which sharpened the root cause:

  • It only surfaces when both ends are JS-backed. With a native peer the TCP FIN delivers EOF regardless, so the missing close_notify is invisible and the test passes even unpatched.
  • It only surfaces on an idle connection. Ending at secure (or right after a write) lets the close_notify ride that write's flush; you have to .end() a macrotask later for it to get stranded in pending_enc_out.

That's exactly the tedious/Prisma pool path: a connection is used, goes idle, then closed. The tests now use both-JS-backed peers + setImmediate(() => sock.end()). They hang without the patch, pass with it, and the handshake
test fails against a naive ungated end().

(Re the .apply/fast-call note: agreed, left as-is since it's a one-shot shutdown.)

@bartlomieju

Copy link
Copy Markdown
Member

Took a close look — the fix is correct and well-scoped (verified the root cause: UnderlyingStream::Js::shutdown() is a no-op so the buffered close_notify was never flushed/ended on the JS path, and the wrapper drives the existing pump to end the underlying stream once drained + handshake-established). The deferred mid-handshake path and the res._owner._secureEstablished gate both check out. A few non-blocking questions before it lands:

  • TLS 1.3 not covered. All three tests pin maxVersion: "TLSv1.2", but Deno's default is TLS 1.3. The fix logic is version-agnostic, and the pump re-checks drain state after every write so post-handshake NewSessionTicket traffic should be handled — but it's unverified. Worth confirming the fix works under 1.3 (and ideally one test without the version pin), since real clients may negotiate either. Why is 1.2 pinned — determinism, or a known 1.3 issue?

  • Already-ended underlying stream. If the peer has already ended/destroyed the Duplex when our .end() fires, this relies on Node's end()-after-end being a safe no-op. It almost certainly is (guarded by underlyingEnded), but a one-line confirmation would be reassuring.

  • Failure mode is a timeout, not an assertion. If the regression returns, the tests hang until the harness timeout rather than failing fast. The comments acknowledge this and it's acceptable for this class of bug, but a per-test deadline would make CI failures clearer.

@tomas-zijdemans

Copy link
Copy Markdown
Contributor Author

Thanks.

(on "why 1.2 pinned", it mirrors tedious/MSSQL. TDS 7.x caps at TLS 1.2)

What I pushed in the latest commit:

  1. TLS 1.3 coverage: Parameterized all three JS-backed-duplex tests over ["TLSv1.2", "TLSv1.3"], passing maxVersion into both ends.

  2. end()-after-destroy guard (ext/node/polyfills/internal_binding/tls_wrap.ts?): Added && !jsStreamOwner.stream.destroyed to the shutdown gate, with a one-line note that end()-after-destroy is a no-op anyway.

  3. Per-test deadline: Imported deadline from @std/async/deadline and wrapped each await promise in await deadline(promise, 10_000), so a regression fails fast with a TimeoutError instead of hanging to the harness timeout.

@tomas-zijdemans

Copy link
Copy Markdown
Contributor Author

Fix CI regression surfaced by test-tls-net-socket-keepalive[-12].js.

Those tests call tls.connect({ socket }) and socket.connect() after, so the
socket is wrapped via JSStreamSocket (our path), then .end() half-closes
while still reading the server's reply. The earlier version eagerly ended the
underlying stream after the handshake, which raced — and usually preempted —
that read, so the peer's response was lost (deterministic locally, flaky on the
slower CI runner).

Fix: on shutdown, flush the close_notify through the pump but do not end
the underlying stream. The close_notify bytes already give the peer a clean
TLS-level EOF (peer_has_closed()), so ending the transport was redundant and
is what broke half-open peers.

The original hang is still fixed (verified by reverting on the same commit:
unpatched hangs, patched delivers EOF), unit_node::tls_test passes (TLS
1.2/1.3), and the keepalive tests now pass.

@bartlomieju bartlomieju left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, nice fix 👍

@bartlomieju
bartlomieju merged commit 96cedb7 into denoland:main Jun 29, 2026
268 of 270 checks passed
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.

"socket hang up" when using prisma and adapter-mssql

2 participants