Skip to content

Commit

Permalink
net: add autoDetectFamily option
Browse files Browse the repository at this point in the history
  • Loading branch information
ShogunPanda committed Sep 20, 2022
1 parent 66531d5 commit 5a35dae
Show file tree
Hide file tree
Showing 5 changed files with 489 additions and 2 deletions.
13 changes: 12 additions & 1 deletion doc/api/net.md
Original file line number Diff line number Diff line change
Expand Up @@ -856,6 +856,10 @@ behavior.
<!-- YAML
added: v0.1.90
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/44731
description: Added the `autoDetectFamily` option, which enables the Happy
Eyeballs algorithm for dualstack connections.
- version:
- v17.7.0
- v16.15.0
Expand Down Expand Up @@ -889,6 +893,7 @@ For TCP connections, available `options` are:
* `port` {number} Required. Port the socket should connect to.
* `host` {string} Host the socket should connect to. **Default:** `'localhost'`.
* `localAddress` {string} Local address the socket should connect from.
This is ignored if `autoDetectFamily` is set to `true`.
* `localPort` {number} Local port the socket should connect from.
* `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`.
Expand All @@ -902,7 +907,13 @@ 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`.

* `autoDetectFamily` {boolean}: Enables the Happy Eyeballs connection algorithm.
The `all` option passed to lookup is set to `true` and the sockets attempts to
connect to all returned AAAA and A records at the same time, keeping only
the first successful connection and disconnecting all the other ones.
Connection errors are not emitted if at least a connection succeeds.
Ignored if the `family` option is not `0`.

For [IPC][] connections, available `options` are:

* `path` {string} Required. Path the client should connect to.
Expand Down
7 changes: 7 additions & 0 deletions lib/internal/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,12 @@ const aggregateTwoErrors = hideStackFrames((innerError, outerError) => {
return innerError || outerError;
});

const aggregateErrors = hideStackFrames((errors, message, code) => {
const err = new AggregateError(errors, message);
err.code = errors[0]?.code;
return err;
});

// Lazily loaded
let util;
let assert;
Expand Down Expand Up @@ -893,6 +899,7 @@ function determineSpecificType(value) {
module.exports = {
AbortError,
aggregateTwoErrors,
aggregateErrors,
captureLargerStackTrace,
codes,
connResetException,
Expand Down
182 changes: 181 additions & 1 deletion lib/net.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

'use strict';

const {
const {
ArrayIsArray,
ArrayPrototypeIndexOf,
Boolean,
Expand Down Expand Up @@ -96,6 +96,7 @@ const {
ERR_SOCKET_CLOSED,
ERR_MISSING_ARGS,
},
aggregateErrors,
errnoException,
exceptionWithHostPort,
genericNodeError,
Expand Down Expand Up @@ -1042,6 +1043,76 @@ function internalConnect(
}


function internalConnectMultiple(
self, addresses, port, localPort, flags
) {
assert(self.connecting);

const context = {
errors: [],
connecting: 0,
completed: false
};

const oncomplete = afterConnectMultiple.bind(self, context);

for (const { address, family: addressType } of addresses) {
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/happy eyeballs: binding to localAddress: %s and localPort: %d (addressType: %d)',
localAddress, localPort, addressType);

err = checkBindError(err, localPort, handle);
if (err) {
context.errors.push(exceptionWithHostPort(err, 'bind', localAddress, localPort));
continue;
}
}

const req = new TCPConnectWrap();
req.oncomplete = oncomplete;
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;
}

context.errors.push(exceptionWithHostPort(err, 'connect', address, port, details));
} else {
context.connecting++;
}
}

if (context.errors.length && context.connecting === 0) {
self.destroy(aggregateErrors(context.error));
}
}

Socket.prototype.connect = function(...args) {
let normalized;
// If passed an array, it's treated as an array of arguments that have
Expand Down Expand Up @@ -1166,6 +1237,64 @@ 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 && options.autoDetectFamily) {
debug('connect: autodetecting family via happy eyeballs');

dnsopts.all = true;

defaultTriggerAsyncIdScope(self[async_id_symbol], function() {
lookup(host, dnsopts, function emitLookup(err, addresses) {
const validAddresses = [];

// Gather all the addresses we can use for happy eyeballs
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)) {
validAddresses.push(address);
}
}

// 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;
}

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);
} else {
self._unrefTimer();
defaultTriggerAsyncIdScope(
self[async_id_symbol],
internalConnectMultiple,
self, validAddresses, port, localPort
);
}
});
});

return;
}

defaultTriggerAsyncIdScope(self[async_id_symbol], function() {
lookup(host, dnsopts, function emitLookup(err, ip, addressType) {
self.emit('lookup', err, ip, addressType, host);
Expand Down Expand Up @@ -1294,6 +1423,57 @@ function afterConnect(status, handle, req, readable, writable) {
}
}

function afterConnectMultiple(context, status, handle, req, readable, writable) {
context.connecting--;

// 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;
}

context.errors.push(ex);

if (context.connecting === 0) {
this.destroy(aggregateErrors(context.errors));
}

return;
}

// One of the connection has completed and correctly dispatched, ignore this one
if (context.completed) {
debug('connect/happy eyeballs: ignoring successful connection to %s:%s', req.address, req.port);
handle.close();
return;
}

// Mark the connection as successful
context.completed = true;
this._handle = handle;
initSocketHandle(this);

if (hasObserver('net')) {
startPerf(
this,
kPerfHooksNetConnectContext,
{ type: 'net', name: 'connect', detail: { host: req.address, port: req.port } }
);
}

afterConnect(status, handle, req, readable, writable);
}

function addAbortSignalOption(self, options) {
if (options?.signal === undefined) {
return;
Expand Down
124 changes: 124 additions & 0 deletions test/parallel/test-http-happy-eyeballs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
'use strict';

const common = require('../common');
const assert = require('assert');
const { parseDNSPacket, writeDNSPacket } = require('../common/dns');
const dgram = require('dgram');
const { Resolver } = require('dns');
const { request, createServer } = require('http');

// Test that happy eyeballs algorithm is properly implemented when using HTTP.
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((req, res) => {
res.writeHead(200);
res.end('response-ipv4');
}));

ipv4Server.listen(0, '0.0.0.0', common.mustCall(() => {
request(`http://example.org:${ipv4Server.address().port}/`, { lookup, autoDetectFamily: true }, (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
{
createDnsServer('::1', '127.0.0.1', common.mustCall(function({ dnsServer, lookup }) {
const ipv4Server = createServer(common.mustNotCall((req, res) => {
res.writeHead(200);
res.end('response-ipv4');
}));

const ipv6Server = createServer(common.mustCall((req, res) => {
res.writeHead(200);
res.end('response-ipv6');
}));

ipv4Server.listen(0, '0.0.0.0', common.mustCall(() => {
const port = ipv4Server.address().port;

ipv6Server.listen(port, '::', common.mustCall(() => {
request(`http://example.org:${ipv4Server.address().port}/`, { lookup, autoDetectFamily: true }, (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();
}));
}));
}));
}
Loading

0 comments on commit 5a35dae

Please sign in to comment.