Repository: nodejs/node
Pull request: net: defer synchronous destroy calls in internalConnect

Defer socket.destroy() calls in internalConnect and internalConnectMultiple to the next tick. This ensures that error handlers have a chance to be set up before errors are emitted, particularly important when using http.request with a custom lookup function that returns synchronously.

## Problem

When using `http.request()` with a custom synchronous lookup function that returns an IP triggering an immediate error (e.g., via blockList), the error was emitted before the HTTP client had set up its error handler. The HTTP client sets up the error handler via `process.nextTick` in `onSocket()`, but the connection error occurred synchronously during `net.createConnection()`.

This caused unhandled 'error' events that could not be caught:

```js
const blockList = new net.BlockList();
blockList.addAddress('127.0.0.1');

const req = http.request({
  host: 'example.com',
  port: 80,
  lookup: (hostname, options, cb) => cb(null, '127.0.0.1', 4),
  family: 4,
  createConnection: (opts) => net.createConnection({ ...opts, blockList })
});

req.on('error', (err) => {
  // This was never called - error was unhandled!
});
```

## Solution

Defer `socket.destroy()` calls to the next tick in:
- `internalConnect()` - bind errors, blockList errors, and connect errors
- `internalConnectMultiple()` - timeout and aggregate errors

This gives callers (especially the HTTP client) time to set up error handlers before errors are emitted.

## Testing

Added `test/parallel/test-http-request-lookup-error-catchable.js` that verifies errors are catchable when using http.request with a synchronous custom lookup and blockList.

Fixes: https://github.com/nodejs/node/issues/48771
Refs: https://github.com/nodejs/node/pull/51038

Changed files:
filename: lib/net.js
patch: @@ -1125,7 +1125,7 @@ function internalConnect(
     err = checkBindError(err, localPort, self._handle);
     if (err) {
       const ex = new ExceptionWithHostPort(err, 'bind', localAddress, localPort);
-      self.destroy(ex);
+      process.nextTick(emitErrorAndDestroy, self, ex);
       return;
     }
   }
@@ -1135,7 +1135,7 @@ function internalConnect(
 
   if (addressType === 6 || addressType === 4) {
     if (self.blockList?.check(address, `ipv${addressType}`)) {
-      self.destroy(new ERR_IP_BLOCKED(address));
+      process.nextTick(emitErrorAndDestroy, self, new ERR_IP_BLOCKED(address));
       return;
     }
     const req = new TCPConnectWrap();
@@ -1167,12 +1167,20 @@ function internalConnect(
     }
 
     const ex = new ExceptionWithHostPort(err, 'connect', address, port, details);
-    self.destroy(ex);
+    process.nextTick(emitErrorAndDestroy, self, ex);
   } else if ((addressType === 6 || addressType === 4) && hasObserver('net')) {
     startPerf(self, kPerfHooksNetConnectContext, { type: 'net', name: 'connect', detail: { host: address, port } });
   }
 }
 
+// Helper function to defer socket destruction to the next tick.
+// This ensures that error handlers have a chance to be set up
+// before the error is emitted, particularly important when using
+// http.request with a custom lookup function.
+function emitErrorAndDestroy(self, err) {
+  self.destroy(err);
+}
+
 
 function internalConnectMultiple(context, canceled) {
   clearTimeout(context[kTimeout]);
@@ -1186,11 +1194,11 @@ function internalConnectMultiple(context, canceled) {
   // All connections have been tried without success, destroy with error
   if (canceled || context.current === context.addresses.length) {
     if (context.errors.length === 0) {
-      self.destroy(new ERR_SOCKET_CONNECTION_TIMEOUT());
+      process.nextTick(emitErrorAndDestroy, self, new ERR_SOCKET_CONNECTION_TIMEOUT());
       return;
     }
 
-    self.destroy(new NodeAggregateError(context.errors));
+    process.nextTick(emitErrorAndDestroy, self, new NodeAggregateError(context.errors));
     return;
   }
 

filename: test/parallel/test-http-request-lookup-error-catchable.js
patch: @@ -0,0 +1,47 @@
+'use strict';
+const common = require('../common');
+const http = require('http');
+const net = require('net');
+
+// This test verifies that errors occurring synchronously during connection
+// when using http.request with a custom lookup function and blockList
+// can be caught by the error handler.
+// Regression test for https://github.com/nodejs/node/issues/48771
+
+// The issue occurs when:
+// 1. http.request() is called with a custom synchronous lookup function
+// 2. The lookup returns an IP that triggers a synchronous error (e.g., blockList)
+// 3. The error is emitted before http's error handler is set up (via nextTick)
+//
+// The fix defers socket.destroy() calls in internalConnect to the next tick,
+// giving http.request() time to set up its error handlers.
+
+const blockList = new net.BlockList();
+blockList.addAddress(common.localhostIPv4);
+
+// Synchronous lookup that returns the blocked IP
+const lookup = (_hostname, _options, callback) => {
+  callback(null, common.localhostIPv4, 4);
+};
+
+const req = http.request({
+  host: 'example.com',
+  port: 80,
+  lookup,
+  family: 4, // Force IPv4 to use simple lookup path
+  createConnection: (opts) => {
+    // Pass blockList to trigger synchronous ERR_IP_BLOCKED error
+    return net.createConnection({ ...opts, blockList });
+  },
+}, common.mustNotCall());
+
+// This error handler must be called.
+// Without the fix, the error would be emitted before http.request()
+// returns, causing an unhandled 'error' event.
+req.on('error', common.mustCall((err) => {
+  if (err.code !== 'ERR_IP_BLOCKED') {
+    throw new Error(`Expected ERR_IP_BLOCKED but got ${err.code}`);
+  }
+}));
+
+req.end();