Skip to content

Commit c301ed9

Browse files
committed
net: add autoDetectFamily option
1 parent 96f0722 commit c301ed9

File tree

5 files changed

+488
-1
lines changed

5 files changed

+488
-1
lines changed

doc/api/net.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -856,6 +856,10 @@ behavior.
856856
<!-- YAML
857857
added: v0.1.90
858858
changes:
859+
- version: REPLACEME
860+
pr-url: https://github.com/nodejs/node/pull/44731
861+
description: Added the `autoDetectFamily` option, which enables the Happy
862+
Eyeballs algorithm for dualstack connections.
859863
- version:
860864
- v17.7.0
861865
- v16.15.0
@@ -889,6 +893,7 @@ For TCP connections, available `options` are:
889893
* `port` {number} Required. Port the socket should connect to.
890894
* `host` {string} Host the socket should connect to. **Default:** `'localhost'`.
891895
* `localAddress` {string} Local address the socket should connect from.
896+
This is ignored if `autoDetectFamily` is set to `true`.
892897
* `localPort` {number} Local port the socket should connect from.
893898
* `family` {number}: Version of IP stack. Must be `4`, `6`, or `0`. The value
894899
`0` indicates that both IPv4 and IPv6 addresses are allowed. **Default:** `0`.
@@ -902,7 +907,13 @@ For TCP connections, available `options` are:
902907
**Default:** `false`.
903908
* `keepAliveInitialDelay` {number} If set to a positive number, it sets the initial delay before
904909
the first keepalive probe is sent on an idle socket.**Default:** `0`.
905-
910+
* `autoDetectFamily` {boolean}: Enables the Happy Eyeballs connection algorithm.
911+
The `all` option passed to lookup is set to `true` and the sockets attempts to
912+
connect to all returned AAAA and A records at the same time, keeping only
913+
the first successful connection and disconnecting all the other ones.
914+
Connection errors are not emitted if at least a connection succeeds.
915+
Ignored if the `family` option is not `0`.
916+
906917
For [IPC][] connections, available `options` are:
907918

908919
* `path` {string} Required. Path the client should connect to.

lib/internal/errors.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,12 @@ const aggregateTwoErrors = hideStackFrames((innerError, outerError) => {
168168
return innerError || outerError;
169169
});
170170

171+
const aggregateErrors = hideStackFrames((errors, message, code) => {
172+
const err = new AggregateError(errors, message);
173+
err.code = errors[0]?.code;
174+
return err;
175+
});
176+
171177
// Lazily loaded
172178
let util;
173179
let assert;
@@ -893,6 +899,7 @@ function determineSpecificType(value) {
893899
module.exports = {
894900
AbortError,
895901
aggregateTwoErrors,
902+
aggregateErrors,
896903
captureLargerStackTrace,
897904
codes,
898905
connResetException,

lib/net.js

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ const {
9696
ERR_SOCKET_CLOSED,
9797
ERR_MISSING_ARGS,
9898
},
99+
aggregateErrors,
99100
errnoException,
100101
exceptionWithHostPort,
101102
genericNodeError,
@@ -1042,6 +1043,76 @@ function internalConnect(
10421043
}
10431044

10441045

1046+
function internalConnectMultiple(
1047+
self, addresses, port, localPort, flags
1048+
) {
1049+
assert(self.connecting);
1050+
1051+
const context = {
1052+
errors: [],
1053+
connecting: 0,
1054+
completed: false
1055+
};
1056+
1057+
const oncomplete = afterConnectMultiple.bind(self, context);
1058+
1059+
for (const { address, family: addressType } of addresses) {
1060+
const handle = new TCP(TCPConstants.SOCKET);
1061+
1062+
let localAddress;
1063+
let err;
1064+
1065+
if (localPort) {
1066+
if (addressType === 4) {
1067+
localAddress = DEFAULT_IPV4_ADDR;
1068+
err = handle.bind(localAddress, localPort);
1069+
} else { // addressType === 6
1070+
localAddress = DEFAULT_IPV6_ADDR;
1071+
err = handle.bind6(localAddress, localPort, flags);
1072+
}
1073+
1074+
debug('connect/happy eyeballs: binding to localAddress: %s and localPort: %d (addressType: %d)',
1075+
localAddress, localPort, addressType);
1076+
1077+
err = checkBindError(err, localPort, handle);
1078+
if (err) {
1079+
context.errors.push(exceptionWithHostPort(err, 'bind', localAddress, localPort));
1080+
continue;
1081+
}
1082+
}
1083+
1084+
const req = new TCPConnectWrap();
1085+
req.oncomplete = oncomplete;
1086+
req.address = address;
1087+
req.port = port;
1088+
req.localAddress = localAddress;
1089+
req.localPort = localPort;
1090+
1091+
if (addressType === 4) {
1092+
err = handle.connect(req, address, port);
1093+
} else {
1094+
err = handle.connect6(req, address, port);
1095+
}
1096+
1097+
if (err) {
1098+
const sockname = self._getsockname();
1099+
let details;
1100+
1101+
if (sockname) {
1102+
details = sockname.address + ':' + sockname.port;
1103+
}
1104+
1105+
context.errors.push(exceptionWithHostPort(err, 'connect', address, port, details));
1106+
} else {
1107+
context.connecting++;
1108+
}
1109+
}
1110+
1111+
if (context.errors.length && context.connecting === 0) {
1112+
self.destroy(aggregateErrors(context.error));
1113+
}
1114+
}
1115+
10451116
Socket.prototype.connect = function(...args) {
10461117
let normalized;
10471118
// If passed an array, it's treated as an array of arguments that have
@@ -1166,6 +1237,64 @@ function lookupAndConnect(self, options) {
11661237
debug('connect: dns options', dnsopts);
11671238
self._host = host;
11681239
const lookup = options.lookup || dns.lookup;
1240+
1241+
if (dnsopts.family !== 4 && dnsopts.family !== 6 && options.autoDetectFamily) {
1242+
debug('connect: autodetecting family via happy eyeballs');
1243+
1244+
dnsopts.all = true;
1245+
1246+
defaultTriggerAsyncIdScope(self[async_id_symbol], function() {
1247+
lookup(host, dnsopts, function emitLookup(err, addresses) {
1248+
const validAddresses = [];
1249+
1250+
// Gather all the addresses we can use for happy eyeballs
1251+
for (let i = 0, l = addresses.length; i < l; i++) {
1252+
const address = addresses[i];
1253+
const { address: ip, family: addressType } = address;
1254+
self.emit('lookup', err, ip, addressType, host);
1255+
1256+
if (isIP(ip) && (addressType === 4 || addressType === 6)) {
1257+
validAddresses.push(address);
1258+
}
1259+
}
1260+
1261+
// It's possible we were destroyed while looking this up.
1262+
// XXX it would be great if we could cancel the promise returned by
1263+
// the look up.
1264+
if (!self.connecting) {
1265+
return;
1266+
} else if (err) {
1267+
// net.createConnection() creates a net.Socket object and immediately
1268+
// calls net.Socket.connect() on it (that's us). There are no event
1269+
// listeners registered yet so defer the error event to the next tick.
1270+
process.nextTick(connectErrorNT, self, err);
1271+
return;
1272+
}
1273+
1274+
const { address: firstIp, family: firstAddressType } = addresses[0];
1275+
1276+
if (!isIP(firstIp)) {
1277+
err = new ERR_INVALID_IP_ADDRESS(firstIp);
1278+
process.nextTick(connectErrorNT, self, err);
1279+
} else if (firstAddressType !== 4 && firstAddressType !== 6) {
1280+
err = new ERR_INVALID_ADDRESS_FAMILY(firstAddressType,
1281+
options.host,
1282+
options.port);
1283+
process.nextTick(connectErrorNT, self, err);
1284+
} else {
1285+
self._unrefTimer();
1286+
defaultTriggerAsyncIdScope(
1287+
self[async_id_symbol],
1288+
internalConnectMultiple,
1289+
self, validAddresses, port, localPort
1290+
);
1291+
}
1292+
});
1293+
});
1294+
1295+
return;
1296+
}
1297+
11691298
defaultTriggerAsyncIdScope(self[async_id_symbol], function() {
11701299
lookup(host, dnsopts, function emitLookup(err, ip, addressType) {
11711300
self.emit('lookup', err, ip, addressType, host);
@@ -1294,6 +1423,57 @@ function afterConnect(status, handle, req, readable, writable) {
12941423
}
12951424
}
12961425

1426+
function afterConnectMultiple(context, status, handle, req, readable, writable) {
1427+
context.connecting--;
1428+
1429+
// Some error occurred, add to the list of exceptions
1430+
if (status !== 0) {
1431+
let details;
1432+
if (req.localAddress && req.localPort) {
1433+
details = req.localAddress + ':' + req.localPort;
1434+
}
1435+
const ex = exceptionWithHostPort(status,
1436+
'connect',
1437+
req.address,
1438+
req.port,
1439+
details);
1440+
if (details) {
1441+
ex.localAddress = req.localAddress;
1442+
ex.localPort = req.localPort;
1443+
}
1444+
1445+
context.errors.push(ex);
1446+
1447+
if (context.connecting === 0) {
1448+
this.destroy(aggregateErrors(context.errors));
1449+
}
1450+
1451+
return;
1452+
}
1453+
1454+
// One of the connection has completed and correctly dispatched, ignore this one
1455+
if (context.completed) {
1456+
debug('connect/happy eyeballs: ignoring successful connection to %s:%s', req.address, req.port);
1457+
handle.close();
1458+
return;
1459+
}
1460+
1461+
// Mark the connection as successful
1462+
context.completed = true;
1463+
this._handle = handle;
1464+
initSocketHandle(this);
1465+
1466+
if (hasObserver('net')) {
1467+
startPerf(
1468+
this,
1469+
kPerfHooksNetConnectContext,
1470+
{ type: 'net', name: 'connect', detail: { host: req.address, port: req.port } }
1471+
);
1472+
}
1473+
1474+
afterConnect(status, handle, req, readable, writable);
1475+
}
1476+
12971477
function addAbortSignalOption(self, options) {
12981478
if (options?.signal === undefined) {
12991479
return;
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
const assert = require('assert');
5+
const { parseDNSPacket, writeDNSPacket } = require('../common/dns');
6+
const dgram = require('dgram');
7+
const { Resolver } = require('dns');
8+
const { request, createServer } = require('http');
9+
10+
// Test that happy eyeballs algorithm is properly implemented when using HTTP.
11+
function _lookup(resolver, hostname, options, cb) {
12+
resolver.resolve(hostname, 'ANY', (err, replies) => {
13+
assert.notStrictEqual(options.family, 4);
14+
15+
if (err) {
16+
return cb(err);
17+
}
18+
19+
const hosts = replies
20+
.map((r) => ({ address: r.address, family: r.type === 'AAAA' ? 6 : 4 }))
21+
.sort((a, b) => b.family - a.family);
22+
23+
if (options.all === true) {
24+
return cb(null, hosts);
25+
}
26+
27+
return cb(null, hosts[0].address, hosts[0].family);
28+
});
29+
}
30+
31+
function createDnsServer(ipv6Addr, ipv4Addr, cb) {
32+
// Create a DNS server which replies with a AAAA and a A record for the same host
33+
const socket = dgram.createSocket('udp4');
34+
35+
socket.on('message', common.mustCall((msg, { address, port }) => {
36+
const parsed = parseDNSPacket(msg);
37+
const domain = parsed.questions[0].domain;
38+
assert.strictEqual(domain, 'example.org');
39+
40+
socket.send(writeDNSPacket({
41+
id: parsed.id,
42+
questions: parsed.questions,
43+
answers: [
44+
{ type: 'AAAA', address: ipv6Addr, ttl: 123, domain: 'example.org' },
45+
{ type: 'A', address: ipv4Addr, ttl: 123, domain: 'example.org' },
46+
]
47+
}), port, address);
48+
}));
49+
50+
socket.bind(0, () => {
51+
const resolver = new Resolver();
52+
resolver.setServers([`127.0.0.1:${socket.address().port}`]);
53+
54+
cb({ dnsServer: socket, lookup: _lookup.bind(null, resolver) });
55+
});
56+
}
57+
58+
// Test that IPV4 is reached if IPV6 is not reachable
59+
{
60+
createDnsServer('::1', '127.0.0.1', common.mustCall(function({ dnsServer, lookup }) {
61+
const ipv4Server = createServer(common.mustCall((req, res) => {
62+
res.writeHead(200);
63+
res.end('response-ipv4');
64+
}));
65+
66+
ipv4Server.listen(0, '0.0.0.0', common.mustCall(() => {
67+
request(`http://example.org:${ipv4Server.address().port}/`, { lookup, autoDetectFamily: true }, (res) => {
68+
assert.strictEqual(res.statusCode, 200);
69+
res.setEncoding('utf-8');
70+
71+
let response = '';
72+
73+
res.on('data', (chunk) => {
74+
response += chunk;
75+
});
76+
77+
res.on('end', common.mustCall(() => {
78+
assert.strictEqual(response, 'response-ipv4');
79+
ipv4Server.close();
80+
dnsServer.close();
81+
}));
82+
}).end();
83+
}));
84+
}));
85+
}
86+
87+
// Test that IPV4 is NOT reached if IPV6 is reachable
88+
{
89+
createDnsServer('::1', '127.0.0.1', common.mustCall(function({ dnsServer, lookup }) {
90+
const ipv4Server = createServer(common.mustNotCall((req, res) => {
91+
res.writeHead(200);
92+
res.end('response-ipv4');
93+
}));
94+
95+
const ipv6Server = createServer(common.mustCall((req, res) => {
96+
res.writeHead(200);
97+
res.end('response-ipv6');
98+
}));
99+
100+
ipv4Server.listen(0, '0.0.0.0', common.mustCall(() => {
101+
const port = ipv4Server.address().port;
102+
103+
ipv6Server.listen(port, '::', common.mustCall(() => {
104+
request(`http://example.org:${ipv4Server.address().port}/`, { lookup, autoDetectFamily: true }, (res) => {
105+
assert.strictEqual(res.statusCode, 200);
106+
res.setEncoding('utf-8');
107+
108+
let response = '';
109+
110+
res.on('data', (chunk) => {
111+
response += chunk;
112+
});
113+
114+
res.on('end', common.mustCall(() => {
115+
assert.strictEqual(response, 'response-ipv6');
116+
ipv4Server.close();
117+
ipv6Server.close();
118+
dnsServer.close();
119+
}));
120+
}).end();
121+
}));
122+
}));
123+
}));
124+
}

0 commit comments

Comments
 (0)