vrpc 3.12.1 → 3.13.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.13.0",
4
4
  "description": "Variadic Remote Procedure Calls",
5
5
  "main": "index.js",
6
6
  "directories": {
@@ -12,7 +12,7 @@
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: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
18
  "test:agent": "tests/agent/test.sh",
@@ -20,6 +20,7 @@
20
20
  "test:performance": "tests/performance/test.sh",
21
21
  "test:persistor": "./node_modules/.bin/mocha tests/persistor/*.js --timeout 30000 --exit",
22
22
  "test:lifecycle": "./node_modules/.bin/mocha tests/lifecycle/*.js --timeout 30000 --exit",
23
+ "test:legacy-runtime": "tests/legacy-runtime/test.sh",
23
24
  "test:production": "./node_modules/.bin/mocha tests/production/lifeCycleTest.js --timeout 30000 --exit && tests/production/test.sh"
24
25
  },
25
26
  "bin": {
@@ -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
@@ -145,6 +145,13 @@ class VrpcAgent extends EventEmitter {
145
145
  }
146
146
  this._baseTopic = `${this._domain}/${this._agent}`
147
147
  VrpcAdapter.onCallback(this._handleVrpcCallback.bind(this))
148
+ // a deleted instance whose dispose failed is gone anyway: say why
149
+ this._handleDisposeFailed = ({ instance, className, error }) =>
150
+ this._log.warn(
151
+ error,
152
+ `Dispose of deleted instance ${className}::${instance} failed: ${error.message}`
153
+ )
154
+ VrpcAdapter.on('disposeFailed', this._handleDisposeFailed)
148
155
  // maps clientId to instanceId
149
156
  this._isolatedInstances = new Map()
150
157
  this._sharedInstances = new Map()
@@ -232,6 +239,7 @@ class VrpcAgent extends EventEmitter {
232
239
  * @returns {Promise} Resolves when disconnected and ended
233
240
  */
234
241
  async end ({ unregister = false } = {}) {
242
+ VrpcAdapter.off('disposeFailed', this._handleDisposeFailed)
235
243
  this._subscribeRetrier.cancel()
236
244
  try {
237
245
  if (!this._client || !this._client.connected) {
@@ -744,7 +752,9 @@ class VrpcAgent extends EventEmitter {
744
752
  const entry = this._isolatedInstances.get(connectionId)
745
753
  if (entry) {
746
754
  entry.forEach(instanceId => {
755
+ const known = VrpcAdapter._instances.get(instanceId)
747
756
  const json = {
757
+ c: known && known.className,
748
758
  f: '__delete__',
749
759
  a: [instanceId],
750
760
  r: null,
@@ -752,6 +762,8 @@ class VrpcAgent extends EventEmitter {
752
762
  }
753
763
  VrpcAdapter._call(json)
754
764
  if (json.r) {
765
+ // gone like a remote delete: no request topic outlives it
766
+ this._unsubscribeMethodsOfDeletedInstance(known.className, instanceId)
755
767
  this._log.debug(`Auto-deleted isolated instance: ${instanceId}`)
756
768
  }
757
769
  })
@@ -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
  })