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()));
+}));