comq 0.15.7 → 0.15.8
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 +1 -8
- package/features/recovery.feature +30 -0
- package/features/silence.feature +46 -0
- package/features/steps/.types/context.d.ts +17 -0
- package/features/steps/backpressure.js +1 -1
- package/features/steps/broker.js +1 -1
- package/features/steps/brokers.js +1 -1
- package/features/steps/common.js +1 -1
- package/features/steps/connection.js +41 -1
- package/features/steps/context.js +12 -2
- package/features/steps/events.js +1 -1
- package/features/steps/hooks.js +1 -0
- package/features/steps/network.js +50 -0
- package/features/steps/networks.js +90 -0
- package/features/steps/parameters.js +6 -0
- package/features/steps/poison.js +1 -1
- package/features/steps/rpc.js +1 -1
- package/features/steps/tasks.js +1 -1
- package/package.json +3 -6
- package/readme.md +13 -2
- package/source/attributes/lazy.test.js +1 -1
- package/source/attributes/track.test.js +3 -3
- package/source/connection.js +92 -11
- package/source/emitter.js +23 -1
- package/source/encoders/index.js +0 -2
- package/source/events.js +1 -1
- package/source/io.js +6 -5
- package/source/shards/channel.js +20 -7
- package/source/shards/connection.js +0 -5
- package/test/amqplib.mock.js +16 -4
- package/test/channel.fixtures.js +1 -1
- package/test/channel.test.js +1 -1
- package/test/connection.test.js +126 -6
- package/test/decode.test.js +0 -13
- package/test/emitter.test.js +71 -0
- package/test/encode.test.js +0 -9
- package/test/encodings.js +1 -1
- package/test/helpers.js +77 -0
- package/test/io.close.test.js +4 -3
- package/test/io.diagnostics.test.js +1 -1
- package/test/io.reply.test.js +2 -2
- package/test/io.request.test.js +16 -2
- package/test/pipeline.test.js +1 -1
- package/test/shards/channel.test.js +63 -9
- package/test/shards/connection.test.js +4 -3
- package/types/encoding.d.ts +1 -2
- package/AGENTS.md +0 -35
- package/CHANGELOG.md +0 -85
- package/source/encoders/msgpack.js +0 -6
package/source/connection.js
CHANGED
|
@@ -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 () {
|
|
@@ -200,7 +197,14 @@ class Connection {
|
|
|
200
197
|
}
|
|
201
198
|
|
|
202
199
|
connection.removeAllListeners()
|
|
203
|
-
|
|
200
|
+
|
|
201
|
+
// amqplib keeps its heartbeater running on a connection it does not know is
|
|
202
|
+
// gone and emits 'error' on it, which throws once no listener is left
|
|
203
|
+
connection.on('error', noop)
|
|
204
|
+
|
|
205
|
+
// a socket destroyed without an error tells amqplib nothing, leaving its
|
|
206
|
+
// timers running and everything pending on it hanging forever
|
|
207
|
+
connection.connection?.stream?.destroy(silence())
|
|
204
208
|
}
|
|
205
209
|
|
|
206
210
|
#recover () {
|
|
@@ -215,7 +219,7 @@ class Connection {
|
|
|
215
219
|
* @return {Promise<comq.amqp.Connection>}
|
|
216
220
|
*/
|
|
217
221
|
async #connect () {
|
|
218
|
-
const connecting = amqp.connect(this.#url,
|
|
222
|
+
const connecting = amqp.connect(this.#url, SOCKET_OPTIONS)
|
|
219
223
|
|
|
220
224
|
let timer
|
|
221
225
|
|
|
@@ -248,19 +252,23 @@ class Connection {
|
|
|
248
252
|
* @param {comq.amqp.Connection} connection
|
|
249
253
|
* @param {number} [timeoutMs]
|
|
250
254
|
*/
|
|
251
|
-
#armWatchdog (connection, timeoutMs =
|
|
255
|
+
#armWatchdog (connection, timeoutMs = tolerance(connection)) {
|
|
256
|
+
this.#disarmWatchdog()
|
|
257
|
+
|
|
252
258
|
const socket = connection.connection?.stream
|
|
253
259
|
|
|
254
|
-
if (socket === undefined) return
|
|
260
|
+
if (socket === undefined || timeoutMs === undefined) return
|
|
255
261
|
|
|
256
262
|
const reset = () => {
|
|
257
263
|
clearTimeout(this.#heartbeatTimer)
|
|
258
|
-
|
|
264
|
+
// destroying a socket without an error tells amqplib nothing: it only
|
|
265
|
+
// listens for 'error' and 'end', so a bare destroy() leaves the connection
|
|
266
|
+
// silently dead — no 'close' event, no recovery, every pending operation
|
|
267
|
+
// hanging forever
|
|
268
|
+
this.#heartbeatTimer = setTimeout(() => socket.destroy(silence()), timeoutMs)
|
|
259
269
|
this.#heartbeatTimer.unref()
|
|
260
270
|
}
|
|
261
271
|
|
|
262
|
-
this.#disarmWatchdog()
|
|
263
|
-
|
|
264
272
|
this.#heartbeatSocket = socket
|
|
265
273
|
this.#heartbeatReset = reset
|
|
266
274
|
|
|
@@ -289,7 +297,25 @@ class Connection {
|
|
|
289
297
|
}
|
|
290
298
|
}
|
|
291
299
|
|
|
300
|
+
/**
|
|
301
|
+
* The heartbeat to ask for when the caller has not, in seconds. A broker is free
|
|
302
|
+
* to suggest one that leaves a connection lost for minutes before anything
|
|
303
|
+
* notices, and RabbitMQ suggests 60 by default.
|
|
304
|
+
*
|
|
305
|
+
* @type {number}
|
|
306
|
+
*/
|
|
307
|
+
const HEARTBEAT_S = 15
|
|
308
|
+
|
|
292
309
|
/** @type {number} */
|
|
310
|
+
const KEEPALIVE_MS = 10_000
|
|
311
|
+
|
|
312
|
+
/** How many heartbeats a connection may miss before it is destroyed. */
|
|
313
|
+
const MISSED_HEARTBEATS = 3
|
|
314
|
+
|
|
315
|
+
/** @type {number} */
|
|
316
|
+
const WATCHDOG_MIN_MS = 15_000
|
|
317
|
+
|
|
318
|
+
/** The watchdog interval used when the negotiated heartbeat is unknown. */
|
|
293
319
|
const WATCHDOG_MS = 60_000
|
|
294
320
|
|
|
295
321
|
/** @type {number} */
|
|
@@ -298,6 +324,15 @@ const CONNECT_MS = 30_000
|
|
|
298
324
|
/** @type {number} */
|
|
299
325
|
const SHUTDOWN_MS = 5_000
|
|
300
326
|
|
|
327
|
+
const SOCKET_OPTIONS = {
|
|
328
|
+
timeout: CONNECT_MS,
|
|
329
|
+
// a peer that went away without a word is noticed by the kernel as well
|
|
330
|
+
keepAlive: true,
|
|
331
|
+
keepAliveDelay: KEEPALIVE_MS
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const HEARTBEAT_SET = /[?&]heartbeat=/
|
|
335
|
+
|
|
301
336
|
const TRANSIENT_CODES = new Set([
|
|
302
337
|
'ECONNREFUSED',
|
|
303
338
|
'EAI_AGAIN',
|
|
@@ -323,6 +358,52 @@ function expiration () {
|
|
|
323
358
|
return delay(timeoutMs, undefined, { ref: false })
|
|
324
359
|
}
|
|
325
360
|
|
|
361
|
+
/**
|
|
362
|
+
* amqplib reads the heartbeat from the URL only, hence it cannot be passed along
|
|
363
|
+
* with the socket options.
|
|
364
|
+
*
|
|
365
|
+
* @param {string} url
|
|
366
|
+
* @return {string}
|
|
367
|
+
*/
|
|
368
|
+
function heartbeaten (url) {
|
|
369
|
+
if (HEARTBEAT_SET.test(url)) return url
|
|
370
|
+
|
|
371
|
+
return url + (url.includes('?') ? '&' : '?') + 'heartbeat=' + HEARTBEAT_S
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* The watchdog measures silence, so it cannot be shorter than the interval at
|
|
376
|
+
* which a healthy broker is expected to say something, which is a heartbeat
|
|
377
|
+
* frame every half a heartbeat. A connection that has agreed to no heartbeats
|
|
378
|
+
* says nothing at all while it is idle, leaving nothing to measure.
|
|
379
|
+
*
|
|
380
|
+
* @param {comq.amqp.Connection} connection
|
|
381
|
+
* @return {number | undefined}
|
|
382
|
+
*/
|
|
383
|
+
function tolerance (connection) {
|
|
384
|
+
const override = global.COMQ_TESTING_WATCHDOG_INTERVAL
|
|
385
|
+
|
|
386
|
+
if (override !== undefined) return override
|
|
387
|
+
|
|
388
|
+
const heartbeat = connection.connection?.heartbeat
|
|
389
|
+
|
|
390
|
+
if (heartbeat === undefined) return WATCHDOG_MS
|
|
391
|
+
if (heartbeat === 0) return undefined
|
|
392
|
+
|
|
393
|
+
return Math.max(heartbeat * MISSED_HEARTBEATS * 1000, WATCHDOG_MIN_MS)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* @return {Error}
|
|
398
|
+
*/
|
|
399
|
+
function silence () {
|
|
400
|
+
const exception = new Error('Connection is silent')
|
|
401
|
+
|
|
402
|
+
exception.code = 'ETIMEDOUT'
|
|
403
|
+
|
|
404
|
+
return exception
|
|
405
|
+
}
|
|
406
|
+
|
|
326
407
|
function noop () {}
|
|
327
408
|
|
|
328
409
|
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
|
|
28
|
+
const emitter = new Emitter()
|
|
7
29
|
|
|
8
30
|
emitter.setMaxListeners(0)
|
|
9
31
|
|
package/source/encoders/index.js
CHANGED
package/source/events.js
CHANGED
|
@@ -4,4 +4,4 @@
|
|
|
4
4
|
exports.connection = ['open', 'close', 'error', 'reconnect']
|
|
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 =
|
|
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
|
}
|
|
@@ -220,6 +217,10 @@ class IO {
|
|
|
220
217
|
} else {
|
|
221
218
|
this.#requests.diagnose('recover', this.#retransmit)
|
|
222
219
|
}
|
|
220
|
+
|
|
221
|
+
// a reply that could not be routed is dropped by the broker, and the queue
|
|
222
|
+
// it was addressed to only exists again once this channel has recovered
|
|
223
|
+
this.#replies.diagnose('recover', this.#retransmit)
|
|
223
224
|
}
|
|
224
225
|
|
|
225
226
|
/**
|
package/source/shards/channel.js
CHANGED
|
@@ -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
|
|
|
@@ -126,6 +129,9 @@ class Channel {
|
|
|
126
129
|
this.#alive[index] = false
|
|
127
130
|
this.#down[index].resolve()
|
|
128
131
|
|
|
132
|
+
// the channel stays in the pool, yet there is one less shard to publish to
|
|
133
|
+
this.#update()
|
|
134
|
+
|
|
129
135
|
if (error !== undefined && connection.closed !== true) {
|
|
130
136
|
this.#diagnostics.emit(LOST, index)
|
|
131
137
|
}
|
|
@@ -134,6 +140,8 @@ class Channel {
|
|
|
134
140
|
connection.diagnose('open', () => {
|
|
135
141
|
this.#alive[index] = true
|
|
136
142
|
this.#down[index] = new Promex()
|
|
143
|
+
|
|
144
|
+
this.#update()
|
|
137
145
|
})
|
|
138
146
|
}
|
|
139
147
|
|
|
@@ -159,6 +167,7 @@ class Channel {
|
|
|
159
167
|
for (const event of events.channel) {
|
|
160
168
|
if (event === RETURN) continue // returns are retried before being reported
|
|
161
169
|
if (event === LOST) continue // a shard is lost with its connection, not its channel
|
|
170
|
+
if (event === REMOVE) continue // it is the pool that removes a channel, not the channel
|
|
162
171
|
|
|
163
172
|
channel.diagnose(event, (...args) => this.#diagnostics.emit(event, ...args, channel.index))
|
|
164
173
|
}
|
|
@@ -214,20 +223,23 @@ class Channel {
|
|
|
214
223
|
this.#bench.set(channel, new Promex())
|
|
215
224
|
this.#channels.delete(channel)
|
|
216
225
|
this.#update()
|
|
217
|
-
this.#diagnostics.emit(
|
|
226
|
+
this.#diagnostics.emit(REMOVE, channel.index)
|
|
218
227
|
}
|
|
219
228
|
|
|
229
|
+
/**
|
|
230
|
+
* A shard leaves the pool when it rejects a publish, and stops being reachable
|
|
231
|
+
* when its connection is lost, which the pool is not told about. Both leave
|
|
232
|
+
* `#one` waiting, so both are reported the same way.
|
|
233
|
+
*/
|
|
220
234
|
#update () {
|
|
221
|
-
const from = this.#pool?.length
|
|
222
|
-
const to = this.#channels.size
|
|
223
|
-
|
|
224
235
|
this.#pool = Array.from(this.#channels)
|
|
225
236
|
|
|
226
|
-
|
|
237
|
+
const paused = this.#reachable().length === 0
|
|
227
238
|
|
|
228
|
-
if (
|
|
239
|
+
if (paused === this.#paused) return
|
|
229
240
|
|
|
230
|
-
|
|
241
|
+
this.#paused = paused
|
|
242
|
+
this.#diagnostics.emit(paused ? 'pause' : 'resume')
|
|
231
243
|
}
|
|
232
244
|
|
|
233
245
|
/**
|
|
@@ -369,6 +381,7 @@ async function create (connections, type) {
|
|
|
369
381
|
|
|
370
382
|
const DEFAULT = ''
|
|
371
383
|
const RETURN = 'return'
|
|
384
|
+
const REMOVE = 'remove'
|
|
372
385
|
const RETURN_HEADER = 'x-return'
|
|
373
386
|
const LOST = 'lost'
|
|
374
387
|
|
|
@@ -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
|
package/test/amqplib.mock.js
CHANGED
|
@@ -18,14 +18,26 @@ class Channel extends EventEmitter {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
class Connection extends EventEmitter {
|
|
21
|
-
constructor () {
|
|
21
|
+
constructor (url = '') {
|
|
22
22
|
super()
|
|
23
23
|
|
|
24
24
|
const stream = new EventEmitter()
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
// amqplib listens for 'error' and 'end' on the socket only, so destroying it
|
|
27
|
+
// without an error leaves the connection unaware and silent forever
|
|
28
|
+
stream.destroy = jest.fn((error) => {
|
|
29
|
+
if (error === undefined || stream.destroyed === true) return
|
|
27
30
|
|
|
28
|
-
|
|
31
|
+
stream.destroyed = true
|
|
32
|
+
|
|
33
|
+
this.emit('error', error)
|
|
34
|
+
this.emit('close', error)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// a broker accepts the heartbeat the client asks for
|
|
38
|
+
const heartbeat = Number(/[?&]heartbeat=(\d+)/.exec(url)?.[1] ?? 60)
|
|
39
|
+
|
|
40
|
+
this.connection = { stream, heartbeat }
|
|
29
41
|
|
|
30
42
|
// noinspection JSValidateTypes
|
|
31
43
|
this.removeAllListeners = jest.spyOn(this, 'removeAllListeners')
|
|
@@ -39,7 +51,7 @@ class Connection extends EventEmitter {
|
|
|
39
51
|
close = jest.fn(async () => undefined)
|
|
40
52
|
}
|
|
41
53
|
|
|
42
|
-
const connect = async () => new Connection()
|
|
54
|
+
const connect = async (url) => new Connection(url)
|
|
43
55
|
|
|
44
56
|
/** @type {jest.MockedObject<import('amqplib')>} */
|
|
45
57
|
const amqplib = {
|
package/test/channel.fixtures.js
CHANGED
package/test/channel.test.js
CHANGED
|
@@ -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('
|
|
7
|
+
const { flip, random, timeout } = require('./helpers')
|
|
8
8
|
|
|
9
9
|
const backpressure = require('./backpressure')
|
|
10
10
|
const { amqplib } = require('./amqplib.mock')
|
package/test/connection.test.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
const { generate } = require('randomstring')
|
|
6
6
|
|
|
7
|
-
const {
|
|
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(
|
|
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 =
|
|
379
|
+
const promise = new Promex()
|
|
343
380
|
|
|
344
381
|
amqplib.connect.mockImplementationOnce(() => promise)
|
|
345
382
|
|
|
@@ -373,11 +410,23 @@ describe('watchdog', () => {
|
|
|
373
410
|
|
|
374
411
|
const conn = await amqplib.connect.mock.results[0].value
|
|
375
412
|
|
|
376
|
-
await jest.advanceTimersByTimeAsync(
|
|
413
|
+
await jest.advanceTimersByTimeAsync(46_000)
|
|
377
414
|
|
|
378
415
|
expect(conn.connection.stream.destroy).toHaveBeenCalled()
|
|
379
416
|
})
|
|
380
417
|
|
|
418
|
+
it('should reconnect after the watchdog destroys a silent connection', async () => {
|
|
419
|
+
jest.useFakeTimers()
|
|
420
|
+
|
|
421
|
+
await connection.open()
|
|
422
|
+
|
|
423
|
+
await jest.advanceTimersByTimeAsync(46_000)
|
|
424
|
+
|
|
425
|
+
// a socket destroyed without an error is a socket amqplib never reports,
|
|
426
|
+
// which leaves the connection silently dead instead of recovering
|
|
427
|
+
expect(amqplib.connect).toHaveBeenCalledTimes(2)
|
|
428
|
+
})
|
|
429
|
+
|
|
381
430
|
it('should not let a replaced socket disarm the watchdog', async () => {
|
|
382
431
|
jest.useFakeTimers()
|
|
383
432
|
|
|
@@ -407,11 +456,11 @@ describe('watchdog', () => {
|
|
|
407
456
|
|
|
408
457
|
const conn = await amqplib.connect.mock.results[0].value
|
|
409
458
|
|
|
410
|
-
await jest.advanceTimersByTimeAsync(
|
|
459
|
+
await jest.advanceTimersByTimeAsync(40_000)
|
|
411
460
|
|
|
412
461
|
conn.connection.stream.emit('data', Buffer.alloc(0))
|
|
413
462
|
|
|
414
|
-
await jest.advanceTimersByTimeAsync(
|
|
463
|
+
await jest.advanceTimersByTimeAsync(40_000)
|
|
415
464
|
|
|
416
465
|
expect(conn.connection.stream.destroy).not.toHaveBeenCalled()
|
|
417
466
|
|
|
@@ -419,6 +468,77 @@ describe('watchdog', () => {
|
|
|
419
468
|
|
|
420
469
|
expect(conn.connection.stream.destroy).toHaveBeenCalled()
|
|
421
470
|
})
|
|
471
|
+
|
|
472
|
+
it('should derive the watchdog from the negotiated heartbeat', async () => {
|
|
473
|
+
jest.useFakeTimers()
|
|
474
|
+
|
|
475
|
+
const connection = new Connection('amqp://localhost?heartbeat=30')
|
|
476
|
+
|
|
477
|
+
await connection.open()
|
|
478
|
+
|
|
479
|
+
const conn = await amqplib.connect.mock.results[0].value
|
|
480
|
+
|
|
481
|
+
// the broker is only expected to say something every 15 seconds
|
|
482
|
+
await jest.advanceTimersByTimeAsync(60_000)
|
|
483
|
+
|
|
484
|
+
expect(conn.connection.stream.destroy).not.toHaveBeenCalled()
|
|
485
|
+
|
|
486
|
+
await jest.advanceTimersByTimeAsync(31_000)
|
|
487
|
+
|
|
488
|
+
expect(conn.connection.stream.destroy).toHaveBeenCalled()
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it('should not arm the watchdog when heartbeats are disabled', async () => {
|
|
492
|
+
jest.useFakeTimers()
|
|
493
|
+
|
|
494
|
+
const connection = new Connection('amqp://localhost?heartbeat=0')
|
|
495
|
+
|
|
496
|
+
await connection.open()
|
|
497
|
+
|
|
498
|
+
const conn = await amqplib.connect.mock.results[0].value
|
|
499
|
+
|
|
500
|
+
// an idle connection that has agreed to no heartbeats is silent by design
|
|
501
|
+
await jest.advanceTimersByTimeAsync(600_000)
|
|
502
|
+
|
|
503
|
+
expect(conn.connection.stream.destroy).not.toHaveBeenCalled()
|
|
504
|
+
})
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
describe('heartbeat', () => {
|
|
508
|
+
it('should ask for a heartbeat', async () => {
|
|
509
|
+
const connection = new Connection('amqp://developer@localhost')
|
|
510
|
+
|
|
511
|
+
await connection.open()
|
|
512
|
+
|
|
513
|
+
expect(amqplib.connect).toHaveBeenCalledWith('amqp://developer@localhost?heartbeat=15',
|
|
514
|
+
expect.anything())
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
it('should append the heartbeat to an existing query', async () => {
|
|
518
|
+
const connection = new Connection('amqp://localhost?frameMax=8192')
|
|
519
|
+
|
|
520
|
+
await connection.open()
|
|
521
|
+
|
|
522
|
+
expect(amqplib.connect).toHaveBeenCalledWith('amqp://localhost?frameMax=8192&heartbeat=15',
|
|
523
|
+
expect.anything())
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
it.each(['amqp://localhost?heartbeat=5', 'amqp://localhost?heartbeat=0'])(
|
|
527
|
+
'should keep the heartbeat requested by the caller (%s)',
|
|
528
|
+
async (url) => {
|
|
529
|
+
const connection = new Connection(url)
|
|
530
|
+
|
|
531
|
+
await connection.open()
|
|
532
|
+
|
|
533
|
+
expect(amqplib.connect).toHaveBeenCalledWith(url, expect.anything())
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
it('should keep the socket alive', async () => {
|
|
537
|
+
await connection.open()
|
|
538
|
+
|
|
539
|
+
expect(amqplib.connect)
|
|
540
|
+
.toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ keepAlive: true }))
|
|
541
|
+
})
|
|
422
542
|
})
|
|
423
543
|
|
|
424
544
|
describe('close', () => {
|
package/test/decode.test.js
CHANGED
|
@@ -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
|
+
})
|