@peerbit/pubsub 5.4.4 → 5.4.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
@@ -234,9 +234,15 @@ type TopicRootCandidateClaimRecord = {
234
234
  const sameCandidates = (left: string[], right: string[]) =>
235
235
  left.length === right.length &&
236
236
  left.every((candidate, index) => candidate === right[index]);
237
- // Topic-root queries may need to wait for the responder to finish opening an
238
- // outbound stream back to the requester after an inbound-only dial.
237
+ // Retain the previous 12 second allowance as one deadline for the complete
238
+ // peer-query phase. It must not multiply by peer count or retry count.
239
239
  const DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS = 12_000;
240
+ // A responder may need to open an outbound stream after an inbound-only dial.
241
+ // Bounded rounds leave enough time for that setup while still allowing a lost
242
+ // request or a newly-ready stream to be retried within the phase deadline.
243
+ const TOPIC_ROOT_QUERY_ROUND_TIMEOUT_MS = 3_000;
244
+ const TOPIC_ROOT_HOST_SCAN_TIMEOUT_MS = DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS;
245
+ const TOPIC_ROOT_HOST_SCAN_CONCURRENCY = 8;
240
246
  const DIRECT_SHARD_ROOT_CONFIRM_TIMEOUT_MS = 2_000;
241
247
 
242
248
  const DEFAULT_PUBSUB_FANOUT_CHANNEL_OPTIONS: Omit<
@@ -458,6 +464,15 @@ export class TopicControlPlane
458
464
  string,
459
465
  TopicRootCandidateClaimRecord
460
466
  >();
467
+ // A directly observed departure makes only that origin's current lease
468
+ // ineffective locally. This is not a component-wide withdrawal: claims learned
469
+ // only through relays keep their existing lease semantics. Keeping the verified
470
+ // bytes + replay floor prevents a relay from resurrecting the departed lease;
471
+ // the map is bounded by the same retained-origin cap as the claim map.
472
+ private readonly suppressedDepartedTopicRootCandidateClaims = new Map<
473
+ string,
474
+ { bytes: Uint8Array; timestamp: bigint }
475
+ >();
461
476
  // Active claims expire, but their per-origin timestamp floors survive for the
462
477
  // auto-mode lifecycle so a frozen wall clock cannot renew a replayed lease.
463
478
  // Retaining only the deterministic lowest origins keeps this state hard-bounded.
@@ -849,7 +864,7 @@ export class TopicControlPlane
849
864
  if (peer.isWritable) {
850
865
  void this.sendAutoTopicRootCandidates([peer]).catch(() => {});
851
866
  }
852
- if (!existingPeer) {
867
+ if (existingPeer !== peer) {
853
868
  peer.addEventListener("stream:outbound", sendWhenOutboundReady);
854
869
  peer.addEventListener(
855
870
  "close",
@@ -866,13 +881,36 @@ export class TopicControlPlane
866
881
  return peer;
867
882
  }
868
883
 
869
- protected override async _removePeer(publicKey: PublicSignKey) {
884
+ protected override async _removePeer(
885
+ publicKey: PublicSignKey,
886
+ ): Promise<PeerStreams | undefined> {
870
887
  const hash = publicKey.hashcode();
871
888
  const protocol = this.peers.get(hash)?.protocol;
872
889
  const hadSignedPeer = [...this.peers.values()].some(
873
890
  (peer) => peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1,
874
891
  );
875
892
  const removed = await super._removePeer(publicKey);
893
+ // A replacement may arrive after base removal but before this continuation.
894
+ if (!removed || this.peers.has(hash)) return;
895
+ let suppressedDepartedOrigin = false;
896
+ if (
897
+ removed &&
898
+ this.autoTopicRootCandidates &&
899
+ protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1 &&
900
+ this.signedTopicRootCandidateClaims.has(hash)
901
+ ) {
902
+ const claim = this.signedTopicRootCandidateClaims.get(hash)!;
903
+ const suppressed =
904
+ this.suppressedDepartedTopicRootCandidateClaims.get(hash);
905
+ suppressedDepartedOrigin =
906
+ !suppressed ||
907
+ suppressed.timestamp !== claim.timestamp ||
908
+ !bytesEqual(suppressed.bytes, claim.bytes);
909
+ this.suppressedDepartedTopicRootCandidateClaims.set(hash, {
910
+ bytes: claim.bytes,
911
+ timestamp: claim.timestamp,
912
+ });
913
+ }
876
914
  if (
877
915
  this.autoTopicRootCandidates &&
878
916
  (protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_0 ||
@@ -882,7 +920,7 @@ export class TopicControlPlane
882
920
  (peer) => peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1,
883
921
  );
884
922
  this.rebuildAutoTopicRootCandidatesFromClaims(BigInt(Date.now()), {
885
- immediate: hadSignedPeer !== hasSignedPeer,
923
+ immediate: suppressedDepartedOrigin || hadSignedPeer !== hasSignedPeer,
886
924
  });
887
925
  }
888
926
  return removed;
@@ -926,6 +964,7 @@ export class TopicControlPlane
926
964
  private clearSignedTopicRootCandidateState() {
927
965
  this.clearTopicRootCandidateClaimTimer();
928
966
  this.signedTopicRootCandidateClaims.clear();
967
+ this.suppressedDepartedTopicRootCandidateClaims.clear();
929
968
  this.topicRootCandidateClaimReplayFloors.clear();
930
969
  this.localSignedTopicRootCandidateClaim = undefined;
931
970
  this.topicRootCandidateClaimRefreshNotBefore = undefined;
@@ -1199,6 +1238,7 @@ export class TopicControlPlane
1199
1238
  for (const [origin, claim] of this.signedTopicRootCandidateClaims) {
1200
1239
  if (claim.expires <= now || claim.acceptUntil <= monotonicNow) {
1201
1240
  this.signedTopicRootCandidateClaims.delete(origin);
1241
+ this.suppressedDepartedTopicRootCandidateClaims.delete(origin);
1202
1242
  changed = true;
1203
1243
  }
1204
1244
  }
@@ -1223,7 +1263,12 @@ export class TopicControlPlane
1223
1263
  ? []
1224
1264
  : [this.publicKeyHash]),
1225
1265
  ...legacyDirectCandidates,
1226
- ...this.signedTopicRootCandidateClaims.keys(),
1266
+ ...[...this.signedTopicRootCandidateClaims.entries()]
1267
+ .filter(
1268
+ ([origin, claim]) =>
1269
+ !this.isDepartedTopicRootCandidateClaimSuppressed(origin, claim),
1270
+ )
1271
+ .map(([origin]) => origin),
1227
1272
  ]),
1228
1273
  ]
1229
1274
  .filter(isCanonicalTopicRootCandidate)
@@ -1268,6 +1313,7 @@ export class TopicControlPlane
1268
1313
  // because the retained maximum only decreases, it cannot re-enter.
1269
1314
  this.topicRootCandidateClaimReplayFloors.delete(highest);
1270
1315
  this.signedTopicRootCandidateClaims.delete(highest);
1316
+ this.suppressedDepartedTopicRootCandidateClaims.delete(highest);
1271
1317
  if (highest === this.publicKeyHash) {
1272
1318
  this.localSignedTopicRootCandidateClaim = undefined;
1273
1319
  }
@@ -1275,9 +1321,22 @@ export class TopicControlPlane
1275
1321
  }
1276
1322
  this.topicRootCandidateClaimReplayFloors.set(origin, claim.timestamp);
1277
1323
  this.signedTopicRootCandidateClaims.set(origin, claim);
1324
+ this.suppressedDepartedTopicRootCandidateClaims.delete(origin);
1278
1325
  return true;
1279
1326
  }
1280
1327
 
1328
+ private isDepartedTopicRootCandidateClaimSuppressed(
1329
+ origin: string,
1330
+ claim: TopicRootCandidateClaimRecord,
1331
+ ): boolean {
1332
+ const suppressed =
1333
+ this.suppressedDepartedTopicRootCandidateClaims.get(origin);
1334
+ return (
1335
+ suppressed?.timestamp === claim.timestamp &&
1336
+ bytesEqual(suppressed.bytes, claim.bytes)
1337
+ );
1338
+ }
1339
+
1281
1340
  private canAdvanceTopicRootCandidateClaimReplayFloor(
1282
1341
  origin: string,
1283
1342
  timestamp: bigint,
@@ -1354,6 +1413,7 @@ export class TopicControlPlane
1354
1413
  private async importSignedTopicRootCandidateClaim(
1355
1414
  rawClaim: Uint8Array,
1356
1415
  outerPeerHash: string,
1416
+ expectedPeer?: PeerStreams,
1357
1417
  ): Promise<boolean> {
1358
1418
  const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
1359
1419
  if (
@@ -1398,11 +1458,35 @@ export class TopicControlPlane
1398
1458
  if (acceptanceDeadline === undefined) {
1399
1459
  return false;
1400
1460
  }
1461
+
1462
+ this.rebuildAutoTopicRootCandidatesFromClaims(now);
1463
+ const retained = this.signedTopicRootCandidateClaims.get(origin);
1464
+ if (
1465
+ outerPeerHash === origin &&
1466
+ expectedPeer !== undefined &&
1467
+ expectedPeer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1 &&
1468
+ this.peers.get(outerPeerHash) === expectedPeer &&
1469
+ lifecycleRevision === this.topicControlPlaneLifecycleRevision &&
1470
+ this.autoTopicRootCandidates &&
1471
+ this.started &&
1472
+ !this.stopping &&
1473
+ !this.topicControlPlaneStopping &&
1474
+ retained?.timestamp === timestamp &&
1475
+ bytesEqual(retained.bytes, rawClaim) &&
1476
+ this.isDepartedTopicRootCandidateClaimSuppressed(origin, retained)
1477
+ ) {
1478
+ // These exact bytes were already verified and remain under the original
1479
+ // monotonic acceptance deadline. A current authenticated origin stream is
1480
+ // sufficient liveness evidence; do not advance the floor or lease.
1481
+ this.suppressedDepartedTopicRootCandidateClaims.delete(origin);
1482
+ this.rebuildAutoTopicRootCandidatesFromClaims(now, { immediate: true });
1483
+ return false;
1484
+ }
1485
+
1401
1486
  if (!this.canAdvanceTopicRootCandidateClaimReplayFloor(origin, timestamp)) {
1402
1487
  return false;
1403
1488
  }
1404
1489
 
1405
- this.rebuildAutoTopicRootCandidatesFromClaims(now);
1406
1490
  if (!this.consumeTopicRootCandidateClaimVerifyBudget(outerPeerHash)) {
1407
1491
  return false;
1408
1492
  }
@@ -1425,7 +1509,9 @@ export class TopicControlPlane
1425
1509
  !this.autoTopicRootCandidates ||
1426
1510
  !this.started ||
1427
1511
  this.stopping ||
1428
- this.topicControlPlaneStopping
1512
+ this.topicControlPlaneStopping ||
1513
+ (expectedPeer !== undefined &&
1514
+ this.peers.get(outerPeerHash) !== expectedPeer)
1429
1515
  ) {
1430
1516
  return false;
1431
1517
  }
@@ -1499,6 +1585,10 @@ export class TopicControlPlane
1499
1585
  }
1500
1586
  }
1501
1587
  const claims = [...this.signedTopicRootCandidateClaims.entries()]
1588
+ .filter(
1589
+ ([origin, claim]) =>
1590
+ !this.isDepartedTopicRootCandidateClaimSuppressed(origin, claim),
1591
+ )
1502
1592
  .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
1503
1593
  .map(([, claim]) => claim.bytes);
1504
1594
  await this.sendSignedTopicRootCandidateClaims(claims, signedStreams);
@@ -2062,7 +2152,9 @@ export class TopicControlPlane
2062
2152
 
2063
2153
  private async resolveTopicRootState(
2064
2154
  topic: string,
2065
- options?: TopicRootResolutionOptions,
2155
+ options?: TopicRootResolutionOptions & {
2156
+ queryConnectedPeers?: boolean;
2157
+ },
2066
2158
  ): Promise<{ root?: string; authoritative: boolean }> {
2067
2159
  throwIfAborted(options?.signal);
2068
2160
  const tracked = await this.topicRootControlPlane.resolveTrackedTopicRoot(
@@ -2072,37 +2164,80 @@ export class TopicControlPlane
2072
2164
  if (tracked) {
2073
2165
  return { root: tracked, authoritative: true };
2074
2166
  }
2075
-
2076
- const resolvedThroughPeers = await this.resolveTopicRootThroughPeers(
2077
- topic,
2078
- options,
2079
- );
2080
- if (resolvedThroughPeers) {
2081
- // Unconfigured peer-query replies cannot override the locally
2082
- // deterministic root for internal shards in auto mode. Roots, resolvers,
2083
- // and trackers explicitly configured on this control plane are resolved
2084
- // above and remain authoritative. A leaf relying on a queried gateway for
2085
- // shard roots must first disable auto mode with setTopicRootCandidates([]).
2086
- return this.normalizePeerTopicRootState(topic, resolvedThroughPeers);
2167
+ if (options?.queryConnectedPeers === false) {
2168
+ return {
2169
+ root: this.topicRootControlPlane.resolveDeterministicTopicRoot(topic),
2170
+ authoritative: false,
2171
+ };
2087
2172
  }
2088
2173
 
2089
- const deterministic =
2090
- this.topicRootControlPlane.resolveDeterministicTopicRoot(topic);
2091
- if (
2092
- deterministic === this.publicKeyHash &&
2093
- this.autoTopicRootCandidates &&
2094
- this.getConnectedTopicRootTrackers().length > 0
2095
- ) {
2096
- for (let attempt = 0; attempt < 8; attempt++) {
2097
- await withAbort(
2098
- delay(150 * (attempt < 4 ? 1 : 2), options),
2099
- options?.signal,
2174
+ const deadlineController = new AbortController();
2175
+ const deadlineTimer = setTimeout(
2176
+ () =>
2177
+ deadlineController.abort(
2178
+ new AbortError("topic root peer-query deadline reached"),
2179
+ ),
2180
+ DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS,
2181
+ );
2182
+ deadlineTimer.unref?.();
2183
+ const querySignals = linkAbortSignals([
2184
+ options?.signal,
2185
+ deadlineController.signal,
2186
+ ]);
2187
+ try {
2188
+ let attempt = 0;
2189
+ for (;;) {
2190
+ throwIfAborted(querySignals.signal);
2191
+ // Preserve the immediate deterministic fallback when no queryable stream
2192
+ // exists at entry. Once a peer was queryable, keep the bounded readiness
2193
+ // window alive: later rounds re-snapshot streams that can become writable
2194
+ // after an inbound-only dial.
2195
+ if (
2196
+ attempt === 0 &&
2197
+ this.getConnectedTopicRootTrackers().length === 0
2198
+ ) {
2199
+ break;
2200
+ }
2201
+ if (attempt > 0) {
2202
+ await withAbort(
2203
+ delay(150 * Math.min(attempt, 2), {
2204
+ signal: querySignals.signal,
2205
+ }),
2206
+ querySignals.signal,
2207
+ );
2208
+ }
2209
+ attempt += 1;
2210
+ const resolvedThroughPeers = await this.resolveTopicRootThroughPeers(
2211
+ topic,
2212
+ {
2213
+ signal: querySignals.signal,
2214
+ timeoutMs: TOPIC_ROOT_QUERY_ROUND_TIMEOUT_MS,
2215
+ },
2100
2216
  );
2101
- const retried = await this.resolveTopicRootThroughPeers(topic, options);
2102
- if (retried) {
2103
- return this.normalizePeerTopicRootState(topic, retried);
2217
+ if (resolvedThroughPeers !== undefined) {
2218
+ // Unconfigured peer-query replies cannot override the locally
2219
+ // deterministic root for internal shards in auto mode. Roots,
2220
+ // resolvers, and trackers explicitly configured on this control
2221
+ // plane are resolved above and remain authoritative. A leaf relying
2222
+ // on a queried gateway for shard roots must first disable auto mode
2223
+ // with setTopicRootCandidates([]).
2224
+ return this.normalizePeerTopicRootState(topic, resolvedThroughPeers);
2104
2225
  }
2105
2226
  }
2227
+ } catch (error) {
2228
+ // Candidate, lifecycle, and caller cancellation are terminal to this
2229
+ // invocation and must retain their exact reason. Only the private phase
2230
+ // deadline falls through to the same deterministic result as exhausted
2231
+ // peer queries.
2232
+ if (options?.signal?.aborted) {
2233
+ throw options.signal.reason ?? error;
2234
+ }
2235
+ if (!deadlineController.signal.aborted) {
2236
+ throw error;
2237
+ }
2238
+ } finally {
2239
+ clearTimeout(deadlineTimer);
2240
+ querySignals.clear();
2106
2241
  }
2107
2242
 
2108
2243
  return {
@@ -2128,7 +2263,9 @@ export class TopicControlPlane
2128
2263
 
2129
2264
  private async resolveShardRootState(
2130
2265
  shardTopic: string,
2131
- options?: TopicRootResolutionOptions,
2266
+ options?: TopicRootResolutionOptions & {
2267
+ queryConnectedPeers?: boolean;
2268
+ },
2132
2269
  ): Promise<{ root: string; candidateGeneration: string }> {
2133
2270
  const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
2134
2271
  for (;;) {
@@ -2143,8 +2280,14 @@ export class TopicControlPlane
2143
2280
 
2144
2281
  const candidateGeneration = this.getTopicRootCandidateGeneration();
2145
2282
  const hasConnectedTrackers =
2283
+ options?.queryConnectedPeers !== false &&
2146
2284
  this.getConnectedTopicRootTrackers().length > 0;
2147
- const cached = this.shardRootCache.get(shardTopic);
2285
+ // A proactive local scan must not inherit an authoritative cache entry that
2286
+ // originated from a connected-peer reply under the same candidate generation.
2287
+ const cached =
2288
+ options?.queryConnectedPeers === false
2289
+ ? undefined
2290
+ : this.shardRootCache.get(shardTopic);
2148
2291
  if (cached && (cached.authoritative || !hasConnectedTrackers)) {
2149
2292
  return { root: cached.root, candidateGeneration };
2150
2293
  }
@@ -2201,6 +2344,7 @@ export class TopicControlPlane
2201
2344
  private async sendDirectControlMessage(
2202
2345
  peer: PeerStreams,
2203
2346
  pubsubMessage: PubSubMessage,
2347
+ signal?: AbortSignal,
2204
2348
  ) {
2205
2349
  const embedded = await this.createMessage(
2206
2350
  this.encodePubSubMessage(pubsubMessage),
@@ -2213,7 +2357,13 @@ export class TopicControlPlane
2213
2357
  skipRecipientValidation: true,
2214
2358
  } as any,
2215
2359
  );
2216
- await this.publishMessage(this.publicKey, embedded, [peer]);
2360
+ await this.publishMessage(
2361
+ this.publicKey,
2362
+ embedded,
2363
+ [peer],
2364
+ undefined,
2365
+ signal,
2366
+ );
2217
2367
  }
2218
2368
 
2219
2369
  private resolvePendingTopicRootQuery(
@@ -2245,6 +2395,9 @@ export class TopicControlPlane
2245
2395
 
2246
2396
  const expectedPeerHash = peer.publicKey.hashcode();
2247
2397
  const requestId = this.nextTopicRootRequestIdValue();
2398
+ const queryController = new AbortController();
2399
+ const querySignals = linkAbortSignals([signal, queryController.signal]);
2400
+ let timedOut = false;
2248
2401
  const responsePromise = new Promise<string | undefined>((resolve) => {
2249
2402
  let settled = false;
2250
2403
  const settle = (root: string | undefined) => {
@@ -2255,14 +2408,16 @@ export class TopicControlPlane
2255
2408
  this.pendingTopicRootQueries.delete(requestId);
2256
2409
  }
2257
2410
  clearTimeout(timer);
2258
- if (signal && onAbort) {
2259
- signal.removeEventListener("abort", onAbort);
2260
- }
2411
+ querySignals.signal.removeEventListener("abort", onAbort);
2261
2412
  resolve(root);
2262
2413
  };
2263
- const onAbort = signal ? () => settle(undefined) : undefined;
2414
+ const onAbort = () => settle(undefined);
2264
2415
  const timer = setTimeout(
2265
- () => settle(undefined),
2416
+ () => {
2417
+ timedOut = true;
2418
+ settle(undefined);
2419
+ queryController.abort(new AbortError("topic root query timed out"));
2420
+ },
2266
2421
  Math.max(1, Math.floor(timeoutMs)),
2267
2422
  );
2268
2423
  timer.unref?.();
@@ -2272,31 +2427,45 @@ export class TopicControlPlane
2272
2427
  resolve: settle,
2273
2428
  timer,
2274
2429
  });
2275
- if (signal && onAbort) {
2276
- signal.addEventListener("abort", onAbort, { once: true });
2277
- if (signal.aborted) onAbort();
2278
- }
2430
+ querySignals.signal.addEventListener("abort", onAbort, { once: true });
2431
+ if (querySignals.signal.aborted) onAbort();
2279
2432
  });
2280
2433
 
2281
2434
  try {
2282
- await withAbort(
2283
- this.sendDirectControlMessage(
2284
- peer,
2285
- new TopicRootQuery({ requestId, topic }),
2286
- ),
2287
- signal,
2288
- );
2289
- } catch (error) {
2290
- const pending = this.pendingTopicRootQueries.get(requestId);
2291
- if (pending) {
2292
- this.pendingTopicRootQueries.delete(requestId);
2293
- clearTimeout(pending.timer);
2294
- pending.resolve(undefined);
2435
+ try {
2436
+ const sending = withAbort(
2437
+ this.sendDirectControlMessage(
2438
+ peer,
2439
+ new TopicRootQuery({ requestId, topic }),
2440
+ querySignals.signal,
2441
+ ),
2442
+ querySignals.signal,
2443
+ );
2444
+ // A write can finish after the request is already on the wire (or stall
2445
+ // while opening the reverse stream). Let either a response or the same
2446
+ // query timeout release this await instead of extending the timeout around
2447
+ // the outbound send.
2448
+ await Promise.race([
2449
+ sending,
2450
+ responsePromise.then((): undefined => undefined),
2451
+ ]);
2452
+ } catch (error) {
2453
+ const pending = this.pendingTopicRootQueries.get(requestId);
2454
+ if (pending && !timedOut) {
2455
+ this.pendingTopicRootQueries.delete(requestId);
2456
+ clearTimeout(pending.timer);
2457
+ pending.resolve(undefined);
2458
+ }
2459
+ if (signal?.aborted) {
2460
+ throw signal.reason ?? error;
2461
+ }
2295
2462
  }
2296
- if (signal?.aborted) throw error;
2297
- }
2298
2463
 
2299
- return withAbort(responsePromise, signal);
2464
+ return await withAbort(responsePromise, signal);
2465
+ } finally {
2466
+ queryController.abort(new AbortError("topic root query settled"));
2467
+ querySignals.clear();
2468
+ }
2300
2469
  }
2301
2470
 
2302
2471
  private async confirmDirectShardRoot(
@@ -2390,7 +2559,7 @@ export class TopicControlPlane
2390
2559
 
2391
2560
  private async resolveTopicRootThroughPeers(
2392
2561
  topic: string,
2393
- options?: TopicRootResolutionOptions,
2562
+ options?: TopicRootResolutionOptions & { timeoutMs?: number },
2394
2563
  ): Promise<string | undefined> {
2395
2564
  throwIfAborted(options?.signal);
2396
2565
  const peers = this.getConnectedTopicRootTrackers();
@@ -2398,24 +2567,82 @@ export class TopicControlPlane
2398
2567
  return undefined;
2399
2568
  }
2400
2569
 
2401
- for (let attempt = 0; attempt < 3; attempt++) {
2402
- for (const peer of peers) {
2403
- const resolved = await this.queryTopicRootFromPeer(
2404
- peer,
2405
- topic,
2406
- DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS,
2407
- options?.signal,
2408
- );
2409
- if (resolved) {
2410
- return resolved;
2411
- }
2412
- }
2413
-
2414
- if (attempt < 2) {
2415
- await withAbort(delay(150 * (attempt + 1), options), options?.signal);
2416
- }
2570
+ const roundController = new AbortController();
2571
+ const roundTimeoutMs = Math.max(
2572
+ 1,
2573
+ Math.floor(options?.timeoutMs ?? DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS),
2574
+ );
2575
+ let roundTimedOut = false;
2576
+ const roundTimer = setTimeout(() => {
2577
+ roundTimedOut = true;
2578
+ roundController.abort(new AbortError("topic root query round timed out"));
2579
+ }, roundTimeoutMs);
2580
+ roundTimer.unref?.();
2581
+ const roundSignals = linkAbortSignals([
2582
+ options?.signal,
2583
+ roundController.signal,
2584
+ ]);
2585
+ const queries = peers.map((peer) =>
2586
+ this.queryTopicRootFromPeer(
2587
+ peer,
2588
+ topic,
2589
+ roundTimeoutMs,
2590
+ roundSignals.signal,
2591
+ ),
2592
+ );
2593
+ const unresolved = Symbol("unresolved topic root query");
2594
+ const results: Array<string | undefined | typeof unresolved> = peers.map(
2595
+ () => unresolved,
2596
+ );
2597
+ // All requests start together, but the stable peer ordering remains the
2598
+ // conflict-resolution policy: a later answer is usable only after every
2599
+ // earlier peer has answered undefined or reached its round timeout.
2600
+ const prioritizedResult = new Promise<string | undefined>(
2601
+ (resolve, reject) => {
2602
+ let settled = false;
2603
+ const resolveInPeerOrder = () => {
2604
+ for (const result of results) {
2605
+ if (result === unresolved) return;
2606
+ if (result !== undefined) {
2607
+ settled = true;
2608
+ resolve(result);
2609
+ return;
2610
+ }
2611
+ }
2612
+ settled = true;
2613
+ resolve(undefined);
2614
+ };
2615
+ queries.forEach((query, index) => {
2616
+ query.then(
2617
+ (root) => {
2618
+ if (settled) return;
2619
+ results[index] = root;
2620
+ resolveInPeerOrder();
2621
+ },
2622
+ (error) => {
2623
+ if (settled) return;
2624
+ if (roundTimedOut && !options?.signal?.aborted) {
2625
+ results[index] = undefined;
2626
+ resolveInPeerOrder();
2627
+ return;
2628
+ }
2629
+ settled = true;
2630
+ reject(error);
2631
+ },
2632
+ );
2633
+ });
2634
+ },
2635
+ );
2636
+ try {
2637
+ return await prioritizedResult;
2638
+ } finally {
2639
+ clearTimeout(roundTimer);
2640
+ // Cancel response timers and in-flight sends for every losing query before
2641
+ // the winning root (or an abort) can leave this round.
2642
+ roundController.abort(new AbortError("topic root query round settled"));
2643
+ await Promise.allSettled(queries);
2644
+ roundSignals.clear();
2417
2645
  }
2418
- return undefined;
2419
2646
  }
2420
2647
 
2421
2648
  private async ensureFanoutChannel(
@@ -2833,25 +3060,77 @@ export class TopicControlPlane
2833
3060
  if (!this.started) throw new NotStartedError();
2834
3061
  const lifecycleSignal = this.topicRootResolutionAbortController.signal;
2835
3062
  return this.withTopicRootCandidateResolution(
2836
- async ({ candidateGeneration, signal }) => {
3063
+ async ({ signal }) => {
2837
3064
  const joins: Promise<void>[] = [];
3065
+ const scanController = new AbortController();
3066
+ const scanTimer = setTimeout(() => {
3067
+ scanController.abort(
3068
+ new AbortError(
3069
+ `topic root host scan timed out after ${TOPIC_ROOT_HOST_SCAN_TIMEOUT_MS}ms`,
3070
+ ),
3071
+ );
3072
+ }, TOPIC_ROOT_HOST_SCAN_TIMEOUT_MS);
3073
+ scanTimer.unref?.();
3074
+ const scanSignals = linkAbortSignals([signal, scanController.signal]);
3075
+ let nextShard = 0;
3076
+ let scanFailure: { error: unknown } | undefined;
3077
+ const scanWorker = async () => {
3078
+ try {
3079
+ for (;;) {
3080
+ if (scanFailure) return;
3081
+ throwIfAborted(scanSignals.signal);
3082
+ const shard = nextShard++;
3083
+ if (shard >= this.shardCount) return;
3084
+ const shardTopic = `${this.shardTopicPrefix}${shard}`;
3085
+ const resolved = await this.resolveShardRootState(shardTopic, {
3086
+ signal: scanSignals.signal,
3087
+ // Proactive hosting follows the generation-bound local control
3088
+ // plane. Connected-peer queries remain on the lazy open path;
3089
+ // asking every peer for every shard multiplies one stale-peer
3090
+ // timeout by the complete shard count.
3091
+ queryConnectedPeers: false,
3092
+ });
3093
+ throwIfAborted(scanSignals.signal);
3094
+ if (scanFailure || resolved.root !== this.publicKeyHash) continue;
3095
+ const joining = this.ensureFanoutChannel(shardTopic, {
3096
+ pin: true,
3097
+ root: resolved.root,
3098
+ rootCandidateGeneration: resolved.candidateGeneration,
3099
+ // The shared deadline bounds root discovery only. Preserve the
3100
+ // existing contract that joins already started are drained.
3101
+ signal,
3102
+ });
3103
+ void joining.catch(() => {});
3104
+ joins.push(joining);
3105
+ }
3106
+ } catch (error) {
3107
+ if (!scanFailure) {
3108
+ scanFailure = { error };
3109
+ // Do not make a sibling resolver consume the rest of the shared
3110
+ // deadline after this scan already has a terminal failure. Joins use
3111
+ // the outer signal and are intentionally drained below.
3112
+ scanController.abort(error);
3113
+ }
3114
+ }
3115
+ };
2838
3116
  try {
2839
- for (let i = 0; i < this.shardCount; i++) {
2840
- throwIfAborted(signal);
2841
- const shardTopic = `${this.shardTopicPrefix}${i}`;
2842
- const resolved = await this.resolveShardRootState(shardTopic, {
2843
- signal,
2844
- });
2845
- if (resolved.root !== this.publicKeyHash) continue;
2846
- const joining = this.ensureFanoutChannel(shardTopic, {
2847
- pin: true,
2848
- root: resolved.root,
2849
- rootCandidateGeneration: resolved.candidateGeneration,
2850
- signal,
2851
- });
2852
- void joining.catch(() => {});
2853
- joins.push(joining);
3117
+ try {
3118
+ await Promise.all(
3119
+ Array.from(
3120
+ {
3121
+ length: Math.min(
3122
+ TOPIC_ROOT_HOST_SCAN_CONCURRENCY,
3123
+ this.shardCount,
3124
+ ),
3125
+ },
3126
+ () => scanWorker(),
3127
+ ),
3128
+ );
3129
+ } finally {
3130
+ clearTimeout(scanTimer);
3131
+ scanSignals.clear();
2854
3132
  }
3133
+ if (scanFailure) throw scanFailure.error;
2855
3134
  await Promise.all(joins);
2856
3135
  } catch (error) {
2857
3136
  await Promise.allSettled(joins);
@@ -3808,7 +4087,11 @@ export class TopicControlPlane
3808
4087
  const outerPeerHash = stream.publicKey.hashcode();
3809
4088
  for (const claim of pubsubMessage.claims) {
3810
4089
  if (
3811
- await this.importSignedTopicRootCandidateClaim(claim, outerPeerHash)
4090
+ await this.importSignedTopicRootCandidateClaim(
4091
+ claim,
4092
+ outerPeerHash,
4093
+ stream,
4094
+ )
3812
4095
  ) {
3813
4096
  imported.push(claim);
3814
4097
  }