@peerbit/pubsub 5.3.0 → 5.3.2

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.
@@ -727,6 +727,7 @@ export type FanoutTreeChannelMetrics = {
727
727
  joinAcceptReceived: number;
728
728
  joinRejectSent: number;
729
729
  joinRejectReceived: number;
730
+ joinPeerResets: number;
730
731
  kickSent: number;
731
732
  kickReceived: number;
732
733
  reparentDisconnect: number;
@@ -861,6 +862,11 @@ const PARENT_REPAIR_DEAD_STREAK_THRESHOLD = 16;
861
862
  const PARENT_REPAIR_DEAD_MIN_LIVENESS_MS = 15_000;
862
863
  const REPAIR_RETRY_MIN_MS = 1_000;
863
864
  const REPAIR_RETRY_INTERVAL_FACTOR = 5;
865
+ // A stream can remain locally readable/writable while silently dropping every
866
+ // control frame. Repeated unanswered JOIN requests are end-to-end evidence that
867
+ // the peer path needs to be rebuilt.
868
+ const JOIN_TIMEOUT_RESET_STREAK_THRESHOLD = 3;
869
+ const JOIN_TIMEOUT_RESET_COOLDOWN_MS = 10_000;
864
870
 
865
871
  const JOIN_REJECT_REDIRECT_QUEUE_MAX = 64;
866
872
  // When a relay loses its parent, pause before trying to rejoin so its children can
@@ -1226,6 +1232,7 @@ const createEmptyMetrics = (): FanoutTreeChannelMetrics => ({
1226
1232
  joinAcceptReceived: 0,
1227
1233
  joinRejectSent: 0,
1228
1234
  joinRejectReceived: 0,
1235
+ joinPeerResets: 0,
1229
1236
  kickSent: 0,
1230
1237
  kickReceived: 0,
1231
1238
  reparentDisconnect: 0,
@@ -1314,6 +1321,8 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
1314
1321
  string,
1315
1322
  FanoutTreeChannelMetrics
1316
1323
  >();
1324
+ private readonly joinTimeoutStreakByPeer = new Map<string, number>();
1325
+ private readonly joinResetCooldownUntilByPeer = new Map<string, number>();
1317
1326
  private bootstraps: Multiaddr[] = [];
1318
1327
  private trackerBySuffixKey = new Map<string, Map<string, TrackerEntry>>();
1319
1328
  private trackerNamespaceLru = new Map<string, number>();
@@ -1398,6 +1407,8 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
1398
1407
  );
1399
1408
  this.underlayPeerDisconnectHandler = undefined;
1400
1409
  }
1410
+ this.joinTimeoutStreakByPeer.clear();
1411
+ this.joinResetCooldownUntilByPeer.clear();
1401
1412
  return super.stop();
1402
1413
  }
1403
1414
 
@@ -2563,6 +2574,7 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
2563
2574
 
2564
2575
  private onPeerDisconnectedFromUnderlay(peerHash: string) {
2565
2576
  if (!peerHash) return;
2577
+ this.joinTimeoutStreakByPeer.delete(peerHash);
2566
2578
 
2567
2579
  // Detach from a disconnected parent immediately, so children can rejoin.
2568
2580
  // This is more reliable than polling `getConnections()` because the underlay
@@ -2796,11 +2808,21 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
2796
2808
 
2797
2809
  if (!ch.joinedOnce) ch.joinedOnce = createDeferred();
2798
2810
  if (!ch.joinLoop) {
2799
- ch.joinLoop = this._joinLoop(ch, joinOpts).catch((err) => {
2800
- // Surface join errors to the caller without crashing the process
2801
- // via an unhandled rejection (joinLoop is not generally awaited).
2802
- ch.joinedOnce?.reject(err);
2803
- });
2811
+ const joinedOnce = ch.joinedOnce;
2812
+ const joinLoop = this._joinLoop(ch, joinOpts)
2813
+ .catch((err) => {
2814
+ // Surface join errors to the caller without crashing the process
2815
+ // via an unhandled rejection (joinLoop is not generally awaited).
2816
+ if (ch.joinLoop === joinLoop) ch.joinLoop = undefined;
2817
+ if (ch.joinedOnce === joinedOnce && !ch.joinedAtLeastOnce) {
2818
+ ch.joinedOnce = undefined;
2819
+ }
2820
+ joinedOnce.reject(err);
2821
+ })
2822
+ .finally(() => {
2823
+ if (ch.joinLoop === joinLoop) ch.joinLoop = undefined;
2824
+ });
2825
+ ch.joinLoop = joinLoop;
2804
2826
  }
2805
2827
  return ch.joinedOnce.promise;
2806
2828
  }
@@ -4011,6 +4033,42 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
4011
4033
  return ch.cachePayloads[idx];
4012
4034
  }
4013
4035
 
4036
+ private noteJoinResponse(peerHash: string) {
4037
+ this.joinTimeoutStreakByPeer.delete(peerHash);
4038
+ }
4039
+
4040
+ private noteJoinTimeout(ch: ChannelState, peerHash: string) {
4041
+ const streak = (this.joinTimeoutStreakByPeer.get(peerHash) ?? 0) + 1;
4042
+ if (streak < JOIN_TIMEOUT_RESET_STREAK_THRESHOLD) {
4043
+ this.joinTimeoutStreakByPeer.set(peerHash, streak);
4044
+ return;
4045
+ }
4046
+
4047
+ this.joinTimeoutStreakByPeer.delete(peerHash);
4048
+ const now = Date.now();
4049
+ for (const [hash, until] of this.joinResetCooldownUntilByPeer) {
4050
+ if (until <= now) this.joinResetCooldownUntilByPeer.delete(hash);
4051
+ }
4052
+ const resetCooldownUntil =
4053
+ this.joinResetCooldownUntilByPeer.get(peerHash) ?? 0;
4054
+ if (resetCooldownUntil > now) return;
4055
+
4056
+ const stream = this.peers.get(peerHash);
4057
+ if (!stream) return;
4058
+ this.joinResetCooldownUntilByPeer.set(
4059
+ peerHash,
4060
+ now + JOIN_TIMEOUT_RESET_COOLDOWN_MS,
4061
+ );
4062
+ ch.metrics.joinPeerResets += 1;
4063
+ try {
4064
+ void this.components.connectionManager
4065
+ .closeConnections(stream.peerId)
4066
+ .catch(() => {});
4067
+ } catch {
4068
+ // Best-effort reset. The join loop keeps retrying other candidates.
4069
+ }
4070
+ }
4071
+
4014
4072
  private async _sendControl(to: string, bytes: Uint8Array) {
4015
4073
  const stream = this.peers.get(to);
4016
4074
  if (!stream) return;
@@ -4023,17 +4081,17 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
4023
4081
  }
4024
4082
 
4025
4083
  private async _sendControlMany(to: string[], bytes: Uint8Array) {
4026
- if (to.length === 0) return;
4084
+ if (to.length === 0) return true;
4027
4085
  const streams = to
4028
4086
  .map((t) => this.peers.get(t))
4029
4087
  .filter((s): s is PeerStreams => Boolean(s));
4030
- if (streams.length === 0) return;
4088
+ if (streams.length === 0) return false;
4031
4089
  this.recordControlSend(bytes, streams.length);
4032
4090
  const message = await this.createMessage(bytes, {
4033
4091
  mode: new AnyWhere(),
4034
4092
  priority: CONTROL_PRIORITY,
4035
4093
  } as any);
4036
- await this.publishMessageMaybe(this.publicKey, message, streams);
4094
+ return this.publishMessageMaybe(this.publicKey, message, streams);
4037
4095
  }
4038
4096
 
4039
4097
  private refillUploadTokens(ch: ChannelState, now = Date.now()) {
@@ -5904,7 +5962,7 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
5904
5962
  ? " 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."
5905
5963
  : "";
5906
5964
  throw new Error(
5907
- `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}).${bootstrapHint}`,
5965
+ `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}`,
5908
5966
  );
5909
5967
  }
5910
5968
 
@@ -6290,6 +6348,7 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
6290
6348
  }
6291
6349
 
6292
6350
  if (res.timedOut) {
6351
+ this.noteJoinTimeout(ch, c.hash);
6293
6352
  try {
6294
6353
  await this.sendTrackerFeedback(
6295
6354
  ch,
@@ -7544,7 +7603,11 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
7544
7603
  const resetPeerConnections = options?.resetPeerConnections === true;
7545
7604
  let kickFailed = false;
7546
7605
  try {
7547
- await this._sendControlMany(unique, this.codec.encodeKick(ch.id.key));
7606
+ const kickDelivered = await this._sendControlMany(
7607
+ unique,
7608
+ this.codec.encodeKick(ch.id.key),
7609
+ );
7610
+ kickFailed = !kickDelivered;
7548
7611
  } catch (error) {
7549
7612
  kickFailed = true;
7550
7613
  throw error;
@@ -8379,6 +8442,8 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
8379
8442
  if (kind === MSG_JOIN_ACCEPT || kind === MSG_JOIN_REJECT) {
8380
8443
  const reqId = this.codec.decodeJoinResponseReqId(data);
8381
8444
  if (reqId == null) return false;
8445
+ // Any explicit join response proves this path is alive end to end.
8446
+ this.noteJoinResponse(fromHash);
8382
8447
  const pending = ch.pendingJoin.get(reqId);
8383
8448
  if (!pending) return true;
8384
8449
  ch.pendingJoin.delete(reqId);
package/src/index.ts CHANGED
@@ -138,6 +138,7 @@ const AUTO_TOPIC_ROOT_CANDIDATES_MAX = 64;
138
138
  // Topic-root queries may need to wait for the responder to finish opening an
139
139
  // outbound stream back to the requester after an inbound-only dial.
140
140
  const DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS = 12_000;
141
+ const DIRECT_SHARD_ROOT_CONFIRM_TIMEOUT_MS = 2_000;
141
142
 
142
143
  const DEFAULT_PUBSUB_FANOUT_CHANNEL_OPTIONS: Omit<
143
144
  FanoutTreeChannelOptions,
@@ -1217,6 +1218,7 @@ export class TopicControlPlane
1217
1218
  private async queryTopicRootFromPeer(
1218
1219
  peer: PeerStreams,
1219
1220
  topic: string,
1221
+ timeoutMs = DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS,
1220
1222
  ): Promise<string | undefined> {
1221
1223
  if (!this.started || this.stopping) return undefined;
1222
1224
 
@@ -1225,7 +1227,7 @@ export class TopicControlPlane
1225
1227
  const timer = setTimeout(() => {
1226
1228
  this.pendingTopicRootQueries.delete(requestId);
1227
1229
  resolve(undefined);
1228
- }, DEFAULT_TOPIC_ROOT_QUERY_TIMEOUT_MS);
1230
+ }, Math.max(1, Math.floor(timeoutMs)));
1229
1231
  timer.unref?.();
1230
1232
  this.pendingTopicRootQueries.set(requestId, { topic, resolve, timer });
1231
1233
  });
@@ -1247,6 +1249,33 @@ export class TopicControlPlane
1247
1249
  return responsePromise;
1248
1250
  }
1249
1251
 
1252
+ private async confirmDirectShardRoot(
1253
+ shardTopic: string,
1254
+ root: string,
1255
+ signal?: AbortSignal,
1256
+ ): Promise<string> {
1257
+ if (root === this.publicKeyHash) return root;
1258
+ const rootPeer = this.peers.get(root);
1259
+ if (!rootPeer) return root;
1260
+
1261
+ const confirmation = await withAbort(
1262
+ this.queryTopicRootFromPeer(
1263
+ rootPeer,
1264
+ shardTopic,
1265
+ DIRECT_SHARD_ROOT_CONFIRM_TIMEOUT_MS,
1266
+ ),
1267
+ signal,
1268
+ );
1269
+ if (!confirmation) return root;
1270
+ if (confirmation !== root) {
1271
+ this.shardRootCache.set(shardTopic, {
1272
+ root: confirmation,
1273
+ authoritative: true,
1274
+ });
1275
+ }
1276
+ return confirmation;
1277
+ }
1278
+
1250
1279
  private async resolveQueryableTopicRoot(
1251
1280
  topic: string,
1252
1281
  ): Promise<string | undefined> {
@@ -1333,6 +1362,7 @@ export class TopicControlPlane
1333
1362
  }
1334
1363
 
1335
1364
  root = root ?? (await this.resolveShardRoot(t));
1365
+ root = await this.confirmDirectShardRoot(t, root, options?.signal);
1336
1366
  const channel = new FanoutChannel(this.fanout, { topic: t, root });
1337
1367
 
1338
1368
  const onPayload = (payload: Uint8Array) => {
@@ -1465,9 +1495,9 @@ export class TopicControlPlane
1465
1495
  // ignore
1466
1496
  }
1467
1497
  try {
1468
- channel.close();
1498
+ await channel.leave({ notifyParent: false, kickChildren: false });
1469
1499
  } catch {
1470
- // ignore
1500
+ channel.close();
1471
1501
  }
1472
1502
  throw error;
1473
1503
  }