Repository: denoland/deno
Pull request: fix(node/http): stop leaking TCP wrappers on HTTPS upgrade with createConnection TLSSocket

Fixes a Node-compat HTTP/TLS bug where `https.request()` could leak native `TCP` wrappers when `createConnection` returns a pre-created `TLSSocket` and the request upgrades the connection.

This is the same pattern used by `npm:ws` - create the TLS socket first with `tls.connect()`, then pass it to `https.request({ createConnection })`.

This patch:
- reuses the original encrypted `TLSSocket` during the upgrade path instead of creating a replacement socket
- skips the sock-init workaround for sockets that are already encrypted
- waits for `secureConnect` before flushing the request on pre-created `TLSSocket`s
- preserves the TLS-upgrade state needed when the handle is reinitialized

## User-visible improvement

With the attached repro, RSS no longer grows linearly with each upgraded connection.

Before this patch:
- RSS grows from 102 MB to 349 MB over about 4,053 connections in a comparable ~5 minute run

After this patch:
- RSS grows from 96 MB to 118 MB over about 3,920 connections in a comparable ~5 minute run
- heap usage stays roughly flat
- the process reaches a steady state instead of continuing to climb

<img width="1000" height="620" alt="deno_tls_upgrade_rss_comparison" src="https://github.com/user-attachments/assets/63d1cba4-b15c-4c6e-84a6-7d68a4933ea1" />

Script used can be found at https://github.com/magurotuna/tls_then_upgrade/blob/main/client.ts

Changed files:
filename: ext/node/polyfills/_tls_wrap.js
patch: @@ -226,6 +226,7 @@ export class TLSSocket extends net.Socket {
 
           // Assign the TLS connection to the handle and resume reading.
           this[kStreamBaseField] = conn;
+          tlssock._tlsUpgraded = true;
           this.upgrading = false;
           if (!this.pauseOnCreate) {
             this.readStart();

filename: ext/node/polyfills/http.ts
patch: @@ -90,6 +90,7 @@ import {
 import { timerId } from "ext:deno_web/03_abort_signal.js";
 import { clearTimeout as webClearTimeout } from "ext:deno_web/02_timers.js";
 import { resourceForReadableStream } from "ext:deno_web/06_streams.js";
+import { kReinitializeHandle } from "ext:deno_node/internal/net.ts";
 import {
   kDestroyed,
   kEnded,
@@ -471,7 +472,9 @@ class ClientRequest extends OutgoingMessage {
             // we apply sock-init-workaround
             // This covers npm:ws and npm:mqtt
             // https://github.com/denoland/deno/issues/27694
-            newSocket._needsSockInitWorkaround = true;
+            if (newSocket.encrypted !== true) {
+              newSocket._needsSockInitWorkaround = true;
+            }
             oncreate(null, newSocket);
           }
         } catch (err) {
@@ -706,9 +709,41 @@ class ClientRequest extends OutgoingMessage {
               port: info[3],
             },
           );
-          const socket = new Socket({
-            handle: new TCP(constants.SOCKET, conn),
-          });
+          const upgradeHandle = new TCP(constants.SOCKET, conn);
+          let socket = this.socket;
+          // Only reuse the existing socket when it was provided via
+          // createConnection (encrypted TLS socket). For plain HTTP
+          // upgrades, create a fresh Socket to avoid dangling state.
+          if (
+            socket?.encrypted === true &&
+            socket?.[kReinitializeHandle]
+          ) {
+            if (this._socketErrorListener) {
+              socket.removeListener("error", this._socketErrorListener);
+              this._socketErrorListener = null;
+            }
+            socket.emit("agentRemove");
+            const tlsWrapState = socket.encrypted === true &&
+                socket._tlsUpgraded &&
+                socket._handle
+              ? {
+                verifyError: socket._handle.verifyError,
+                parentWrap: socket._handle._parentWrap,
+              }
+              : null;
+            if (tlsWrapState) {
+              delete socket._handle.afterConnectTls;
+            }
+            socket[kReinitializeHandle](upgradeHandle);
+            if (tlsWrapState) {
+              upgradeHandle.verifyError = tlsWrapState.verifyError;
+              upgradeHandle._parent = upgradeHandle;
+              upgradeHandle._parentWrap = tlsWrapState.parentWrap;
+              socket._tlsUpgraded = true;
+            }
+          } else {
+            socket = new Socket({ handle: upgradeHandle });
+          }
 
           this.upgradeOrConnect = true;
 
@@ -867,7 +902,9 @@ class ClientRequest extends OutgoingMessage {
         };
         this._socketErrorListener = socketErrorListener;
         socket.once("error", socketErrorListener);
-        if (socket.readyState === "opening") {
+        if (socket.encrypted === true && socket.secureConnecting) {
+          socket.once("secureConnect", onConnect);
+        } else if (socket.readyState === "opening") {
           socket.on("connect", onConnect);
         } else {
           onConnect();

filename: tests/unit_node/http_upgrade_create_connection_test.ts
patch: @@ -0,0 +1,81 @@
+// Copyright 2018-2026 the Deno authors. MIT license.
+
+import { once } from "node:events";
+import https from "node:https";
+import tls from "node:tls";
+import type { AddressInfo } from "node:net";
+import type { Duplex } from "node:stream";
+
+import { assert, assertStrictEquals } from "@std/assert";
+
+Deno.test({
+  name: "[node/https] client upgrade reuses TLSSocket from createConnection",
+  permissions: {
+    net: true,
+    read: [
+      "tests/testdata/tls/localhost.crt",
+      "tests/testdata/tls/localhost.key",
+    ],
+  },
+}, async () => {
+  const cert = Deno.readTextFileSync("tests/testdata/tls/localhost.crt");
+  const key = Deno.readTextFileSync("tests/testdata/tls/localhost.key");
+  let serverSocket: Duplex | undefined;
+
+  const server = https.createServer({ cert, key });
+  server.on("upgrade", (_req, socket, _head) => {
+    serverSocket = socket;
+    socket.write(
+      "HTTP/1.1 101 Switching Protocols\r\n" +
+        "Connection: Upgrade\r\n" +
+        "Upgrade: websocket\r\n" +
+        "\r\n",
+    );
+  });
+
+  server.listen(0, "127.0.0.1");
+  await once(server, "listening");
+
+  const { port } = server.address() as AddressInfo;
+  const tlsSocket = tls.connect({
+    host: "127.0.0.1",
+    port,
+    rejectUnauthorized: false,
+    servername: "localhost",
+  });
+
+  const req = https.request({
+    host: "127.0.0.1",
+    port,
+    method: "GET",
+    createConnection: () => tlsSocket,
+    headers: {
+      Connection: "Upgrade",
+      Upgrade: "websocket",
+      "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==",
+      "Sec-WebSocket-Version": "13",
+    },
+  });
+
+  const [_res, socket, _head] = await new Promise<
+    [unknown, Duplex, unknown]
+  >((resolve, reject) => {
+    req.on("upgrade", (...args) => resolve(args));
+    req.on("error", reject);
+    req.end();
+  });
+
+  assertStrictEquals(socket, tlsSocket);
+  // @ts-ignore TLSSocket-specific property
+  assert(socket.encrypted);
+
+  const socketClosed = once(socket, "close");
+  const peerClosed = serverSocket
+    ? once(serverSocket, "close")
+    : Promise.resolve();
+  const serverClosed = once(server, "close");
+  socket.destroy();
+  serverSocket?.destroy();
+  server.close();
+  await Promise.all([socketClosed, peerClosed, serverClosed]);
+});