Skip to content

Commit

Permalink
net: add connection attempt events
Browse files Browse the repository at this point in the history
PR-URL: #51045
Fixes: #48763
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Marco Ippolito <marcoippolito54@gmail.com>
  • Loading branch information
ShogunPanda authored and richardlau committed Mar 25, 2024
1 parent cb3270e commit 58a636b
Show file tree
Hide file tree
Showing 10 changed files with 549 additions and 514 deletions.
44 changes: 42 additions & 2 deletions doc/api/net.md
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,47 @@ added: v0.1.90
Emitted when a socket connection is successfully established.
See [`net.createConnection()`][].

### Event: `'connectionAttempt'`

<!-- YAML
added: REPLACEME
-->

* `ip` {number} The IP which the socket is attempting to connect to.
* `port` {number} The port which the socket is attempting to connect to.
* `family` {number} The family of the IP. It can be `6` for IPv6 or `4` for IPv4.

Emitted when a new connection attempt is started. This may be emitted multiple times
if the family autoselection algorithm is enabled in [`socket.connect(options)`][].

### Event: `'connectionAttemptFailed'`

<!-- YAML
added: REPLACEME
-->

* `ip` {number} The IP which the socket attempted to connect to.
* `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.
\* `error` {Error} The error associated with the failure.

Emitted when a connection attempt failed. This may be emitted multiple times
if the family autoselection algorithm is enabled in [`socket.connect(options)`][].

### Event: `'connectionAttemptTimeout'`

<!-- YAML
added: REPLACEME
-->

* `ip` {number} The IP which the socket attempted to connect to.
* `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)`][].

### Event: `'data'`

<!-- YAML
Expand Down Expand Up @@ -952,8 +993,7 @@ For TCP connections, available `options` are:
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 is given the amount of time specified by the `autoSelectFamilyAttemptTimeout`
option before timing out and trying the next address.
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 `AggregateError` with all failed attempts is emitted.
Expand Down
31 changes: 26 additions & 5 deletions lib/net.js
Original file line number Diff line number Diff line change
Expand Up @@ -1058,6 +1058,7 @@ function internalConnect(
}

debug('connect: attempting to connect to %s:%d (addressType: %d)', address, port, addressType);
self.emit('connectionAttempt', address, port, addressType);

if (addressType === 6 || addressType === 4) {
const req = new TCPConnectWrap();
Expand All @@ -1066,6 +1067,7 @@ function internalConnect(
req.port = port;
req.localAddress = localAddress;
req.localPort = localPort;
req.addressType = addressType;

if (addressType === 4)
err = self._handle.connect(req, address, port);
Expand Down Expand Up @@ -1149,13 +1151,15 @@ function internalConnectMultiple(context, canceled) {
}

debug('connect/multiple: attempting to connect to %s:%d (addressType: %d)', address, port, addressType);
self.emit('connectionAttempt', address, port, addressType);

const req = new TCPConnectWrap();
req.oncomplete = FunctionPrototypeBind(afterConnectMultiple, undefined, context, current);
req.address = address;
req.port = port;
req.localAddress = localAddress;
req.localPort = localPort;
req.addressType = addressType;

ArrayPrototypePush(self.autoSelectFamilyAttemptedAddresses, `${address}:${port}`);

Expand All @@ -1173,7 +1177,10 @@ function internalConnectMultiple(context, canceled) {
details = sockname.address + ':' + sockname.port;
}

ArrayPrototypePush(context.errors, new ExceptionWithHostPort(err, 'connect', address, port, details));
const ex = new ExceptionWithHostPort(err, 'connect', address, port, details);
ArrayPrototypePush(context.errors, ex);

self.emit('connectionAttemptFailed', address, port, addressType, ex);
internalConnectMultiple(context);
return;
}
Expand Down Expand Up @@ -1601,6 +1608,8 @@ function afterConnect(status, handle, req, readable, writable) {
ex.localAddress = req.localAddress;
ex.localPort = req.localPort;
}

self.emit('connectionAttemptFailed', req.address, req.port, req.addressType, ex);
self.destroy(ex);
}
}
Expand Down Expand Up @@ -1661,10 +1670,16 @@ function afterConnectMultiple(context, current, status, handle, req, readable, w

// Some error occurred, add to the list of exceptions
if (status !== 0) {
ArrayPrototypePush(context.errors, createConnectionError(req, status));
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);
}

// Try the next address
internalConnectMultiple(context, status === UV_ECANCELED);
return;
}

Expand All @@ -1681,10 +1696,16 @@ function afterConnectMultiple(context, current, status, handle, req, readable, w

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();
internalConnectMultiple(context);

// Try the next address, unless we were aborted
if (context.socket.connecting) {
internalConnectMultiple(context);
}
}

function addServerAbortSignalOption(self, options) {
Expand Down
21 changes: 21 additions & 0 deletions test/common/dns.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const assert = require('assert');
const os = require('os');
const { isIP } = require('net');

const types = {
A: 1,
Expand Down Expand Up @@ -309,6 +310,25 @@ function errorLookupMock(code = mockedErrorCode, syscall = mockedSysCall) {
};
}

function createMockedLookup(...addresses) {
addresses = addresses.map((address) => ({ address: address, family: isIP(address) }));

// Create a DNS server which replies with a AAAA and a A record for the same host
return function lookup(hostname, options, cb) {
if (options.all === true) {
process.nextTick(() => {
cb(null, addresses);
});

return;
}

process.nextTick(() => {
cb(null, addresses[0].address, addresses[0].family);
});
};
}

module.exports = {
types,
classes,
Expand All @@ -317,4 +337,5 @@ module.exports = {
errorLookupMock,
mockedErrorCode,
mockedSysCall,
createMockedLookup,
};
128 changes: 128 additions & 0 deletions test/internet/test-net-autoselectfamily-events-failure.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
'use strict';

const common = require('../common');
const { addresses: { INET6_IP, INET4_IP } } = require('../common/internet');
const { createMockedLookup } = require('../common/dns');

const assert = require('assert');
const { createConnection } = require('net');

//
// When testing this is MacOS, remember that the last connection will have no timeout at Node.js
// level but only at operating system one.
//
// The default for MacOS is 75 seconds. It can be changed by doing:
//
// sudo sysctl net.inet.tcp.keepinit=VALUE_IN_MS
//

// Test that all failure events are emitted when trying a single IP (which means autoselectfamily is bypassed)
{
const pass = common.mustCallAtLeast(1);

const connection = createConnection({
host: 'example.org',
port: 10,
lookup: createMockedLookup(INET4_IP),
autoSelectFamily: true,
autoSelectFamilyAttemptTimeout: 10,
});

connection.on('connectionAttempt', (address, port, family) => {
assert.strictEqual(address, INET4_IP);
assert.strictEqual(port, 10);
assert.strictEqual(family, 4);

pass();
});

connection.on('connectionAttemptFailed', (address, port, family, error) => {
assert.strictEqual(address, INET4_IP);
assert.strictEqual(port, 10);
assert.strictEqual(family, 4);

assert.ok(
error.code.match(/ECONNREFUSED|ENETUNREACH|EHOSTUNREACH|ETIMEDOUT/),
`Received unexpected error code ${error.code}`,
);

pass();
});

connection.on('ready', () => {
pass();
connection.destroy();
});

connection.on('error', () => {
pass();
connection.destroy();
});

setTimeout(() => {
pass();
process.exit(0);
}, 5000).unref();

}

// Test that all events are emitted when trying multiple IPs
{
const pass = common.mustCallAtLeast(1);

const connection = createConnection({
host: 'example.org',
port: 10,
lookup: createMockedLookup(INET6_IP, INET4_IP),
autoSelectFamily: true,
autoSelectFamilyAttemptTimeout: 10,
});

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 },
];

connection.on('connectionAttempt', (address, port, family) => {
const expected = addresses.shift();

assert.strictEqual(address, expected.address);
assert.strictEqual(port, expected.port);
assert.strictEqual(family, expected.family);

pass();
});

connection.on('connectionAttemptFailed', (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(
error.code.match(/ECONNREFUSED|ENETUNREACH|EHOSTUNREACH|ETIMEDOUT/),
`Received unexpected error code ${error.code}`,
);

pass();
});

connection.on('ready', () => {
pass();
connection.destroy();
});

connection.on('error', () => {
pass();
connection.destroy();
});

setTimeout(() => {
pass();
process.exit(0);
}, 5000).unref();

}
54 changes: 54 additions & 0 deletions test/internet/test-net-autoselectfamily-events-timeout.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
'use strict';

const common = require('../common');
const { addresses: { INET6_IP, INET4_IP } } = require('../common/internet');
const { createMockedLookup } = require('../common/dns');

const assert = require('assert');
const { createConnection } = require('net');

//
// When testing this is MacOS, remember that the last connection will have no timeout at Node.js
// level but only at operating system one.
//
// The default for MacOS is 75 seconds. It can be changed by doing:
//
// sudo sysctl net.inet.tcp.keepinit=VALUE_IN_MS
//
// Depending on the network, it might be impossible to obtain a timeout in 10ms,
// which is the minimum value allowed by network family autoselection.
// At the time of writing (Dec 2023), the network times out on local machine and in the Node CI,
// but it does not on GitHub actions runner.
// Therefore, after five seconds we just consider this test as passed.

// Test that if a connection attempt times out and the socket is destroyed before the
// next attempt starts then the process does not crash
{
const connection = createConnection({
host: 'example.org',
port: 443,
lookup: createMockedLookup(INET4_IP, INET6_IP),
autoSelectFamily: true,
autoSelectFamilyAttemptTimeout: 10,
});

const pass = common.mustCall();

connection.on('connectionAttemptTimeout', (address, port, family) => {
assert.strictEqual(address, INET4_IP);
assert.strictEqual(port, 443);
assert.strictEqual(family, 4);
connection.destroy();
pass();
});

connection.on('ready', () => {
pass();
connection.destroy();
});

setTimeout(() => {
pass();
process.exit(0);
}, 5000).unref();
}
9 changes: 9 additions & 0 deletions test/internet/test-net-autoselectfamily-timeout-close.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ const { addresses } = require('../common/internet');
const assert = require('assert');
const { connect } = require('net');

//
// When testing this is MacOS, remember that the last connection will have no timeout at Node.js
// level but only at operating system one.
//
// The default for MacOS is 75 seconds. It can be changed by doing:
//
// sudo sysctl net.inet.tcp.keepinit=VALUE_IN_MS
//

// Test that when all errors are returned when no connections succeeded and that the close event is emitted
{
const connection = connect({
Expand Down
Loading

0 comments on commit 58a636b

Please sign in to comment.