vrpc 3.12.0 → 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.0",
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()
@@ -153,6 +160,11 @@ class VrpcAgent extends EventEmitter {
153
160
  subscribe: (topics, options) => this._mqttSubscribe(topics, options),
154
161
  log: this._log
155
162
  })
163
+ // counts the connections: an announcement belongs to the connection
164
+ // that prepared it (see _handleConnect)
165
+ this._connectGeneration = 0
166
+ // connected, request topics subscribed and info announced
167
+ this._serving = false
156
168
 
157
169
  // Handle the internal error event in case the user forgot to implement it
158
170
  this.on('error', err => {
@@ -172,8 +184,9 @@ class VrpcAgent extends EventEmitter {
172
184
  * If the connection could not be established because of authorization
173
185
  * failure, the 'error' event will be emitted.
174
186
  *
175
- * @return {Promise} Resolves once connected or explicitly ended, never
176
- * rejects
187
+ * @return {Promise} Resolves once the agent serves - connected, its
188
+ * request topics subscribed and its info announced - or once it was
189
+ * explicitly ended before that, never rejects
177
190
  */
178
191
  async serve () {
179
192
  let username = this._username
@@ -226,6 +239,7 @@ class VrpcAgent extends EventEmitter {
226
239
  * @returns {Promise} Resolves when disconnected and ended
227
240
  */
228
241
  async end ({ unregister = false } = {}) {
242
+ VrpcAdapter.off('disposeFailed', this._handleDisposeFailed)
229
243
  this._subscribeRetrier.cancel()
230
244
  try {
231
245
  if (!this._client || !this._client.connected) {
@@ -377,7 +391,13 @@ class VrpcAgent extends EventEmitter {
377
391
  )
378
392
  }
379
393
 
380
- _mqttSubscribe (topic, options) {
394
+ /**
395
+ * Subscribes topic(s); `onSettled` is called once the broker has
396
+ * answered (granted, refused or failed alike).
397
+ *
398
+ * @private
399
+ */
400
+ _mqttSubscribe (topic, options, onSettled) {
381
401
  this._client.subscribe(
382
402
  topic,
383
403
  { qos: this._qos, ...options },
@@ -424,6 +444,7 @@ class VrpcAgent extends EventEmitter {
424
444
  this._log.debug(`Already subscribed to topic '${topic}'`)
425
445
  }
426
446
  }
447
+ if (onSettled) onSettled()
427
448
  }
428
449
  )
429
450
  }
@@ -458,12 +479,18 @@ class VrpcAgent extends EventEmitter {
458
479
  return VrpcAdapter._getMetaData(className)
459
480
  }
460
481
 
482
+ /**
483
+ * Resolves once the agent serves (its own 'connect' event: connected,
484
+ * request topics subscribed, info announced), or once it was ended.
485
+ *
486
+ * @private
487
+ */
461
488
  async _ensureConnected () {
462
489
  return new Promise(resolve => {
463
- if (this._client.connected) {
490
+ if (this._serving) {
464
491
  resolve()
465
492
  } else {
466
- this._client.once('connect', resolve)
493
+ this.once('connect', resolve)
467
494
  // Will be triggered if the user called 'agent.end()'
468
495
  this.once('end', resolve)
469
496
  }
@@ -502,36 +529,64 @@ class VrpcAgent extends EventEmitter {
502
529
  return str
503
530
  }
504
531
 
532
+ /**
533
+ * Serve first, announce second. The request topics - the statics and
534
+ * the methods of every instance that exists already - are subscribed
535
+ * before the agent publishes its agent and class info, and the
536
+ * announcement waits until the broker has answered every subscription.
537
+ * A client acts on the announcement at once; a request it publishes
538
+ * before the broker routes the topic is lost (QoS 0) and only times out
539
+ * on the caller's side. That was the fate of the first call into an
540
+ * agent that came online after the client waiting for it. A refused
541
+ * subscription is the retrier's business and delays nothing.
542
+ *
543
+ * @private
544
+ */
505
545
  _handleConnect () {
506
546
  this._log.info('[OK]')
507
547
  // everything is subscribed afresh: pending retries are moot
508
548
  this._subscribeRetrier.cancel()
549
+ const generation = ++this._connectGeneration
550
+ const batches = []
509
551
  try {
510
- const topics = this._generateTopics()
511
- if (topics.length > 0) this._mqttSubscribe(topics)
552
+ const statics = this._generateTopics()
553
+ if (statics.length > 0) batches.push(statics)
512
554
  } catch (err) {
513
555
  this._log.error(
514
556
  err,
515
557
  `Problem during initial topic subscription: ${err.message}`
516
558
  )
517
559
  }
518
- // Publish agent online
519
- this._publishAgentInfoMessage()
520
-
521
- // Publish class information
522
- const classes = this._getClasses()
523
- classes.forEach(className => {
524
- this._publishClassInfoMessage(className)
525
- this._publishClassInfoConciseMessage(className)
526
- })
527
- // Register all pre-existing instances
560
+ // all pre-existing instances
528
561
  for (const [
529
562
  instanceId,
530
563
  { className }
531
564
  ] of VrpcAdapter._instances.entries()) {
532
- this._subscribeToMethodsOfNewInstance(className, instanceId)
565
+ batches.push(this._methodsTopic(className, instanceId))
566
+ }
567
+ let pending = batches.length
568
+ const announce = () => {
569
+ // a connection that ended meanwhile announces nothing: the next
570
+ // one prepares and announces itself
571
+ if (generation !== this._connectGeneration) return
572
+ this._publishAgentInfoMessage()
573
+ for (const className of this._getClasses()) {
574
+ this._publishClassInfoMessage(className)
575
+ this._publishClassInfoConciseMessage(className)
576
+ }
577
+ this._serving = true
578
+ this.emit('connect')
579
+ }
580
+ if (pending === 0) {
581
+ announce()
582
+ return
583
+ }
584
+ for (const topic of batches) {
585
+ this._mqttSubscribe(topic, undefined, () => {
586
+ pending -= 1
587
+ if (pending === 0) announce()
588
+ })
533
589
  }
534
- this.emit('connect')
535
590
  }
536
591
 
537
592
  _publishAgentInfoMessage () {
@@ -697,7 +752,9 @@ class VrpcAgent extends EventEmitter {
697
752
  const entry = this._isolatedInstances.get(connectionId)
698
753
  if (entry) {
699
754
  entry.forEach(instanceId => {
755
+ const known = VrpcAdapter._instances.get(instanceId)
700
756
  const json = {
757
+ c: known && known.className,
701
758
  f: '__delete__',
702
759
  a: [instanceId],
703
760
  r: null,
@@ -705,6 +762,8 @@ class VrpcAgent extends EventEmitter {
705
762
  }
706
763
  VrpcAdapter._call(json)
707
764
  if (json.r) {
765
+ // gone like a remote delete: no request topic outlives it
766
+ this._unsubscribeMethodsOfDeletedInstance(known.className, instanceId)
708
767
  this._log.debug(`Auto-deleted isolated instance: ${instanceId}`)
709
768
  }
710
769
  })
@@ -785,14 +844,23 @@ class VrpcAgent extends EventEmitter {
785
844
  return true
786
845
  }
787
846
 
847
+ /**
848
+ * The request topics of one instance: every method of it.
849
+ *
850
+ * @private
851
+ */
852
+ _methodsTopic (className, instance) {
853
+ return `${this._baseTopic}/${className}/${instance}/+`
854
+ }
855
+
788
856
  _subscribeToMethodsOfNewInstance (className, instance) {
789
- const topic = `${this._baseTopic}/${className}/${instance}/+`
857
+ const topic = this._methodsTopic(className, instance)
790
858
  this._mqttSubscribe(topic)
791
859
  this._log.debug(`Subscribed to new topic after instantiation: ${topic}`)
792
860
  }
793
861
 
794
862
  _unsubscribeMethodsOfDeletedInstance (className, instance) {
795
- const topic = `${this._baseTopic}/${className}/${instance}/+`
863
+ const topic = this._methodsTopic(className, instance)
796
864
  this._mqttUnsubscribe(topic)
797
865
  this._log.debug(`Unsubscribed from topic after deletion: ${topic}`)
798
866
  }
@@ -807,6 +875,8 @@ class VrpcAgent extends EventEmitter {
807
875
  }
808
876
 
809
877
  _handleClose () {
878
+ // the next connection prepares and announces itself afresh
879
+ this._serving = false
810
880
  this.emit('close')
811
881
  }
812
882