Repository: denoland/deno
Pull request: fix(ext/node): tls.connect regression

The TLS start sequence has been broken since #26661 because of the way how we wrap TCP handle to create TLS handle.

#26661 introduced happy-eyeballs algorithm and some connection could be dropped because of happy-eyeball attempt timeout. The current implementation doesn't consider that case and it could start TLS handshake with timed out TCP connection. That caused #27652 .

This PR fixes it by changing the initialization steps. Now `wrapHandle` of TLSSocket set up `afterConnectTls` callback in TCP handle, and `afterConnect` of TCP handle calls it at `connect` event timing if it exists. This avoids starting TLS session with timed out connection.

closes #27652

Changed files:
filename: ext/node/polyfills/_tls_wrap.ts
patch: @@ -79,7 +79,7 @@ export class TLSSocket extends net.Socket {
   ssl: any;
 
   _start() {
-    this[kHandle].afterConnect();
+    this[kHandle].afterConnectTls();
   }
 
   constructor(socket: any, opts: any = kEmptyObject) {
@@ -150,16 +150,14 @@ export class TLSSocket extends net.Socket {
 
       const { promise, resolve } = Promise.withResolvers();
 
-      // Patches `afterConnect` hook to replace TCP conn with TLS conn
-      const afterConnect = handle.afterConnect;
-      handle.afterConnect = async (req: any, status: number) => {
+      // Set `afterConnectTls` hook. This is called in the `afterConnect` method of net.Socket
+      handle.afterConnectTls = async () => {
         options.hostname ??= undefined; // coerce to undefined if null, startTls expects hostname to be undefined
         if (tlssock._needsSockInitWorkaround) {
           // skips the TLS handshake for @npmcli/agent as it's handled by
           // onSocket handler of ClientRequest object.
           tlssock.emit("secure");
           tlssock.removeListener("end", onConnectEnd);
-          return afterConnect.call(handle, req, status);
         }
 
         try {
@@ -190,7 +188,6 @@ export class TLSSocket extends net.Socket {
         } catch {
           // TODO(kt3k): Handle this
         }
-        return afterConnect.call(handle, req, status);
       };
 
       handle.upgrading = promise;

filename: ext/node/polyfills/net.ts
patch: @@ -378,6 +378,12 @@ function _afterConnect(
     socket.emit("connect");
     socket.emit("ready");
 
+    // Deno specific: run tls handshake if it's from a tls socket
+    // This swaps the handle[kStreamBaseField] from TcpConn to TlsConn
+    if (typeof handle.afterConnectTls === "function") {
+      handle.afterConnectTls();
+    }
+
     // Start the first read, or get an immediate EOF.
     // this doesn't actually consume any bytes, because len=0.
     if (readable && !socket.isPaused()) {

filename: tests/unit_node/tls_test.ts
patch: @@ -12,6 +12,7 @@ import { fromFileUrl, join } from "@std/path";
 import * as tls from "node:tls";
 import * as net from "node:net";
 import * as stream from "node:stream";
+import { text } from "node:stream/consumers";
 import { execCode } from "../unit/test_util.ts";
 
 const tlsTestdataDir = fromFileUrl(
@@ -97,6 +98,18 @@ Connection: close
   assertEquals(bodyText, "hello");
 });
 
+// Regression at https://github.com/denoland/deno/issues/27652
+Deno.test("tls.connect makes tls connection to example.com", async () => {
+  const socket = tls.connect(443, "example.com");
+  await new Promise((resolve) => {
+    socket.on("secureConnect", resolve);
+  });
+  socket.write(
+    "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n",
+  );
+  assertStringIncludes(await text(socket), "<title>Example Domain</title>");
+});
+
 // https://github.com/denoland/deno/pull/20120
 Deno.test("tls.connect mid-read tcp->tls upgrade", async () => {
   const { promise, resolve } = Promise.withResolvers<void>();