@peerbit/pubsub 5.4.9 → 5.4.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peerbit/pubsub",
3
- "version": "5.4.9",
3
+ "version": "5.4.12",
4
4
  "description": "Direct streaming for libp2p",
5
5
  "type": "module",
6
6
  "types": "./dist/src/index.d.ts",
@@ -67,7 +67,7 @@
67
67
  },
68
68
  "homepage": "https://github.com/dao-xyz/peerbit#readme",
69
69
  "devDependencies": {
70
- "@peerbit/libp2p-test-utils": "3.0.10"
70
+ "@peerbit/libp2p-test-utils": "3.0.11"
71
71
  },
72
72
  "dependencies": {
73
73
  "@dao-xyz/borsh": "^6.0.0",
@@ -86,12 +86,12 @@
86
86
  "uint8arraylist": "^3.0.2",
87
87
  "uint8arrays": "^5.1.0",
88
88
  "@peerbit/any-store-interface": "1.1.4",
89
+ "@peerbit/crypto": "3.1.6",
89
90
  "@peerbit/logger": "2.0.2",
90
- "@peerbit/stream": "5.2.3",
91
91
  "@peerbit/pubsub-interface": "5.2.2",
92
- "@peerbit/crypto": "3.1.6",
93
- "@peerbit/time": "3.0.1",
94
- "@peerbit/stream-interface": "6.0.16"
92
+ "@peerbit/stream": "5.2.4",
93
+ "@peerbit/stream-interface": "6.0.16",
94
+ "@peerbit/time": "3.0.1"
95
95
  },
96
96
  "scripts": {
97
97
  "bench": "TS_NODE_PROJECT=../../../tsconfig.json node --loader ts-node/esm ./benchmark/index.ts",
@@ -512,7 +512,7 @@ export type FanoutTreeJoinOptions = {
512
512
  parentUpgradeStaleRootProbeProbability?: number;
513
513
 
514
514
  /**
515
- * How long to wait for a parent probe reply before skipping that candidate.
515
+ * How long to wait for a candidate or idle-parent probe reply.
516
516
  */
517
517
  parentProbeTimeoutMs?: number;
518
518
 
@@ -766,6 +766,8 @@ export type FanoutTreeChannelMetrics = {
766
766
  reparentUpgradeSkipProbeOverloaded: number;
767
767
  reparentUpgradeSkipProbeCooldown: number;
768
768
  parentProbeReqSent: number;
769
+ /** Subset of parentProbeReqSent used to check the current idle parent. */
770
+ parentHealthProbeReqSent: number;
769
771
  parentProbeReqReceived: number;
770
772
  parentProbeReplySent: number;
771
773
  parentProbeReplyReceived: number;
@@ -836,6 +838,11 @@ export interface FanoutTreeEvents extends StreamEvents {
836
838
  "fanout:data": CustomEvent<FanoutTreeDataEvent>;
837
839
  "fanout:unicast": CustomEvent<FanoutTreeUnicastEvent>;
838
840
  "fanout:joined": CustomEvent<{ topic: string; root: string; parent: string }>;
841
+ "fanout:child-joined": CustomEvent<{
842
+ topic: string;
843
+ root: string;
844
+ child: string;
845
+ }>;
839
846
  "fanout:kicked": CustomEvent<{ topic: string; root: string; from: string }>;
840
847
  "fanout:provider": CustomEvent<{
841
848
  namespace: string;
@@ -870,6 +877,19 @@ const PROVIDER_DIRECTORY_MAX_NAMESPACES = 4_096;
870
877
  // large networks can cause unbounded memory growth on the tracker nodes.
871
878
  const TRACKER_DIRECTORY_MAX_ENTRIES = 16_384;
872
879
  const TRACKER_DIRECTORY_MAX_NAMESPACES = 4_096;
880
+ // Best-effort bound on per-key control metrics for keys without an open channel.
881
+ // Every inbound control frame is recorded under its key, including tracker frames
882
+ // for channels we do not host, so bootstraps/relays see an open-ended key set.
883
+ // Open channels keep their metrics on the channel and are never evicted.
884
+ const DETACHED_METRICS_MAX_KEYS = 4_096;
885
+ // Best-effort bounds for provider watches and namespace names. Provider namespaces
886
+ // are commonly per block (`cid:<cid>`), so anything keyed by them must be bounded.
887
+ const PROVIDER_WATCH_MAX_NAMESPACES = 4_096;
888
+ const PROVIDER_NAMESPACE_NAME_MAX_ENTRIES = 16_384;
889
+ // Per-channel ingress token buckets (proxy-publish/unicast) are kept for recent
890
+ // senders only: a few multiples of the child limit, with a small floor.
891
+ const INGRESS_BUCKETS_MIN_ENTRIES = 64;
892
+ const INGRESS_BUCKETS_PER_CHILD = 4;
873
893
 
874
894
  const FANOUT_PROTOCOLS = ["/peerbit/fanout-tree/0.5.0"];
875
895
 
@@ -886,6 +906,7 @@ const DATA_WRITE_FAIL_KICK_COOLDOWN_MS = 2_000;
886
906
  const DATA_WRITE_FAIL_KICK_MAX_PER_EVENT = 4;
887
907
  const PARENT_REPAIR_DEAD_STREAK_THRESHOLD = 16;
888
908
  const PARENT_REPAIR_DEAD_MIN_LIVENESS_MS = 15_000;
909
+ const PARENT_IDLE_PROBE_INTERVAL_MS = 5_000;
889
910
  const REPAIR_RETRY_MIN_MS = 1_000;
890
911
  const REPAIR_RETRY_INTERVAL_FACTOR = 5;
891
912
  // A stream can remain locally readable/writable while silently dropping every
@@ -1162,7 +1183,10 @@ type ChannelState = {
1162
1183
  number,
1163
1184
  { resolve(entries: TrackerCandidate[]): void }
1164
1185
  >;
1165
- pendingParentProbe: Map<number, { resolve(reply?: ParentProbeReply): void }>;
1186
+ pendingParentProbe: Map<
1187
+ number,
1188
+ { peer: PeerStreams; resolve(reply?: ParentProbeReply): void }
1189
+ >;
1166
1190
  parentUpgradeReservationsByHash: Map<
1167
1191
  string,
1168
1192
  { token: number; expiresAt: number; minFreeSlots: number }
@@ -1300,6 +1324,7 @@ const createEmptyMetrics = (): InternalFanoutTreeChannelMetrics => ({
1300
1324
  reparentUpgradeSkipProbeOverloaded: 0,
1301
1325
  reparentUpgradeSkipProbeCooldown: 0,
1302
1326
  parentProbeReqSent: 0,
1327
+ parentHealthProbeReqSent: 0,
1303
1328
  parentProbeReqReceived: 0,
1304
1329
  parentProbeReplySent: 0,
1305
1330
  parentProbeReplyReceived: 0,
@@ -1358,13 +1383,27 @@ const isDataId = (id: Uint8Array) =>
1358
1383
  id[2] === ID_PREFIX[2] &&
1359
1384
  id[3] === ID_PREFIX[3];
1360
1385
 
1386
+ const isProviderControlKind = (kind: number) =>
1387
+ kind === MSG_PROVIDER_ANNOUNCE ||
1388
+ kind === MSG_PROVIDER_QUERY ||
1389
+ kind === MSG_PROVIDER_REPLY ||
1390
+ kind === MSG_PROVIDER_SUBSCRIBE ||
1391
+ kind === MSG_PROVIDER_UNSUBSCRIBE ||
1392
+ kind === MSG_PROVIDER_NOTIFY;
1393
+
1361
1394
  export class FanoutTree extends DirectStream<FanoutTreeEvents> {
1362
1395
  private channelsBySuffixKey = new Map<string, ChannelState>();
1363
1396
  private readonly cachedSuffixKey = new WeakMap<Uint8Array, string>();
1397
+ // Metrics for keys without an open channel, in LRU order (oldest first) and
1398
+ // bounded by DETACHED_METRICS_MAX_KEYS. Open channels own `ch.metrics`.
1364
1399
  private readonly metricsBySuffixKey = new Map<
1365
1400
  string,
1366
1401
  InternalFanoutTreeChannelMetrics
1367
1402
  >();
1403
+ // Provider control frames are keyed per provider namespace (often per block),
1404
+ // which no channel metrics lookup can address, so they share one aggregate.
1405
+ private readonly providerControlMetrics: InternalFanoutTreeChannelMetrics =
1406
+ createEmptyMetrics();
1368
1407
  private readonly joinTimeoutStreakByPeer = new Map<string, number>();
1369
1408
  private readonly joinResetCooldownUntilByPeer = new Map<string, number>();
1370
1409
  private bootstraps: Multiaddr[] = [];
@@ -1425,6 +1464,11 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
1425
1464
  this.topicRootControlPlane =
1426
1465
  topicRootControlPlane ?? new TopicRootControlPlane();
1427
1466
 
1467
+ this.registerUnderlayPeerDisconnectHandler();
1468
+ }
1469
+
1470
+ private registerUnderlayPeerDisconnectHandler() {
1471
+ if (this.underlayPeerDisconnectHandler) return;
1428
1472
  const onPeerDisconnect = (ev: any) => {
1429
1473
  const peerId = ev?.detail;
1430
1474
  if (!peerId) return;
@@ -1443,6 +1487,15 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
1443
1487
  );
1444
1488
  }
1445
1489
 
1490
+ public override async start() {
1491
+ await super.start();
1492
+ // stop() removes the listener, so a restarted instance must add it again
1493
+ // (but not while a stop that raced this start is still tearing down).
1494
+ if (this.started && !this.stopping) {
1495
+ this.registerUnderlayPeerDisconnectHandler();
1496
+ }
1497
+ }
1498
+
1446
1499
  private createLoopCloseController(): AbortController {
1447
1500
  const controller = new AbortController();
1448
1501
  const stopSignal = this.closeController.signal;
@@ -1551,6 +1604,27 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
1551
1604
  }
1552
1605
  }
1553
1606
 
1607
+ private touchProviderWatchers(
1608
+ suffixKey: string,
1609
+ ): Map<string, ProviderWatchRegistration> {
1610
+ // LRU touch
1611
+ const watchers =
1612
+ this.providerWatchersBySuffixKey.get(suffixKey) ??
1613
+ new Map<string, ProviderWatchRegistration>();
1614
+ this.providerWatchersBySuffixKey.delete(suffixKey);
1615
+ this.providerWatchersBySuffixKey.set(suffixKey, watchers);
1616
+ while (
1617
+ this.providerWatchersBySuffixKey.size > PROVIDER_WATCH_MAX_NAMESPACES
1618
+ ) {
1619
+ const oldest = this.providerWatchersBySuffixKey.keys().next().value as
1620
+ | string
1621
+ | undefined;
1622
+ if (oldest === undefined) break;
1623
+ this.providerWatchersBySuffixKey.delete(oldest);
1624
+ }
1625
+ return watchers;
1626
+ }
1627
+
1554
1628
  private pruneProviderWatchersIfEmpty(suffixKey: string) {
1555
1629
  const watchers = this.providerWatchersBySuffixKey.get(suffixKey);
1556
1630
  if (watchers && watchers.size === 0) {
@@ -1686,7 +1760,19 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
1686
1760
  private getProviderNamespaceId(namespace: string): ProviderNamespaceId {
1687
1761
  const key = sha256Sync(textEncoder.encode(`provider|${namespace}`));
1688
1762
  const suffixKey = toBase64(key.subarray(0, 24));
1763
+ // LRU touch: every announced block adds a namespace, so keep this bounded.
1764
+ this.providerNamespaceBySuffixKey.delete(suffixKey);
1689
1765
  this.providerNamespaceBySuffixKey.set(suffixKey, namespace);
1766
+ while (
1767
+ this.providerNamespaceBySuffixKey.size >
1768
+ PROVIDER_NAMESPACE_NAME_MAX_ENTRIES
1769
+ ) {
1770
+ const oldest = this.providerNamespaceBySuffixKey.keys().next().value as
1771
+ | string
1772
+ | undefined;
1773
+ if (oldest === undefined) break;
1774
+ this.providerNamespaceBySuffixKey.delete(oldest);
1775
+ }
1690
1776
  return { namespace, key, suffixKey };
1691
1777
  }
1692
1778
 
@@ -2505,6 +2591,9 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
2505
2591
  lastTrackerQueryAt: 0,
2506
2592
  };
2507
2593
  this.channelsBySuffixKey.set(id.suffixKey, ch);
2594
+ // The open channel now owns its metrics; take them out of the detached LRU
2595
+ // so they can never be evicted while the channel is open.
2596
+ this.metricsBySuffixKey.delete(id.suffixKey);
2508
2597
  const needsAnnounceLoop = ch.effectiveMaxChildren > 0;
2509
2598
  if (needsAnnounceLoop) {
2510
2599
  ch.announceLoop = this._announceLoop(ch).catch(() => {});
@@ -2705,6 +2794,13 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
2705
2794
  if (!peerHash) return;
2706
2795
  this.joinTimeoutStreakByPeer.delete(peerHash);
2707
2796
 
2797
+ // A disconnected watcher can no longer receive provider notifications.
2798
+ for (const [suffixKey, watchers] of this.providerWatchersBySuffixKey) {
2799
+ if (watchers.delete(peerHash) && watchers.size === 0) {
2800
+ this.providerWatchersBySuffixKey.delete(suffixKey);
2801
+ }
2802
+ }
2803
+
2708
2804
  // Detach from a disconnected parent immediately, so children can rejoin.
2709
2805
  // This is more reliable than polling `getConnections()` because the underlay
2710
2806
  // can flap/reconnect faster than the join loop cadence.
@@ -2803,6 +2899,8 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
2803
2899
 
2804
2900
  this.channelsBySuffixKey.delete(id.suffixKey);
2805
2901
  this.trackerBySuffixKey.delete(id.suffixKey);
2902
+ // Keep the counters readable after close (and across a reopen), bounded.
2903
+ this.storeDetachedMetrics(id.suffixKey, ch.metrics);
2806
2904
 
2807
2905
  await Promise.all(pendingSends);
2808
2906
  }
@@ -3985,20 +4083,57 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
3985
4083
  private getMetricsForSuffixKey(
3986
4084
  suffixKey: string,
3987
4085
  ): InternalFanoutTreeChannelMetrics {
3988
- let m = this.metricsBySuffixKey.get(suffixKey);
3989
- if (!m) {
3990
- m = createEmptyMetrics();
3991
- this.metricsBySuffixKey.set(suffixKey, m);
3992
- }
4086
+ const ch = this.channelsBySuffixKey.get(suffixKey);
4087
+ if (ch) return ch.metrics;
4088
+ const m = this.metricsBySuffixKey.get(suffixKey) ?? createEmptyMetrics();
4089
+ this.storeDetachedMetrics(suffixKey, m);
3993
4090
  return m;
3994
4091
  }
3995
4092
 
3996
- private recordControlSend(bytes: Uint8Array, transmissions: number) {
4093
+ private storeDetachedMetrics(
4094
+ suffixKey: string,
4095
+ metrics: InternalFanoutTreeChannelMetrics,
4096
+ ) {
4097
+ // LRU touch
4098
+ this.metricsBySuffixKey.delete(suffixKey);
4099
+ this.metricsBySuffixKey.set(suffixKey, metrics);
4100
+ while (this.metricsBySuffixKey.size > DETACHED_METRICS_MAX_KEYS) {
4101
+ const oldest = this.metricsBySuffixKey.keys().next().value as
4102
+ | string
4103
+ | undefined;
4104
+ if (oldest === undefined) break;
4105
+ this.metricsBySuffixKey.delete(oldest);
4106
+ }
4107
+ }
4108
+
4109
+ private getControlMetrics(
4110
+ suffixKey: string,
4111
+ kind: number,
4112
+ ): InternalFanoutTreeChannelMetrics {
4113
+ return isProviderControlKind(kind)
4114
+ ? this.providerControlMetrics
4115
+ : this.getMetricsForSuffixKey(suffixKey);
4116
+ }
4117
+
4118
+ /**
4119
+ * Returns aggregate diagnostic counters for provider control frames.
4120
+ *
4121
+ * @internal
4122
+ */
4123
+ public getProviderControlMetrics(): FanoutTreeChannelMetrics {
4124
+ return this.providerControlMetrics;
4125
+ }
4126
+
4127
+ private recordControlSend(
4128
+ bytes: Uint8Array,
4129
+ transmissions: number,
4130
+ probePurpose: "upgrade" | "health" = "upgrade",
4131
+ ) {
3997
4132
  if (transmissions <= 0) return;
3998
4133
  if (bytes.length < 1 + 24) return;
3999
4134
  const kind = bytes[0]!;
4000
4135
  const suffixKey = toBase64(bytes.subarray(1, 25));
4001
- const m = this.getMetricsForSuffixKey(suffixKey);
4136
+ const m = this.getControlMetrics(suffixKey, kind);
4002
4137
  m.controlSends += transmissions;
4003
4138
  const sentBytes = bytes.byteLength * transmissions;
4004
4139
  m.controlBytesSent += sentBytes;
@@ -4053,6 +4188,9 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
4053
4188
  break;
4054
4189
  case MSG_PARENT_PROBE_REQ:
4055
4190
  m.parentProbeReqSent += transmissions;
4191
+ if (probePurpose === "health") {
4192
+ m.parentHealthProbeReqSent += transmissions;
4193
+ }
4056
4194
  m.controlBytesSentJoin += sentBytes;
4057
4195
  break;
4058
4196
  case MSG_PARENT_PROBE_REPLY:
@@ -4077,7 +4215,7 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
4077
4215
  kind: number,
4078
4216
  bytesReceived: number,
4079
4217
  ) {
4080
- const m = this.getMetricsForSuffixKey(suffixKey);
4218
+ const m = this.getControlMetrics(suffixKey, kind);
4081
4219
  m.controlReceives += 1;
4082
4220
  m.controlBytesReceived += bytesReceived;
4083
4221
  switch (kind) {
@@ -4205,13 +4343,14 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
4205
4343
  to: string,
4206
4344
  bytes: Uint8Array,
4207
4345
  signal?: AbortSignal,
4346
+ probePurpose: "upgrade" | "health" = "upgrade",
4208
4347
  ) {
4209
4348
  if (signal?.aborted) {
4210
4349
  throw signal.reason ?? new AbortError("fanout control send aborted");
4211
4350
  }
4212
4351
  const stream = this.peers.get(to);
4213
4352
  if (!stream) return;
4214
- this.recordControlSend(bytes, 1);
4353
+ this.recordControlSend(bytes, 1, probePurpose);
4215
4354
  const message = await this.createMessage(bytes, {
4216
4355
  mode: new AnyWhere(),
4217
4356
  priority: CONTROL_PRIORITY,
@@ -4298,17 +4437,35 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
4298
4437
  tokens = Math.min(capacity, tokens + (elapsedMs * budgetBps) / 1_000);
4299
4438
  }
4300
4439
  if (tokens < costBytes) {
4301
- // LRU-touch on access to keep the bucket map bounded by active children.
4302
- byPeer.delete(fromHash);
4303
- byPeer.set(fromHash, { tokens, lastRefillAt: now });
4440
+ this.setIngressBucket(ch, byPeer, fromHash, { tokens, lastRefillAt: now });
4304
4441
  return false;
4305
4442
  }
4306
4443
  tokens -= costBytes;
4307
- byPeer.delete(fromHash);
4308
- byPeer.set(fromHash, { tokens, lastRefillAt: now });
4444
+ this.setIngressBucket(ch, byPeer, fromHash, { tokens, lastRefillAt: now });
4309
4445
  return true;
4310
4446
  }
4311
4447
 
4448
+ private setIngressBucket(
4449
+ ch: ChannelState,
4450
+ byPeer: Map<string, { tokens: number; lastRefillAt: number }>,
4451
+ fromHash: string,
4452
+ bucket: { tokens: number; lastRefillAt: number },
4453
+ ) {
4454
+ // LRU touch, capped: long-lived (pinned) roots see an open-ended set of
4455
+ // children over time, and buckets are not dropped when a child leaves.
4456
+ byPeer.delete(fromHash);
4457
+ byPeer.set(fromHash, bucket);
4458
+ const maxBuckets = Math.max(
4459
+ INGRESS_BUCKETS_MIN_ENTRIES,
4460
+ ch.maxChildren * INGRESS_BUCKETS_PER_CHILD,
4461
+ );
4462
+ while (byPeer.size > maxBuckets) {
4463
+ const oldest = byPeer.keys().next().value as string | undefined;
4464
+ if (oldest === undefined) break;
4465
+ byPeer.delete(oldest);
4466
+ }
4467
+ }
4468
+
4312
4469
  private async _sendData(
4313
4470
  ch: ChannelState,
4314
4471
  to: string[],
@@ -5894,24 +6051,56 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
5894
6051
  signal: AbortSignal,
5895
6052
  minFreeSlots = 0,
5896
6053
  reserveRootCapacity = true,
6054
+ purpose: "upgrade" | "health" = "upgrade",
5897
6055
  ): Promise<ParentProbeReply | undefined> {
5898
- if (!this.peers.get(parentHash)) return;
5899
- const reqId = (this.random() * 0xffffffff) >>> 0;
5900
- const p = new Promise<ParentProbeReply | undefined>((resolve) => {
5901
- ch.pendingParentProbe.set(reqId, { resolve });
5902
- });
5903
- await this._sendControl(
5904
- parentHash,
5905
- this.codec.encodeParentProbeReq(ch.id.key, reqId, minFreeSlots, reserveRootCapacity),
5906
- );
5907
- const res = await Promise.race([
5908
- p,
5909
- delay(Math.max(1, timeoutMs), { signal }).then(
5910
- (): undefined => undefined,
5911
- ),
5912
- ]);
5913
- if (!res) ch.pendingParentProbe.delete(reqId);
5914
- return res;
6056
+ const peer = this.peers.get(parentHash);
6057
+ if (!peer) return;
6058
+ const controller = new AbortController();
6059
+ let reqId = (this.random() * 0xffffffff) >>> 0;
6060
+ while (ch.pendingParentProbe.has(reqId)) reqId = (reqId + 1) >>> 0;
6061
+ let pending:
6062
+ | { peer: PeerStreams; resolve(reply?: ParentProbeReply): void }
6063
+ | undefined;
6064
+ let onAbort!: () => void;
6065
+ let timer: ReturnType<typeof setTimeout> | undefined;
6066
+ try {
6067
+ return await new Promise<ParentProbeReply | undefined>((resolve, reject) => {
6068
+ pending = { peer, resolve };
6069
+ ch.pendingParentProbe.set(reqId, pending);
6070
+ onAbort = () => {
6071
+ const reason = signal.reason ?? new AbortError("fanout probe aborted");
6072
+ controller.abort(reason);
6073
+ reject(reason);
6074
+ };
6075
+ signal.addEventListener("abort", onAbort, { once: true });
6076
+ if (signal.aborted) return onAbort();
6077
+ // Bound signing/queueing as well as the reply; a stuck send must not
6078
+ // prevent the join loop from recovering or closing the channel.
6079
+ timer = setTimeout(() => {
6080
+ controller.abort(new AbortError("fanout probe timed out"));
6081
+ resolve(undefined);
6082
+ }, Math.max(1, timeoutMs));
6083
+ timer.unref?.();
6084
+ void this._sendControl(
6085
+ parentHash,
6086
+ this.codec.encodeParentProbeReq(
6087
+ ch.id.key,
6088
+ reqId,
6089
+ minFreeSlots,
6090
+ reserveRootCapacity,
6091
+ ),
6092
+ controller.signal,
6093
+ purpose,
6094
+ ).catch(() => resolve(undefined));
6095
+ });
6096
+ } finally {
6097
+ clearTimeout(timer);
6098
+ signal.removeEventListener("abort", onAbort);
6099
+ if (ch.pendingParentProbe.get(reqId) === pending) {
6100
+ ch.pendingParentProbe.delete(reqId);
6101
+ }
6102
+ controller.abort(new AbortError("fanout probe settled"));
6103
+ }
5915
6104
  }
5916
6105
 
5917
6106
  private async _joinLoop(
@@ -6020,6 +6209,10 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
6020
6209
  if (initialJoinRemainingMs() === 0) throwInitialJoinTimeout();
6021
6210
  };
6022
6211
  let nextParentUpgradeCheckAt = 0;
6212
+ let nextParentHealthProbeAt = 0;
6213
+ let failedParentProbe:
6214
+ | { peer: PeerStreams; route: ChannelState["routeFromRoot"] }
6215
+ | undefined;
6023
6216
  let parentUpgradeCheckSeq = 0;
6024
6217
  let parentUpgradeActiveGuardBackoffMs = 0;
6025
6218
  const unsuccessfulColdBootstrapPeers = new Set<string>();
@@ -6110,8 +6303,10 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
6110
6303
 
6111
6304
  // Parent disappeared? Rejoin.
6112
6305
  if (ch.parent) {
6113
- const parentPeer = this.peers.get(ch.parent);
6306
+ const parentHash = ch.parent;
6307
+ const parentPeer = this.peers.get(parentHash);
6114
6308
  let connected = false;
6309
+ let probeFailed = false;
6115
6310
  if (parentPeer) {
6116
6311
  try {
6117
6312
  const conns = this.components.connectionManager.getConnections(
@@ -6122,8 +6317,61 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
6122
6317
  connected = parentPeer.isReadable || parentPeer.isWritable;
6123
6318
  }
6124
6319
  }
6320
+ const now = Date.now();
6321
+ if (now - ch.lastParentDataAt < PARENT_IDLE_PROBE_INTERVAL_MS) {
6322
+ failedParentProbe = undefined;
6323
+ } else if (
6324
+ connected &&
6325
+ parentPeer &&
6326
+ now >= nextParentHealthProbeAt
6327
+ ) {
6328
+ const route = ch.routeFromRoot;
6329
+ const lastDataAt = ch.lastParentDataAt;
6330
+ const reply = await this.probeParentCandidate(
6331
+ ch,
6332
+ parentHash,
6333
+ parentUpgrade.probe.timeoutMs,
6334
+ signal,
6335
+ 0,
6336
+ false,
6337
+ "health",
6338
+ );
6339
+ if (
6340
+ ch.closed ||
6341
+ signal.aborted ||
6342
+ this.channelsBySuffixKey.get(ch.id.suffixKey) !== ch ||
6343
+ ch.parent !== parentHash ||
6344
+ ch.routeFromRoot !== route ||
6345
+ this.peers.get(parentHash) !== parentPeer
6346
+ ) {
6347
+ failedParentProbe = undefined;
6348
+ continue;
6349
+ }
6350
+ nextParentHealthProbeAt = Date.now() + PARENT_IDLE_PROBE_INTERVAL_MS;
6351
+ // A full parent is still healthy. Do not reserve capacity or
6352
+ // refresh lastParentDataAt: probes must not mask stalled data.
6353
+ if (reply || ch.lastParentDataAt !== lastDataAt) {
6354
+ failedParentProbe = undefined;
6355
+ } else if (
6356
+ failedParentProbe?.peer === parentPeer &&
6357
+ failedParentProbe.route === route
6358
+ ) {
6359
+ probeFailed = true;
6360
+ connected = false;
6361
+ cooldownUntilByHash.set(
6362
+ parentHash,
6363
+ Date.now() + candidateCooldownMs,
6364
+ );
6365
+ } else {
6366
+ failedParentProbe = { peer: parentPeer, route };
6367
+ nextParentHealthProbeAt = Date.now() + Math.max(retryMs, 1_000);
6368
+ }
6369
+ }
6125
6370
  if (!connected) {
6126
- ch.metrics.reparentDisconnect += 1;
6371
+ if (probeFailed) ch.metrics.reparentStale += 1;
6372
+ else ch.metrics.reparentDisconnect += 1;
6373
+ failedParentProbe = undefined;
6374
+ nextParentHealthProbeAt = 0;
6127
6375
  const hadChildren = ch.children.size > 0;
6128
6376
  this.detachFromParent(ch);
6129
6377
  nextParentUpgradeCheckAt = 0;
@@ -8424,11 +8672,7 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
8424
8672
  const want = Math.max(1, decoded.want);
8425
8673
  const ttlMs = Math.min(120_000, Math.max(1_000, decoded.ttlMs));
8426
8674
  const now = Date.now();
8427
- let watchers = this.providerWatchersBySuffixKey.get(suffixKey);
8428
- if (!watchers) {
8429
- watchers = new Map<string, ProviderWatchRegistration>();
8430
- this.providerWatchersBySuffixKey.set(suffixKey, watchers);
8431
- }
8675
+ const watchers = this.touchProviderWatchers(suffixKey);
8432
8676
  watchers.set(fromHash, {
8433
8677
  hash: fromHash,
8434
8678
  want,
@@ -8566,7 +8810,14 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
8566
8810
  if (!decoded) return false;
8567
8811
  const { reqId, ...reply } = decoded;
8568
8812
  const pending = ch.pendingParentProbe.get(reqId);
8569
- if (!pending) return true;
8813
+ if (
8814
+ !pending ||
8815
+ pending.peer !== peerStream ||
8816
+ pending.peer.publicKey.hashcode() !== fromHash ||
8817
+ this.peers.get(fromHash) !== peerStream
8818
+ ) {
8819
+ return true;
8820
+ }
8570
8821
  ch.pendingParentProbe.delete(reqId);
8571
8822
  pending.resolve(reply);
8572
8823
  this.touchPeerHint(ch, fromHash);
@@ -8851,7 +9102,8 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
8851
9102
  void this.kickChildHashes(ch, [worstChild]).catch(() => {});
8852
9103
  }
8853
9104
 
8854
- ch.children.set(fromHash, { bidPerByte });
9105
+ const child = { bidPerByte };
9106
+ ch.children.set(fromHash, child);
8855
9107
  if (ch.isRoot && consumedParentUpgradeReservation) {
8856
9108
  ch.parentUpgradeTrackerNoCapacityUntil = Math.max(
8857
9109
  ch.parentUpgradeTrackerNoCapacityUntil,
@@ -8868,6 +9120,12 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
8868
9120
  );
8869
9121
  void (async () => {
8870
9122
  await this._sendControl(fromHash, joinAccept);
9123
+ if (ch.closed || ch.children.get(fromHash) !== child) return;
9124
+ this.dispatchEvent(
9125
+ new CustomEvent("fanout:child-joined", {
9126
+ detail: { topic: ch.id.topic, root: ch.id.root, child: fromHash },
9127
+ }),
9128
+ );
8871
9129
  if (ch.endSeqExclusive > 0) {
8872
9130
  await this._sendControl(
8873
9131
  fromHash,
@@ -9311,6 +9569,21 @@ export class FanoutTree extends DirectStream<FanoutTreeEvents> {
9311
9569
  }
9312
9570
 
9313
9571
  if (kind === MSG_KICK) {
9572
+ if (this.peers.get(fromHash) !== peerStream) return true;
9573
+ if (fromHash !== ch.parent) {
9574
+ if (ch.parentUpgradeGrace?.previousParent === fromHash) {
9575
+ // This attachment was evicted; never roll back to it.
9576
+ this.clearParentUpgradeGrace(ch);
9577
+ }
9578
+ if (ch.parentShadow?.hash === fromHash) {
9579
+ ch.parentShadow = undefined;
9580
+ ch.metrics.parentShadowReset += 1;
9581
+ if (this.parentUpgradeShadowInFlightSuffixKey === ch.id.suffixKey) {
9582
+ this.parentUpgradeShadowInFlightSuffixKey = undefined;
9583
+ }
9584
+ }
9585
+ return true;
9586
+ }
9314
9587
  ch.metrics.reparentKicked += 1;
9315
9588
  ch.parent = undefined;
9316
9589
  ch.level = Number.POSITIVE_INFINITY;