Repository: denoland/deno
Pull request: fix(ext/node/net): emit `error` before `close` when connection is refused

This PR delays the timing of callback called in `socket._handle.close(cb)` call, and causes `error` event happens before `close` event when `socket.destroy()` is called.

- This prevents uncaught error with `npm:mongodb` package (#24376, #19078).

closes #24376
closes #23665
closes #21951
closes #19078

Note: The changes in `unit_node/tls_test.ts`, `unit_node/http_test.ts`, and `polyfills/dgram.ts` were needed for avoiding op leak error in testing. They are not directly related to the fix.

TODO:
- [x] ~~enable test cases~~ Write test cases (I couldn't find the exact test case which exercise this behavior of `net.createConnection` in node.js native test cases)

Changed files:
filename: ext/node/polyfills/dgram.ts
patch: @@ -531,16 +531,17 @@ export class Socket extends EventEmitter {
     healthCheck(this);
     stopReceiving(this);
 
-    state.handle!.close();
+    state.handle!.close(() => {
+      // Deviates from the Node implementation to avoid leaking the timer ops at 'close' event
+      defaultTriggerAsyncIdScope(
+        this[asyncIdSymbol],
+        nextTick,
+        socketCloseNT,
+        this,
+      );
+    });
     state.handle = null;
 
-    defaultTriggerAsyncIdScope(
-      this[asyncIdSymbol],
-      nextTick,
-      socketCloseNT,
-      this,
-    );
-
     return this;
   }
 

filename: ext/node/polyfills/internal_binding/handle_wrap.ts
patch: @@ -25,13 +25,12 @@
 // - https://github.com/nodejs/node/blob/master/src/handle_wrap.h
 
 // TODO(petamoriken): enable prefer-primordials for node polyfills
-// deno-lint-ignore-file prefer-primordials
-
 import { unreachable } from "ext:deno_node/_util/asserts.ts";
 import {
   AsyncWrap,
   providerType,
 } from "ext:deno_node/internal_binding/async_wrap.ts";
+import { nextTick } from "ext:deno_node/_process/process.ts";
 
 export class HandleWrap extends AsyncWrap {
   constructor(provider: providerType) {
@@ -40,7 +39,9 @@ export class HandleWrap extends AsyncWrap {
 
   close(cb: () => void = () => {}) {
     this._onClose();
-    queueMicrotask(cb);
+    // We need to delay 'cb' at least 2 ticks to avoid "close" event happenning before "error" event in net.Socket
+    // See https://github.com/denoland/deno/pull/24656 for more information
+    nextTick(nextTick, cb);
   }
 
   ref() {

filename: tests/unit_node/http_test.ts
patch: @@ -846,7 +846,10 @@ Deno.test(
   "[node/http] client upgrade",
   { permissions: { net: true } },
   async () => {
-    const { promise, resolve } = Promise.withResolvers<void>();
+    const { promise: serverClosed, resolve: resolveServer } = Promise
+      .withResolvers<void>();
+    const { promise: socketClosed, resolve: resolveSocket } = Promise
+      .withResolvers<void>();
     const server = http.createServer((req, res) => {
       // @ts-ignore: It exists on TLSSocket
       assert(!req.socket.encrypted);
@@ -887,12 +890,16 @@ Deno.test(
         // @ts-ignore it's a socket for real
         serverSocket!.end();
         server.close(() => {
-          resolve();
+          resolveServer();
+        });
+        socket.on("close", () => {
+          resolveSocket();
         });
       });
     });
 
-    await promise;
+    await serverClosed;
+    await socketClosed;
   },
 );
 

filename: tests/unit_node/net_test.ts
patch: @@ -5,7 +5,7 @@ import { assert, assertEquals } from "@std/assert/mod.ts";
 import * as path from "@std/path/mod.ts";
 import * as http from "node:http";
 
-Deno.test("[node/net] close event emits after error event", async () => {
+Deno.test("[node/net] close event emits after error event - when host is not found", async () => {
   const socket = net.createConnection(27009, "doesnotexist");
   const events: ("error" | "close")[] = [];
   const errorEmitted = Promise.withResolvers<void>();
@@ -24,6 +24,25 @@ Deno.test("[node/net] close event emits after error event", async () => {
   assertEquals(events, ["error", "close"]);
 });
 
+Deno.test("[node/net] close event emits after error event - when connection is refused", async () => {
+  const socket = net.createConnection(27009, "127.0.0.1");
+  const events: ("error" | "close")[] = [];
+  const errorEmitted = Promise.withResolvers<void>();
+  const closeEmitted = Promise.withResolvers<void>();
+  socket.once("error", () => {
+    events.push("error");
+    errorEmitted.resolve();
+  });
+  socket.once("close", () => {
+    events.push("close");
+    closeEmitted.resolve();
+  });
+  await Promise.all([errorEmitted.promise, closeEmitted.promise]);
+
+  // `error` happens before `close`
+  assertEquals(events, ["error", "close"]);
+});
+
 Deno.test("[node/net] the port is available immediately after close callback", async () => {
   const deferred = Promise.withResolvers<void>();
 

filename: tests/unit_node/tls_test.ts
patch: @@ -48,6 +48,7 @@ for (
     conn.close();
     outgoing.destroy();
     listener.close();
+    await new Promise((resolve) => outgoing.on("close", resolve));
   });
 }
 
@@ -93,6 +94,7 @@ Connection: close
 
 // https://github.com/denoland/deno/pull/20120
 Deno.test("tls.connect mid-read tcp->tls upgrade", async () => {
+  const { promise, resolve } = Promise.withResolvers<void>();
   const ctl = new AbortController();
   const serve = Deno.serve({
     port: 8443,
@@ -119,8 +121,10 @@ Deno.test("tls.connect mid-read tcp->tls upgrade", async () => {
     conn.destroy();
     ctl.abort();
   });
+  conn.on("close", resolve);
 
   await serve.finished;
+  await promise;
 });
 
 Deno.test("tls.createServer creates a TLS server", async () => {
@@ -136,6 +140,7 @@ Deno.test("tls.createServer creates a TLS server", async () => {
           socket.destroy();
         }
       });
+      socket.on("close", () => deferred.resolve());
     },
   );
   server.listen(0, async () => {
@@ -166,7 +171,6 @@ Deno.test("tls.createServer creates a TLS server", async () => {
 
     conn.close();
     server.close();
-    deferred.resolve();
   });
   await deferred.promise;
 });