diff --git a/doc/api/net.md b/doc/api/net.md index f6fac2b6932a..4f026fe01751 100644 --- a/doc/api/net.md +++ b/doc/api/net.md @@ -1143,9 +1143,12 @@ 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` and 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,21 +1399,26 @@ 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 + `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, the next attempt starts without + canceling any pending attempts. The first successful TCP connection wins and + the other attempts are canceled. 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 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:** +* `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 can start the next one + sooner. 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`. diff --git a/lib/net.js b/lib/net.js index 8ec389cf2dc4..48a75fd6416f 100644 --- a/lib/net.js +++ b/lib/net.js @@ -26,6 +26,7 @@ const { ArrayPrototypeIncludes, ArrayPrototypeIndexOf, ArrayPrototypePush, + ArrayPrototypeSplice, Boolean, FunctionPrototypeBind, FunctionPrototypeCall, @@ -66,7 +67,6 @@ const { UV_EBADF, UV_EINVAL, UV_ENOTCONN, - UV_ECANCELED, UV_ETIMEDOUT, } = internalBinding('uv'); const { convertIpv6StringToBuffer } = internalBinding('cares_wrap'); @@ -172,6 +172,7 @@ const DEFAULT_IPV6_ADDR = '::'; const noop = () => {}; const kPerfHooksNetConnectContext = Symbol('kPerfHooksNetConnectContext'); +const kAutoSelectFamilyContext = Symbol('kAutoSelectFamilyContext'); const dc = require('diagnostics_channel'); const netClientSocketChannel = dc.channel('net.client.socket'); @@ -1165,6 +1166,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 +1484,102 @@ function internalConnect( } -function internalConnectMultiple(context, canceled) { +function closeConnectionAttempts(context) { clearTimeout(context[kTimeout]); - const self = context.socket; - - // We were requested to abort. Stop all operations - if (self._aborted) { - return; + context[kTimeout] = null; + context.done = true; + for (let i = 0; i < context.pending.length; i++) { + const { handle, req } = context.pending[i]; + req.oncomplete = undefined; + handle.close(); } + context.pending.length = 0; +} - // 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; - } +function scheduleConnectionAttempt(context, delay, attempt) { + clearTimeout(context[kTimeout]); + context[kTimeout] = attempt ? + setTimeout(internalConnectMultipleTimeout, delay, context, attempt) : + setTimeout(internalConnectMultiple, delay, context); + if (context.socket._handle?.hasRef?.() === false) context[kTimeout].unref(); +} - self.destroy(new NodeAggregateError(context.errors)); - return; +function internalConnectMultipleTimeout(context, attempt) { + const { handle, req } = attempt; + if (!context.done && ArrayPrototypeIncludes(context.pending, attempt)) { + debug('connect/multiple: connection to %s:%s is still pending', req.address, req.port); + context.socket.emit('connectionAttemptTimeout', req.address, req.port, req.addressType); + if (context.localPort && !context.done) { + // Two attempts cannot portably bind to the same fixed source port. + const index = ArrayPrototypeIndexOf(context.pending, attempt); + if (index !== -1) { + ArrayPrototypeSplice(context.pending, index, 1); + req.oncomplete = undefined; + handle.close(); + ArrayPrototypePush(context.errors, createConnectionError(req, UV_ETIMEDOUT)); + } + } } + if (!context.done && context.socket.connecting) internalConnectMultiple(context); +} - assert(self.connecting); - - const current = context.current++; +function internalConnectMultiple(context) { + context[kTimeout] = null; + const self = context.socket; + if (context.done || !self.connecting || self._aborted) return; - if (current > 0) { - self[kReinitializeHandle](new TCP(TCPConstants.SOCKET)); + if (context.current === context.addresses.length) { + if (context.pending.length === 0) { + self.destroy(context.errors.length === 0 ? + new ERR_SOCKET_CONNECTION_TIMEOUT() : new NodeAggregateError(context.errors)); + } + return; } + const current = context.current++; const { localPort, port, flags } = context; const { address, family: addressType } = context.addresses[current]; + const handle = new TCP(TCPConstants.SOCKET); + if (self._handle?.hasRef?.() === false) handle.unref(); 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) { + handle.close(); ArrayPrototypePush(context.errors, new ExceptionWithHostPort(err, 'bind', localAddress, localPort)); - internalConnectMultiple(context); + scheduleConnectionAttempt(context, 10); return; } } if (self.blockList?.check(address, `ipv${addressType}`)) { + handle.close(); const ex = new ERR_IP_BLOCKED(address); ArrayPrototypePush(context.errors, ex); self.emit('connectionAttemptFailed', address, port, addressType, ex); - internalConnectMultiple(context); + if (self.connecting && !context.done) scheduleConnectionAttempt(context, 10); 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) { + 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 +1587,21 @@ 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); + handle.close(); + const ex = createConnectionError(req, err); ArrayPrototypePush(context.errors, ex); - self.emit('connectionAttemptFailed', address, port, addressType, ex); - internalConnectMultiple(context); + if (self.connecting && !context.done) scheduleConnectionAttempt(context, 10); return; } - if (current < context.addresses.length - 1) { - 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); + const attempt = { handle, req }; + ArrayPrototypePush(context.pending, attempt); + if (context.current < context.addresses.length) { + scheduleConnectionAttempt(context, context.timeout, attempt); } } @@ -1985,7 +2009,10 @@ function lookupAndConnectMultiple( timeout, [kTimeout]: null, errors: [], + pending: [], + done: false, }; + self[kAutoSelectFamilyContext] = context; self._unrefTimer(); defaultTriggerAsyncIdScope(self[async_id_symbol], internalConnectMultiple, context); @@ -2007,6 +2034,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 +2055,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 +2179,44 @@ 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); + let index = -1; + for (let i = 0; i < context.pending.length; i++) { + if (context.pending[i].handle === handle) { + index = i; + break; + } + } + if (index === -1) return; + ArrayPrototypeSplice(context.pending, index, 1); - // 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); + if (context.done || !context.socket.connecting) { handle.close(); return; } const self = context.socket; - - // Some error occurred, add to the list of exceptions if (status !== 0) { + handle.close(); const ex = createConnectionError(req, status); ArrayPrototypePush(context.errors, 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); + if (self.connecting && !context.done) { + if (context.current < context.addresses.length) { + scheduleConnectionAttempt(context, 10); + } else if (context.pending.length === 0) { + internalConnectMultiple(context); + } } - return; } + closeConnectionAttempts(context); + self[kAutoSelectFamilyContext] = undefined; + const unrefed = self._handle?.hasRef?.() === false; + self[kReinitializeHandle](handle); + if (unrefed) self._handle.unref(); if (hasObserver('net')) { startPerf( self, @@ -2175,24 +2224,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/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-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())); +}));