nodejs/node · #66229

net: race family autoselection attempts

mcollina · merged Oct 5, 202610 files · 532 + / 107 −
doc/api/net.md27 + / 19 −
@@ -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][].
lib/internal/tls/wrap.js1 + / 1 −
@@ -728,7 +728,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', 
lib/net.js188 + / 79 −
@@ -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,107 +1488,205 @@ 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;   req.localPort = localPort;   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,61 +2265,43 @@ 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,       kPerfHooksNetConnectContext,       { 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;
test/internet/test-net-autoselectfamily-events-failure.js8 + / 8 −
@@ -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}`,     ); 
test/parallel/test-net-autoselectfamily-local-port.jsadded43 + / 0 −
@@ -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()));+    }));+  }));+}));
test/parallel/test-net-autoselectfamily-parallel-destroy.jsadded48 + / 0 −
@@ -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();+  }));+}));
test/parallel/test-net-autoselectfamily-parallel-failure-fallback.jsadded52 + / 0 −
@@ -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()));+}));
test/parallel/test-net-autoselectfamily-parallel-final-timeout.jsadded49 + / 0 −
@@ -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());
test/parallel/test-net-autoselectfamily-parallel-winner.jsadded55 + / 0 −
@@ -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();+    }));+  }));+}));
test/parallel/test-net-autoselectfamily-parallel.jsadded61 + / 0 −
@@ -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()));+}));