comq 0.15.7 → 0.16.0

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 (56) hide show
  1. package/.releaserc +15 -6
  2. package/features/channels.feature +14 -0
  3. package/features/recovery.feature +30 -0
  4. package/features/silence.feature +46 -0
  5. package/features/steps/.types/context.d.ts +17 -0
  6. package/features/steps/backpressure.js +1 -1
  7. package/features/steps/broker.js +1 -1
  8. package/features/steps/brokers.js +1 -1
  9. package/features/steps/channels.js +45 -0
  10. package/features/steps/common.js +1 -1
  11. package/features/steps/connection.js +41 -1
  12. package/features/steps/context.js +24 -4
  13. package/features/steps/events.js +1 -1
  14. package/features/steps/hooks.js +2 -0
  15. package/features/steps/network.js +50 -0
  16. package/features/steps/networks.js +90 -0
  17. package/features/steps/parameters.js +6 -0
  18. package/features/steps/poison.js +1 -1
  19. package/features/steps/rpc.js +1 -1
  20. package/features/steps/tasks.js +1 -1
  21. package/package.json +8 -9
  22. package/readme.md +17 -2
  23. package/source/attributes/lazy.test.js +1 -1
  24. package/source/attributes/track.test.js +3 -3
  25. package/source/channel.js +21 -0
  26. package/source/connection.js +112 -12
  27. package/source/emitter.js +23 -1
  28. package/source/encoders/index.js +0 -2
  29. package/source/events.js +2 -2
  30. package/source/io.js +12 -5
  31. package/source/shards/channel.js +28 -7
  32. package/source/shards/connection.js +0 -5
  33. package/test/amqplib.mock.js +20 -4
  34. package/test/channel.fixtures.js +1 -1
  35. package/test/channel.test.js +44 -1
  36. package/test/connection.mock.js +2 -0
  37. package/test/connection.test.js +226 -6
  38. package/test/decode.test.js +0 -13
  39. package/test/emitter.test.js +71 -0
  40. package/test/encode.test.js +0 -9
  41. package/test/encodings.js +1 -1
  42. package/test/helpers.js +77 -0
  43. package/test/io.close.test.js +25 -3
  44. package/test/io.diagnostics.test.js +2 -2
  45. package/test/io.reply.test.js +2 -2
  46. package/test/io.request.test.js +16 -2
  47. package/test/pipeline.test.js +1 -1
  48. package/test/shards/channel.test.js +63 -9
  49. package/test/shards/connection.test.js +4 -3
  50. package/types/channel.d.ts +4 -0
  51. package/types/diagnostic.d.ts +2 -2
  52. package/types/encoding.d.ts +1 -2
  53. package/types/io.d.ts +2 -0
  54. package/AGENTS.md +0 -35
  55. package/CHANGELOG.md +0 -85
  56. package/source/encoders/msgpack.js +0 -6
package/readme.md CHANGED
@@ -299,7 +299,6 @@ If the specified encoding format is not supported, an exception will be thrown.
299
299
  The following encoding formats are supported:
300
300
 
301
301
  - `application/json`
302
- - `application/msgpack`
303
302
  - `application/octet-stream`
304
303
  - `text/plain`
305
304
 
@@ -314,6 +313,12 @@ the connection is restored.
314
313
 
315
314
  When the established connection is lost, it will be automatically restored.
316
315
  Reconnection attempts will be made indefinitely, with intervals increasing up to 30 seconds.
316
+ Unless the URL sets one, a 15 second heartbeat is requested, so that a connection that is gone
317
+ without a word, such as after a machine wakes from sleep, is noticed within a minute instead of
318
+ being left to whatever the broker suggests.
319
+ A connection that stays silent for three heartbeats is destroyed regardless of what the broker and
320
+ the operating system have reported, since neither is guaranteed to report anything at all.
321
+ Requesting `heartbeat=0` disables both.
317
322
  If the broker rejects the connection, for example, due to access being denied, an exception will be thrown.
318
323
  Once reconnected, the topology will be recovered, and any unanswered requests and unconfirmed events will be
319
324
  retransmitted.
@@ -493,10 +498,13 @@ Subscribe to one of the diagnostic events:
493
498
  - `error`: an attempt to restore the connection has failed. The exception is passed as an argument.
494
499
  Attempts continue until one succeeds.
495
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.
496
504
  - `flow`: back pressure is applied to a channel. [Channel type](./types/topology.d.ts) is passed as
497
505
  an argument.
498
506
  - `drain`: back pressure is removed from a channel. Channel type is passed.
499
- - `remove`: channel is removed from the [pool](#sharded-connection).
507
+ - `remove`: channel is removed from the [pool](#sharded-connection), having failed to publish.
500
508
  - `lost`: a shard has lost its connection, hence the requests awaiting their replies on it are
501
509
  re-sent. Channel type is passed.
502
510
  - `recover`: channel's topology is recovered. Channel type is passed.
@@ -504,7 +512,13 @@ Subscribe to one of the diagnostic events:
504
512
  exceptions. Channel type,
505
513
  raw [amqp message object](https://amqp-node.github.io/amqplib/channel_api.html#channel_consume)
506
514
  and the exception are passed as arguments.
515
+ - `return`: message is returned by the broker as unroutable. Channel type and the raw
516
+ [amqp message object](https://amqp-node.github.io/amqplib/channel_api.html#channel_publish) are
517
+ passed as arguments. In the case of a [sharded connection](#sharded-connection), the message is
518
+ reported only once every shard has rejected it.
507
519
  - `pause`: channel is paused. Channel type is passed.
520
+ In the case of a [sharded connection](#sharded-connection), it means that there is no shard left
521
+ to publish to, be it because every one of them has rejected a publish or lost its connection.
508
522
  - `resume`: channel is resumed. Channel type is passed.
509
523
 
510
524
  In the case of a [sharded connection](#sharded-connection), an additional argument specifying the
@@ -522,6 +536,7 @@ established, there is no way to capture the initial `open` event.
522
536
  io.diagnose('flow', (type) => console.log(`Back pressure was applied to the ${type} channel`))
523
537
 
524
538
  io.diagnose('reconnect', (shard) => console.log('AMQP reconnecting', { shard }))
539
+ io.diagnose('exhausted', (limit, shard) => console.log('AMQP channels exhausted', { limit, shard }))
525
540
  io.diagnose('open', (shard) => console.log('AMQP connection established', { shard }))
526
541
  io.diagnose('close', (error, shard) => console.log('AMQP connection closed', { message: error?.message, shard }))
527
542
  io.diagnose('error', (error, shard) => console.log('AMQP connection failed', { message: error.message, shard }))
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const { generate } = require('randomstring')
4
- const { timeout } = require('@toa.io/generic')
4
+ const { timeout } = require('../../test/helpers')
5
5
 
6
6
  const { lazy } = require('./lazy')
7
7
 
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const { generate } = require('randomstring')
4
- const { promex } = require('@toa.io/generic')
4
+ const { Promex } = require('promex')
5
5
 
6
6
  const { track } = require('./track')
7
7
 
@@ -35,8 +35,8 @@ let test
35
35
  beforeEach(() => {
36
36
  jest.clearAllMocks()
37
37
 
38
- done = promex()
39
- undone = promex()
38
+ done = new Promex()
39
+ undone = new Promex()
40
40
  test = new Test()
41
41
  })
42
42
 
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
 
@@ -50,10 +50,7 @@ class Connection {
50
50
  * @param {string} url
51
51
  */
52
52
  constructor (url) {
53
- this.#url = url
54
-
55
- // EventEmitter throws on 'error' with no listeners
56
- this.#diagnostics.on('error', noop)
53
+ this.#url = heartbeaten(url)
57
54
  }
58
55
 
59
56
  get connected () {
@@ -102,6 +99,7 @@ class Connection {
102
99
  const topology = presets[type]
103
100
  const channel = await channels.create(this.#connection, topology, index)
104
101
 
102
+ this.#channels = this.#channels.filter((channel) => !channel.closed)
105
103
  this.#channels.push(channel)
106
104
 
107
105
  return channel
@@ -143,6 +141,8 @@ class Connection {
143
141
  this.#diagnostics.emit('open')
144
142
 
145
143
  try {
144
+ this.#channels = this.#channels.filter((channel) => !channel.closed)
145
+
146
146
  for (const channel of this.#channels) await channel.recover(connection)
147
147
  } catch (exception) {
148
148
  this.#diagnostics.emit('error', exception)
@@ -200,10 +200,30 @@ class Connection {
200
200
  }
201
201
 
202
202
  connection.removeAllListeners()
203
- connection.connection?.stream?.destroy()
203
+
204
+ // amqplib keeps its heartbeater running on a connection it does not know is
205
+ // gone and emits 'error' on it, which throws once no listener is left
206
+ connection.on('error', noop)
207
+
208
+ // a socket destroyed without an error tells amqplib nothing, leaving its
209
+ // timers running and everything pending on it hanging forever
210
+ connection.connection?.stream?.destroy(silence())
204
211
  }
205
212
 
206
- #recover () {
213
+ /**
214
+ * @param {Error} [exception]
215
+ * @return {Promise<void> | false}
216
+ */
217
+ #recover (exception) {
218
+ // a connection that has no channel left to give is not something reconnecting
219
+ // fixes, and `#recovery` on a connection that is perfectly well is a promise
220
+ // nobody ever resolves — returning `false` lets the caller see the refusal
221
+ if (exception?.message === EXHAUSTED) {
222
+ this.#diagnostics.emit('exhausted', this.#connection?.connection?.channelMax)
223
+
224
+ return false
225
+ }
226
+
207
227
  return this.#recovery
208
228
  }
209
229
 
@@ -215,7 +235,7 @@ class Connection {
215
235
  * @return {Promise<comq.amqp.Connection>}
216
236
  */
217
237
  async #connect () {
218
- const connecting = amqp.connect(this.#url, { timeout: CONNECT_MS })
238
+ const connecting = amqp.connect(this.#url, SOCKET_OPTIONS)
219
239
 
220
240
  let timer
221
241
 
@@ -248,19 +268,23 @@ class Connection {
248
268
  * @param {comq.amqp.Connection} connection
249
269
  * @param {number} [timeoutMs]
250
270
  */
251
- #armWatchdog (connection, timeoutMs = global.COMQ_TESTING_WATCHDOG_INTERVAL ?? WATCHDOG_MS) {
271
+ #armWatchdog (connection, timeoutMs = tolerance(connection)) {
272
+ this.#disarmWatchdog()
273
+
252
274
  const socket = connection.connection?.stream
253
275
 
254
- if (socket === undefined) return
276
+ if (socket === undefined || timeoutMs === undefined) return
255
277
 
256
278
  const reset = () => {
257
279
  clearTimeout(this.#heartbeatTimer)
258
- this.#heartbeatTimer = setTimeout(() => socket.destroy(), timeoutMs)
280
+ // destroying a socket without an error tells amqplib nothing: it only
281
+ // listens for 'error' and 'end', so a bare destroy() leaves the connection
282
+ // silently dead — no 'close' event, no recovery, every pending operation
283
+ // hanging forever
284
+ this.#heartbeatTimer = setTimeout(() => socket.destroy(silence()), timeoutMs)
259
285
  this.#heartbeatTimer.unref()
260
286
  }
261
287
 
262
- this.#disarmWatchdog()
263
-
264
288
  this.#heartbeatSocket = socket
265
289
  this.#heartbeatReset = reset
266
290
 
@@ -289,7 +313,25 @@ class Connection {
289
313
  }
290
314
  }
291
315
 
316
+ /**
317
+ * The heartbeat to ask for when the caller has not, in seconds. A broker is free
318
+ * to suggest one that leaves a connection lost for minutes before anything
319
+ * notices, and RabbitMQ suggests 60 by default.
320
+ *
321
+ * @type {number}
322
+ */
323
+ const HEARTBEAT_S = 15
324
+
292
325
  /** @type {number} */
326
+ const KEEPALIVE_MS = 10_000
327
+
328
+ /** How many heartbeats a connection may miss before it is destroyed. */
329
+ const MISSED_HEARTBEATS = 3
330
+
331
+ /** @type {number} */
332
+ const WATCHDOG_MIN_MS = 15_000
333
+
334
+ /** The watchdog interval used when the negotiated heartbeat is unknown. */
293
335
  const WATCHDOG_MS = 60_000
294
336
 
295
337
  /** @type {number} */
@@ -298,6 +340,18 @@ const CONNECT_MS = 30_000
298
340
  /** @type {number} */
299
341
  const SHUTDOWN_MS = 5_000
300
342
 
343
+ const SOCKET_OPTIONS = {
344
+ timeout: CONNECT_MS,
345
+ // a peer that went away without a word is noticed by the kernel as well
346
+ keepAlive: true,
347
+ keepAliveDelay: KEEPALIVE_MS
348
+ }
349
+
350
+ const HEARTBEAT_SET = /[?&]heartbeat=/
351
+
352
+ /** What amqplib says when the negotiated channel limit leaves no identifier free. */
353
+ const EXHAUSTED = 'No channels left to allocate'
354
+
301
355
  const TRANSIENT_CODES = new Set([
302
356
  'ECONNREFUSED',
303
357
  'EAI_AGAIN',
@@ -323,6 +377,52 @@ function expiration () {
323
377
  return delay(timeoutMs, undefined, { ref: false })
324
378
  }
325
379
 
380
+ /**
381
+ * amqplib reads the heartbeat from the URL only, hence it cannot be passed along
382
+ * with the socket options.
383
+ *
384
+ * @param {string} url
385
+ * @return {string}
386
+ */
387
+ function heartbeaten (url) {
388
+ if (HEARTBEAT_SET.test(url)) return url
389
+
390
+ return url + (url.includes('?') ? '&' : '?') + 'heartbeat=' + HEARTBEAT_S
391
+ }
392
+
393
+ /**
394
+ * The watchdog measures silence, so it cannot be shorter than the interval at
395
+ * which a healthy broker is expected to say something, which is a heartbeat
396
+ * frame every half a heartbeat. A connection that has agreed to no heartbeats
397
+ * says nothing at all while it is idle, leaving nothing to measure.
398
+ *
399
+ * @param {comq.amqp.Connection} connection
400
+ * @return {number | undefined}
401
+ */
402
+ function tolerance (connection) {
403
+ const override = global.COMQ_TESTING_WATCHDOG_INTERVAL
404
+
405
+ if (override !== undefined) return override
406
+
407
+ const heartbeat = connection.connection?.heartbeat
408
+
409
+ if (heartbeat === undefined) return WATCHDOG_MS
410
+ if (heartbeat === 0) return undefined
411
+
412
+ return Math.max(heartbeat * MISSED_HEARTBEATS * 1000, WATCHDOG_MIN_MS)
413
+ }
414
+
415
+ /**
416
+ * @return {Error}
417
+ */
418
+ function silence () {
419
+ const exception = new Error('Connection is silent')
420
+
421
+ exception.code = 'ETIMEDOUT'
422
+
423
+ return exception
424
+ }
425
+
326
426
  function noop () {}
327
427
 
328
428
  exports.Connection = Connection
package/source/emitter.js CHANGED
@@ -2,8 +2,30 @@
2
2
 
3
3
  const { EventEmitter } = require('node:events')
4
4
 
5
+ /**
6
+ * A listener must not be able to break what it observes: an exception thrown by
7
+ * one used to leave the reconnection it was reporting on unfinished, or take the
8
+ * process down through the emitter amqplib was calling.
9
+ */
10
+ class Emitter extends EventEmitter {
11
+ emit (event, ...args) {
12
+ // raw listeners are used so that `once` still removes itself
13
+ const listeners = this.rawListeners(event)
14
+
15
+ for (const listener of listeners) {
16
+ try {
17
+ listener.apply(this, args)
18
+ } catch {
19
+ // a diagnostic listener has no one to report to
20
+ }
21
+ }
22
+
23
+ return listeners.length > 0
24
+ }
25
+ }
26
+
5
27
  function create () {
6
- const emitter = new EventEmitter()
28
+ const emitter = new Emitter()
7
29
 
8
30
  emitter.setMaxListeners(0)
9
31
 
@@ -1,9 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const json = require('./json')
4
- const msgpack = require('./msgpack')
5
4
  const plain = require('./plain')
6
5
 
7
6
  exports['application/json'] = json
8
- exports['application/msgpack'] = msgpack
9
7
  exports['text/plain'] = plain
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
- exports.channel = ['flow', 'drain', 'recover', 'discard', 'pause', 'resume', 'return', 'lost']
7
+ exports.channel = ['flow', 'drain', 'recover', 'discard', 'pause', 'resume', 'return', 'lost', 'remove']
package/source/io.js CHANGED
@@ -1,7 +1,6 @@
1
1
  'use strict'
2
2
 
3
3
  const stream = require('node:stream')
4
- const { EventEmitter } = require('node:events')
5
4
  const { randomBytes } = require('node:crypto')
6
5
  const { setTimeout } = require('node:timers/promises')
7
6
  const { Promex } = require('promex')
@@ -11,6 +10,7 @@ const { decode } = require('./decode')
11
10
  const { encode } = require('./encode')
12
11
  const { pipeline, transform } = require('./pipeline')
13
12
  const events = require('./events')
13
+ const emitter = require('./emitter')
14
14
  const io = require('./.io')
15
15
 
16
16
  /**
@@ -44,7 +44,7 @@ class IO {
44
44
  /** @type {Set<comq.Destroyable>} */
45
45
  #replyPipes = new Set()
46
46
 
47
- #diagnostics = new EventEmitter()
47
+ #diagnostics = emitter.create()
48
48
 
49
49
  /**
50
50
  * @param {comq.Connection} connection
@@ -52,9 +52,6 @@ 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
-
58
55
  for (const event of events.connection) {
59
56
  this.#connection.diagnose(event, (...args) => this.#diagnostics.emit(event, ...args))
60
57
  }
@@ -166,6 +163,12 @@ class IO {
166
163
  await this.seal()
167
164
  await this.#destroyStreams(this.#replyPipes)
168
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
+
169
172
  await this.#connection.close()
170
173
  })
171
174
 
@@ -220,6 +223,10 @@ class IO {
220
223
  } else {
221
224
  this.#requests.diagnose('recover', this.#retransmit)
222
225
  }
226
+
227
+ // a reply that could not be routed is dropped by the broker, and the queue
228
+ // it was addressed to only exists again once this channel has recovered
229
+ this.#replies.diagnose('recover', this.#retransmit)
223
230
  }
224
231
 
225
232
  /**
@@ -31,6 +31,9 @@ class Channel {
31
31
  /** @type {Map<comq.Channel, Promex>} */
32
32
  #bench = new Map()
33
33
 
34
+ /** @type {boolean} */
35
+ #paused = false
36
+
34
37
  /** @type {comq.topology.type} */
35
38
  #type
36
39
 
@@ -74,6 +77,14 @@ class Channel {
74
77
  return await this.#one((channel) => channel.fire(queue, buffer, options))
75
78
  }
76
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
+
77
88
  async seal () {
78
89
  await this.#all((channel) => channel.seal())
79
90
  }
@@ -126,6 +137,9 @@ class Channel {
126
137
  this.#alive[index] = false
127
138
  this.#down[index].resolve()
128
139
 
140
+ // the channel stays in the pool, yet there is one less shard to publish to
141
+ this.#update()
142
+
129
143
  if (error !== undefined && connection.closed !== true) {
130
144
  this.#diagnostics.emit(LOST, index)
131
145
  }
@@ -134,6 +148,8 @@ class Channel {
134
148
  connection.diagnose('open', () => {
135
149
  this.#alive[index] = true
136
150
  this.#down[index] = new Promex()
151
+
152
+ this.#update()
137
153
  })
138
154
  }
139
155
 
@@ -159,6 +175,7 @@ class Channel {
159
175
  for (const event of events.channel) {
160
176
  if (event === RETURN) continue // returns are retried before being reported
161
177
  if (event === LOST) continue // a shard is lost with its connection, not its channel
178
+ if (event === REMOVE) continue // it is the pool that removes a channel, not the channel
162
179
 
163
180
  channel.diagnose(event, (...args) => this.#diagnostics.emit(event, ...args, channel.index))
164
181
  }
@@ -214,20 +231,23 @@ class Channel {
214
231
  this.#bench.set(channel, new Promex())
215
232
  this.#channels.delete(channel)
216
233
  this.#update()
217
- this.#diagnostics.emit('remove', channel.index)
234
+ this.#diagnostics.emit(REMOVE, channel.index)
218
235
  }
219
236
 
237
+ /**
238
+ * A shard leaves the pool when it rejects a publish, and stops being reachable
239
+ * when its connection is lost, which the pool is not told about. Both leave
240
+ * `#one` waiting, so both are reported the same way.
241
+ */
220
242
  #update () {
221
- const from = this.#pool?.length
222
- const to = this.#channels.size
223
-
224
243
  this.#pool = Array.from(this.#channels)
225
244
 
226
- if (from === undefined) return
245
+ const paused = this.#reachable().length === 0
227
246
 
228
- if (from !== 0 && to === 0) { this.#diagnostics.emit('pause') }
247
+ if (paused === this.#paused) return
229
248
 
230
- if (from === 0 && to !== 0) { this.#diagnostics.emit('resume') }
249
+ this.#paused = paused
250
+ this.#diagnostics.emit(paused ? 'pause' : 'resume')
231
251
  }
232
252
 
233
253
  /**
@@ -369,6 +389,7 @@ async function create (connections, type) {
369
389
 
370
390
  const DEFAULT = ''
371
391
  const RETURN = 'return'
392
+ const REMOVE = 'remove'
372
393
  const RETURN_HEADER = 'x-return'
373
394
  const LOST = 'lost'
374
395
 
@@ -19,9 +19,6 @@ 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
-
25
22
  connections.map(this.#pipe)
26
23
  }
27
24
 
@@ -75,6 +72,4 @@ class Connection {
75
72
  }
76
73
  }
77
74
 
78
- function noop () {}
79
-
80
75
  exports.Connection = Connection
@@ -15,17 +15,33 @@ 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 {
21
- constructor () {
22
+ constructor (url = '') {
22
23
  super()
23
24
 
24
25
  const stream = new EventEmitter()
25
26
 
26
- stream.destroy = jest.fn()
27
+ // amqplib listens for 'error' and 'end' on the socket only, so destroying it
28
+ // without an error leaves the connection unaware and silent forever
29
+ stream.destroy = jest.fn((error) => {
30
+ if (error === undefined || stream.destroyed === true) return
31
+
32
+ stream.destroyed = true
33
+
34
+ this.emit('error', error)
35
+ this.emit('close', error)
36
+ })
37
+
38
+ // a broker accepts the heartbeat the client asks for
39
+ const heartbeat = Number(/[?&]heartbeat=(\d+)/.exec(url)?.[1] ?? 60)
40
+
41
+ // and negotiates the channel limit down to whatever the client asked for
42
+ const channelMax = Number(/[?&]channelMax=(\d+)/.exec(url)?.[1] ?? 2047)
27
43
 
28
- this.connection = { stream }
44
+ this.connection = { stream, heartbeat, channelMax }
29
45
 
30
46
  // noinspection JSValidateTypes
31
47
  this.removeAllListeners = jest.spyOn(this, 'removeAllListeners')
@@ -39,7 +55,7 @@ class Connection extends EventEmitter {
39
55
  close = jest.fn(async () => undefined)
40
56
  }
41
57
 
42
- const connect = async () => new Connection()
58
+ const connect = async (url) => new Connection(url)
43
59
 
44
60
  /** @type {jest.MockedObject<import('amqplib')>} */
45
61
  const amqplib = {
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const { random, flip } = require('@toa.io/generic')
3
+ const { random, flip } = require('./helpers')
4
4
 
5
5
  const preset = () => ({
6
6
  prefetch: random(10),
@@ -4,7 +4,7 @@
4
4
 
5
5
  const { randomBytes } = require('node:crypto')
6
6
  const { generate } = require('randomstring')
7
- const { flip, random, timeout } = require('@toa.io/generic')
7
+ const { flip, random, timeout } = require('./helpers')
8
8
 
9
9
  const backpressure = require('./backpressure')
10
10
  const { amqplib } = require('./amqplib.mock')
@@ -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