@peerbit/pubsub 5.4.4 → 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.
@@ -586,8 +586,9 @@ export type FanoutTreeJoinOptions = {
586
586
  /**
587
587
  * Candidate scoring mode for selecting parent join targets.
588
588
  *
589
- * - `ranked-shuffle` (default): rank by (level, freeSlots, bid, source) and
590
- * shuffle within `candidateShuffleTopK` to spread load.
589
+ * - `ranked-shuffle` (default): rank by (level, freeSlots, bid, source),
590
+ * shuffle within `candidateShuffleTopK` to spread load, then promote at
591
+ * most one already-usable peer for a low-latency first attempt.
591
592
  * - `ranked-strict`: try ranked candidates in order (no shuffle).
592
593
  * - `weighted`: weighted shuffle within `candidateShuffleTopK` using
593
594
  * `candidateScoringWeights` (defaults bias low level + free slots).
@@ -730,6 +731,18 @@ export type FanoutTreeChannelMetrics = {
730
731
  joinRejectSent: number;
731
732
  joinRejectReceived: number;
732
733
  joinPeerResets: number;
734
+ /**
735
+ * Cold-join diagnostics. Implementations populate every counter, while the
736
+ * optional boundary keeps older external metrics fixtures source-compatible.
737
+ */
738
+ joinBootstrapDialAttempts?: number;
739
+ joinBootstrapDialFailures?: number;
740
+ joinCandidateDialAttempts?: number;
741
+ joinCandidateDialFailures?: number;
742
+ joinConnectedCandidateAttempts?: number;
743
+ joinUnconnectedCandidateAttempts?: number;
744
+ joinReqTimeouts?: number;
745
+ joinDeadlineExpirations?: number;
733
746
  kickSent: number;
734
747
  kickReceived: number;
735
748
  reparentDisconnect: number;
@@ -808,6 +821,17 @@ export type FanoutTreeChannelMetrics = {
808
821
  routeProxyRejected: number;
809
822
  };
810
823
 
824
+ type InternalFanoutTreeChannelMetrics = FanoutTreeChannelMetrics & {
825
+ joinBootstrapDialAttempts: number;
826
+ joinBootstrapDialFailures: number;
827
+ joinCandidateDialAttempts: number;
828
+ joinCandidateDialFailures: number;
829
+ joinConnectedCandidateAttempts: number;
830
+ joinUnconnectedCandidateAttempts: number;
831
+ joinReqTimeouts: number;
832
+ joinDeadlineExpirations: number;
833
+ };
834
+
811
835
  export interface FanoutTreeEvents extends StreamEvents {
812
836
  "fanout:data": CustomEvent<FanoutTreeDataEvent>;
813
837
  "fanout:unicast": CustomEvent<FanoutTreeUnicastEvent>;
@@ -990,6 +1014,13 @@ type JoinAttemptResult = {
990
1014
  redirects?: Array<{ hash: string; addrs: Multiaddr[] }>;
991
1015
  };
992
1016
 
1017
+ type JoinDialDiagnostics = {
1018
+ metrics: InternalFanoutTreeChannelMetrics;
1019
+ deadlineAt?: number;
1020
+ preferConnected?: boolean;
1021
+ excludeReadyPeerHashes?: Set<string>;
1022
+ };
1023
+
993
1024
  type PendingUnicastAck = {
994
1025
  expectedOrigin: string;
995
1026
  resolve: () => void;
@@ -1001,7 +1032,7 @@ type PendingUnicastAck = {
1001
1032
 
1002
1033
  type ChannelState = {
1003
1034
  id: FanoutTreeChannelId;
1004
- metrics: FanoutTreeChannelMetrics;
1035
+ metrics: InternalFanoutTreeChannelMetrics;
1005
1036
  level: number;
1006
1037
  isRoot: boolean;
1007
1038
  closeController: AbortController;
@@ -1212,7 +1243,7 @@ const createDeferred = (): {
1212
1243
  return { resolve, reject, promise };
1213
1244
  };
1214
1245
 
1215
- const createEmptyMetrics = (): FanoutTreeChannelMetrics => ({
1246
+ const createEmptyMetrics = (): InternalFanoutTreeChannelMetrics => ({
1216
1247
  controlSends: 0,
1217
1248
  controlBytesSent: 0,
1218
1249
  controlReceives: 0,
@@ -1238,6 +1269,14 @@ const createEmptyMetrics = (): FanoutTreeChannelMetrics => ({
1238
1269
  joinRejectSent: 0,
1239
1270
  joinRejectReceived: 0,
1240
1271
  joinPeerResets: 0,
1272
+ joinBootstrapDialAttempts: 0,
1273
+ joinBootstrapDialFailures: 0,
1274
+ joinCandidateDialAttempts: 0,
1275
+ joinCandidateDialFailures: 0,
1276
+ joinConnectedCandidateAttempts: 0,
1277
+ joinUnconnectedCandidateAttempts: 0,
1278
+ joinReqTimeouts: 0,
1279
+ joinDeadlineExpirations: 0,
1241
1280
  kickSent: 0,
1242
1281
  kickReceived: 0,
1243
1282
  reparentDisconnect: 0,
@@ -1324,7 +1363,7 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
1324
1363
  private readonly cachedSuffixKey = new WeakMap<Uint8Array, string>();
1325
1364
  private readonly metricsBySuffixKey = new Map<
1326
1365
  string,
1327
- FanoutTreeChannelMetrics
1366
+ InternalFanoutTreeChannelMetrics
1328
1367
  >();
1329
1368
  private readonly joinTimeoutStreakByPeer = new Map<string, number>();
1330
1369
  private readonly joinResetCooldownUntilByPeer = new Map<string, number>();
@@ -3943,7 +3982,9 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
3943
3982
  return key;
3944
3983
  }
3945
3984
 
3946
- private getMetricsForSuffixKey(suffixKey: string): FanoutTreeChannelMetrics {
3985
+ private getMetricsForSuffixKey(
3986
+ suffixKey: string,
3987
+ ): InternalFanoutTreeChannelMetrics {
3947
3988
  let m = this.metricsBySuffixKey.get(suffixKey);
3948
3989
  if (!m) {
3949
3990
  m = createEmptyMetrics();
@@ -4160,7 +4201,14 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
4160
4201
  }
4161
4202
  }
4162
4203
 
4163
- private async _sendControl(to: string, bytes: Uint8Array) {
4204
+ private async _sendControl(
4205
+ to: string,
4206
+ bytes: Uint8Array,
4207
+ signal?: AbortSignal,
4208
+ ) {
4209
+ if (signal?.aborted) {
4210
+ throw signal.reason ?? new AbortError("fanout control send aborted");
4211
+ }
4164
4212
  const stream = this.peers.get(to);
4165
4213
  if (!stream) return;
4166
4214
  this.recordControlSend(bytes, 1);
@@ -4168,7 +4216,16 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
4168
4216
  mode: new AnyWhere(),
4169
4217
  priority: CONTROL_PRIORITY,
4170
4218
  } as any);
4171
- await this.publishMessageMaybe(this.publicKey, message, [stream]);
4219
+ if (signal?.aborted) {
4220
+ throw signal.reason ?? new AbortError("fanout control send aborted");
4221
+ }
4222
+ await this.publishMessageMaybe(
4223
+ this.publicKey,
4224
+ message,
4225
+ [stream],
4226
+ undefined,
4227
+ signal,
4228
+ );
4172
4229
  }
4173
4230
 
4174
4231
  private async _sendControlMany(to: string[], bytes: Uint8Array) {
@@ -4894,35 +4951,157 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
4894
4951
  return out;
4895
4952
  }
4896
4953
 
4954
+ private isPeerReadyForJoin(hash: string): boolean {
4955
+ const stream = this.peers.get(hash);
4956
+ if (!stream || !stream.isReadable || !stream.isWritable) return false;
4957
+ try {
4958
+ return (
4959
+ this.components.connectionManager.getConnections(stream.peerId).length > 0
4960
+ );
4961
+ } catch {
4962
+ // Test/mocked connection managers may not expose peer-scoped snapshots.
4963
+ return true;
4964
+ }
4965
+ }
4966
+
4967
+ private connectedPeerHashForBootstrap(
4968
+ address: Multiaddr,
4969
+ ): string | undefined {
4970
+ const peerId = address
4971
+ .getComponents()
4972
+ .filter((component) => component.name === "p2p")
4973
+ .at(-1)?.value;
4974
+ if (!peerId) return;
4975
+ for (const [hash, stream] of this.peers) {
4976
+ if (stream.peerId.toString() !== peerId) continue;
4977
+ if (this.isPeerReadyForJoin(hash)) return hash;
4978
+ }
4979
+ return;
4980
+ }
4981
+
4982
+ private createBoundedDialAttempt(
4983
+ signal: AbortSignal,
4984
+ timeoutMs: number,
4985
+ deadlineAt?: number,
4986
+ ):
4987
+ | {
4988
+ signal: AbortSignal;
4989
+ timeoutMs: number;
4990
+ clear: () => void;
4991
+ }
4992
+ | undefined {
4993
+ if (signal.aborted) return;
4994
+ const remainingMs =
4995
+ deadlineAt == null
4996
+ ? Number.POSITIVE_INFINITY
4997
+ : Math.max(0, deadlineAt - Date.now());
4998
+ if (remainingMs <= 0) return;
4999
+ const boundedTimeoutMs = Math.max(
5000
+ 1,
5001
+ Math.min(Math.max(1, Math.floor(timeoutMs)), remainingMs),
5002
+ );
5003
+ const timeoutSignal = AbortSignal.timeout(boundedTimeoutMs);
5004
+ const combined = anySignal([signal, timeoutSignal]) as AbortSignal & {
5005
+ clear?: () => void;
5006
+ };
5007
+ return {
5008
+ signal: combined,
5009
+ timeoutMs: boundedTimeoutMs,
5010
+ clear: () => combined.clear?.(),
5011
+ };
5012
+ }
5013
+
4897
5014
  private async ensureBootstrapPeers(
4898
5015
  addrs: Multiaddr[],
4899
5016
  timeoutMs: number,
4900
5017
  signal: AbortSignal,
4901
5018
  maxPeers = 0,
5019
+ diagnostics?: JoinDialDiagnostics,
4902
5020
  ): Promise<string[]> {
4903
5021
  if (addrs.length === 0) return [];
4904
5022
  const max = Math.max(0, Math.floor(maxPeers));
4905
- const shuffled = addrs.slice();
5023
+ const connected: string[] = [];
5024
+ const disconnected: Multiaddr[] = [];
5025
+ const connectedSeen = new Set<string>();
5026
+ for (const address of addrs) {
5027
+ const readyHash = diagnostics
5028
+ ? this.connectedPeerHashForBootstrap(address)
5029
+ : undefined;
5030
+ if (
5031
+ readyHash &&
5032
+ diagnostics?.excludeReadyPeerHashes?.has(readyHash)
5033
+ ) {
5034
+ continue;
5035
+ }
5036
+ const hash = diagnostics?.preferConnected ? readyHash : undefined;
5037
+ if (!hash) {
5038
+ disconnected.push(address);
5039
+ continue;
5040
+ }
5041
+ if (!connectedSeen.has(hash)) {
5042
+ connectedSeen.add(hash);
5043
+ connected.push(hash);
5044
+ }
5045
+ }
5046
+ const connectedLimit =
5047
+ max > 0 ? Math.min(max, connected.length) : connected.length;
5048
+ const out = connected.slice(0, connectedLimit);
5049
+ if (diagnostics?.preferConnected && out.length > 0) return out;
5050
+
5051
+ const shuffled = disconnected.slice();
4906
5052
  for (let i = shuffled.length - 1; i > 0; i--) {
4907
5053
  const j = Math.floor(this.random() * (i + 1));
4908
5054
  const tmp = shuffled[i]!;
4909
5055
  shuffled[i] = shuffled[j]!;
4910
5056
  shuffled[j] = tmp;
4911
5057
  }
4912
- const target = max > 0 ? Math.min(max, shuffled.length) : shuffled.length;
4913
- const out: string[] = [];
5058
+ const target = max > 0 ? Math.min(max, addrs.length) : addrs.length;
4914
5059
  for (const a of shuffled) {
4915
5060
  if (signal.aborted) break;
4916
5061
  if (target > 0 && out.length >= target) break;
5062
+ const attempt = diagnostics
5063
+ ? this.createBoundedDialAttempt(
5064
+ signal,
5065
+ timeoutMs,
5066
+ diagnostics.deadlineAt,
5067
+ )
5068
+ : undefined;
5069
+ if (diagnostics && !attempt) break;
5070
+ if (diagnostics) diagnostics.metrics.joinBootstrapDialAttempts += 1;
5071
+ let ready = false;
5072
+ let skippedExcluded = false;
4917
5073
  try {
4918
- const conn = await this.components.connectionManager.openConnection(a);
5074
+ const conn = attempt
5075
+ ? await this.components.connectionManager.openConnection(a, {
5076
+ signal: attempt.signal,
5077
+ })
5078
+ : await this.components.connectionManager.openConnection(a);
4919
5079
  const h = getPublicKeyFromPeerId(conn.remotePeer).hashcode();
4920
- await this.waitFor(h, { seek: "present", timeout: timeoutMs, signal });
4921
- out.push(h);
5080
+ await this.waitFor(h, {
5081
+ seek: "present",
5082
+ timeout: attempt?.timeoutMs ?? timeoutMs,
5083
+ signal: attempt?.signal ?? signal,
5084
+ });
5085
+ skippedExcluded =
5086
+ diagnostics?.excludeReadyPeerHashes?.has(h) === true;
5087
+ ready =
5088
+ !skippedExcluded &&
5089
+ (diagnostics ? this.isPeerReadyForJoin(h) : true);
5090
+ if (ready) out.push(h);
4922
5091
  } catch {
4923
5092
  // ignore dial failures
5093
+ } finally {
5094
+ attempt?.clear();
5095
+ if (!ready && !skippedExcluded && diagnostics) {
5096
+ diagnostics.metrics.joinBootstrapDialFailures += 1;
5097
+ }
4924
5098
  }
5099
+ if (ready && diagnostics?.preferConnected) break;
4925
5100
  }
5101
+ // Exhaustion completes one bounded pass over the configured bootstrap set.
5102
+ // Allow a later round to revisit ready trackers because their candidate view
5103
+ // may have changed while this cold join was progressing.
5104
+ if (out.length === 0) diagnostics?.excludeReadyPeerHashes?.clear();
4926
5105
  return [...new Set(out)];
4927
5106
  }
4928
5107
 
@@ -5351,21 +5530,44 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
5351
5530
  addrs: Multiaddr[],
5352
5531
  timeoutMs: number,
5353
5532
  signal: AbortSignal,
5533
+ diagnostics?: JoinDialDiagnostics,
5354
5534
  ): Promise<boolean> {
5355
- if (this.peers.get(hash)) return true;
5535
+ if (this.isPeerReadyForJoin(hash)) return true;
5356
5536
  for (const a of addrs) {
5357
5537
  if (signal.aborted) return false;
5538
+ const attempt = diagnostics
5539
+ ? this.createBoundedDialAttempt(
5540
+ signal,
5541
+ timeoutMs,
5542
+ diagnostics.deadlineAt,
5543
+ )
5544
+ : undefined;
5545
+ if (diagnostics && !attempt) return false;
5546
+ if (diagnostics) diagnostics.metrics.joinCandidateDialAttempts += 1;
5547
+ let ready = false;
5358
5548
  try {
5359
- await this.components.connectionManager.openConnection(a);
5549
+ if (attempt) {
5550
+ await this.components.connectionManager.openConnection(a, {
5551
+ signal: attempt.signal,
5552
+ });
5553
+ } else {
5554
+ await this.components.connectionManager.openConnection(a);
5555
+ }
5360
5556
  await this.waitFor(hash, {
5361
5557
  seek: "present",
5362
- timeout: timeoutMs,
5363
- signal,
5558
+ timeout: attempt?.timeoutMs ?? timeoutMs,
5559
+ signal: attempt?.signal ?? signal,
5364
5560
  });
5365
- return true;
5561
+ ready = diagnostics ? this.isPeerReadyForJoin(hash) : true;
5366
5562
  } catch {
5367
5563
  // ignore and try next
5564
+ } finally {
5565
+ attempt?.clear();
5566
+ if (!ready && diagnostics) {
5567
+ diagnostics.metrics.joinCandidateDialFailures += 1;
5568
+ }
5368
5569
  }
5570
+ if (ready) return true;
5369
5571
  }
5370
5572
  return false;
5371
5573
  }
@@ -5503,6 +5705,22 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
5503
5705
  );
5504
5706
  }
5505
5707
 
5708
+ private sendTrackerFeedbackBestEffort(
5709
+ ch: ChannelState,
5710
+ trackerPeers: string[],
5711
+ candidateHash: string,
5712
+ event: number,
5713
+ reason = 0,
5714
+ ): void {
5715
+ void this.sendTrackerFeedback(
5716
+ ch,
5717
+ trackerPeers,
5718
+ candidateHash,
5719
+ event,
5720
+ reason,
5721
+ ).catch(() => {});
5722
+ }
5723
+
5506
5724
  private pruneParentUpgradeReservations(ch: ChannelState, now = Date.now()) {
5507
5725
  for (const [hash, reservation] of ch.parentUpgradeReservationsByHash) {
5508
5726
  if (reservation.expiresAt <= now) {
@@ -5767,14 +5985,45 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
5767
5985
  source: Number(joinOpts.candidateScoringWeights?.source ?? 0.25),
5768
5986
  };
5769
5987
  const start = Date.now();
5988
+ const initialJoinDeadlineAt = timeoutMs > 0 ? start + timeoutMs : undefined;
5770
5989
  const cooldownUntilByHash = new Map<string, number>();
5771
5990
  const combinedSignal = joinOpts.signal
5772
5991
  ? anySignal([ch.closeController.signal, joinOpts.signal])
5773
5992
  : ch.closeController.signal;
5774
5993
  const signal = combinedSignal as AbortSignal & { clear?: () => void };
5994
+ const initialJoinRemainingMs = () =>
5995
+ !ch.joinedAtLeastOnce && initialJoinDeadlineAt != null
5996
+ ? Math.max(0, initialJoinDeadlineAt - Date.now())
5997
+ : undefined;
5998
+ const clampInitialJoinWait = (requestedMs: number) => {
5999
+ const remainingMs = initialJoinRemainingMs();
6000
+ return remainingMs == null
6001
+ ? requestedMs
6002
+ : Math.min(requestedMs, remainingMs);
6003
+ };
6004
+ const throwInitialJoinTimeout = (): never => {
6005
+ ch.metrics.joinDeadlineExpirations += 1;
6006
+ const bootstrapsCount = this.getBootstrapsForChannel(ch).length;
6007
+ const rootPeer = this.peers.get(ch.id.root);
6008
+ const rootNeighbor = Boolean(
6009
+ rootPeer && rootPeer.isReadable && rootPeer.isWritable,
6010
+ );
6011
+ const bootstrapHint =
6012
+ bootstrapsCount === 0 && !rootNeighbor
6013
+ ? " No fanout bootstraps are configured for this channel, and the root is not a direct neighbor. If this peer reached the network via a bootstrap or relay node, initialize it with Peerbit.bootstrap(...) instead of Peerbit.dial(...), or configure FanoutTree.setBootstraps(...) before joining sharded topics."
6014
+ : "";
6015
+ throw new Error(
6016
+ `fanout join timed out after ${timeoutMs}ms (topic=${ch.id.topic} root=${ch.id.root} self=${this.publicKeyHash} rootNeighbor=${rootNeighbor} peers=${this.peers.size} bootstraps=${bootstrapsCount} joinReqSent=${ch.metrics.joinReqSent} joinAcceptReceived=${ch.metrics.joinAcceptReceived} joinRejectReceived=${ch.metrics.joinRejectReceived} peerResets=${ch.metrics.joinPeerResets}).${bootstrapHint}`,
6017
+ );
6018
+ };
6019
+ const throwIfInitialJoinTimedOut = () => {
6020
+ if (initialJoinRemainingMs() === 0) throwInitialJoinTimeout();
6021
+ };
5775
6022
  let nextParentUpgradeCheckAt = 0;
5776
6023
  let parentUpgradeCheckSeq = 0;
5777
6024
  let parentUpgradeActiveGuardBackoffMs = 0;
6025
+ const unsuccessfulColdBootstrapPeers = new Set<string>();
6026
+ let bootstrapFallbackRetryAt = 0;
5778
6027
  const scheduleNextParentUpgradeCheck = (
5779
6028
  now: number,
5780
6029
  first = false,
@@ -6036,30 +6285,16 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
6036
6285
  continue;
6037
6286
  }
6038
6287
 
6039
- // `timeoutMs` is meant to bound the initial `joinChannel()` await, not to
6040
- // stop re-parenting attempts for long-running nodes.
6041
- if (
6042
- !ch.joinedAtLeastOnce &&
6043
- timeoutMs > 0 &&
6044
- Date.now() - start > timeoutMs
6045
- ) {
6046
- const bootstrapsCount = this.getBootstrapsForChannel(ch).length;
6047
- const rootPeer = this.peers.get(ch.id.root);
6048
- const rootNeighbor = Boolean(
6049
- rootPeer && rootPeer.isReadable && rootPeer.isWritable,
6050
- );
6051
- const bootstrapHint =
6052
- bootstrapsCount === 0 && !rootNeighbor
6053
- ? " No fanout bootstraps are configured for this channel, and the root is not a direct neighbor. If this peer reached the network via a bootstrap or relay node, initialize it with Peerbit.bootstrap(...) instead of Peerbit.dial(...), or configure FanoutTree.setBootstraps(...) before joining sharded topics."
6054
- : "";
6055
- throw new Error(
6056
- `fanout join timed out after ${timeoutMs}ms (topic=${ch.id.topic} root=${ch.id.root} self=${this.publicKeyHash} rootNeighbor=${rootNeighbor} peers=${this.peers.size} bootstraps=${bootstrapsCount} joinReqSent=${ch.metrics.joinReqSent} joinAcceptReceived=${ch.metrics.joinAcceptReceived} joinRejectReceived=${ch.metrics.joinRejectReceived} peerResets=${ch.metrics.joinPeerResets}).${bootstrapHint}`,
6057
- );
6058
- }
6288
+ // `timeoutMs` bounds only the initial `joinChannel()` await. Re-parenting
6289
+ // remains unbounded after the first attachment, while every cold-open wait
6290
+ // below is clamped to this same absolute deadline.
6291
+ throwIfInitialJoinTimedOut();
6059
6292
 
6060
6293
  const cooldownMs = ch.rejoinCooldownUntil - Date.now();
6061
6294
  if (cooldownMs > 0) {
6062
- await delay(cooldownMs, { signal });
6295
+ const waitMs = clampInitialJoinWait(cooldownMs);
6296
+ if (waitMs <= 0) throwInitialJoinTimeout();
6297
+ await delay(waitMs, { signal });
6063
6298
  continue;
6064
6299
  }
6065
6300
 
@@ -6067,10 +6302,18 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
6067
6302
  let bootstrapPeers: string[] = [];
6068
6303
  if (bootstraps.length > 0) {
6069
6304
  const now = Date.now();
6070
- const connectedCached = ch.cachedBootstrapPeers.filter((h) =>
6071
- Boolean(this.peers.get(h)),
6305
+ const connectedCached = ch.cachedBootstrapPeers.filter(
6306
+ (h) =>
6307
+ this.isPeerReadyForJoin(h) &&
6308
+ !unsuccessfulColdBootstrapPeers.has(h),
6309
+ );
6310
+ const hasExcludedReadyCached = ch.cachedBootstrapPeers.some(
6311
+ (h) =>
6312
+ this.isPeerReadyForJoin(h) &&
6313
+ unsuccessfulColdBootstrapPeers.has(h),
6072
6314
  );
6073
6315
  const due =
6316
+ (hasExcludedReadyCached && now >= bootstrapFallbackRetryAt) ||
6074
6317
  ch.lastBootstrapEnsureAt === 0 ||
6075
6318
  bootstrapEnsureIntervalMs === 0 ||
6076
6319
  now - ch.lastBootstrapEnsureAt >= bootstrapEnsureIntervalMs;
@@ -6080,18 +6323,45 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
6080
6323
  : false;
6081
6324
  if (due && !haveEnough) {
6082
6325
  ch.lastBootstrapEnsureAt = now;
6326
+ const wasFallbackPass = unsuccessfulColdBootstrapPeers.size > 0;
6327
+ const diagnostics = !ch.joinedAtLeastOnce
6328
+ ? {
6329
+ metrics: ch.metrics,
6330
+ deadlineAt: initialJoinDeadlineAt,
6331
+ preferConnected: true,
6332
+ excludeReadyPeerHashes: unsuccessfulColdBootstrapPeers,
6333
+ }
6334
+ : undefined;
6083
6335
  const peers = await this.ensureBootstrapPeers(
6084
6336
  bootstraps,
6085
6337
  bootstrapDialTimeoutMs,
6086
6338
  signal,
6087
6339
  bootstrapMaxPeers,
6340
+ diagnostics,
6088
6341
  );
6089
- if (peers.length > 0) ch.cachedBootstrapPeers = peers;
6342
+ if (peers.length > 0) {
6343
+ const cohortChanged =
6344
+ peers.length !== ch.cachedBootstrapPeers.length ||
6345
+ peers.some(
6346
+ (hash, index) => ch.cachedBootstrapPeers[index] !== hash,
6347
+ );
6348
+ ch.cachedBootstrapPeers = peers;
6349
+ if (cohortChanged) {
6350
+ ch.lastTrackerQueryAt = 0;
6351
+ ch.cachedTrackerCandidates = [];
6352
+ }
6353
+ } else if (wasFallbackPass) {
6354
+ bootstrapFallbackRetryAt =
6355
+ Date.now() + Math.max(1, bootstrapEnsureIntervalMs);
6356
+ }
6090
6357
  }
6091
- bootstrapPeers = ch.cachedBootstrapPeers.filter((h) =>
6092
- Boolean(this.peers.get(h)),
6358
+ bootstrapPeers = ch.cachedBootstrapPeers.filter(
6359
+ (h) =>
6360
+ this.isPeerReadyForJoin(h) &&
6361
+ !unsuccessfulColdBootstrapPeers.has(h),
6093
6362
  );
6094
6363
  }
6364
+ throwIfInitialJoinTimedOut();
6095
6365
 
6096
6366
  let tracker: TrackerCandidate[] = [];
6097
6367
  if (bootstrapPeers.length > 0 && trackerCandidates > 0) {
@@ -6102,17 +6372,22 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
6102
6372
  now - ch.lastTrackerQueryAt >= trackerQueryIntervalMs;
6103
6373
  if (due) {
6104
6374
  ch.lastTrackerQueryAt = now;
6375
+ const queryTimeoutMs = clampInitialJoinWait(
6376
+ Math.max(1, trackerQueryTimeoutMs),
6377
+ );
6378
+ if (queryTimeoutMs <= 0) throwInitialJoinTimeout();
6105
6379
  const res = await this.queryTrackers(
6106
6380
  ch,
6107
6381
  bootstrapPeers,
6108
6382
  trackerCandidates,
6109
- trackerQueryTimeoutMs,
6383
+ queryTimeoutMs,
6110
6384
  signal,
6111
6385
  );
6112
6386
  if (res.length > 0) ch.cachedTrackerCandidates = res;
6113
6387
  }
6114
6388
  tracker = ch.cachedTrackerCandidates;
6115
6389
  }
6390
+ throwIfInitialJoinTimedOut();
6116
6391
 
6117
6392
  const candidatesByHash = new Map<
6118
6393
  string,
@@ -6150,7 +6425,10 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
6150
6425
  // Fast path: if the designated root is already a direct neighbor, try it first.
6151
6426
  // Without this, large join storms can repeatedly time out on arbitrary peers
6152
6427
  // that don't host the channel yet, starving the real root candidate.
6153
- if (ch.id.root !== this.publicKeyHash && this.peers.has(ch.id.root)) {
6428
+ if (
6429
+ ch.id.root !== this.publicKeyHash &&
6430
+ this.isPeerReadyForJoin(ch.id.root)
6431
+ ) {
6154
6432
  upsertCandidate({
6155
6433
  hash: ch.id.root,
6156
6434
  addrs: [],
@@ -6198,6 +6476,7 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
6198
6476
  const connectedFallbackMax = 64;
6199
6477
  for (const h of this.peers.keys()) {
6200
6478
  if (h === this.publicKeyHash) continue;
6479
+ if (!this.isPeerReadyForJoin(h)) continue;
6201
6480
  if (bootstrapPeerSet.has(h) && candidatesByHash.has(h)) continue;
6202
6481
  upsertCandidate({
6203
6482
  hash: h,
@@ -6245,10 +6524,16 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
6245
6524
  retryMs,
6246
6525
  trackerQueryIntervalMs > 0 ? trackerQueryIntervalMs : retryMs,
6247
6526
  );
6248
- await delay(Math.max(1, Math.min(waitMs, capMs)), { signal });
6527
+ const boundedWaitMs = clampInitialJoinWait(
6528
+ Math.max(1, Math.min(waitMs, capMs)),
6529
+ );
6530
+ if (boundedWaitMs <= 0) throwInitialJoinTimeout();
6531
+ await delay(boundedWaitMs, { signal });
6249
6532
  continue;
6250
6533
  }
6251
- await delay(retryMs, { signal });
6534
+ const waitMs = clampInitialJoinWait(retryMs);
6535
+ if (waitMs <= 0) throwInitialJoinTimeout();
6536
+ await delay(waitMs, { signal });
6252
6537
  continue;
6253
6538
  }
6254
6539
 
@@ -6265,6 +6550,16 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
6265
6550
  ordered[j] = tmp;
6266
6551
  }
6267
6552
  }
6553
+
6554
+ // Give one usable peer a latency advantage without moving every
6555
+ // connected fallback ahead of better-ranked dialable candidates.
6556
+ const firstReadyIndex = ordered.findIndex((candidate) =>
6557
+ this.isPeerReadyForJoin(candidate.hash),
6558
+ );
6559
+ if (firstReadyIndex > 0) {
6560
+ const [firstReady] = ordered.splice(firstReadyIndex, 1);
6561
+ ordered.unshift(firstReady!);
6562
+ }
6268
6563
  } else if (candidateScoringMode === "weighted") {
6269
6564
  const wLevel = Number.isFinite(candidateScoringWeights.level)
6270
6565
  ? Math.max(0, candidateScoringWeights.level)
@@ -6348,9 +6643,15 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
6348
6643
  ) {
6349
6644
  if (signal.aborted) break;
6350
6645
  if (attempts >= joinAttemptsPerRound) break;
6646
+ throwIfInitialJoinTimedOut();
6351
6647
  const c = queue[i]!;
6352
6648
  attempts += 1;
6353
- const wasConnected = Boolean(this.peers.get(c.hash));
6649
+ const wasConnected = this.isPeerReadyForJoin(c.hash);
6650
+ if (wasConnected) {
6651
+ ch.metrics.joinConnectedCandidateAttempts += 1;
6652
+ } else {
6653
+ ch.metrics.joinUnconnectedCandidateAttempts += 1;
6654
+ }
6354
6655
  let dialOk = wasConnected;
6355
6656
  if (!dialOk && c.addrs.length > 0) {
6356
6657
  dialOk = await this.ensurePeerConnection(
@@ -6358,19 +6659,18 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
6358
6659
  c.addrs,
6359
6660
  bootstrapDialTimeoutMs,
6360
6661
  signal,
6662
+ !ch.joinedAtLeastOnce
6663
+ ? { metrics: ch.metrics, deadlineAt: initialJoinDeadlineAt }
6664
+ : undefined,
6361
6665
  );
6362
6666
  }
6363
6667
  if (!dialOk) {
6364
- try {
6365
- await this.sendTrackerFeedback(
6366
- ch,
6367
- bootstrapPeers,
6368
- c.hash,
6369
- TRACKER_FEEDBACK_DIAL_FAILED,
6370
- );
6371
- } catch {
6372
- // ignore
6373
- }
6668
+ this.sendTrackerFeedbackBestEffort(
6669
+ ch,
6670
+ bootstrapPeers,
6671
+ c.hash,
6672
+ TRACKER_FEEDBACK_DIAL_FAILED,
6673
+ );
6374
6674
  if (candidateCooldownMs > 0) {
6375
6675
  cooldownUntilByHash.set(
6376
6676
  c.hash,
@@ -6383,11 +6683,15 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
6383
6683
  dialedNew.add(c.hash);
6384
6684
  }
6385
6685
  const reqId = (this.random() * 0xffffffff) >>> 0;
6686
+ const requestTimeoutMs = clampInitialJoinWait(
6687
+ Math.max(1, joinReqTimeoutMs),
6688
+ );
6689
+ if (requestTimeoutMs <= 0) throwInitialJoinTimeout();
6386
6690
  const res = await this.tryJoinOnce(
6387
6691
  ch,
6388
6692
  c.hash,
6389
6693
  reqId,
6390
- joinReqTimeoutMs,
6694
+ requestTimeoutMs,
6391
6695
  signal,
6392
6696
  );
6393
6697
 
@@ -6412,16 +6716,12 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
6412
6716
  }
6413
6717
 
6414
6718
  if (res.ok) {
6415
- try {
6416
- await this.sendTrackerFeedback(
6417
- ch,
6418
- bootstrapPeers,
6419
- c.hash,
6420
- TRACKER_FEEDBACK_JOINED,
6421
- );
6422
- } catch {
6423
- // ignore
6424
- }
6719
+ this.sendTrackerFeedbackBestEffort(
6720
+ ch,
6721
+ bootstrapPeers,
6722
+ c.hash,
6723
+ TRACKER_FEEDBACK_JOINED,
6724
+ );
6425
6725
  cooldownUntilByHash.delete(c.hash);
6426
6726
  break;
6427
6727
  }
@@ -6439,17 +6739,14 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
6439
6739
  }
6440
6740
 
6441
6741
  if (res.timedOut) {
6742
+ ch.metrics.joinReqTimeouts += 1;
6442
6743
  this.noteJoinTimeout(ch, c.hash);
6443
- try {
6444
- await this.sendTrackerFeedback(
6445
- ch,
6446
- bootstrapPeers,
6447
- c.hash,
6448
- TRACKER_FEEDBACK_JOIN_TIMEOUT,
6449
- );
6450
- } catch {
6451
- // ignore
6452
- }
6744
+ this.sendTrackerFeedbackBestEffort(
6745
+ ch,
6746
+ bootstrapPeers,
6747
+ c.hash,
6748
+ TRACKER_FEEDBACK_JOIN_TIMEOUT,
6749
+ );
6453
6750
  if (candidateCooldownMs > 0) {
6454
6751
  cooldownUntilByHash.set(
6455
6752
  c.hash,
@@ -6474,17 +6771,13 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
6474
6771
  Date.now() + candidateCooldownMs * factor,
6475
6772
  );
6476
6773
  }
6477
- try {
6478
- await this.sendTrackerFeedback(
6479
- ch,
6480
- bootstrapPeers,
6481
- c.hash,
6482
- TRACKER_FEEDBACK_JOIN_REJECT,
6483
- rejectReason,
6484
- );
6485
- } catch {
6486
- // ignore
6487
- }
6774
+ this.sendTrackerFeedbackBestEffort(
6775
+ ch,
6776
+ bootstrapPeers,
6777
+ c.hash,
6778
+ TRACKER_FEEDBACK_JOIN_REJECT,
6779
+ rejectReason,
6780
+ );
6488
6781
  }
6489
6782
 
6490
6783
  if (ch.parent) {
@@ -6501,7 +6794,17 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
6501
6794
  }
6502
6795
  continue;
6503
6796
  }
6504
- await delay(retryMs, { signal });
6797
+ if (
6798
+ bootstraps.length > 1 &&
6799
+ Date.now() >= bootstrapFallbackRetryAt
6800
+ ) {
6801
+ for (const hash of bootstrapPeers) {
6802
+ unsuccessfulColdBootstrapPeers.add(hash);
6803
+ }
6804
+ }
6805
+ const waitMs = clampInitialJoinWait(retryMs);
6806
+ if (waitMs <= 0) throwInitialJoinTimeout();
6807
+ await delay(waitMs, { signal });
6505
6808
  }
6506
6809
  } finally {
6507
6810
  signal.clear?.();
@@ -7652,13 +7955,50 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
7652
7955
  ): Promise<JoinAttemptResult> {
7653
7956
  if (ch.parent && options?.allowReplace !== true) return { ok: true };
7654
7957
  if (!this.peers.get(parentHash)) return { ok: false, timedOut: true };
7655
- const p = new Promise<JoinAttemptResult>((resolve) => {
7656
- ch.pendingJoin.set(reqId, {
7657
- resolve,
7658
- shadowAttach: options?.shadowAttach === true,
7659
- });
7958
+ const attemptController = new AbortController();
7959
+ let settled = false;
7960
+ let resolveAttempt!: (result: JoinAttemptResult) => void;
7961
+ let rejectAttempt!: (error: unknown) => void;
7962
+ const attempt = new Promise<JoinAttemptResult>((resolve, reject) => {
7963
+ resolveAttempt = resolve;
7964
+ rejectAttempt = reject;
7660
7965
  });
7661
- await this._sendControl(
7966
+ const settleResult = (result: JoinAttemptResult) => {
7967
+ if (settled) return;
7968
+ settled = true;
7969
+ resolveAttempt(result);
7970
+ };
7971
+ const settleError = (error: unknown) => {
7972
+ if (settled) return;
7973
+ settled = true;
7974
+ rejectAttempt(error);
7975
+ };
7976
+ ch.pendingJoin.set(reqId, {
7977
+ resolve: settleResult,
7978
+ shadowAttach: options?.shadowAttach === true,
7979
+ });
7980
+
7981
+ const onAbort = () => {
7982
+ const reason = signal.reason ?? new AbortError("fanout join aborted");
7983
+ if (!attemptController.signal.aborted) {
7984
+ attemptController.abort(reason);
7985
+ }
7986
+ settleError(reason);
7987
+ };
7988
+ signal.addEventListener("abort", onAbort, { once: true });
7989
+ if (signal.aborted) onAbort();
7990
+
7991
+ const timer = setTimeout(() => {
7992
+ if (!attemptController.signal.aborted) {
7993
+ attemptController.abort(
7994
+ new AbortError("fanout join attempt timed out"),
7995
+ );
7996
+ }
7997
+ settleResult({ ok: false, timedOut: true });
7998
+ }, Math.max(1, timeoutMs));
7999
+ timer.unref?.();
8000
+
8001
+ const send = this._sendControl(
7662
8002
  parentHash,
7663
8003
  this.codec.encodeJoinReq(
7664
8004
  ch.id.key,
@@ -7666,18 +8006,29 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
7666
8006
  ch.bidPerByte,
7667
8007
  options?.parentUpgradeReservationToken,
7668
8008
  ),
7669
- );
7670
- const res = await Promise.race([
7671
- p,
7672
- delay(Math.max(1, timeoutMs), { signal }).then(
7673
- (): JoinAttemptResult => ({
7674
- ok: false,
7675
- timedOut: true,
7676
- }),
7677
- ),
7678
- ]);
7679
- if (res.timedOut) ch.pendingJoin.delete(reqId);
7680
- return res;
8009
+ attemptController.signal,
8010
+ ).catch((error) => {
8011
+ if (settled) return;
8012
+ if (signal.aborted) {
8013
+ settleError(signal.reason ?? error);
8014
+ return;
8015
+ }
8016
+ if (!attemptController.signal.aborted) settleError(error);
8017
+ });
8018
+
8019
+ try {
8020
+ return await attempt;
8021
+ } finally {
8022
+ clearTimeout(timer);
8023
+ signal.removeEventListener("abort", onAbort);
8024
+ ch.pendingJoin.delete(reqId);
8025
+ if (!attemptController.signal.aborted) {
8026
+ attemptController.abort(
8027
+ new AbortError("fanout join attempt settled"),
8028
+ );
8029
+ }
8030
+ void send;
8031
+ }
7681
8032
  }
7682
8033
 
7683
8034
  private async kickChildHashes(