apache-iggy 0.10.0 → 0.10.1-edge.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/README.md +248 -28
  2. package/dist/client/client.config.d.ts +2 -0
  3. package/dist/client/client.config.js +13 -2
  4. package/dist/client/client.config.test.js +15 -2
  5. package/dist/client/client.connection-string.js +13 -1
  6. package/dist/client/client.connection-string.test.js +43 -1
  7. package/dist/client/client.connection.d.ts +33 -135
  8. package/dist/client/client.connection.js +190 -453
  9. package/dist/client/client.connection.test.js +244 -669
  10. package/dist/client/client.d.ts +4 -35
  11. package/dist/client/client.js +8 -101
  12. package/dist/client/client.operation.d.ts +120 -0
  13. package/dist/client/client.operation.js +428 -0
  14. package/dist/client/client.operation.test.d.ts +2 -0
  15. package/dist/client/client.operation.test.js +285 -0
  16. package/dist/client/client.socket.d.ts +92 -209
  17. package/dist/client/client.socket.js +1205 -981
  18. package/dist/client/client.socket.test.js +4865 -679
  19. package/dist/client/client.type.d.ts +65 -22
  20. package/dist/client/index.d.ts +1 -0
  21. package/dist/client/index.js +1 -0
  22. package/dist/client.e2e.js +3 -5
  23. package/dist/e2e/resource-options.e2e.d.ts +2 -0
  24. package/dist/{wire/stream/purge-stream.command.js → e2e/resource-options.e2e.js} +12 -20
  25. package/dist/e2e/resource-options.suite.d.ts +15 -0
  26. package/dist/e2e/resource-options.suite.js +272 -0
  27. package/dist/e2e/tcp.cluster.e2e.js +1 -0
  28. package/dist/e2e/tcp.consumer-stream.e2e.js +4 -0
  29. package/dist/e2e/tcp.parallel.e2e.js +5 -6
  30. package/dist/e2e/tcp.stream.e2e.js +0 -3
  31. package/dist/e2e/tcp.system.e2e.js +10 -1
  32. package/dist/e2e/tcp.token.e2e.js +8 -2
  33. package/dist/e2e/tcp.topic.e2e.js +0 -3
  34. package/dist/e2e/tcp.user.e2e.js +46 -3
  35. package/dist/e2e/tls.system.e2e.js +21 -12
  36. package/dist/index.d.ts +2 -1
  37. package/dist/index.js +1 -1
  38. package/dist/stream/consumer-stream.js +97 -12
  39. package/dist/wire/cluster/cluster.type.d.ts +2 -2
  40. package/dist/wire/cluster/cluster.utils.d.ts +1 -0
  41. package/dist/wire/cluster/cluster.utils.js +4 -1
  42. package/dist/wire/command-set.d.ts +8 -8
  43. package/dist/wire/command-set.js +18 -9
  44. package/dist/wire/command.code.d.ts +3 -10
  45. package/dist/wire/command.code.js +9 -11
  46. package/dist/wire/command.utils.d.ts +3 -2
  47. package/dist/wire/command.utils.js +3 -2
  48. package/dist/wire/error.code.js +17 -1
  49. package/dist/wire/error.code.test.js +28 -1
  50. package/dist/wire/error.utils.d.ts +4 -2
  51. package/dist/wire/error.utils.js +6 -3
  52. package/dist/wire/identifier.utils.d.ts +2 -0
  53. package/dist/wire/identifier.utils.js +2 -0
  54. package/dist/wire/index.d.ts +1 -0
  55. package/dist/wire/index.js +1 -0
  56. package/dist/wire/message/header.type.d.ts +1 -1
  57. package/dist/wire/message/message-batch.test.js +11 -5
  58. package/dist/wire/message/message.utils.d.ts +0 -10
  59. package/dist/wire/message/message.utils.js +1 -22
  60. package/dist/wire/message/poll-messages.command.d.ts +12 -1
  61. package/dist/wire/message/poll-messages.command.js +64 -16
  62. package/dist/wire/message/poll-messages.command.test.js +143 -5
  63. package/dist/wire/message/poll.utils.d.ts +4 -0
  64. package/dist/wire/message/poll.utils.js +6 -1
  65. package/dist/wire/message/send-messages.command.d.ts +3 -3
  66. package/dist/wire/message/send-messages.command.js +103 -3
  67. package/dist/wire/message/send-messages.command.test.js +113 -2
  68. package/dist/wire/offset/delete-offset.command.d.ts +11 -4
  69. package/dist/wire/offset/delete-offset.command.js +1 -1
  70. package/dist/wire/offset/get-offset.command.d.ts +1 -1
  71. package/dist/wire/offset/offset-write.command.test.d.ts +2 -0
  72. package/dist/wire/offset/offset-write.command.test.js +43 -0
  73. package/dist/wire/offset/offset.utils.js +10 -6
  74. package/dist/wire/offset/store-offset.command.d.ts +10 -0
  75. package/dist/wire/offset/store-offset.command.js +1 -1
  76. package/dist/wire/options.utils.d.ts +46 -0
  77. package/dist/wire/options.utils.js +57 -0
  78. package/dist/wire/partition/partition-route.d.ts +12 -0
  79. package/dist/wire/partition/partition-route.js +40 -0
  80. package/dist/wire/partition/partition-route.test.d.ts +2 -0
  81. package/dist/wire/partition/partition-route.test.js +42 -0
  82. package/dist/wire/session/login-with-token.command.d.ts +2 -2
  83. package/dist/wire/session/login-with-token.command.js +7 -2
  84. package/dist/wire/session/login.command.d.ts +2 -2
  85. package/dist/wire/session/login.command.js +7 -2
  86. package/dist/wire/stream/create-stream.command.d.ts +5 -2
  87. package/dist/wire/stream/create-stream.command.js +20 -2
  88. package/dist/wire/stream/index.d.ts +1 -1
  89. package/dist/wire/stream/index.js +1 -1
  90. package/dist/wire/stream/stream-options.d.ts +77 -0
  91. package/dist/wire/stream/stream-options.js +101 -0
  92. package/dist/wire/stream/stream-options.test.d.ts +2 -0
  93. package/dist/wire/stream/stream-options.test.js +103 -0
  94. package/dist/wire/stream/stream.utils.d.ts +9 -0
  95. package/dist/wire/stream/stream.utils.js +17 -3
  96. package/dist/wire/stream/update-stream.command.d.ts +23 -5
  97. package/dist/wire/stream/update-stream.command.js +10 -17
  98. package/dist/wire/system/describe-options.command.d.ts +23 -6
  99. package/dist/wire/system/describe-options.command.js +21 -2
  100. package/dist/wire/system/get-stats.command.d.ts +17 -0
  101. package/dist/wire/system/get-stats.command.js +69 -16
  102. package/dist/wire/system/get-stats.command.test.d.ts +2 -0
  103. package/dist/wire/system/get-stats.command.test.js +97 -0
  104. package/dist/wire/system/index.d.ts +2 -0
  105. package/dist/wire/system/index.js +2 -0
  106. package/dist/wire/system/snapshot.command.d.ts +64 -0
  107. package/dist/wire/system/snapshot.command.js +76 -0
  108. package/dist/wire/system/snapshot.command.test.d.ts +2 -0
  109. package/dist/wire/system/snapshot.command.test.js +86 -0
  110. package/dist/wire/system/update-resource.command.d.ts +53 -0
  111. package/dist/wire/system/update-resource.command.js +130 -0
  112. package/dist/wire/system/update-resource.command.test.d.ts +2 -0
  113. package/dist/wire/system/update-resource.command.test.js +497 -0
  114. package/dist/wire/token/create-token.command.d.ts +1 -1
  115. package/dist/wire/token/create-token.command.js +1 -1
  116. package/dist/wire/token/create-token.command.test.js +6 -0
  117. package/dist/wire/topic/create-topic.command.d.ts +20 -12
  118. package/dist/wire/topic/create-topic.command.js +11 -4
  119. package/dist/wire/topic/index.d.ts +1 -2
  120. package/dist/wire/topic/index.js +1 -2
  121. package/dist/wire/topic/topic.utils.d.ts +14 -5
  122. package/dist/wire/topic/topic.utils.js +26 -11
  123. package/dist/wire/topic/topic.utils.test.js +13 -13
  124. package/dist/wire/topic/update-topic.command.d.ts +22 -15
  125. package/dist/wire/topic/update-topic.command.js +12 -34
  126. package/dist/wire/user/create-user.command.d.ts +12 -2
  127. package/dist/wire/user/create-user.command.js +7 -3
  128. package/dist/wire/user/index.d.ts +1 -0
  129. package/dist/wire/user/index.js +1 -0
  130. package/dist/wire/user/update-user.command.d.ts +25 -7
  131. package/dist/wire/user/update-user.command.js +18 -31
  132. package/dist/wire/user/user.utils.d.ts +2 -0
  133. package/dist/wire/user/user.utils.js +5 -3
  134. package/dist/wire/vsr/header.d.ts +23 -8
  135. package/dist/wire/vsr/header.js +32 -10
  136. package/dist/wire/vsr/header.test.js +5 -1
  137. package/dist/wire/vsr/index.d.ts +8 -2
  138. package/dist/wire/vsr/index.js +44 -11
  139. package/dist/wire/vsr/operation.d.ts +18 -6
  140. package/dist/wire/vsr/operation.js +46 -16
  141. package/dist/wire/vsr/operation.test.js +32 -8
  142. package/dist/wire/vsr/register.d.ts +10 -6
  143. package/dist/wire/vsr/register.js +24 -7
  144. package/dist/wire/vsr/register.test.js +28 -8
  145. package/dist/wire/vsr/reply.d.ts +4 -1
  146. package/dist/wire/vsr/reply.js +42 -7
  147. package/dist/wire/vsr/reply.test.js +11 -1
  148. package/dist/wire/vsr/session.d.ts +9 -10
  149. package/dist/wire/vsr/session.js +32 -23
  150. package/dist/wire/vsr/session.test.js +33 -7
  151. package/dist/wire/vsr/vsr.test.js +57 -6
  152. package/package.json +6 -7
  153. package/dist/wire/message/flush-unsaved-buffers.command.d.ts +0 -26
  154. package/dist/wire/message/flush-unsaved-buffers.command.js +0 -36
  155. package/dist/wire/stream/purge-stream.command.d.ts +0 -22
  156. package/dist/wire/topic/purge-topic.command.d.ts +0 -24
  157. package/dist/wire/topic/purge-topic.command.js +0 -39
@@ -17,780 +17,355 @@
17
17
  import assert from 'node:assert/strict';
18
18
  import { once } from 'node:events';
19
19
  import { readFileSync } from 'node:fs';
20
- import { createServer } from 'node:net';
20
+ import { createServer, Socket } from 'node:net';
21
21
  import { createServer as createTlsServer } from 'node:tls';
22
- import { describe, it, before, after } from 'node:test';
23
- import { ProtocolFrameError } from './client.frame.js';
24
- import { IggyConnection } from './client.connection.js';
25
- import { Command, HEADER_SIZE, REPLY_OFFSET } from '../wire/vsr/header.js';
26
- const FRAME_LIMIT = 2 * HEADER_SIZE;
22
+ import { describe, it } from 'node:test';
23
+ import { IggyConnection, LINK_WRITE_QUEUE, MAX_PENDING_PER_LINK } from './client.connection.js';
24
+ import { ResponseFrameDecoder, ProtocolFrameError } from './client.frame.js';
25
+ import { Command, HEADER_SIZE, REPLY_OFFSET, REQUEST_OFFSET, encodeRequestHeader } from '../wire/vsr/header.js';
26
+ import { Operation } from '../wire/vsr/operation.js';
27
+ import { FrozenFrame, OPERATION_TIMEOUT_MS, VsrResponseTimeoutError } from './client.operation.js';
28
+ import { ConnectionClosedError } from '../index.js';
27
29
  const TLS_CERTIFICATE = readFileSync(new URL('../../../../core/certs/iggy_cert.pem', import.meta.url));
28
30
  const TLS_KEY = readFileSync(new URL('../../../../core/certs/iggy_key.pem', import.meta.url));
29
31
  const TLS_CA_CERTIFICATE = readFileSync(new URL('../../../../core/certs/iggy_ca_cert.pem', import.meta.url));
30
- const startTlsServer = async () => {
31
- const server = createTlsServer({
32
- cert: TLS_CERTIFICATE,
33
- key: TLS_KEY
34
- });
35
- server.listen(0, '127.0.0.1');
36
- await once(server, 'listening');
37
- return server;
32
+ const FRAME_LIMIT = 2 * HEADER_SIZE;
33
+ const connectionConfig = (server) => ({
34
+ transport: 'TCP', options: { host: '127.0.0.1', port: server.address().port },
35
+ credentials: { username: 'iggy', password: 'iggy' }, heartbeatInterval: 0,
36
+ reconnect: { enabled: false, interval: 0, maxRetries: 0 }, maxResponseFrameSize: FRAME_LIMIT
37
+ });
38
+ const requestFrame = (id) => encodeRequestHeader({
39
+ size: HEADER_SIZE, client: 1n, request: id, operation: Operation.NonReplicated, session: 1n, nonReplicatedCode: 1
40
+ });
41
+ const replyFrame = (request, body = Buffer.alloc(0)) => {
42
+ const response = Buffer.alloc(HEADER_SIZE + body.length);
43
+ response.writeUInt32LE(response.length, REPLY_OFFSET.size);
44
+ response[REPLY_OFFSET.command] = Command.Reply;
45
+ response[REPLY_OFFSET.operation] = request[REQUEST_OFFSET.operation];
46
+ response.writeBigUInt64LE(request.readBigUInt64LE(REQUEST_OFFSET.request), REPLY_OFFSET.request);
47
+ body.copy(response, HEADER_SIZE);
48
+ return response;
38
49
  };
39
- const startServer = async () => {
40
- const server = createServer();
50
+ const startServer = async (handler, tls = false) => {
51
+ const accept = (socket) => {
52
+ const decoder = new ResponseFrameDecoder(FRAME_LIMIT);
53
+ socket.on('error', () => undefined);
54
+ socket.on('data', (bytes) => { if (!handler)
55
+ return; for (const frame of decoder.push(Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)))
56
+ handler?.(frame, socket); });
57
+ };
58
+ const server = tls ? createTlsServer({ cert: TLS_CERTIFICATE, key: TLS_KEY }, accept) : createServer(accept);
41
59
  server.listen(0, '127.0.0.1');
42
60
  await once(server, 'listening');
43
61
  return server;
44
62
  };
45
- const connectionConfig = (server) => ({
46
- transport: 'TCP',
47
- options: {
48
- host: '127.0.0.1',
49
- port: server.address().port
50
- },
51
- credentials: { username: 'iggy', password: 'iggy' },
52
- reconnect: { enabled: false, interval: 0, maxRetries: 0 },
53
- maxResponseFrameSize: FRAME_LIMIT
54
- });
55
- const replyFrame = (body) => {
56
- const frame = Buffer.alloc(HEADER_SIZE + body.length);
57
- frame.writeUInt32LE(frame.length, REPLY_OFFSET.size);
58
- frame.writeUInt8(Command.Reply, REPLY_OFFSET.command);
59
- body.copy(frame, HEADER_SIZE);
60
- return frame;
61
- };
62
63
  const closeConnection = async (connection, server) => {
63
64
  connection._destroy();
64
- if (!connection.socket.destroyed)
65
- await once(connection.socket, 'close');
66
65
  await new Promise((resolve) => server.close(() => resolve()));
67
66
  };
68
- let keepAlive;
69
- describe('IggyConnection', () => {
70
- // Note:
71
- // before node v24 Timeout.unref() would let eventloop exit before test end
72
- // (tested against 22.x 23.x -> fail vs 24.x 26.x -> pass)
73
- // this timeout prevent eventloop exit before this test end
74
- before(() => { keepAlive = setInterval(() => { }, 10000); });
75
- it('recognizes a connection established before connect is called', async () => {
76
- const server = await startServer();
77
- const accepted = once(server, 'connection');
78
- const connection = new IggyConnection(connectionConfig(server));
67
+ const exchange = (connection, id, signal = new AbortController().signal) => connection.exchange(() => requestFrame(id), Date.now() + 1_000, signal);
68
+ describe('IggyConnection multiplexing', () => {
69
+ it('matches normalized peer addresses and ports with a configured-host fallback', () => {
70
+ const host = 'bootstrap.example.test';
71
+ const configuredPort = 8090;
72
+ const peerPort = configuredPort + 1;
73
+ const connection = new IggyConnection({ transport: 'TCP', options: { host, port: configuredPort },
74
+ credentials: { username: 'iggy', password: 'iggy' } });
75
+ assert.equal(connection.isConnectedTo(host.toUpperCase(), configuredPort), true);
76
+ assert.equal(connection.isConnectedTo('192.0.2.10', peerPort), false);
77
+ connection.socket = new Socket();
78
+ Object.defineProperties(connection.socket, {
79
+ remoteAddress: { value: '::ffff:192.0.2.10' },
80
+ remotePort: { value: peerPort }
81
+ });
79
82
  try {
80
- await accepted;
81
- await new Promise((resolve) => setImmediate(resolve));
82
- assert.equal(await connection.connect(), connection);
83
- assert.equal(connection.connected, true);
83
+ assert.equal(connection.isConnectedTo('192.0.2.10', peerPort), true);
84
+ assert.equal(connection.isConnectedTo('::FFFF:192.0.2.10', peerPort), true);
85
+ assert.equal(connection.isConnectedTo('192.0.2.10', configuredPort), false);
86
+ assert.equal(connection.isConnectedTo('192.0.2.11', peerPort), false);
87
+ assert.equal(connection.isConnectedTo(host.toUpperCase(), configuredPort), true);
88
+ assert.equal(connection.isConnectedTo(host, peerPort), false);
84
89
  }
85
90
  finally {
86
- await closeConnection(connection, server);
91
+ connection._destroy();
87
92
  }
88
93
  });
89
- it('shares connection attempts, recognizes endpoints, and writes frames', async () => {
90
- const server = await startServer();
91
- const received = new Promise((resolve) => {
92
- server.once('connection', (socket) => {
93
- socket.once('data', (data) => resolve(Buffer.from(data)));
94
- });
95
- });
96
- const connection = new IggyConnection(connectionConfig(server));
94
+ it('recognizes an IP roster endpoint for a DNS TLS connection', async () => {
95
+ const server = await startServer((frame, socket) => socket.write(replyFrame(frame)), true);
96
+ const secured = once(server, 'secureConnection');
97
+ const host = 'bootstrap.example.test';
98
+ const port = server.address().port;
99
+ let accepted = 0;
100
+ server.on('secureConnection', () => { accepted += 1; });
101
+ const connection = new IggyConnection({ ...connectionConfig(server), transport: 'TLS',
102
+ options: { host, port, family: 4, rejectUnauthorized: false,
103
+ lookup: (_hostname, _options, callback) => callback(null, '127.0.0.1', 4) } });
97
104
  try {
98
- const first = connection.connect();
99
- assert.equal(connection.connect(), first);
100
- await first;
101
- assert.equal(await connection.connect(), connection);
102
- assert.equal(connection.isConnectedTo('localhost', server.address().port), true);
103
- connection.config.options = {
104
- ...connection.config.options,
105
- host: 'broker.example'
106
- };
107
- assert.equal(connection.isConnectedTo('broker.example', server.address().port), true);
108
- const frame = replyFrame(Buffer.from('payload'));
109
- connection.writeFrame(frame);
110
- assert.deepEqual(await received, frame);
105
+ await connection.connect();
106
+ const [peer] = await secured;
107
+ const socket = connection.socket;
108
+ assert.equal(peer.servername, host);
109
+ assert.equal(socket.remoteAddress, '127.0.0.1');
110
+ assert.equal(connection.isConnectedTo('127.0.0.1', port), true);
111
+ assert.equal(connection.isConnectedTo('::ffff:127.0.0.1', port), true);
112
+ assert.equal(connection.isConnectedTo(host.toUpperCase(), port), true);
113
+ assert.equal((await exchange(connection, 1n)).error, undefined);
114
+ await connection.connect();
115
+ assert.equal(connection.socket, socket);
116
+ assert.equal(accepted, 1);
111
117
  }
112
118
  finally {
113
119
  await closeConnection(connection, server);
114
120
  }
115
121
  });
116
- it('emits complete buffered responses and rejects malformed frames', async () => {
117
- const server = await startServer();
122
+ it('dials lazily and shares an in-flight connection attempt', async () => {
123
+ const server = await startServer((frame, socket) => socket.write(replyFrame(frame)));
124
+ let accepted = 0;
125
+ server.on('connection', () => { accepted += 1; });
118
126
  const connection = new IggyConnection(connectionConfig(server));
119
127
  try {
120
- await connection.connect();
121
- const frame = replyFrame(Buffer.from('response'));
122
- const response = once(connection, 'response');
123
- connection._onData(frame.subarray(0, 6));
124
- connection._onData(frame.subarray(6));
125
- assert.deepEqual((await response)[0], frame);
126
- const malformed = replyFrame(Buffer.alloc(0));
127
- malformed.writeUInt32LE(FRAME_LIMIT + 1, REPLY_OFFSET.size);
128
- const error = once(connection, 'error');
129
- connection._onData(malformed);
130
- assert.ok((await error)[0] instanceof ProtocolFrameError);
128
+ await new Promise((resolve) => setImmediate(resolve));
129
+ assert.equal(accepted, 0);
130
+ const pending = connection.connect();
131
+ assert.equal(connection.connect(), pending);
132
+ await pending;
133
+ assert.equal(accepted, 1);
134
+ assert.equal(connection.isConnectedTo('localhost', server.address().port), true);
135
+ assert.equal((await exchange(connection, 1n)).error, undefined);
131
136
  }
132
137
  finally {
133
138
  await closeConnection(connection, server);
134
139
  }
135
140
  });
136
- it('suppresses expected reset errors during intentional shutdown', async () => {
137
- const server = await startServer();
141
+ it('correlates out-of-order replies on one socket', async () => {
142
+ const requests = [];
143
+ const server = await startServer((frame, socket) => {
144
+ requests.push(frame);
145
+ if (requests.length === 2)
146
+ socket.write(Buffer.concat([replyFrame(requests[1], Buffer.from('two')), replyFrame(requests[0], Buffer.from('one'))]));
147
+ });
138
148
  const connection = new IggyConnection(connectionConfig(server));
139
149
  try {
140
150
  await connection.connect();
141
- let emitted = false;
142
- connection.on('error', () => { emitted = true; });
143
- connection.ending = true;
144
- connection.socket.emit('error', Object.assign(new Error('reset'), { code: 'ECONNRESET' }));
145
- assert.equal(emitted, false);
151
+ const [first, second] = await Promise.all([exchange(connection, 1n), exchange(connection, 2n)]);
152
+ assert.equal(first.response?.subarray(HEADER_SIZE).toString(), 'one');
153
+ assert.equal(second.response?.subarray(HEADER_SIZE).toString(), 'two');
146
154
  }
147
155
  finally {
148
156
  await closeConnection(connection, server);
149
157
  }
150
158
  });
151
- it('does not reconnect or reopen after destruction', async () => {
152
- const server = await startServer();
153
- const config = {
154
- ...connectionConfig(server),
155
- reconnect: { enabled: true, interval: 20, maxRetries: 1 }
156
- };
157
- let connections = 0;
158
- let serverSocket;
159
- server.on('connection', (socket) => {
160
- connections += 1;
161
- serverSocket = socket;
159
+ it('isolates cancellation and discards a late reply without closing the socket', async () => {
160
+ const canceled = new AbortController();
161
+ let first;
162
+ const server = await startServer((frame, socket) => {
163
+ if (frame.readBigUInt64LE(REQUEST_OFFSET.request) === 1n) {
164
+ first = frame;
165
+ canceled.abort();
166
+ }
167
+ else
168
+ socket.write(Buffer.concat([replyFrame(first), replyFrame(frame, Buffer.from('alive'))]));
162
169
  });
163
- const connection = new IggyConnection(config);
170
+ const connection = new IggyConnection(connectionConfig(server));
164
171
  try {
165
172
  await connection.connect();
166
- const disconnected = once(connection, 'disconnected');
167
- serverSocket?.destroy();
168
- await disconnected;
169
- connection._destroy();
170
- await assert.rejects(() => connection.connect(), /connection is closed/);
171
- await new Promise((resolve) => setTimeout(resolve, 40));
172
- assert.equal(connections, 1);
173
+ assert.ok((await exchange(connection, 1n, canceled.signal)).error);
174
+ assert.equal((await exchange(connection, 2n)).response?.subarray(HEADER_SIZE).toString(), 'alive');
175
+ assert.equal(connection.connected, true);
173
176
  }
174
177
  finally {
175
178
  await closeConnection(connection, server);
176
179
  }
177
180
  });
178
- it('shares reconnect backoff with callers', async () => {
181
+ it('times out an expired deadline before building a frame', async () => {
179
182
  const server = await startServer();
180
- const config = {
181
- ...connectionConfig(server),
182
- reconnect: { enabled: true, interval: 10, maxRetries: 2 }
183
- };
184
- let serverSocket;
185
- let connections = 0;
186
- server.on('connection', (socket) => {
187
- connections += 1;
188
- serverSocket = socket;
189
- });
190
- const connection = new IggyConnection(config);
191
- connection.on('error', () => undefined);
183
+ const connection = new IggyConnection(connectionConfig(server));
184
+ let built = 0;
192
185
  try {
193
186
  await connection.connect();
194
- const oldSocket = connection.socket;
195
- connection._onData(Buffer.alloc(3));
196
- const disconnected = once(connection, 'disconnected');
197
- serverSocket?.destroy();
198
- await disconnected;
199
- const first = connection.connect();
200
- assert.equal(connection.connect(), first);
201
- assert.equal(await first, connection);
202
- assert.equal(connection.connected, true);
203
- assert.equal(connections, 2);
204
- const frame = replyFrame(Buffer.from('fresh'));
205
- const response = once(connection, 'response');
206
- oldSocket.emit('data', Buffer.alloc(8));
207
- connection._onData(frame);
208
- assert.deepEqual((await response)[0], frame);
187
+ const result = await connection.exchange(() => { built += 1; return requestFrame(1n); }, Date.now() - 1, new AbortController().signal);
188
+ assert.ok(result.error instanceof VsrResponseTimeoutError);
189
+ assert.match(String(result.error), /after 0 ms/);
190
+ assert.equal(result.started, false);
191
+ assert.equal(built, 0);
209
192
  }
210
193
  finally {
211
194
  await closeConnection(connection, server);
212
195
  }
213
196
  });
214
- it('keeps the seed endpoint when a redirect fails', async () => {
197
+ it('reports a caller abort ahead of an expired deadline', async () => {
215
198
  const server = await startServer();
216
- const unavailable = await startServer();
217
- const unavailablePort = unavailable.address().port;
218
- await new Promise((resolve) => unavailable.close(() => resolve()));
219
- const connection = new IggyConnection({
220
- ...connectionConfig(server),
221
- reconnect: { enabled: true, interval: 10, maxRetries: 1 }
222
- });
223
- connection.on('error', () => undefined);
199
+ const connection = new IggyConnection(connectionConfig(server));
200
+ const cancel = new AbortController();
201
+ const reason = new Error('caller canceled');
202
+ let built = 0;
224
203
  try {
225
204
  await connection.connect();
226
- const seedPort = server.address().port;
227
- const reconnected = new Promise((resolve) => {
228
- connection.once('connect', () => resolve());
229
- });
230
- await assert.rejects(() => connection.redirect('127.0.0.1', unavailablePort));
231
- await reconnected;
232
- assert.equal(connection.config.options.host, '127.0.0.1');
233
- assert.equal(connection.config.options.port, seedPort);
234
- assert.equal(connection.connected, true);
235
- assert.equal(connection.isConnectedTo('127.0.0.1', unavailablePort), false);
205
+ cancel.abort(reason);
206
+ const result = await connection.exchange(() => { built += 1; return requestFrame(1n); }, Date.now() - 1, cancel.signal);
207
+ assert.equal(result.error, reason);
208
+ assert.equal(result.started, false);
209
+ assert.equal(built, 0);
236
210
  }
237
211
  finally {
238
212
  await closeConnection(connection, server);
239
213
  }
240
214
  });
241
- it('stops a pending reconnect after a redirect replaces the socket', async () => {
242
- const seed = await startServer();
243
- const target = await startServer();
244
- const targetPort = target.address().port;
245
- let seedConnections = 0;
246
- let seedSocket;
247
- seed.on('connection', (socket) => {
248
- seedConnections += 1;
249
- seedSocket = socket;
215
+ it('assembles partial frames and rejects an oversized declared frame', async () => {
216
+ let count = 0;
217
+ const server = await startServer((frame, socket) => {
218
+ const response = replyFrame(frame, Buffer.from('complete'));
219
+ if (++count === 1) {
220
+ socket.write(response.subarray(0, 6));
221
+ setImmediate(() => socket.write(response.subarray(6)));
222
+ }
223
+ else {
224
+ response.writeUInt32LE(FRAME_LIMIT + 1, REPLY_OFFSET.size);
225
+ socket.write(response);
226
+ }
250
227
  });
251
- let targetConnections = 0;
252
- target.on('connection', () => {
253
- targetConnections += 1;
254
- });
255
- const connection = new IggyConnection({
256
- ...connectionConfig(seed),
257
- reconnect: { enabled: true, interval: 50, maxRetries: 3 }
258
- });
259
- connection.on('error', () => undefined);
228
+ const connection = new IggyConnection(connectionConfig(server));
260
229
  try {
261
230
  await connection.connect();
262
- const disconnected = once(connection, 'disconnected');
263
- seedSocket?.destroy();
264
- await disconnected;
265
- await connection.redirect('127.0.0.1', targetPort);
266
- assert.equal(connection.connected, true);
267
- await new Promise((resolve) => setTimeout(resolve, 150));
268
- assert.equal(connection.connected, true);
269
- assert.equal(connection.isConnectedTo('127.0.0.1', targetPort), true);
270
- assert.equal(seedConnections, 1);
271
- assert.equal(targetConnections, 1);
231
+ assert.equal((await exchange(connection, 1n)).response?.subarray(HEADER_SIZE).toString(), 'complete');
232
+ assert.ok((await exchange(connection, 2n)).error instanceof ProtocolFrameError);
233
+ assert.equal(connection.connected, false);
272
234
  }
273
235
  finally {
274
- connection._destroy();
275
- await new Promise((resolve) => seed.close(() => resolve()));
276
- await new Promise((resolve) => target.close(() => resolve()));
236
+ await closeConnection(connection, server);
277
237
  }
278
238
  });
279
- it('exhausts consecutive failed retries without unhandled rejections', async () => {
239
+ it('bounds pending calls before frame allocation and releases capacity after cancellation', async () => {
280
240
  const server = await startServer();
281
- let serverSocket;
282
- server.on('connection', (socket) => {
283
- serverSocket = socket;
284
- });
285
- const rejections = [];
286
- const onUnhandled = (reason) => rejections.push(reason);
287
- process.on('unhandledRejection', onUnhandled);
288
- const connection = new IggyConnection({
289
- ...connectionConfig(server),
290
- reconnect: { enabled: true, interval: 10, maxRetries: 3 }
291
- });
241
+ const connection = new IggyConnection(connectionConfig(server));
242
+ const cancel = new AbortController();
243
+ let allocated = 0;
292
244
  try {
293
245
  await connection.connect();
294
- const exhausted = new Promise((resolve) => {
295
- const onError = (error) => {
296
- if (!error.message.includes('reconnect maxRetries exceeded'))
297
- return;
298
- connection.removeListener('error', onError);
299
- resolve(error);
300
- };
301
- connection.on('error', onError);
302
- });
303
- const closed = new Promise((resolve) => server.close(() => resolve()));
304
- serverSocket?.destroy();
305
- const retryStartedAt = Date.now();
306
- await closed;
307
- const error = await exhausted;
308
- // Three retries at a 10 ms interval must spend at least 30 ms in
309
- // backoff; a broken wait would redial back to back.
310
- assert.ok(Date.now() - retryStartedAt >= 30, 'reconnect backoff did not elapse between retries');
311
- assert.match(error.message, /reconnect maxRetries exceeded/);
246
+ const calls = Array.from({ length: MAX_PENDING_PER_LINK + 1 }, (_, index) => connection.exchange(() => { allocated += 1; return requestFrame(BigInt(index + 1)); }, Date.now() + 2_000, cancel.signal));
312
247
  await new Promise((resolve) => setTimeout(resolve, 20));
313
- assert.deepEqual(rejections, []);
248
+ assert.equal(allocated, MAX_PENDING_PER_LINK);
249
+ cancel.abort();
250
+ await Promise.all(calls);
251
+ assert.equal(allocated, MAX_PENDING_PER_LINK);
252
+ assert.equal(connection.connected, true);
314
253
  }
315
254
  finally {
316
- process.removeListener('unhandledRejection', onUnhandled);
317
- connection._destroy();
255
+ cancel.abort();
256
+ await closeConnection(connection, server);
318
257
  }
319
258
  });
320
- it('falls back to the seed endpoint when a redirected leader dies', async () => {
321
- const seed = await startServer();
322
- const seedPort = seed.address().port;
323
- const target = await startServer();
324
- const targetPort = target.address().port;
325
- let targetSocket;
326
- target.on('connection', (socket) => {
327
- targetSocket = socket;
328
- });
329
- const connection = new IggyConnection({
330
- ...connectionConfig(seed),
331
- reconnect: { enabled: true, interval: 10, maxRetries: 4 }
332
- });
333
- connection.on('error', () => undefined);
259
+ it('bounds queued writers and pins canceled frame bytes until the write callback completes', async () => {
260
+ const received = [];
261
+ const server = await startServer((frame) => { received.push(frame); });
262
+ const connection = new IggyConnection(connectionConfig(server));
263
+ const cancel = new AbortController();
264
+ let released = false;
265
+ const owner = new FrozenFrame(requestFrame(1n), () => { released = true; });
266
+ let completeWrite;
267
+ let writing;
268
+ const started = new Promise((resolve) => { writing = resolve; });
334
269
  try {
335
270
  await connection.connect();
336
- await connection.redirect('127.0.0.1', targetPort);
337
- assert.equal(connection.config.options.port, targetPort);
338
- const reconnected = new Promise((resolve) => {
339
- connection.once('connect', () => resolve());
340
- });
341
- const targetClosed = new Promise((resolve) => target.close(() => resolve()));
342
- targetSocket?.destroy();
343
- await targetClosed;
344
- await reconnected;
271
+ const write = connection.socket.write.bind(connection.socket);
272
+ connection.socket.write = ((chunk, callback) => write(chunk, (error) => { completeWrite = () => callback(error); writing(); }));
273
+ const first = connection.exchange(() => owner.buffer, Date.now() + 2_000, cancel.signal, owner);
274
+ await started;
275
+ owner.release();
276
+ const waiting = Array.from({ length: LINK_WRITE_QUEUE + 8 }, (_, index) => exchange(connection, BigInt(index + 2), cancel.signal));
345
277
  await new Promise((resolve) => setImmediate(resolve));
346
- assert.equal(connection.connected, true);
347
- assert.equal(connection.isConnectedTo('127.0.0.1', seedPort), true);
348
- assert.equal(connection.config.options.port, seedPort);
349
- }
350
- finally {
351
- await closeConnection(connection, seed);
352
- }
353
- });
354
- it('rotates a redial through the roster it learned while connected', async () => {
355
- const seed = await startServer();
356
- const seedPort = seed.address().port;
357
- const connection = new IggyConnection(connectionConfig(seed));
358
- connection.on('error', () => undefined);
359
- try {
360
- connection.rememberRoster([
361
- { host: '127.0.0.1', port: seedPort },
362
- { host: '127.0.0.1', port: seedPort + 1 },
363
- { host: '127.0.0.1', port: seedPort + 2 }
364
- ]);
365
- // The endpoint the client is on leads, the roster follows, and the
366
- // roster's copy of that endpoint does not earn a second attempt.
367
- assert.deepEqual(connection._redialCandidates().map((options) => options.port), [seedPort, seedPort + 1, seedPort + 2]);
278
+ assert.equal(connection.writes.length, LINK_WRITE_QUEUE);
279
+ cancel.abort();
280
+ assert.equal((await first).started, true);
281
+ assert.ok((await Promise.all(waiting)).every((result) => !result.started));
282
+ assert.equal(released, false);
283
+ assert.equal(owner.buffer.readBigUInt64LE(REQUEST_OFFSET.request), 1n);
284
+ connection.socket.write = write;
285
+ assert.ok(completeWrite);
286
+ completeWrite();
287
+ await new Promise((resolve) => setImmediate(resolve));
288
+ assert.equal(released, true);
289
+ assert.throws(() => owner.buffer, /released/);
290
+ assert.equal(received.length, 1);
368
291
  }
369
292
  finally {
370
- connection._destroy();
371
- await new Promise((resolve) => seed.close(() => resolve()));
293
+ cancel.abort();
294
+ completeWrite?.();
295
+ await closeConnection(connection, server);
372
296
  }
373
297
  });
374
- it('walks each roster endpoint once per request', async () => {
375
- const seed = await startServer();
376
- const seedPort = seed.address().port;
377
- const connection = new IggyConnection(connectionConfig(seed));
378
- connection.on('error', () => undefined);
298
+ it('expires an incomplete frame independently of the caller deadline', async (context) => {
299
+ const server = await startServer((frame, socket) => socket.write(replyFrame(frame, Buffer.from('body')).subarray(0, HEADER_SIZE + 1)));
300
+ const connection = new IggyConnection(connectionConfig(server));
379
301
  try {
380
302
  await connection.connect();
381
- connection.rememberRoster([
382
- { host: '127.0.0.1', port: seedPort },
383
- { host: '127.0.0.1', port: seedPort + 1 },
384
- { host: '127.0.0.1', port: seedPort + 2 }
385
- ]);
386
- const visited = new Set();
387
- assert.deepEqual(connection.nextRosterEndpoint(visited), {
388
- host: '127.0.0.1', port: seedPort + 1
389
- });
390
- assert.deepEqual(connection.nextRosterEndpoint(visited), {
391
- host: '127.0.0.1', port: seedPort + 2
392
- });
393
- assert.equal(connection.nextRosterEndpoint(visited), undefined);
394
- assert.equal(visited.size, 3);
395
- }
396
- finally {
397
- await closeConnection(connection, seed);
398
- }
399
- });
400
- it('dials the endpoint it is on, then the seed, then the roster', async () => {
401
- const seed = await startServer();
402
- const seedPort = seed.address().port;
403
- const connection = new IggyConnection(connectionConfig(seed));
404
- connection.on('error', () => undefined);
405
- try {
406
- // A redirect moves the client off its seed; the seed is still the one
407
- // endpoint the caller vouched for, so it comes before a roster the
408
- // cluster may have reshaped since.
409
- connection.config.options = {
410
- ...connection.config.options,
411
- port: seedPort + 9
412
- };
413
- connection.rememberRoster([{ host: '127.0.0.1', port: seedPort + 5 }]);
414
- assert.deepEqual(connection._redialCandidates().map((options) => options.port), [seedPort + 9, seedPort, seedPort + 5]);
415
- }
416
- finally {
417
- connection._destroy();
418
- await new Promise((resolve) => seed.close(() => resolve()));
419
- }
420
- });
421
- it('counts endpoints that only differ in spelling once', async () => {
422
- const seed = await startServer();
423
- const seedPort = seed.address().port;
424
- const connection = new IggyConnection(connectionConfig(seed));
425
- connection.on('error', () => undefined);
426
- try {
427
- // The loopback aliases and an IPv4-mapped address all name the endpoint
428
- // the client is already on, so none of them earns a dial of its own.
429
- connection.rememberRoster([
430
- { host: 'localhost', port: seedPort },
431
- { host: '::1', port: seedPort },
432
- { host: '::ffff:127.0.0.1', port: seedPort },
433
- { host: '127.0.0.1', port: seedPort + 1 }
434
- ]);
435
- assert.deepEqual(connection._redialCandidates().map((options) => options.port), [seedPort, seedPort + 1]);
436
- }
437
- finally {
438
- connection._destroy();
439
- await new Promise((resolve) => seed.close(() => resolve()));
440
- }
441
- });
442
- it('does not redial at all when reconnection is disabled', async () => {
443
- // `enabled: false` is what a caller says to opt out. The retry budget is
444
- // whatever the defaults hold, so a loop that reads it without checking
445
- // this flag would run every one of those passes - and with the backoff
446
- // gated on the same flag, back to back.
447
- //
448
- // The endpoint accepts and hangs up, so the drop that would start a
449
- // redial happens and every dial of it is counted.
450
- const hangup = await startServer();
451
- const hangupPort = hangup.address().port;
452
- let accepted = 0;
453
- hangup.on('connection', (socket) => {
454
- accepted += 1;
455
- socket.destroy();
456
- });
457
- const connection = new IggyConnection({
458
- transport: 'TCP',
459
- options: { host: '127.0.0.1', port: hangupPort },
460
- credentials: { username: 'iggy', password: 'iggy' },
461
- reconnect: { enabled: false, interval: 10, maxRetries: 12 },
462
- maxResponseFrameSize: FRAME_LIMIT
463
- });
464
- connection.on('error', () => undefined);
465
- try {
466
- await connection.connect().catch(() => undefined);
467
- await new Promise((resolve) => setTimeout(resolve, 300));
468
- assert.equal(accepted, 1, 'a client that turned reconnection off redialed anyway');
469
- assert.equal(connection.connected, false);
470
- }
471
- finally {
472
- connection._destroy();
473
- await new Promise((resolve) => hangup.close(() => resolve()));
474
- }
475
- });
476
- it('sweeps the endpoints it knows once when reconnection is disabled', async () => {
477
- // Opting out of retries is not opting out of the endpoints: with more
478
- // than one known, they get exactly one pass and no backoff, as in the
479
- // other SDKs.
480
- const dead = await startServer();
481
- const deadPort = dead.address().port;
482
- await new Promise((resolve) => dead.close(() => resolve()));
483
- const live = await startServer();
484
- const livePort = live.address().port;
485
- let accepted = 0;
486
- live.on('connection', () => { accepted += 1; });
487
- const connection = new IggyConnection({
488
- transport: 'TCP',
489
- options: { host: '127.0.0.1', port: deadPort },
490
- credentials: { username: 'iggy', password: 'iggy' },
491
- reconnect: { enabled: false, interval: 10, maxRetries: 12 },
492
- maxResponseFrameSize: FRAME_LIMIT
493
- });
494
- connection.on('error', () => undefined);
495
- try {
496
- connection.rememberRoster([{ host: '127.0.0.1', port: livePort }]);
497
- await connection.connect().catch(() => undefined);
498
- await new Promise((resolve) => setTimeout(resolve, 200));
499
- assert.equal(accepted, 1, 'the known endpoints got either no pass or more than one');
500
- }
501
- finally {
502
- connection._destroy();
503
- await new Promise((resolve) => live.close(() => resolve()));
504
- }
505
- });
506
- it('makes one pass when every endpoint is down and reconnection is disabled', async () => {
507
- // One pass, not the whole retry budget: with the budget read but the
508
- // flag ignored, a client that opted out of retries dials every endpoint
509
- // once per pass for all of them -- and with the backoff gated on the same
510
- // flag, back to back.
511
- //
512
- // Plain TCP behind a TLS client, closed at once: the dial fails, so the
513
- // pass moves on, and every dial is counted where it lands.
514
- const first = await startServer();
515
- const firstPort = first.address().port;
516
- let firstDials = 0;
517
- first.on('connection', (socket) => {
518
- firstDials += 1;
519
- socket.destroy();
520
- });
521
- const second = await startServer();
522
- const secondPort = second.address().port;
523
- let secondDials = 0;
524
- second.on('connection', (socket) => {
525
- secondDials += 1;
526
- socket.destroy();
527
- });
528
- const connection = new IggyConnection({
529
- transport: 'TLS',
530
- options: {
531
- host: '127.0.0.1',
532
- port: firstPort,
533
- rejectUnauthorized: false
534
- },
535
- credentials: { username: 'iggy', password: 'iggy' },
536
- reconnect: { enabled: false, interval: 10, maxRetries: 12 },
537
- maxResponseFrameSize: FRAME_LIMIT
538
- });
539
- connection.on('error', () => undefined);
540
- try {
541
- connection.rememberRoster([{ host: '127.0.0.1', port: secondPort }]);
542
- await connection.connect().catch(() => undefined);
543
- await new Promise((resolve) => setTimeout(resolve, 300));
544
- assert.equal(connection.connected, false);
545
- // The connect's own dial of the configured endpoint, then one pass over
546
- // both: the endpoint the client starts on is dialed twice, the one
547
- // behind it once.
548
- assert.deepEqual([firstDials, secondDials], [2, 1], 'a client that opted out of retries swept more than once');
549
- }
550
- finally {
551
- connection._destroy();
552
- await new Promise((resolve) => first.close(() => resolve()));
553
- await new Promise((resolve) => second.close(() => resolve()));
554
- }
555
- });
556
- it('skips the first backoff when another endpoint is known', async () => {
557
- // The endpoint the client is on is dead and a live one sits behind it in
558
- // the roster: waiting out the interval before the first pass would push
559
- // the failover past what the caller waits for, and the node just lost may
560
- // be gone for good.
561
- const dead = await startServer();
562
- const deadPort = dead.address().port;
563
- await new Promise((resolve) => dead.close(() => resolve()));
564
- const live = await startServer();
565
- const livePort = live.address().port;
566
- const interval = 3000;
567
- const connection = new IggyConnection({
568
- transport: 'TCP',
569
- options: { host: '127.0.0.1', port: deadPort },
570
- credentials: { username: 'iggy', password: 'iggy' },
571
- reconnect: { enabled: true, interval, maxRetries: 3 },
572
- maxResponseFrameSize: FRAME_LIMIT
573
- });
574
- connection.on('error', () => undefined);
575
- try {
576
- connection.rememberRoster([{ host: '127.0.0.1', port: livePort }]);
577
- const dialed = once(live, 'connection');
578
- const started = Date.now();
579
- void connection.connect().catch(() => undefined);
580
- await dialed;
581
- assert.ok(Date.now() - started < interval, 'the failover waited out the backoff before its first pass');
582
- }
583
- finally {
584
- connection._destroy();
585
- await new Promise((resolve) => live.close(() => resolve()));
586
- }
587
- });
588
- it('bounds a dial that never becomes usable when others are queued behind it', async () => {
589
- // Plain TCP behind a TLS client: the socket connects, so only a bound on
590
- // the handshake ends the attempt. The endpoint behind it is dead, so the
591
- // pass has to end on its own rather than hang on the first one.
592
- const silent = await startServer();
593
- const silentPort = silent.address().port;
594
- const held = [];
595
- silent.on('connection', (socket) => { held.push(socket); });
596
- const dead = await startServer();
597
- const deadPort = dead.address().port;
598
- await new Promise((resolve) => dead.close(() => resolve()));
599
- const connection = new IggyConnection({
600
- transport: 'TLS',
601
- options: {
602
- host: '127.0.0.1',
603
- port: silentPort,
604
- rejectUnauthorized: false
605
- },
606
- credentials: { username: 'iggy', password: 'iggy' },
607
- reconnect: { enabled: true, interval: 10, maxRetries: 3 },
608
- maxResponseFrameSize: FRAME_LIMIT
609
- });
610
- connection.on('error', () => undefined);
611
- try {
612
- connection.rememberRoster([{ host: '127.0.0.1', port: deadPort }]);
613
- void connection.connect().catch(() => undefined);
614
- // Unbounded, the first dial never ends and this endpoint is dialed
615
- // exactly once, forever.
616
- const deadline = Date.now() + 8_000;
617
- while (held.length < 2 && Date.now() < deadline)
618
- await new Promise((resolve) => setTimeout(resolve, 50));
619
- assert.ok(held.length >= 2, 'a dial that never became usable held the pass');
303
+ context.mock.timers.enable({ apis: ['setTimeout'] });
304
+ const received = once(connection.socket, 'data');
305
+ const pending = connection.exchange(() => requestFrame(1n), Date.now() + OPERATION_TIMEOUT_MS * 2, new AbortController().signal);
306
+ await received;
307
+ context.mock.timers.tick(OPERATION_TIMEOUT_MS);
308
+ const result = await pending;
309
+ assert.ok(result.error instanceof ProtocolFrameError);
310
+ assert.match(String(result.error), /incomplete consensus frame timed out/);
620
311
  assert.equal(connection.connected, false);
621
312
  }
622
313
  finally {
623
- connection._destroy();
624
- held.forEach((socket) => socket.destroy());
625
- await new Promise((resolve) => silent.close(() => resolve()));
314
+ context.mock.timers.reset();
315
+ await closeConnection(connection, server);
626
316
  }
627
317
  });
628
- it('stops a redial pass that is destroyed part-way through', async () => {
629
- // The endpoint the client is on is dead, so every dial to it is refused
630
- // - and the live roster endpoint behind it is what the pass would reach
631
- // next, unless the destroy in between stops the pass.
632
- const dead = await startServer();
633
- const deadPort = dead.address().port;
634
- await new Promise((resolve) => dead.close(() => resolve()));
635
- const live = await startServer();
636
- const livePort = live.address().port;
318
+ it('never reconnects a failed physical link', async () => {
637
319
  let accepted = 0;
638
- live.on('connection', () => { accepted += 1; });
639
- const connection = new IggyConnection({
640
- transport: 'TCP',
641
- options: { host: '127.0.0.1', port: deadPort },
642
- credentials: { username: 'iggy', password: 'iggy' },
643
- reconnect: { enabled: true, interval: 10, maxRetries: 3 },
644
- maxResponseFrameSize: FRAME_LIMIT
645
- });
646
- let destroyed = false;
647
- let connectsAfterDestroy = 0;
648
- connection.on('connect', () => {
649
- if (destroyed)
650
- connectsAfterDestroy += 1;
651
- });
652
- try {
653
- connection.rememberRoster([{ host: '127.0.0.1', port: livePort }]);
654
- // The first failure is the initial connect, which is what starts the
655
- // redial pass; the next one is that pass's first candidate, so
656
- // destroying there lands between two candidates rather than before the
657
- // pass.
658
- const destroyedMidPass = new Promise((resolve) => {
659
- let failures = 0;
660
- connection.on('error', () => {
661
- failures += 1;
662
- if (failures < 2 || destroyed)
663
- return;
664
- connection._destroy();
665
- destroyed = true;
666
- resolve();
667
- });
668
- });
669
- await connection.connect().catch(() => undefined);
670
- await destroyedMidPass;
671
- await new Promise((resolve) => setTimeout(resolve, 100));
672
- assert.equal(accepted, 0, 'a destroyed connection must not keep dialing the rest of the pass');
673
- assert.equal(connectsAfterDestroy, 0, 'a destroyed connection must not announce a connection');
674
- assert.equal(connection.connected, false);
675
- }
676
- finally {
677
- connection._destroy();
678
- await new Promise((resolve) => live.close(() => resolve()));
679
- }
680
- });
681
- it('settles a dial in flight when a redirect replaces the socket', async () => {
682
- const seed = await startServer();
683
- const target = await startServer();
684
- const targetPort = target.address().port;
685
- const connection = new IggyConnection(connectionConfig(seed));
686
- connection.on('error', () => undefined);
687
- try {
688
- const pending = connection.connect();
689
- const redirected = connection.redirect('127.0.0.1', targetPort);
690
- await assert.rejects(() => pending, /connection closed before it was established/);
691
- await redirected;
692
- assert.equal(connection.connected, true);
693
- assert.equal(connection.isConnectedTo('127.0.0.1', targetPort), true);
694
- }
695
- finally {
696
- connection._destroy();
697
- await new Promise((resolve) => seed.close(() => resolve()));
698
- await new Promise((resolve) => target.close(() => resolve()));
699
- }
700
- });
701
- it('shares the redirect dial with a disconnected listener', async () => {
702
- const seed = await startServer();
703
- const target = await startServer();
704
- const targetPort = target.address().port;
705
- let seedConnections = 0;
706
- let targetConnections = 0;
707
- seed.on('connection', () => {
708
- seedConnections += 1;
709
- });
710
- target.on('connection', () => {
711
- targetConnections += 1;
712
- });
713
- const connection = new IggyConnection(connectionConfig(seed));
714
- connection.on('error', () => undefined);
715
- let listenerConnection;
716
- try {
717
- await connection.connect();
718
- connection.once('disconnected', () => {
719
- listenerConnection = connection.connect();
720
- });
721
- await connection.redirect('127.0.0.1', targetPort);
722
- assert.ok(listenerConnection);
723
- assert.equal(await listenerConnection, connection);
724
- assert.equal(connection.connected, true);
725
- assert.equal(connection.isConnectedTo('127.0.0.1', targetPort), true);
726
- assert.equal(seedConnections, 1);
727
- assert.equal(targetConnections, 1);
728
- }
729
- finally {
730
- connection._destroy();
731
- await new Promise((resolve) => seed.close(() => resolve()));
732
- await new Promise((resolve) => target.close(() => resolve()));
733
- }
734
- });
735
- it('rejects an unreadable tls_ca_file with a TypeError at socket creation', () => {
736
- assert.throws(() => new IggyConnection({
737
- transport: 'TLS',
738
- options: {
739
- host: '127.0.0.1',
740
- port: 8090,
741
- caFile: '/does/not/exist.pem'
742
- },
743
- credentials: { username: 'iggy', password: 'iggy' },
744
- reconnect: { enabled: false, interval: 0, maxRetries: 0 }
745
- }), /cannot read tls_ca_file/);
746
- });
747
- it('sends a DNS host as the SNI server name when none is set', async () => {
748
- const server = await startTlsServer();
749
- const secureConnection = once(server, 'secureConnection');
750
- const connection = new IggyConnection({
751
- transport: 'TLS',
752
- options: {
753
- host: 'localhost',
754
- port: server.address().port,
755
- ca: TLS_CA_CERTIFICATE
756
- },
757
- credentials: { username: 'iggy', password: 'iggy' },
758
- reconnect: { enabled: false, interval: 0, maxRetries: 0 }
759
- });
320
+ const server = await startServer((_frame, socket) => socket.destroy());
321
+ server.on('connection', () => { accepted += 1; });
322
+ const connection = new IggyConnection(connectionConfig(server));
760
323
  try {
761
324
  await connection.connect();
762
- assert.equal(connection.connected, true);
763
- const [tlsSocket] = await secureConnection;
764
- assert.equal(tlsSocket.servername, 'localhost');
325
+ assert.ok((await exchange(connection, 1n)).error instanceof ConnectionClosedError);
326
+ await assert.rejects(connection.connect());
327
+ assert.equal(accepted, 1);
765
328
  }
766
329
  finally {
767
330
  await closeConnection(connection, server);
768
331
  }
769
332
  });
770
- it('omits SNI for IP literal hosts', async () => {
771
- const server = await startTlsServer();
772
- const secureConnection = once(server, 'secureConnection');
773
- const connection = new IggyConnection({
774
- transport: 'TLS',
775
- options: {
776
- host: '127.0.0.1',
777
- port: server.address().port,
778
- rejectUnauthorized: false
779
- },
780
- credentials: { username: 'iggy', password: 'iggy' },
781
- reconnect: { enabled: false, interval: 0, maxRetries: 0 }
782
- });
333
+ it('bounds TCP plus TLS establishment when the peer never completes TLS', async () => {
334
+ const server = await startServer();
335
+ const connection = new IggyConnection({ ...connectionConfig(server), transport: 'TLS',
336
+ options: { ...connectionConfig(server).options, rejectUnauthorized: false } });
337
+ const started = Date.now();
783
338
  try {
784
- await connection.connect();
785
- assert.equal(connection.connected, true);
786
- const [tlsSocket] = await secureConnection;
787
- // Node reports a missing SNI name as false on the server side.
788
- assert.ok(!tlsSocket.servername);
339
+ await assert.rejects(connection.connect(), /timed out/);
340
+ assert.ok(Date.now() - started < 3_000);
341
+ assert.equal(connection.connected, false);
789
342
  }
790
343
  finally {
791
344
  await closeConnection(connection, server);
792
345
  }
793
346
  });
794
- after(() => clearInterval(keepAlive));
347
+ it('rejects an unreadable CA file when connecting without leaking a socket', async () => {
348
+ const connection = new IggyConnection({ transport: 'TLS', options: { host: '127.0.0.1', port: 8090,
349
+ caFile: '/does/not/exist.pem' }, credentials: { username: 'iggy', password: 'iggy' } });
350
+ await assert.rejects(connection.connect(), /cannot read tls_ca_file/);
351
+ connection._destroy();
352
+ });
353
+ for (const host of ['localhost', '127.0.0.1']) {
354
+ it(`uses the correct TLS server name for ${host}`, async () => {
355
+ const server = await startServer(undefined, true);
356
+ const secured = once(server, 'secureConnection');
357
+ const connection = new IggyConnection({ ...connectionConfig(server), transport: 'TLS',
358
+ options: { host, port: server.address().port,
359
+ ...(host === 'localhost' ? { ca: TLS_CA_CERTIFICATE } : { rejectUnauthorized: false }) } });
360
+ try {
361
+ await connection.connect();
362
+ const [socket] = await secured;
363
+ assert.equal(socket.servername || undefined, host === 'localhost' ? host : undefined);
364
+ }
365
+ finally {
366
+ await closeConnection(connection, server);
367
+ }
368
+ });
369
+ }
795
370
  });
796
371
  //# sourceMappingURL=client.connection.test.js.map