summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaolo Insogna <paolo@cowtech.it>2022-12-03 18:55:57 +0100
committerDanielle Adams <adamzdanielle@gmail.com>2023-01-04 20:31:51 -0500
commit84cd1322790a74e65dc4fcc0c602b66c71285182 (patch)
treee6eb3defd0adeac10dc59482b8e8a7cdcc4dbe8f
parent7a56409aae46037698011c30dbcb257e13b2c723 (diff)
downloadnode-new-84cd1322790a74e65dc4fcc0c602b66c71285182.tar.gz
net: add autoSelectFamily and autoSelectFamilyAttemptTimeout options
PR-URL: https://github.com/nodejs/node/pull/44731 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Robert Nagy <ronagy@icloud.com> Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com> Reviewed-By: Rafael Gonzaga <rafael.nunu@hotmail.com> Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
-rw-r--r--doc/api/net.md18
-rw-r--r--lib/_tls_wrap.js18
-rw-r--r--lib/internal/errors.js8
-rw-r--r--lib/internal/net.js1
-rw-r--r--lib/net.js255
-rw-r--r--test/parallel/test-http-happy-eyeballs.js148
-rw-r--r--test/parallel/test-https-happy-eyeballs.js164
-rw-r--r--test/parallel/test-net-happy-eyeballs-ipv4first.js112
-rw-r--r--test/parallel/test-net-happy-eyeballs.js215
9 files changed, 932 insertions, 7 deletions
diff --git a/doc/api/net.md b/doc/api/net.md
index 501c5ac81b..ba39e6bd34 100644
--- a/doc/api/net.md
+++ b/doc/api/net.md
@@ -854,6 +854,9 @@ behavior.
<!-- YAML
added: v0.1.90
changes:
+ - version: REPLACEME
+ pr-url: https://github.com/nodejs/node/pull/44731
+ description: Added the `autoSelectFamily` option.
- version: v17.7.0
pr-url: https://github.com/nodejs/node/pull/41310
description: The `noDelay`, `keepAlive`, and `keepAliveInitialDelay`
@@ -898,6 +901,20 @@ For TCP connections, available `options` are:
**Default:** `false`.
* `keepAliveInitialDelay` {number} If set to a positive number, it sets the initial delay before
the first keepalive probe is sent on an idle socket.**Default:** `0`.
+* `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 and so on.
+ Each connection attempt 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.
+ **Default:** `false`.
+* `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:** `250`.
For [IPC][] connections, available `options` are:
@@ -1622,6 +1639,7 @@ net.isIPv6('fhqwhgads'); // returns false
[IPC]: #ipc-support
[Identifying paths for IPC connections]: #identifying-paths-for-ipc-connections
+[RFC 8305]: https://www.rfc-editor.org/rfc/rfc8305.txt
[Readable Stream]: stream.md#class-streamreadable
[`'close'`]: #event-close
[`'connect'`]: #event-connect
diff --git a/lib/_tls_wrap.js b/lib/_tls_wrap.js
index 09e5994040..65db04e769 100644
--- a/lib/_tls_wrap.js
+++ b/lib/_tls_wrap.js
@@ -54,6 +54,7 @@ const EE = require('events');
const net = require('net');
const tls = require('tls');
const common = require('_tls_common');
+const { kWrapConnectedHandle } = require('internal/net');
const JSStreamSocket = require('internal/js_stream_socket');
const { Buffer } = require('buffer');
let debug = require('internal/util/debuglog').debuglog('tls', (fn) => {
@@ -598,11 +599,10 @@ TLSSocket.prototype.disableRenegotiation = function disableRenegotiation() {
this[kDisableRenegotiation] = true;
};
-TLSSocket.prototype._wrapHandle = function(wrap) {
- let handle;
-
- if (wrap)
+TLSSocket.prototype._wrapHandle = function(wrap, handle) {
+ if (!handle && wrap) {
handle = wrap._handle;
+ }
const options = this._tlsOptions;
if (!handle) {
@@ -633,6 +633,16 @@ TLSSocket.prototype._wrapHandle = function(wrap) {
return res;
};
+TLSSocket.prototype[kWrapConnectedHandle] = function(handle) {
+ this._handle = this._wrapHandle(null, handle);
+ this.ssl = this._handle;
+ this._init();
+
+ if (this._tlsOptions.enableTrace) {
+ this._handle.enableTrace();
+ }
+};
+
// This eliminates a cyclic reference to TLSWrap
// Ref: https://github.com/nodejs/node/commit/f7620fb96d339f704932f9bb9a0dceb9952df2d4
function defineHandleReading(socket, handle) {
diff --git a/lib/internal/errors.js b/lib/internal/errors.js
index f68855c148..38c9d391d3 100644
--- a/lib/internal/errors.js
+++ b/lib/internal/errors.js
@@ -168,6 +168,13 @@ const aggregateTwoErrors = hideStackFrames((innerError, outerError) => {
return innerError || outerError;
});
+const aggregateErrors = hideStackFrames((errors, message, code) => {
+ // eslint-disable-next-line no-restricted-syntax
+ const err = new AggregateError(new SafeArrayIterator(errors), message);
+ err.code = errors[0]?.code;
+ return err;
+});
+
// Lazily loaded
let util;
let assert;
@@ -893,6 +900,7 @@ function determineSpecificType(value) {
module.exports = {
AbortError,
aggregateTwoErrors,
+ aggregateErrors,
captureLargerStackTrace,
codes,
connResetException,
diff --git a/lib/internal/net.js b/lib/internal/net.js
index 625377acd5..35e2a03706 100644
--- a/lib/internal/net.js
+++ b/lib/internal/net.js
@@ -67,6 +67,7 @@ function makeSyncWrite(fd) {
}
module.exports = {
+ kWrapConnectedHandle: Symbol('wrapConnectedHandle'),
isIP,
isIPv4,
isIPv6,
diff --git a/lib/net.js b/lib/net.js
index 06bb3879fa..103b6c9264 100644
--- a/lib/net.js
+++ b/lib/net.js
@@ -24,7 +24,10 @@
const {
ArrayIsArray,
ArrayPrototypeIndexOf,
+ ArrayPrototypePush,
Boolean,
+ FunctionPrototypeBind,
+ MathMax,
Number,
NumberIsNaN,
NumberParseInt,
@@ -40,6 +43,7 @@ let debug = require('internal/util/debuglog').debuglog('net', (fn) => {
debug = fn;
});
const {
+ kWrapConnectedHandle,
isIP,
isIPv4,
isIPv6,
@@ -96,6 +100,7 @@ const {
ERR_SOCKET_CLOSED,
ERR_MISSING_ARGS,
},
+ aggregateErrors,
errnoException,
exceptionWithHostPort,
genericNodeError,
@@ -105,6 +110,7 @@ const { isUint8Array } = require('internal/util/types');
const { queueMicrotask } = require('internal/process/task_queues');
const {
validateAbortSignal,
+ validateBoolean,
validateFunction,
validateInt32,
validateNumber,
@@ -123,8 +129,9 @@ let dns;
let BlockList;
let SocketAddress;
-const { clearTimeout } = require('timers');
+const { clearTimeout, setTimeout } = require('timers');
const { kTimeout } = require('internal/timers');
+const kTimeoutTriggered = Symbol('kTimeoutTriggered');
const DEFAULT_IPV4_ADDR = '0.0.0.0';
const DEFAULT_IPV6_ADDR = '::';
@@ -1057,6 +1064,73 @@ function internalConnect(
}
+function internalConnectMultiple(context) {
+ clearTimeout(context[kTimeout]);
+ const self = context.socket;
+ assert(self.connecting);
+
+ // All connections have been tried without success, destroy with error
+ if (context.current === context.addresses.length) {
+ self.destroy(aggregateErrors(context.errors));
+ return;
+ }
+
+ const { localPort, port, flags } = context;
+ const { address, family: addressType } = context.addresses[context.current++];
+ const handle = new TCP(TCPConstants.SOCKET);
+ let localAddress;
+ let err;
+
+ if (localPort) {
+ if (addressType === 4) {
+ localAddress = DEFAULT_IPV4_ADDR;
+ err = handle.bind(localAddress, localPort);
+ } else { // addressType === 6
+ localAddress = DEFAULT_IPV6_ADDR;
+ 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) {
+ ArrayPrototypePush(context.errors, exceptionWithHostPort(err, 'bind', localAddress, localPort));
+ internalConnectMultiple(context);
+ return;
+ }
+ }
+
+ const req = new TCPConnectWrap();
+ req.oncomplete = FunctionPrototypeBind(afterConnectMultiple, undefined, context);
+ req.address = address;
+ req.port = port;
+ req.localAddress = localAddress;
+ req.localPort = localPort;
+
+ if (addressType === 4) {
+ err = handle.connect(req, address, port);
+ } else {
+ err = handle.connect6(req, address, port);
+ }
+
+ if (err) {
+ const sockname = self._getsockname();
+ let details;
+
+ if (sockname) {
+ details = sockname.address + ':' + sockname.port;
+ }
+
+ ArrayPrototypePush(context.errors, exceptionWithHostPort(err, 'connect', address, port, details));
+ internalConnectMultiple(context);
+ return;
+ }
+
+ // If the attempt has not returned an error, start the connection timer
+ context[kTimeout] = setTimeout(internalConnectMultipleTimeout, context.timeout, context, req);
+}
+
Socket.prototype.connect = function(...args) {
let normalized;
// If passed an array, it's treated as an array of arguments that have
@@ -1126,9 +1200,9 @@ function socketToDnsFamily(family) {
}
function lookupAndConnect(self, options) {
- const { localAddress, localPort } = options;
+ const { localAddress, localPort, autoSelectFamily } = options;
const host = options.host || 'localhost';
- let { port } = options;
+ let { port, autoSelectFamilyAttemptTimeout } = options;
if (localAddress && !isIP(localAddress)) {
throw new ERR_INVALID_IP_ADDRESS(localAddress);
@@ -1147,6 +1221,20 @@ function lookupAndConnect(self, options) {
}
port |= 0;
+ if (autoSelectFamily !== undefined) {
+ validateBoolean(autoSelectFamily);
+ }
+
+ if (autoSelectFamilyAttemptTimeout !== undefined) {
+ validateInt32(autoSelectFamilyAttemptTimeout);
+
+ if (autoSelectFamilyAttemptTimeout < 10) {
+ autoSelectFamilyAttemptTimeout = 10;
+ }
+ } else {
+ autoSelectFamilyAttemptTimeout = 250;
+ }
+
// If host is an IP, skip performing a lookup
const addressType = isIP(host);
if (addressType) {
@@ -1181,6 +1269,26 @@ function lookupAndConnect(self, options) {
debug('connect: dns options', dnsopts);
self._host = host;
const lookup = options.lookup || dns.lookup;
+
+ if (dnsopts.family !== 4 && dnsopts.family !== 6 && !localAddress && autoSelectFamily) {
+ debug('connect: autodetecting');
+
+ dnsopts.all = true;
+ lookupAndConnectMultiple(
+ self,
+ async_id_symbol,
+ lookup,
+ host,
+ options,
+ dnsopts,
+ port,
+ localPort,
+ autoSelectFamilyAttemptTimeout
+ );
+
+ return;
+ }
+
defaultTriggerAsyncIdScope(self[async_id_symbol], function() {
lookup(host, dnsopts, function emitLookup(err, ip, addressType) {
self.emit('lookup', err, ip, addressType, host);
@@ -1215,6 +1323,86 @@ function lookupAndConnect(self, options) {
});
}
+function lookupAndConnectMultiple(self, async_id_symbol, lookup, host, options, dnsopts, port, localPort, timeout) {
+ defaultTriggerAsyncIdScope(self[async_id_symbol], function emitLookup() {
+ lookup(host, dnsopts, function emitLookup(err, addresses) {
+ // It's possible we were destroyed while looking this up.
+ // XXX it would be great if we could cancel the promise returned by
+ // the look up.
+ if (!self.connecting) {
+ return;
+ } else if (err) {
+ // net.createConnection() creates a net.Socket object and immediately
+ // calls net.Socket.connect() on it (that's us). There are no event
+ // listeners registered yet so defer the error event to the next tick.
+ process.nextTick(connectErrorNT, self, err);
+ return;
+ }
+
+ // Filter addresses by only keeping the one which are either IPv4 or IPV6.
+ // The first valid address determines which group has preference on the
+ // alternate family sorting which happens later.
+ const validIps = [[], []];
+ let destinations;
+ for (let i = 0, l = addresses.length; i < l; i++) {
+ const address = addresses[i];
+ const { address: ip, family: addressType } = address;
+ self.emit('lookup', err, ip, addressType, host);
+
+ if (isIP(ip) && (addressType === 4 || addressType === 6)) {
+ if (!destinations) {
+ destinations = addressType === 6 ? { 6: 0, 4: 1 } : { 4: 0, 6: 1 };
+ }
+
+ ArrayPrototypePush(validIps[destinations[addressType]], address);
+ }
+ }
+
+ // When no AAAA or A records are available, fail on the first one
+ if (!validIps[0].length && !validIps[1].length) {
+ const { address: firstIp, family: firstAddressType } = addresses[0];
+
+ if (!isIP(firstIp)) {
+ err = new ERR_INVALID_IP_ADDRESS(firstIp);
+ process.nextTick(connectErrorNT, self, err);
+ } else if (firstAddressType !== 4 && firstAddressType !== 6) {
+ err = new ERR_INVALID_ADDRESS_FAMILY(firstAddressType,
+ options.host,
+ options.port);
+ process.nextTick(connectErrorNT, self, err);
+ }
+
+ return;
+ }
+
+ // Sort addresses alternating families
+ const toAttempt = [];
+ for (let i = 0, l = MathMax(validIps[0].length, validIps[1].length); i < l; i++) {
+ if (i in validIps[0]) {
+ ArrayPrototypePush(toAttempt, validIps[0][i]);
+ }
+ if (i in validIps[1]) {
+ ArrayPrototypePush(toAttempt, validIps[1][i]);
+ }
+ }
+
+ const context = {
+ socket: self,
+ addresses,
+ current: 0,
+ port,
+ localPort,
+ timeout,
+ [kTimeout]: null,
+ [kTimeoutTriggered]: false,
+ errors: [],
+ };
+
+ self._unrefTimer();
+ defaultTriggerAsyncIdScope(self[async_id_symbol], internalConnectMultiple, context);
+ });
+ });
+}
function connectErrorNT(self, err) {
self.destroy(err);
@@ -1309,6 +1497,67 @@ function afterConnect(status, handle, req, readable, writable) {
}
}
+function afterConnectMultiple(context, status, handle, req, readable, writable) {
+ const self = context.socket;
+
+ // Make sure another connection is not spawned
+ clearTimeout(context[kTimeout]);
+
+ // Some error occurred, add to the list of exceptions
+ if (status !== 0) {
+ let details;
+ if (req.localAddress && req.localPort) {
+ details = req.localAddress + ':' + req.localPort;
+ }
+ const ex = exceptionWithHostPort(status,
+ 'connect',
+ req.address,
+ req.port,
+ details);
+ if (details) {
+ ex.localAddress = req.localAddress;
+ ex.localPort = req.localPort;
+ }
+
+ ArrayPrototypePush(context.errors, ex);
+
+ // Try the next address
+ internalConnectMultiple(context);
+ return;
+ }
+
+ // One of the connection has completed and correctly dispatched but after timeout, ignore this one
+ if (context[kTimeoutTriggered]) {
+ debug('connect/multiple: ignoring successful but timedout connection to %s:%s', req.address, req.port);
+ handle.close();
+ return;
+ }
+
+ // Perform initialization sequence on the handle, then move on with the regular callback
+ self._handle = handle;
+ initSocketHandle(self);
+
+ if (self[kWrapConnectedHandle]) {
+ self[kWrapConnectedHandle](handle);
+ initSocketHandle(self); // This is called again to initialize the TLSWrap
+ }
+
+ if (hasObserver('net')) {
+ startPerf(
+ self,
+ kPerfHooksNetConnectContext,
+ { type: 'net', name: 'connect', detail: { host: req.address, port: req.port } }
+ );
+ }
+
+ afterConnect(status, handle, req, readable, writable);
+}
+
+function internalConnectMultipleTimeout(context, req) {
+ context[kTimeoutTriggered] = true;
+ internalConnectMultiple(context);
+}
+
function addAbortSignalOption(self, options) {
if (options?.signal === undefined) {
return;
diff --git a/test/parallel/test-http-happy-eyeballs.js b/test/parallel/test-http-happy-eyeballs.js
new file mode 100644
index 0000000000..da81ce9a01
--- /dev/null
+++ b/test/parallel/test-http-happy-eyeballs.js
@@ -0,0 +1,148 @@
+'use strict';
+
+const common = require('../common');
+const { parseDNSPacket, writeDNSPacket } = require('../common/dns');
+
+const assert = require('assert');
+const dgram = require('dgram');
+const { Resolver } = require('dns');
+const { request, createServer } = require('http');
+
+// Test that happy eyeballs algorithm is properly implemented when using HTTP.
+
+let autoSelectFamilyAttemptTimeout = common.platformTimeout(250);
+if (common.isWindows) {
+ // Some of the windows machines in the CI need more time to establish connection
+ autoSelectFamilyAttemptTimeout = common.platformTimeout(1500);
+}
+
+function _lookup(resolver, hostname, options, cb) {
+ resolver.resolve(hostname, 'ANY', (err, replies) => {
+ assert.notStrictEqual(options.family, 4);
+
+ if (err) {
+ return cb(err);
+ }
+
+ const hosts = replies
+ .map((r) => ({ address: r.address, family: r.type === 'AAAA' ? 6 : 4 }))
+ .sort((a, b) => b.family - a.family);
+
+ if (options.all === true) {
+ return cb(null, hosts);
+ }
+
+ return cb(null, hosts[0].address, hosts[0].family);
+ });
+}
+
+function createDnsServer(ipv6Addr, ipv4Addr, cb) {
+ // Create a DNS server which replies with a AAAA and a A record for the same host
+ const socket = dgram.createSocket('udp4');
+
+ socket.on('message', common.mustCall((msg, { address, port }) => {
+ const parsed = parseDNSPacket(msg);
+ const domain = parsed.questions[0].domain;
+ assert.strictEqual(domain, 'example.org');
+
+ socket.send(writeDNSPacket({
+ id: parsed.id,
+ questions: parsed.questions,
+ answers: [
+ { type: 'AAAA', address: ipv6Addr, ttl: 123, domain: 'example.org' },
+ { type: 'A', address: ipv4Addr, ttl: 123, domain: 'example.org' },
+ ]
+ }), port, address);
+ }));
+
+ socket.bind(0, () => {
+ const resolver = new Resolver();
+ resolver.setServers([`127.0.0.1:${socket.address().port}`]);
+
+ cb({ dnsServer: socket, lookup: _lookup.bind(null, resolver) });
+ });
+}
+
+// Test that IPV4 is reached if IPV6 is not reachable
+{
+ createDnsServer('::1', '127.0.0.1', common.mustCall(function({ dnsServer, lookup }) {
+ const ipv4Server = createServer(common.mustCall((_, res) => {
+ res.writeHead(200, { Connection: 'close' });
+ res.end('response-ipv4');
+ }));
+
+ ipv4Server.listen(0, '127.0.0.1', common.mustCall(() => {
+ request(
+ `http://example.org:${ipv4Server.address().port}/`,
+ {
+ lookup,
+ autoSelectFamily: true,
+ autoSelectFamilyAttemptTimeout
+ },
+ (res) => {
+ assert.strictEqual(res.statusCode, 200);
+ res.setEncoding('utf-8');
+
+ let response = '';
+
+ res.on('data', (chunk) => {
+ response += chunk;
+ });
+
+ res.on('end', common.mustCall(() => {
+ assert.strictEqual(response, 'response-ipv4');
+ ipv4Server.close();
+ dnsServer.close();
+ }));
+ }
+ ).end();
+ }));
+ }));
+}
+
+// Test that IPV4 is NOT reached if IPV6 is reachable
+if (common.hasIPv6) {
+ createDnsServer('::1', '127.0.0.1', common.mustCall(function({ dnsServer, lookup }) {
+ const ipv4Server = createServer(common.mustNotCall((_, res) => {
+ res.writeHead(200, { Connection: 'close' });
+ res.end('response-ipv4');
+ }));
+
+ const ipv6Server = createServer(common.mustCall((_, res) => {
+ res.writeHead(200, { Connection: 'close' });
+ res.end('response-ipv6');
+ }));
+
+ ipv4Server.listen(0, '127.0.0.1', common.mustCall(() => {
+ const port = ipv4Server.address().port;
+
+ ipv6Server.listen(port, '::1', common.mustCall(() => {
+ request(
+ `http://example.org:${ipv4Server.address().port}/`,
+ {
+ lookup,
+ autoSelectFamily: true,
+ autoSelectFamilyAttemptTimeout,
+ },
+ (res) => {
+ assert.strictEqual(res.statusCode, 200);
+ res.setEncoding('utf-8');
+
+ let response = '';
+
+ res.on('data', (chunk) => {
+ response += chunk;
+ });
+
+ res.on('end', common.mustCall(() => {
+ assert.strictEqual(response, 'response-ipv6');
+ ipv4Server.close();
+ ipv6Server.close();
+ dnsServer.close();
+ }));
+ }
+ ).end();
+ }));
+ }));
+ }));
+}
diff --git a/test/parallel/test-https-happy-eyeballs.js b/test/parallel/test-https-happy-eyeballs.js
new file mode 100644
index 0000000000..789a95c551
--- /dev/null
+++ b/test/parallel/test-https-happy-eyeballs.js
@@ -0,0 +1,164 @@
+'use strict';
+
+const common = require('../common');
+
+if (!common.hasCrypto) {
+ common.skip('missing crypto');
+}
+
+const { parseDNSPacket, writeDNSPacket } = require('../common/dns');
+const fixtures = require('../common/fixtures');
+
+const assert = require('assert');
+const dgram = require('dgram');
+const { Resolver } = require('dns');
+const { request, createServer } = require('https');
+
+if (!common.hasCrypto)
+ common.skip('missing crypto');
+
+const options = {
+ key: fixtures.readKey('agent1-key.pem'),
+ cert: fixtures.readKey('agent1-cert.pem')
+};
+
+// Test that happy eyeballs algorithm is properly implemented when using HTTP.
+
+let autoSelectFamilyAttemptTimeout = common.platformTimeout(250);
+if (common.isWindows) {
+ // Some of the windows machines in the CI need more time to establish connection
+ autoSelectFamilyAttemptTimeout = common.platformTimeout(1500);
+}
+
+function _lookup(resolver, hostname, options, cb) {
+ resolver.resolve(hostname, 'ANY', (err, replies) => {
+ assert.notStrictEqual(options.family, 4);
+
+ if (err) {
+ return cb(err);
+ }
+
+ const hosts = replies
+ .map((r) => ({ address: r.address, family: r.type === 'AAAA' ? 6 : 4 }))
+ .sort((a, b) => b.family - a.family);
+
+ if (options.all === true) {
+ return cb(null, hosts);
+ }
+
+ return cb(null, hosts[0].address, hosts[0].family);
+ });
+}
+
+function createDnsServer(ipv6Addr, ipv4Addr, cb) {
+ // Create a DNS server which replies with a AAAA and a A record for the same host
+ const socket = dgram.createSocket('udp4');
+
+ socket.on('message', common.mustCall((msg, { address, port }) => {
+ const parsed = parseDNSPacket(msg);
+ const domain = parsed.questions[0].domain;
+ assert.strictEqual(domain, 'example.org');
+
+ socket.send(writeDNSPacket({
+ id: parsed.id,
+ questions: parsed.questions,
+ answers: [
+ { type: 'AAAA', address: ipv6Addr, ttl: 123, domain: 'example.org' },
+ { type: 'A', address: ipv4Addr, ttl: 123, domain: 'example.org' },
+ ]
+ }), port, address);
+ }));
+
+ socket.bind(0, () => {
+ const resolver = new Resolver();
+ resolver.setServers([`127.0.0.1:${socket.address().port}`]);
+
+ cb({ dnsServer: socket, lookup: _lookup.bind(null, resolver) });
+ });
+}
+
+// Test that IPV4 is reached if IPV6 is not reachable
+{
+ createDnsServer('::1', '127.0.0.1', common.mustCall(function({ dnsServer, lookup }) {
+ const ipv4Server = createServer(options, common.mustCall((_, res) => {
+ res.writeHead(200, { Connection: 'close' });
+ res.end('response-ipv4');
+ }));
+
+ ipv4Server.listen(0, '127.0.0.1', common.mustCall(() => {
+ request(
+ `https://example.org:${ipv4Server.address().port}/`,
+ {
+ lookup,
+ rejectUnauthorized: false,
+ autoSelectFamily: true,
+ autoSelectFamilyAttemptTimeout
+ },
+ (res) => {
+ assert.strictEqual(res.statusCode, 200);
+ res.setEncoding('utf-8');
+
+ let response = '';
+
+ res.on('data', (chunk) => {
+ response += chunk;
+ });
+
+ res.on('end', common.mustCall(() => {
+ assert.strictEqual(response, 'response-ipv4');
+ ipv4Server.close();
+ dnsServer.close();
+ }));
+ }
+ ).end();
+ }));
+ }));
+}
+
+// Test that IPV4 is NOT reached if IPV6 is reachable
+if (common.hasIPv6) {
+ createDnsServer('::1', '127.0.0.1', common.mustCall(function({ dnsServer, lookup }) {
+ const ipv4Server = createServer(options, common.mustNotCall((_, res) => {
+ res.writeHead(200, { Connection: 'close' });
+ res.end('response-ipv4');
+ }));
+
+ const ipv6Server = createServer(options, common.mustCall((_, res) => {
+ res.writeHead(200, { Connection: 'close' });
+ res.end('response-ipv6');
+ }));
+
+ ipv4Server.listen(0, '127.0.0.1', common.mustCall(() => {
+ const port = ipv4Server.address().port;
+
+ ipv6Server.listen(port, '::1', common.mustCall(() => {
+ request(
+ `https://example.org:${ipv4Server.address().port}/`,
+ {
+ lookup,
+ rejectUnauthorized: false,
+ autoSelectFamily: true,
+ autoSelectFamilyAttemptTimeout,
+ },
+ (res) => {
+ assert.strictEqual(res.statusCode, 200);
+ res.setEncoding('utf-8');
+
+ let response = '';
+
+ res.on('data', (chunk) => {
+ response += chunk;
+ });
+
+ res.on('end', common.mustCall(() => {
+ assert.strictEqual(response, 'response-ipv6');
+ ipv4Server.close();
+ ipv6Server.close();
+ dnsServer.close();
+ }));
+ }
+ ).end();
+ }));
+ }));
+ }));
+}
diff --git a/test/parallel/test-net-happy-eyeballs-ipv4first.js b/test/parallel/test-net-happy-eyeballs-ipv4first.js
new file mode 100644
index 0000000000..00bff09ac6
--- /dev/null
+++ b/test/parallel/test-net-happy-eyeballs-ipv4first.js
@@ -0,0 +1,112 @@
+'use strict';
+
+const common = require('../common');
+const { parseDNSPacket, writeDNSPacket } = require('../common/dns');
+
+const assert = require('assert');
+const dgram = require('dgram');
+const { Resolver } = require('dns');
+const { createConnection, createServer } = require('net');
+
+// Test that happy eyeballs algorithm is properly implemented when a A record is returned first.
+
+let autoSelectFamilyAttemptTimeout = common.platformTimeout(250);
+if (common.isWindows) {
+ // Some of the windows machines in the CI need more time to establish connection
+ autoSelectFamilyAttemptTimeout = common.platformTimeout(1500);
+}
+
+function _lookup(resolver, hostname, options, cb) {
+ resolver.resolve(hostname, 'ANY', (err, replies) => {
+ assert.notStrictEqual(options.family, 4);
+
+ if (err) {
+ return cb(err);
+ }
+
+ const hosts = replies
+ .map((r) => ({ address: r.address, family: r.type === 'AAAA' ? 6 : 4 }));
+
+ if (options.all === true) {
+ return cb(null, hosts);
+ }
+
+ return cb(null, hosts[0].address, hosts[0].family);
+ });
+}
+
+function createDnsServer(ipv6Addr, ipv4Addr, cb) {
+ // Create a DNS server which replies with a AAAA and a A record for the same host
+ const socket = dgram.createSocket('udp4');
+
+ socket.on('message', common.mustCall((msg, { address, port }) => {
+ const parsed = parseDNSPacket(msg);
+ const domain = parsed.questions[0].domain;
+ assert.strictEqual(domain, 'example.org');
+
+ socket.send(writeDNSPacket({
+ id: parsed.id,
+ questions: parsed.questions,
+ answers: [
+ { type: 'A', address: ipv4Addr, ttl: 123, domain: 'example.org' },
+ { type: 'AAAA', address: ipv6Addr, ttl: 123, domain: 'example.org' },
+ ]
+ }), port, address);
+ }));
+
+ socket.bind(0, () => {
+ const resolver = new Resolver();
+ resolver.setServers([`127.0.0.1:${socket.address().port}`]);
+
+ cb({ dnsServer: socket, lookup: _lookup.bind(null, resolver) });
+ });
+}
+
+// Test that IPV6 is NOT reached if IPV4 is sorted first
+if (common.hasIPv6) {
+ createDnsServer('::1', '127.0.0.1', common.mustCall(function({ dnsServer, lookup }) {
+ const ipv4Server = createServer((socket) => {
+ socket.on('data', common.mustCall(() => {
+ socket.write('response-ipv4');
+ socket.end();
+ }));
+ });
+
+ const ipv6Server = createServer((socket) => {
+ socket.on('data', common.mustNotCall(() => {
+ socket.write('response-ipv6');
+ socket.end();
+ }));
+ });
+
+ ipv4Server.listen(0, '127.0.0.1', common.mustCall(() => {
+ const port = ipv4Server.address().port;
+
+ ipv6Server.listen(port, '::1', common.mustCall(() => {
+ const connection = createConnection({
+ host: 'example.org',
+ port,
+ lookup,
+ autoSelectFamily: true,
+ autoSelectFamilyAttemptTimeout
+ });
+
+ let response = '';
+ connection.setEncoding('utf-8');
+
+ connection.on('data', (chunk) => {
+ response += chunk;
+ });
+
+ connection.on('end', common.mustCall(() => {
+ assert.strictEqual(response, 'response-ipv4');
+ ipv4Server.close();
+ ipv6Server.close();
+ dnsServer.close();
+ }));
+
+ connection.write('request');
+ }));
+ }));
+ }));
+}
diff --git a/test/parallel/test-net-happy-eyeballs.js b/test/parallel/test-net-happy-eyeballs.js
new file mode 100644
index 0000000000..a7b19a074e
--- /dev/null
+++ b/test/parallel/test-net-happy-eyeballs.js
@@ -0,0 +1,215 @@
+'use strict';
+
+const common = require('../common');
+const { parseDNSPacket, writeDNSPacket } = require('../common/dns');
+
+const assert = require('assert');
+const dgram = require('dgram');
+const { Resolver } = require('dns');
+const { createConnection, createServer } = require('net');
+
+// Test that happy eyeballs algorithm is properly implemented.
+
+let autoSelectFamilyAttemptTimeout = common.platformTimeout(250);
+if (common.isWindows) {
+ // Some of the windows machines in the CI need more time to establish connection
+ autoSelectFamilyAttemptTimeout = common.platformTimeout(1500);
+}
+
+function _lookup(resolver, hostname, options, cb) {
+ resolver.resolve(hostname, 'ANY', (err, replies) => {
+ assert.notStrictEqual(options.family, 4);
+
+ if (err) {
+ return cb(err);
+ }
+
+ const hosts = replies
+ .map((r) => ({ address: r.address, family: r.type === 'AAAA' ? 6 : 4 }))
+ .sort((a, b) => b.family - a.family);
+
+ if (options.all === true) {
+ return cb(null, hosts);
+ }
+
+ return cb(null, hosts[0].address, hosts[0].family);
+ });
+}
+
+function createDnsServer(ipv6Addr, ipv4Addr, cb) {
+ // Create a DNS server which replies with a AAAA and a A record for the same host
+ const socket = dgram.createSocket('udp4');
+
+ socket.on('message', common.mustCall((msg, { address, port }) => {
+ const parsed = parseDNSPacket(msg);
+ const domain = parsed.questions[0].domain;
+ assert.strictEqual(domain, 'example.org');
+
+ socket.send(writeDNSPacket({
+ id: parsed.id,
+ questions: parsed.questions,
+ answers: [
+ { type: 'AAAA', address: ipv6Addr, ttl: 123, domain: 'example.org' },
+ { type: 'A', address: ipv4Addr, ttl: 123, domain: 'example.org' },
+ ]
+ }), port, address);
+ }));
+
+ socket.bind(0, () => {
+ const resolver = new Resolver();
+ resolver.setServers([`127.0.0.1:${socket.address().port}`]);
+
+ cb({ dnsServer: socket, lookup: _lookup.bind(null, resolver) });
+ });
+}
+
+// Test that IPV4 is reached if IPV6 is not reachable
+{
+ createDnsServer('::1', '127.0.0.1', common.mustCall(function({ dnsServer, lookup }) {
+ const ipv4Server = createServer((socket) => {
+ socket.on('data', common.mustCall(() => {
+ socket.write('response-ipv4');
+ socket.end();
+ }));
+ });
+
+ ipv4Server.listen(0, '127.0.0.1', common.mustCall(() => {
+ const connection = createConnection({
+ host: 'example.org',
+ port: ipv4Server.address().port,
+ lookup,
+ autoSelectFamily: true,
+ autoSelectFamilyAttemptTimeout,
+ });
+
+ let response = '';
+ connection.setEncoding('utf-8');
+
+ connection.on('data', (chunk) => {
+ response += chunk;
+ });
+
+ connection.on('end', common.mustCall(() => {
+ assert.strictEqual(response, 'response-ipv4');
+ ipv4Server.close();
+ dnsServer.close();
+ }));
+
+ connection.write('request');
+ }));
+ }));
+}
+
+// Test that IPV4 is NOT reached if IPV6 is reachable
+if (common.hasIPv6) {
+ createDnsServer('::1', '127.0.0.1', common.mustCall(function({ dnsServer, lookup }) {
+ const ipv4Server = createServer((socket) => {
+ socket.on('data', common.mustNotCall(() => {
+ socket.write('response-ipv4');
+ socket.end();
+ }));
+ });
+
+ const ipv6Server = createServer((socket) => {
+ socket.on('data', common.mustCall(() => {
+ socket.write('response-ipv6');
+ socket.end();
+ }));
+ });
+
+ ipv4Server.listen(0, '127.0.0.1', common.mustCall(() => {
+ const port = ipv4Server.address().port;
+
+ ipv6Server.listen(port, '::1', common.mustCall(() => {
+ const connection = createConnection({
+ host: 'example.org',
+ port,
+ lookup,
+ autoSelectFamily: true,
+ autoSelectFamilyAttemptTimeout,
+ });
+
+ let response = '';
+ connection.setEncoding('utf-8');
+
+ connection.on('data', (chunk) => {
+ response += chunk;
+ });
+
+ connection.on('end', common.mustCall(() => {
+ assert.strictEqual(response, 'response-ipv6');
+ ipv4Server.close();
+ ipv6Server.close();
+ dnsServer.close();
+ }));
+
+ connection.write('request');
+ }));
+ }));
+ }));
+}
+
+// Test that when all errors are returned when no connections succeeded
+{
+ createDnsServer('::1', '127.0.0.1', common.mustCall(function({ dnsServer, lookup }) {
+ const connection = createConnection({
+ host: 'example.org',
+ port: 10,
+ lookup,
+ autoSelectFamily: true,
+ autoSelectFamilyAttemptTimeout,
+ });
+
+ connection.on('ready', common.mustNotCall());
+ connection.on('error', common.mustCall((error) => {
+ assert.strictEqual(error.constructor.name, 'AggregateError');
+ assert.strictEqual(error.errors.length, 2);
+
+ const errors = error.errors.map((e) => e.message);
+ assert.ok(errors.includes('connect ECONNREFUSED 127.0.0.1:10'));
+
+ if (common.hasIPv6) {
+ assert.ok(errors.includes('connect ECONNREFUSED ::1:10'));
+ }
+
+ dnsServer.close();
+ }));
+ }));
+}
+
+// Test that the option can be disabled
+{
+ createDnsServer('::1', '127.0.0.1', common.mustCall(function({ dnsServer, lookup }) {
+ const ipv4Server = createServer((socket) => {
+ socket.on('data', common.mustCall(() => {
+ socket.write('response-ipv4');
+ socket.end();
+ }));
+ });
+
+ ipv4Server.listen(0, '127.0.0.1', common.mustCall(() => {
+ const port = ipv4Server.address().port;
+
+ const connection = createConnection({
+ host: 'example.org',
+ port,
+ lookup,
+ autoSelectFamily: false,
+ });
+
+ connection.on('ready', common.mustNotCall());
+ connection.on('error', common.mustCall((error) => {
+ if (common.hasIPv6) {
+ assert.strictEqual(error.code, 'ECONNREFUSED');
+ assert.strictEqual(error.message, `connect ECONNREFUSED ::1:${port}`);
+ } else {
+ assert.strictEqual(error.code, 'EADDRNOTAVAIL');
+ assert.strictEqual(error.message, `connect EADDRNOTAVAIL ::1:${port} - Local (:::0)`);
+ }
+
+ ipv4Server.close();
+ dnsServer.close();
+ }));
+ }));
+ }));
+}