@peerbit/pubsub 5.3.4 → 5.3.6

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
@@ -230,6 +230,14 @@ export type TopicControlPlaneOptions = DirectStreamOptions & {
230
230
  subscriberCacheMaxEntries?: number;
231
231
  };
232
232
 
233
+ type EnsureFanoutChannelOptions = {
234
+ ephemeral?: boolean;
235
+ pin?: boolean;
236
+ root?: string;
237
+ rootCandidateGeneration?: string;
238
+ signal?: AbortSignal;
239
+ };
240
+
233
241
  export type TopicControlPlaneComponents = DirectStreamComponents;
234
242
 
235
243
  export type PeerId = Libp2pPeerId | PublicSignKey;
@@ -333,6 +341,9 @@ export class TopicControlPlane
333
341
  private autoTopicRootCandidates = false;
334
342
  private autoTopicRootCandidateSet?: Set<string>;
335
343
  private reconcileShardOverlaysInFlight?: Promise<void>;
344
+ private reconcileShardOverlaysDirty = false;
345
+ private topicControlPlaneStopping = false;
346
+ private topicControlPlaneLifecycleRevision = 0;
336
347
  private hostOwnedShardRootsInFlight?: Promise<void>;
337
348
  private hostOwnedShardRootsDirty = false;
338
349
  private autoCandidatesBroadcastTimers: Array<ReturnType<typeof setTimeout>> =
@@ -356,9 +367,18 @@ export class TopicControlPlane
356
367
  idleCloseTimeout?: ReturnType<typeof setTimeout>;
357
368
  }
358
369
  >();
370
+ private ensureFanoutChannelInFlight = new Map<
371
+ string,
372
+ {
373
+ candidateGeneration: string;
374
+ lifecycleRevision: number;
375
+ opening: Promise<void>;
376
+ }
377
+ >();
359
378
  private pendingTopicRootQueries = new Map<
360
379
  number,
361
380
  {
381
+ expectedPeerHash: string;
362
382
  topic: string;
363
383
  resolve: (root: string | undefined) => void;
364
384
  timer: ReturnType<typeof setTimeout>;
@@ -524,6 +544,7 @@ export class TopicControlPlane
524
544
  }
525
545
 
526
546
  public override async start() {
547
+ this.topicControlPlaneStopping = false;
527
548
  await this.fanout.start();
528
549
  this._onFanoutPeerUnreachable =
529
550
  this._onFanoutPeerUnreachable ||
@@ -540,9 +561,16 @@ export class TopicControlPlane
540
561
  if (this.hostOwnedShardRootsDirty) {
541
562
  this.scheduleHostOwnedShardRoots();
542
563
  }
564
+ if (this.reconcileShardOverlaysDirty) {
565
+ this.scheduleReconcileShardOverlays();
566
+ }
543
567
  }
544
568
 
545
569
  public override async stop() {
570
+ this.topicControlPlaneStopping = true;
571
+ this.topicControlPlaneLifecycleRevision += 1;
572
+ this.reconcileShardOverlaysDirty = false;
573
+ this.ensureFanoutChannelInFlight.clear();
546
574
  if (this._onFanoutPeerUnreachable) {
547
575
  this.fanout.removeEventListener(
548
576
  "fanout:peer-unreachable",
@@ -603,7 +631,8 @@ export class TopicControlPlane
603
631
 
604
632
  this.debounceSubscribeAggregator.close();
605
633
  this.debounceUnsubscribeAggregator.close();
606
- return super.stop();
634
+ await super.stop();
635
+ this.reconcileShardOverlaysDirty = false;
607
636
  }
608
637
 
609
638
  public override async onPeerConnected(
@@ -840,21 +869,45 @@ export class TopicControlPlane
840
869
  }
841
870
 
842
871
  private scheduleReconcileShardOverlays() {
872
+ this.reconcileShardOverlaysDirty = true;
873
+ if (!this.started || this.stopping || this.topicControlPlaneStopping)
874
+ return;
843
875
  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
- });
876
+ this.reconcileShardOverlaysInFlight = (async () => {
877
+ for (;;) {
878
+ if (!this.started || this.stopping || this.topicControlPlaneStopping)
879
+ return;
880
+ if (!this.reconcileShardOverlaysDirty) return;
881
+ this.reconcileShardOverlaysDirty = false;
882
+ try {
883
+ await this.reconcileShardOverlays();
884
+ } catch {
885
+ // Fanout streams or roots might not be ready yet. Preserve both
886
+ // failures and root changes that arrived during the active pass.
887
+ if (!this.started || this.stopping || this.topicControlPlaneStopping)
888
+ return;
889
+ this.reconcileShardOverlaysDirty = true;
890
+ await new Promise<void>((resolve) => {
891
+ const timer = setTimeout(resolve, 250);
892
+ timer.unref?.();
893
+ });
894
+ }
895
+ }
896
+ })().finally(() => {
897
+ this.reconcileShardOverlaysInFlight = undefined;
898
+ if (
899
+ this.reconcileShardOverlaysDirty &&
900
+ this.started &&
901
+ !this.stopping &&
902
+ !this.topicControlPlaneStopping
903
+ ) {
904
+ this.scheduleReconcileShardOverlays();
905
+ }
906
+ });
854
907
  }
855
908
 
856
909
  private async reconcileShardOverlays() {
857
- if (!this.started) return;
910
+ if (!this.started || this.topicControlPlaneStopping) return;
858
911
 
859
912
  const byShard = new Map<string, string[]>();
860
913
  for (const topic of this.subscriptions.keys()) {
@@ -1134,6 +1187,41 @@ export class TopicControlPlane
1134
1187
  return PubSubMessage.from(bytes);
1135
1188
  }
1136
1189
 
1190
+ private getTopicRootCandidateGeneration(): string {
1191
+ return JSON.stringify([
1192
+ this.autoTopicRootCandidates,
1193
+ this.topicRootControlPlane.getTopicRootCandidates(),
1194
+ ]);
1195
+ }
1196
+
1197
+ private assertTopicControlPlaneActive(lifecycleRevision: number): void {
1198
+ if (
1199
+ lifecycleRevision !== this.topicControlPlaneLifecycleRevision ||
1200
+ !this.started ||
1201
+ this.stopping ||
1202
+ this.topicControlPlaneStopping
1203
+ ) {
1204
+ throw new NotStartedError();
1205
+ }
1206
+ }
1207
+
1208
+ private normalizePeerTopicRootState(
1209
+ topic: string,
1210
+ root: string,
1211
+ ): { root: string; authoritative: boolean } {
1212
+ const deterministic =
1213
+ this.topicRootControlPlane.resolveDeterministicTopicRoot(topic);
1214
+ if (
1215
+ this.autoTopicRootCandidates &&
1216
+ topic.startsWith(this.shardTopicPrefix) &&
1217
+ deterministic &&
1218
+ root !== deterministic
1219
+ ) {
1220
+ return { root: deterministic, authoritative: false };
1221
+ }
1222
+ return { root, authoritative: true };
1223
+ }
1224
+
1137
1225
  private async resolveTopicRootState(
1138
1226
  topic: string,
1139
1227
  ): Promise<{ root?: string; authoritative: boolean }> {
@@ -1144,7 +1232,12 @@ export class TopicControlPlane
1144
1232
 
1145
1233
  const resolvedThroughPeers = await this.resolveTopicRootThroughPeers(topic);
1146
1234
  if (resolvedThroughPeers) {
1147
- return { root: resolvedThroughPeers, authoritative: true };
1235
+ // Unconfigured peer-query replies cannot override the locally
1236
+ // deterministic root for internal shards in auto mode. Roots, resolvers,
1237
+ // and trackers explicitly configured on this control plane are resolved
1238
+ // above and remain authoritative. A leaf relying on a queried gateway for
1239
+ // shard roots must first disable auto mode with setTopicRootCandidates([]).
1240
+ return this.normalizePeerTopicRootState(topic, resolvedThroughPeers);
1148
1241
  }
1149
1242
 
1150
1243
  const deterministic = this.topicRootControlPlane.resolveDeterministicTopicRoot(topic);
@@ -1157,46 +1250,65 @@ export class TopicControlPlane
1157
1250
  await delay(150 * (attempt < 4 ? 1 : 2));
1158
1251
  const retried = await this.resolveTopicRootThroughPeers(topic);
1159
1252
  if (retried) {
1160
- return { root: retried, authoritative: true };
1253
+ return this.normalizePeerTopicRootState(topic, retried);
1161
1254
  }
1162
1255
  }
1163
1256
  }
1164
1257
 
1165
- return { root: deterministic, authoritative: false };
1258
+ return {
1259
+ root: this.topicRootControlPlane.resolveDeterministicTopicRoot(topic),
1260
+ authoritative: false,
1261
+ };
1166
1262
  }
1167
1263
 
1168
1264
  public async resolveTopicRoot(topic: string): Promise<string | undefined> {
1169
1265
  return (await this.resolveTopicRootState(topic)).root;
1170
1266
  }
1171
1267
 
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
- }
1268
+ private async resolveShardRootState(
1269
+ shardTopic: string,
1270
+ ): Promise<{ root: string; candidateGeneration: string }> {
1271
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
1272
+ for (;;) {
1273
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1274
+ // If someone configured topic-root candidates externally (e.g.
1275
+ // TestSession router selection or Peerbit.bootstrap) after this peer
1276
+ // entered auto mode, disable auto mode before caching a root.
1277
+ if (this.autoTopicRootCandidates) {
1278
+ this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured();
1279
+ }
1179
1280
 
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);
1281
+ const candidateGeneration = this.getTopicRootCandidateGeneration();
1282
+ const hasConnectedTrackers =
1283
+ this.getConnectedTopicRootTrackers().length > 0;
1284
+ const cached = this.shardRootCache.get(shardTopic);
1285
+ if (cached && (cached.authoritative || !hasConnectedTrackers)) {
1286
+ return { root: cached.root, candidateGeneration };
1287
+ }
1288
+
1289
+ const resolved = await this.resolveTopicRootState(shardTopic);
1290
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1291
+ if (candidateGeneration !== this.getTopicRootCandidateGeneration()) {
1292
+ continue;
1293
+ }
1294
+ if (!resolved.root) {
1295
+ throw new Error(
1296
+ `No root resolved for shard topic ${shardTopic}. Configure TopicRootControlPlane candidates/resolver/trackers, or dial/bootstrap a peer that can resolve shard roots.`,
1297
+ );
1298
+ }
1299
+ if (resolved.authoritative || !hasConnectedTrackers) {
1300
+ this.shardRootCache.set(
1301
+ shardTopic,
1302
+ resolved as {
1303
+ root: string;
1304
+ authoritative: boolean;
1305
+ },
1306
+ );
1307
+ } else {
1308
+ this.shardRootCache.delete(shardTopic);
1309
+ }
1310
+ return { root: resolved.root, candidateGeneration };
1198
1311
  }
1199
- return resolved.root;
1200
1312
  }
1201
1313
 
1202
1314
  private getConnectedTopicRootTrackers(): PeerStreams[] {
@@ -1240,9 +1352,14 @@ export class TopicControlPlane
1240
1352
 
1241
1353
  private resolvePendingTopicRootQuery(
1242
1354
  message: TopicRootQueryResponse,
1355
+ responderPeerHash: string,
1243
1356
  ): boolean {
1244
1357
  const pending = this.pendingTopicRootQueries.get(message.requestId);
1245
- if (!pending || pending.topic !== message.topic) {
1358
+ if (
1359
+ !pending ||
1360
+ pending.topic !== message.topic ||
1361
+ pending.expectedPeerHash !== responderPeerHash
1362
+ ) {
1246
1363
  return false;
1247
1364
  }
1248
1365
  this.pendingTopicRootQueries.delete(message.requestId);
@@ -1258,6 +1375,7 @@ export class TopicControlPlane
1258
1375
  ): Promise<string | undefined> {
1259
1376
  if (!this.started || this.stopping) return undefined;
1260
1377
 
1378
+ const expectedPeerHash = peer.publicKey.hashcode();
1261
1379
  const requestId = this.nextTopicRootRequestIdValue();
1262
1380
  const responsePromise = new Promise<string | undefined>((resolve) => {
1263
1381
  const timer = setTimeout(() => {
@@ -1265,7 +1383,12 @@ export class TopicControlPlane
1265
1383
  resolve(undefined);
1266
1384
  }, Math.max(1, Math.floor(timeoutMs)));
1267
1385
  timer.unref?.();
1268
- this.pendingTopicRootQueries.set(requestId, { topic, resolve, timer });
1386
+ this.pendingTopicRootQueries.set(requestId, {
1387
+ expectedPeerHash,
1388
+ topic,
1389
+ resolve,
1390
+ timer,
1391
+ });
1269
1392
  });
1270
1393
 
1271
1394
  try {
@@ -1288,8 +1411,11 @@ export class TopicControlPlane
1288
1411
  private async confirmDirectShardRoot(
1289
1412
  shardTopic: string,
1290
1413
  root: string,
1414
+ candidateGeneration: string,
1415
+ lifecycleRevision: number,
1291
1416
  signal?: AbortSignal,
1292
1417
  ): Promise<string> {
1418
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1293
1419
  if (root === this.publicKeyHash) return root;
1294
1420
  const rootPeer = this.peers.get(root);
1295
1421
  if (!rootPeer) return root;
@@ -1302,37 +1428,64 @@ export class TopicControlPlane
1302
1428
  ),
1303
1429
  signal,
1304
1430
  );
1431
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1432
+ if (candidateGeneration !== this.getTopicRootCandidateGeneration()) {
1433
+ return root;
1434
+ }
1305
1435
  if (!confirmation) return root;
1306
- if (confirmation !== root) {
1436
+ const resolved = this.normalizePeerTopicRootState(shardTopic, confirmation);
1437
+ if (!resolved.authoritative) {
1438
+ this.shardRootCache.delete(shardTopic);
1439
+ } else if (resolved.root !== root) {
1307
1440
  this.shardRootCache.set(shardTopic, {
1308
- root: confirmation,
1441
+ root: resolved.root,
1309
1442
  authoritative: true,
1310
1443
  });
1311
1444
  }
1312
- return confirmation;
1445
+ return resolved.root;
1313
1446
  }
1314
1447
 
1315
1448
  private async resolveQueryableTopicRoot(
1316
1449
  topic: string,
1317
1450
  ): 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
- }
1451
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
1452
+ for (;;) {
1453
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1454
+ const rootCandidateGeneration = this.getTopicRootCandidateGeneration();
1455
+ const root =
1456
+ await this.topicRootControlPlane.resolveCanonicalTopicRoot(topic);
1457
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1458
+ if (rootCandidateGeneration !== this.getTopicRootCandidateGeneration()) {
1459
+ continue;
1460
+ }
1461
+ if (root !== this.publicKeyHash) {
1462
+ return root;
1463
+ }
1464
+ if (!topic.startsWith(this.shardTopicPrefix)) {
1465
+ return root;
1466
+ }
1325
1467
 
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;
1468
+ try {
1469
+ await this.ensureFanoutChannel(topic, {
1470
+ pin: true,
1471
+ root,
1472
+ rootCandidateGeneration,
1473
+ });
1474
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1475
+ if (
1476
+ rootCandidateGeneration !== this.getTopicRootCandidateGeneration()
1477
+ ) {
1478
+ continue;
1479
+ }
1480
+ return root;
1481
+ } catch (error) {
1482
+ warn(
1483
+ `Failed to host shard root ${topic} before answering root query: ${
1484
+ error instanceof Error ? error.message : String(error)
1485
+ }`,
1486
+ );
1487
+ return undefined;
1488
+ }
1336
1489
  }
1337
1490
  }
1338
1491
 
@@ -1361,14 +1514,69 @@ export class TopicControlPlane
1361
1514
 
1362
1515
  private async ensureFanoutChannel(
1363
1516
  shardTopic: string,
1364
- options?: {
1365
- ephemeral?: boolean;
1366
- pin?: boolean;
1367
- root?: string;
1368
- signal?: AbortSignal;
1369
- },
1517
+ options?: EnsureFanoutChannelOptions,
1370
1518
  ): Promise<void> {
1371
- if (!this.started) throw new NotStartedError();
1519
+ const topic = shardTopic.toString();
1520
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
1521
+ for (;;) {
1522
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1523
+ const candidateGeneration = this.getTopicRootCandidateGeneration();
1524
+ const active = this.ensureFanoutChannelInFlight.get(topic);
1525
+ if (
1526
+ active?.candidateGeneration === candidateGeneration &&
1527
+ active.lifecycleRevision === lifecycleRevision
1528
+ ) {
1529
+ try {
1530
+ await withAbort(active.opening, options?.signal);
1531
+ } catch (error) {
1532
+ if (options?.signal?.aborted) throw error;
1533
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1534
+ }
1535
+ continue;
1536
+ }
1537
+
1538
+ const opening = this.ensureFanoutChannelOnce(
1539
+ topic,
1540
+ options,
1541
+ lifecycleRevision,
1542
+ candidateGeneration,
1543
+ );
1544
+ const inFlight = {
1545
+ candidateGeneration,
1546
+ lifecycleRevision,
1547
+ opening,
1548
+ };
1549
+ this.ensureFanoutChannelInFlight.set(topic, inFlight);
1550
+ try {
1551
+ await opening;
1552
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1553
+ if (candidateGeneration !== this.getTopicRootCandidateGeneration()) {
1554
+ continue;
1555
+ }
1556
+ return;
1557
+ } catch (error) {
1558
+ if (options?.signal?.aborted) throw error;
1559
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1560
+ if (candidateGeneration !== this.getTopicRootCandidateGeneration()) {
1561
+ continue;
1562
+ }
1563
+ throw error;
1564
+ } finally {
1565
+ if (this.ensureFanoutChannelInFlight.get(topic) === inFlight) {
1566
+ this.ensureFanoutChannelInFlight.delete(topic);
1567
+ }
1568
+ }
1569
+ }
1570
+ }
1571
+
1572
+ private async ensureFanoutChannelOnce(
1573
+ shardTopic: string,
1574
+ options: EnsureFanoutChannelOptions | undefined,
1575
+ lifecycleRevision: number,
1576
+ candidateGeneration: string,
1577
+ ): Promise<void> {
1578
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1579
+ if (candidateGeneration !== this.getTopicRootCandidateGeneration()) return;
1372
1580
  const t = shardTopic.toString();
1373
1581
  const pin = options?.pin === true;
1374
1582
  const wantEphemeral = options?.ephemeral === true;
@@ -1376,10 +1584,31 @@ export class TopicControlPlane
1376
1584
  // Allow callers that already resolved the shard root (e.g. hostShardRootsNow)
1377
1585
  // to pass it through to avoid a race where the candidate set changes between
1378
1586
  // two resolve calls, causing an unnecessary (and potentially slow) join.
1379
- let root: string | undefined = options?.root;
1587
+ const suppliedRoot = options?.root;
1588
+ const suppliedRootGeneration = options?.rootCandidateGeneration;
1589
+ if (
1590
+ suppliedRoot !== undefined &&
1591
+ suppliedRootGeneration !== candidateGeneration
1592
+ ) {
1593
+ return;
1594
+ }
1595
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1596
+ let resolvedGeneration = candidateGeneration;
1597
+ let root = suppliedRoot;
1380
1598
  const existing = this.fanoutChannels.get(t);
1381
1599
  if (existing) {
1382
- root = root ?? (await this.resolveShardRoot(t));
1600
+ if (!root) {
1601
+ const resolved = await this.resolveShardRootState(t);
1602
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1603
+ root = resolved.root;
1604
+ resolvedGeneration = resolved.candidateGeneration;
1605
+ }
1606
+ if (
1607
+ resolvedGeneration !== candidateGeneration ||
1608
+ candidateGeneration !== this.getTopicRootCandidateGeneration()
1609
+ ) {
1610
+ return;
1611
+ }
1383
1612
  if (root === existing.root) {
1384
1613
  existing.lastUsedAt = Date.now();
1385
1614
  if (existing.ephemeral && !wantEphemeral) {
@@ -1390,15 +1619,48 @@ export class TopicControlPlane
1390
1619
  }
1391
1620
  if (pin) this.pinnedShards.add(t);
1392
1621
  await withAbort(existing.join, options?.signal);
1622
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1623
+ if (
1624
+ resolvedGeneration !== candidateGeneration ||
1625
+ candidateGeneration !== this.getTopicRootCandidateGeneration()
1626
+ ) {
1627
+ return;
1628
+ }
1393
1629
  return;
1394
1630
  }
1395
1631
 
1396
- // Root mapping changed (candidate set updated): migrate to the new overlay.
1397
- await withAbort(this.closeFanoutChannel(t, { force: true }), options?.signal);
1632
+ // Root mapping changed (candidate set updated): migrate to the new
1633
+ // overlay.
1634
+ await withAbort(
1635
+ this.closeFanoutChannel(t, { force: true }),
1636
+ options?.signal,
1637
+ );
1638
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1639
+ if (candidateGeneration !== this.getTopicRootCandidateGeneration()) {
1640
+ return;
1641
+ }
1398
1642
  }
1399
1643
 
1400
- root = root ?? (await this.resolveShardRoot(t));
1401
- root = await this.confirmDirectShardRoot(t, root, options?.signal);
1644
+ if (!root) {
1645
+ const resolved = await this.resolveShardRootState(t);
1646
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1647
+ root = resolved.root;
1648
+ resolvedGeneration = resolved.candidateGeneration;
1649
+ }
1650
+ root = await this.confirmDirectShardRoot(
1651
+ t,
1652
+ root,
1653
+ resolvedGeneration,
1654
+ lifecycleRevision,
1655
+ options?.signal,
1656
+ );
1657
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1658
+ if (
1659
+ resolvedGeneration !== candidateGeneration ||
1660
+ candidateGeneration !== this.getTopicRootCandidateGeneration()
1661
+ ) {
1662
+ return;
1663
+ }
1402
1664
  const channel = new FanoutChannel(this.fanout, { topic: t, root });
1403
1665
 
1404
1666
  const onPayload = (payload: Uint8Array) => {
@@ -1541,7 +1803,7 @@ export class TopicControlPlane
1541
1803
 
1542
1804
  const lastUsedAt = Date.now();
1543
1805
  if (pin) this.pinnedShards.add(t);
1544
- this.fanoutChannels.set(t, {
1806
+ const channelState = {
1545
1807
  root,
1546
1808
  channel,
1547
1809
  join,
@@ -1549,14 +1811,19 @@ export class TopicControlPlane
1549
1811
  onUnicast,
1550
1812
  ephemeral: wantEphemeral,
1551
1813
  lastUsedAt,
1552
- });
1814
+ };
1815
+ this.fanoutChannels.set(t, channelState);
1553
1816
  join.catch(() => {
1554
- this.fanoutChannels.delete(t);
1817
+ if (this.fanoutChannels.get(t) === channelState) {
1818
+ this.fanoutChannels.delete(t);
1819
+ }
1555
1820
  });
1556
1821
  join
1557
1822
  .then(() => {
1558
1823
  const st = this.fanoutChannels.get(t);
1559
- if (st?.ephemeral) this.scheduleFanoutIdleClose(t);
1824
+ if (st === channelState && st.ephemeral) {
1825
+ this.scheduleFanoutIdleClose(t);
1826
+ }
1560
1827
  })
1561
1828
  .catch(() => {
1562
1829
  // ignore
@@ -1565,6 +1832,7 @@ export class TopicControlPlane
1565
1832
  this.evictEphemeralFanoutChannels(t);
1566
1833
  }
1567
1834
  await withAbort(join, options?.signal);
1835
+ this.assertTopicControlPlaneActive(lifecycleRevision);
1568
1836
  }
1569
1837
 
1570
1838
  private async closeFanoutChannel(
@@ -1604,9 +1872,15 @@ export class TopicControlPlane
1604
1872
  const joins: Promise<void>[] = [];
1605
1873
  for (let i = 0; i < this.shardCount; i++) {
1606
1874
  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 }));
1875
+ const resolved = await this.resolveShardRootState(shardTopic);
1876
+ if (resolved.root !== this.publicKeyHash) continue;
1877
+ joins.push(
1878
+ this.ensureFanoutChannel(shardTopic, {
1879
+ pin: true,
1880
+ root: resolved.root,
1881
+ rootCandidateGeneration: resolved.candidateGeneration,
1882
+ }),
1883
+ );
1610
1884
  }
1611
1885
  await Promise.all(joins);
1612
1886
  }
@@ -2503,6 +2777,19 @@ export class TopicControlPlane
2503
2777
  stream: PeerStreams;
2504
2778
  }): Promise<void> {
2505
2779
  const { pubsubMessage, message, from, stream } = input;
2780
+ const isDirectRootControl =
2781
+ pubsubMessage instanceof TopicRootCandidates ||
2782
+ pubsubMessage instanceof TopicRootQuery ||
2783
+ pubsubMessage instanceof TopicRootQueryResponse;
2784
+ const directRootSigner = isDirectRootControl
2785
+ ? message.header.signatures?.publicKeys[0]
2786
+ : undefined;
2787
+ if (
2788
+ isDirectRootControl &&
2789
+ (!directRootSigner || !directRootSigner.equals(stream.publicKey))
2790
+ ) {
2791
+ return;
2792
+ }
2506
2793
 
2507
2794
  if (pubsubMessage instanceof TopicRootCandidates) {
2508
2795
  // Used only to converge deterministic shard-root candidates in auto mode.
@@ -2524,7 +2811,8 @@ export class TopicControlPlane
2524
2811
  }
2525
2812
 
2526
2813
  if (pubsubMessage instanceof TopicRootQueryResponse) {
2527
- this.resolvePendingTopicRootQuery(pubsubMessage);
2814
+ const senderHash = directRootSigner!.hashcode();
2815
+ this.resolvePendingTopicRootQuery(pubsubMessage, senderHash);
2528
2816
  return;
2529
2817
  }
2530
2818