comq 0.15.8 → 0.16.1

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/.releaserc CHANGED
@@ -3,8 +3,24 @@
3
3
  "release"
4
4
  ],
5
5
  "plugins": [
6
- "@semantic-release/commit-analyzer",
7
- "@semantic-release/release-notes-generator",
6
+ [
7
+ "@semantic-release/commit-analyzer",
8
+ {
9
+ "preset": "conventionalcommits",
10
+ "releaseRules": [
11
+ {
12
+ "breaking": true,
13
+ "release": "minor"
14
+ }
15
+ ]
16
+ }
17
+ ],
18
+ [
19
+ "@semantic-release/release-notes-generator",
20
+ {
21
+ "preset": "conventionalcommits"
22
+ }
23
+ ],
8
24
  [
9
25
  "@semantic-release/npm"
10
26
  ],
@@ -0,0 +1,14 @@
1
+ Feature: Channel exhaustion
2
+
3
+ A connection allocates channels from a range the broker negotiates, and once the range
4
+ is spent there is nothing a reconnection can do about it. The refusal is reported and
5
+ passed on rather than retried, so that whoever asked for a channel is told instead of
6
+ waiting for a recovery that is not coming.
7
+
8
+ Scenario: Reporting a connection with no channel left to allocate
9
+ Given the AMQP channel limit is set to 2
10
+ And an active connection to the broker
11
+ And a producer replying `echo` queue
12
+ When emitting an event to the `exhaustion` exchange is attempted
13
+ Then the exception is thrown: "No channels left to allocate"
14
+ And the `exhausted` event is emitted
@@ -18,6 +18,20 @@ Feature: Connection Tolerance
18
18
  When the broker is up
19
19
  Then the connection is restored
20
20
 
21
+ Scenario: Restoring singleton connection after broker restart
22
+ Given an active singleton connection
23
+ When the broker is down
24
+ Then the connection is lost
25
+ When the broker is up
26
+ Then the connection is restored
27
+
28
+ Scenario: Restoring sharded singleton connection after broker crash
29
+ Given an active sharded singleton connection
30
+ When the broker has crashed
31
+ Then the connection is lost
32
+ When the broker is up
33
+ Then the connection is restored
34
+
21
35
  Scenario: Connecting with wrong credentials
22
36
  When I attempt to connect to the broker as "someone" with password "whatever"
23
37
  Then an exception is thrown: "Handshake terminated by server: 403 (ACCESS-REFUSED)"
@@ -24,6 +24,22 @@ Feature: Silent Connection Tolerance
24
24
  When the consumer sends a request to the `echo` queue
25
25
  Then the consumer receives the reply
26
26
 
27
+ Scenario: Restoring a singleton connection that has gone silent
28
+ Given an active singleton connection
29
+ And a producer replying `echo` queue
30
+ When the network goes silent
31
+ Then the connection is lost within 10 seconds
32
+ When the consumer sends a request to the `echo` queue
33
+ Then the consumer receives the reply
34
+
35
+ Scenario: Restoring a sharded singleton connection whose every shard has gone silent
36
+ Given an active sharded singleton connection
37
+ And a producer replying `echo` queue
38
+ When the network goes silent
39
+ Then the connection is lost within 10 seconds
40
+ When the consumer sends a request to the `echo` queue
41
+ Then the consumer receives the reply
42
+
27
43
  Scenario: Answering a request sent as the connection goes silent
28
44
  Given an active connection to the broker
29
45
  And a producer replying `echo` queue
@@ -0,0 +1,45 @@
1
+ 'use strict'
2
+
3
+ const assert = require('node:assert')
4
+ const { randomBytes } = require('node:crypto')
5
+
6
+ const { Given, When, Then } = require('@cucumber/cucumber')
7
+
8
+ Given('the AMQP channel limit is set to {number}',
9
+ /**
10
+ * @param {number} limit
11
+ */
12
+ function (limit) {
13
+ global.COMQ_TESTING_AMQP_CHANNEL_MAX = limit
14
+ })
15
+
16
+ When('emitting an event to the {token} exchange is attempted',
17
+ /**
18
+ * @param {string} exchange
19
+ * @this {comq.features.Context}
20
+ */
21
+ async function (exchange) {
22
+ this.exception = undefined
23
+
24
+ await this.io.emit(exchange, randomBytes(8))
25
+ .catch((exception) => { this.exception = exception })
26
+ })
27
+
28
+ Then('the exception is thrown: {string}',
29
+ /**
30
+ * @param {string} message
31
+ * @this {comq.features.Context}
32
+ */
33
+ function (message) {
34
+ assert.notEqual(this.exception, undefined, 'no exception was thrown')
35
+ assert.equal(this.exception.message, message)
36
+ })
37
+
38
+ Then('the {token} event is emitted',
39
+ /**
40
+ * @param {comq.diagnostics.Event} event
41
+ * @this {comq.features.Context}
42
+ */
43
+ function (event) {
44
+ assert.equal(this.events[event], true, `the '${event}' event was not emitted`)
45
+ })
@@ -100,17 +100,27 @@ class Context extends World {
100
100
  #url (i, user, password) {
101
101
  const address = this.networks[i]?.address ?? getAddress(i)
102
102
  const url = PROTOCOL + user + ':' + password + '@' + address
103
- const heartbeat = global.COMQ_TESTING_AMQP_HEARTBEAT
103
+ const query = []
104
104
 
105
105
  // the watchdog measures silence, so it may only be shortened along with the
106
106
  // interval at which a healthy broker is expected to say something
107
- return heartbeat === undefined ? url : url + '?heartbeat=' + heartbeat
107
+ if (global.COMQ_TESTING_AMQP_HEARTBEAT !== undefined) {
108
+ query.push('heartbeat=' + global.COMQ_TESTING_AMQP_HEARTBEAT)
109
+ }
110
+
111
+ // a broker negotiates the channel limit down to what the client asks for,
112
+ // which is how exhaustion is reached without opening two thousand channels
113
+ if (global.COMQ_TESTING_AMQP_CHANNEL_MAX !== undefined) {
114
+ query.push('channelMax=' + global.COMQ_TESTING_AMQP_CHANNEL_MAX)
115
+ }
116
+
117
+ return query.length === 0 ? url : url + '?' + query.join('&')
108
118
  }
109
119
  }
110
120
 
111
121
  const PROTOCOL = 'amqp://'
112
122
 
113
123
  /** @type {comq.diagnostics.Event[]} */
114
- const EVENTS = ['open', 'close', 'flow', 'discard', 'pause', 'resume']
124
+ const EVENTS = ['open', 'close', 'flow', 'discard', 'pause', 'resume', 'exhausted']
115
125
 
116
126
  exports.Context = Context
@@ -14,6 +14,7 @@ After(
14
14
  async function () {
15
15
  delete global.COMQ_TESTING_WATCHDOG_INTERVAL
16
16
  delete global.COMQ_TESTING_AMQP_HEARTBEAT
17
+ delete global.COMQ_TESTING_AMQP_CHANNEL_MAX
17
18
 
18
19
  await Promise.all(
19
20
  Array.from({ length: BROKERS_AMOUNT }, (_, n) => actions.up(n))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.15.8",
3
+ "version": "0.16.1",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",
@@ -33,15 +33,17 @@
33
33
  "@semantic-release/github": "^12.0.2",
34
34
  "@semantic-release/npm": "^13.1.2",
35
35
  "@semantic-release/release-notes-generator": "^14.1.0",
36
- "@testcontainers/rabbitmq": "^12.0.4",
36
+ "@testcontainers/rabbitmq": "^12.1.0",
37
37
  "@types/amqplib": "^0.10.8",
38
38
  "@types/jest": "^30.0.0",
39
+ "conventional-changelog-conventionalcommits": "^8.0.0",
39
40
  "fast-glob": "^3.3.3",
40
- "fuzzysort": "3.1.0",
41
+ "fuzzysort": "4.0.2",
41
42
  "husky": "^9.1.7",
42
43
  "jest": "^30.4.2",
43
- "js-yaml": "^5.2.3",
44
+ "js-yaml": "^5.4.1",
44
45
  "randomstring": "^1.3.1",
46
+ "semantic-release": "^25.0.9",
45
47
  "snazzy": "^9.0.0",
46
48
  "standard": "^17.1.2",
47
49
  "testcontainers": "^12.0.4"
package/readme.md CHANGED
@@ -498,6 +498,9 @@ Subscribe to one of the diagnostic events:
498
498
  - `error`: an attempt to restore the connection has failed. The exception is passed as an argument.
499
499
  Attempts continue until one succeeds.
500
500
  - `reconnect`: an attempt to restore the connection has started. Followed by `open` or `error`.
501
+ - `exhausted`: the connection has no channel left to allocate. The negotiated channel limit is
502
+ passed as an argument. Reconnecting does not free a channel, so the operation that asked for
503
+ one is rejected rather than retried.
501
504
  - `flow`: back pressure is applied to a channel. [Channel type](./types/topology.d.ts) is passed as
502
505
  an argument.
503
506
  - `drain`: back pressure is removed from a channel. Channel type is passed.
@@ -533,6 +536,7 @@ established, there is no way to capture the initial `open` event.
533
536
  io.diagnose('flow', (type) => console.log(`Back pressure was applied to the ${type} channel`))
534
537
 
535
538
  io.diagnose('reconnect', (shard) => console.log('AMQP reconnecting', { shard }))
539
+ io.diagnose('exhausted', (limit, shard) => console.log('AMQP channels exhausted', { limit, shard }))
536
540
  io.diagnose('open', (shard) => console.log('AMQP connection established', { shard }))
537
541
  io.diagnose('close', (error, shard) => console.log('AMQP connection closed', { message: error?.message, shard }))
538
542
  io.diagnose('error', (error, shard) => console.log('AMQP connection failed', { message: error.message, shard }))
package/source/channel.js CHANGED
@@ -42,6 +42,8 @@ class Channel {
42
42
 
43
43
  #diagnostics = emitter.create()
44
44
 
45
+ #closed = false
46
+
45
47
  /**
46
48
  * @param {comq.amqp.Connection} connection
47
49
  * @param {comq.Topology} topology
@@ -126,6 +128,21 @@ class Channel {
126
128
  }
127
129
  }
128
130
 
131
+ /**
132
+ * Gives the channel back. A connection has a limited number of them, and one whose
133
+ * IO is done with it would otherwise be held until the whole connection goes.
134
+ */
135
+ async close () {
136
+ if (this.#closed) return
137
+
138
+ this.#closed = true
139
+
140
+ await this.seal()
141
+
142
+ // a channel that went down with its connection is the outcome this asks for
143
+ await this.#channel?.close().catch(noop)
144
+ }
145
+
129
146
  async seal () {
130
147
  this.#sealed = true
131
148
 
@@ -142,6 +159,10 @@ class Channel {
142
159
  this.#diagnostics.off(event, listener)
143
160
  }
144
161
 
162
+ get closed () {
163
+ return this.#closed
164
+ }
165
+
145
166
  async recover (connection) {
146
167
  this.#connection = connection
147
168
 
@@ -64,17 +64,10 @@ class Connection {
64
64
  async open () {
65
65
  this.#closed = false
66
66
 
67
- if (this.#opening !== null) return this.#opening
68
-
69
- this.#opening = retry(this.#open).finally(() => { this.#opening = null })
70
-
71
- await this.#opening
67
+ await this.#reopen()
72
68
 
73
69
  if (this.#closed) return
74
70
 
75
- // close may have landed after this attempt succeeded but before `#opening` was cleared
76
- if (this.#connection === undefined) return this.open()
77
-
78
71
  this.#running = true
79
72
  }
80
73
 
@@ -99,6 +92,7 @@ class Connection {
99
92
  const topology = presets[type]
100
93
  const channel = await channels.create(this.#connection, topology, index)
101
94
 
95
+ this.#channels = this.#channels.filter((channel) => !channel.closed)
102
96
  this.#channels.push(channel)
103
97
 
104
98
  return channel
@@ -140,6 +134,8 @@ class Connection {
140
134
  this.#diagnostics.emit('open')
141
135
 
142
136
  try {
137
+ this.#channels = this.#channels.filter((channel) => !channel.closed)
138
+
143
139
  for (const channel of this.#channels) await channel.recover(connection)
144
140
  } catch (exception) {
145
141
  this.#diagnostics.emit('error', exception)
@@ -169,10 +165,29 @@ class Connection {
169
165
  this.#connection = undefined
170
166
 
171
167
  if (error !== undefined && !this.#closed) {
172
- this.open().catch((exception) => this.#diagnostics.emit('error', exception))
168
+ this.#reopen().catch((exception) => this.#diagnostics.emit('error', exception))
173
169
  }
174
170
  }
175
171
 
172
+ /**
173
+ * Establishes the connection, or joins the attempt under way. A lost connection is
174
+ * restored through here and not through `open()`: what a subclass makes of `open()` is
175
+ * its own — the singleton answers it with the first opening, made once and remembered,
176
+ * which is no way to make a second one.
177
+ *
178
+ * @return {Promise<void>}
179
+ */
180
+ async #reopen () {
181
+ this.#opening ??= retry(this.#open).finally(() => { this.#opening = null })
182
+
183
+ await this.#opening
184
+
185
+ if (this.#closed) return
186
+
187
+ // close may have landed after this attempt succeeded but before `#opening` was cleared
188
+ if (this.#connection === undefined) return this.#reopen()
189
+ }
190
+
176
191
  /**
177
192
  * An AMQP connection is only closed once the broker has replied with Close-Ok,
178
193
  * which never happens on a connection that has already been lost.
@@ -207,7 +222,20 @@ class Connection {
207
222
  connection.connection?.stream?.destroy(silence())
208
223
  }
209
224
 
210
- #recover () {
225
+ /**
226
+ * @param {Error} [exception]
227
+ * @return {Promise<void> | false}
228
+ */
229
+ #recover (exception) {
230
+ // a connection that has no channel left to give is not something reconnecting
231
+ // fixes, and `#recovery` on a connection that is perfectly well is a promise
232
+ // nobody ever resolves — returning `false` lets the caller see the refusal
233
+ if (exception?.message === EXHAUSTED) {
234
+ this.#diagnostics.emit('exhausted', this.#connection?.connection?.channelMax)
235
+
236
+ return false
237
+ }
238
+
211
239
  return this.#recovery
212
240
  }
213
241
 
@@ -333,6 +361,9 @@ const SOCKET_OPTIONS = {
333
361
 
334
362
  const HEARTBEAT_SET = /[?&]heartbeat=/
335
363
 
364
+ /** What amqplib says when the negotiated channel limit leaves no identifier free. */
365
+ const EXHAUSTED = 'No channels left to allocate'
366
+
336
367
  const TRANSIENT_CODES = new Set([
337
368
  'ECONNREFUSED',
338
369
  'EAI_AGAIN',
package/source/events.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  /** @type {comq.diagnostics.Event[]} */
4
- exports.connection = ['open', 'close', 'error', 'reconnect']
4
+ exports.connection = ['open', 'close', 'error', 'reconnect', 'exhausted']
5
5
 
6
6
  /** @type {comq.diagnostics.Event[]} */
7
7
  exports.channel = ['flow', 'drain', 'recover', 'discard', 'pause', 'resume', 'return', 'lost', 'remove']
package/source/io.js CHANGED
@@ -163,6 +163,12 @@ class IO {
163
163
  await this.seal()
164
164
  await this.#destroyStreams(this.#replyPipes)
165
165
  await track(this)
166
+
167
+ // a connection is shared and outlives its IOs, so the channels are given back
168
+ // here — held to the end of the connection, they would run it out of them
169
+ await Promise.all([this.#requests, this.#replies, this.#events]
170
+ .map((channel) => channel?.close()))
171
+
166
172
  await this.#connection.close()
167
173
  })
168
174
 
@@ -77,6 +77,14 @@ class Channel {
77
77
  return await this.#one((channel) => channel.fire(queue, buffer, options))
78
78
  }
79
79
 
80
+ async close () {
81
+ await this.#all((channel) => channel.close())
82
+ }
83
+
84
+ get closed () {
85
+ return [...this.#channels].every((channel) => channel.closed)
86
+ }
87
+
80
88
  async seal () {
81
89
  await this.#all((channel) => channel.seal())
82
90
  }
@@ -15,6 +15,7 @@ class Channel extends EventEmitter {
15
15
  assertExchange = jest.fn(async () => undefined)
16
16
  bindQueue = jest.fn(async () => undefined)
17
17
  publish = jest.fn((_0, _1, _2, _3, resolve) => resolve?.(null))
18
+ close = jest.fn(async () => undefined)
18
19
  }
19
20
 
20
21
  class Connection extends EventEmitter {
@@ -37,7 +38,10 @@ class Connection extends EventEmitter {
37
38
  // a broker accepts the heartbeat the client asks for
38
39
  const heartbeat = Number(/[?&]heartbeat=(\d+)/.exec(url)?.[1] ?? 60)
39
40
 
40
- this.connection = { stream, heartbeat }
41
+ // and negotiates the channel limit down to whatever the client asked for
42
+ const channelMax = Number(/[?&]channelMax=(\d+)/.exec(url)?.[1] ?? 2047)
43
+
44
+ this.connection = { stream, heartbeat, channelMax }
41
45
 
42
46
  // noinspection JSValidateTypes
43
47
  this.removeAllListeners = jest.spyOn(this, 'removeAllListeners')
@@ -503,6 +503,49 @@ describe('seal', () => {
503
503
  })
504
504
  })
505
505
 
506
+ describe('close', () => {
507
+ const queue = generate()
508
+ const consumer = jest.fn()
509
+
510
+ beforeEach(async () => {
511
+ channel = await create(connection, topology)
512
+ chan = await getCreatedChannel()
513
+ })
514
+
515
+ it('should give the channel back', async () => {
516
+ await channel.close()
517
+
518
+ expect(chan.close).toHaveBeenCalled()
519
+ expect(channel.closed).toStrictEqual(true)
520
+ })
521
+
522
+ it('should stop consuming before giving it back', async () => {
523
+ await channel.consume(queue, consumer)
524
+
525
+ const { consumerTag: tag } = await chan.consume.mock.results[0].value
526
+
527
+ await channel.close()
528
+
529
+ expect(chan.cancel).toHaveBeenCalledWith(tag)
530
+ expect(chan.cancel.mock.invocationCallOrder[0])
531
+ .toBeLessThan(chan.close.mock.invocationCallOrder[0])
532
+ })
533
+
534
+ it('should give it back once', async () => {
535
+ await channel.close()
536
+ await channel.close()
537
+
538
+ expect(chan.close).toHaveBeenCalledTimes(1)
539
+ })
540
+
541
+ // one that went down with its connection is the outcome this asks for
542
+ it('should ignore one that is already gone', async () => {
543
+ chan.close.mockImplementationOnce(async () => { throw new Error() })
544
+
545
+ await expect(channel.close()).resolves.not.toThrow()
546
+ })
547
+ })
548
+
506
549
  describe('back pressure', () => {
507
550
  const exchange = generate()
508
551
  const queue = generate()
@@ -12,6 +12,8 @@ const channel = /** @type {jest.MockedFunction<(sharded?: boolean, index?: numbe
12
12
  publish: jest.fn(async () => undefined),
13
13
  diagnose: jest.fn(async () => undefined),
14
14
  seal: jest.fn(async () => undefined),
15
+ close: jest.fn(async () => undefined),
16
+ closed: false,
15
17
  recover: jest.fn(async () => undefined)
16
18
  }))
17
19
 
@@ -184,7 +184,7 @@ describe('reconnection', () => {
184
184
 
185
185
  const boom = new Error('reconnect failed')
186
186
 
187
- connection.open = jest.fn(async () => { throw boom })
187
+ amqplib.connect.mockRejectedValueOnce(boom)
188
188
 
189
189
  conn.emit('close', new Error('broker down'))
190
190
 
@@ -194,8 +194,38 @@ describe('reconnection', () => {
194
194
 
195
195
  expect(errors).toContain(boom)
196
196
  expect(unhandled).not.toHaveBeenCalled()
197
+
198
+ // the attempt is retried in the background, and would connect in a later case
199
+ await connection.close()
197
200
  })
198
201
 
202
+ // the singleton answers `open()` with the first opening, made once and remembered
203
+ it('should reconnect a connection whose open() is remembered', async () => {
204
+ class Remembered extends Connection {
205
+ #opened = null
206
+
207
+ async open () {
208
+ this.#opened ??= super.open()
209
+
210
+ await this.#opened
211
+ }
212
+ }
213
+
214
+ const remembered = new Remembered(generate())
215
+
216
+ await remembered.open()
217
+
218
+ const first = await amqplib.connect.mock.results.at(-1).value
219
+
220
+ first.emit('close', new Error('lost'))
221
+
222
+ await expect(remembered.createChannel('event')).resolves.toBeDefined()
223
+
224
+ expect(amqplib.connect).toHaveBeenCalledTimes(3)
225
+
226
+ await remembered.close()
227
+ }, 10000)
228
+
199
229
  it('should reconnect when connection is lost while channels recover', async () => {
200
230
  const channel = await connection.createChannel('request')
201
231
 
@@ -398,6 +428,106 @@ describe('create channel', () => {
398
428
  })
399
429
  })
400
430
 
431
+ describe('closed channels', () => {
432
+ /** @type {jest.MockedObject<comq.amqp.Connection>} */
433
+ let conn
434
+
435
+ beforeEach(async () => {
436
+ // an earlier case leaves an implementation of its own behind
437
+ create.mockImplementation(async () => ({ recover: jest.fn(async () => undefined), closed: false }))
438
+
439
+ await connection.open()
440
+
441
+ conn = await amqplib.connect.mock.results[0].value
442
+ })
443
+
444
+ afterEach(async () => {
445
+ await connection.close()
446
+ })
447
+
448
+ // a channel given back is not one this connection has any more
449
+ it('should not recover one that was given back', async () => {
450
+ const channel = await connection.createChannel('event')
451
+
452
+ channel.closed = true
453
+
454
+ conn.emit('close', new Error())
455
+
456
+ await timeout(50)
457
+
458
+ expect(channel.recover).not.toHaveBeenCalled()
459
+ })
460
+
461
+ it('should recover one that is still in use', async () => {
462
+ const channel = await connection.createChannel('event')
463
+
464
+ conn.emit('close', new Error())
465
+
466
+ await timeout(50)
467
+
468
+ expect(channel.recover).toHaveBeenCalled()
469
+ })
470
+ })
471
+
472
+ describe('channel exhaustion', () => {
473
+ const EXHAUSTED = 'No channels left to allocate'
474
+
475
+ /** @type {jest.MockedObject<comq.amqp.Connection>} */
476
+ let conn
477
+
478
+ beforeEach(async () => {
479
+ await connection.open()
480
+
481
+ conn = await amqplib.connect.mock.results[0].value
482
+ })
483
+
484
+ it('should reject rather than wait for a recovery that is not coming', async () => {
485
+ create.mockImplementation(async () => { throw new Error(EXHAUSTED) })
486
+
487
+ // the regression is a hang, so the assertion is that it settles at all
488
+ const settled = await Promise.race([
489
+ connection.createChannel('event').then(() => 'resolved', (e) => e.message),
490
+ timeout(100).then(() => 'pending')
491
+ ])
492
+
493
+ expect(settled).toStrictEqual(EXHAUSTED)
494
+ })
495
+
496
+ it('should not retry', async () => {
497
+ create.mockImplementation(async () => { throw new Error(EXHAUSTED) })
498
+
499
+ await connection.createChannel('event').catch(() => undefined)
500
+
501
+ expect(create).toHaveBeenCalledTimes(1)
502
+ })
503
+
504
+ it('should report the limit the broker negotiated', async () => {
505
+ const listener = jest.fn()
506
+
507
+ await connection.diagnose('exhausted', listener)
508
+
509
+ create.mockImplementation(async () => { throw new Error(EXHAUSTED) })
510
+
511
+ await connection.createChannel('event').catch(() => undefined)
512
+
513
+ expect(listener).toHaveBeenCalledWith(conn.connection.channelMax)
514
+ })
515
+
516
+ it('should leave every other failure its recovery', async () => {
517
+ create.mockImplementation(async () => { throw new Error() })
518
+
519
+ setTimeout(() => {
520
+ create.mockImplementation(async () => generate())
521
+
522
+ conn.emit('close', new Error())
523
+ }, 1)
524
+
525
+ const channel = await connection.createChannel('request')
526
+
527
+ expect(channel).toStrictEqual(await create.mock.results[1].value)
528
+ })
529
+ })
530
+
401
531
  describe('watchdog', () => {
402
532
  afterEach(() => {
403
533
  jest.useRealTimers()
@@ -93,6 +93,27 @@ describe('close', () => {
93
93
  await expect(io.close()).resolves.not.toThrow()
94
94
  })
95
95
 
96
+ // a connection is shared and outlives its IOs, and it has a limited number of channels
97
+ it('should give the channels back', async () => {
98
+ await reply()
99
+ await io.close()
100
+
101
+ expect(requests.close).toHaveBeenCalled()
102
+ expect(events.close).toHaveBeenCalled()
103
+ })
104
+
105
+ it('should give them back before letting go of the connection', async () => {
106
+ await reply()
107
+
108
+ connection.close.mockImplementation(async () => {
109
+ expect(requests.close).toHaveBeenCalled()
110
+ })
111
+
112
+ await io.close()
113
+
114
+ expect.assertions(1)
115
+ })
116
+
96
117
  it('should wait for event processing completion', async () => {
97
118
  const promise = /** @type {Promise<void>} */ new Promex()
98
119
  const queue = generate()
@@ -61,7 +61,7 @@ it('should not throw on error without listeners', async () => {
61
61
  expect(() => emit(new Error(generate()))).not.toThrow()
62
62
  })
63
63
 
64
- it.each(['open', 'close', 'error', 'reconnect'])('should re-emit %s from connection',
64
+ it.each(['open', 'close', 'error', 'reconnect', 'exhausted'])('should re-emit %s from connection',
65
65
  /**
66
66
  * @param {comq.diagnostics.Event} event
67
67
  */
@@ -27,6 +27,10 @@ declare namespace comq {
27
27
 
28
28
  seal (): Promise<void>
29
29
 
30
+ close (): Promise<void>
31
+
32
+ readonly closed: boolean
33
+
30
34
  diagnose (event: _diagnostics.Event, listener: Function): void
31
35
 
32
36
  forget (event: _diagnostics.Event, listener: Function): void
@@ -1,7 +1,7 @@
1
1
  declare namespace comq.diagnostics {
2
2
 
3
- type Event = 'open' | 'close' | 'error' | 'reconnect' | 'flow' | 'drain' | 'remove' | 'lost' |
4
- 'recover' | 'discard' | 'pause' | 'resume' | 'return'
3
+ type Event = 'open' | 'close' | 'error' | 'reconnect' | 'exhausted' | 'flow' | 'drain' |
4
+ 'remove' | 'lost' | 'recover' | 'discard' | 'pause' | 'resume' | 'return'
5
5
 
6
6
  interface Diagnosable {
7
7
  diagnose(event: Event, listener: Function): void
package/types/io.d.ts CHANGED
@@ -65,6 +65,8 @@ declare namespace comq {
65
65
 
66
66
  diagnose(event: 'reconnect', listener: (index?: number) => void): void
67
67
 
68
+ diagnose(event: 'exhausted', listener: (limit?: number, index?: number) => void): void
69
+
68
70
  diagnose(event: 'flow', listener: (channel: _topology.type, index?: number) => void): void
69
71
 
70
72
  diagnose(event: 'drain', listener: (channel: _topology.type, index?: number) => void): void