vrpc 3.12.1 → 3.14.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vrpc",
3
- "version": "3.12.1",
3
+ "version": "3.14.0",
4
4
  "description": "Variadic Remote Procedure Calls",
5
5
  "main": "index.js",
6
6
  "directories": {
@@ -12,14 +12,16 @@
12
12
  "build:addon": "ln -sf binding.gyp.no-auto binding.gyp && node-gyp rebuild && rm binding.gyp",
13
13
  "build:browser": "./node_modules/.bin/webpack --stats-error-details",
14
14
  "build:doc": "./node_modules/.bin/jsdoc2md -f vrpc/VrpcAdapter.js vrpc/VrpcAgent.js vrpc/VrpcClient.js vrpc/VrpcNative.js --param-list-format=list --separators > docs/api.md",
15
- "test": "npm run test:adapter && npm run test:agent && npm run test:client && npm run test:persistor && npm run test:lifecycle && npm run test:production",
15
+ "test": "npm run test:adapter && npm run test:reconnect && npm run test:agent && npm run test:client && npm run test:persistor && npm run test:lifecycle && npm run test:legacy-runtime && npm run test:production",
16
16
  "test:adapter": "./node_modules/.bin/mocha tests/adapter/*.js --exit",
17
17
  "test:native": "./node_modules/.bin/mocha tests/native/*.js --exit",
18
+ "test:reconnect": "./node_modules/.bin/mocha tests/reconnect/*.spec.js --exit",
18
19
  "test:agent": "tests/agent/test.sh",
19
20
  "test:client": "tests/client/test.sh",
20
21
  "test:performance": "tests/performance/test.sh",
21
22
  "test:persistor": "./node_modules/.bin/mocha tests/persistor/*.js --timeout 30000 --exit",
22
23
  "test:lifecycle": "./node_modules/.bin/mocha tests/lifecycle/*.js --timeout 30000 --exit",
24
+ "test:legacy-runtime": "tests/legacy-runtime/test.sh",
23
25
  "test:production": "./node_modules/.bin/mocha tests/production/lifeCycleTest.js --timeout 30000 --exit && tests/production/test.sh"
24
26
  },
25
27
  "bin": {
@@ -56,6 +58,7 @@
56
58
  "fs-extra": "^11.3.1",
57
59
  "jsdoc-to-markdown": "^7.0.1",
58
60
  "mocha": "^9.2.2",
61
+ "mqtt-packet": "^9.0.2",
59
62
  "os-browserify": "^0.3.0",
60
63
  "sinon": "^21.0.0",
61
64
  "stream-browserify": "^3.0.0",
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Paces the reconnects of an mqtt.js client after refused CONNACKs.
3
+ *
4
+ * mqtt.js retries at a fixed `reconnectPeriod`. A connection the broker
5
+ * refuses - a stale credential, an authorization service that is away -
6
+ * would knock once per period for as long as it lives, and an
7
+ * authorization service may block a client id that knocks too often. So
8
+ * every refusal doubles the wait up to `max`, and a successful connect
9
+ * restores the base. The refusal itself stays visible as the client's
10
+ * 'error' event, so a host can renew its credentials and `hurry()`.
11
+ *
12
+ * mqtt.js reads `reconnectPeriod` only when it arms its reconnect
13
+ * interval, once per outage; re-arming it takes the internal
14
+ * `_clearReconnect` / `_setupReconnect`. Without them (a future mqtt.js)
15
+ * a new period applies from the next outage on.
16
+ */
17
+
18
+ const REFUSED = /^Connection refused/
19
+
20
+ class ReconnectBackoff {
21
+ /**
22
+ * @param {Object} client an mqtt.js client created with
23
+ * `reconnectOnConnackError: true`
24
+ * @param {Object} [options]
25
+ * @param {Number} [options.max=30000] longest wait in ms between attempts
26
+ */
27
+ constructor (client, { max = 30000 } = {}) {
28
+ this._client = client
29
+ this._base = client.options.reconnectPeriod
30
+ this._max = Math.max(max, this._base)
31
+ this._next = this._base
32
+ client.on('error', err => {
33
+ if (ReconnectBackoff.isRefusal(err)) this._slowDown()
34
+ })
35
+ client.on('connect', () => this._restore())
36
+ }
37
+
38
+ /** Whether an mqtt.js error is a refused CONNACK. */
39
+ static isRefusal (err) {
40
+ return (
41
+ !!err && typeof err.code === 'number' && REFUSED.test(err.message || '')
42
+ )
43
+ }
44
+
45
+ /** The wait in ms before the next attempt. */
46
+ get period () {
47
+ return this._client.options.reconnectPeriod
48
+ }
49
+
50
+ /**
51
+ * New credentials: the next attempt follows within the base period
52
+ * instead of waiting out the back-off. A live connection stays as it is.
53
+ */
54
+ hurry () {
55
+ this._restore()
56
+ const client = this._client
57
+ if (client.connected || client.disconnecting || !client.reconnecting) return
58
+ if (typeof client._clearReconnect !== 'function') return
59
+ client._clearReconnect()
60
+ client._setupReconnect()
61
+ }
62
+
63
+ // Runs inside the CONNACK handler's 'error' emit: the handler re-arms
64
+ // the reconnect interval right after (reconnectOnConnackError), with the
65
+ // period set here
66
+ _slowDown () {
67
+ const client = this._client
68
+ client.options.reconnectPeriod = this._next
69
+ this._next = Math.min(this._next * 2, this._max)
70
+ if (typeof client._clearReconnect === 'function') client._clearReconnect()
71
+ }
72
+
73
+ _restore () {
74
+ this._next = this._base
75
+ this._client.options.reconnectPeriod = this._base
76
+ }
77
+ }
78
+
79
+ module.exports = ReconnectBackoff
@@ -43,6 +43,17 @@ const commentParser = require('./comment-parser')
43
43
  const EventEmitter = require('events')
44
44
  const { nanoid } = require('nanoid')
45
45
 
46
+ // Node before 18.18 / 20.4 has no dispose symbols, and a class's
47
+ // [Symbol.asyncDispose]() would become a method named 'undefined'. Node's
48
+ // own stand-ins live in the symbol registry, so a class file and the adapter
49
+ // meet on one symbol whichever of them installs it first.
50
+ if (typeof Symbol.dispose !== 'symbol') {
51
+ Symbol.dispose = Symbol.for('nodejs.dispose')
52
+ }
53
+ if (typeof Symbol.asyncDispose !== 'symbol') {
54
+ Symbol.asyncDispose = Symbol.for('nodejs.asyncDispose')
55
+ }
56
+
46
57
  /**
47
58
  * Generates an adapter layer for existing code and enables further VRPC-based
48
59
  * communication.
@@ -197,7 +208,10 @@ class VrpcAdapter {
197
208
  }
198
209
 
199
210
  /**
200
- * Deletes an instance
211
+ * Deletes an instance: its name is free at once, the adapter takes back
212
+ * every listener it attached, and the object releases what it runs
213
+ * through its `[Symbol.asyncDispose]` or `[Symbol.dispose]` method, which
214
+ * runs in the background here (a remote delete answers once it settled).
201
215
  *
202
216
  * @param {String|Object} instance Instance (name or object itself) to be deleted
203
217
  * @returns {Boolean} True in case of success, false otherwise
@@ -208,10 +222,7 @@ class VrpcAdapter {
208
222
  }
209
223
  if (typeof instance === 'object') {
210
224
  for (const [k, v] of VrpcAdapter._instances) {
211
- if (v.instance === instance) {
212
- VrpcAdapter._instances.delete(k)
213
- return true
214
- }
225
+ if (v.instance === instance) return VrpcAdapter._delete(k)
215
226
  }
216
227
  return false
217
228
  }
@@ -577,11 +588,17 @@ class VrpcAdapter {
577
588
  instance,
578
589
  className: json.c
579
590
  })
580
- json.r = VrpcAdapter._delete(instance)
591
+ const { ended, disposed } = VrpcAdapter._end(instance)
581
592
  VrpcAdapter._emitter.emit('delete', {
582
593
  instance,
583
594
  className: json.c
584
595
  })
596
+ // a delete answers once the object released what it runs, so that a
597
+ // constructor right after it never meets a port or a session the old
598
+ // object still holds; the agent's own housekeeping awaits no answer
599
+ if (disposed && json.s !== VrpcAdapter.LOCAL_SENDER) {
600
+ VrpcAdapter._handlePromise(json, disposed.then(() => ended))
601
+ } else json.r = ended
585
602
  } catch (err) {
586
603
  const { message, cause } = err
587
604
  json.e = { message, cause }
@@ -603,6 +620,15 @@ class VrpcAdapter {
603
620
  json.r = true
604
621
  return
605
622
  }
623
+ // an unknown context fails before its arguments are unwrapped: a
624
+ // listener registered for a name that does not exist would outlive the
625
+ // failed call and pass for attached once the name is created
626
+ if (
627
+ !VrpcAdapter._functionRegistry.has(json.c) &&
628
+ !VrpcAdapter._instances.has(json.c)
629
+ ) {
630
+ throw new Error(`Could not find context: ${json.c}`)
631
+ }
606
632
  const unwrapped = VrpcAdapter._unwrapArguments(json)
607
633
  // special case: removing a listener the adapter does not hold (the
608
634
  // client's subscription outlived a restart of this agent, or it sent
@@ -671,33 +697,115 @@ class VrpcAdapter {
671
697
  }
672
698
 
673
699
  static _delete (instanceId) {
700
+ return VrpcAdapter._end(instanceId).ended
701
+ }
702
+
703
+ /**
704
+ * Ends an instance - the one teardown behind every door (a remote delete,
705
+ * the auto-delete of an isolated instance whose client went, an
706
+ * in-process delete). Node has no destructor, so an object that runs a
707
+ * clock or holds a connection lives on as long as anything reaches it;
708
+ * ending it takes three steps:
709
+ * 1. its name is free at once: no call reaches the object any more;
710
+ * 2. the adapter takes back every listener it attached (its listeners are
711
+ * silent from now on anyway, see _generateListener);
712
+ * 3. the object releases what it runs through the ECMAScript dispose
713
+ * protocol, `[Symbol.asyncDispose]` or else `[Symbol.dispose]`.
714
+ *
715
+ * @returns {Object} `{ ended, disposed }`: whether the name was known, and
716
+ * a promise when an asynchronous dispose is under way (it never rejects)
717
+ */
718
+ static _end (instanceId) {
674
719
  const entry = VrpcAdapter._instances.get(instanceId)
675
- if (entry) {
676
- // in case the instance was an EventEmitter, free all registered listeners
677
- if (VrpcAdapter._isFunction(entry.removeAllListeners)) {
678
- entry.removeAllListeners()
720
+ if (!entry) return { ended: false, disposed: null }
721
+ VrpcAdapter._instances.delete(instanceId)
722
+ VrpcAdapter._detachListeners(instanceId, entry.instance)
723
+ return { ended: true, disposed: VrpcAdapter._dispose(instanceId, entry) }
724
+ }
725
+
726
+ static _detachListeners (instanceId, instance) {
727
+ const registrations = VrpcAdapter._listeners[instanceId]
728
+ delete VrpcAdapter._listeners[instanceId]
729
+ if (!registrations || !VrpcAdapter._isFunction(instance.removeListener)) {
730
+ return
731
+ }
732
+ for (const { event, listener, clients } of Object.values(registrations)) {
733
+ try {
734
+ VrpcAdapter._removeTimes(instance, event, listener, clients.length)
735
+ } catch (err) {
736
+ // a class whose removeListener fails keeps a silent listener
679
737
  }
680
- delete VrpcAdapter._listeners[instanceId]
681
- VrpcAdapter._instances.delete(instanceId)
682
- return true
683
738
  }
684
- return false
739
+ }
740
+
741
+ // a subscription repeated n times may hold n attachments of one listener
742
+ // (EventEmitter semantics): each removeListener takes one of them
743
+ static _removeTimes (instance, event, listener, times) {
744
+ for (let n = 0; n < Math.max(1, times); n++) {
745
+ instance.removeListener(event, listener)
746
+ }
747
+ }
748
+
749
+ /**
750
+ * Runs the object's dispose method. A dispose that fails or outlasts
751
+ * VrpcAdapter.DISPOSE_TIMEOUT is reported (event 'disposeFailed') and
752
+ * never thrown: the instance is gone either way.
753
+ *
754
+ * @returns {Promise|null} Settles when an asynchronous dispose is done
755
+ */
756
+ static _dispose (instanceId, { instance, className }) {
757
+ const fail = error =>
758
+ VrpcAdapter._emitter.emit('disposeFailed', {
759
+ instance: instanceId,
760
+ className,
761
+ error
762
+ })
763
+ let result
764
+ try {
765
+ if (VrpcAdapter._isFunction(instance[Symbol.asyncDispose])) {
766
+ result = instance[Symbol.asyncDispose]()
767
+ } else if (VrpcAdapter._isFunction(instance[Symbol.dispose])) {
768
+ result = instance[Symbol.dispose]()
769
+ }
770
+ } catch (err) {
771
+ fail(err)
772
+ return null
773
+ }
774
+ if (!VrpcAdapter._isPromise(result)) return null
775
+ let timer
776
+ const bound = new Promise(resolve => {
777
+ timer = setTimeout(() => {
778
+ fail(
779
+ new Error(
780
+ `dispose did not settle within ${VrpcAdapter.DISPOSE_TIMEOUT} ms`
781
+ )
782
+ )
783
+ resolve()
784
+ }, VrpcAdapter.DISPOSE_TIMEOUT)
785
+ })
786
+ const settled = result.then(
787
+ () => {},
788
+ err => fail(err)
789
+ )
790
+ return Promise.race([settled, bound]).then(() => clearTimeout(timer))
685
791
  }
686
792
 
687
793
  static _unregisterClient (clientId) {
688
794
  Object.entries(VrpcAdapter._listeners).forEach(([ik, iv]) => {
689
795
  Object.entries(iv).forEach(([ek, ev]) => {
690
- const { clients, listener, event } = ev
691
- const index = clients.indexOf(clientId)
692
- if (index !== -1) {
693
- clients.splice(index, 1)
694
- if (clients.length === 0) {
695
- const instance = VrpcAdapter.getInstance(ik)
696
- if (instance && instance.removeListener) {
697
- instance.removeListener(event, listener)
698
- }
699
- delete VrpcAdapter._listeners[ik][ek]
796
+ const { listener, event } = ev
797
+ // a client is listed once per subscription: its going ends them all
798
+ const remaining = ev.clients.filter(x => x !== clientId)
799
+ const gone = ev.clients.length - remaining.length
800
+ if (gone === 0) return
801
+ ev.clients = remaining
802
+ if (remaining.length === 0) {
803
+ const entry = VrpcAdapter._instances.get(ik)
804
+ const instance = entry && entry.instance
805
+ if (instance && instance.removeListener) {
806
+ VrpcAdapter._removeTimes(instance, event, listener, gone)
700
807
  }
808
+ delete VrpcAdapter._listeners[ik][ek]
701
809
  }
702
810
  })
703
811
  })
@@ -774,8 +882,27 @@ class VrpcAdapter {
774
882
  return unwrapped
775
883
  }
776
884
 
885
+ /**
886
+ * Whether the object under `instanceId` is still the one the adapter saw
887
+ * when it built a listener or a callback for it: a deleted instance never
888
+ * speaks on the bus again (#1705), even when its class kept the listener
889
+ * or its clock kept running. A static context (a class name) has no
890
+ * object and always speaks.
891
+ */
892
+ static _speaksFor (instanceId) {
893
+ const entry = VrpcAdapter._instances.get(instanceId)
894
+ if (!entry) return () => true
895
+ const { instance } = entry
896
+ return () => {
897
+ const live = VrpcAdapter._instances.get(instanceId)
898
+ return live !== undefined && live.instance === instance
899
+ }
900
+ }
901
+
777
902
  static _generateCallback ({ eventId, clientId, instanceId, isCallAll }) {
903
+ const speaks = VrpcAdapter._speaksFor(instanceId)
778
904
  return (...innerArgs) => {
905
+ if (!speaks()) return
779
906
  // "callAll" event notifications expect the instanceId as first argument
780
907
  const a = isCallAll ? [instanceId, ...innerArgs] : [...innerArgs]
781
908
  VrpcAdapter._callback({ a, s: clientId, i: eventId })
@@ -820,7 +947,9 @@ class VrpcAdapter {
820
947
  }
821
948
 
822
949
  static _generateListener ({ eventId, instanceId, isCallAll }) {
950
+ const speaks = VrpcAdapter._speaksFor(instanceId)
823
951
  return (...innerArgs) => {
952
+ if (!speaks()) return
824
953
  const a = isCallAll ? [instanceId, ...innerArgs] : [...innerArgs]
825
954
  VrpcAdapter._callback({ a, i: eventId })
826
955
  }
@@ -1003,12 +1132,28 @@ class VrpcAdapter {
1003
1132
  * @property {String} instance The instance name
1004
1133
  */
1005
1134
 
1135
+ /**
1136
+ * Event 'disposeFailed'
1137
+ *
1138
+ * Emitted when the dispose method of a deleted instance throws, rejects or
1139
+ * does not settle within VrpcAdapter.DISPOSE_TIMEOUT. The instance is gone
1140
+ * regardless.
1141
+ *
1142
+ * @event VrpcAdapter#disposeFailed
1143
+ * @type {Object}
1144
+ * @property {String} className The class name of the deleted instance
1145
+ * @property {String} instance The instance name
1146
+ * @property {Error} error What went wrong
1147
+ */
1148
+
1006
1149
  // Initialize static members
1007
1150
  VrpcAdapter._functionRegistry = new Map()
1008
1151
  VrpcAdapter._instances = new Map()
1009
1152
  VrpcAdapter._emitter = new EventEmitter()
1010
1153
  VrpcAdapter._correlationId = 0
1011
1154
  VrpcAdapter._listeners = {}
1155
+ // the longest a delete waits for the dispose method of its instance
1156
+ VrpcAdapter.DISPOSE_TIMEOUT = 10000
1012
1157
  VrpcAdapter._blackList = new Set([
1013
1158
  'caller',
1014
1159
  'callee',
package/vrpc/VrpcAgent.js CHANGED
@@ -7,6 +7,7 @@ const EventEmitter = require('events')
7
7
  const jsonStringifySafe = require('json-stringify-safe')
8
8
  const VrpcAdapter = require('./VrpcAdapter')
9
9
  const SubscribeRetrier = require('./SubscribeRetrier')
10
+ const ReconnectBackoff = require('./ReconnectBackoff')
10
11
 
11
12
  const VRPC_PROTOCOL_VERSION = 3
12
13
 
@@ -145,6 +146,13 @@ class VrpcAgent extends EventEmitter {
145
146
  }
146
147
  this._baseTopic = `${this._domain}/${this._agent}`
147
148
  VrpcAdapter.onCallback(this._handleVrpcCallback.bind(this))
149
+ // a deleted instance whose dispose failed is gone anyway: say why
150
+ this._handleDisposeFailed = ({ instance, className, error }) =>
151
+ this._log.warn(
152
+ error,
153
+ `Dispose of deleted instance ${className}::${instance} failed: ${error.message}`
154
+ )
155
+ VrpcAdapter.on('disposeFailed', this._handleDisposeFailed)
148
156
  // maps clientId to instanceId
149
157
  this._isolatedInstances = new Map()
150
158
  this._sharedInstances = new Map()
@@ -214,6 +222,8 @@ class VrpcAgent extends EventEmitter {
214
222
  this._log.info(`Broker : ${this._broker}`)
215
223
  this._log.info('Connecting to MQTT server...')
216
224
  this._client = mqtt.connect(this._broker, this._options)
225
+ // a refused agent knocks less and less often, not once a second
226
+ this._backoff = new ReconnectBackoff(this._client)
217
227
  this._client.on('connect', this._handleConnect.bind(this))
218
228
  this._client.on('reconnect', this._handleReconnect.bind(this))
219
229
  this._client.on('error', this._handleError.bind(this))
@@ -232,10 +242,19 @@ class VrpcAgent extends EventEmitter {
232
242
  * @returns {Promise} Resolves when disconnected and ended
233
243
  */
234
244
  async end ({ unregister = false } = {}) {
245
+ VrpcAdapter.off('disposeFailed', this._handleDisposeFailed)
235
246
  this._subscribeRetrier.cancel()
236
247
  try {
237
248
  if (!this._client || !this._client.connected) {
238
- this.emit('end')
249
+ // a client that is still trying (refused, broker away) stops
250
+ // trying: end() is how a refusal becomes final
251
+ // (its 'end' is forwarded as the agent's)
252
+ if (this._client) {
253
+ const client = this._client
254
+ await new Promise(resolve => client.end(true, {}, resolve))
255
+ } else {
256
+ this.emit('end')
257
+ }
239
258
  return
240
259
  }
241
260
  const agentTopic = `${this._baseTopic}/__agentInfo__`
@@ -744,7 +763,9 @@ class VrpcAgent extends EventEmitter {
744
763
  const entry = this._isolatedInstances.get(connectionId)
745
764
  if (entry) {
746
765
  entry.forEach(instanceId => {
766
+ const known = VrpcAdapter._instances.get(instanceId)
747
767
  const json = {
768
+ c: known && known.className,
748
769
  f: '__delete__',
749
770
  a: [instanceId],
750
771
  r: null,
@@ -752,6 +773,8 @@ class VrpcAgent extends EventEmitter {
752
773
  }
753
774
  VrpcAdapter._call(json)
754
775
  if (json.r) {
776
+ // gone like a remote delete: no request topic outlives it
777
+ this._unsubscribeMethodsOfDeletedInstance(known.className, instanceId)
755
778
  this._log.debug(`Auto-deleted isolated instance: ${instanceId}`)
756
779
  }
757
780
  })