comq 0.15.4 → 0.15.6

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## [0.15.6](https://github.com/toa-io/comq/compare/v0.15.5...v0.15.6) (2026-08-26)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * do not report shards as lost on shutdown ([abbe0e8](https://github.com/toa-io/comq/commit/abbe0e8e1048509ff2c981b7aa33659c0829e821))
7
+ * do not report shards as lost on shutdown ([#248](https://github.com/toa-io/comq/issues/248)) ([b269a5c](https://github.com/toa-io/comq/commit/b269a5cc78d8ca74036290abec234a1e87056e9c))
8
+
9
+ ## [0.15.5](https://github.com/toa-io/comq/compare/v0.15.4...v0.15.5) (2026-08-25)
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * recover from a lost connection reliably ([59b1fab](https://github.com/toa-io/comq/commit/59b1fab61d4f27ab6eb608693da6493b5b1f4ae5))
15
+ * recover from a lost connection reliably ([#247](https://github.com/toa-io/comq/issues/247)) ([e4701cf](https://github.com/toa-io/comq/commit/e4701cfa384d5451c15d0a6bf53c82ce1da91ac5))
16
+
1
17
  ## [0.15.4](https://github.com/toa-io/comq/compare/v0.15.3...v0.15.4) (2026-08-16)
2
18
 
3
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.15.4",
3
+ "version": "0.15.6",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",
package/readme.md CHANGED
@@ -490,10 +490,14 @@ Subscribe to one of the diagnostic events:
490
490
  - `close`: connection is closed.
491
491
  Optional [`error`](https://amqp-node.github.io/amqplib/channel_api.html#model_events) is passed
492
492
  as an argument.
493
+ - `error`: an attempt to restore the connection has failed. The exception is passed as an argument.
494
+ Attempts continue, so this event is the only way to observe an ongoing outage.
493
495
  - `flow`: back pressure is applied to a channel. [Channel type](./types/topology.d.ts) is passed as
494
496
  an argument.
495
497
  - `drain`: back pressure is removed from a channel. Channel type is passed.
496
498
  - `remove`: channel is removed from the [pool](#sharded-connection).
499
+ - `lost`: a shard has lost its connection, hence the requests awaiting their replies on it are
500
+ re-sent. Channel type is passed.
497
501
  - `recover`: channel's topology is recovered. Channel type is passed.
498
502
  - `discard`: message is [discarded](#messages) as it repeatedly caused
499
503
  exceptions. Channel type,
@@ -515,6 +519,10 @@ established, there is no way to capture the initial `open` event.
515
519
 
516
520
  ```javascript
517
521
  io.diagnose('flow', (type) => console.log(`Back pressure was applied to the ${type} channel`))
522
+
523
+ io.diagnose('open', () => console.log('AMQP connection established'))
524
+ io.diagnose('close', (error) => console.log('AMQP connection closed', error?.message))
525
+ io.diagnose('error', (error) => console.log('AMQP connection failed', error.message))
518
526
  ```
519
527
 
520
528
  # Gratitude
@@ -1,5 +1,6 @@
1
1
  'use strict'
2
2
 
3
+ const { setTimeout: delay } = require('node:timers/promises')
3
4
  const amqp = require('amqplib')
4
5
  const { Promex } = require('promex')
5
6
  const { retry } = require('reretry')
@@ -31,9 +32,18 @@ class Connection {
31
32
  /** @type {boolean} */
32
33
  #running = false
33
34
 
35
+ /** @type {boolean} */
36
+ #closed = false
37
+
34
38
  /** @type {NodeJS.Timeout | null} */
35
39
  #heartbeatTimer = null
36
40
 
41
+ /** @type {import('node:net').Socket | null} */
42
+ #heartbeatSocket = null
43
+
44
+ /** @type {(() => void) | null} */
45
+ #heartbeatReset = null
46
+
37
47
  #diagnostics = emitter.create()
38
48
 
39
49
  /**
@@ -50,7 +60,13 @@ class Connection {
50
60
  return this.#connection !== undefined
51
61
  }
52
62
 
63
+ get closed () {
64
+ return this.#closed
65
+ }
66
+
53
67
  async open () {
68
+ this.#closed = false
69
+
54
70
  if (this.#opening !== null) return this.#opening
55
71
 
56
72
  this.#opening = retry(this.#open).finally(() => { this.#opening = null })
@@ -61,9 +77,12 @@ class Connection {
61
77
  }
62
78
 
63
79
  async close () {
64
- if (this.#connection === undefined) await this.#recovery
80
+ this.#closed = true
65
81
 
66
- await this.#connection.close()
82
+ // a connection that is about to be established must not be left open, yet an
83
+ // attempt that is being retried must not hold up the shutdown
84
+ if (this.#opening !== null) await Promise.race([this.#opening.catch(noop), expiration()])
85
+ if (this.#connection !== undefined) await this.#shutdown(this.#connection)
67
86
  }
68
87
 
69
88
  createChannel = failsafe(this, this.#recover,
@@ -88,16 +107,25 @@ class Connection {
88
107
  }
89
108
 
90
109
  #open = async (retry) => {
110
+ if (this.#closed) return
111
+
91
112
  /** @type {comq.amqp.Connection} */
92
113
  let connection
93
114
 
94
115
  try {
95
- connection = await amqp.connect(this.#url)
116
+ connection = await amqp.connect(this.#url, { timeout: CONNECT_MS })
96
117
  } catch (exception) {
97
- if (this.#transient(exception)) return retry
98
- else throw exception
118
+ if (this.#closed) return
119
+ if (!this.#transient(exception)) throw exception
120
+
121
+ // attempts are made until one succeeds, so an outage is observable only here
122
+ this.#diagnostics.emit('error', exception)
123
+
124
+ return retry
99
125
  }
100
126
 
127
+ if (this.#closed) return await this.#shutdown(connection)
128
+
101
129
  // This prevents the process from crashing; 'close' will be emitted next.
102
130
  // https://amqp-node.github.io/amqplib/channel_api.html#model_events
103
131
  connection.on('error', noop)
@@ -110,17 +138,16 @@ class Connection {
110
138
  try {
111
139
  for (const channel of this.#channels) await channel.recover(connection)
112
140
  } catch (exception) {
113
- this.#disarmWatchdog()
114
141
  this.#diagnostics.emit('error', exception)
115
- connection.removeAllListeners()
116
-
117
- await connection.close().catch(noop)
118
-
119
- if (this.#connection === connection) this.#connection = undefined
142
+ this.#drop(connection)
120
143
 
121
144
  return retry
122
145
  }
123
146
 
147
+ // the connection may have been lost while the topology was being recovered,
148
+ // in which case 'close' has left the reconnection to this very attempt
149
+ if (this.#connection !== connection) return retry
150
+
124
151
  this.#recovery.resolve()
125
152
  this.#recovery = new Promex()
126
153
  }
@@ -130,19 +157,45 @@ class Connection {
130
157
  * @param {Error} [error]
131
158
  */
132
159
  #close = (connection, error) => {
133
- this.#disarmWatchdog()
134
-
135
160
  if (this.#connection !== connection) return
136
161
 
162
+ this.#disarmWatchdog()
137
163
  this.#diagnostics.emit('close', error)
138
164
  connection.removeAllListeners()
139
165
  this.#connection = undefined
140
166
 
141
- if (error !== undefined) {
167
+ if (error !== undefined && !this.#closed) {
142
168
  this.open().catch((exception) => this.#diagnostics.emit('error', exception))
143
169
  }
144
170
  }
145
171
 
172
+ /**
173
+ * An AMQP connection is only closed once the broker has replied with Close-Ok,
174
+ * which never happens on a connection that has already been lost.
175
+ *
176
+ * @param {comq.amqp.Connection} connection
177
+ */
178
+ async #shutdown (connection) {
179
+ const closing = connection.close().catch(noop)
180
+
181
+ await Promise.race([closing, expiration()])
182
+
183
+ this.#drop(connection)
184
+ }
185
+
186
+ /**
187
+ * @param {comq.amqp.Connection} connection
188
+ */
189
+ #drop (connection) {
190
+ if (this.#connection === connection) {
191
+ this.#disarmWatchdog()
192
+ this.#connection = undefined
193
+ }
194
+
195
+ connection.removeAllListeners()
196
+ connection.connection?.stream?.destroy()
197
+ }
198
+
146
199
  #recover () {
147
200
  return this.#recovery
148
201
  }
@@ -162,6 +215,11 @@ class Connection {
162
215
  this.#heartbeatTimer.unref()
163
216
  }
164
217
 
218
+ this.#disarmWatchdog()
219
+
220
+ this.#heartbeatSocket = socket
221
+ this.#heartbeatReset = reset
222
+
165
223
  reset()
166
224
  socket.on('data', reset)
167
225
  }
@@ -169,6 +227,13 @@ class Connection {
169
227
  #disarmWatchdog () {
170
228
  clearTimeout(this.#heartbeatTimer)
171
229
  this.#heartbeatTimer = null
230
+
231
+ // otherwise a byte arriving on a replaced socket rearms the watchdog of a
232
+ // connection that is already gone, leaving the current one unguarded
233
+ this.#heartbeatSocket?.off('data', this.#heartbeatReset)
234
+
235
+ this.#heartbeatSocket = null
236
+ this.#heartbeatReset = null
172
237
  }
173
238
 
174
239
  #transient (exception) {
@@ -183,6 +248,12 @@ class Connection {
183
248
  /** @type {number} */
184
249
  const WATCHDOG_MS = 60_000
185
250
 
251
+ /** @type {number} */
252
+ const CONNECT_MS = 30_000
253
+
254
+ /** @type {number} */
255
+ const SHUTDOWN_MS = 5_000
256
+
186
257
  const TRANSIENT_CODES = new Set([
187
258
  'ECONNREFUSED',
188
259
  'EAI_AGAIN',
@@ -195,9 +266,19 @@ const TRANSIENT_CODES = new Set([
195
266
 
196
267
  const TRANSIENT_MESSAGES = new Set([
197
268
  'Socket closed abruptly during opening handshake',
198
- 'Client network socket disconnected before secure TLS connection was established'
269
+ 'Client network socket disconnected before secure TLS connection was established',
270
+ 'connect ETIMEDOUT' // amqplib reports the `timeout` option without a code
199
271
  ])
200
272
 
273
+ /**
274
+ * @return {Promise<void>}
275
+ */
276
+ function expiration () {
277
+ const timeoutMs = global.COMQ_TESTING_SHUTDOWN_TIMEOUT ?? SHUTDOWN_MS
278
+
279
+ return delay(timeoutMs, undefined, { ref: false })
280
+ }
281
+
201
282
  function noop () {}
202
283
 
203
284
  exports.Connection = Connection
package/source/io.js CHANGED
@@ -52,6 +52,9 @@ class IO {
52
52
  constructor (connection) {
53
53
  this.#connection = connection
54
54
 
55
+ // EventEmitter throws on 'error' with no listeners
56
+ this.#diagnostics.on('error', noop)
57
+
55
58
  for (const event of events.connection) {
56
59
  this.#connection.diagnose(event, (...args) => this.#diagnostics.emit(event, ...args))
57
60
  }
@@ -111,6 +111,7 @@ class Channel {
111
111
  * when it actually fails to publish, otherwise a lost connection would
112
112
  * interrupt the streams it is carrying. Losing a shard is reported instead,
113
113
  * since a request awaiting its reply on it will never be answered.
114
+ * A connection closed on purpose is not a loss.
114
115
  *
115
116
  * @param {comq.Connection} connection
116
117
  * @param {number} index
@@ -121,10 +122,13 @@ class Channel {
121
122
 
122
123
  if (connection.connected === false) this.#down[index].resolve()
123
124
 
124
- connection.diagnose('close', () => {
125
+ connection.diagnose('close', (error) => {
125
126
  this.#alive[index] = false
126
127
  this.#down[index].resolve()
127
- this.#diagnostics.emit(LOST, index)
128
+
129
+ if (error !== undefined && connection.closed !== true) {
130
+ this.#diagnostics.emit(LOST, index)
131
+ }
128
132
  })
129
133
 
130
134
  connection.diagnose('open', () => {
@@ -19,6 +19,9 @@ class Connection {
19
19
  constructor (connections) {
20
20
  this.#connections = connections
21
21
 
22
+ // EventEmitter throws on 'error' with no listeners
23
+ this.#diagnostics.on('error', noop)
24
+
22
25
  connections.map(this.#pipe)
23
26
  }
24
27
 
@@ -72,4 +75,6 @@ class Connection {
72
75
  }
73
76
  }
74
77
 
78
+ function noop () {}
79
+
75
80
  exports.Connection = Connection
@@ -39,9 +39,12 @@ class Connection extends EventEmitter {
39
39
  close = jest.fn(async () => undefined)
40
40
  }
41
41
 
42
+ const connect = async () => new Connection()
43
+
42
44
  /** @type {jest.MockedObject<import('amqplib')>} */
43
45
  const amqplib = {
44
- connect: jest.fn(async () => new Connection())
46
+ connect: jest.fn(connect)
45
47
  }
46
48
 
47
49
  exports.amqplib = amqplib
50
+ exports.connect = connect
@@ -20,6 +20,7 @@ const channel = /** @type {jest.MockedFunction<(sharded?: boolean, index?: numbe
20
20
  */
21
21
  const connection = (sharded = false) => (/** @type {jest.MockedObject<comq.Connection>} */ {
22
22
  connected: true,
23
+ closed: false,
23
24
  createChannel: jest.fn(async (type, index) => channel(sharded, index)),
24
25
  open: jest.fn(async () => undefined),
25
26
  close: jest.fn(async () => undefined),
@@ -5,7 +5,7 @@
5
5
  const { generate } = require('randomstring')
6
6
 
7
7
  const { timeout, promex, random } = require('@toa.io/generic')
8
- const { amqplib } = require('./amqplib.mock')
8
+ const { amqplib, connect } = require('./amqplib.mock')
9
9
  const { channel: create } = require('./connection.mock')
10
10
  const mock = { amqplib, channel: { create } }
11
11
 
@@ -26,6 +26,7 @@ const url = generate()
26
26
 
27
27
  beforeEach(() => {
28
28
  jest.clearAllMocks()
29
+ amqplib.connect.mockImplementation(connect)
29
30
 
30
31
  connection = new Connection(url)
31
32
  })
@@ -36,12 +37,13 @@ describe('initial connection', () => {
36
37
  it('should connect', async () => {
37
38
  await connection.open()
38
39
 
39
- expect(amqplib.connect).toHaveBeenCalledWith(url)
40
+ expect(amqplib.connect).toHaveBeenCalledWith(url, { timeout: expect.any(Number) })
40
41
  })
41
42
 
42
43
  it.each(/** @type {[string, Partial<Error>][]} */[
43
44
  ['Socket closed', { message: 'Socket closed abruptly during opening handshake' }],
44
45
  ['TLS disconnect', { message: 'Client network socket disconnected before secure TLS connection was established' }],
46
+ ['connect timeout', { message: 'connect ETIMEDOUT' }],
45
47
  ['ECONNREFUSED', { code: 'ECONNREFUSED' }],
46
48
  ['EAI_AGAIN', { code: 'EAI_AGAIN' }],
47
49
  ['ENOTFOUND', { code: 'ENOTFOUND' }],
@@ -157,6 +159,58 @@ describe('reconnection', () => {
157
159
  expect(unhandled).not.toHaveBeenCalled()
158
160
  })
159
161
 
162
+ it('should reconnect when connection is lost while channels recover', async () => {
163
+ const channel = await connection.createChannel('request')
164
+
165
+ // the connection is lost right after the topology has been recovered on it
166
+ channel.recover.mockImplementationOnce(async (connection) => {
167
+ connection.emit('close', new Error('lost again'))
168
+ })
169
+
170
+ conn.emit('close', new Error('lost'))
171
+
172
+ await expect(connection.createChannel('event')).resolves.toBeDefined()
173
+
174
+ expect(amqplib.connect).toHaveBeenCalledTimes(3)
175
+ }, 10000)
176
+
177
+ it('should not wait for a connection that failed to recover to close', async () => {
178
+ const channel = await connection.createChannel('request')
179
+
180
+ channel.recover
181
+ .mockRejectedValueOnce(new Error('Channel closed'))
182
+ .mockResolvedValue(undefined)
183
+
184
+ // Close-Ok never arrives on a connection that has already been lost
185
+ conn.close.mockImplementation(() => new Promise(() => undefined))
186
+ conn.emit('close', new Error('lost'))
187
+
188
+ await timeout(0)
189
+
190
+ const failed = await amqplib.connect.mock.results[1].value
191
+
192
+ failed.close.mockImplementation(() => new Promise(() => undefined))
193
+
194
+ await expect(connection.createChannel('event')).resolves.toBeDefined()
195
+
196
+ expect(failed.connection.stream.destroy).toHaveBeenCalled()
197
+ }, 10000)
198
+
199
+ it('should emit error on a failed connection attempt', async () => {
200
+ const errors = []
201
+ const exception = { code: 'ECONNREFUSED' }
202
+
203
+ connection.diagnose('error', (error) => errors.push(error))
204
+ amqplib.connect.mockImplementationOnce(async () => { throw exception })
205
+
206
+ conn.emit('close', new Error('lost'))
207
+
208
+ // the attempt is retried, hence the channel is created on the next connection
209
+ await connection.createChannel('event')
210
+
211
+ expect(errors).toContain(exception)
212
+ }, 10000)
213
+
160
214
  it('should ignore close from a stale connection', async () => {
161
215
  const stale = conn
162
216
  const closeHandler = stale.on.mock.calls.find(([event]) => event === 'close')[1]
@@ -289,6 +343,28 @@ describe('watchdog', () => {
289
343
  expect(conn.connection.stream.destroy).toHaveBeenCalled()
290
344
  })
291
345
 
346
+ it('should not let a replaced socket disarm the watchdog', async () => {
347
+ jest.useFakeTimers()
348
+
349
+ await connection.open()
350
+
351
+ const stale = await amqplib.connect.mock.results[0].value
352
+
353
+ stale.emit('close', new Error('lost'))
354
+
355
+ await jest.advanceTimersByTimeAsync(1)
356
+
357
+ const live = await amqplib.connect.mock.results[1].value
358
+
359
+ await jest.advanceTimersByTimeAsync(30_000)
360
+
361
+ stale.connection.stream.emit('data', Buffer.alloc(0))
362
+
363
+ await jest.advanceTimersByTimeAsync(30_000)
364
+
365
+ expect(live.connection.stream.destroy).toHaveBeenCalled()
366
+ })
367
+
292
368
  it('should reset watchdog on socket data', async () => {
293
369
  jest.useFakeTimers()
294
370
 
@@ -311,6 +387,14 @@ describe('watchdog', () => {
311
387
  })
312
388
 
313
389
  describe('close', () => {
390
+ beforeEach(() => {
391
+ global.COMQ_TESTING_SHUTDOWN_TIMEOUT = 10
392
+ })
393
+
394
+ afterEach(() => {
395
+ delete global.COMQ_TESTING_SHUTDOWN_TIMEOUT
396
+ })
397
+
314
398
  it('should close connection', async () => {
315
399
  await connection.open()
316
400
  await connection.close()
@@ -318,6 +402,7 @@ describe('close', () => {
318
402
  const amqp = await amqplib.connect.mock.results[0].value
319
403
 
320
404
  expect(amqp.close).toHaveBeenCalled()
405
+ expect(connection.closed).toStrictEqual(true)
321
406
  })
322
407
 
323
408
  it('should close after connection is (re)established', async () => {
@@ -331,6 +416,49 @@ describe('close', () => {
331
416
 
332
417
  expect(conn.close).toHaveBeenCalled()
333
418
  })
419
+
420
+ it('should not wait for Close-Ok that never arrives', async () => {
421
+ await connection.open()
422
+
423
+ /** @type {jest.MockedObject<comq.amqp.Connection>} */
424
+ const conn = await amqplib.connect.mock.results[0].value
425
+
426
+ conn.close.mockImplementation(() => new Promise(() => undefined))
427
+
428
+ await connection.close()
429
+
430
+ expect(conn.connection.stream.destroy).toHaveBeenCalled()
431
+ })
432
+
433
+ it('should open again after close', async () => {
434
+ await connection.open()
435
+ await connection.close()
436
+ await connection.open()
437
+
438
+ await expect(connection.createChannel('request')).resolves.toBeDefined()
439
+
440
+ expect(amqplib.connect).toHaveBeenCalledTimes(2)
441
+ })
442
+
443
+ it('should stop reconnecting', async () => {
444
+ await connection.open()
445
+
446
+ const conn = await amqplib.connect.mock.results[0].value
447
+ const attempts = []
448
+
449
+ connection.diagnose('error', (exception) => attempts.push(exception))
450
+ amqplib.connect.mockImplementation(async () => { throw { code: 'ECONNREFUSED' } }) // eslint-disable-line
451
+
452
+ conn.emit('close', new Error('lost'))
453
+
454
+ await connection.close()
455
+
456
+ const failed = attempts.length
457
+
458
+ await timeout(1500)
459
+
460
+ expect(attempts.length).toStrictEqual(failed)
461
+ }, 10000)
334
462
  })
335
463
 
336
464
  describe('diagnostics', () => {
@@ -54,6 +54,13 @@ describe.each(['request', 'reply', 'event'])('%s channel events',
54
54
  })
55
55
  })
56
56
 
57
+ it('should not throw on error without listeners', async () => {
58
+ const call = connection.diagnose.mock.calls.find(([event]) => event === 'error')
59
+ const emit = call[1]
60
+
61
+ expect(() => emit(new Error(generate()))).not.toThrow()
62
+ })
63
+
57
64
  it.each(['open', 'close'])('should re-emit %s from connection',
58
65
  /**
59
66
  * @param {comq.diagnostics.Event} event
@@ -40,11 +40,45 @@ it('should report a lost shard', async () => {
40
40
 
41
41
  expect(calls.length).toBeGreaterThan(0)
42
42
 
43
- for (const call of calls) call[1]()
43
+ for (const call of calls) call[1](new Error('gone'))
44
44
 
45
45
  expect(lost).toHaveBeenCalledWith(1)
46
46
  })
47
47
 
48
+ it('should not report a lost shard when the connection is closed', async () => {
49
+ channel = await create(connections, type)
50
+
51
+ const lost = /** @type {jest.MockedFunction} */ jest.fn()
52
+
53
+ channel.diagnose('lost', lost)
54
+
55
+ connections[1].closed = true
56
+
57
+ const calls = connections[1].diagnose.mock.calls.filter((call) => call[0] === 'close')
58
+
59
+ expect(calls.length).toBeGreaterThan(0)
60
+
61
+ for (const call of calls) call[1](new Error('gone'))
62
+
63
+ expect(lost).not.toHaveBeenCalled()
64
+ })
65
+
66
+ it('should not report a lost shard when the connection is closed cleanly', async () => {
67
+ channel = await create(connections, type)
68
+
69
+ const lost = /** @type {jest.MockedFunction} */ jest.fn()
70
+
71
+ channel.diagnose('lost', lost)
72
+
73
+ const calls = connections[1].diagnose.mock.calls.filter((call) => call[0] === 'close')
74
+
75
+ expect(calls.length).toBeGreaterThan(0)
76
+
77
+ for (const call of calls) call[1]()
78
+
79
+ expect(lost).not.toHaveBeenCalled()
80
+ })
81
+
48
82
  it('should not publish to a lost shard', async () => {
49
83
  channel = await create(connections, type)
50
84
 
@@ -7,6 +7,7 @@ declare namespace comq {
7
7
 
8
8
  interface Connection {
9
9
  readonly connected?: boolean
10
+ readonly closed?: boolean
10
11
 
11
12
  open(): Promise<void>
12
13
 
package/types/io.d.ts CHANGED
@@ -59,7 +59,7 @@ declare namespace comq {
59
59
 
60
60
  diagnose(event: 'open', listener: (index?: number) => void): void
61
61
 
62
- diagnose(event: 'close', listener: (index?: number) => void): void
62
+ diagnose(event: 'close', listener: (error?: Error, index?: number) => void): void
63
63
 
64
64
  diagnose(event: 'error', listener: (error: Error, index?: number) => void): void
65
65
 
@@ -69,6 +69,8 @@ declare namespace comq {
69
69
 
70
70
  diagnose(event: 'remove', listener: (index?: number) => void): void
71
71
 
72
+ diagnose(event: 'lost', listener: (channel: _topology.type, index?: number) => void): void
73
+
72
74
  diagnose(event: 'recover', listener: (channel: _topology.type, index?: number) => void): void
73
75
 
74
76
  diagnose(event: 'discard', listener: (channel: _topology.type, message: any, index?: number) => void): void