Skip to content

Commit 1111c41

Browse files
committed
net: ignore stale lookup callbacks after reconnect
A DNS lookup started before Socket.destroy() can complete after the same Socket has been reconnected. Since reconnecting sets `connecting` back to true, the stale lookup callback can act on the new connection lifecycle and start another connect on its handle. Track the socket connection generation across destroy boundaries and ignore stale lookup and autoSelectFamily callbacks from an earlier generation. Signed-off-by: Hamed Elaraby <qef0@hotmail.com>
1 parent 2fbd056 commit 1111c41

2 files changed

Lines changed: 159 additions & 3 deletions

File tree

lib/net.js

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,13 @@ function closeSocketHandle(self, isException, isCleanupPending = false) {
376376
const kBytesRead = Symbol('kBytesRead');
377377
const kBytesWritten = Symbol('kBytesWritten');
378378
const kSetTOS = Symbol('kSetTOS');
379+
// Generation token for a Socket's connection lifecycle. Async work started by
380+
// a connect() attempt (e.g. a DNS lookup) captures the current generation; the
381+
// generation is advanced once per lifecycle, in _destroy, so stale async work
382+
// from a destroyed lifecycle cannot drive a newer lifecycle established by a
383+
// later connect() (e.g. reconnect after 'close'). Multiple connect() calls
384+
// within one undestroyed lifecycle share a generation.
385+
const kConnectGeneration = Symbol('kConnectGeneration');
379386
// Marks a Socket whose handle is an adopted, already-bound BoundSocket.
380387
const kBoundSource = Symbol('kBoundSource');
381388

@@ -1100,6 +1107,9 @@ Socket.prototype._destroy = function(exception, cb) {
11001107
debug('destroy');
11011108

11021109
this.connecting = false;
1110+
// The connection lifecycle ends here; invalidate async work (e.g. a DNS
1111+
// lookup) still in flight so it cannot drive a newer lifecycle.
1112+
this[kConnectGeneration] = (this[kConnectGeneration] || 0) + 1;
11031113

11041114
for (let s = this; s !== null; s = s._parent) {
11051115
clearTimeout(s[kTimeout]);
@@ -1415,6 +1425,13 @@ function internalConnectMultiple(context, canceled) {
14151425
clearTimeout(context[kTimeout]);
14161426
const self = context.socket;
14171427

1428+
// The connection lifecycle that created this context may have been destroyed
1429+
// and a newer connect() established; a stale context must not drive the new
1430+
// lifecycle's handle.
1431+
if (self[kConnectGeneration] !== context.connectGeneration) {
1432+
return;
1433+
}
1434+
14181435
// We were requested to abort. Stop all operations
14191436
if (self._aborted) {
14201437
return;
@@ -1778,14 +1795,18 @@ function lookupAndConnect(self, options) {
17781795
return;
17791796
}
17801797

1798+
const connectGeneration = self[kConnectGeneration];
1799+
17811800
defaultTriggerAsyncIdScope(self[async_id_symbol], function() {
17821801
lookup(host, dnsopts, function emitLookup(err, ip, addressType) {
17831802
self.emit('lookup', err, ip, addressType, host);
17841803

17851804
// It's possible we were destroyed while looking this up.
17861805
// XXX it would be great if we could cancel the promise returned by
17871806
// the look up.
1788-
if (!self.connecting) return;
1807+
// A stale callback may also arrive after the attempt was destroyed and a
1808+
// newer connect() attempt was started; it must not drive the new attempt.
1809+
if (!self.connecting || self[kConnectGeneration] !== connectGeneration) return;
17891810

17901811
if (err) {
17911812
// net.createConnection() creates a net.Socket object and immediately
@@ -1815,12 +1836,16 @@ function lookupAndConnect(self, options) {
18151836
function lookupAndConnectMultiple(
18161837
self, async_id_symbol, lookup, host, options, dnsopts, port, localAddress, localPort, timeout,
18171838
) {
1839+
const connectGeneration = self[kConnectGeneration];
1840+
18181841
defaultTriggerAsyncIdScope(self[async_id_symbol], function emitLookup() {
18191842
lookup(host, dnsopts, function emitLookup(err, addresses) {
18201843
// It's possible we were destroyed while looking this up.
18211844
// XXX it would be great if we could cancel the promise returned by
18221845
// the look up.
1823-
if (!self.connecting) {
1846+
// A stale callback may also arrive after the attempt was destroyed and a
1847+
// newer connect() attempt was started; it must not drive the new attempt.
1848+
if (!self.connecting || self[kConnectGeneration] !== connectGeneration) {
18241849
return;
18251850
} else if (err) {
18261851
self.emit('lookup', err, undefined, undefined, host);
@@ -1843,7 +1868,7 @@ function lookupAndConnectMultiple(
18431868
const { address: ip, family: addressType } = address;
18441869
self.emit('lookup', err, ip, addressType, host);
18451870
// It's possible we were destroyed while looking this up.
1846-
if (!self.connecting) {
1871+
if (!self.connecting || self[kConnectGeneration] !== connectGeneration) {
18471872
return;
18481873
}
18491874
if (isIP(ip) && (addressType === 4 || addressType === 6)) {
@@ -1914,6 +1939,7 @@ function lookupAndConnectMultiple(
19141939
socket: self,
19151940
addresses: toAttempt,
19161941
current: 0,
1942+
connectGeneration,
19171943
port,
19181944
localPort,
19191945
timeout,
@@ -2087,6 +2113,14 @@ function afterConnectMultiple(context, current, status, handle, req, readable, w
20872113

20882114
const self = context.socket;
20892115

2116+
// The connection lifecycle that created this context may have been destroyed
2117+
// and a newer connect() established; drop the stale completion, closing the
2118+
// completed handle so it cannot leak.
2119+
if (self[kConnectGeneration] !== context.connectGeneration) {
2120+
handle.close();
2121+
return;
2122+
}
2123+
20902124
// Some error occurred, add to the list of exceptions
20912125
if (status !== 0) {
20922126
const ex = createConnectionError(req, status);
@@ -2115,6 +2149,14 @@ function afterConnectMultiple(context, current, status, handle, req, readable, w
21152149

21162150
function internalConnectMultipleTimeout(context, req, handle) {
21172151
debug('connect/multiple: connection to %s:%s timed out', req.address, req.port);
2152+
2153+
// The connection lifecycle that created this context may have been destroyed
2154+
// and a newer connect() established; drop the stale timeout so it cannot
2155+
// emit a spurious connectionAttemptTimeout or drive further attempts.
2156+
if (context.socket[kConnectGeneration] !== context.connectGeneration) {
2157+
return;
2158+
}
2159+
21182160
context.socket.emit('connectionAttemptTimeout', req.address, req.port, req.addressType);
21192161

21202162
req.oncomplete = undefined;
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
const assert = require('assert');
5+
const net = require('net');
6+
7+
// Regression test for https://github.com/nodejs/node/issues/55519.
8+
//
9+
// Destroying a socket while its DNS lookup is still in flight and then
10+
// reconnecting on 'close' must not let the stale lookup callback from the
11+
// destroyed attempt drive internalConnect on the new attempt's handle. Doing
12+
// so would issue a second connect on the same handle and fail the connection
13+
// with EALREADY (EINVAL on Windows).
14+
//
15+
// The flow is driven deterministically: the custom lookup captures the
16+
// callbacks of both attempts and the stale (attempt 1) callback is invoked
17+
// before the callback of the current (attempt 2) attempt.
18+
19+
const cases = [
20+
{ autoSelectFamily: false },
21+
{ autoSelectFamily: true },
22+
];
23+
24+
function runCase(options, done) {
25+
let connected = false;
26+
let accepted = false;
27+
28+
const watchdog = setTimeout(() => {
29+
console.error(`test-case timed out: ${JSON.stringify(options)}`);
30+
process.exit(1);
31+
}, 10_000);
32+
33+
const socket = new net.Socket();
34+
35+
const finish = common.mustCall(() => {
36+
clearTimeout(watchdog);
37+
socket.destroy();
38+
server.close();
39+
done();
40+
});
41+
42+
// Teardown only after both the client has connected and the server has
43+
// accepted the connection, so the accept callback is never dropped by an
44+
// early server.close().
45+
const server = net.createServer(common.mustCall(() => {
46+
accepted = true;
47+
if (connected) {
48+
finish();
49+
}
50+
}));
51+
52+
server.listen(0, common.localhostIPv4, common.mustCall(() => {
53+
const port = server.address().port;
54+
const lookupCalls = [];
55+
let reconnected = false;
56+
let connectionAttempts = 0;
57+
58+
function lookup(host, dnsopts, cb) {
59+
lookupCalls.push({ dnsopts, cb });
60+
}
61+
62+
socket.on('connectionAttempt', () => {
63+
connectionAttempts++;
64+
});
65+
66+
socket.on('connect', common.mustCall(() => {
67+
// Only the current (second) attempt may have connected: the stale
68+
// callback must not have started a connect on the new attempt's handle.
69+
assert.strictEqual(connectionAttempts, 1);
70+
connected = true;
71+
if (accepted) {
72+
finish();
73+
}
74+
}));
75+
76+
socket.on('error', common.mustNotCall());
77+
78+
socket.on('close', common.mustCallAtLeast(() => {
79+
if (reconnected) {
80+
return;
81+
}
82+
reconnected = true;
83+
// Start a new connection attempt; the lookup callback of the first
84+
// attempt is still pending.
85+
socket.connect({ host: 'host.example', port, lookup, ...options });
86+
assert.strictEqual(lookupCalls.length, 2);
87+
88+
const fire = (call) => {
89+
if (call.dnsopts.all === true) {
90+
call.cb(null, [{ address: common.localhostIPv4, family: 4 }]);
91+
} else {
92+
call.cb(null, common.localhostIPv4, 4);
93+
}
94+
};
95+
// Invoke the stale callback first, then the current attempt's callback.
96+
// The stale callback must be ignored.
97+
fire(lookupCalls[0]);
98+
fire(lookupCalls[1]);
99+
}));
100+
101+
socket.connect({ host: 'host.example', port, lookup, ...options });
102+
socket.destroy();
103+
}));
104+
}
105+
106+
let index = 0;
107+
function next() {
108+
if (index >= cases.length) {
109+
return;
110+
}
111+
runCase(cases[index++], next);
112+
}
113+
114+
next();

0 commit comments

Comments
 (0)