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.
@@ -39,6 +39,16 @@ const Ajv = require('ajv')
39
39
  const EventEmitter = require('events')
40
40
  const { nanoid } = require('nanoid')
41
41
 
42
+ // A runtime without dispose symbols (older browsers, Node before 18.18 /
43
+ // 20.4) gets Node's own stand-ins from the symbol registry, so a class file
44
+ // and the adapter meet on one symbol whichever of them installs it first.
45
+ if (typeof Symbol.dispose !== 'symbol') {
46
+ Symbol.dispose = Symbol.for('nodejs.dispose')
47
+ }
48
+ if (typeof Symbol.asyncDispose !== 'symbol') {
49
+ Symbol.asyncDispose = Symbol.for('nodejs.asyncDispose')
50
+ }
51
+
42
52
  const VRPC_GLOBAL = '__global__'
43
53
  const VRPC_WINDOW_NS = 'vrpc'
44
54
 
@@ -188,7 +198,10 @@ class VrpcAdapter {
188
198
  }
189
199
 
190
200
  /**
191
- * Deletes an instance
201
+ * Deletes an instance: its name is free at once, the adapter takes back
202
+ * every listener it attached, and the object releases what it runs
203
+ * through its `[Symbol.asyncDispose]` or `[Symbol.dispose]` method, which
204
+ * runs in the background here (a remote delete answers once it settled).
192
205
  *
193
206
  * @param {String|Object} instance Instance (name or object itself) to be deleted
194
207
  * @returns {Boolean} True in case of success, false otherwise
@@ -199,10 +212,7 @@ class VrpcAdapter {
199
212
  }
200
213
  if (typeof instance === 'object') {
201
214
  for (const [k, v] of VrpcAdapter._instances) {
202
- if (v.instance === instance) {
203
- VrpcAdapter._instances.delete(k)
204
- return true
205
- }
215
+ if (v.instance === instance) return VrpcAdapter._delete(k)
206
216
  }
207
217
  return false
208
218
  }
@@ -457,11 +467,17 @@ class VrpcAdapter {
457
467
  instance,
458
468
  className: json.c
459
469
  })
460
- json.r = VrpcAdapter._delete(instance)
470
+ const { ended, disposed } = VrpcAdapter._end(instance)
461
471
  VrpcAdapter._emitter.emit('delete', {
462
472
  instance,
463
473
  className: json.c
464
474
  })
475
+ // a delete answers once the object released what it runs, so that a
476
+ // constructor right after it never meets a resource the old object
477
+ // still holds; the agent's own housekeeping awaits no answer
478
+ if (disposed && json.s !== VrpcAdapter.LOCAL_SENDER) {
479
+ VrpcAdapter._handlePromise(json, disposed.then(() => ended))
480
+ } else json.r = ended
465
481
  } catch (err) {
466
482
  json.e = err.message
467
483
  }
@@ -482,6 +498,15 @@ class VrpcAdapter {
482
498
  json.r = true
483
499
  return
484
500
  }
501
+ // an unknown context fails before its arguments are unwrapped: a
502
+ // listener registered for a name that does not exist would outlive the
503
+ // failed call and pass for attached once the name is created
504
+ if (
505
+ !VrpcAdapter._functionRegistry.has(json.c) &&
506
+ !VrpcAdapter._instances.has(json.c)
507
+ ) {
508
+ throw new Error(`Could not find context: ${json.c}`)
509
+ }
485
510
  const unwrapped = VrpcAdapter._unwrapArguments(json)
486
511
  if (!unwrapped) {
487
512
  // this call must be skipped (event emitter stuff)
@@ -557,33 +582,112 @@ class VrpcAdapter {
557
582
  }
558
583
 
559
584
  static _delete (instanceId) {
585
+ return VrpcAdapter._end(instanceId).ended
586
+ }
587
+
588
+ /**
589
+ * Ends an instance - the one teardown behind every door (a remote delete,
590
+ * the auto-delete of an isolated instance whose client went, an
591
+ * in-process delete): its name is free at once, the adapter takes back
592
+ * every listener it attached (silent from now on anyway, see
593
+ * _generateListener), and the object releases what it runs through the
594
+ * ECMAScript dispose protocol, `[Symbol.asyncDispose]` or else
595
+ * `[Symbol.dispose]`.
596
+ *
597
+ * @returns {Object} `{ ended, disposed }`: whether the name was known, and
598
+ * a promise when an asynchronous dispose is under way (it never rejects)
599
+ */
600
+ static _end (instanceId) {
560
601
  const entry = VrpcAdapter._instances.get(instanceId)
561
- if (entry) {
562
- // in case the instance was an EventEmitter, free all registered listeners
563
- if (VrpcAdapter._isFunction(entry.removeAllListeners)) {
564
- entry.removeAllListeners()
602
+ if (!entry) return { ended: false, disposed: null }
603
+ VrpcAdapter._instances.delete(instanceId)
604
+ VrpcAdapter._detachListeners(instanceId, entry.instance)
605
+ return { ended: true, disposed: VrpcAdapter._dispose(instanceId, entry) }
606
+ }
607
+
608
+ static _detachListeners (instanceId, instance) {
609
+ const registrations = VrpcAdapter._listeners[instanceId]
610
+ delete VrpcAdapter._listeners[instanceId]
611
+ if (!registrations || !VrpcAdapter._isFunction(instance.removeListener)) {
612
+ return
613
+ }
614
+ for (const { event, listener, clients } of Object.values(registrations)) {
615
+ try {
616
+ VrpcAdapter._removeTimes(instance, event, listener, clients.length)
617
+ } catch (err) {
618
+ // a class whose removeListener fails keeps a silent listener
565
619
  }
566
- delete VrpcAdapter._listeners[instanceId]
567
- VrpcAdapter._instances.delete(instanceId)
568
- return true
569
620
  }
570
- return false
621
+ }
622
+
623
+ // a subscription repeated n times may hold n attachments of one listener
624
+ // (EventEmitter semantics): each removeListener takes one of them
625
+ static _removeTimes (instance, event, listener, times) {
626
+ for (let n = 0; n < Math.max(1, times); n++) {
627
+ instance.removeListener(event, listener)
628
+ }
629
+ }
630
+
631
+ /**
632
+ * Runs the object's dispose method. A dispose that fails or outlasts
633
+ * VrpcAdapter.DISPOSE_TIMEOUT is reported (event 'disposeFailed') and
634
+ * never thrown: the instance is gone either way.
635
+ *
636
+ * @returns {Promise|null} Settles when an asynchronous dispose is done
637
+ */
638
+ static _dispose (instanceId, { instance, className }) {
639
+ const fail = error =>
640
+ VrpcAdapter._emitter.emit('disposeFailed', {
641
+ instance: instanceId,
642
+ className,
643
+ error
644
+ })
645
+ let result
646
+ try {
647
+ if (VrpcAdapter._isFunction(instance[Symbol.asyncDispose])) {
648
+ result = instance[Symbol.asyncDispose]()
649
+ } else if (VrpcAdapter._isFunction(instance[Symbol.dispose])) {
650
+ result = instance[Symbol.dispose]()
651
+ }
652
+ } catch (err) {
653
+ fail(err)
654
+ return null
655
+ }
656
+ if (!VrpcAdapter._isPromise(result)) return null
657
+ let timer
658
+ const bound = new Promise(resolve => {
659
+ timer = setTimeout(() => {
660
+ fail(
661
+ new Error(
662
+ `dispose did not settle within ${VrpcAdapter.DISPOSE_TIMEOUT} ms`
663
+ )
664
+ )
665
+ resolve()
666
+ }, VrpcAdapter.DISPOSE_TIMEOUT)
667
+ })
668
+ const settled = result.then(
669
+ () => {},
670
+ err => fail(err)
671
+ )
672
+ return Promise.race([settled, bound]).then(() => clearTimeout(timer))
571
673
  }
572
674
 
573
675
  static _unregisterClient (clientId) {
574
676
  Object.entries(VrpcAdapter._listeners).forEach(([ik, iv]) => {
575
677
  Object.entries(iv).forEach(([ek, ev]) => {
576
- const { clients, listener, event } = ev
577
- const index = clients.indexOf(clientId)
578
- if (index !== -1) {
579
- clients.splice(index, 1)
580
- if (clients.length === 0) {
581
- const instance = VrpcAdapter.getInstance(ik)
582
- if (instance && instance.removeListener) {
583
- instance.removeListener(event, listener)
584
- }
585
- delete VrpcAdapter._listeners[ik][ek]
678
+ const { listener, event } = ev
679
+ // a client is listed once per subscription: its going ends them all
680
+ const remaining = ev.clients.filter(x => x !== clientId)
681
+ const gone = ev.clients.length - remaining.length
682
+ if (gone === 0) return
683
+ ev.clients = remaining
684
+ if (remaining.length === 0) {
685
+ const entry = VrpcAdapter._instances.get(ik)
686
+ const instance = entry && entry.instance
687
+ if (instance && instance.removeListener) {
688
+ VrpcAdapter._removeTimes(instance, event, listener, gone)
586
689
  }
690
+ delete VrpcAdapter._listeners[ik][ek]
587
691
  }
588
692
  })
589
693
  })
@@ -622,6 +726,7 @@ class VrpcAdapter {
622
726
  // event registration
623
727
  if (func === 'on' || func === 'addListener') {
624
728
  VrpcAdapter._mustTrackClient = true
729
+ const held = VrpcAdapter._holdsListener(context, arg)
625
730
  const listener = VrpcAdapter._registerListener({
626
731
  clientId,
627
732
  isCallAll,
@@ -629,9 +734,8 @@ class VrpcAdapter {
629
734
  eventId: arg,
630
735
  event: args[0]
631
736
  })
632
- if (!listener) {
633
- return null // skip call as listener is already registered
634
- }
737
+ // attached already: skip the call, one listener hears once
738
+ if (held) return null
635
739
  unwrapped.push(listener)
636
740
  // event un-registration
637
741
  } else if (func === 'off' || func === 'removeListener') {
@@ -666,8 +770,32 @@ class VrpcAdapter {
666
770
  return unwrapped
667
771
  }
668
772
 
773
+ /**
774
+ * Whether the object under `instanceId` is still the one the adapter saw
775
+ * when it built a listener or a callback for it: a deleted instance never
776
+ * speaks on the bus again, even when its class kept the listener or its
777
+ * clock kept running. A static context (a class name) has no object and
778
+ * always speaks.
779
+ */
780
+ static _speaksFor (instanceId) {
781
+ const entry = VrpcAdapter._instances.get(instanceId)
782
+ if (!entry) return () => true
783
+ const { instance } = entry
784
+ return () => {
785
+ const live = VrpcAdapter._instances.get(instanceId)
786
+ return live !== undefined && live.instance === instance
787
+ }
788
+ }
789
+
790
+ static _holdsListener (instanceId, eventId) {
791
+ const registrations = VrpcAdapter._listeners[instanceId]
792
+ return Boolean(registrations && registrations[eventId])
793
+ }
794
+
669
795
  static _generateCallback ({ eventId, clientId, instanceId, isCallAll }) {
796
+ const speaks = VrpcAdapter._speaksFor(instanceId)
670
797
  return (...innerArgs) => {
798
+ if (!speaks()) return
671
799
  // "callAll" event notifications expect the instanceId as first argument
672
800
  const a = isCallAll ? [instanceId, ...innerArgs] : [...innerArgs]
673
801
  VrpcAdapter._callback({ a, s: clientId, i: eventId })
@@ -708,11 +836,13 @@ class VrpcAdapter {
708
836
  return VrpcAdapter._listeners[instanceId][eventId].listener
709
837
  }
710
838
  VrpcAdapter._listeners[instanceId][eventId].clients.push(clientId)
711
- return null
839
+ return VrpcAdapter._listeners[instanceId][eventId].listener
712
840
  }
713
841
 
714
842
  static _generateListener ({ eventId, instanceId, isCallAll }) {
843
+ const speaks = VrpcAdapter._speaksFor(instanceId)
715
844
  return (...innerArgs) => {
845
+ if (!speaks()) return
716
846
  const a = isCallAll ? [instanceId, ...innerArgs] : [...innerArgs]
717
847
  VrpcAdapter._callback({ a, i: eventId })
718
848
  }
@@ -892,12 +1022,28 @@ class VrpcAdapter {
892
1022
  * @property {String} instance The instance name
893
1023
  */
894
1024
 
1025
+ /**
1026
+ * Event 'disposeFailed'
1027
+ *
1028
+ * Emitted when the dispose method of a deleted instance throws, rejects or
1029
+ * does not settle within VrpcAdapter.DISPOSE_TIMEOUT. The instance is gone
1030
+ * regardless.
1031
+ *
1032
+ * @event VrpcAdapter#disposeFailed
1033
+ * @type {Object}
1034
+ * @property {String} className The class name of the deleted instance
1035
+ * @property {String} instance The instance name
1036
+ * @property {Error} error What went wrong
1037
+ */
1038
+
895
1039
  // Initialize static members
896
1040
  VrpcAdapter._functionRegistry = new Map()
897
1041
  VrpcAdapter._instances = new Map()
898
1042
  VrpcAdapter._emitter = new EventEmitter()
899
1043
  VrpcAdapter._correlationId = 0
900
1044
  VrpcAdapter._listeners = {}
1045
+ // the longest a delete waits for the dispose method of its instance
1046
+ VrpcAdapter.DISPOSE_TIMEOUT = 10000
901
1047
  VrpcAdapter._blackList = new Set([
902
1048
  'caller',
903
1049
  'callee',
@@ -68,6 +68,13 @@ class VrpcAgent extends EventEmitter {
68
68
  }
69
69
  this._baseTopic = `${this._domain}/${this._agent}`
70
70
  VrpcAdapter.onCallback(this._handleVrpcCallback.bind(this))
71
+ // a deleted instance whose dispose failed is gone anyway: say why
72
+ this._handleDisposeFailed = ({ instance, className, error }) =>
73
+ this._log.warn(
74
+ error,
75
+ `Dispose of deleted instance ${className}::${instance} failed: ${error.message}`
76
+ )
77
+ VrpcAdapter.on('disposeFailed', this._handleDisposeFailed)
71
78
  // maps clientId to instanceId
72
79
  this._isolatedInstances = new Map()
73
80
  this._sharedInstances = new Map()
@@ -146,6 +153,7 @@ class VrpcAgent extends EventEmitter {
146
153
  * @returns {Promise} Resolves when disconnected and ended
147
154
  */
148
155
  async end ({ unregister = false } = {}) {
156
+ VrpcAdapter.off('disposeFailed', this._handleDisposeFailed)
149
157
  try {
150
158
  if (!this._client || !this._client.connected) {
151
159
  this.emit('end')
@@ -542,7 +550,9 @@ class VrpcAgent extends EventEmitter {
542
550
  const entry = this._isolatedInstances.get(connectionId)
543
551
  if (entry) {
544
552
  entry.forEach(instanceId => {
553
+ const known = VrpcAdapter._instances.get(instanceId)
545
554
  const json = {
555
+ c: known && known.className,
546
556
  f: '__delete__',
547
557
  a: [instanceId],
548
558
  r: null,
@@ -550,6 +560,8 @@ class VrpcAgent extends EventEmitter {
550
560
  }
551
561
  VrpcAdapter._call(json)
552
562
  if (json.r) {
563
+ // gone like a remote delete: no request topic outlives it
564
+ this._unsubscribeMethodsOfDeletedInstance(known.className, instanceId)
553
565
  this._log.debug(`Auto-deleted isolated instance: ${instanceId}`)
554
566
  }
555
567
  })
@@ -40,9 +40,14 @@ const { nanoid } = require('nanoid')
40
40
  const mqtt = require('mqtt')
41
41
  const EventEmitter = require('events')
42
42
  const SubscribeRetrier = require('./SubscribeRetrier')
43
+ const ReconnectBackoff = require('./ReconnectBackoff')
43
44
 
44
45
  const VRPC_PROTOCOL_VERSION = 3
45
46
 
47
+ // mqtt clients this client ended on purpose: the 'client disconnecting'
48
+ // workaround must never revive one of them
49
+ const ENDED = new WeakSet()
50
+
46
51
  /**
47
52
  * Client capable of creating proxy objects and remotely calling
48
53
  * functions as provided through one or more (distributed) agents.
@@ -220,7 +225,19 @@ class VrpcClient extends EventEmitter {
220
225
  /**
221
226
  * Actually connects to the MQTT broker.
222
227
  *
228
+ * A refused connection is retried with a growing wait (doubling up to
229
+ * 30 s, back to the start once connected), and every refusal is emitted
230
+ * as 'error', so a caller can renew its credentials (see
231
+ * updateCredentials).
232
+ *
223
233
  * @emits connected
234
+ * @param {Object} [options]
235
+ * @param {Boolean} [options.keepTrying=false] If true, a connection that
236
+ * is not made within [timeout] still rejects the returned promise, but
237
+ * keeps trying until end() and emits 'connect' once it stands, and a
238
+ * further connect({ keepTrying: true }) while it is trying waits for it
239
+ * instead of opening a second connection. If false (the default), nothing
240
+ * changes: the client gives up at the timeout and must be connected again.
224
241
  * @returns {Promise} Resolves once connected within [timeout], rejects otherwise
225
242
  * @example
226
243
  * try {
@@ -229,14 +246,14 @@ class VrpcClient extends EventEmitter {
229
246
  * console.error(`Could not connect because: ${err.message}`)
230
247
  * }
231
248
  */
232
- async connect () {
249
+ async connect ({ keepTrying = false } = {}) {
233
250
  if (this._client && this._client.connected) return
234
- let username = this._username
235
- let password = this._password
236
- if (this._token) {
237
- username = username || this._generateUserName()
238
- password = this._token
251
+ // keepTrying: a connection that is still trying is waited for, never
252
+ // doubled (without it, connect() behaves as it always did)
253
+ if (keepTrying && this._client && !ENDED.has(this._client)) {
254
+ return this._awaitConnect(this._client, { keepTrying })
239
255
  }
256
+ const { username, password } = this._credentials()
240
257
  const options = {
241
258
  username,
242
259
  password,
@@ -264,6 +281,7 @@ class VrpcClient extends EventEmitter {
264
281
  })
265
282
 
266
283
  this._client = mqtt.connect(this._broker, options)
284
+ this._backoff = new ReconnectBackoff(this._client)
267
285
 
268
286
  // Persistent lifecycle handlers
269
287
  this._client.on('offline', () => {
@@ -393,25 +411,78 @@ class VrpcClient extends EventEmitter {
393
411
  }
394
412
  })
395
413
 
414
+ return this._awaitConnect(this._client, { keepTrying })
415
+ }
416
+
417
+ _awaitConnect (client, { keepTrying }) {
396
418
  return new Promise((resolve, reject) => {
397
419
  const timeout = setTimeout(() => {
398
420
  // This will run only if the 'connect' event isn't fired in time.
421
+ const error = new Error(
422
+ `Connection trial timed out (> ${this._timeout} ms)`
423
+ )
424
+ if (keepTrying) {
425
+ // the client keeps trying in the background, until end()
426
+ client.removeListener('connect', onConnect)
427
+ reject(error)
428
+ return
429
+ }
399
430
  // We explicitly end the client to stop it from retrying in the background.
400
- this._client.end(true, () => {
401
- reject(
402
- new Error(`Connection trial timed out (> ${this._timeout} ms)`)
403
- )
404
- })
431
+ VrpcClient._endMqtt(client, true, {}, () => reject(error))
405
432
  }, this._timeout)
406
433
 
407
- this._client.once('connect', () => {
434
+ const onConnect = () => {
408
435
  // If we connect successfully, clear the timeout and resolve the promise.
409
436
  clearTimeout(timeout)
410
437
  resolve()
411
- })
438
+ }
439
+ client.once('connect', onConnect)
412
440
  })
413
441
  }
414
442
 
443
+ /**
444
+ * Replaces the credentials the connection logs in with
445
+ *
446
+ * The broker checks credentials when a connection is made: a live
447
+ * connection carries on undisturbed, and the next one - after a network
448
+ * loss, a broker restart - logs in with these. A connection that is
449
+ * being refused right now tries again with them within a second,
450
+ * instead of waiting out its back-off.
451
+ *
452
+ * @param {Object} credentials Only the given fields change
453
+ * @param {String} [credentials.username] MQTT username
454
+ * @param {String} [credentials.password] MQTT password (if no token is used)
455
+ * @param {String} [credentials.token] Access token
456
+ */
457
+ updateCredentials ({ username, password, token } = {}) {
458
+ if (username !== undefined) this._username = username
459
+ if (password !== undefined) this._password = password
460
+ if (token !== undefined) this._token = token
461
+ if (!this._client || ENDED.has(this._client)) return
462
+ const credentials = this._credentials()
463
+ for (const key of ['username', 'password']) {
464
+ if (credentials[key] === undefined) delete this._client.options[key]
465
+ else this._client.options[key] = credentials[key]
466
+ }
467
+ this._backoff.hurry()
468
+ }
469
+
470
+ // what the broker sees: a token rides as the password
471
+ _credentials () {
472
+ let username = this._username
473
+ let password = this._password
474
+ if (this._token) {
475
+ username = username || this._generateUserName()
476
+ password = this._token
477
+ }
478
+ return { username, password }
479
+ }
480
+
481
+ static _endMqtt (client, force, options, callback) {
482
+ ENDED.add(client)
483
+ client.end(force, options, callback)
484
+ }
485
+
415
486
  /**
416
487
  * Creates a new remote instance and provides a proxy to it.
417
488
  *
@@ -546,7 +617,27 @@ class VrpcClient extends EventEmitter {
546
617
  const topic = `${this._domain}/${agent}/${className}/__static__/__delete__`
547
618
  this._mqttPublish(topic, JSON.stringify(json))
548
619
  delete this._proxies[`${agent}/${className}/${instance}`]
549
- return this._handleAgentAnswer(json, agent)
620
+ const deleted = await this._handleAgentAnswer(json, agent)
621
+ // the answer and the class info that no longer lists the instance
622
+ // travel on different topics, and a broker need not keep their order:
623
+ // what this client deleted is gone for it at once
624
+ if (deleted === true) this._forgetInstance({ agent, className, instance })
625
+ return deleted
626
+ }
627
+
628
+ /**
629
+ * Takes an instance off this client's view of its agent, the way a class
630
+ * info without it does: listing, instanceGone, cached subscriptions. A
631
+ * class info that arrives later finds nothing left to remove.
632
+ */
633
+ _forgetInstance ({ agent, className, instance }) {
634
+ const info = this._agents[agent] && this._agents[agent].classes[className]
635
+ if (!info || !Array.isArray(info.instances)) return
636
+ if (!info.instances.includes(instance)) return
637
+ info.instances = info.instances.filter(x => x !== instance)
638
+ const domain = this._domain
639
+ this.emit('instanceGone', [instance], { domain, agent, className })
640
+ this._clearCachedSubscriptions({ lostInstance: instance, agent, className })
550
641
  }
551
642
 
552
643
  /**
@@ -795,7 +886,10 @@ class VrpcClient extends EventEmitter {
795
886
  this._token = token
796
887
  this._agent = agent
797
888
  if (this._client) {
798
- await new Promise(resolve => this._client.end(false, {}, resolve))
889
+ const client = this._client
890
+ await new Promise(resolve =>
891
+ VrpcClient._endMqtt(client, false, {}, resolve)
892
+ )
799
893
  }
800
894
  return this.connect()
801
895
  }
@@ -842,7 +936,9 @@ class VrpcClient extends EventEmitter {
842
936
  this.removeAllListeners()
843
937
  const client = this._client
844
938
  this._client = null // Prevent race conditions during shutdown
845
- return new Promise(resolve => client.end(false, {}, resolve))
939
+ return new Promise(resolve =>
940
+ VrpcClient._endMqtt(client, false, {}, resolve)
941
+ )
846
942
  }
847
943
 
848
944
  // Private functions
@@ -909,8 +1005,16 @@ class VrpcClient extends EventEmitter {
909
1005
  return `${this._domain}/${this._mqttClientId}/${this._connectionSecret}`
910
1006
  }
911
1007
 
1008
+ // The 'client disconnecting' workaround below revives a client mqtt.js
1009
+ // left half-closed, never one that was ended on purpose (a connect
1010
+ // timeout, end(), reconnectWithToken) or has been replaced since
1011
+ _isRevivable (client) {
1012
+ return client === this._client && !ENDED.has(client)
1013
+ }
1014
+
912
1015
  _mqttPublish (topic, message, options) {
913
- this._client.publish(
1016
+ const client = this._client
1017
+ client.publish(
914
1018
  topic,
915
1019
  message,
916
1020
  { qos: this._qos, ...options },
@@ -919,13 +1023,16 @@ class VrpcClient extends EventEmitter {
919
1023
  this._log.warn(
920
1024
  `Could not publish MQTT message because: ${err.message}`
921
1025
  )
922
- if (err.message === 'client disconnecting') {
1026
+ if (
1027
+ err.message === 'client disconnecting' &&
1028
+ this._isRevivable(client)
1029
+ ) {
923
1030
  // This most certainly relates to a bug in MQTT.js (#1284)
924
1031
  this._log.error(`Failed to publish, because: ${err.message}`)
925
1032
  this.emit('error', err)
926
1033
  this._log.info('Forcing reconnect now...')
927
- this._client.disconnecting = false
928
- this._client.reconnect()
1034
+ client.disconnecting = false
1035
+ client.reconnect()
929
1036
  }
930
1037
  }
931
1038
  }
@@ -933,8 +1040,9 @@ class VrpcClient extends EventEmitter {
933
1040
  }
934
1041
 
935
1042
  async _mqttSubscribe (topic, options) {
1043
+ const client = this._client
936
1044
  return new Promise((resolve, reject) => {
937
- this._client.subscribe(
1045
+ client.subscribe(
938
1046
  topic,
939
1047
  { qos: this._qos, ...options },
940
1048
  (err, granted) => {
@@ -942,15 +1050,20 @@ class VrpcClient extends EventEmitter {
942
1050
  this._log.warn(
943
1051
  `Could not subscribe to topic(s): '${topic}', because: ${err.message}`
944
1052
  )
945
- if (err.message === 'client disconnecting') {
1053
+ if (
1054
+ err.message === 'client disconnecting' &&
1055
+ this._isRevivable(client)
1056
+ ) {
946
1057
  // This most certainly relates to a bug in MQTT.js (#1284)
947
1058
  const msg = `Failed to subscribe, because: ${err.message}`
948
1059
  this._log.error(msg)
949
1060
  this.emit('error', err)
950
1061
  this._log.info('Forcing reconnect now...')
951
- this._client.disconnecting = false
952
- this._client.reconnect()
1062
+ client.disconnecting = false
1063
+ client.reconnect()
953
1064
  reject(new Error(msg))
1065
+ } else if (err.message === 'client disconnecting') {
1066
+ reject(err)
954
1067
  }
955
1068
  } else {
956
1069
  const topicArray = Array.isArray(topic) ? topic : [topic]
@@ -98,6 +98,8 @@ class VrpcPersistor {
98
98
  .replace(/[^a-zA-Z0-9]/g, '-')}`
99
99
 
100
100
  this._storage = null
101
+ // the 'update' listener on each persisted instance, dropped on delete
102
+ this._updateListeners = new Map()
101
103
  this._isInitialized = this._init(Storage)
102
104
 
103
105
  this._log.info(
@@ -282,7 +284,11 @@ class VrpcPersistor {
282
284
  const obj = VrpcAdapter.getInstance(instance)
283
285
  // Ensure the object is an event emitter
284
286
  if (obj && typeof obj.on === 'function') {
285
- obj.on('update', data => {
287
+ const listener = data => {
288
+ // a deleted object never rewrites the record of its name
289
+ // (#1705): an in-process delete announces nothing
290
+ const live = VrpcAdapter._instances.get(instance)
291
+ if (!live || live.instance !== obj) return
286
292
  this._log.info(
287
293
  `[VrpcPersistor] Persisting update for: ${instance} (${className})`
288
294
  )
@@ -293,7 +299,9 @@ class VrpcPersistor {
293
299
  `[VrpcPersistor] Failed persisting update of ${instance} (${className}): ${err.message}`
294
300
  )
295
301
  )
296
- })
302
+ }
303
+ obj.on('update', listener)
304
+ this._updateListeners.set(instance, { obj, listener })
297
305
  }
298
306
  } catch (err) {
299
307
  this._log.warn(
@@ -304,6 +312,11 @@ class VrpcPersistor {
304
312
 
305
313
  // Persist instance deletion
306
314
  VrpcAdapter.on('delete', ({ instance, className }) => {
315
+ const update = this._updateListeners.get(instance)
316
+ if (update) {
317
+ this._updateListeners.delete(instance)
318
+ update.obj.removeListener('update', update.listener)
319
+ }
307
320
  this._log.info(
308
321
  `[VrpcPersistor] Deleting persisted instance: ${instance} (${className})`
309
322
  )
@@ -230,6 +230,17 @@ function mkextract (opts) {
230
230
  indent = startPos + MARKER_START.length
231
231
  }
232
232
 
233
+ // a member with a computed key ([Symbol.asyncDispose]) names no remote
234
+ // function: the block documents it and nothing else - without this it
235
+ // landed on the first call in the member's body
236
+ if (
237
+ findMethod &&
238
+ /^\s*(static\s+)?(async\s+)?\*?\s*((get|set)\s+)?\[[^\]]+\]\s*\(/.test(line)
239
+ ) {
240
+ result = parse_block(chunk, opts)
241
+ findMethod = false
242
+ }
243
+
233
244
  if (findMethod) {
234
245
  const ret = line.match(/\s*function\s+(\w+)\s*\(/) ||
235
246
  line.match(/\s*(\w+)\s*=?\s*\(/)