vrpc 3.8.1 → 3.8.2

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.2",
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
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(