@peerbit/pubsub 5.4.3 → 5.4.5

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.
@@ -873,6 +888,25 @@ export class TopicControlPlane
873
888
  (peer) => peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1,
874
889
  );
875
890
  const removed = await super._removePeer(publicKey);
891
+ let suppressedDepartedOrigin = false;
892
+ if (
893
+ removed &&
894
+ this.autoTopicRootCandidates &&
895
+ protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1 &&
896
+ this.signedTopicRootCandidateClaims.has(hash)
897
+ ) {
898
+ const claim = this.signedTopicRootCandidateClaims.get(hash)!;
899
+ const suppressed =
900
+ this.suppressedDepartedTopicRootCandidateClaims.get(hash);
901
+ suppressedDepartedOrigin =
902
+ !suppressed ||
903
+ suppressed.timestamp !== claim.timestamp ||
904
+ !bytesEqual(suppressed.bytes, claim.bytes);
905
+ this.suppressedDepartedTopicRootCandidateClaims.set(hash, {
906
+ bytes: claim.bytes,
907
+ timestamp: claim.timestamp,
908
+ });
909
+ }
876
910
  if (
877
911
  this.autoTopicRootCandidates &&
878
912
  (protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_0 ||
@@ -882,7 +916,7 @@ export class TopicControlPlane
882
916
  (peer) => peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1,
883
917
  );
884
918
  this.rebuildAutoTopicRootCandidatesFromClaims(BigInt(Date.now()), {
885
- immediate: hadSignedPeer !== hasSignedPeer,
919
+ immediate: suppressedDepartedOrigin || hadSignedPeer !== hasSignedPeer,
886
920
  });
887
921
  }
888
922
  return removed;
@@ -926,6 +960,7 @@ export class TopicControlPlane
926
960
  private clearSignedTopicRootCandidateState() {
927
961
  this.clearTopicRootCandidateClaimTimer();
928
962
  this.signedTopicRootCandidateClaims.clear();
963
+ this.suppressedDepartedTopicRootCandidateClaims.clear();
929
964
  this.topicRootCandidateClaimReplayFloors.clear();
930
965
  this.localSignedTopicRootCandidateClaim = undefined;
931
966
  this.topicRootCandidateClaimRefreshNotBefore = undefined;
@@ -1199,6 +1234,7 @@ export class TopicControlPlane
1199
1234
  for (const [origin, claim] of this.signedTopicRootCandidateClaims) {
1200
1235
  if (claim.expires <= now || claim.acceptUntil <= monotonicNow) {
1201
1236
  this.signedTopicRootCandidateClaims.delete(origin);
1237
+ this.suppressedDepartedTopicRootCandidateClaims.delete(origin);
1202
1238
  changed = true;
1203
1239
  }
1204
1240
  }
@@ -1223,7 +1259,12 @@ export class TopicControlPlane
1223
1259
  ? []
1224
1260
  : [this.publicKeyHash]),
1225
1261
  ...legacyDirectCandidates,
1226
- ...this.signedTopicRootCandidateClaims.keys(),
1262
+ ...[...this.signedTopicRootCandidateClaims.entries()]
1263
+ .filter(
1264
+ ([origin, claim]) =>
1265
+ !this.isDepartedTopicRootCandidateClaimSuppressed(origin, claim),
1266
+ )
1267
+ .map(([origin]) => origin),
1227
1268
  ]),
1228
1269
  ]
1229
1270
  .filter(isCanonicalTopicRootCandidate)
@@ -1268,6 +1309,7 @@ export class TopicControlPlane
1268
1309
  // because the retained maximum only decreases, it cannot re-enter.
1269
1310
  this.topicRootCandidateClaimReplayFloors.delete(highest);
1270
1311
  this.signedTopicRootCandidateClaims.delete(highest);
1312
+ this.suppressedDepartedTopicRootCandidateClaims.delete(highest);
1271
1313
  if (highest === this.publicKeyHash) {
1272
1314
  this.localSignedTopicRootCandidateClaim = undefined;
1273
1315
  }
@@ -1275,9 +1317,22 @@ export class TopicControlPlane
1275
1317
  }
1276
1318
  this.topicRootCandidateClaimReplayFloors.set(origin, claim.timestamp);
1277
1319
  this.signedTopicRootCandidateClaims.set(origin, claim);
1320
+ this.suppressedDepartedTopicRootCandidateClaims.delete(origin);
1278
1321
  return true;
1279
1322
  }
1280
1323
 
1324
+ private isDepartedTopicRootCandidateClaimSuppressed(
1325
+ origin: string,
1326
+ claim: TopicRootCandidateClaimRecord,
1327
+ ): boolean {
1328
+ const suppressed =
1329
+ this.suppressedDepartedTopicRootCandidateClaims.get(origin);
1330
+ return (
1331
+ suppressed?.timestamp === claim.timestamp &&
1332
+ bytesEqual(suppressed.bytes, claim.bytes)
1333
+ );
1334
+ }
1335
+
1281
1336
  private canAdvanceTopicRootCandidateClaimReplayFloor(
1282
1337
  origin: string,
1283
1338
  timestamp: bigint,
@@ -1354,6 +1409,7 @@ export class TopicControlPlane
1354
1409
  private async importSignedTopicRootCandidateClaim(
1355
1410
  rawClaim: Uint8Array,
1356
1411
  outerPeerHash: string,
1412
+ expectedPeer?: PeerStreams,
1357
1413
  ): Promise<boolean> {
1358
1414
  const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
1359
1415
  if (
@@ -1398,11 +1454,35 @@ export class TopicControlPlane
1398
1454
  if (acceptanceDeadline === undefined) {
1399
1455
  return false;
1400
1456
  }
1457
+
1458
+ this.rebuildAutoTopicRootCandidatesFromClaims(now);
1459
+ const retained = this.signedTopicRootCandidateClaims.get(origin);
1460
+ if (
1461
+ outerPeerHash === origin &&
1462
+ expectedPeer !== undefined &&
1463
+ expectedPeer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1 &&
1464
+ this.peers.get(outerPeerHash) === expectedPeer &&
1465
+ lifecycleRevision === this.topicControlPlaneLifecycleRevision &&
1466
+ this.autoTopicRootCandidates &&
1467
+ this.started &&
1468
+ !this.stopping &&
1469
+ !this.topicControlPlaneStopping &&
1470
+ retained?.timestamp === timestamp &&
1471
+ bytesEqual(retained.bytes, rawClaim) &&
1472
+ this.isDepartedTopicRootCandidateClaimSuppressed(origin, retained)
1473
+ ) {
1474
+ // These exact bytes were already verified and remain under the original
1475
+ // monotonic acceptance deadline. A current authenticated origin stream is
1476
+ // sufficient liveness evidence; do not advance the floor or lease.
1477
+ this.suppressedDepartedTopicRootCandidateClaims.delete(origin);
1478
+ this.rebuildAutoTopicRootCandidatesFromClaims(now, { immediate: true });
1479
+ return false;
1480
+ }
1481
+
1401
1482
  if (!this.canAdvanceTopicRootCandidateClaimReplayFloor(origin, timestamp)) {
1402
1483
  return false;
1403
1484
  }
1404
1485
 
1405
- this.rebuildAutoTopicRootCandidatesFromClaims(now);
1406
1486
  if (!this.consumeTopicRootCandidateClaimVerifyBudget(outerPeerHash)) {
1407
1487
  return false;
1408
1488
  }
@@ -1425,7 +1505,9 @@ export class TopicControlPlane
1425
1505
  !this.autoTopicRootCandidates ||
1426
1506
  !this.started ||
1427
1507
  this.stopping ||
1428
- this.topicControlPlaneStopping
1508
+ this.topicControlPlaneStopping ||
1509
+ (expectedPeer !== undefined &&
1510
+ this.peers.get(outerPeerHash) !== expectedPeer)
1429
1511
  ) {
1430
1512
  return false;
1431
1513
  }
@@ -1499,6 +1581,10 @@ export class TopicControlPlane
1499
1581
  }
1500
1582
  }
1501
1583
  const claims = [...this.signedTopicRootCandidateClaims.entries()]
1584
+ .filter(
1585
+ ([origin, claim]) =>
1586
+ !this.isDepartedTopicRootCandidateClaimSuppressed(origin, claim),
1587
+ )
1502
1588
  .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
1503
1589
  .map(([, claim]) => claim.bytes);
1504
1590
  await this.sendSignedTopicRootCandidateClaims(claims, signedStreams);
@@ -2062,7 +2148,9 @@ export class TopicControlPlane
2062
2148
 
2063
2149
  private async resolveTopicRootState(
2064
2150
  topic: string,
2065
- options?: TopicRootResolutionOptions,
2151
+ options?: TopicRootResolutionOptions & {
2152
+ queryConnectedPeers?: boolean;
2153
+ },
2066
2154
  ): Promise<{ root?: string; authoritative: boolean }> {
2067
2155
  throwIfAborted(options?.signal);
2068
2156
  const tracked = await this.topicRootControlPlane.resolveTrackedTopicRoot(
@@ -2072,37 +2160,80 @@ export class TopicControlPlane
2072
2160
  if (tracked) {
2073
2161
  return { root: tracked, authoritative: true };
2074
2162
  }
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);
2163
+ if (options?.queryConnectedPeers === false) {
2164
+ return {
2165
+ root: this.topicRootControlPlane.resolveDeterministicTopicRoot(topic),
2166
+ authoritative: false,
2167
+ };
2087
2168
  }
2088
2169
 
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,
2170
+ const deadlineController = new AbortController();
2171
+ const deadlineTimer = setTimeout(
2172
+ () =>
2173
+ deadlineController.abort(
2174
+ new AbortError("topic root peer-query deadline reached"),
2175
+ ),
2176
+ DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS,
2177
+ );
2178
+ deadlineTimer.unref?.();
2179
+ const querySignals = linkAbortSignals([
2180
+ options?.signal,
2181
+ deadlineController.signal,
2182
+ ]);
2183
+ try {
2184
+ let attempt = 0;
2185
+ for (;;) {
2186
+ throwIfAborted(querySignals.signal);
2187
+ // Preserve the immediate deterministic fallback when no queryable stream
2188
+ // exists at entry. Once a peer was queryable, keep the bounded readiness
2189
+ // window alive: later rounds re-snapshot streams that can become writable
2190
+ // after an inbound-only dial.
2191
+ if (
2192
+ attempt === 0 &&
2193
+ this.getConnectedTopicRootTrackers().length === 0
2194
+ ) {
2195
+ break;
2196
+ }
2197
+ if (attempt > 0) {
2198
+ await withAbort(
2199
+ delay(150 * Math.min(attempt, 2), {
2200
+ signal: querySignals.signal,
2201
+ }),
2202
+ querySignals.signal,
2203
+ );
2204
+ }
2205
+ attempt += 1;
2206
+ const resolvedThroughPeers = await this.resolveTopicRootThroughPeers(
2207
+ topic,
2208
+ {
2209
+ signal: querySignals.signal,
2210
+ timeoutMs: TOPIC_ROOT_QUERY_ROUND_TIMEOUT_MS,
2211
+ },
2100
2212
  );
2101
- const retried = await this.resolveTopicRootThroughPeers(topic, options);
2102
- if (retried) {
2103
- return this.normalizePeerTopicRootState(topic, retried);
2213
+ if (resolvedThroughPeers !== undefined) {
2214
+ // Unconfigured peer-query replies cannot override the locally
2215
+ // deterministic root for internal shards in auto mode. Roots,
2216
+ // resolvers, and trackers explicitly configured on this control
2217
+ // plane are resolved above and remain authoritative. A leaf relying
2218
+ // on a queried gateway for shard roots must first disable auto mode
2219
+ // with setTopicRootCandidates([]).
2220
+ return this.normalizePeerTopicRootState(topic, resolvedThroughPeers);
2104
2221
  }
2105
2222
  }
2223
+ } catch (error) {
2224
+ // Candidate, lifecycle, and caller cancellation are terminal to this
2225
+ // invocation and must retain their exact reason. Only the private phase
2226
+ // deadline falls through to the same deterministic result as exhausted
2227
+ // peer queries.
2228
+ if (options?.signal?.aborted) {
2229
+ throw options.signal.reason ?? error;
2230
+ }
2231
+ if (!deadlineController.signal.aborted) {
2232
+ throw error;
2233
+ }
2234
+ } finally {
2235
+ clearTimeout(deadlineTimer);
2236
+ querySignals.clear();
2106
2237
  }
2107
2238
 
2108
2239
  return {
@@ -2128,7 +2259,9 @@ export class TopicControlPlane
2128
2259
 
2129
2260
  private async resolveShardRootState(
2130
2261
  shardTopic: string,
2131
- options?: TopicRootResolutionOptions,
2262
+ options?: TopicRootResolutionOptions & {
2263
+ queryConnectedPeers?: boolean;
2264
+ },
2132
2265
  ): Promise<{ root: string; candidateGeneration: string }> {
2133
2266
  const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
2134
2267
  for (;;) {
@@ -2143,8 +2276,14 @@ export class TopicControlPlane
2143
2276
 
2144
2277
  const candidateGeneration = this.getTopicRootCandidateGeneration();
2145
2278
  const hasConnectedTrackers =
2279
+ options?.queryConnectedPeers !== false &&
2146
2280
  this.getConnectedTopicRootTrackers().length > 0;
2147
- const cached = this.shardRootCache.get(shardTopic);
2281
+ // A proactive local scan must not inherit an authoritative cache entry that
2282
+ // originated from a connected-peer reply under the same candidate generation.
2283
+ const cached =
2284
+ options?.queryConnectedPeers === false
2285
+ ? undefined
2286
+ : this.shardRootCache.get(shardTopic);
2148
2287
  if (cached && (cached.authoritative || !hasConnectedTrackers)) {
2149
2288
  return { root: cached.root, candidateGeneration };
2150
2289
  }
@@ -2201,6 +2340,7 @@ export class TopicControlPlane
2201
2340
  private async sendDirectControlMessage(
2202
2341
  peer: PeerStreams,
2203
2342
  pubsubMessage: PubSubMessage,
2343
+ signal?: AbortSignal,
2204
2344
  ) {
2205
2345
  const embedded = await this.createMessage(
2206
2346
  this.encodePubSubMessage(pubsubMessage),
@@ -2213,7 +2353,13 @@ export class TopicControlPlane
2213
2353
  skipRecipientValidation: true,
2214
2354
  } as any,
2215
2355
  );
2216
- await this.publishMessage(this.publicKey, embedded, [peer]);
2356
+ await this.publishMessage(
2357
+ this.publicKey,
2358
+ embedded,
2359
+ [peer],
2360
+ undefined,
2361
+ signal,
2362
+ );
2217
2363
  }
2218
2364
 
2219
2365
  private resolvePendingTopicRootQuery(
@@ -2245,6 +2391,9 @@ export class TopicControlPlane
2245
2391
 
2246
2392
  const expectedPeerHash = peer.publicKey.hashcode();
2247
2393
  const requestId = this.nextTopicRootRequestIdValue();
2394
+ const queryController = new AbortController();
2395
+ const querySignals = linkAbortSignals([signal, queryController.signal]);
2396
+ let timedOut = false;
2248
2397
  const responsePromise = new Promise<string | undefined>((resolve) => {
2249
2398
  let settled = false;
2250
2399
  const settle = (root: string | undefined) => {
@@ -2255,14 +2404,16 @@ export class TopicControlPlane
2255
2404
  this.pendingTopicRootQueries.delete(requestId);
2256
2405
  }
2257
2406
  clearTimeout(timer);
2258
- if (signal && onAbort) {
2259
- signal.removeEventListener("abort", onAbort);
2260
- }
2407
+ querySignals.signal.removeEventListener("abort", onAbort);
2261
2408
  resolve(root);
2262
2409
  };
2263
- const onAbort = signal ? () => settle(undefined) : undefined;
2410
+ const onAbort = () => settle(undefined);
2264
2411
  const timer = setTimeout(
2265
- () => settle(undefined),
2412
+ () => {
2413
+ timedOut = true;
2414
+ settle(undefined);
2415
+ queryController.abort(new AbortError("topic root query timed out"));
2416
+ },
2266
2417
  Math.max(1, Math.floor(timeoutMs)),
2267
2418
  );
2268
2419
  timer.unref?.();
@@ -2272,31 +2423,45 @@ export class TopicControlPlane
2272
2423
  resolve: settle,
2273
2424
  timer,
2274
2425
  });
2275
- if (signal && onAbort) {
2276
- signal.addEventListener("abort", onAbort, { once: true });
2277
- if (signal.aborted) onAbort();
2278
- }
2426
+ querySignals.signal.addEventListener("abort", onAbort, { once: true });
2427
+ if (querySignals.signal.aborted) onAbort();
2279
2428
  });
2280
2429
 
2281
2430
  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);
2431
+ try {
2432
+ const sending = withAbort(
2433
+ this.sendDirectControlMessage(
2434
+ peer,
2435
+ new TopicRootQuery({ requestId, topic }),
2436
+ querySignals.signal,
2437
+ ),
2438
+ querySignals.signal,
2439
+ );
2440
+ // A write can finish after the request is already on the wire (or stall
2441
+ // while opening the reverse stream). Let either a response or the same
2442
+ // query timeout release this await instead of extending the timeout around
2443
+ // the outbound send.
2444
+ await Promise.race([
2445
+ sending,
2446
+ responsePromise.then((): undefined => undefined),
2447
+ ]);
2448
+ } catch (error) {
2449
+ const pending = this.pendingTopicRootQueries.get(requestId);
2450
+ if (pending && !timedOut) {
2451
+ this.pendingTopicRootQueries.delete(requestId);
2452
+ clearTimeout(pending.timer);
2453
+ pending.resolve(undefined);
2454
+ }
2455
+ if (signal?.aborted) {
2456
+ throw signal.reason ?? error;
2457
+ }
2295
2458
  }
2296
- if (signal?.aborted) throw error;
2297
- }
2298
2459
 
2299
- return withAbort(responsePromise, signal);
2460
+ return await withAbort(responsePromise, signal);
2461
+ } finally {
2462
+ queryController.abort(new AbortError("topic root query settled"));
2463
+ querySignals.clear();
2464
+ }
2300
2465
  }
2301
2466
 
2302
2467
  private async confirmDirectShardRoot(
@@ -2390,7 +2555,7 @@ export class TopicControlPlane
2390
2555
 
2391
2556
  private async resolveTopicRootThroughPeers(
2392
2557
  topic: string,
2393
- options?: TopicRootResolutionOptions,
2558
+ options?: TopicRootResolutionOptions & { timeoutMs?: number },
2394
2559
  ): Promise<string | undefined> {
2395
2560
  throwIfAborted(options?.signal);
2396
2561
  const peers = this.getConnectedTopicRootTrackers();
@@ -2398,24 +2563,82 @@ export class TopicControlPlane
2398
2563
  return undefined;
2399
2564
  }
2400
2565
 
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
- }
2566
+ const roundController = new AbortController();
2567
+ const roundTimeoutMs = Math.max(
2568
+ 1,
2569
+ Math.floor(options?.timeoutMs ?? DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS),
2570
+ );
2571
+ let roundTimedOut = false;
2572
+ const roundTimer = setTimeout(() => {
2573
+ roundTimedOut = true;
2574
+ roundController.abort(new AbortError("topic root query round timed out"));
2575
+ }, roundTimeoutMs);
2576
+ roundTimer.unref?.();
2577
+ const roundSignals = linkAbortSignals([
2578
+ options?.signal,
2579
+ roundController.signal,
2580
+ ]);
2581
+ const queries = peers.map((peer) =>
2582
+ this.queryTopicRootFromPeer(
2583
+ peer,
2584
+ topic,
2585
+ roundTimeoutMs,
2586
+ roundSignals.signal,
2587
+ ),
2588
+ );
2589
+ const unresolved = Symbol("unresolved topic root query");
2590
+ const results: Array<string | undefined | typeof unresolved> = peers.map(
2591
+ () => unresolved,
2592
+ );
2593
+ // All requests start together, but the stable peer ordering remains the
2594
+ // conflict-resolution policy: a later answer is usable only after every
2595
+ // earlier peer has answered undefined or reached its round timeout.
2596
+ const prioritizedResult = new Promise<string | undefined>(
2597
+ (resolve, reject) => {
2598
+ let settled = false;
2599
+ const resolveInPeerOrder = () => {
2600
+ for (const result of results) {
2601
+ if (result === unresolved) return;
2602
+ if (result !== undefined) {
2603
+ settled = true;
2604
+ resolve(result);
2605
+ return;
2606
+ }
2607
+ }
2608
+ settled = true;
2609
+ resolve(undefined);
2610
+ };
2611
+ queries.forEach((query, index) => {
2612
+ query.then(
2613
+ (root) => {
2614
+ if (settled) return;
2615
+ results[index] = root;
2616
+ resolveInPeerOrder();
2617
+ },
2618
+ (error) => {
2619
+ if (settled) return;
2620
+ if (roundTimedOut && !options?.signal?.aborted) {
2621
+ results[index] = undefined;
2622
+ resolveInPeerOrder();
2623
+ return;
2624
+ }
2625
+ settled = true;
2626
+ reject(error);
2627
+ },
2628
+ );
2629
+ });
2630
+ },
2631
+ );
2632
+ try {
2633
+ return await prioritizedResult;
2634
+ } finally {
2635
+ clearTimeout(roundTimer);
2636
+ // Cancel response timers and in-flight sends for every losing query before
2637
+ // the winning root (or an abort) can leave this round.
2638
+ roundController.abort(new AbortError("topic root query round settled"));
2639
+ await Promise.allSettled(queries);
2640
+ roundSignals.clear();
2417
2641
  }
2418
- return undefined;
2419
2642
  }
2420
2643
 
2421
2644
  private async ensureFanoutChannel(
@@ -2833,25 +3056,77 @@ export class TopicControlPlane
2833
3056
  if (!this.started) throw new NotStartedError();
2834
3057
  const lifecycleSignal = this.topicRootResolutionAbortController.signal;
2835
3058
  return this.withTopicRootCandidateResolution(
2836
- async ({ candidateGeneration, signal }) => {
3059
+ async ({ signal }) => {
2837
3060
  const joins: Promise<void>[] = [];
3061
+ const scanController = new AbortController();
3062
+ const scanTimer = setTimeout(() => {
3063
+ scanController.abort(
3064
+ new AbortError(
3065
+ `topic root host scan timed out after ${TOPIC_ROOT_HOST_SCAN_TIMEOUT_MS}ms`,
3066
+ ),
3067
+ );
3068
+ }, TOPIC_ROOT_HOST_SCAN_TIMEOUT_MS);
3069
+ scanTimer.unref?.();
3070
+ const scanSignals = linkAbortSignals([signal, scanController.signal]);
3071
+ let nextShard = 0;
3072
+ let scanFailure: { error: unknown } | undefined;
3073
+ const scanWorker = async () => {
3074
+ try {
3075
+ for (;;) {
3076
+ if (scanFailure) return;
3077
+ throwIfAborted(scanSignals.signal);
3078
+ const shard = nextShard++;
3079
+ if (shard >= this.shardCount) return;
3080
+ const shardTopic = `${this.shardTopicPrefix}${shard}`;
3081
+ const resolved = await this.resolveShardRootState(shardTopic, {
3082
+ signal: scanSignals.signal,
3083
+ // Proactive hosting follows the generation-bound local control
3084
+ // plane. Connected-peer queries remain on the lazy open path;
3085
+ // asking every peer for every shard multiplies one stale-peer
3086
+ // timeout by the complete shard count.
3087
+ queryConnectedPeers: false,
3088
+ });
3089
+ throwIfAborted(scanSignals.signal);
3090
+ if (scanFailure || resolved.root !== this.publicKeyHash) continue;
3091
+ const joining = this.ensureFanoutChannel(shardTopic, {
3092
+ pin: true,
3093
+ root: resolved.root,
3094
+ rootCandidateGeneration: resolved.candidateGeneration,
3095
+ // The shared deadline bounds root discovery only. Preserve the
3096
+ // existing contract that joins already started are drained.
3097
+ signal,
3098
+ });
3099
+ void joining.catch(() => {});
3100
+ joins.push(joining);
3101
+ }
3102
+ } catch (error) {
3103
+ if (!scanFailure) {
3104
+ scanFailure = { error };
3105
+ // Do not make a sibling resolver consume the rest of the shared
3106
+ // deadline after this scan already has a terminal failure. Joins use
3107
+ // the outer signal and are intentionally drained below.
3108
+ scanController.abort(error);
3109
+ }
3110
+ }
3111
+ };
2838
3112
  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);
3113
+ try {
3114
+ await Promise.all(
3115
+ Array.from(
3116
+ {
3117
+ length: Math.min(
3118
+ TOPIC_ROOT_HOST_SCAN_CONCURRENCY,
3119
+ this.shardCount,
3120
+ ),
3121
+ },
3122
+ () => scanWorker(),
3123
+ ),
3124
+ );
3125
+ } finally {
3126
+ clearTimeout(scanTimer);
3127
+ scanSignals.clear();
2854
3128
  }
3129
+ if (scanFailure) throw scanFailure.error;
2855
3130
  await Promise.all(joins);
2856
3131
  } catch (error) {
2857
3132
  await Promise.allSettled(joins);
@@ -3808,7 +4083,11 @@ export class TopicControlPlane
3808
4083
  const outerPeerHash = stream.publicKey.hashcode();
3809
4084
  for (const claim of pubsubMessage.claims) {
3810
4085
  if (
3811
- await this.importSignedTopicRootCandidateClaim(claim, outerPeerHash)
4086
+ await this.importSignedTopicRootCandidateClaim(
4087
+ claim,
4088
+ outerPeerHash,
4089
+ stream,
4090
+ )
3812
4091
  ) {
3813
4092
  imported.push(claim);
3814
4093
  }