@peerbit/pubsub 5.3.8 → 5.3.10

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/dist/src/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {} from "@libp2p/interface";
2
2
  import { PublicSignKey, getPublicKeyFromPeerId } from "@peerbit/crypto";
3
3
  import { logger as loggerFn } from "@peerbit/logger";
4
- import { DataEvent, GetSubscribers, PeerUnavailable, PubSubData, PubSubMessage, PublishEvent, Subscribe, SubscriptionData, SubscriptionEvent, TopicRootCandidates, TopicRootQuery, TopicRootQueryResponse, UnsubcriptionEvent, Unsubscribe, } from "@peerbit/pubsub-interface";
4
+ import { assertCanonicalTopicRootCandidates, assertTopicRootCandidatesFrame, DataEvent, GetSubscribers, isCanonicalTopicRootCandidate, PeerUnavailable, PubSubData, PubSubMessage, PublishEvent, Subscribe, SubscriptionData, SubscriptionEvent, TOPIC_ROOT_CANDIDATES_MAX, TopicRootCandidates, TopicRootQuery, TopicRootQueryResponse, UnsubcriptionEvent, Unsubscribe, } from "@peerbit/pubsub-interface";
5
5
  import { DirectStream, } from "@peerbit/stream";
6
6
  import { AcknowledgeAnyWhere, AcknowledgeDelivery, AnyWhere, DataMessage, DeliveryError, MessageHeader, NotStartedError, SilentDelivery, deliveryModeHasReceiver, getMsgId, isAcknowledgeAnyWhereDeliveryMode, isAcknowledgeDeliveryMode, isAnyWhereDeliveryMode, isSilentDeliveryMode, } from "@peerbit/stream-interface";
7
7
  import { AbortError, TimeoutError, delay } from "@peerbit/time";
@@ -9,7 +9,7 @@ import { Uint8ArrayList } from "uint8arraylist";
9
9
  import { toUint8Array } from "./bytes.js";
10
10
  import { debouncedAccumulatorSetCounter, } from "./debounced-set.js";
11
11
  import { FanoutChannel } from "./fanout-channel.js";
12
- import { TopicRootControlPlane } from "./topic-root-control-plane.js";
12
+ import { TopicRootControlPlane, } from "./topic-root-control-plane.js";
13
13
  export * from "./fanout-tree.js";
14
14
  // The complete /peerbit/fanout-tree/0.5.0 wire codec and the
15
15
  // parent-upgrade decision module (the TS references the native rust-core
@@ -43,6 +43,7 @@ const withAbort = async (promise, signal) => {
43
43
  if (!signal)
44
44
  return promise;
45
45
  if (signal.aborted) {
46
+ void promise.catch(() => { });
46
47
  throw signal.reason ?? new AbortError("Operation was aborted");
47
48
  }
48
49
  return new Promise((resolve, reject) => {
@@ -68,6 +69,41 @@ const withAbort = async (promise, signal) => {
68
69
  });
69
70
  });
70
71
  };
72
+ const throwIfAborted = (signal) => {
73
+ if (signal?.aborted) {
74
+ throw signal.reason ?? new AbortError("Operation was aborted");
75
+ }
76
+ };
77
+ const linkAbortSignals = (signals) => {
78
+ const unique = [...new Set(signals.filter((signal) => signal !== undefined))];
79
+ if (unique.length === 1) {
80
+ return { signal: unique[0], clear: () => { } };
81
+ }
82
+ const controller = new AbortController();
83
+ const listeners = new Map();
84
+ const clear = () => {
85
+ for (const [signal, listener] of listeners) {
86
+ signal.removeEventListener("abort", listener);
87
+ }
88
+ listeners.clear();
89
+ };
90
+ const abortFrom = (signal) => {
91
+ clear();
92
+ controller.abort(signal.reason ?? new AbortError("Topic-root resolution aborted"));
93
+ };
94
+ const aborted = unique.find((signal) => signal.aborted);
95
+ if (aborted) {
96
+ abortFrom(aborted);
97
+ }
98
+ else {
99
+ for (const signal of unique) {
100
+ const listener = () => abortFrom(signal);
101
+ listeners.set(signal, listener);
102
+ signal.addEventListener("abort", listener, { once: true });
103
+ }
104
+ }
105
+ return { signal: controller.signal, clear };
106
+ };
71
107
  const SUBSCRIBER_CACHE_MAX_ENTRIES_HARD_CAP = 100_000;
72
108
  const SUBSCRIBER_CACHE_DEFAULT_MAX_ENTRIES = 4_096;
73
109
  const DEFAULT_FANOUT_PUBLISH_IDLE_CLOSE_MS = 60_000;
@@ -75,7 +111,9 @@ const DEFAULT_FANOUT_PUBLISH_MAX_EPHEMERAL_CHANNELS = 64;
75
111
  const DEFAULT_PUBSUB_SHARD_COUNT = 256;
76
112
  const PUBSUB_SHARD_COUNT_HARD_CAP = 16_384;
77
113
  const DEFAULT_PUBSUB_SHARD_TOPIC_PREFIX = "/peerbit/pubsub-shard/1/";
78
- const AUTO_TOPIC_ROOT_CANDIDATES_MAX = 64;
114
+ const AUTO_TOPIC_ROOT_CANDIDATE_UPDATE_COOLDOWN_MS = 2_000;
115
+ const sameCandidates = (left, right) => left.length === right.length &&
116
+ left.every((candidate, index) => candidate === right[index]);
79
117
  // Topic-root queries may need to wait for the responder to finish opening an
80
118
  // outbound stream back to the requester after an inbound-only dial.
81
119
  const DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS = 12_000;
@@ -147,10 +185,15 @@ export class TopicControlPlane extends DirectStream {
147
185
  // This keeps small ad-hoc networks working without explicit bootstraps.
148
186
  autoTopicRootCandidates = false;
149
187
  autoTopicRootCandidateSet;
188
+ pendingAutoTopicRootCandidates;
189
+ autoTopicRootCandidateUpdateTimer;
150
190
  reconcileShardOverlaysInFlight;
151
191
  reconcileShardOverlaysDirty = false;
152
192
  topicControlPlaneStopping = false;
153
193
  topicControlPlaneLifecycleRevision = 0;
194
+ topicRootResolutionAbortController = new AbortController();
195
+ topicRootCandidateResolutionGeneration;
196
+ topicRootCandidateResolutionAbortController = new AbortController();
154
197
  hostOwnedShardRootsInFlight;
155
198
  hostOwnedShardRootsDirty = false;
156
199
  autoCandidatesBroadcastTimers = [];
@@ -249,6 +292,7 @@ export class TopicControlPlane extends DirectStream {
249
292
  // Disable auto mode and stop its background gossip/timers.
250
293
  this.autoTopicRootCandidates = false;
251
294
  this.autoTopicRootCandidateSet = undefined;
295
+ this.clearAutoTopicRootCandidateUpdateSchedule();
252
296
  for (const t of this.autoCandidatesBroadcastTimers)
253
297
  clearTimeout(t);
254
298
  this.autoCandidatesBroadcastTimers = [];
@@ -268,6 +312,9 @@ export class TopicControlPlane extends DirectStream {
268
312
  }
269
313
  async start() {
270
314
  this.topicControlPlaneStopping = false;
315
+ if (this.topicRootResolutionAbortController.signal.aborted) {
316
+ this.topicRootResolutionAbortController = new AbortController();
317
+ }
271
318
  await this.fanout.start();
272
319
  this._onFanoutPeerUnreachable =
273
320
  this._onFanoutPeerUnreachable ||
@@ -286,6 +333,8 @@ export class TopicControlPlane extends DirectStream {
286
333
  }
287
334
  async stop() {
288
335
  this.topicControlPlaneStopping = true;
336
+ this.topicRootResolutionAbortController.abort(new AbortError("topic control plane stopped"));
337
+ this.clearAutoTopicRootCandidateUpdateSchedule();
289
338
  this.topicControlPlaneLifecycleRevision += 1;
290
339
  this.reconcileShardOverlaysDirty = false;
291
340
  for (const opening of this.ensureFanoutChannelInFlight.values()) {
@@ -397,6 +446,7 @@ export class TopicControlPlane extends DirectStream {
397
446
  // intact and reconcile shard overlays under the new mapping.
398
447
  this.autoTopicRootCandidates = false;
399
448
  this.autoTopicRootCandidateSet = undefined;
449
+ this.clearAutoTopicRootCandidateUpdateSchedule();
400
450
  this.shardRootCache.clear();
401
451
  // Ensure we host any shard roots we're now responsible for. This is important
402
452
  // in tests where candidates may be configured before protocol streams have
@@ -409,43 +459,25 @@ export class TopicControlPlane extends DirectStream {
409
459
  maybeUpdateAutoTopicRootCandidates(peerHash) {
410
460
  if (!this.autoTopicRootCandidates)
411
461
  return;
412
- if (!peerHash || peerHash === this.publicKeyHash)
413
- return;
414
- if (this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured())
462
+ if (!isCanonicalTopicRootCandidate(peerHash) ||
463
+ peerHash === this.publicKeyHash)
415
464
  return;
416
- const current = this.topicRootControlPlane.getTopicRootCandidates();
417
- const managed = this.autoTopicRootCandidateSet;
418
- if (current.includes(peerHash))
419
- return;
420
- managed?.add(peerHash);
421
- const next = this.normalizeAutoTopicRootCandidates(managed ? [...managed] : [...current, peerHash]);
422
- this.autoTopicRootCandidateSet = new Set(next);
423
- this.topicRootControlPlane.setTopicRootCandidates(next);
424
- this.shardRootCache.clear();
425
- this.scheduleReconcileShardOverlays();
426
- // In auto-candidate mode, shard roots are selected deterministically across
427
- // *all* connected peers (not just those currently subscribed to a shard).
428
- // That means a peer can be selected as root for shards it isn't using yet.
429
- // Ensure we proactively host the shard roots we're responsible for so other
430
- // peers can join without timing out in small ad-hoc networks.
431
- this.scheduleHostOwnedShardRoots();
432
- // Share the updated candidate set so other peers converge on the same
433
- // deterministic mapping even in partially connected topologies.
434
- this.scheduleAutoTopicRootCandidatesBroadcast();
465
+ this.queueAutoTopicRootCandidateUpdate([peerHash]);
435
466
  }
436
467
  normalizeAutoTopicRootCandidates(candidates) {
468
+ const canonicalCandidates = candidates.filter(isCanonicalTopicRootCandidate);
437
469
  if (this.nativeTopicControl) {
438
- return this.nativeTopicControl.normalizeAutoCandidates(candidates, this.publicKeyHash);
470
+ return this.nativeTopicControl.normalizeAutoCandidates(canonicalCandidates, this.publicKeyHash);
439
471
  }
440
472
  const unique = new Set();
441
- for (const c of candidates) {
442
- if (!c)
443
- continue;
473
+ for (const c of canonicalCandidates) {
444
474
  unique.add(c);
445
475
  }
446
- unique.add(this.publicKeyHash);
476
+ if (isCanonicalTopicRootCandidate(this.publicKeyHash)) {
477
+ unique.add(this.publicKeyHash);
478
+ }
447
479
  const sorted = [...unique].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
448
- return sorted.slice(0, AUTO_TOPIC_ROOT_CANDIDATES_MAX);
480
+ return sorted.slice(0, TOPIC_ROOT_CANDIDATES_MAX);
449
481
  }
450
482
  scheduleAutoTopicRootCandidatesBroadcast(targets) {
451
483
  if (!this.autoTopicRootCandidates)
@@ -515,29 +547,78 @@ export class TopicControlPlane extends DirectStream {
515
547
  mergeAutoTopicRootCandidatesFromPeer(candidates) {
516
548
  if (!this.autoTopicRootCandidates)
517
549
  return false;
550
+ return this.queueAutoTopicRootCandidateUpdate(candidates);
551
+ }
552
+ queueAutoTopicRootCandidateUpdate(candidates) {
553
+ if (!this.autoTopicRootCandidates ||
554
+ this.stopping ||
555
+ this.topicControlPlaneStopping)
556
+ return false;
518
557
  if (this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured())
519
558
  return false;
520
559
  const managed = this.autoTopicRootCandidateSet;
521
560
  if (!managed)
522
561
  return false;
523
- const before = this.topicRootControlPlane.getTopicRootCandidates();
524
- for (const c of candidates) {
525
- if (!c)
526
- continue;
527
- managed.add(c);
528
- }
529
- const next = this.normalizeAutoTopicRootCandidates([...managed]);
530
- if (before.length === next.length &&
531
- before.every((c, i) => c === next[i])) {
562
+ const before = this.pendingAutoTopicRootCandidates ?? [...managed];
563
+ const next = this.normalizeAutoTopicRootCandidates([
564
+ ...before,
565
+ ...candidates,
566
+ ]);
567
+ if (sameCandidates(before, next))
532
568
  return false;
569
+ if (this.autoTopicRootCandidateUpdateTimer) {
570
+ this.pendingAutoTopicRootCandidates = next;
571
+ return true;
533
572
  }
573
+ this.scheduleAutoTopicRootCandidateUpdateCooldown();
574
+ this.applyAutoTopicRootCandidates(next);
575
+ return true;
576
+ }
577
+ applyAutoTopicRootCandidates(next) {
534
578
  this.autoTopicRootCandidateSet = new Set(next);
535
579
  this.topicRootControlPlane.setTopicRootCandidates(next);
536
580
  this.shardRootCache.clear();
537
581
  this.scheduleReconcileShardOverlays();
538
582
  this.scheduleHostOwnedShardRoots();
539
583
  this.scheduleAutoTopicRootCandidatesBroadcast();
540
- return true;
584
+ }
585
+ scheduleAutoTopicRootCandidateUpdateCooldown() {
586
+ if (this.autoTopicRootCandidateUpdateTimer)
587
+ return;
588
+ const timer = setTimeout(() => {
589
+ if (this.autoTopicRootCandidateUpdateTimer !== timer)
590
+ return;
591
+ this.flushPendingAutoTopicRootCandidateUpdate();
592
+ }, AUTO_TOPIC_ROOT_CANDIDATE_UPDATE_COOLDOWN_MS);
593
+ timer.unref?.();
594
+ this.autoTopicRootCandidateUpdateTimer = timer;
595
+ }
596
+ flushPendingAutoTopicRootCandidateUpdate() {
597
+ if (this.autoTopicRootCandidateUpdateTimer) {
598
+ clearTimeout(this.autoTopicRootCandidateUpdateTimer);
599
+ this.autoTopicRootCandidateUpdateTimer = undefined;
600
+ }
601
+ const next = this.pendingAutoTopicRootCandidates;
602
+ this.pendingAutoTopicRootCandidates = undefined;
603
+ if (!next)
604
+ return;
605
+ if (!this.autoTopicRootCandidates ||
606
+ this.stopping ||
607
+ this.topicControlPlaneStopping)
608
+ return;
609
+ if (this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured())
610
+ return;
611
+ // The trailing update starts its own fixed window so another inbound update
612
+ // cannot create a second candidate generation immediately afterwards.
613
+ this.scheduleAutoTopicRootCandidateUpdateCooldown();
614
+ this.applyAutoTopicRootCandidates(next);
615
+ }
616
+ clearAutoTopicRootCandidateUpdateSchedule() {
617
+ if (this.autoTopicRootCandidateUpdateTimer) {
618
+ clearTimeout(this.autoTopicRootCandidateUpdateTimer);
619
+ this.autoTopicRootCandidateUpdateTimer = undefined;
620
+ }
621
+ this.pendingAutoTopicRootCandidates = undefined;
541
622
  }
542
623
  scheduleHostOwnedShardRoots() {
543
624
  this.hostOwnedShardRootsDirty = true;
@@ -571,6 +652,7 @@ export class TopicControlPlane extends DirectStream {
571
652
  }
572
653
  scheduleReconcileShardOverlays() {
573
654
  const candidateGeneration = this.getTopicRootCandidateGeneration();
655
+ this.syncTopicRootCandidateResolutionSignal(candidateGeneration);
574
656
  for (const opening of this.ensureFanoutChannelInFlight.values()) {
575
657
  if (opening.candidateGeneration !== candidateGeneration) {
576
658
  opening.abortController.abort(new AbortError("topic root candidates changed"));
@@ -821,6 +903,9 @@ export class TopicControlPlane extends DirectStream {
821
903
  * their fallback behavior).
822
904
  */
823
905
  decodePubSubMessage(bytes) {
906
+ if (bytes[0] === 4) {
907
+ assertTopicRootCandidatesFrame(bytes);
908
+ }
824
909
  const native = this.nativeTopicControl;
825
910
  if (native) {
826
911
  const decoded = native.decodePubSubMessage(bytes);
@@ -841,6 +926,7 @@ export class TopicControlPlane extends DirectStream {
841
926
  case "get-subscribers":
842
927
  return new GetSubscribers({ topics: decoded.topics });
843
928
  case "topic-root-candidates":
929
+ assertCanonicalTopicRootCandidates(decoded.candidates);
844
930
  return new TopicRootCandidates({ candidates: decoded.candidates });
845
931
  case "peer-unavailable":
846
932
  return new PeerUnavailable({
@@ -878,6 +964,53 @@ export class TopicControlPlane extends DirectStream {
878
964
  throw new NotStartedError();
879
965
  }
880
966
  }
967
+ syncTopicRootCandidateResolutionSignal(candidateGeneration = this.getTopicRootCandidateGeneration()) {
968
+ if (this.topicRootCandidateResolutionGeneration === undefined) {
969
+ this.topicRootCandidateResolutionGeneration = candidateGeneration;
970
+ }
971
+ else if (candidateGeneration !== this.topicRootCandidateResolutionGeneration) {
972
+ this.topicRootCandidateResolutionAbortController.abort(new AbortError("topic root candidates changed"));
973
+ this.topicRootCandidateResolutionAbortController = new AbortController();
974
+ this.topicRootCandidateResolutionGeneration = candidateGeneration;
975
+ }
976
+ return this.topicRootCandidateResolutionAbortController.signal;
977
+ }
978
+ async withTopicRootCandidateResolution(operation, terminalSignals = []) {
979
+ for (;;) {
980
+ for (const signal of terminalSignals)
981
+ throwIfAborted(signal);
982
+ const candidateGeneration = this.getTopicRootCandidateGeneration();
983
+ const candidateSignal = this.syncTopicRootCandidateResolutionSignal(candidateGeneration);
984
+ const linked = linkAbortSignals([...terminalSignals, candidateSignal]);
985
+ try {
986
+ const result = await operation({
987
+ candidateGeneration,
988
+ signal: linked.signal,
989
+ });
990
+ for (const signal of terminalSignals)
991
+ throwIfAborted(signal);
992
+ if (candidateSignal.aborted ||
993
+ candidateGeneration !== this.getTopicRootCandidateGeneration()) {
994
+ continue;
995
+ }
996
+ return result;
997
+ }
998
+ catch (error) {
999
+ for (const signal of terminalSignals) {
1000
+ if (signal?.aborted)
1001
+ throw signal.reason ?? error;
1002
+ }
1003
+ if (candidateSignal.aborted ||
1004
+ candidateGeneration !== this.getTopicRootCandidateGeneration()) {
1005
+ continue;
1006
+ }
1007
+ throw error;
1008
+ }
1009
+ finally {
1010
+ linked.clear();
1011
+ }
1012
+ }
1013
+ }
881
1014
  normalizePeerTopicRootState(topic, root) {
882
1015
  const deterministic = this.topicRootControlPlane.resolveDeterministicTopicRoot(topic);
883
1016
  if (this.autoTopicRootCandidates &&
@@ -888,12 +1021,13 @@ export class TopicControlPlane extends DirectStream {
888
1021
  }
889
1022
  return { root, authoritative: true };
890
1023
  }
891
- async resolveTopicRootState(topic) {
892
- const tracked = await this.topicRootControlPlane.resolveTrackedTopicRoot(topic);
1024
+ async resolveTopicRootState(topic, options) {
1025
+ throwIfAborted(options?.signal);
1026
+ const tracked = await this.topicRootControlPlane.resolveTrackedTopicRoot(topic, options);
893
1027
  if (tracked) {
894
1028
  return { root: tracked, authoritative: true };
895
1029
  }
896
- const resolvedThroughPeers = await this.resolveTopicRootThroughPeers(topic);
1030
+ const resolvedThroughPeers = await this.resolveTopicRootThroughPeers(topic, options);
897
1031
  if (resolvedThroughPeers) {
898
1032
  // Unconfigured peer-query replies cannot override the locally
899
1033
  // deterministic root for internal shards in auto mode. Roots, resolvers,
@@ -907,8 +1041,8 @@ export class TopicControlPlane extends DirectStream {
907
1041
  this.autoTopicRootCandidates &&
908
1042
  this.getConnectedTopicRootTrackers().length > 0) {
909
1043
  for (let attempt = 0; attempt < 8; attempt++) {
910
- await delay(150 * (attempt < 4 ? 1 : 2));
911
- const retried = await this.resolveTopicRootThroughPeers(topic);
1044
+ await withAbort(delay(150 * (attempt < 4 ? 1 : 2), options), options?.signal);
1045
+ const retried = await this.resolveTopicRootThroughPeers(topic, options);
912
1046
  if (retried) {
913
1047
  return this.normalizePeerTopicRootState(topic, retried);
914
1048
  }
@@ -919,12 +1053,16 @@ export class TopicControlPlane extends DirectStream {
919
1053
  authoritative: false,
920
1054
  };
921
1055
  }
922
- async resolveTopicRoot(topic) {
923
- return (await this.resolveTopicRootState(topic)).root;
1056
+ async resolveTopicRoot(topic, options) {
1057
+ const lifecycleSignal = this.started && !this.stopping && !this.topicControlPlaneStopping
1058
+ ? this.topicRootResolutionAbortController.signal
1059
+ : undefined;
1060
+ return this.withTopicRootCandidateResolution(async ({ signal }) => (await this.resolveTopicRootState(topic, { signal })).root, [lifecycleSignal, options?.signal]);
924
1061
  }
925
- async resolveShardRootState(shardTopic) {
1062
+ async resolveShardRootState(shardTopic, options) {
926
1063
  const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
927
1064
  for (;;) {
1065
+ throwIfAborted(options?.signal);
928
1066
  this.assertTopicControlPlaneActive(lifecycleRevision);
929
1067
  // If someone configured topic-root candidates externally (e.g.
930
1068
  // TestSession router selection or Peerbit.bootstrap) after this peer
@@ -938,7 +1076,7 @@ export class TopicControlPlane extends DirectStream {
938
1076
  if (cached && (cached.authoritative || !hasConnectedTrackers)) {
939
1077
  return { root: cached.root, candidateGeneration };
940
1078
  }
941
- const resolved = await this.resolveTopicRootState(shardTopic);
1079
+ const resolved = await this.resolveTopicRootState(shardTopic, options);
942
1080
  this.assertTopicControlPlaneActive(lifecycleRevision);
943
1081
  if (candidateGeneration !== this.getTopicRootCandidateGeneration()) {
944
1082
  continue;
@@ -998,36 +1136,57 @@ export class TopicControlPlane extends DirectStream {
998
1136
  pending.resolve(message.root);
999
1137
  return true;
1000
1138
  }
1001
- async queryTopicRootFromPeer(peer, topic, timeoutMs = DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS) {
1139
+ async queryTopicRootFromPeer(peer, topic, timeoutMs = DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS, signal) {
1140
+ throwIfAborted(signal);
1002
1141
  if (!this.started || this.stopping)
1003
1142
  return undefined;
1004
1143
  const expectedPeerHash = peer.publicKey.hashcode();
1005
1144
  const requestId = this.nextTopicRootRequestIdValue();
1006
1145
  const responsePromise = new Promise((resolve) => {
1007
- const timer = setTimeout(() => {
1008
- this.pendingTopicRootQueries.delete(requestId);
1009
- resolve(undefined);
1010
- }, Math.max(1, Math.floor(timeoutMs)));
1146
+ let settled = false;
1147
+ const settle = (root) => {
1148
+ if (settled)
1149
+ return;
1150
+ settled = true;
1151
+ const pending = this.pendingTopicRootQueries.get(requestId);
1152
+ if (pending?.resolve === settle) {
1153
+ this.pendingTopicRootQueries.delete(requestId);
1154
+ }
1155
+ clearTimeout(timer);
1156
+ if (signal && onAbort) {
1157
+ signal.removeEventListener("abort", onAbort);
1158
+ }
1159
+ resolve(root);
1160
+ };
1161
+ const onAbort = signal ? () => settle(undefined) : undefined;
1162
+ const timer = setTimeout(() => settle(undefined), Math.max(1, Math.floor(timeoutMs)));
1011
1163
  timer.unref?.();
1012
1164
  this.pendingTopicRootQueries.set(requestId, {
1013
1165
  expectedPeerHash,
1014
1166
  topic,
1015
- resolve,
1167
+ resolve: settle,
1016
1168
  timer,
1017
1169
  });
1170
+ if (signal && onAbort) {
1171
+ signal.addEventListener("abort", onAbort, { once: true });
1172
+ if (signal.aborted)
1173
+ onAbort();
1174
+ }
1018
1175
  });
1019
1176
  try {
1020
- await this.sendDirectControlMessage(peer, new TopicRootQuery({ requestId, topic }));
1177
+ await withAbort(this.sendDirectControlMessage(peer, new TopicRootQuery({ requestId, topic })), signal);
1021
1178
  }
1022
- catch {
1179
+ catch (error) {
1023
1180
  const pending = this.pendingTopicRootQueries.get(requestId);
1024
1181
  if (pending) {
1025
1182
  this.pendingTopicRootQueries.delete(requestId);
1026
1183
  clearTimeout(pending.timer);
1027
1184
  pending.resolve(undefined);
1028
1185
  }
1186
+ if (signal?.aborted)
1187
+ throw error;
1029
1188
  }
1030
- return responsePromise;
1189
+ return withAbort(responsePromise, signal);
1031
1190
  }
1032
1191
  async confirmDirectShardRoot(shardTopic, root, candidateGeneration, lifecycleRevision, signal) {
1033
1192
  this.assertTopicControlPlaneActive(lifecycleRevision);
@@ -1036,7 +1195,7 @@ export class TopicControlPlane extends DirectStream {
1036
1195
  const rootPeer = this.peers.get(root);
1037
1196
  if (!rootPeer)
1038
1197
  return root;
1039
- const confirmation = await withAbort(this.queryTopicRootFromPeer(rootPeer, shardTopic, DIRECT_SHARD_ROOT_CONFIRM_TIMEOUT_MS), signal);
1198
+ const confirmation = await withAbort(this.queryTopicRootFromPeer(rootPeer, shardTopic, DIRECT_SHARD_ROOT_CONFIRM_TIMEOUT_MS, signal), signal);
1040
1199
  this.assertTopicControlPlaneActive(lifecycleRevision);
1041
1200
  if (candidateGeneration !== this.getTopicRootCandidateGeneration()) {
1042
1201
  return root;
@@ -1057,14 +1216,14 @@ export class TopicControlPlane extends DirectStream {
1057
1216
  }
1058
1217
  async resolveQueryableTopicRoot(topic) {
1059
1218
  const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
1060
- for (;;) {
1219
+ const lifecycleSignal = this.topicRootResolutionAbortController.signal;
1220
+ return this.withTopicRootCandidateResolution(async ({ candidateGeneration, signal }) => {
1221
+ throwIfAborted(signal);
1061
1222
  this.assertTopicControlPlaneActive(lifecycleRevision);
1062
- const rootCandidateGeneration = this.getTopicRootCandidateGeneration();
1063
- const root = await this.topicRootControlPlane.resolveCanonicalTopicRoot(topic);
1223
+ const root = await this.topicRootControlPlane.resolveCanonicalTopicRoot(topic, {
1224
+ signal,
1225
+ });
1064
1226
  this.assertTopicControlPlaneActive(lifecycleRevision);
1065
- if (rootCandidateGeneration !== this.getTopicRootCandidateGeneration()) {
1066
- continue;
1067
- }
1068
1227
  if (root !== this.publicKeyHash) {
1069
1228
  return root;
1070
1229
  }
@@ -1075,34 +1234,37 @@ export class TopicControlPlane extends DirectStream {
1075
1234
  await this.ensureFanoutChannel(topic, {
1076
1235
  pin: true,
1077
1236
  root,
1078
- rootCandidateGeneration,
1237
+ rootCandidateGeneration: candidateGeneration,
1238
+ signal,
1079
1239
  });
1080
1240
  this.assertTopicControlPlaneActive(lifecycleRevision);
1081
- if (rootCandidateGeneration !== this.getTopicRootCandidateGeneration()) {
1082
- continue;
1083
- }
1084
1241
  return root;
1085
1242
  }
1086
1243
  catch (error) {
1244
+ if (signal.aborted ||
1245
+ candidateGeneration !== this.getTopicRootCandidateGeneration()) {
1246
+ throw error;
1247
+ }
1087
1248
  warn(`Failed to host shard root ${topic} before answering root query: ${error instanceof Error ? error.message : String(error)}`);
1088
1249
  return undefined;
1089
1250
  }
1090
- }
1251
+ }, [lifecycleSignal]);
1091
1252
  }
1092
- async resolveTopicRootThroughPeers(topic) {
1253
+ async resolveTopicRootThroughPeers(topic, options) {
1254
+ throwIfAborted(options?.signal);
1093
1255
  const peers = this.getConnectedTopicRootTrackers();
1094
1256
  if (peers.length === 0) {
1095
1257
  return undefined;
1096
1258
  }
1097
1259
  for (let attempt = 0; attempt < 3; attempt++) {
1098
1260
  for (const peer of peers) {
1099
- const resolved = await this.queryTopicRootFromPeer(peer, topic);
1261
+ const resolved = await this.queryTopicRootFromPeer(peer, topic, DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS, options?.signal);
1100
1262
  if (resolved) {
1101
1263
  return resolved;
1102
1264
  }
1103
1265
  }
1104
1266
  if (attempt < 2) {
1105
- await delay(150 * (attempt + 1));
1267
+ await withAbort(delay(150 * (attempt + 1), options), options?.signal);
1106
1268
  }
1107
1269
  }
1108
1270
  return undefined;
@@ -1226,7 +1388,9 @@ export class TopicControlPlane extends DirectStream {
1226
1388
  const existing = this.fanoutChannels.get(t);
1227
1389
  if (existing) {
1228
1390
  if (!root) {
1229
- const resolved = await this.resolveShardRootState(t);
1391
+ const resolved = await this.resolveShardRootState(t, {
1392
+ signal: options?.signal,
1393
+ });
1230
1394
  this.assertTopicControlPlaneActive(lifecycleRevision);
1231
1395
  root = resolved.root;
1232
1396
  resolvedGeneration = resolved.candidateGeneration;
@@ -1263,7 +1427,9 @@ export class TopicControlPlane extends DirectStream {
1263
1427
  }
1264
1428
  }
1265
1429
  if (!root) {
1266
- const resolved = await this.resolveShardRootState(t);
1430
+ const resolved = await this.resolveShardRootState(t, {
1431
+ signal: options?.signal,
1432
+ });
1267
1433
  this.assertTopicControlPlaneActive(lifecycleRevision);
1268
1434
  root = resolved.root;
1269
1435
  resolvedGeneration = resolved.candidateGeneration;
@@ -1481,22 +1647,37 @@ export class TopicControlPlane extends DirectStream {
1481
1647
  }
1482
1648
  }
1483
1649
  }
1484
- async hostShardRootsNow() {
1650
+ async hostShardRootsNow(options) {
1485
1651
  if (!this.started)
1486
1652
  throw new NotStartedError();
1487
- const joins = [];
1488
- for (let i = 0; i < this.shardCount; i++) {
1489
- const shardTopic = `${this.shardTopicPrefix}${i}`;
1490
- const resolved = await this.resolveShardRootState(shardTopic);
1491
- if (resolved.root !== this.publicKeyHash)
1492
- continue;
1493
- joins.push(this.ensureFanoutChannel(shardTopic, {
1494
- pin: true,
1495
- root: resolved.root,
1496
- rootCandidateGeneration: resolved.candidateGeneration,
1497
- }));
1498
- }
1499
- await Promise.all(joins);
1653
+ const lifecycleSignal = this.topicRootResolutionAbortController.signal;
1654
+ return this.withTopicRootCandidateResolution(async ({ candidateGeneration, signal }) => {
1655
+ const joins = [];
1656
+ try {
1657
+ for (let i = 0; i < this.shardCount; i++) {
1658
+ throwIfAborted(signal);
1659
+ const shardTopic = `${this.shardTopicPrefix}${i}`;
1660
+ const resolved = await this.resolveShardRootState(shardTopic, {
1661
+ signal,
1662
+ });
1663
+ if (resolved.root !== this.publicKeyHash)
1664
+ continue;
1665
+ const joining = this.ensureFanoutChannel(shardTopic, {
1666
+ pin: true,
1667
+ root: resolved.root,
1668
+ rootCandidateGeneration: resolved.candidateGeneration,
1669
+ signal,
1670
+ });
1671
+ void joining.catch(() => { });
1672
+ joins.push(joining);
1673
+ }
1674
+ await Promise.all(joins);
1675
+ }
1676
+ catch (error) {
1677
+ await Promise.allSettled(joins);
1678
+ throw error;
1679
+ }
1680
+ }, [lifecycleSignal, options?.signal]);
1500
1681
  }
1501
1682
  async subscribe(topic) {
1502
1683
  this.pendingSubscriptions.add(topic);