comq 0.13.0 → 0.15.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.
@@ -3,7 +3,7 @@
3
3
  const stream = require('node:stream')
4
4
  const assert = require('node:assert')
5
5
  const { randomBytes } = require('node:crypto')
6
- const { load: parse } = require('js-yaml')
6
+ const { parse } = require('./yaml')
7
7
  const { match, timeout, quantity } = require('@toa.io/generic')
8
8
  const { Given, When, Then } = require('@cucumber/cucumber')
9
9
  const { Readable } = require('node:stream')
@@ -0,0 +1,15 @@
1
+ 'use strict'
2
+
3
+ const { load } = require('js-yaml')
4
+
5
+ /**
6
+ * @param {string} [yaml]
7
+ * @returns {any}
8
+ */
9
+ function parse (yaml) {
10
+ if (yaml === undefined || yaml.trim() === '') return undefined
11
+
12
+ return load(yaml)
13
+ }
14
+
15
+ exports.parse = parse
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",
@@ -23,37 +23,47 @@
23
23
  },
24
24
  "main": "source/index.js",
25
25
  "types": "types/index.d.ts",
26
+ "bin": {
27
+ "feat": "scripts/feat.js"
28
+ },
26
29
  "devDependencies": {
27
- "@cucumber/cucumber": "^10.0.1",
28
- "@toa.io/command": "^0.4.0",
29
- "@toa.io/generic": "^1.0.0-alpha.0",
30
- "@types/amqplib": "^0.10.3",
31
- "@types/jest": "^29.5.8",
32
- "husky": "^9.0.11",
33
- "jest": "^29.5.0",
34
- "js-yaml": "^4.1.1",
35
- "randomstring": "^1.3.0",
30
+ "@cucumber/cucumber": "^13.2.0",
31
+ "@semantic-release/changelog": "^7.0.0",
32
+ "@semantic-release/commit-analyzer": "^13.0.1",
33
+ "@semantic-release/git": "^10.0.1",
34
+ "@semantic-release/github": "^12.0.2",
35
+ "@semantic-release/npm": "^13.1.2",
36
+ "@semantic-release/release-notes-generator": "^14.1.0",
37
+ "@testcontainers/rabbitmq": "^12.0.4",
38
+ "@toa.io/generic": "^1.0.0-alpha.208",
39
+ "@types/amqplib": "^0.10.8",
40
+ "@types/jest": "^30.0.0",
41
+ "fast-glob": "^3.3.3",
42
+ "fuzzysort": "3.1.0",
43
+ "husky": "^9.1.7",
44
+ "jest": "^30.4.2",
45
+ "js-yaml": "^5.2.3",
46
+ "randomstring": "^1.3.1",
36
47
  "snazzy": "^9.0.0",
37
- "standard": "^17.1.0"
48
+ "standard": "^17.1.2",
49
+ "testcontainers": "^12.0.4"
38
50
  },
39
51
  "dependencies": {
40
52
  "amqplib": "^2.0.1",
41
- "msgpackr": "^2.0.1",
53
+ "msgpackr": "^2.0.5",
42
54
  "promex": "^0.1.1",
43
55
  "reretry": "^0.1.0"
44
56
  },
45
57
  "scripts": {
46
- "prepare": "echo run: husky install",
58
+ "prepare": "husky",
47
59
  "test": "standard && jest",
48
60
  "features": "cucumber-js --fail-fast -t 'not @manual'",
61
+ "features:fast": "cucumber-js --fail-fast -t 'not @manual and not @heavy'",
49
62
  "lint": "standard --fix --verbose | snazzy"
50
63
  },
51
64
  "standard": {
52
65
  "env": [
53
66
  "jest"
54
67
  ]
55
- },
56
- "workspaces": [
57
- "."
58
- ]
68
+ }
59
69
  }
package/readme.md CHANGED
@@ -103,10 +103,11 @@ await io.reply('add_numbers', ({ a, b }) => (a + b))
103
103
 
104
104
  ## Request
105
105
 
106
- `async IO.request(queue: string, payload: any, encoding?: string): any`
106
+ `async IO.request(queue: string, payload: any, encoding?: string, timeout?: number): any`
107
107
 
108
108
  Send encoded Request message with `replyTo` and `correlationId` properties set and
109
- return decoded Reply content.
109
+ return decoded Reply content. Optional `timeout` (ms) rejects with `ETIMEDOUT` if no
110
+ Reply arrives in time; encoding may be omitted when only `timeout` is passed.
110
111
 
111
112
  On the initial call, queues for Requests and Replies are asserted.
112
113
 
@@ -114,6 +115,7 @@ On the initial call, queues for Requests and Replies are asserted.
114
115
 
115
116
  ```javascript
116
117
  const sum = await io.request('add_numbers', { a: 1, b: 2 })
118
+ const sumOrTimeout = await io.request('add_numbers', { a: 1, b: 2 }, 5000)
117
119
  ```
118
120
 
119
121
  ## Consumption
@@ -329,14 +331,15 @@ combined into a cluster.
329
331
  Outgoing messages are sent to a single connection chosen at random from the shard pool. Shards that lose their
330
332
  underlying connection or experience channel [back pressure](#flow-control) on a corresponding channel are removed from
331
333
  the pool until the issue is resolved. Pending messages meeting these conditions are immediately routed among the
332
- remaining shards in the pool. If no shards are available, messages will wait until a shard's connection is
333
- re-established.
334
+ remaining shards in the pool. If no shards are available, `send` / `publish` / `request` wait until a shard's
335
+ connection is re-established (unbounded). For RPC, pass a [`request` timeout](#request) so callers fail with
336
+ `ETIMEDOUT` instead of hanging while the pool is empty or a Reply never arrives.
334
337
 
335
338
  Incoming messages are consumed from all shards.
336
339
 
337
340
  `async connect(...shards: string[]): IO`
338
341
 
339
- Returns an instance of `IO` once a successful connection to one of the shards is established.
342
+ Returns an instance of `IO` once connections to the shards are established.
340
343
 
341
344
  ### Example
342
345
 
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ const { spawnSync } = require('node:child_process')
5
+ const path = require('node:path')
6
+ const fg = require('fast-glob')
7
+ const fuzzysort = require('fuzzysort')
8
+
9
+ const ROOT = path.resolve(__dirname, '..')
10
+ const USAGE = 'Usage: feat <query> [name] [cucumber-args...]'
11
+
12
+ function toKey (file) {
13
+ return path.relative(ROOT, file)
14
+ .replace(/^features\//, '')
15
+ .replace(/\.feature$/, '')
16
+ .replace(/\./g, '/')
17
+ }
18
+
19
+ function normalizeQuery (query) {
20
+ return query.replace(/\./g, '/').replace(/\\/g, '/')
21
+ }
22
+
23
+ async function main () {
24
+ const args = process.argv.slice(2)
25
+ const dashIndex = args.findIndex((arg) => arg.startsWith('-'))
26
+ const positional = dashIndex === -1 ? args : args.slice(0, dashIndex)
27
+ const query = positional[0]
28
+ const name = positional[1]
29
+
30
+ const cucumberArgs = [
31
+ ...(name ? ['--name', name] : []),
32
+ ...positional.slice(2),
33
+ ...(dashIndex === -1 ? [] : args.slice(dashIndex))
34
+ ]
35
+
36
+ if (!query) {
37
+ console.error(USAGE)
38
+ process.exit(1)
39
+ }
40
+
41
+ const files = await fg('features/**/*.feature', { cwd: ROOT, absolute: true })
42
+ const items = files.map((file) => ({ file, key: toKey(file) }))
43
+ const result = fuzzysort.go(normalizeQuery(query), items, { key: 'key', limit: 1 })
44
+
45
+ if (result.length === 0) {
46
+ console.error(`No feature files match "${query}"`)
47
+ process.exit(1)
48
+ }
49
+
50
+ const selected = result.map(({ obj }) => obj.file)
51
+
52
+ for (const file of selected) { console.error(path.relative(ROOT, file)) }
53
+
54
+ const cucumberBin = path.join(ROOT, 'node_modules', '.bin', 'cucumber-js')
55
+
56
+ const run = spawnSync(cucumberBin, [...selected, ...cucumberArgs], {
57
+ cwd: ROOT,
58
+ stdio: 'inherit'
59
+ })
60
+
61
+ process.exit(run.status ?? 1)
62
+ }
63
+
64
+ main().catch((error) => {
65
+ console.error(error.message)
66
+ process.exit(1)
67
+ })
package/source/channel.js CHANGED
@@ -34,6 +34,9 @@ class Channel {
34
34
  /** @type {Promex} */
35
35
  #recovery = new Promex()
36
36
 
37
+ /** @type {boolean} */
38
+ #recovering = false
39
+
37
40
  /** @type {Set<Promex>} */
38
41
  #confirmations = new Set()
39
42
 
@@ -145,7 +148,14 @@ class Channel {
145
148
  await this.create()
146
149
 
147
150
  lazy.reset(this)
148
- await recall(this)
151
+
152
+ this.#recovering = true
153
+
154
+ try {
155
+ await recall(this)
156
+ } finally {
157
+ this.#recovering = false
158
+ }
149
159
 
150
160
  this.#unpause(INTERRUPTION)
151
161
 
@@ -332,6 +342,7 @@ class Channel {
332
342
 
333
343
  async #recover (exception) {
334
344
  if (permanent(exception)) return false
345
+ if (this.#recovering) return false
335
346
  else await this.#recovery
336
347
  }
337
348
  }
@@ -25,9 +25,15 @@ class Connection {
25
25
  /** @type {Promex} */
26
26
  #recovery = new Promex()
27
27
 
28
+ /** @type {Promise<void> | null} */
29
+ #opening = null
30
+
28
31
  /** @type {boolean} */
29
32
  #running = false
30
33
 
34
+ /** @type {NodeJS.Timeout | null} */
35
+ #heartbeatTimer = null
36
+
31
37
  #diagnostics = emitter.create()
32
38
 
33
39
  /**
@@ -35,10 +41,17 @@ class Connection {
35
41
  */
36
42
  constructor (url) {
37
43
  this.#url = url
44
+
45
+ // EventEmitter throws on 'error' with no listeners
46
+ this.#diagnostics.on('error', noop)
38
47
  }
39
48
 
40
49
  async open () {
41
- await retry(this.#open)
50
+ if (this.#opening !== null) return this.#opening
51
+
52
+ this.#opening = retry(this.#open).finally(() => { this.#opening = null })
53
+
54
+ await this.#opening
42
55
 
43
56
  this.#running = true
44
57
  }
@@ -71,8 +84,11 @@ class Connection {
71
84
  }
72
85
 
73
86
  #open = async (retry) => {
87
+ /** @type {comq.amqp.Connection} */
88
+ let connection
89
+
74
90
  try {
75
- this.#connection = await amqp.connect(this.#url)
91
+ connection = await amqp.connect(this.#url)
76
92
  } catch (exception) {
77
93
  if (this.#transient(exception)) return retry
78
94
  else throw exception
@@ -80,40 +96,104 @@ class Connection {
80
96
 
81
97
  // This prevents the process from crashing; 'close' will be emitted next.
82
98
  // https://amqp-node.github.io/amqplib/channel_api.html#model_events
83
- this.#connection.on('error', noop)
99
+ connection.on('error', noop)
84
100
 
85
- this.#connection.on('close', this.#close)
101
+ connection.on('close', (error) => this.#close(connection, error))
102
+ this.#connection = connection
103
+ this.#armWatchdog(connection)
86
104
  this.#diagnostics.emit('open')
87
105
 
88
- for (const channel of this.#channels) await channel.recover(this.#connection)
106
+ try {
107
+ for (const channel of this.#channels) await channel.recover(connection)
108
+ } catch (exception) {
109
+ this.#disarmWatchdog()
110
+ this.#diagnostics.emit('error', exception)
111
+ connection.removeAllListeners()
112
+
113
+ await connection.close().catch(noop)
114
+
115
+ if (this.#connection === connection) this.#connection = undefined
116
+
117
+ return retry
118
+ }
89
119
 
90
120
  this.#recovery.resolve()
91
121
  this.#recovery = new Promex()
92
122
  }
93
123
 
94
124
  /**
95
- * @param {Error} error
125
+ * @param {comq.amqp.Connection} connection
126
+ * @param {Error} [error]
96
127
  */
97
- #close = async (error) => {
128
+ #close = (connection, error) => {
129
+ this.#disarmWatchdog()
130
+
131
+ if (this.#connection !== connection) return
132
+
98
133
  this.#diagnostics.emit('close', error)
99
- this.#connection.removeAllListeners()
134
+ connection.removeAllListeners()
100
135
  this.#connection = undefined
101
136
 
102
- if (error !== undefined) await this.open()
137
+ if (error !== undefined) {
138
+ this.open().catch((exception) => this.#diagnostics.emit('error', exception))
139
+ }
103
140
  }
104
141
 
105
142
  #recover () {
106
143
  return this.#recovery
107
144
  }
108
145
 
146
+ /**
147
+ * @param {comq.amqp.Connection} connection
148
+ * @param {number} [timeoutMs]
149
+ */
150
+ #armWatchdog (connection, timeoutMs = WATCHDOG_MS) {
151
+ const socket = connection.connection?.stream
152
+
153
+ if (socket === undefined) return
154
+
155
+ const reset = () => {
156
+ clearTimeout(this.#heartbeatTimer)
157
+ this.#heartbeatTimer = setTimeout(() => socket.destroy(), timeoutMs)
158
+ this.#heartbeatTimer.unref()
159
+ }
160
+
161
+ reset()
162
+ socket.on('data', reset)
163
+ }
164
+
165
+ #disarmWatchdog () {
166
+ clearTimeout(this.#heartbeatTimer)
167
+ this.#heartbeatTimer = null
168
+ }
169
+
109
170
  #transient (exception) {
110
- const abruptly = exception.message === 'Socket closed abruptly during opening handshake'
111
- const tls = exception.message === 'Client network socket disconnected before secure TLS connection was established'
171
+ if (this.#running) return true
172
+ if (TRANSIENT_CODES.has(exception.code)) return true
173
+ if (TRANSIENT_MESSAGES.has(exception.message)) return true
112
174
 
113
- return this.#running || abruptly || tls
175
+ return false
114
176
  }
115
177
  }
116
178
 
179
+ /** @type {number} */
180
+ const WATCHDOG_MS = 60_000
181
+
182
+ const TRANSIENT_CODES = new Set([
183
+ 'ECONNREFUSED',
184
+ 'EAI_AGAIN',
185
+ 'ENOTFOUND',
186
+ 'ETIMEDOUT',
187
+ 'ECONNRESET',
188
+ 'EHOSTUNREACH',
189
+ 'ENETUNREACH'
190
+ ])
191
+
192
+ const TRANSIENT_MESSAGES = new Set([
193
+ 'Socket closed abruptly during opening handshake',
194
+ 'Client network socket disconnected before secure TLS connection was established'
195
+ ])
196
+
117
197
  function noop () {}
118
198
 
119
199
  exports.Connection = Connection
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']
4
+ exports.connection = ['open', 'close', 'error']
5
5
 
6
6
  /** @type {comq.diagnostics.Event[]} */
7
7
  exports.channel = ['flow', 'drain', 'recover', 'discard', 'pause', 'resume']
package/source/io.js CHANGED
@@ -3,7 +3,7 @@
3
3
  const stream = require('node:stream')
4
4
  const { EventEmitter } = require('node:events')
5
5
  const { randomBytes } = require('node:crypto')
6
- const { setTimeout } = require('node:timers/promises')
6
+ const { setTimeout: delay } = require('node:timers/promises')
7
7
  const { Promex } = require('promex')
8
8
  const { memo, failsafe, lazy, track } = require('./attributes')
9
9
 
@@ -75,24 +75,45 @@ class IO {
75
75
  /**
76
76
  * @param {string} queue
77
77
  * @param {any | Readable} payload
78
- * @param {comq.Encoding} [encoding]
78
+ * @param {comq.Encoding | number} [encoding]
79
+ * @param {number} [timeout]
79
80
  * @returns {Promise<any | Readable>}
80
81
  */
81
- async (queue, payload, encoding) => {
82
+ async (queue, payload, encoding, timeout) => {
83
+ if (typeof encoding === 'number') {
84
+ timeout = encoding
85
+ encoding = undefined
86
+ }
87
+
82
88
  if (payload instanceof stream.Readable) {
83
89
  return pipeline(
84
90
  payload,
85
- (payload) => this.request(queue, payload, encoding),
91
+ (payload) => this.request(queue, payload, encoding, timeout),
86
92
  this.#requests
87
93
  )
88
94
  }
89
95
 
90
- const request = this.#createRequest(queue, payload, encoding)
96
+ const request = this.#createRequest(queue, payload, /** @type {comq.Encoding} */ encoding)
91
97
  const reply = this.#createReply(request)
92
98
 
93
- await this.#requests.send(queue, request.buffer, request.properties)
99
+ const done = this.#requests.send(queue, request.buffer, request.properties)
100
+ .then(() => reply)
101
+
102
+ if (typeof timeout !== 'number') return done
94
103
 
95
- return reply
104
+ try {
105
+ return await Promise.race([
106
+ done,
107
+ delay(timeout).then(() => Promise.reject(timeoutError(timeout)))
108
+ ])
109
+ } catch (exception) {
110
+ if (exception.code === 'ETIMEDOUT') {
111
+ request.emitter.removeAllListeners(request.properties.correlationId)
112
+ reply.reject(exception)
113
+ }
114
+
115
+ throw exception
116
+ }
96
117
  }))
97
118
 
98
119
  consume = lazy(this, this.#createEventChannel,
@@ -396,7 +417,7 @@ class IO {
396
417
  Even if these messages are lost, the reply stream will be closed anyway,
397
418
  either due to missing heartbeat or the deletion of the stream queue.
398
419
  */
399
- await setTimeout(50)
420
+ await delay(50)
400
421
  }
401
422
 
402
423
  /**
@@ -456,6 +477,19 @@ const DEFAULT = 'application/json'
456
477
 
457
478
  const RETRANSMISSION = /** @type {Error} */ Symbol('retransmission')
458
479
 
480
+ /**
481
+ * @param {number} timeout
482
+ * @returns {Error}
483
+ */
484
+ function timeoutError (timeout) {
485
+ const error = /** @type {Error & { code: string }} */ new Error(
486
+ `Request timed out after ${timeout}ms`)
487
+
488
+ error.code = 'ETIMEDOUT'
489
+
490
+ return error
491
+ }
492
+
459
493
  function noop () {}
460
494
 
461
495
  exports.IO = IO
@@ -21,6 +21,12 @@ class Connection extends EventEmitter {
21
21
  constructor () {
22
22
  super()
23
23
 
24
+ const stream = new EventEmitter()
25
+
26
+ stream.destroy = jest.fn()
27
+
28
+ this.connection = { stream }
29
+
24
30
  // noinspection JSValidateTypes
25
31
  this.removeAllListeners = jest.spyOn(this, 'removeAllListeners')
26
32
 
@@ -552,6 +552,29 @@ describe('recovery', () => {
552
552
  await expect(channel.consume(queue, consumer)).rejects.toStrictEqual(exception)
553
553
  })
554
554
 
555
+ it('should not deadlock when recall hits a transient error during recover', async () => {
556
+ await channel.consume(queue, consumer)
557
+
558
+ const replacement = await amqplib.connect()
559
+ const method = `create${topology.confirms ? 'Confirm' : ''}Channel`
560
+
561
+ replacement[method].mockImplementationOnce(async () => {
562
+ const transient = await amqplib.connect()
563
+ const bad = await transient[method]()
564
+
565
+ bad.consume.mockImplementation(async () => { throw new Error('Channel closed') })
566
+
567
+ return bad
568
+ })
569
+
570
+ const deadlock = timeout(200).then(() => {
571
+ throw new Error('recover hung (deadlock)')
572
+ })
573
+
574
+ await expect(Promise.race([channel.recover(replacement), deadlock]))
575
+ .rejects.toThrow('Channel closed')
576
+ })
577
+
555
578
  describe.each(/** @type {[string, boolean][]} */ [
556
579
  ['', false],
557
580
  [' not', true]
@@ -40,7 +40,15 @@ describe('initial connection', () => {
40
40
  })
41
41
 
42
42
  it.each(/** @type {[string, Partial<Error>][]} */[
43
- ['Socket closed', { message: 'Socket closed abruptly during opening handshake' }]
43
+ ['Socket closed', { message: 'Socket closed abruptly during opening handshake' }],
44
+ ['TLS disconnect', { message: 'Client network socket disconnected before secure TLS connection was established' }],
45
+ ['ECONNREFUSED', { code: 'ECONNREFUSED' }],
46
+ ['EAI_AGAIN', { code: 'EAI_AGAIN' }],
47
+ ['ENOTFOUND', { code: 'ENOTFOUND' }],
48
+ ['ETIMEDOUT', { code: 'ETIMEDOUT' }],
49
+ ['ECONNRESET', { code: 'ECONNRESET' }],
50
+ ['EHOSTUNREACH', { code: 'EHOSTUNREACH' }],
51
+ ['ENETUNREACH', { code: 'ENETUNREACH' }]
44
52
  ])('should reconnect on %s',
45
53
  async (_, error) => {
46
54
  amqplib.connect.mockImplementationOnce(async () => { throw error })
@@ -50,10 +58,9 @@ describe('initial connection', () => {
50
58
  expect(amqplib.connect).toHaveBeenCalledTimes(2)
51
59
  })
52
60
 
53
- it.each(/** @type {[string, Partial<Error>][]} */[
54
- ['ECONNREFUSED', { code: 'ECONNREFUSED' }],
55
- ['any exception', new Error(generate())]
56
- ])('should throw if error is %s', async (_, exception) => {
61
+ it('should throw if error is permanent', async () => {
62
+ const exception = new Error(generate())
63
+
57
64
  amqplib.connect.mockImplementationOnce(async () => { throw exception })
58
65
 
59
66
  await expect(connection.open()).rejects.toStrictEqual(exception)
@@ -109,6 +116,68 @@ describe('reconnection', () => {
109
116
 
110
117
  expect(channel.recover).toHaveBeenCalledWith(replacement)
111
118
  })
119
+
120
+ it('should retry when channel recover fails', async () => {
121
+ const channel = await connection.createChannel('request')
122
+
123
+ channel.recover
124
+ .mockRejectedValueOnce(new Error('Channel closed'))
125
+ .mockResolvedValue(undefined)
126
+
127
+ conn.emit('close', new Error())
128
+
129
+ const start = Date.now()
130
+
131
+ while (channel.recover.mock.calls.length < 2 && Date.now() - start < 10000) {
132
+ await timeout(50)
133
+ }
134
+
135
+ expect(channel.recover).toHaveBeenCalledTimes(2)
136
+ expect(amqplib.connect.mock.calls.length).toBeGreaterThanOrEqual(3)
137
+ }, 15000)
138
+
139
+ it('should emit error when reconnect open fails', async () => {
140
+ const errors = []
141
+ const unhandled = jest.fn()
142
+
143
+ connection.diagnose('error', (exception) => errors.push(exception))
144
+ process.on('unhandledRejection', unhandled)
145
+
146
+ const boom = new Error('reconnect failed')
147
+
148
+ connection.open = jest.fn(async () => { throw boom })
149
+
150
+ conn.emit('close', new Error('broker down'))
151
+
152
+ await timeout(10)
153
+
154
+ process.off('unhandledRejection', unhandled)
155
+
156
+ expect(errors).toContain(boom)
157
+ expect(unhandled).not.toHaveBeenCalled()
158
+ })
159
+
160
+ it('should ignore close from a stale connection', async () => {
161
+ const stale = conn
162
+ const closeHandler = stale.on.mock.calls.find(([event]) => event === 'close')[1]
163
+
164
+ stale.emit('close', new Error('gone'))
165
+
166
+ await timeout(50)
167
+
168
+ const live = await amqplib.connect.mock.results[1].value
169
+ const connects = amqplib.connect.mock.calls.length
170
+
171
+ expect(live).not.toBe(stale)
172
+
173
+ live.removeAllListeners.mockClear()
174
+ closeHandler(new Error('late'))
175
+
176
+ await timeout(10)
177
+
178
+ expect(amqplib.connect).toHaveBeenCalledTimes(connects)
179
+ expect(live.removeAllListeners).not.toHaveBeenCalled()
180
+ })
112
181
  })
113
182
 
114
183
  describe('create channel', () => {
@@ -203,6 +272,44 @@ describe('create channel', () => {
203
272
  })
204
273
  })
205
274
 
275
+ describe('watchdog', () => {
276
+ afterEach(() => {
277
+ jest.useRealTimers()
278
+ })
279
+
280
+ it('should destroy silent connection when watchdog expires', async () => {
281
+ jest.useFakeTimers()
282
+
283
+ await connection.open()
284
+
285
+ const conn = await amqplib.connect.mock.results[0].value
286
+
287
+ await jest.advanceTimersByTimeAsync(60_000)
288
+
289
+ expect(conn.connection.stream.destroy).toHaveBeenCalled()
290
+ })
291
+
292
+ it('should reset watchdog on socket data', async () => {
293
+ jest.useFakeTimers()
294
+
295
+ await connection.open()
296
+
297
+ const conn = await amqplib.connect.mock.results[0].value
298
+
299
+ await jest.advanceTimersByTimeAsync(50_000)
300
+
301
+ conn.connection.stream.emit('data', Buffer.alloc(0))
302
+
303
+ await jest.advanceTimersByTimeAsync(50_000)
304
+
305
+ expect(conn.connection.stream.destroy).not.toHaveBeenCalled()
306
+
307
+ await jest.advanceTimersByTimeAsync(10_000)
308
+
309
+ expect(conn.connection.stream.destroy).toHaveBeenCalled()
310
+ })
311
+ })
312
+
206
313
  describe('close', () => {
207
314
  it('should close connection', async () => {
208
315
  await connection.open()