@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/src/index.ts CHANGED
@@ -5,8 +5,11 @@ import {
5
5
  import { PublicSignKey, getPublicKeyFromPeerId } from "@peerbit/crypto";
6
6
  import { logger as loggerFn } from "@peerbit/logger";
7
7
  import {
8
+ assertCanonicalTopicRootCandidates,
9
+ assertTopicRootCandidatesFrame,
8
10
  DataEvent,
9
11
  GetSubscribers,
12
+ isCanonicalTopicRootCandidate,
10
13
  PeerUnavailable,
11
14
  type PubSub,
12
15
  PubSubData,
@@ -16,6 +19,7 @@ import {
16
19
  Subscribe,
17
20
  SubscriptionData,
18
21
  SubscriptionEvent,
22
+ TOPIC_ROOT_CANDIDATES_MAX,
19
23
  TopicRootCandidates,
20
24
  TopicRootQuery,
21
25
  TopicRootQueryResponse,
@@ -66,7 +70,10 @@ import type {
66
70
  FanoutTreeDataEvent,
67
71
  FanoutTreeJoinOptions,
68
72
  } from "./fanout-tree.js";
69
- import { TopicRootControlPlane } from "./topic-root-control-plane.js";
73
+ import {
74
+ TopicRootControlPlane,
75
+ type TopicRootResolutionOptions,
76
+ } from "./topic-root-control-plane.js";
70
77
 
71
78
  export * from "./fanout-tree.js";
72
79
  // The complete /peerbit/fanout-tree/0.5.0 wire codec and the
@@ -103,6 +110,7 @@ const logErrorIfStarted = (e?: { message: string }) => {
103
110
  const withAbort = async <T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> => {
104
111
  if (!signal) return promise;
105
112
  if (signal.aborted) {
113
+ void promise.catch(() => {});
106
114
  throw signal.reason ?? new AbortError("Operation was aborted");
107
115
  }
108
116
  return new Promise<T>((resolve, reject) => {
@@ -131,6 +139,53 @@ const withAbort = async <T>(promise: Promise<T>, signal?: AbortSignal): Promise<
131
139
  });
132
140
  };
133
141
 
142
+ const throwIfAborted = (signal?: AbortSignal): void => {
143
+ if (signal?.aborted) {
144
+ throw signal.reason ?? new AbortError("Operation was aborted");
145
+ }
146
+ };
147
+
148
+ type AbortSignalLink = {
149
+ signal: AbortSignal;
150
+ clear: () => void;
151
+ };
152
+
153
+ const linkAbortSignals = (
154
+ signals: Array<AbortSignal | undefined>,
155
+ ): AbortSignalLink => {
156
+ const unique = [...new Set(signals.filter((signal) => signal !== undefined))];
157
+ if (unique.length === 1) {
158
+ return { signal: unique[0]!, clear: () => {} };
159
+ }
160
+
161
+ const controller = new AbortController();
162
+ const listeners = new Map<AbortSignal, () => void>();
163
+ const clear = () => {
164
+ for (const [signal, listener] of listeners) {
165
+ signal.removeEventListener("abort", listener);
166
+ }
167
+ listeners.clear();
168
+ };
169
+ const abortFrom = (signal: AbortSignal) => {
170
+ clear();
171
+ controller.abort(
172
+ signal.reason ?? new AbortError("Topic-root resolution aborted"),
173
+ );
174
+ };
175
+
176
+ const aborted = unique.find((signal) => signal.aborted);
177
+ if (aborted) {
178
+ abortFrom(aborted);
179
+ } else {
180
+ for (const signal of unique) {
181
+ const listener = () => abortFrom(signal);
182
+ listeners.set(signal, listener);
183
+ signal.addEventListener("abort", listener, { once: true });
184
+ }
185
+ }
186
+ return { signal: controller.signal, clear };
187
+ };
188
+
134
189
  const SUBSCRIBER_CACHE_MAX_ENTRIES_HARD_CAP = 100_000;
135
190
  const SUBSCRIBER_CACHE_DEFAULT_MAX_ENTRIES = 4_096;
136
191
  const DEFAULT_FANOUT_PUBLISH_IDLE_CLOSE_MS = 60_000;
@@ -138,7 +193,10 @@ const DEFAULT_FANOUT_PUBLISH_MAX_EPHEMERAL_CHANNELS = 64;
138
193
  const DEFAULT_PUBSUB_SHARD_COUNT = 256;
139
194
  const PUBSUB_SHARD_COUNT_HARD_CAP = 16_384;
140
195
  const DEFAULT_PUBSUB_SHARD_TOPIC_PREFIX = "/peerbit/pubsub-shard/1/";
141
- const AUTO_TOPIC_ROOT_CANDIDATES_MAX = 64;
196
+ const AUTO_TOPIC_ROOT_CANDIDATE_UPDATE_COOLDOWN_MS = 2_000;
197
+ const sameCandidates = (left: string[], right: string[]) =>
198
+ left.length === right.length &&
199
+ left.every((candidate, index) => candidate === right[index]);
142
200
  // Topic-root queries may need to wait for the responder to finish opening an
143
201
  // outbound stream back to the requester after an inbound-only dial.
144
202
  const DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS = 12_000;
@@ -344,10 +402,15 @@ export class TopicControlPlane
344
402
  // This keeps small ad-hoc networks working without explicit bootstraps.
345
403
  private autoTopicRootCandidates = false;
346
404
  private autoTopicRootCandidateSet?: Set<string>;
405
+ private pendingAutoTopicRootCandidates?: string[];
406
+ private autoTopicRootCandidateUpdateTimer?: ReturnType<typeof setTimeout>;
347
407
  private reconcileShardOverlaysInFlight?: Promise<void>;
348
408
  private reconcileShardOverlaysDirty = false;
349
409
  private topicControlPlaneStopping = false;
350
410
  private topicControlPlaneLifecycleRevision = 0;
411
+ private topicRootResolutionAbortController = new AbortController();
412
+ private topicRootCandidateResolutionGeneration?: string;
413
+ private topicRootCandidateResolutionAbortController = new AbortController();
351
414
  private hostOwnedShardRootsInFlight?: Promise<void>;
352
415
  private hostOwnedShardRootsDirty = false;
353
416
  private autoCandidatesBroadcastTimers: Array<ReturnType<typeof setTimeout>> =
@@ -531,6 +594,7 @@ export class TopicControlPlane
531
594
  // Disable auto mode and stop its background gossip/timers.
532
595
  this.autoTopicRootCandidates = false;
533
596
  this.autoTopicRootCandidateSet = undefined;
597
+ this.clearAutoTopicRootCandidateUpdateSchedule();
534
598
  for (const t of this.autoCandidatesBroadcastTimers) clearTimeout(t);
535
599
  this.autoCandidatesBroadcastTimers = [];
536
600
  if (this.autoCandidatesGossipInterval) {
@@ -551,6 +615,9 @@ export class TopicControlPlane
551
615
 
552
616
  public override async start() {
553
617
  this.topicControlPlaneStopping = false;
618
+ if (this.topicRootResolutionAbortController.signal.aborted) {
619
+ this.topicRootResolutionAbortController = new AbortController();
620
+ }
554
621
  await this.fanout.start();
555
622
  this._onFanoutPeerUnreachable =
556
623
  this._onFanoutPeerUnreachable ||
@@ -574,6 +641,10 @@ export class TopicControlPlane
574
641
 
575
642
  public override async stop() {
576
643
  this.topicControlPlaneStopping = true;
644
+ this.topicRootResolutionAbortController.abort(
645
+ new AbortError("topic control plane stopped"),
646
+ );
647
+ this.clearAutoTopicRootCandidateUpdateSchedule();
577
648
  this.topicControlPlaneLifecycleRevision += 1;
578
649
  this.reconcileShardOverlaysDirty = false;
579
650
  for (const opening of this.ensureFanoutChannelInFlight.values()) {
@@ -697,6 +768,7 @@ export class TopicControlPlane
697
768
  // intact and reconcile shard overlays under the new mapping.
698
769
  this.autoTopicRootCandidates = false;
699
770
  this.autoTopicRootCandidateSet = undefined;
771
+ this.clearAutoTopicRootCandidateUpdateSchedule();
700
772
  this.shardRootCache.clear();
701
773
 
702
774
  // Ensure we host any shard roots we're now responsible for. This is important
@@ -710,52 +782,34 @@ export class TopicControlPlane
710
782
 
711
783
  private maybeUpdateAutoTopicRootCandidates(peerHash: string) {
712
784
  if (!this.autoTopicRootCandidates) return;
713
- if (!peerHash || peerHash === this.publicKeyHash) return;
714
-
715
- if (this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured())
785
+ if (
786
+ !isCanonicalTopicRootCandidate(peerHash) ||
787
+ peerHash === this.publicKeyHash
788
+ )
716
789
  return;
717
790
 
718
- const current = this.topicRootControlPlane.getTopicRootCandidates();
719
- const managed = this.autoTopicRootCandidateSet;
720
-
721
- if (current.includes(peerHash)) return;
722
-
723
- managed?.add(peerHash);
724
- const next = this.normalizeAutoTopicRootCandidates(
725
- managed ? [...managed] : [...current, peerHash],
726
- );
727
- this.autoTopicRootCandidateSet = new Set(next);
728
- this.topicRootControlPlane.setTopicRootCandidates(next);
729
- this.shardRootCache.clear();
730
- this.scheduleReconcileShardOverlays();
731
-
732
- // In auto-candidate mode, shard roots are selected deterministically across
733
- // *all* connected peers (not just those currently subscribed to a shard).
734
- // That means a peer can be selected as root for shards it isn't using yet.
735
- // Ensure we proactively host the shard roots we're responsible for so other
736
- // peers can join without timing out in small ad-hoc networks.
737
- this.scheduleHostOwnedShardRoots();
738
-
739
- // Share the updated candidate set so other peers converge on the same
740
- // deterministic mapping even in partially connected topologies.
741
- this.scheduleAutoTopicRootCandidatesBroadcast();
791
+ this.queueAutoTopicRootCandidateUpdate([peerHash]);
742
792
  }
743
793
 
744
794
  private normalizeAutoTopicRootCandidates(candidates: string[]): string[] {
795
+ const canonicalCandidates = candidates.filter(
796
+ isCanonicalTopicRootCandidate,
797
+ );
745
798
  if (this.nativeTopicControl) {
746
799
  return this.nativeTopicControl.normalizeAutoCandidates(
747
- candidates,
800
+ canonicalCandidates,
748
801
  this.publicKeyHash,
749
802
  );
750
803
  }
751
804
  const unique = new Set<string>();
752
- for (const c of candidates) {
753
- if (!c) continue;
805
+ for (const c of canonicalCandidates) {
754
806
  unique.add(c);
755
807
  }
756
- unique.add(this.publicKeyHash);
808
+ if (isCanonicalTopicRootCandidate(this.publicKeyHash)) {
809
+ unique.add(this.publicKeyHash);
810
+ }
757
811
  const sorted = [...unique].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
758
- return sorted.slice(0, AUTO_TOPIC_ROOT_CANDIDATES_MAX);
812
+ return sorted.slice(0, TOPIC_ROOT_CANDIDATES_MAX);
759
813
  }
760
814
 
761
815
  private scheduleAutoTopicRootCandidatesBroadcast(targets?: PeerStreams[]) {
@@ -827,31 +881,88 @@ export class TopicControlPlane
827
881
 
828
882
  private mergeAutoTopicRootCandidatesFromPeer(candidates: string[]): boolean {
829
883
  if (!this.autoTopicRootCandidates) return false;
884
+ return this.queueAutoTopicRootCandidateUpdate(candidates);
885
+ }
886
+
887
+ private queueAutoTopicRootCandidateUpdate(
888
+ candidates: readonly string[],
889
+ ): boolean {
890
+ if (
891
+ !this.autoTopicRootCandidates ||
892
+ this.stopping ||
893
+ this.topicControlPlaneStopping
894
+ )
895
+ return false;
830
896
  if (this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured())
831
897
  return false;
832
898
  const managed = this.autoTopicRootCandidateSet;
833
899
  if (!managed) return false;
834
900
 
835
- const before = this.topicRootControlPlane.getTopicRootCandidates();
836
- for (const c of candidates) {
837
- if (!c) continue;
838
- managed.add(c);
839
- }
840
- const next = this.normalizeAutoTopicRootCandidates([...managed]);
841
- if (
842
- before.length === next.length &&
843
- before.every((c, i) => c === next[i])
844
- ) {
845
- return false;
901
+ const before = this.pendingAutoTopicRootCandidates ?? [...managed];
902
+ const next = this.normalizeAutoTopicRootCandidates([
903
+ ...before,
904
+ ...candidates,
905
+ ]);
906
+ if (sameCandidates(before, next)) return false;
907
+
908
+ if (this.autoTopicRootCandidateUpdateTimer) {
909
+ this.pendingAutoTopicRootCandidates = next;
910
+ return true;
846
911
  }
847
912
 
913
+ this.scheduleAutoTopicRootCandidateUpdateCooldown();
914
+ this.applyAutoTopicRootCandidates(next);
915
+ return true;
916
+ }
917
+
918
+ private applyAutoTopicRootCandidates(next: string[]) {
848
919
  this.autoTopicRootCandidateSet = new Set(next);
849
920
  this.topicRootControlPlane.setTopicRootCandidates(next);
850
921
  this.shardRootCache.clear();
851
922
  this.scheduleReconcileShardOverlays();
852
923
  this.scheduleHostOwnedShardRoots();
853
924
  this.scheduleAutoTopicRootCandidatesBroadcast();
854
- return true;
925
+ }
926
+
927
+ private scheduleAutoTopicRootCandidateUpdateCooldown() {
928
+ if (this.autoTopicRootCandidateUpdateTimer) return;
929
+ const timer = setTimeout(() => {
930
+ if (this.autoTopicRootCandidateUpdateTimer !== timer) return;
931
+ this.flushPendingAutoTopicRootCandidateUpdate();
932
+ }, AUTO_TOPIC_ROOT_CANDIDATE_UPDATE_COOLDOWN_MS);
933
+ timer.unref?.();
934
+ this.autoTopicRootCandidateUpdateTimer = timer;
935
+ }
936
+
937
+ private flushPendingAutoTopicRootCandidateUpdate() {
938
+ if (this.autoTopicRootCandidateUpdateTimer) {
939
+ clearTimeout(this.autoTopicRootCandidateUpdateTimer);
940
+ this.autoTopicRootCandidateUpdateTimer = undefined;
941
+ }
942
+ const next = this.pendingAutoTopicRootCandidates;
943
+ this.pendingAutoTopicRootCandidates = undefined;
944
+ if (!next) return;
945
+ if (
946
+ !this.autoTopicRootCandidates ||
947
+ this.stopping ||
948
+ this.topicControlPlaneStopping
949
+ )
950
+ return;
951
+ if (this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured())
952
+ return;
953
+
954
+ // The trailing update starts its own fixed window so another inbound update
955
+ // cannot create a second candidate generation immediately afterwards.
956
+ this.scheduleAutoTopicRootCandidateUpdateCooldown();
957
+ this.applyAutoTopicRootCandidates(next);
958
+ }
959
+
960
+ private clearAutoTopicRootCandidateUpdateSchedule() {
961
+ if (this.autoTopicRootCandidateUpdateTimer) {
962
+ clearTimeout(this.autoTopicRootCandidateUpdateTimer);
963
+ this.autoTopicRootCandidateUpdateTimer = undefined;
964
+ }
965
+ this.pendingAutoTopicRootCandidates = undefined;
855
966
  }
856
967
 
857
968
  private scheduleHostOwnedShardRoots() {
@@ -881,6 +992,7 @@ export class TopicControlPlane
881
992
 
882
993
  private scheduleReconcileShardOverlays() {
883
994
  const candidateGeneration = this.getTopicRootCandidateGeneration();
995
+ this.syncTopicRootCandidateResolutionSignal(candidateGeneration);
884
996
  for (const opening of this.ensureFanoutChannelInFlight.values()) {
885
997
  if (opening.candidateGeneration !== candidateGeneration) {
886
998
  opening.abortController.abort(
@@ -1162,6 +1274,9 @@ export class TopicControlPlane
1162
1274
  * their fallback behavior).
1163
1275
  */
1164
1276
  private decodePubSubMessage(bytes: Uint8Array): PubSubMessage {
1277
+ if (bytes[0] === 4) {
1278
+ assertTopicRootCandidatesFrame(bytes);
1279
+ }
1165
1280
  const native = this.nativeTopicControl;
1166
1281
  if (native) {
1167
1282
  const decoded = native.decodePubSubMessage(bytes);
@@ -1182,6 +1297,7 @@ export class TopicControlPlane
1182
1297
  case "get-subscribers":
1183
1298
  return new GetSubscribers({ topics: decoded.topics });
1184
1299
  case "topic-root-candidates":
1300
+ assertCanonicalTopicRootCandidates(decoded.candidates);
1185
1301
  return new TopicRootCandidates({ candidates: decoded.candidates });
1186
1302
  case "peer-unavailable":
1187
1303
  return new PeerUnavailable({
@@ -1224,6 +1340,67 @@ export class TopicControlPlane
1224
1340
  }
1225
1341
  }
1226
1342
 
1343
+ private syncTopicRootCandidateResolutionSignal(
1344
+ candidateGeneration = this.getTopicRootCandidateGeneration(),
1345
+ ): AbortSignal {
1346
+ if (this.topicRootCandidateResolutionGeneration === undefined) {
1347
+ this.topicRootCandidateResolutionGeneration = candidateGeneration;
1348
+ } else if (
1349
+ candidateGeneration !== this.topicRootCandidateResolutionGeneration
1350
+ ) {
1351
+ this.topicRootCandidateResolutionAbortController.abort(
1352
+ new AbortError("topic root candidates changed"),
1353
+ );
1354
+ this.topicRootCandidateResolutionAbortController = new AbortController();
1355
+ this.topicRootCandidateResolutionGeneration = candidateGeneration;
1356
+ }
1357
+ return this.topicRootCandidateResolutionAbortController.signal;
1358
+ }
1359
+
1360
+ private async withTopicRootCandidateResolution<T>(
1361
+ operation: (context: {
1362
+ candidateGeneration: string;
1363
+ signal: AbortSignal;
1364
+ }) => Promise<T>,
1365
+ terminalSignals: Array<AbortSignal | undefined> = [],
1366
+ ): Promise<T> {
1367
+ for (;;) {
1368
+ for (const signal of terminalSignals) throwIfAborted(signal);
1369
+ const candidateGeneration = this.getTopicRootCandidateGeneration();
1370
+ const candidateSignal = this.syncTopicRootCandidateResolutionSignal(
1371
+ candidateGeneration,
1372
+ );
1373
+ const linked = linkAbortSignals([...terminalSignals, candidateSignal]);
1374
+ try {
1375
+ const result = await operation({
1376
+ candidateGeneration,
1377
+ signal: linked.signal,
1378
+ });
1379
+ for (const signal of terminalSignals) throwIfAborted(signal);
1380
+ if (
1381
+ candidateSignal.aborted ||
1382
+ candidateGeneration !== this.getTopicRootCandidateGeneration()
1383
+ ) {
1384
+ continue;
1385
+ }
1386
+ return result;
1387
+ } catch (error) {
1388
+ for (const signal of terminalSignals) {
1389
+ if (signal?.aborted) throw signal.reason ?? error;
1390
+ }
1391
+ if (
1392
+ candidateSignal.aborted ||
1393
+ candidateGeneration !== this.getTopicRootCandidateGeneration()
1394
+ ) {
1395
+ continue;
1396
+ }
1397
+ throw error;
1398
+ } finally {
1399
+ linked.clear();
1400
+ }
1401
+ }
1402
+ }
1403
+
1227
1404
  private normalizePeerTopicRootState(
1228
1405
  topic: string,
1229
1406
  root: string,
@@ -1243,13 +1420,21 @@ export class TopicControlPlane
1243
1420
 
1244
1421
  private async resolveTopicRootState(
1245
1422
  topic: string,
1423
+ options?: TopicRootResolutionOptions,
1246
1424
  ): Promise<{ root?: string; authoritative: boolean }> {
1247
- const tracked = await this.topicRootControlPlane.resolveTrackedTopicRoot(topic);
1425
+ throwIfAborted(options?.signal);
1426
+ const tracked = await this.topicRootControlPlane.resolveTrackedTopicRoot(
1427
+ topic,
1428
+ options,
1429
+ );
1248
1430
  if (tracked) {
1249
1431
  return { root: tracked, authoritative: true };
1250
1432
  }
1251
1433
 
1252
- const resolvedThroughPeers = await this.resolveTopicRootThroughPeers(topic);
1434
+ const resolvedThroughPeers = await this.resolveTopicRootThroughPeers(
1435
+ topic,
1436
+ options,
1437
+ );
1253
1438
  if (resolvedThroughPeers) {
1254
1439
  // Unconfigured peer-query replies cannot override the locally
1255
1440
  // deterministic root for internal shards in auto mode. Roots, resolvers,
@@ -1266,8 +1451,11 @@ export class TopicControlPlane
1266
1451
  this.getConnectedTopicRootTrackers().length > 0
1267
1452
  ) {
1268
1453
  for (let attempt = 0; attempt < 8; attempt++) {
1269
- await delay(150 * (attempt < 4 ? 1 : 2));
1270
- const retried = await this.resolveTopicRootThroughPeers(topic);
1454
+ await withAbort(
1455
+ delay(150 * (attempt < 4 ? 1 : 2), options),
1456
+ options?.signal,
1457
+ );
1458
+ const retried = await this.resolveTopicRootThroughPeers(topic, options);
1271
1459
  if (retried) {
1272
1460
  return this.normalizePeerTopicRootState(topic, retried);
1273
1461
  }
@@ -1280,15 +1468,28 @@ export class TopicControlPlane
1280
1468
  };
1281
1469
  }
1282
1470
 
1283
- public async resolveTopicRoot(topic: string): Promise<string | undefined> {
1284
- return (await this.resolveTopicRootState(topic)).root;
1471
+ public async resolveTopicRoot(
1472
+ topic: string,
1473
+ options?: TopicRootResolutionOptions,
1474
+ ): Promise<string | undefined> {
1475
+ const lifecycleSignal =
1476
+ this.started && !this.stopping && !this.topicControlPlaneStopping
1477
+ ? this.topicRootResolutionAbortController.signal
1478
+ : undefined;
1479
+ return this.withTopicRootCandidateResolution(
1480
+ async ({ signal }) =>
1481
+ (await this.resolveTopicRootState(topic, { signal })).root,
1482
+ [lifecycleSignal, options?.signal],
1483
+ );
1285
1484
  }
1286
1485
 
1287
1486
  private async resolveShardRootState(
1288
1487
  shardTopic: string,
1488
+ options?: TopicRootResolutionOptions,
1289
1489
  ): Promise<{ root: string; candidateGeneration: string }> {
1290
1490
  const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
1291
1491
  for (;;) {
1492
+ throwIfAborted(options?.signal);
1292
1493
  this.assertTopicControlPlaneActive(lifecycleRevision);
1293
1494
  // If someone configured topic-root candidates externally (e.g.
1294
1495
  // TestSession router selection or Peerbit.bootstrap) after this peer
@@ -1305,7 +1506,7 @@ export class TopicControlPlane
1305
1506
  return { root: cached.root, candidateGeneration };
1306
1507
  }
1307
1508
 
1308
- const resolved = await this.resolveTopicRootState(shardTopic);
1509
+ const resolved = await this.resolveTopicRootState(shardTopic, options);
1309
1510
  this.assertTopicControlPlaneActive(lifecycleRevision);
1310
1511
  if (candidateGeneration !== this.getTopicRootCandidateGeneration()) {
1311
1512
  continue;
@@ -1391,40 +1592,65 @@ export class TopicControlPlane
1391
1592
  peer: PeerStreams,
1392
1593
  topic: string,
1393
1594
  timeoutMs = DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS,
1595
+ signal?: AbortSignal,
1394
1596
  ): Promise<string | undefined> {
1597
+ throwIfAborted(signal);
1395
1598
  if (!this.started || this.stopping) return undefined;
1396
1599
 
1397
1600
  const expectedPeerHash = peer.publicKey.hashcode();
1398
1601
  const requestId = this.nextTopicRootRequestIdValue();
1399
1602
  const responsePromise = new Promise<string | undefined>((resolve) => {
1400
- const timer = setTimeout(() => {
1401
- this.pendingTopicRootQueries.delete(requestId);
1402
- resolve(undefined);
1403
- }, Math.max(1, Math.floor(timeoutMs)));
1603
+ let settled = false;
1604
+ const settle = (root: string | undefined) => {
1605
+ if (settled) return;
1606
+ settled = true;
1607
+ const pending = this.pendingTopicRootQueries.get(requestId);
1608
+ if (pending?.resolve === settle) {
1609
+ this.pendingTopicRootQueries.delete(requestId);
1610
+ }
1611
+ clearTimeout(timer);
1612
+ if (signal && onAbort) {
1613
+ signal.removeEventListener("abort", onAbort);
1614
+ }
1615
+ resolve(root);
1616
+ };
1617
+ const onAbort = signal ? () => settle(undefined) : undefined;
1618
+ const timer = setTimeout(
1619
+ () => settle(undefined),
1620
+ Math.max(1, Math.floor(timeoutMs)),
1621
+ );
1404
1622
  timer.unref?.();
1405
1623
  this.pendingTopicRootQueries.set(requestId, {
1406
1624
  expectedPeerHash,
1407
1625
  topic,
1408
- resolve,
1626
+ resolve: settle,
1409
1627
  timer,
1410
1628
  });
1629
+ if (signal && onAbort) {
1630
+ signal.addEventListener("abort", onAbort, { once: true });
1631
+ if (signal.aborted) onAbort();
1632
+ }
1411
1633
  });
1412
1634
 
1413
1635
  try {
1414
- await this.sendDirectControlMessage(
1415
- peer,
1416
- new TopicRootQuery({ requestId, topic }),
1636
+ await withAbort(
1637
+ this.sendDirectControlMessage(
1638
+ peer,
1639
+ new TopicRootQuery({ requestId, topic }),
1640
+ ),
1641
+ signal,
1417
1642
  );
1418
- } catch {
1643
+ } catch (error) {
1419
1644
  const pending = this.pendingTopicRootQueries.get(requestId);
1420
1645
  if (pending) {
1421
1646
  this.pendingTopicRootQueries.delete(requestId);
1422
1647
  clearTimeout(pending.timer);
1423
1648
  pending.resolve(undefined);
1424
1649
  }
1650
+ if (signal?.aborted) throw error;
1425
1651
  }
1426
1652
 
1427
- return responsePromise;
1653
+ return withAbort(responsePromise, signal);
1428
1654
  }
1429
1655
 
1430
1656
  private async confirmDirectShardRoot(
@@ -1444,6 +1670,7 @@ export class TopicControlPlane
1444
1670
  rootPeer,
1445
1671
  shardTopic,
1446
1672
  DIRECT_SHARD_ROOT_CONFIRM_TIMEOUT_MS,
1673
+ signal,
1447
1674
  ),
1448
1675
  signal,
1449
1676
  );
@@ -1468,49 +1695,56 @@ export class TopicControlPlane
1468
1695
  topic: string,
1469
1696
  ): Promise<string | undefined> {
1470
1697
  const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
1471
- for (;;) {
1472
- this.assertTopicControlPlaneActive(lifecycleRevision);
1473
- const rootCandidateGeneration = this.getTopicRootCandidateGeneration();
1474
- const root =
1475
- await this.topicRootControlPlane.resolveCanonicalTopicRoot(topic);
1476
- this.assertTopicControlPlaneActive(lifecycleRevision);
1477
- if (rootCandidateGeneration !== this.getTopicRootCandidateGeneration()) {
1478
- continue;
1479
- }
1480
- if (root !== this.publicKeyHash) {
1481
- return root;
1482
- }
1483
- if (!topic.startsWith(this.shardTopicPrefix)) {
1484
- return root;
1485
- }
1486
-
1487
- try {
1488
- await this.ensureFanoutChannel(topic, {
1489
- pin: true,
1490
- root,
1491
- rootCandidateGeneration,
1492
- });
1698
+ const lifecycleSignal = this.topicRootResolutionAbortController.signal;
1699
+ return this.withTopicRootCandidateResolution(
1700
+ async ({ candidateGeneration, signal }) => {
1701
+ throwIfAborted(signal);
1493
1702
  this.assertTopicControlPlaneActive(lifecycleRevision);
1494
- if (
1495
- rootCandidateGeneration !== this.getTopicRootCandidateGeneration()
1496
- ) {
1497
- continue;
1703
+ const root =
1704
+ await this.topicRootControlPlane.resolveCanonicalTopicRoot(topic, {
1705
+ signal,
1706
+ });
1707
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1708
+ if (root !== this.publicKeyHash) {
1709
+ return root;
1498
1710
  }
1499
- return root;
1500
- } catch (error) {
1501
- warn(
1502
- `Failed to host shard root ${topic} before answering root query: ${
1503
- error instanceof Error ? error.message : String(error)
1504
- }`,
1505
- );
1506
- return undefined;
1507
- }
1508
- }
1711
+ if (!topic.startsWith(this.shardTopicPrefix)) {
1712
+ return root;
1713
+ }
1714
+
1715
+ try {
1716
+ await this.ensureFanoutChannel(topic, {
1717
+ pin: true,
1718
+ root,
1719
+ rootCandidateGeneration: candidateGeneration,
1720
+ signal,
1721
+ });
1722
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1723
+ return root;
1724
+ } catch (error) {
1725
+ if (
1726
+ signal.aborted ||
1727
+ candidateGeneration !== this.getTopicRootCandidateGeneration()
1728
+ ) {
1729
+ throw error;
1730
+ }
1731
+ warn(
1732
+ `Failed to host shard root ${topic} before answering root query: ${
1733
+ error instanceof Error ? error.message : String(error)
1734
+ }`,
1735
+ );
1736
+ return undefined;
1737
+ }
1738
+ },
1739
+ [lifecycleSignal],
1740
+ );
1509
1741
  }
1510
1742
 
1511
1743
  private async resolveTopicRootThroughPeers(
1512
1744
  topic: string,
1745
+ options?: TopicRootResolutionOptions,
1513
1746
  ): Promise<string | undefined> {
1747
+ throwIfAborted(options?.signal);
1514
1748
  const peers = this.getConnectedTopicRootTrackers();
1515
1749
  if (peers.length === 0) {
1516
1750
  return undefined;
@@ -1518,14 +1752,22 @@ export class TopicControlPlane
1518
1752
 
1519
1753
  for (let attempt = 0; attempt < 3; attempt++) {
1520
1754
  for (const peer of peers) {
1521
- const resolved = await this.queryTopicRootFromPeer(peer, topic);
1755
+ const resolved = await this.queryTopicRootFromPeer(
1756
+ peer,
1757
+ topic,
1758
+ DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS,
1759
+ options?.signal,
1760
+ );
1522
1761
  if (resolved) {
1523
1762
  return resolved;
1524
1763
  }
1525
1764
  }
1526
1765
 
1527
1766
  if (attempt < 2) {
1528
- await delay(150 * (attempt + 1));
1767
+ await withAbort(
1768
+ delay(150 * (attempt + 1), options),
1769
+ options?.signal,
1770
+ );
1529
1771
  }
1530
1772
  }
1531
1773
  return undefined;
@@ -1669,7 +1911,9 @@ export class TopicControlPlane
1669
1911
  const existing = this.fanoutChannels.get(t);
1670
1912
  if (existing) {
1671
1913
  if (!root) {
1672
- const resolved = await this.resolveShardRootState(t);
1914
+ const resolved = await this.resolveShardRootState(t, {
1915
+ signal: options?.signal,
1916
+ });
1673
1917
  this.assertTopicControlPlaneActive(lifecycleRevision);
1674
1918
  root = resolved.root;
1675
1919
  resolvedGeneration = resolved.candidateGeneration;
@@ -1713,7 +1957,9 @@ export class TopicControlPlane
1713
1957
  }
1714
1958
 
1715
1959
  if (!root) {
1716
- const resolved = await this.resolveShardRootState(t);
1960
+ const resolved = await this.resolveShardRootState(t, {
1961
+ signal: options?.signal,
1962
+ });
1717
1963
  this.assertTopicControlPlaneActive(lifecycleRevision);
1718
1964
  root = resolved.root;
1719
1965
  resolvedGeneration = resolved.candidateGeneration;
@@ -1938,22 +2184,37 @@ export class TopicControlPlane
1938
2184
  }
1939
2185
  }
1940
2186
 
1941
- public async hostShardRootsNow() {
2187
+ public async hostShardRootsNow(options?: TopicRootResolutionOptions) {
1942
2188
  if (!this.started) throw new NotStartedError();
1943
- const joins: Promise<void>[] = [];
1944
- for (let i = 0; i < this.shardCount; i++) {
1945
- const shardTopic = `${this.shardTopicPrefix}${i}`;
1946
- const resolved = await this.resolveShardRootState(shardTopic);
1947
- if (resolved.root !== this.publicKeyHash) continue;
1948
- joins.push(
1949
- this.ensureFanoutChannel(shardTopic, {
1950
- pin: true,
1951
- root: resolved.root,
1952
- rootCandidateGeneration: resolved.candidateGeneration,
1953
- }),
1954
- );
1955
- }
1956
- await Promise.all(joins);
2189
+ const lifecycleSignal = this.topicRootResolutionAbortController.signal;
2190
+ return this.withTopicRootCandidateResolution(
2191
+ async ({ candidateGeneration, signal }) => {
2192
+ const joins: Promise<void>[] = [];
2193
+ try {
2194
+ for (let i = 0; i < this.shardCount; i++) {
2195
+ throwIfAborted(signal);
2196
+ const shardTopic = `${this.shardTopicPrefix}${i}`;
2197
+ const resolved = await this.resolveShardRootState(shardTopic, {
2198
+ signal,
2199
+ });
2200
+ if (resolved.root !== this.publicKeyHash) continue;
2201
+ const joining = this.ensureFanoutChannel(shardTopic, {
2202
+ pin: true,
2203
+ root: resolved.root,
2204
+ rootCandidateGeneration: resolved.candidateGeneration,
2205
+ signal,
2206
+ });
2207
+ void joining.catch(() => {});
2208
+ joins.push(joining);
2209
+ }
2210
+ await Promise.all(joins);
2211
+ } catch (error) {
2212
+ await Promise.allSettled(joins);
2213
+ throw error;
2214
+ }
2215
+ },
2216
+ [lifecycleSignal, options?.signal],
2217
+ );
1957
2218
  }
1958
2219
 
1959
2220
  async subscribe(topic: string) {