vrpc 3.8.1 → 3.8.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vrpc",
3
- "version": "3.8.1",
3
+ "version": "3.8.3",
4
4
  "description": "Variadic Remote Procedure Calls",
5
5
  "main": "index.js",
6
6
  "directories": {
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Retries MQTT subscriptions the broker refused (SUBACK qos=128).
3
+ *
4
+ * A refusal is not always final: the broker asks an authorization
5
+ * service for every subscription, and while that service is unavailable
6
+ * it refuses everything. An agent or client that subscribed in such a
7
+ * window used to stay deaf for good - connected, looking online, never
8
+ * receiving a message on the refused topic. This retries with a growing
9
+ * delay until the broker grants the topic, and forgets everything when a
10
+ * (re)connect subscribes afresh anyway.
11
+ *
12
+ * Browser-safe: timers only.
13
+ */
14
+ class SubscribeRetrier {
15
+ /**
16
+ * @param {Object} options
17
+ * @param {Function} options.subscribe (topics: String[], options: Object) => void
18
+ * the subscribe call to repeat; its own refusal handling schedules again
19
+ * @param {Object} [options.log] logger with warn()
20
+ * @param {Number} [options.initialDelayMs=1000]
21
+ * @param {Number} [options.maxDelayMs=30000]
22
+ */
23
+ constructor ({ subscribe, log, initialDelayMs = 1000, maxDelayMs = 30000 }) {
24
+ this._subscribe = subscribe
25
+ this._log = log
26
+ this._initialDelayMs = initialDelayMs
27
+ this._maxDelayMs = maxDelayMs
28
+ this._pending = new Map() // options key -> { options, topics: Set }
29
+ this._inFlight = new Set() // topics of the retry round under way
30
+ this._timer = null
31
+ this._attempt = 0
32
+ }
33
+
34
+ /** Topics waiting for a retry. */
35
+ get pending () {
36
+ const topics = []
37
+ for (const group of this._pending.values()) topics.push(...group.topics)
38
+ return topics
39
+ }
40
+
41
+ /** The delay the next retry will wait, in ms. */
42
+ get nextDelayMs () {
43
+ return Math.min(this._initialDelayMs * 2 ** this._attempt, this._maxDelayMs)
44
+ }
45
+
46
+ /**
47
+ * Remembers refused topics and arms the retry timer if none is armed.
48
+ *
49
+ * @param {String[]} topics
50
+ * @param {Object} [options] the subscribe options to repeat with
51
+ */
52
+ schedule (topics, options = {}) {
53
+ if (!topics || topics.length === 0) return
54
+ for (const topic of topics) this._inFlight.delete(topic)
55
+ const key = JSON.stringify(options)
56
+ const group = this._pending.get(key) || { options, topics: new Set() }
57
+ for (const topic of topics) group.topics.add(topic)
58
+ this._pending.set(key, group)
59
+ if (this._timer) return
60
+ const delayMs = this.nextDelayMs
61
+ if (this._log) {
62
+ this._log.warn(
63
+ `Retrying ${this.pending.length} refused subscription(s) in ${delayMs} ms: ${this.pending}`
64
+ )
65
+ }
66
+ this._timer = setTimeout(() => this._fire(), delayMs)
67
+ }
68
+
69
+ /**
70
+ * Called with the topics a subscribe granted: once nothing is pending
71
+ * any more, the backoff starts over.
72
+ */
73
+ granted (topics) {
74
+ for (const topic of topics) this._inFlight.delete(topic)
75
+ for (const group of this._pending.values()) {
76
+ for (const topic of topics) group.topics.delete(topic)
77
+ }
78
+ for (const [key, group] of this._pending) {
79
+ if (group.topics.size === 0) this._pending.delete(key)
80
+ }
81
+ // nothing waiting, nothing under way, nothing armed: start over
82
+ if (this._pending.size === 0 && this._inFlight.size === 0 && !this._timer) {
83
+ this._attempt = 0
84
+ }
85
+ }
86
+
87
+ /** Drops every pending retry: a (re)connect subscribes afresh, an end() stops. */
88
+ cancel () {
89
+ clearTimeout(this._timer)
90
+ this._timer = null
91
+ this._pending.clear()
92
+ this._inFlight.clear()
93
+ this._attempt = 0
94
+ }
95
+
96
+ _fire () {
97
+ this._timer = null
98
+ this._attempt += 1
99
+ const groups = Array.from(this._pending.values())
100
+ this._pending.clear()
101
+ for (const { topics } of groups) {
102
+ for (const topic of topics) this._inFlight.add(topic)
103
+ }
104
+ for (const { options, topics } of groups) {
105
+ try {
106
+ this._subscribe(Array.from(topics), options)
107
+ } catch (err) {
108
+ if (this._log) this._log.warn(`Retrying subscription failed: ${err.message}`)
109
+ this.schedule(Array.from(topics), options)
110
+ }
111
+ }
112
+ }
113
+ }
114
+
115
+ module.exports = SubscribeRetrier
@@ -547,6 +547,17 @@ class VrpcAdapter {
547
547
  return
548
548
  }
549
549
  const unwrapped = VrpcAdapter._unwrapArguments(json)
550
+ // special case: removing a listener the adapter does not hold (the
551
+ // client's subscription outlived a restart of this agent, or it sent
552
+ // no listener at all) - nothing to remove, and the instance's
553
+ // EventEmitter would throw on a listener that is not a function
554
+ if (
555
+ (json.f === 'off' || json.f === 'removeListener') &&
556
+ typeof unwrapped[1] !== 'function'
557
+ ) {
558
+ json.r = true
559
+ return
560
+ }
550
561
  // Check whether context is a registered class
551
562
  const entry = VrpcAdapter._functionRegistry.get(json.c)
552
563
  if (entry !== undefined) {
package/vrpc/VrpcAgent.js CHANGED
@@ -6,6 +6,7 @@ const { ArgumentParser } = require('argparse')
6
6
  const EventEmitter = require('events')
7
7
  const jsonStringifySafe = require('json-stringify-safe')
8
8
  const VrpcAdapter = require('./VrpcAdapter')
9
+ const SubscribeRetrier = require('./SubscribeRetrier')
9
10
 
10
11
  const VRPC_PROTOCOL_VERSION = 3
11
12
 
@@ -144,6 +145,11 @@ class VrpcAgent extends EventEmitter {
144
145
  // maps clientId to instanceId
145
146
  this._isolatedInstances = new Map()
146
147
  this._sharedInstances = new Map()
148
+ // refused subscriptions (SUBACK qos=128) are retried until granted
149
+ this._subscribeRetrier = new SubscribeRetrier({
150
+ subscribe: (topics, options) => this._mqttSubscribe(topics, options),
151
+ log: this._log
152
+ })
147
153
 
148
154
  // Handle the internal error event in case the user forgot to implement it
149
155
  this.on('error', err => {
@@ -213,6 +219,7 @@ class VrpcAgent extends EventEmitter {
213
219
  * @returns {Promise} Resolves when disconnected and ended
214
220
  */
215
221
  async end ({ unregister = false } = {}) {
222
+ this._subscribeRetrier.cancel()
216
223
  try {
217
224
  if (!this._client || !this._client.connected) {
218
225
  this.emit('end')
@@ -370,7 +377,11 @@ class VrpcAgent extends EventEmitter {
370
377
  err.subscribeOptions = options
371
378
  this._log.error(err)
372
379
  this.emit('error', err)
380
+ this._subscribeRetrier.schedule(erroneousGranted, options)
373
381
  }
382
+ this._subscribeRetrier.granted(
383
+ granted.filter(x => x.qos !== 128).map(x => x.topic)
384
+ )
374
385
  const reducedQos = granted.filter(x => x.qos < this._qos)
375
386
  if (reducedQos.length > 0) {
376
387
  err = new Error(
@@ -471,6 +482,8 @@ class VrpcAgent extends EventEmitter {
471
482
 
472
483
  _handleConnect () {
473
484
  this._log.info('[OK]')
485
+ // everything is subscribed afresh: pending retries are moot
486
+ this._subscribeRetrier.cancel()
474
487
  try {
475
488
  const topics = this._generateTopics()
476
489
  if (topics.length > 0) this._mqttSubscribe(topics)
@@ -41,6 +41,7 @@ const os = require('os')
41
41
  const { nanoid } = require('nanoid')
42
42
  const mqtt = require('mqtt')
43
43
  const EventEmitter = require('events')
44
+ const SubscribeRetrier = require('./SubscribeRetrier')
44
45
 
45
46
  const VRPC_PROTOCOL_VERSION = 3
46
47
 
@@ -135,6 +136,11 @@ class VrpcClient extends EventEmitter {
135
136
  this._client = null
136
137
  this._cachedSubscriptions = {}
137
138
  this._proxies = {}
139
+ // refused subscriptions (SUBACK qos=128) are retried until granted
140
+ this._subscribeRetrier = new SubscribeRetrier({
141
+ subscribe: (topics, options) => this._mqttSubscribe(topics, options).catch(() => {}),
142
+ log: this._log
143
+ })
138
144
  this._callbackIds = new WeakMap()
139
145
  this._emitterListener = new WeakMap()
140
146
  this._pendingSubscriptions = new Map()
@@ -247,6 +253,8 @@ class VrpcClient extends EventEmitter {
247
253
  this._client.on('connect', async () => {
248
254
  // forward the good news
249
255
  this.emit('connect')
256
+ // everything is subscribed afresh below: pending retries are moot
257
+ this._subscribeRetrier.cancel()
250
258
  try {
251
259
  // This will give us an overview of all remotely available classes
252
260
  const agent = this._agent === '*' ? '+' : this._agent
@@ -783,6 +791,7 @@ class VrpcClient extends EventEmitter {
783
791
  * @returns {Promise} Resolves when ended
784
792
  */
785
793
  async end () {
794
+ this._subscribeRetrier.cancel()
786
795
  if (!this._client) return
787
796
  this._mqttPublish(
788
797
  `${this._vrpcConnectionId}/__clientInfo__`,
@@ -920,7 +929,11 @@ class VrpcClient extends EventEmitter {
920
929
  err.subscribeOptions = options
921
930
  this._log.error(err.message)
922
931
  this.emit('error', err)
932
+ this._subscribeRetrier.schedule(erroneousGranted, options)
923
933
  }
934
+ this._subscribeRetrier.granted(
935
+ granted.filter(x => x.qos !== 128).map(x => x.topic)
936
+ )
924
937
  const reducedQos = granted.filter(x => x.qos < this._qos)
925
938
  if (reducedQos.length > 0) {
926
939
  err = new Error(
@@ -1271,6 +1284,17 @@ class VrpcClient extends EventEmitter {
1271
1284
  isContinuousRemover
1272
1285
  ) {
1273
1286
  const id = await this._offRemoteEvent({ callback })
1287
+ if (
1288
+ !id &&
1289
+ (functionName === 'off' || functionName === 'removeListener')
1290
+ ) {
1291
+ // Nothing of ours to remove - the subscription was already
1292
+ // dropped (the instance or its agent went away and the cache
1293
+ // was cleared, or the listener was never registered). Sending
1294
+ // the call anyway would hand the agent a null listener, which
1295
+ // its EventEmitter rejects. Handled locally: nothing to do.
1296
+ return null
1297
+ }
1274
1298
  wrapped.push(id || null)
1275
1299
  continue
1276
1300
  } else {