@peerbit/pubsub 5.3.5 → 5.3.7

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
@@ -47,6 +47,10 @@ import {
47
47
  type WithExtraSigners,
48
48
  deliveryModeHasReceiver,
49
49
  getMsgId,
50
+ isAcknowledgeAnyWhereDeliveryMode,
51
+ isAcknowledgeDeliveryMode,
52
+ isAnyWhereDeliveryMode,
53
+ isSilentDeliveryMode,
50
54
  } from "@peerbit/stream-interface";
51
55
  import { AbortError, TimeoutError, delay } from "@peerbit/time";
52
56
  import { Uint8ArrayList } from "uint8arraylist";
@@ -230,6 +234,14 @@ export type TopicControlPlaneOptions = DirectStreamOptions & {
230
234
  subscriberCacheMaxEntries?: number;
231
235
  };
232
236
 
237
+ type EnsureFanoutChannelOptions = {
238
+ ephemeral?: boolean;
239
+ pin?: boolean;
240
+ root?: string;
241
+ rootCandidateGeneration?: string;
242
+ signal?: AbortSignal;
243
+ };
244
+
233
245
  export type TopicControlPlaneComponents = DirectStreamComponents;
234
246
 
235
247
  export type PeerId = Libp2pPeerId | PublicSignKey;
@@ -333,6 +345,9 @@ export class TopicControlPlane
333
345
  private autoTopicRootCandidates = false;
334
346
  private autoTopicRootCandidateSet?: Set<string>;
335
347
  private reconcileShardOverlaysInFlight?: Promise<void>;
348
+ private reconcileShardOverlaysDirty = false;
349
+ private topicControlPlaneStopping = false;
350
+ private topicControlPlaneLifecycleRevision = 0;
336
351
  private hostOwnedShardRootsInFlight?: Promise<void>;
337
352
  private hostOwnedShardRootsDirty = false;
338
353
  private autoCandidatesBroadcastTimers: Array<ReturnType<typeof setTimeout>> =
@@ -356,9 +371,18 @@ export class TopicControlPlane
356
371
  idleCloseTimeout?: ReturnType<typeof setTimeout>;
357
372
  }
358
373
  >();
374
+ private ensureFanoutChannelInFlight = new Map<
375
+ string,
376
+ {
377
+ candidateGeneration: string;
378
+ lifecycleRevision: number;
379
+ opening: Promise<void>;
380
+ }
381
+ >();
359
382
  private pendingTopicRootQueries = new Map<
360
383
  number,
361
384
  {
385
+ expectedPeerHash: string;
362
386
  topic: string;
363
387
  resolve: (root: string | undefined) => void;
364
388
  timer: ReturnType<typeof setTimeout>;
@@ -524,6 +548,7 @@ export class TopicControlPlane
524
548
  }
525
549
 
526
550
  public override async start() {
551
+ this.topicControlPlaneStopping = false;
527
552
  await this.fanout.start();
528
553
  this._onFanoutPeerUnreachable =
529
554
  this._onFanoutPeerUnreachable ||
@@ -540,9 +565,16 @@ export class TopicControlPlane
540
565
  if (this.hostOwnedShardRootsDirty) {
541
566
  this.scheduleHostOwnedShardRoots();
542
567
  }
568
+ if (this.reconcileShardOverlaysDirty) {
569
+ this.scheduleReconcileShardOverlays();
570
+ }
543
571
  }
544
572
 
545
573
  public override async stop() {
574
+ this.topicControlPlaneStopping = true;
575
+ this.topicControlPlaneLifecycleRevision += 1;
576
+ this.reconcileShardOverlaysDirty = false;
577
+ this.ensureFanoutChannelInFlight.clear();
546
578
  if (this._onFanoutPeerUnreachable) {
547
579
  this.fanout.removeEventListener(
548
580
  "fanout:peer-unreachable",
@@ -603,7 +635,8 @@ export class TopicControlPlane
603
635
 
604
636
  this.debounceSubscribeAggregator.close();
605
637
  this.debounceUnsubscribeAggregator.close();
606
- return super.stop();
638
+ await super.stop();
639
+ this.reconcileShardOverlaysDirty = false;
607
640
  }
608
641
 
609
642
  public override async onPeerConnected(
@@ -840,21 +873,45 @@ export class TopicControlPlane
840
873
  }
841
874
 
842
875
  private scheduleReconcileShardOverlays() {
876
+ this.reconcileShardOverlaysDirty = true;
877
+ if (!this.started || this.stopping || this.topicControlPlaneStopping)
878
+ return;
843
879
  if (this.reconcileShardOverlaysInFlight) return;
844
- this.reconcileShardOverlaysInFlight = this.reconcileShardOverlays()
845
- .catch(() => {
846
- // best-effort retry: fanout streams/roots might not be ready yet.
847
- if (!this.started || this.stopping) return;
848
- const t = setTimeout(() => this.scheduleReconcileShardOverlays(), 250);
849
- t.unref?.();
850
- })
851
- .finally(() => {
852
- this.reconcileShardOverlaysInFlight = undefined;
853
- });
880
+ this.reconcileShardOverlaysInFlight = (async () => {
881
+ for (;;) {
882
+ if (!this.started || this.stopping || this.topicControlPlaneStopping)
883
+ return;
884
+ if (!this.reconcileShardOverlaysDirty) return;
885
+ this.reconcileShardOverlaysDirty = false;
886
+ try {
887
+ await this.reconcileShardOverlays();
888
+ } catch {
889
+ // Fanout streams or roots might not be ready yet. Preserve both
890
+ // failures and root changes that arrived during the active pass.
891
+ if (!this.started || this.stopping || this.topicControlPlaneStopping)
892
+ return;
893
+ this.reconcileShardOverlaysDirty = true;
894
+ await new Promise<void>((resolve) => {
895
+ const timer = setTimeout(resolve, 250);
896
+ timer.unref?.();
897
+ });
898
+ }
899
+ }
900
+ })().finally(() => {
901
+ this.reconcileShardOverlaysInFlight = undefined;
902
+ if (
903
+ this.reconcileShardOverlaysDirty &&
904
+ this.started &&
905
+ !this.stopping &&
906
+ !this.topicControlPlaneStopping
907
+ ) {
908
+ this.scheduleReconcileShardOverlays();
909
+ }
910
+ });
854
911
  }
855
912
 
856
913
  private async reconcileShardOverlays() {
857
- if (!this.started) return;
914
+ if (!this.started || this.topicControlPlaneStopping) return;
858
915
 
859
916
  const byShard = new Map<string, string[]>();
860
917
  for (const topic of this.subscriptions.keys()) {
@@ -1134,6 +1191,41 @@ export class TopicControlPlane
1134
1191
  return PubSubMessage.from(bytes);
1135
1192
  }
1136
1193
 
1194
+ private getTopicRootCandidateGeneration(): string {
1195
+ return JSON.stringify([
1196
+ this.autoTopicRootCandidates,
1197
+ this.topicRootControlPlane.getTopicRootCandidates(),
1198
+ ]);
1199
+ }
1200
+
1201
+ private assertTopicControlPlaneActive(lifecycleRevision: number): void {
1202
+ if (
1203
+ lifecycleRevision !== this.topicControlPlaneLifecycleRevision ||
1204
+ !this.started ||
1205
+ this.stopping ||
1206
+ this.topicControlPlaneStopping
1207
+ ) {
1208
+ throw new NotStartedError();
1209
+ }
1210
+ }
1211
+
1212
+ private normalizePeerTopicRootState(
1213
+ topic: string,
1214
+ root: string,
1215
+ ): { root: string; authoritative: boolean } {
1216
+ const deterministic =
1217
+ this.topicRootControlPlane.resolveDeterministicTopicRoot(topic);
1218
+ if (
1219
+ this.autoTopicRootCandidates &&
1220
+ topic.startsWith(this.shardTopicPrefix) &&
1221
+ deterministic &&
1222
+ root !== deterministic
1223
+ ) {
1224
+ return { root: deterministic, authoritative: false };
1225
+ }
1226
+ return { root, authoritative: true };
1227
+ }
1228
+
1137
1229
  private async resolveTopicRootState(
1138
1230
  topic: string,
1139
1231
  ): Promise<{ root?: string; authoritative: boolean }> {
@@ -1144,7 +1236,12 @@ export class TopicControlPlane
1144
1236
 
1145
1237
  const resolvedThroughPeers = await this.resolveTopicRootThroughPeers(topic);
1146
1238
  if (resolvedThroughPeers) {
1147
- return { root: resolvedThroughPeers, authoritative: true };
1239
+ // Unconfigured peer-query replies cannot override the locally
1240
+ // deterministic root for internal shards in auto mode. Roots, resolvers,
1241
+ // and trackers explicitly configured on this control plane are resolved
1242
+ // above and remain authoritative. A leaf relying on a queried gateway for
1243
+ // shard roots must first disable auto mode with setTopicRootCandidates([]).
1244
+ return this.normalizePeerTopicRootState(topic, resolvedThroughPeers);
1148
1245
  }
1149
1246
 
1150
1247
  const deterministic = this.topicRootControlPlane.resolveDeterministicTopicRoot(topic);
@@ -1157,46 +1254,65 @@ export class TopicControlPlane
1157
1254
  await delay(150 * (attempt < 4 ? 1 : 2));
1158
1255
  const retried = await this.resolveTopicRootThroughPeers(topic);
1159
1256
  if (retried) {
1160
- return { root: retried, authoritative: true };
1257
+ return this.normalizePeerTopicRootState(topic, retried);
1161
1258
  }
1162
1259
  }
1163
1260
  }
1164
1261
 
1165
- return { root: deterministic, authoritative: false };
1262
+ return {
1263
+ root: this.topicRootControlPlane.resolveDeterministicTopicRoot(topic),
1264
+ authoritative: false,
1265
+ };
1166
1266
  }
1167
1267
 
1168
1268
  public async resolveTopicRoot(topic: string): Promise<string | undefined> {
1169
1269
  return (await this.resolveTopicRootState(topic)).root;
1170
1270
  }
1171
1271
 
1172
- private async resolveShardRoot(shardTopic: string): Promise<string> {
1173
- // If someone configured topic-root candidates externally (e.g. TestSession router
1174
- // selection or Peerbit.bootstrap) after this peer entered auto mode, disable auto
1175
- // mode before we cache any roots based on a stale candidate set.
1176
- if (this.autoTopicRootCandidates) {
1177
- this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured();
1178
- }
1272
+ private async resolveShardRootState(
1273
+ shardTopic: string,
1274
+ ): Promise<{ root: string; candidateGeneration: string }> {
1275
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
1276
+ for (;;) {
1277
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1278
+ // If someone configured topic-root candidates externally (e.g.
1279
+ // TestSession router selection or Peerbit.bootstrap) after this peer
1280
+ // entered auto mode, disable auto mode before caching a root.
1281
+ if (this.autoTopicRootCandidates) {
1282
+ this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured();
1283
+ }
1179
1284
 
1180
- const hasConnectedTrackers = this.getConnectedTopicRootTrackers().length > 0;
1181
- const cached = this.shardRootCache.get(shardTopic);
1182
- if (cached && (cached.authoritative || !hasConnectedTrackers)) {
1183
- return cached.root;
1184
- }
1185
- const resolved = await this.resolveTopicRootState(shardTopic);
1186
- if (!resolved.root) {
1187
- throw new Error(
1188
- `No root resolved for shard topic ${shardTopic}. Configure TopicRootControlPlane candidates/resolver/trackers, or dial/bootstrap a peer that can resolve shard roots.`,
1189
- );
1190
- }
1191
- if (resolved.authoritative || !hasConnectedTrackers) {
1192
- this.shardRootCache.set(shardTopic, resolved as {
1193
- root: string;
1194
- authoritative: boolean;
1195
- });
1196
- } else {
1197
- this.shardRootCache.delete(shardTopic);
1285
+ const candidateGeneration = this.getTopicRootCandidateGeneration();
1286
+ const hasConnectedTrackers =
1287
+ this.getConnectedTopicRootTrackers().length > 0;
1288
+ const cached = this.shardRootCache.get(shardTopic);
1289
+ if (cached && (cached.authoritative || !hasConnectedTrackers)) {
1290
+ return { root: cached.root, candidateGeneration };
1291
+ }
1292
+
1293
+ const resolved = await this.resolveTopicRootState(shardTopic);
1294
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1295
+ if (candidateGeneration !== this.getTopicRootCandidateGeneration()) {
1296
+ continue;
1297
+ }
1298
+ if (!resolved.root) {
1299
+ throw new Error(
1300
+ `No root resolved for shard topic ${shardTopic}. Configure TopicRootControlPlane candidates/resolver/trackers, or dial/bootstrap a peer that can resolve shard roots.`,
1301
+ );
1302
+ }
1303
+ if (resolved.authoritative || !hasConnectedTrackers) {
1304
+ this.shardRootCache.set(
1305
+ shardTopic,
1306
+ resolved as {
1307
+ root: string;
1308
+ authoritative: boolean;
1309
+ },
1310
+ );
1311
+ } else {
1312
+ this.shardRootCache.delete(shardTopic);
1313
+ }
1314
+ return { root: resolved.root, candidateGeneration };
1198
1315
  }
1199
- return resolved.root;
1200
1316
  }
1201
1317
 
1202
1318
  private getConnectedTopicRootTrackers(): PeerStreams[] {
@@ -1240,9 +1356,14 @@ export class TopicControlPlane
1240
1356
 
1241
1357
  private resolvePendingTopicRootQuery(
1242
1358
  message: TopicRootQueryResponse,
1359
+ responderPeerHash: string,
1243
1360
  ): boolean {
1244
1361
  const pending = this.pendingTopicRootQueries.get(message.requestId);
1245
- if (!pending || pending.topic !== message.topic) {
1362
+ if (
1363
+ !pending ||
1364
+ pending.topic !== message.topic ||
1365
+ pending.expectedPeerHash !== responderPeerHash
1366
+ ) {
1246
1367
  return false;
1247
1368
  }
1248
1369
  this.pendingTopicRootQueries.delete(message.requestId);
@@ -1258,6 +1379,7 @@ export class TopicControlPlane
1258
1379
  ): Promise<string | undefined> {
1259
1380
  if (!this.started || this.stopping) return undefined;
1260
1381
 
1382
+ const expectedPeerHash = peer.publicKey.hashcode();
1261
1383
  const requestId = this.nextTopicRootRequestIdValue();
1262
1384
  const responsePromise = new Promise<string | undefined>((resolve) => {
1263
1385
  const timer = setTimeout(() => {
@@ -1265,7 +1387,12 @@ export class TopicControlPlane
1265
1387
  resolve(undefined);
1266
1388
  }, Math.max(1, Math.floor(timeoutMs)));
1267
1389
  timer.unref?.();
1268
- this.pendingTopicRootQueries.set(requestId, { topic, resolve, timer });
1390
+ this.pendingTopicRootQueries.set(requestId, {
1391
+ expectedPeerHash,
1392
+ topic,
1393
+ resolve,
1394
+ timer,
1395
+ });
1269
1396
  });
1270
1397
 
1271
1398
  try {
@@ -1288,8 +1415,11 @@ export class TopicControlPlane
1288
1415
  private async confirmDirectShardRoot(
1289
1416
  shardTopic: string,
1290
1417
  root: string,
1418
+ candidateGeneration: string,
1419
+ lifecycleRevision: number,
1291
1420
  signal?: AbortSignal,
1292
1421
  ): Promise<string> {
1422
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1293
1423
  if (root === this.publicKeyHash) return root;
1294
1424
  const rootPeer = this.peers.get(root);
1295
1425
  if (!rootPeer) return root;
@@ -1302,37 +1432,64 @@ export class TopicControlPlane
1302
1432
  ),
1303
1433
  signal,
1304
1434
  );
1435
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1436
+ if (candidateGeneration !== this.getTopicRootCandidateGeneration()) {
1437
+ return root;
1438
+ }
1305
1439
  if (!confirmation) return root;
1306
- if (confirmation !== root) {
1440
+ const resolved = this.normalizePeerTopicRootState(shardTopic, confirmation);
1441
+ if (!resolved.authoritative) {
1442
+ this.shardRootCache.delete(shardTopic);
1443
+ } else if (resolved.root !== root) {
1307
1444
  this.shardRootCache.set(shardTopic, {
1308
- root: confirmation,
1445
+ root: resolved.root,
1309
1446
  authoritative: true,
1310
1447
  });
1311
1448
  }
1312
- return confirmation;
1449
+ return resolved.root;
1313
1450
  }
1314
1451
 
1315
1452
  private async resolveQueryableTopicRoot(
1316
1453
  topic: string,
1317
1454
  ): Promise<string | undefined> {
1318
- const root = await this.topicRootControlPlane.resolveCanonicalTopicRoot(topic);
1319
- if (root !== this.publicKeyHash) {
1320
- return root;
1321
- }
1322
- if (!topic.startsWith(this.shardTopicPrefix)) {
1323
- return root;
1324
- }
1455
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
1456
+ for (;;) {
1457
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1458
+ const rootCandidateGeneration = this.getTopicRootCandidateGeneration();
1459
+ const root =
1460
+ await this.topicRootControlPlane.resolveCanonicalTopicRoot(topic);
1461
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1462
+ if (rootCandidateGeneration !== this.getTopicRootCandidateGeneration()) {
1463
+ continue;
1464
+ }
1465
+ if (root !== this.publicKeyHash) {
1466
+ return root;
1467
+ }
1468
+ if (!topic.startsWith(this.shardTopicPrefix)) {
1469
+ return root;
1470
+ }
1325
1471
 
1326
- try {
1327
- await this.ensureFanoutChannel(topic, { pin: true, root });
1328
- return root;
1329
- } catch (error) {
1330
- warn(
1331
- `Failed to host shard root ${topic} before answering root query: ${
1332
- error instanceof Error ? error.message : String(error)
1333
- }`,
1334
- );
1335
- return undefined;
1472
+ try {
1473
+ await this.ensureFanoutChannel(topic, {
1474
+ pin: true,
1475
+ root,
1476
+ rootCandidateGeneration,
1477
+ });
1478
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1479
+ if (
1480
+ rootCandidateGeneration !== this.getTopicRootCandidateGeneration()
1481
+ ) {
1482
+ continue;
1483
+ }
1484
+ return root;
1485
+ } catch (error) {
1486
+ warn(
1487
+ `Failed to host shard root ${topic} before answering root query: ${
1488
+ error instanceof Error ? error.message : String(error)
1489
+ }`,
1490
+ );
1491
+ return undefined;
1492
+ }
1336
1493
  }
1337
1494
  }
1338
1495
 
@@ -1361,14 +1518,69 @@ export class TopicControlPlane
1361
1518
 
1362
1519
  private async ensureFanoutChannel(
1363
1520
  shardTopic: string,
1364
- options?: {
1365
- ephemeral?: boolean;
1366
- pin?: boolean;
1367
- root?: string;
1368
- signal?: AbortSignal;
1369
- },
1521
+ options?: EnsureFanoutChannelOptions,
1370
1522
  ): Promise<void> {
1371
- if (!this.started) throw new NotStartedError();
1523
+ const topic = shardTopic.toString();
1524
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
1525
+ for (;;) {
1526
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1527
+ const candidateGeneration = this.getTopicRootCandidateGeneration();
1528
+ const active = this.ensureFanoutChannelInFlight.get(topic);
1529
+ if (
1530
+ active?.candidateGeneration === candidateGeneration &&
1531
+ active.lifecycleRevision === lifecycleRevision
1532
+ ) {
1533
+ try {
1534
+ await withAbort(active.opening, options?.signal);
1535
+ } catch (error) {
1536
+ if (options?.signal?.aborted) throw error;
1537
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1538
+ }
1539
+ continue;
1540
+ }
1541
+
1542
+ const opening = this.ensureFanoutChannelOnce(
1543
+ topic,
1544
+ options,
1545
+ lifecycleRevision,
1546
+ candidateGeneration,
1547
+ );
1548
+ const inFlight = {
1549
+ candidateGeneration,
1550
+ lifecycleRevision,
1551
+ opening,
1552
+ };
1553
+ this.ensureFanoutChannelInFlight.set(topic, inFlight);
1554
+ try {
1555
+ await opening;
1556
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1557
+ if (candidateGeneration !== this.getTopicRootCandidateGeneration()) {
1558
+ continue;
1559
+ }
1560
+ return;
1561
+ } catch (error) {
1562
+ if (options?.signal?.aborted) throw error;
1563
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1564
+ if (candidateGeneration !== this.getTopicRootCandidateGeneration()) {
1565
+ continue;
1566
+ }
1567
+ throw error;
1568
+ } finally {
1569
+ if (this.ensureFanoutChannelInFlight.get(topic) === inFlight) {
1570
+ this.ensureFanoutChannelInFlight.delete(topic);
1571
+ }
1572
+ }
1573
+ }
1574
+ }
1575
+
1576
+ private async ensureFanoutChannelOnce(
1577
+ shardTopic: string,
1578
+ options: EnsureFanoutChannelOptions | undefined,
1579
+ lifecycleRevision: number,
1580
+ candidateGeneration: string,
1581
+ ): Promise<void> {
1582
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1583
+ if (candidateGeneration !== this.getTopicRootCandidateGeneration()) return;
1372
1584
  const t = shardTopic.toString();
1373
1585
  const pin = options?.pin === true;
1374
1586
  const wantEphemeral = options?.ephemeral === true;
@@ -1376,10 +1588,31 @@ export class TopicControlPlane
1376
1588
  // Allow callers that already resolved the shard root (e.g. hostShardRootsNow)
1377
1589
  // to pass it through to avoid a race where the candidate set changes between
1378
1590
  // two resolve calls, causing an unnecessary (and potentially slow) join.
1379
- let root: string | undefined = options?.root;
1591
+ const suppliedRoot = options?.root;
1592
+ const suppliedRootGeneration = options?.rootCandidateGeneration;
1593
+ if (
1594
+ suppliedRoot !== undefined &&
1595
+ suppliedRootGeneration !== candidateGeneration
1596
+ ) {
1597
+ return;
1598
+ }
1599
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1600
+ let resolvedGeneration = candidateGeneration;
1601
+ let root = suppliedRoot;
1380
1602
  const existing = this.fanoutChannels.get(t);
1381
1603
  if (existing) {
1382
- root = root ?? (await this.resolveShardRoot(t));
1604
+ if (!root) {
1605
+ const resolved = await this.resolveShardRootState(t);
1606
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1607
+ root = resolved.root;
1608
+ resolvedGeneration = resolved.candidateGeneration;
1609
+ }
1610
+ if (
1611
+ resolvedGeneration !== candidateGeneration ||
1612
+ candidateGeneration !== this.getTopicRootCandidateGeneration()
1613
+ ) {
1614
+ return;
1615
+ }
1383
1616
  if (root === existing.root) {
1384
1617
  existing.lastUsedAt = Date.now();
1385
1618
  if (existing.ephemeral && !wantEphemeral) {
@@ -1390,15 +1623,48 @@ export class TopicControlPlane
1390
1623
  }
1391
1624
  if (pin) this.pinnedShards.add(t);
1392
1625
  await withAbort(existing.join, options?.signal);
1626
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1627
+ if (
1628
+ resolvedGeneration !== candidateGeneration ||
1629
+ candidateGeneration !== this.getTopicRootCandidateGeneration()
1630
+ ) {
1631
+ return;
1632
+ }
1393
1633
  return;
1394
1634
  }
1395
1635
 
1396
- // Root mapping changed (candidate set updated): migrate to the new overlay.
1397
- await withAbort(this.closeFanoutChannel(t, { force: true }), options?.signal);
1636
+ // Root mapping changed (candidate set updated): migrate to the new
1637
+ // overlay.
1638
+ await withAbort(
1639
+ this.closeFanoutChannel(t, { force: true }),
1640
+ options?.signal,
1641
+ );
1642
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1643
+ if (candidateGeneration !== this.getTopicRootCandidateGeneration()) {
1644
+ return;
1645
+ }
1398
1646
  }
1399
1647
 
1400
- root = root ?? (await this.resolveShardRoot(t));
1401
- root = await this.confirmDirectShardRoot(t, root, options?.signal);
1648
+ if (!root) {
1649
+ const resolved = await this.resolveShardRootState(t);
1650
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1651
+ root = resolved.root;
1652
+ resolvedGeneration = resolved.candidateGeneration;
1653
+ }
1654
+ root = await this.confirmDirectShardRoot(
1655
+ t,
1656
+ root,
1657
+ resolvedGeneration,
1658
+ lifecycleRevision,
1659
+ options?.signal,
1660
+ );
1661
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1662
+ if (
1663
+ resolvedGeneration !== candidateGeneration ||
1664
+ candidateGeneration !== this.getTopicRootCandidateGeneration()
1665
+ ) {
1666
+ return;
1667
+ }
1402
1668
  const channel = new FanoutChannel(this.fanout, { topic: t, root });
1403
1669
 
1404
1670
  const onPayload = (payload: Uint8Array) => {
@@ -1541,7 +1807,7 @@ export class TopicControlPlane
1541
1807
 
1542
1808
  const lastUsedAt = Date.now();
1543
1809
  if (pin) this.pinnedShards.add(t);
1544
- this.fanoutChannels.set(t, {
1810
+ const channelState = {
1545
1811
  root,
1546
1812
  channel,
1547
1813
  join,
@@ -1549,14 +1815,19 @@ export class TopicControlPlane
1549
1815
  onUnicast,
1550
1816
  ephemeral: wantEphemeral,
1551
1817
  lastUsedAt,
1552
- });
1818
+ };
1819
+ this.fanoutChannels.set(t, channelState);
1553
1820
  join.catch(() => {
1554
- this.fanoutChannels.delete(t);
1821
+ if (this.fanoutChannels.get(t) === channelState) {
1822
+ this.fanoutChannels.delete(t);
1823
+ }
1555
1824
  });
1556
1825
  join
1557
1826
  .then(() => {
1558
1827
  const st = this.fanoutChannels.get(t);
1559
- if (st?.ephemeral) this.scheduleFanoutIdleClose(t);
1828
+ if (st === channelState && st.ephemeral) {
1829
+ this.scheduleFanoutIdleClose(t);
1830
+ }
1560
1831
  })
1561
1832
  .catch(() => {
1562
1833
  // ignore
@@ -1565,6 +1836,7 @@ export class TopicControlPlane
1565
1836
  this.evictEphemeralFanoutChannels(t);
1566
1837
  }
1567
1838
  await withAbort(join, options?.signal);
1839
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1568
1840
  }
1569
1841
 
1570
1842
  private async closeFanoutChannel(
@@ -1604,9 +1876,15 @@ export class TopicControlPlane
1604
1876
  const joins: Promise<void>[] = [];
1605
1877
  for (let i = 0; i < this.shardCount; i++) {
1606
1878
  const shardTopic = `${this.shardTopicPrefix}${i}`;
1607
- const root = await this.resolveShardRoot(shardTopic);
1608
- if (root !== this.publicKeyHash) continue;
1609
- joins.push(this.ensureFanoutChannel(shardTopic, { pin: true, root }));
1879
+ const resolved = await this.resolveShardRootState(shardTopic);
1880
+ if (resolved.root !== this.publicKeyHash) continue;
1881
+ joins.push(
1882
+ this.ensureFanoutChannel(shardTopic, {
1883
+ pin: true,
1884
+ root: resolved.root,
1885
+ rootCandidateGeneration: resolved.candidateGeneration,
1886
+ }),
1887
+ );
1610
1888
  }
1611
1889
  await Promise.all(joins);
1612
1890
  }
@@ -2010,7 +2288,8 @@ export class TopicControlPlane
2010
2288
  );
2011
2289
  }
2012
2290
 
2013
- const silentDelivery = options?.mode instanceof SilentDelivery;
2291
+ const silentDelivery =
2292
+ options?.mode != null && isSilentDeliveryMode(options.mode);
2014
2293
  try {
2015
2294
  await this.publishMessage(
2016
2295
  this.publicKey,
@@ -2153,7 +2432,7 @@ export class TopicControlPlane
2153
2432
  }),
2154
2433
  );
2155
2434
 
2156
- const silentDelivery = options.mode instanceof SilentDelivery;
2435
+ const silentDelivery = isSilentDeliveryMode(options.mode);
2157
2436
  try {
2158
2437
  await this.publishMessage(
2159
2438
  this.publicKey,
@@ -2503,6 +2782,19 @@ export class TopicControlPlane
2503
2782
  stream: PeerStreams;
2504
2783
  }): Promise<void> {
2505
2784
  const { pubsubMessage, message, from, stream } = input;
2785
+ const isDirectRootControl =
2786
+ pubsubMessage instanceof TopicRootCandidates ||
2787
+ pubsubMessage instanceof TopicRootQuery ||
2788
+ pubsubMessage instanceof TopicRootQueryResponse;
2789
+ const directRootSigner = isDirectRootControl
2790
+ ? message.header.signatures?.publicKeys[0]
2791
+ : undefined;
2792
+ if (
2793
+ isDirectRootControl &&
2794
+ (!directRootSigner || !directRootSigner.equals(stream.publicKey))
2795
+ ) {
2796
+ return;
2797
+ }
2506
2798
 
2507
2799
  if (pubsubMessage instanceof TopicRootCandidates) {
2508
2800
  // Used only to converge deterministic shard-root candidates in auto mode.
@@ -2524,7 +2816,8 @@ export class TopicControlPlane
2524
2816
  }
2525
2817
 
2526
2818
  if (pubsubMessage instanceof TopicRootQueryResponse) {
2527
- this.resolvePendingTopicRootQuery(pubsubMessage);
2819
+ const senderHash = directRootSigner!.hashcode();
2820
+ this.resolvePendingTopicRootQuery(pubsubMessage, senderHash);
2528
2821
  return;
2529
2822
  }
2530
2823
 
@@ -2857,8 +3150,8 @@ export class TopicControlPlane
2857
3150
  if (deliveryModeHasReceiver(message.header.mode)) {
2858
3151
  isForMe = message.header.mode.to.includes(this.publicKeyHash);
2859
3152
  } else if (
2860
- message.header.mode instanceof AnyWhere ||
2861
- message.header.mode instanceof AcknowledgeAnyWhere
3153
+ isAnyWhereDeliveryMode(message.header.mode) ||
3154
+ isAcknowledgeAnyWhereDeliveryMode(message.header.mode)
2862
3155
  ) {
2863
3156
  isForMe = true;
2864
3157
  }
@@ -2889,8 +3182,8 @@ export class TopicControlPlane
2889
3182
  }
2890
3183
 
2891
3184
  if (
2892
- message.header.mode instanceof SilentDelivery ||
2893
- message.header.mode instanceof AcknowledgeDelivery
3185
+ isSilentDeliveryMode(message.header.mode) ||
3186
+ isAcknowledgeDeliveryMode(message.header.mode)
2894
3187
  ) {
2895
3188
  if (
2896
3189
  message.header.mode.to.length === 1 &&
@@ -2902,8 +3195,8 @@ export class TopicControlPlane
2902
3195
 
2903
3196
  const shouldForward =
2904
3197
  seenBefore === 0 ||
2905
- ((message.header.mode instanceof AcknowledgeDelivery ||
2906
- message.header.mode instanceof AcknowledgeAnyWhere) &&
3198
+ ((isAcknowledgeDeliveryMode(message.header.mode) ||
3199
+ isAcknowledgeAnyWhereDeliveryMode(message.header.mode)) &&
2907
3200
  seenBefore < message.header.mode.redundancy);
2908
3201
  if (shouldForward) {
2909
3202
  this.relayMessage(from, message).catch(logErrorIfStarted);