From 17eb086f6cbf1a0bfbfccf406f4f12e18f6d308c Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Wed, 23 Sep 2026 07:23:50 +0200 Subject: [PATCH 1/2] 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 --- doc/api/net.md | 40 ++-- lib/net.js | 194 ++++++++++-------- .../test-net-autoselectfamily-local-port.js | 43 ++++ ...t-net-autoselectfamily-parallel-destroy.js | 48 +++++ ...st-net-autoselectfamily-parallel-winner.js | 55 +++++ .../test-net-autoselectfamily-parallel.js | 61 ++++++ 6 files changed, 345 insertions(+), 96 deletions(-) create mode 100644 test/parallel/test-net-autoselectfamily-local-port.js create mode 100644 test/parallel/test-net-autoselectfamily-parallel-destroy.js create mode 100644 test/parallel/test-net-autoselectfamily-parallel-winner.js create mode 100644 test/parallel/test-net-autoselectfamily-parallel.js 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())); +})); From 19242957f17e797befaa64d17f1bff8d80344872 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 2 Oct 2026 10:37:44 +0200 Subject: [PATCH 2/2] net: keep the socket handle for the first attempt Run the first family autoselection attempt on the socket's own handle, so state set on it before connecting (TLS wrapping, onread buffers, socket options) is kept. Additional attempts use new handles and replace it only if they win. Start the next attempt immediately after an explicit failure. If the last attempt fails while others are pending, give them one more attempt timeout. Report AggregateError errors in attempt order. Add hasRef() to the TLSWrap proxied methods. Signed-off-by: Matteo Collina --- doc/api/net.md | 36 +-- lib/internal/tls/wrap.js | 2 +- lib/net.js | 207 ++++++++++++------ ...est-net-autoselectfamily-events-failure.js | 16 +- ...oselectfamily-parallel-failure-fallback.js | 52 +++++ ...autoselectfamily-parallel-final-timeout.js | 49 +++++ 6 files changed, 269 insertions(+), 93 deletions(-) create mode 100644 test/parallel/test-net-autoselectfamily-parallel-failure-fallback.js create mode 100644 test/parallel/test-net-autoselectfamily-parallel-final-timeout.js diff --git a/doc/api/net.md b/doc/api/net.md index 4f026fe01751..4f48ab0da868 100644 --- a/doc/api/net.md +++ b/doc/api/net.md @@ -1144,11 +1144,10 @@ added: * `family` {number} The family of the IP. It can be `6` for IPv6 or `4` for IPv4. Emitted when a connection attempt is still pending after the configured -`autoSelectFamilyAttemptTimeout` and another attempt is about to start. The +`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)`][]. +autoselection algorithm is enabled in [`socket.connect(options)`][]. ### Event: `'data'` @@ -1403,23 +1402,24 @@ For TCP connections, available `options` are: 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` 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 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()`][]. + 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 48a75fd6416f..90b6ec9983fe 100644 --- a/lib/net.js +++ b/lib/net.js @@ -26,6 +26,7 @@ const { ArrayPrototypeIncludes, ArrayPrototypeIndexOf, ArrayPrototypePush, + ArrayPrototypeSlice, ArrayPrototypeSplice, Boolean, FunctionPrototypeBind, @@ -173,6 +174,9 @@ 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'); @@ -1489,58 +1493,134 @@ function closeConnectionAttempts(context) { context[kTimeout] = null; context.done = true; for (let i = 0; i < context.pending.length; i++) { - const { handle, req } = context.pending[i]; + const { handle, req, own } = context.pending[i]; req.oncomplete = undefined; - handle.close(); + // The socket's own handle is closed by whoever replaces or destroys it. + if (!own) handle.close(); } context.pending.length = 0; } -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(); +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; + } + + if (context.socketHandleState === kSocketHandleFailed) { + const unrefed = self._handle.hasRef() === false; + self[kReinitializeHandle](new TCP(TCPConstants.SOCKET)); + if (unrefed) self._handle.unref(); + } + + 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; + + // Start the next attempt right away, without waiting for the attempt timeout. + if (context.current < context.addresses.length) { + internalConnectMultiple(context); + return; + } + + if (context.pending.length === 0) { + self.destroy(connectMultipleError(context)); + return; + } + + // 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(); + } } function internalConnectMultipleTimeout(context, attempt) { - const { handle, req } = attempt; - if (!context.done && ArrayPrototypeIncludes(context.pending, 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); - context.socket.emit('connectionAttemptTimeout', req.address, req.port, req.addressType); - if (context.localPort && !context.done) { + attempt.timedOut = true; + if (context.localPort) { // 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)); - } + 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 && context.socket.connecting) internalConnectMultiple(context); + + if (!context.done && self.connecting) internalConnectMultiple(context); } -function internalConnectMultiple(context) { +function internalConnectMultipleFinalTimeout(context) { context[kTimeout] = null; const self = context.socket; - if (context.done || !self.connecting || self._aborted) return; + if (context.done || !self.connecting) return; - 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 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 = new TCP(TCPConstants.SOCKET); - if (self._handle?.hasRef?.() === false) handle.unref(); + const handle = acquireAttemptHandle(context); + const own = handle === self._handle; let localAddress; let err; @@ -1553,28 +1633,31 @@ function internalConnectMultiple(context) { 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, handle); if (err) { - handle.close(); - ArrayPrototypePush(context.errors, new ExceptionWithHostPort(err, 'bind', localAddress, localPort)); - scheduleConnectionAttempt(context, 10); + releaseAttemptHandle(context, handle, own); + context.errors[current] = new ExceptionWithHostPort(err, 'bind', localAddress, localPort); + connectionAttemptFailed(context, current); return; } } if (self.blockList?.check(address, `ipv${addressType}`)) { - handle.close(); + 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); - if (self.connecting && !context.done) scheduleConnectionAttempt(context, 10); + 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) { - handle.close(); + if (!own) handle.close(); return; } @@ -1590,18 +1673,20 @@ function internalConnectMultiple(context) { err = addressType === 4 ? handle.connect(req, address, port) : handle.connect6(req, address, port); if (err) { - handle.close(); + releaseAttemptHandle(context, handle, own); const ex = createConnectionError(req, err); - ArrayPrototypePush(context.errors, ex); + context.errors[current] = ex; self.emit('connectionAttemptFailed', address, port, addressType, ex); - if (self.connecting && !context.done) scheduleConnectionAttempt(context, 10); + connectionAttemptFailed(context, current); return; } - const attempt = { handle, req }; + const attempt = { handle, req, index: current, own, timedOut: false }; ArrayPrototypePush(context.pending, attempt); if (context.current < context.addresses.length) { - scheduleConnectionAttempt(context, context.timeout, attempt); + debug('connect/multiple: setting the attempt timeout to %d ms', context.timeout); + context[kTimeout] = setTimeout(internalConnectMultipleTimeout, context.timeout, context, attempt); + if (self._handle.hasRef() === false) context[kTimeout].unref(); } } @@ -2010,6 +2095,7 @@ function lookupAndConnectMultiple( [kTimeout]: null, errors: [], pending: [], + socketHandleState: kSocketHandleUnused, done: false, }; self[kAutoSelectFamilyContext] = context; @@ -2181,42 +2267,31 @@ function createConnectionError(req, status) { 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); + const attempt = removePendingAttempt(context, req); + if (attempt === undefined) return; - if (context.done || !context.socket.connecting) { - handle.close(); + const self = context.socket; + if (context.done || !self.connecting) { + if (!attempt.own) attempt.handle.close(); return; } - const self = context.socket; if (status !== 0) { - handle.close(); + 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); - if (self.connecting && !context.done) { - if (context.current < context.addresses.length) { - scheduleConnectionAttempt(context, 10); - } else if (context.pending.length === 0) { - internalConnectMultiple(context); - } - } + connectionAttemptFailed(context, attempt.index); return; } closeConnectionAttempts(context); self[kAutoSelectFamilyContext] = undefined; - const unrefed = self._handle?.hasRef?.() === false; - self[kReinitializeHandle](handle); - if (unrefed) self._handle.unref(); + if (!attempt.own) { + const unrefed = self._handle.hasRef() === false; + self[kReinitializeHandle](attempt.handle); + if (unrefed) self._handle.unref(); + } if (hasObserver('net')) { startPerf( self, 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-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());