diff --git a/doc/api/net.md b/doc/api/net.md index f6fac2b6932a..4f48ab0da868 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 37fa7845843f..f0e2753b3c94 100644 --- a/lib/internal/tls/wrap.js +++ b/lib/internal/tls/wrap.js @@ -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', diff --git a/lib/net.js b/lib/net.js index 8ec389cf2dc4..90b6ec9983fe 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 27f0ad96d328..0c988e63662e 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 000000000000..ba3ae408dfb5 --- /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 000000000000..e75ee9850a6e --- /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 000000000000..7daa03f1dc34 --- /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 000000000000..6f0bf561599e --- /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 000000000000..1cf952eeb3ea --- /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 000000000000..118816d188fb --- /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())); +}));