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
@@ -4,7 +4,8 @@
4
4
 
5
5
  const { generate } = require('randomstring')
6
6
 
7
- const { timeout, promex, random } = require('@toa.io/generic')
7
+ const { Promex } = require('promex')
8
+ const { timeout, random } = require('./helpers')
8
9
  const { amqplib, connect } = require('./amqplib.mock')
9
10
  const { channel: create } = require('./connection.mock')
10
11
  const mock = { amqplib, channel: { create } }
@@ -37,7 +38,8 @@ describe('initial connection', () => {
37
38
  it('should connect', async () => {
38
39
  await connection.open()
39
40
 
40
- expect(amqplib.connect).toHaveBeenCalledWith(url, { timeout: expect.any(Number) })
41
+ expect(amqplib.connect).toHaveBeenCalledWith(expect.stringContaining(url),
42
+ expect.objectContaining({ timeout: expect.any(Number) }))
41
43
  })
42
44
 
43
45
  it.each(/** @type {[string, Partial<Error>][]} */[
@@ -138,6 +140,41 @@ describe('reconnection', () => {
138
140
  expect(amqplib.connect.mock.calls.length).toBeGreaterThanOrEqual(3)
139
141
  }, 15000)
140
142
 
143
+ it('should keep an error sink on a dropped connection', async () => {
144
+ const channel = await connection.createChannel('request')
145
+
146
+ channel.recover
147
+ .mockRejectedValueOnce(new Error('Channel closed'))
148
+ .mockResolvedValue(undefined)
149
+
150
+ conn.emit('close', new Error('lost'))
151
+
152
+ await timeout(10)
153
+
154
+ const dropped = await amqplib.connect.mock.results[1].value
155
+
156
+ // amqplib goes on emitting on a connection it does not know is gone, and an
157
+ // 'error' with no listener left takes the process down
158
+ expect(() => dropped.emit('error', new Error('Heartbeat timeout'))).not.toThrow()
159
+
160
+ // let the attempt that follows the failed recovery settle
161
+ const start = Date.now()
162
+
163
+ while (channel.recover.mock.calls.length < 2 && Date.now() - start < 10000) await timeout(50)
164
+ }, 15000)
165
+
166
+ it('should reconnect despite a listener that throws', async () => {
167
+ // a diagnostic listener has no business breaking the reconnection it reports
168
+ connection.diagnose('reconnect', () => { throw new Error('listener') })
169
+ connection.diagnose('open', () => { throw new Error('listener') })
170
+
171
+ conn.emit('close', new Error('lost'))
172
+
173
+ await expect(connection.createChannel('event')).resolves.toBeDefined()
174
+
175
+ expect(amqplib.connect).toHaveBeenCalledTimes(2)
176
+ }, 10000)
177
+
141
178
  it('should emit error when reconnect open fails', async () => {
142
179
  const errors = []
143
180
  const unhandled = jest.fn()
@@ -339,7 +376,7 @@ describe('create channel', () => {
339
376
 
340
377
  jest.clearAllMocks()
341
378
 
342
- const promise = promex()
379
+ const promise = new Promex()
343
380
 
344
381
  amqplib.connect.mockImplementationOnce(() => promise)
345
382
 
@@ -361,6 +398,106 @@ describe('create channel', () => {
361
398
  })
362
399
  })
363
400
 
401
+ describe('closed channels', () => {
402
+ /** @type {jest.MockedObject<comq.amqp.Connection>} */
403
+ let conn
404
+
405
+ beforeEach(async () => {
406
+ // an earlier case leaves an implementation of its own behind
407
+ create.mockImplementation(async () => ({ recover: jest.fn(async () => undefined), closed: false }))
408
+
409
+ await connection.open()
410
+
411
+ conn = await amqplib.connect.mock.results[0].value
412
+ })
413
+
414
+ afterEach(async () => {
415
+ await connection.close()
416
+ })
417
+
418
+ // a channel given back is not one this connection has any more
419
+ it('should not recover one that was given back', async () => {
420
+ const channel = await connection.createChannel('event')
421
+
422
+ channel.closed = true
423
+
424
+ conn.emit('close', new Error())
425
+
426
+ await timeout(50)
427
+
428
+ expect(channel.recover).not.toHaveBeenCalled()
429
+ })
430
+
431
+ it('should recover one that is still in use', async () => {
432
+ const channel = await connection.createChannel('event')
433
+
434
+ conn.emit('close', new Error())
435
+
436
+ await timeout(50)
437
+
438
+ expect(channel.recover).toHaveBeenCalled()
439
+ })
440
+ })
441
+
442
+ describe('channel exhaustion', () => {
443
+ const EXHAUSTED = 'No channels left to allocate'
444
+
445
+ /** @type {jest.MockedObject<comq.amqp.Connection>} */
446
+ let conn
447
+
448
+ beforeEach(async () => {
449
+ await connection.open()
450
+
451
+ conn = await amqplib.connect.mock.results[0].value
452
+ })
453
+
454
+ it('should reject rather than wait for a recovery that is not coming', async () => {
455
+ create.mockImplementation(async () => { throw new Error(EXHAUSTED) })
456
+
457
+ // the regression is a hang, so the assertion is that it settles at all
458
+ const settled = await Promise.race([
459
+ connection.createChannel('event').then(() => 'resolved', (e) => e.message),
460
+ timeout(100).then(() => 'pending')
461
+ ])
462
+
463
+ expect(settled).toStrictEqual(EXHAUSTED)
464
+ })
465
+
466
+ it('should not retry', async () => {
467
+ create.mockImplementation(async () => { throw new Error(EXHAUSTED) })
468
+
469
+ await connection.createChannel('event').catch(() => undefined)
470
+
471
+ expect(create).toHaveBeenCalledTimes(1)
472
+ })
473
+
474
+ it('should report the limit the broker negotiated', async () => {
475
+ const listener = jest.fn()
476
+
477
+ await connection.diagnose('exhausted', listener)
478
+
479
+ create.mockImplementation(async () => { throw new Error(EXHAUSTED) })
480
+
481
+ await connection.createChannel('event').catch(() => undefined)
482
+
483
+ expect(listener).toHaveBeenCalledWith(conn.connection.channelMax)
484
+ })
485
+
486
+ it('should leave every other failure its recovery', async () => {
487
+ create.mockImplementation(async () => { throw new Error() })
488
+
489
+ setTimeout(() => {
490
+ create.mockImplementation(async () => generate())
491
+
492
+ conn.emit('close', new Error())
493
+ }, 1)
494
+
495
+ const channel = await connection.createChannel('request')
496
+
497
+ expect(channel).toStrictEqual(await create.mock.results[1].value)
498
+ })
499
+ })
500
+
364
501
  describe('watchdog', () => {
365
502
  afterEach(() => {
366
503
  jest.useRealTimers()
@@ -373,11 +510,23 @@ describe('watchdog', () => {
373
510
 
374
511
  const conn = await amqplib.connect.mock.results[0].value
375
512
 
376
- await jest.advanceTimersByTimeAsync(60_000)
513
+ await jest.advanceTimersByTimeAsync(46_000)
377
514
 
378
515
  expect(conn.connection.stream.destroy).toHaveBeenCalled()
379
516
  })
380
517
 
518
+ it('should reconnect after the watchdog destroys a silent connection', async () => {
519
+ jest.useFakeTimers()
520
+
521
+ await connection.open()
522
+
523
+ await jest.advanceTimersByTimeAsync(46_000)
524
+
525
+ // a socket destroyed without an error is a socket amqplib never reports,
526
+ // which leaves the connection silently dead instead of recovering
527
+ expect(amqplib.connect).toHaveBeenCalledTimes(2)
528
+ })
529
+
381
530
  it('should not let a replaced socket disarm the watchdog', async () => {
382
531
  jest.useFakeTimers()
383
532
 
@@ -407,11 +556,11 @@ describe('watchdog', () => {
407
556
 
408
557
  const conn = await amqplib.connect.mock.results[0].value
409
558
 
410
- await jest.advanceTimersByTimeAsync(50_000)
559
+ await jest.advanceTimersByTimeAsync(40_000)
411
560
 
412
561
  conn.connection.stream.emit('data', Buffer.alloc(0))
413
562
 
414
- await jest.advanceTimersByTimeAsync(50_000)
563
+ await jest.advanceTimersByTimeAsync(40_000)
415
564
 
416
565
  expect(conn.connection.stream.destroy).not.toHaveBeenCalled()
417
566
 
@@ -419,6 +568,77 @@ describe('watchdog', () => {
419
568
 
420
569
  expect(conn.connection.stream.destroy).toHaveBeenCalled()
421
570
  })
571
+
572
+ it('should derive the watchdog from the negotiated heartbeat', async () => {
573
+ jest.useFakeTimers()
574
+
575
+ const connection = new Connection('amqp://localhost?heartbeat=30')
576
+
577
+ await connection.open()
578
+
579
+ const conn = await amqplib.connect.mock.results[0].value
580
+
581
+ // the broker is only expected to say something every 15 seconds
582
+ await jest.advanceTimersByTimeAsync(60_000)
583
+
584
+ expect(conn.connection.stream.destroy).not.toHaveBeenCalled()
585
+
586
+ await jest.advanceTimersByTimeAsync(31_000)
587
+
588
+ expect(conn.connection.stream.destroy).toHaveBeenCalled()
589
+ })
590
+
591
+ it('should not arm the watchdog when heartbeats are disabled', async () => {
592
+ jest.useFakeTimers()
593
+
594
+ const connection = new Connection('amqp://localhost?heartbeat=0')
595
+
596
+ await connection.open()
597
+
598
+ const conn = await amqplib.connect.mock.results[0].value
599
+
600
+ // an idle connection that has agreed to no heartbeats is silent by design
601
+ await jest.advanceTimersByTimeAsync(600_000)
602
+
603
+ expect(conn.connection.stream.destroy).not.toHaveBeenCalled()
604
+ })
605
+ })
606
+
607
+ describe('heartbeat', () => {
608
+ it('should ask for a heartbeat', async () => {
609
+ const connection = new Connection('amqp://developer@localhost')
610
+
611
+ await connection.open()
612
+
613
+ expect(amqplib.connect).toHaveBeenCalledWith('amqp://developer@localhost?heartbeat=15',
614
+ expect.anything())
615
+ })
616
+
617
+ it('should append the heartbeat to an existing query', async () => {
618
+ const connection = new Connection('amqp://localhost?frameMax=8192')
619
+
620
+ await connection.open()
621
+
622
+ expect(amqplib.connect).toHaveBeenCalledWith('amqp://localhost?frameMax=8192&heartbeat=15',
623
+ expect.anything())
624
+ })
625
+
626
+ it.each(['amqp://localhost?heartbeat=5', 'amqp://localhost?heartbeat=0'])(
627
+ 'should keep the heartbeat requested by the caller (%s)',
628
+ async (url) => {
629
+ const connection = new Connection(url)
630
+
631
+ await connection.open()
632
+
633
+ expect(amqplib.connect).toHaveBeenCalledWith(url, expect.anything())
634
+ })
635
+
636
+ it('should keep the socket alive', async () => {
637
+ await connection.open()
638
+
639
+ expect(amqplib.connect)
640
+ .toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ keepAlive: true }))
641
+ })
422
642
  })
423
643
 
424
644
  describe('close', () => {
@@ -2,7 +2,6 @@
2
2
 
3
3
  const { randomBytes } = require('node:crypto')
4
4
  const { generate } = require('randomstring')
5
- const { pack } = require('msgpackr')
6
5
 
7
6
  const { decode } = require('../source/decode')
8
7
 
@@ -23,18 +22,6 @@ it('should decode application/json', async () => {
23
22
  expect(decoded).toStrictEqual(object)
24
23
  })
25
24
 
26
- it('should decode application/msgpack', async () => {
27
- const object = { [generate()]: generate() }
28
- const content = pack(object)
29
- const contentType = 'application/msgpack'
30
- const properties = { contentType }
31
- const message = /** @type {comq.amqp.Message} */ { content, properties }
32
-
33
- const decoded = decode(message)
34
-
35
- expect(decoded).toStrictEqual(object)
36
- })
37
-
38
25
  it('should return buffer if content type is not defined', async () => {
39
26
  const object = { [generate()]: generate() }
40
27
  const json = JSON.stringify(object)
@@ -0,0 +1,71 @@
1
+ 'use strict'
2
+
3
+ const { generate } = require('randomstring')
4
+ const emitter = require('../source/emitter')
5
+
6
+ /** @type {import('node:events').EventEmitter} */
7
+ let instance
8
+
9
+ beforeEach(() => {
10
+ instance = emitter.create()
11
+ })
12
+
13
+ it('should be', async () => {
14
+ expect(emitter.create).toBeInstanceOf(Function)
15
+ })
16
+
17
+ it('should emit', async () => {
18
+ const listener = jest.fn()
19
+ const event = generate()
20
+ const args = [generate(), generate()]
21
+
22
+ instance.on(event, listener)
23
+
24
+ expect(instance.emit(event, ...args)).toStrictEqual(true)
25
+ expect(listener).toHaveBeenCalledWith(...args)
26
+ })
27
+
28
+ it('should report an event no one listens to', async () => {
29
+ expect(instance.emit(generate())).toStrictEqual(false)
30
+ })
31
+
32
+ it('should not throw on error without listeners', async () => {
33
+ expect(() => instance.emit('error', new Error(generate()))).not.toThrow()
34
+ })
35
+
36
+ it('should not let a listener break the emission', async () => {
37
+ const event = generate()
38
+ const listener = jest.fn()
39
+
40
+ instance.on(event, () => { throw new Error(generate()) })
41
+ instance.on(event, listener)
42
+
43
+ expect(() => instance.emit(event)).not.toThrow()
44
+
45
+ // the listeners that follow a broken one are still called
46
+ expect(listener).toHaveBeenCalled()
47
+ })
48
+
49
+ it('should call a `once` listener once', async () => {
50
+ const event = generate()
51
+ const listener = jest.fn()
52
+
53
+ instance.once(event, listener)
54
+
55
+ instance.emit(event)
56
+ instance.emit(event)
57
+
58
+ expect(listener).toHaveBeenCalledTimes(1)
59
+ })
60
+
61
+ it('should not call a listener that has been removed', async () => {
62
+ const event = generate()
63
+ const listener = jest.fn()
64
+
65
+ instance.on(event, listener)
66
+ instance.off(event, listener)
67
+
68
+ instance.emit(event)
69
+
70
+ expect(listener).not.toHaveBeenCalled()
71
+ })
@@ -1,7 +1,6 @@
1
1
  'use strict'
2
2
 
3
3
  const { generate } = require('randomstring')
4
- const { pack } = require('msgpackr')
5
4
 
6
5
  const { encode } = require('../source/encode')
7
6
 
@@ -26,14 +25,6 @@ it('should encode as json', async () => {
26
25
  expect(buffer).toStrictEqual(expected)
27
26
  })
28
27
 
29
- it('should encode as msgpack', async () => {
30
- const value = { [generate()]: generate() }
31
- const packed = pack(value)
32
- const buffer = encode(value, 'application/msgpack')
33
-
34
- expect(buffer).toStrictEqual(packed)
35
- })
36
-
37
28
  /** @type {[comq.Encoding, string, Buffer][]} */
38
29
  const samples = [
39
30
  ['text/plain', 'some string', Buffer.from('some string')]
package/test/encodings.js CHANGED
@@ -1,4 +1,4 @@
1
1
  'use strict'
2
2
 
3
3
  /** @type {comq.Encoding[]} */
4
- exports.encodings = ['application/msgpack', 'application/json']
4
+ exports.encodings = ['application/json']
@@ -0,0 +1,77 @@
1
+ 'use strict'
2
+
3
+ const timeout = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
4
+
5
+ const immediate = () => new Promise((resolve) => setImmediate(resolve))
6
+
7
+ const random = (max = 100) => Math.floor(Math.random() * max)
8
+
9
+ const flip = () => Math.random() < 0.5
10
+
11
+ const sample = (array) => array[Math.floor(Math.random() * array.length)]
12
+
13
+ const quantity = (input) => {
14
+ const matched = String(input).match(/^(\d+(?:\.\d+)?)([^\d\W]*)$/)
15
+
16
+ if (matched === null) {
17
+ throw new Error(`'${input}' doesn't look like a quantity of something`)
18
+ }
19
+
20
+ const number = +matched[1]
21
+ const suffix = matched[2]
22
+
23
+ if (suffix.length === 0) {
24
+ return number
25
+ }
26
+
27
+ const pair = MULTIPLIERS.find(([unit]) => suffix.startsWith(unit))
28
+
29
+ if (pair === undefined) {
30
+ throw new Error(`'${suffix}' doesn't look like a quantity unit`)
31
+ }
32
+
33
+ return number * pair[1]
34
+ }
35
+
36
+ const match = (reference, candidate) => {
37
+ if (typeof candidate !== typeof reference) {
38
+ return false
39
+ }
40
+
41
+ if (Array.isArray(candidate)) {
42
+ return Array.isArray(reference) &&
43
+ candidate.every((value) => reference.some((item) => match(item, value)))
44
+ }
45
+
46
+ if (typeof candidate === 'object') {
47
+ if (candidate === null) {
48
+ return reference === null
49
+ }
50
+
51
+ if (reference === null) {
52
+ return false
53
+ }
54
+
55
+ return Object.entries(candidate).every(([key, value]) => match(reference[key], value))
56
+ }
57
+
58
+ return reference === candidate
59
+ }
60
+
61
+ const MULTIPLIERS = Object.entries({
62
+ k: 1000,
63
+ Ki: 1024,
64
+ K: 1024,
65
+ Mi: 1024 ** 2,
66
+ M: 1000 ** 2,
67
+ Gi: 1024 ** 3,
68
+ G: 1000 ** 3
69
+ })
70
+
71
+ exports.timeout = timeout
72
+ exports.immediate = immediate
73
+ exports.random = random
74
+ exports.flip = flip
75
+ exports.sample = sample
76
+ exports.quantity = quantity
77
+ exports.match = match
@@ -2,7 +2,8 @@
2
2
 
3
3
  const { randomBytes } = require('node:crypto')
4
4
  const { generate } = require('randomstring')
5
- const { promex, immediate } = require('@toa.io/generic')
5
+ const { Promex } = require('promex')
6
+ const { immediate } = require('./helpers')
6
7
 
7
8
  const mock = require('./connection.mock')
8
9
  const { IO } = require('../source/io')
@@ -92,8 +93,29 @@ describe('close', () => {
92
93
  await expect(io.close()).resolves.not.toThrow()
93
94
  })
94
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
+
95
117
  it('should wait for event processing completion', async () => {
96
- const promise = /** @type {Promise<void>} */ promex()
118
+ const promise = /** @type {Promise<void>} */ new Promex()
97
119
  const queue = generate()
98
120
  const group = generate()
99
121
  const consumer = jest.fn(async () => promise)
@@ -128,7 +150,7 @@ describe('close', () => {
128
150
  })
129
151
 
130
152
  it('should wait for request processing completion', async () => {
131
- const promise = /** @type {Promise<void>} */ promex()
153
+ const promise = /** @type {Promise<void>} */ new Promex()
132
154
  const producer = jest.fn(async () => promise)
133
155
 
134
156
  await reply(/** @type {Function} */ producer)
@@ -28,7 +28,7 @@ describe.each(['request', 'reply', 'event'])('%s channel events',
28
28
  * @param {comq.topology.type} type
29
29
  */
30
30
  (type) => {
31
- it.each(['flow', 'drain', 'recover', 'discard', 'pause', 'resume'])('should re-emit %s',
31
+ it.each(['flow', 'drain', 'recover', 'discard', 'pause', 'resume', 'lost', 'remove'])('should re-emit %s',
32
32
  /**
33
33
  * @param {comq.diagnostics.Event} event
34
34
  */
@@ -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
  */
@@ -194,12 +194,12 @@ describe('encoding', () => {
194
194
 
195
195
  it('should set octet-stream for Buffer reply', async () => {
196
196
  const request = { [generate()]: generate() }
197
- const content = encode(request, 'application/msgpack')
197
+ const content = encode(request, 'application/json')
198
198
 
199
199
  const properties = {
200
200
  replyTo: generate(),
201
201
  correlationId: generate(),
202
- contentType: 'application/msgpack'
202
+ contentType: 'application/json'
203
203
  }
204
204
 
205
205
  const message = /** @type {comq.amqp.Message} */ { content, properties }
@@ -5,7 +5,7 @@
5
5
  const stream = require('node:stream')
6
6
  const { randomBytes } = require('node:crypto')
7
7
  const { generate } = require('randomstring')
8
- const { immediate } = require('@toa.io/generic')
8
+ const { immediate } = require('./helpers')
9
9
  const { encode } = require('../source/encode')
10
10
 
11
11
  const mock = require('./connection.mock')
@@ -182,6 +182,20 @@ describe('send', () => {
182
182
  expect(requests.send).toHaveBeenCalledTimes(2)
183
183
  })
184
184
 
185
+ // a reply that could not be routed has been dropped by the broker
186
+ it('should resend unanswered Requests when the reply channel recovers', async () => {
187
+ expect(replies.diagnose).toHaveBeenCalledWith('recover', expect.any(Function))
188
+
189
+ const calls = replies.diagnose.mock.calls.filter((call) => call[0] === 'recover')
190
+ const listeners = calls.map((call) => call[1])
191
+
192
+ for (const listener of listeners) listener()
193
+
194
+ await immediate()
195
+
196
+ expect(requests.send).toHaveBeenCalledTimes(2)
197
+ })
198
+
185
199
  it('should resend unanswered Requests on sharded connection', async () => {
186
200
  jest.clearAllMocks()
187
201
 
@@ -338,7 +352,7 @@ describe('reply', () => {
338
352
  expect(output).toStrictEqual(content)
339
353
  })
340
354
 
341
- const encodings = ['application/msgpack', 'application/json']
355
+ const encodings = ['application/json']
342
356
 
343
357
  it.each(encodings)('should decode %s',
344
358
  /**
@@ -3,7 +3,7 @@
3
3
  const { EventEmitter } = require('node:events')
4
4
  const stream = require('node:stream')
5
5
  const { pipeline, transform } = require('../source/pipeline')
6
- const { timeout } = require('@toa.io/generic')
6
+ const { timeout } = require('./helpers')
7
7
 
8
8
  /** @type {Channel} */
9
9
  let channel