Commit 9746ebce077 for nodejs

commit 9746ebce077fceddcf0e8b11e85839a4cc11f6bb
Author: Matteo Collina <hello@matteocollina.com>
Date:   Mon Oct 5 23:26:47 2026 +0200

    net: race family autoselection attempts

    Keep pending TCP connections alive when starting
     fallback attempts. The first successful connection wins; fixed
     local ports retain sequential attempts.

    Assisted-by: pi coding agent
    Signed-off-by: Matteo Collina <hello@matteocollina.com>
    PR-URL: https://github.com/nodejs/node/pull/66229
    Reviewed-By: Paolo Insogna <paolo@cowtech.it>
    Reviewed-By: Tim Perry <pimterry@gmail.com>
    Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>

diff --git a/doc/api/net.md b/doc/api/net.md
index aabfaa4ead5..feecdf92efc 100644
--- a/doc/api/net.md
+++ b/doc/api/net.md
@@ -1143,9 +1143,11 @@ added:
 * `port` {number} The port which the socket attempted to connect to.
 * `family` {number} The family of the IP. It can be `6` for IPv6 or `4` for IPv4.

-Emitted when a connection attempt timed out. This is only emitted (and may be
-emitted multiple times) if the family autoselection algorithm is enabled
-in [`socket.connect(options)`][].
+Emitted when a connection attempt is still pending after the configured
+`autoSelectFamilyAttemptTimeout`. If another attempt is about to start, the
+pending attempt remains active and may still establish the connection, unless
+`localPort` requires sequential attempts. This is only emitted if the family
+autoselection algorithm is enabled in [`socket.connect(options)`][].

 ### Event: `'data'`

@@ -1396,22 +1398,28 @@ For TCP connections, available `options` are:

 * `autoSelectFamily` {boolean}: If set to `true`, it enables a family
   autodetection algorithm that loosely implements section 5 of [RFC 8305][]. The
-  `all` option passed to lookup is set to `true` and the sockets attempts to
-  connect to all obtained IPv6 and IPv4 addresses, in sequence, until a
-  connection is established. The first returned AAAA address is tried first,
-  then the first returned A address, then the second returned AAAA address and
-  so on. Each connection attempt (but the last one) is given the amount of time
-  specified by the `autoSelectFamilyAttemptTimeout` option before timing out and
-  trying the next address. Ignored if the `family` option is not `0` or if
-  `localAddress` is set. Connection errors are not emitted if at least one
-  connection succeeds. If all connections attempts fails, a single
-  `AggregateError` with all failed attempts is emitted. **Default:**
-  [`net.getDefaultAutoSelectFamily()`][].
-* `autoSelectFamilyAttemptTimeout` {number}: The amount of time in milliseconds
-  to wait for a connection attempt to finish before trying the next address when
-  using the `autoSelectFamily` option. If set to a positive integer less than
-  `10`, then the value `10` will be used instead. **Default:**
-  [`net.getDefaultAutoSelectFamilyAttemptTimeout()`][].
+  `all` option passed to lookup is set to `true` and the socket attempts to
+  connect to all obtained IPv6 and IPv4 addresses until a connection is
+  established. The first valid address is tried first, followed by addresses
+  from alternating families in their original order. After
+  `autoSelectFamilyAttemptTimeout` milliseconds, or as soon as an attempt fails,
+  the next attempt starts without canceling any pending attempts. The first
+  successful TCP connection wins and the other attempts are canceled. If the
+  last attempt fails while others are still pending, they are given one more
+  `autoSelectFamilyAttemptTimeout` before the connection fails. When `localPort`
+  is set, attempts are made sequentially because multiple connections cannot
+  portably bind the same local port. The option is ignored if `family` is not
+  `0` or if `localAddress` is set. Connection errors are not emitted if at least
+  one connection succeeds. If all connection attempts fail, a single
+  `AggregateError` with all failed attempts, in attempt order, is emitted.
+  **Default:** [`net.getDefaultAutoSelectFamily()`][].
+* `autoSelectFamilyAttemptTimeout` {number}: The delay in milliseconds before
+  starting the next connection attempt while the previous one is pending when
+  using the `autoSelectFamily` option. A failed attempt starts the next one
+  immediately. A pending attempt is not canceled when this delay elapses, except
+  when `localPort` requires sequential attempts. If set to a positive integer
+  less than `10`, then the value `10` will be used instead.
+  **Default:** [`net.getDefaultAutoSelectFamilyAttemptTimeout()`][].
 * `family` {number}: Version of IP stack. Must be `4`, `6`, or `0`. The value
   `0` indicates that both IPv4 and IPv6 addresses are allowed. **Default:** `0`.
 * `hints` {number} Optional [`dns.lookup()` hints][].
diff --git a/lib/internal/tls/wrap.js b/lib/internal/tls/wrap.js
index 60c1634b02b..7a47f10bb04 100644
--- a/lib/internal/tls/wrap.js
+++ b/lib/internal/tls/wrap.js
@@ -730,7 +730,7 @@ ObjectSetPrototypeOf(TLSSocket, net.Socket);
 exports.TLSSocket = TLSSocket;

 const proxiedMethods = [
-  'ref', 'unref', 'open', 'bind', 'listen', 'connect', 'bind6',
+  'ref', 'unref', 'hasRef', 'open', 'bind', 'listen', 'connect', 'bind6',
   'connect6', 'getsockname', 'getpeername', 'setNoDelay', 'setKeepAlive',
   'setSimultaneousAccepts', 'setBlocking',

diff --git a/lib/net.js b/lib/net.js
index 8ec389cf2dc..90b6ec9983f 100644
--- a/lib/net.js
+++ b/lib/net.js
@@ -26,6 +26,8 @@ const {
   ArrayPrototypeIncludes,
   ArrayPrototypeIndexOf,
   ArrayPrototypePush,
+  ArrayPrototypeSlice,
+  ArrayPrototypeSplice,
   Boolean,
   FunctionPrototypeBind,
   FunctionPrototypeCall,
@@ -66,7 +68,6 @@ const {
   UV_EBADF,
   UV_EINVAL,
   UV_ENOTCONN,
-  UV_ECANCELED,
   UV_ETIMEDOUT,
 } = internalBinding('uv');
 const { convertIpv6StringToBuffer } = internalBinding('cares_wrap');
@@ -172,6 +173,10 @@ const DEFAULT_IPV6_ADDR = '::';
 const noop = () => {};

 const kPerfHooksNetConnectContext = Symbol('kPerfHooksNetConnectContext');
+const kAutoSelectFamilyContext = Symbol('kAutoSelectFamilyContext');
+const kSocketHandleUnused = 0;
+const kSocketHandlePending = 1;
+const kSocketHandleFailed = 2;

 const dc = require('diagnostics_channel');
 const netClientSocketChannel = dc.channel('net.client.socket');
@@ -1165,6 +1170,12 @@ Socket.prototype._destroy = function(exception, cb) {

   this.connecting = false;

+  const context = this[kAutoSelectFamilyContext];
+  if (context) {
+    closeConnectionAttempts(context);
+    this[kAutoSelectFamilyContext] = undefined;
+  }
+
   // `_parent` may be null; we use a loose `!= null` check in case external
   // code sets it to undefined.
   for (let s = this; s != null; s = s._parent) {
@@ -1477,72 +1488,181 @@ function internalConnect(
 }


-function internalConnectMultiple(context, canceled) {
+function closeConnectionAttempts(context) {
   clearTimeout(context[kTimeout]);
+  context[kTimeout] = null;
+  context.done = true;
+  for (let i = 0; i < context.pending.length; i++) {
+    const { handle, req, own } = context.pending[i];
+    req.oncomplete = undefined;
+    // The socket's own handle is closed by whoever replaces or destroys it.
+    if (!own) handle.close();
+  }
+  context.pending.length = 0;
+}
+
+function removePendingAttempt(context, req) {
+  const { pending } = context;
+  for (let i = 0; i < pending.length; i++) {
+    if (pending[i].req === req) {
+      const attempt = pending[i];
+      ArrayPrototypeSplice(pending, i, 1);
+      return attempt;
+    }
+  }
+}
+
+function connectMultipleError(context) {
+  // Errors are stored by attempt index to preserve the attempt order.
+  const errors = [];
+  for (let i = 0; i < context.errors.length; i++) {
+    if (context.errors[i] !== undefined) ArrayPrototypePush(errors, context.errors[i]);
+  }
+  return errors.length === 0 ? new ERR_SOCKET_CONNECTION_TIMEOUT() : new NodeAggregateError(errors);
+}
+
+// Returns the handle for the next attempt. The first attempt uses the socket's
+// own handle so that any state set on it before connecting is kept.
+function acquireAttemptHandle(context) {
   const self = context.socket;
+  if (context.socketHandleState === kSocketHandlePending) {
+    const handle = new TCP(TCPConstants.SOCKET);
+    if (self._handle.hasRef() === false) handle.unref();
+    return handle;
+  }

-  // We were requested to abort. Stop all operations
-  if (self._aborted) {
-    return;
+  if (context.socketHandleState === kSocketHandleFailed) {
+    const unrefed = self._handle.hasRef() === false;
+    self[kReinitializeHandle](new TCP(TCPConstants.SOCKET));
+    if (unrefed) self._handle.unref();
   }

-  // 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());
-      return;
-    }
+  context.socketHandleState = kSocketHandlePending;
+  return self._handle;
+}
+
+function releaseAttemptHandle(context, handle, own) {
+  if (own) {
+    context.socketHandleState = kSocketHandleFailed;
+  } else {
+    handle.close();
+  }
+}
+
+function connectionAttemptFailed(context, index) {
+  const self = context.socket;
+  if (context.done || !self.connecting) return;

-    self.destroy(new NodeAggregateError(context.errors));
+  // Start the next attempt right away, without waiting for the attempt timeout.
+  if (context.current < context.addresses.length) {
+    internalConnectMultiple(context);
     return;
   }

-  assert(self.connecting);
+  if (context.pending.length === 0) {
+    self.destroy(connectMultipleError(context));
+    return;
+  }

-  const current = context.current++;
+  // The last attempt failed but earlier ones are still pending: give them one
+  // more attempt timeout instead of waiting for the operating system.
+  if (index === context.addresses.length - 1 && context[kTimeout] === null) {
+    context[kTimeout] = setTimeout(internalConnectMultipleFinalTimeout, context.timeout, context);
+    if (self._handle.hasRef() === false) context[kTimeout].unref();
+  }
+}

-  if (current > 0) {
-    self[kReinitializeHandle](new TCP(TCPConstants.SOCKET));
+function internalConnectMultipleTimeout(context, attempt) {
+  context[kTimeout] = null;
+  const self = context.socket;
+  if (context.done || !self.connecting) return;
+
+  const { handle, req, index, own } = attempt;
+  if (ArrayPrototypeIncludes(context.pending, attempt)) {
+    debug('connect/multiple: connection to %s:%s is still pending', req.address, req.port);
+    attempt.timedOut = true;
+    if (context.localPort) {
+      // Two attempts cannot portably bind to the same fixed source port.
+      removePendingAttempt(context, req);
+      req.oncomplete = undefined;
+      releaseAttemptHandle(context, handle, own);
+      context.errors[index] = createConnectionError(req, UV_ETIMEDOUT);
+    }
+    self.emit('connectionAttemptTimeout', req.address, req.port, req.addressType);
   }

+  if (!context.done && self.connecting) internalConnectMultiple(context);
+}
+
+function internalConnectMultipleFinalTimeout(context) {
+  context[kTimeout] = null;
+  const self = context.socket;
+  if (context.done || !self.connecting) return;
+
+  const pending = ArrayPrototypeSlice(context.pending);
+  for (let i = 0; i < pending.length; i++) {
+    const { req, index, timedOut } = pending[i];
+    debug('connect/multiple: connection to %s:%s timed out', req.address, req.port);
+    context.errors[index] = createConnectionError(req, UV_ETIMEDOUT);
+    if (!timedOut) self.emit('connectionAttemptTimeout', req.address, req.port, req.addressType);
+  }
+
+  if (!context.done && self.connecting) self.destroy(connectMultipleError(context));
+}
+
+function internalConnectMultiple(context) {
+  clearTimeout(context[kTimeout]);
+  context[kTimeout] = null;
+  const self = context.socket;
+  if (context.done || !self.connecting || self._aborted) return;
+
+  const current = context.current++;
   const { localPort, port, flags } = context;
   const { address, family: addressType } = context.addresses[current];
+  const handle = acquireAttemptHandle(context);
+  const own = handle === self._handle;
   let localAddress;
   let err;

   if (localPort) {
     if (addressType === 4) {
       localAddress = DEFAULT_IPV4_ADDR;
-      err = self._handle.bind(localAddress, localPort);
+      err = handle.bind(localAddress, localPort);
     } else { // addressType === 6
       localAddress = DEFAULT_IPV6_ADDR;
-      err = self._handle.bind6(localAddress, localPort, flags);
+      err = handle.bind6(localAddress, localPort, flags);
     }

     debug('connect/multiple: binding to localAddress: %s and localPort: %d (addressType: %d)',
           localAddress, localPort, addressType);

-    err = checkBindError(err, localPort, self._handle);
+    err = checkBindError(err, localPort, handle);
     if (err) {
-      ArrayPrototypePush(context.errors, new ExceptionWithHostPort(err, 'bind', localAddress, localPort));
-      internalConnectMultiple(context);
+      releaseAttemptHandle(context, handle, own);
+      context.errors[current] = new ExceptionWithHostPort(err, 'bind', localAddress, localPort);
+      connectionAttemptFailed(context, current);
       return;
     }
   }

   if (self.blockList?.check(address, `ipv${addressType}`)) {
+    releaseAttemptHandle(context, handle, own);
     const ex = new ERR_IP_BLOCKED(address);
-    ArrayPrototypePush(context.errors, ex);
+    context.errors[current] = ex;
     self.emit('connectionAttemptFailed', address, port, addressType, ex);
-    internalConnectMultiple(context);
+    connectionAttemptFailed(context, current);
     return;
   }

   debug('connect/multiple: attempting to connect to %s:%d (addressType: %d)', address, port, addressType);
   self.emit('connectionAttempt', address, port, addressType);
+  if (!self.connecting || context.done) {
+    if (!own) handle.close();
+    return;
+  }

   const req = new TCPConnectWrap();
-  req.oncomplete = FunctionPrototypeBind(afterConnectMultiple, undefined, context, current);
+  req.oncomplete = FunctionPrototypeBind(afterConnectMultiple, undefined, context);
   req.address = address;
   req.port = port;
   req.localAddress = localAddress;
@@ -1550,34 +1670,23 @@ function internalConnectMultiple(context, canceled) {
   req.addressType = addressType;

   ArrayPrototypePush(self.autoSelectFamilyAttemptedAddresses, `${address}:${port}`);
-
-  if (addressType === 4) {
-    err = self._handle.connect(req, address, port);
-  } else {
-    err = self._handle.connect6(req, address, port);
-  }
+  err = addressType === 4 ? handle.connect(req, address, port) : handle.connect6(req, address, port);

   if (err) {
-    const sockname = self._getsockname();
-    let details;
-
-    if (sockname) {
-      details = sockname.address + ':' + sockname.port;
-    }
-
-    const ex = new ExceptionWithHostPort(err, 'connect', address, port, details);
-    ArrayPrototypePush(context.errors, ex);
-
+    releaseAttemptHandle(context, handle, own);
+    const ex = createConnectionError(req, err);
+    context.errors[current] = ex;
     self.emit('connectionAttemptFailed', address, port, addressType, ex);
-    internalConnectMultiple(context);
+    connectionAttemptFailed(context, current);
     return;
   }

-  if (current < context.addresses.length - 1) {
+  const attempt = { handle, req, index: current, own, timedOut: false };
+  ArrayPrototypePush(context.pending, attempt);
+  if (context.current < context.addresses.length) {
     debug('connect/multiple: setting the attempt timeout to %d ms', context.timeout);
-
-    // If the attempt has not returned an error, start the connection timer
-    context[kTimeout] = setTimeout(internalConnectMultipleTimeout, context.timeout, context, req, self._handle);
+    context[kTimeout] = setTimeout(internalConnectMultipleTimeout, context.timeout, context, attempt);
+    if (self._handle.hasRef() === false) context[kTimeout].unref();
   }
 }

@@ -1985,7 +2094,11 @@ function lookupAndConnectMultiple(
         timeout,
         [kTimeout]: null,
         errors: [],
+        pending: [],
+        socketHandleState: kSocketHandleUnused,
+        done: false,
       };
+      self[kAutoSelectFamilyContext] = context;

       self._unrefTimer();
       defaultTriggerAsyncIdScope(self[async_id_symbol], internalConnectMultiple, context);
@@ -2007,6 +2120,13 @@ Socket.prototype.ref = function() {
   if (typeof this._handle.ref === 'function') {
     this._handle.ref();
   }
+  const context = this[kAutoSelectFamilyContext];
+  if (context) {
+    context[kTimeout]?.ref();
+    for (let i = 0; i < context.pending.length; i++) {
+      context.pending[i].handle.ref();
+    }
+  }

   return this;
 };
@@ -2021,6 +2141,13 @@ Socket.prototype.unref = function() {
   if (typeof this._handle.unref === 'function') {
     this._handle.unref();
   }
+  const context = this[kAutoSelectFamilyContext];
+  if (context) {
+    context[kTimeout]?.unref();
+    for (let i = 0; i < context.pending.length; i++) {
+      context.pending[i].handle.unref();
+    }
+  }

   return this;
 };
@@ -2138,36 +2265,33 @@ function createConnectionError(req, status) {
   return ex;
 }

-function afterConnectMultiple(context, current, status, handle, req, readable, writable) {
+function afterConnectMultiple(context, status, handle, req, readable, writable) {
   debug('connect/multiple: connection attempt to %s:%s completed with status %s', req.address, req.port, status);
+  const attempt = removePendingAttempt(context, req);
+  if (attempt === undefined) return;

-  // Make sure another connection is not spawned
-  clearTimeout(context[kTimeout]);
-
-  // One of the connection has completed and correctly dispatched but after timeout, ignore this one
-  if (status === 0 && current !== context.current - 1) {
-    debug('connect/multiple: ignoring successful but timedout connection to %s:%s', req.address, req.port);
-    handle.close();
+  const self = context.socket;
+  if (context.done || !self.connecting) {
+    if (!attempt.own) attempt.handle.close();
     return;
   }

-  const self = context.socket;
-
-  // Some error occurred, add to the list of exceptions
   if (status !== 0) {
+    releaseAttemptHandle(context, attempt.handle, attempt.own);
     const ex = createConnectionError(req, status);
-    ArrayPrototypePush(context.errors, ex);
-
+    context.errors[attempt.index] = ex;
     self.emit('connectionAttemptFailed', req.address, req.port, req.addressType, ex);
-
-    // Try the next address, unless we were aborted
-    if (context.socket.connecting) {
-      internalConnectMultiple(context, status === UV_ECANCELED);
-    }
-
+    connectionAttemptFailed(context, attempt.index);
     return;
   }

+  closeConnectionAttempts(context);
+  self[kAutoSelectFamilyContext] = undefined;
+  if (!attempt.own) {
+    const unrefed = self._handle.hasRef() === false;
+    self[kReinitializeHandle](attempt.handle);
+    if (unrefed) self._handle.unref();
+  }
   if (hasObserver('net')) {
     startPerf(
       self,
@@ -2175,24 +2299,9 @@ function afterConnectMultiple(context, current, status, handle, req, readable, w
       { type: 'net', name: 'connect', detail: { host: req.address, port: req.port } },
     );
   }
-
   afterConnect(status, self._handle, req, readable, writable);
 }

-function internalConnectMultipleTimeout(context, req, handle) {
-  debug('connect/multiple: connection to %s:%s timed out', req.address, req.port);
-  context.socket.emit('connectionAttemptTimeout', req.address, req.port, req.addressType);
-
-  req.oncomplete = undefined;
-  ArrayPrototypePush(context.errors, createConnectionError(req, UV_ETIMEDOUT));
-  handle.close();
-
-  // Try the next address, unless we were aborted
-  if (context.socket.connecting) {
-    internalConnectMultiple(context);
-  }
-}
-
 function addServerAbortSignalOption(self, options) {
   if (options?.signal === undefined) {
     return;
diff --git a/test/internet/test-net-autoselectfamily-events-failure.js b/test/internet/test-net-autoselectfamily-events-failure.js
index 27f0ad96d32..0c988e63662 100644
--- a/test/internet/test-net-autoselectfamily-events-failure.js
+++ b/test/internet/test-net-autoselectfamily-events-failure.js
@@ -80,30 +80,30 @@ const { createConnection } = require('net');

   const addresses = [
     { address: INET6_IP, port: 10, family: 6 },
-    { address: INET6_IP, port: 10, family: 6 },
-    { address: INET4_IP, port: 10, family: 4 },
     { address: INET4_IP, port: 10, family: 4 },
   ];
+  const attempted = [];

+  // Attempts run in parallel, so a failure is not necessarily reported
+  // before the next attempt starts.
   connection.on('connectionAttempt', common.mustCallAtLeast((address, port, family) => {
     const expected = addresses.shift();

     assert.strictEqual(address, expected.address);
     assert.strictEqual(port, expected.port);
     assert.strictEqual(family, expected.family);
+    attempted.push(expected);

     pass();
   }, 0));

   connection.on('connectionAttemptFailed', common.mustCallAtLeast((address, port, family, error) => {
-    const expected = addresses.shift();
-
-    assert.strictEqual(address, expected.address);
-    assert.strictEqual(port, expected.port);
-    assert.strictEqual(family, expected.family);
+    assert.ok(attempted.some((expected) => {
+      return expected.address === address && expected.port === port && expected.family === family;
+    }));

     assert.ok(
-      error.code.match(/ECONNREFUSED|ENETUNREACH|EHOSTUNREACH|ETIMEDOUT/),
+      error.code.match(/EACCES|ECONNREFUSED|ENETUNREACH|EHOSTUNREACH|ETIMEDOUT/),
       `Received unexpected error code ${error.code}`,
     );

diff --git a/test/parallel/test-net-autoselectfamily-local-port.js b/test/parallel/test-net-autoselectfamily-local-port.js
new file mode 100644
index 00000000000..ba3ae408dfb
--- /dev/null
+++ b/test/parallel/test-net-autoselectfamily-local-port.js
@@ -0,0 +1,43 @@
+'use strict';
+
+const common = require('../common');
+if (!common.hasIPv6) common.skip('IPv6 loopback is unavailable');
+
+const assert = require('node:assert');
+const { createConnection, createServer } = require('node:net');
+const { TCP } = process.binding('tcp_wrap');
+// A pending IPv6 connect with a fixed source port must be closed before an
+// IPv4 candidate binds to that port.
+TCP.prototype.connect6 = function() {
+  return 0;
+};
+
+const reserved = createServer();
+reserved.listen(0, '127.0.0.1', common.mustCall(() => {
+  const localPort = reserved.address().port;
+  reserved.close(common.mustCall(() => {
+    const server = createServer(common.mustCall((socket) => socket.end()));
+    server.listen(0, '127.0.0.1', common.mustCall(() => {
+      const port = server.address().port;
+      const connection = createConnection({
+        host: 'example.org',
+        port,
+        localPort,
+        lookup: common.mustCall((host, options, callback) => {
+          process.nextTick(callback, null, [
+            { address: '::1', family: 6 },
+            { address: '127.0.0.1', family: 4 },
+          ]);
+        }),
+        autoSelectFamily: true,
+        autoSelectFamilyAttemptTimeout: 10,
+      });
+      connection.on('connect', common.mustCall(() => {
+        assert.strictEqual(connection.localPort, localPort);
+        assert.strictEqual(connection.remoteAddress, '127.0.0.1');
+      }));
+      connection.on('error', common.mustNotCall());
+      connection.on('close', common.mustCall(() => server.close()));
+    }));
+  }));
+}));
diff --git a/test/parallel/test-net-autoselectfamily-parallel-destroy.js b/test/parallel/test-net-autoselectfamily-parallel-destroy.js
new file mode 100644
index 00000000000..e75ee9850a6
--- /dev/null
+++ b/test/parallel/test-net-autoselectfamily-parallel-destroy.js
@@ -0,0 +1,48 @@
+'use strict';
+
+const common = require('../common');
+const assert = require('node:assert');
+const { createConnection, createServer } = require('node:net');
+const { TCP } = process.binding('tcp_wrap');
+
+const connect = TCP.prototype.connect;
+let completeFirst;
+TCP.prototype.connect = function(req, address, port) {
+  if (address === '127.0.0.1') {
+    const oncomplete = req.oncomplete;
+    req.oncomplete = (...args) => {
+      completeFirst = () => oncomplete(...args);
+    };
+  }
+  return Reflect.apply(connect, this, [req, address, port]);
+};
+
+const server = createServer((socket) => socket.end());
+server.listen(0, '127.0.0.1', common.mustCall(() => {
+  const port = server.address().port;
+  const secondAddress = common.hasIPv6 ? '::1' : '127.0.0.2';
+  const connection = createConnection({
+    host: 'example.org',
+    port,
+    lookup: common.mustCall((host, options, callback) => {
+      process.nextTick(callback, null, [
+        { address: '127.0.0.1', family: 4 },
+        { address: secondAddress, family: common.hasIPv6 ? 6 : 4 },
+      ]);
+    }),
+    autoSelectFamily: true,
+    autoSelectFamilyAttemptTimeout: 10,
+  });
+
+  connection.on('connectionAttempt', common.mustCallAtLeast((address) => {
+    if (address === secondAddress) connection.destroy();
+  }, 2));
+  connection.on('connect', common.mustNotCall());
+  connection.on('error', common.mustNotCall());
+  connection.on('close', common.mustCall(() => {
+    assert.deepStrictEqual(connection.autoSelectFamilyAttemptedAddresses,
+                           [`127.0.0.1:${port}`]);
+    if (completeFirst) setImmediate(completeFirst);
+    server.close();
+  }));
+}));
diff --git a/test/parallel/test-net-autoselectfamily-parallel-failure-fallback.js b/test/parallel/test-net-autoselectfamily-parallel-failure-fallback.js
new file mode 100644
index 00000000000..7daa03f1dc3
--- /dev/null
+++ b/test/parallel/test-net-autoselectfamily-parallel-failure-fallback.js
@@ -0,0 +1,52 @@
+// Flags: --expose-internals
+'use strict';
+
+const common = require('../common');
+const assert = require('node:assert');
+const { createConnection, createServer } = require('node:net');
+const { internalBinding } = require('internal/test/binding');
+const { TCP } = internalBinding('tcp_wrap');
+const { UV_ECONNREFUSED } = internalBinding('uv');
+
+// An attempt that fails explicitly starts the next attempt right away, without
+// waiting for autoSelectFamilyAttemptTimeout.
+const connect = TCP.prototype.connect;
+TCP.prototype.connect = function(req, address, port) {
+  if (address === '10.0.0.1') {
+    setImmediate(() => req.oncomplete(UV_ECONNREFUSED, this, req, false, false));
+    return 0;
+  }
+  return Reflect.apply(connect, this, [req, address, port]);
+};
+
+const server = createServer(common.mustCall((socket) => socket.end()));
+server.listen(0, '127.0.0.1', common.mustCall(() => {
+  const port = server.address().port;
+  const connection = createConnection({
+    host: 'example.org',
+    port,
+    lookup: common.mustCall((host, options, callback) => {
+      process.nextTick(callback, null, [
+        { address: '10.0.0.1', family: 4 },
+        { address: '127.0.0.1', family: 4 },
+      ]);
+    }),
+    autoSelectFamily: true,
+    autoSelectFamilyAttemptTimeout: common.platformTimeout(60000),
+  });
+
+  connection.on('connectionAttemptTimeout', common.mustNotCall());
+  connection.on('connectionAttemptFailed', common.mustCall((address, p, family, error) => {
+    assert.strictEqual(address, '10.0.0.1');
+    assert.strictEqual(error.code, 'ECONNREFUSED');
+    process.nextTick(common.mustCall(() => {
+      assert.deepStrictEqual(connection.autoSelectFamilyAttemptedAddresses,
+                             [`10.0.0.1:${port}`, `127.0.0.1:${port}`]);
+    }));
+  }));
+  connection.on('connect', common.mustCall(() => {
+    assert.strictEqual(connection.remoteAddress, '127.0.0.1');
+  }));
+  connection.on('error', common.mustNotCall());
+  connection.on('close', common.mustCall(() => server.close()));
+}));
diff --git a/test/parallel/test-net-autoselectfamily-parallel-final-timeout.js b/test/parallel/test-net-autoselectfamily-parallel-final-timeout.js
new file mode 100644
index 00000000000..6f0bf561599
--- /dev/null
+++ b/test/parallel/test-net-autoselectfamily-parallel-final-timeout.js
@@ -0,0 +1,49 @@
+// Flags: --expose-internals
+'use strict';
+
+const common = require('../common');
+const assert = require('node:assert');
+const { createConnection } = require('node:net');
+const { internalBinding } = require('internal/test/binding');
+const { TCP } = internalBinding('tcp_wrap');
+const { UV_ECONNREFUSED } = internalBinding('uv');
+
+// When the last attempt fails while an earlier one is still pending, the
+// pending attempt gets one more attempt timeout instead of waiting for the
+// operating system. Errors are reported in attempt order.
+TCP.prototype.connect = function(req, address, port) {
+  if (address === '10.0.0.2') {
+    setImmediate(() => req.oncomplete(UV_ECONNREFUSED, this, req, false, false));
+  }
+  // Attempts to 10.0.0.1 never complete.
+  return 0;
+};
+
+const connection = createConnection({
+  host: 'example.org',
+  port: 10,
+  lookup: common.mustCall((host, options, callback) => {
+    process.nextTick(callback, null, [
+      { address: '10.0.0.1', family: 4 },
+      { address: '10.0.0.2', family: 4 },
+    ]);
+  }),
+  autoSelectFamily: true,
+  autoSelectFamilyAttemptTimeout: 10,
+});
+
+connection.on('connectionAttemptTimeout', common.mustCall((address) => {
+  assert.strictEqual(address, '10.0.0.1');
+}));
+connection.on('connectionAttemptFailed', common.mustCall((address) => {
+  assert.strictEqual(address, '10.0.0.2');
+}));
+connection.on('connect', common.mustNotCall());
+connection.on('error', common.mustCall((error) => {
+  assert.strictEqual(error.constructor.name, 'AggregateError');
+  assert.deepStrictEqual(error.errors.map((e) => [e.address, e.code]), [
+    ['10.0.0.1', 'ETIMEDOUT'],
+    ['10.0.0.2', 'ECONNREFUSED'],
+  ]);
+}));
+connection.on('close', common.mustCall());
diff --git a/test/parallel/test-net-autoselectfamily-parallel-winner.js b/test/parallel/test-net-autoselectfamily-parallel-winner.js
new file mode 100644
index 00000000000..1cf952eeb3e
--- /dev/null
+++ b/test/parallel/test-net-autoselectfamily-parallel-winner.js
@@ -0,0 +1,55 @@
+'use strict';
+
+const common = require('../common');
+if (!common.hasIPv6) common.skip('IPv6 loopback is unavailable');
+
+const assert = require('node:assert');
+const { createConnection, createServer } = require('node:net');
+const { TCP } = process.binding('tcp_wrap');
+
+// Hold the IPv6 completion while the IPv4 connection wins. A late completion
+// for the losing handle must not emit another 'connect' or close the winner.
+const connect = TCP.prototype.connect6;
+let completeFirst;
+let winnerSelected = false;
+TCP.prototype.connect6 = function(req, address, port) {
+  const oncomplete = req.oncomplete;
+  req.oncomplete = (...args) => {
+    const complete = () => oncomplete(...args);
+    if (winnerSelected) setImmediate(complete);
+    else completeFirst = complete;
+  };
+  return Reflect.apply(connect, this, [req, address, port]);
+};
+
+const ipv6 = createServer((socket) => socket.end());
+const ipv4 = createServer(common.mustCall((socket) => socket.end()));
+ipv6.listen(0, '::1', common.mustCall(() => {
+  const port = ipv6.address().port;
+  ipv4.listen(port, '127.0.0.1', common.mustCall(() => {
+    const connection = createConnection({
+      host: 'example.org',
+      port,
+      lookup: common.mustCall((host, options, callback) => {
+        process.nextTick(callback, null, [
+          { address: '::1', family: 6 },
+          { address: '127.0.0.1', family: 4 },
+        ]);
+      }),
+      autoSelectFamily: true,
+      autoSelectFamilyAttemptTimeout: 10,
+    });
+    connection.on('connect', common.mustCall(() => {
+      winnerSelected = true;
+      assert.strictEqual(connection.remoteAddress, '127.0.0.1');
+      assert.deepStrictEqual(connection.autoSelectFamilyAttemptedAddresses,
+                             [`::1:${port}`, `127.0.0.1:${port}`]);
+      if (completeFirst) setImmediate(completeFirst);
+    }));
+    connection.on('error', common.mustNotCall());
+    connection.on('close', common.mustCall(() => {
+      ipv4.close();
+      ipv6.close();
+    }));
+  }));
+}));
diff --git a/test/parallel/test-net-autoselectfamily-parallel.js b/test/parallel/test-net-autoselectfamily-parallel.js
new file mode 100644
index 00000000000..118816d188f
--- /dev/null
+++ b/test/parallel/test-net-autoselectfamily-parallel.js
@@ -0,0 +1,61 @@
+'use strict';
+
+const common = require('../common');
+const assert = require('node:assert');
+const { createConnection, createServer } = require('node:net');
+const { TCP } = process.binding('tcp_wrap');
+
+// Delay delivery of a successful TCP completion until the other address has
+// failed. The first connection must remain usable after fallback has started.
+const connect = TCP.prototype.connect;
+let firstCompletion;
+let secondFailed = false;
+TCP.prototype.connect = function(req, address, port) {
+  if (address === '127.0.0.1') {
+    const oncomplete = req.oncomplete;
+    req.oncomplete = (...args) => {
+      const complete = () => oncomplete(...args);
+      if (secondFailed) {
+        setImmediate(complete);
+      } else {
+        firstCompletion = complete;
+      }
+    };
+  }
+  return Reflect.apply(connect, this, [req, address, port]);
+};
+
+const server = createServer(common.mustCall((socket) => socket.end()));
+server.listen(0, '127.0.0.1', common.mustCall(() => {
+  const port = server.address().port;
+  const secondAddress = common.hasIPv6 ? '::1' : '127.0.0.2';
+  const connection = createConnection({
+    host: 'example.org',
+    port,
+    lookup: common.mustCall((host, options, callback) => {
+      assert.strictEqual(options.all, true);
+      process.nextTick(callback, null, [
+        { address: '127.0.0.1', family: 4 },
+        { address: secondAddress, family: common.hasIPv6 ? 6 : 4 },
+      ]);
+    }),
+    autoSelectFamily: true,
+    autoSelectFamilyAttemptTimeout: 10,
+  });
+
+  connection.on('connectionAttemptTimeout', common.mustCall((address) => {
+    assert.strictEqual(address, '127.0.0.1');
+  }));
+  connection.on('connectionAttemptFailed', common.mustCall((address) => {
+    assert.strictEqual(address, secondAddress);
+    secondFailed = true;
+    if (firstCompletion) setImmediate(firstCompletion);
+  }));
+  connection.on('connect', common.mustCall(() => {
+    assert.strictEqual(connection.remoteAddress, '127.0.0.1');
+    assert.deepStrictEqual(connection.autoSelectFamilyAttemptedAddresses,
+                           [`127.0.0.1:${port}`, `${secondAddress}:${port}`]);
+  }));
+  connection.on('error', common.mustNotCall());
+  connection.on('close', common.mustCall(() => server.close()));
+}));