p2p-fret 0.5.0 → 0.6.0

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.
@@ -1,6 +1,12 @@
1
- import type { DigitreeStore } from '../store/digitree-store.js';
1
+ import type { DigitreeStore, PeerEntry } from '../store/digitree-store.js';
2
2
  export type SizeEstimate = {
3
3
  n: number;
4
4
  confidence: number;
5
5
  };
6
- export declare function estimateSizeAndConfidence(store: DigitreeStore, m: number): SizeEstimate;
6
+ /**
7
+ * Online network-size estimate from inter-peer ring gaps. An optional `filter` scopes the
8
+ * estimate to a subset of the store (e.g. same-network members only) so foreign peers from
9
+ * a co-resident network can't inflate `n_est` and the derived cluster span / near-radius.
10
+ * Defaults to counting every entry, leaving the standalone/simulator path unchanged.
11
+ */
12
+ export declare function estimateSizeAndConfidence(store: DigitreeStore, m: number, filter?: (e: PeerEntry) => boolean): SizeEstimate;
@@ -11,8 +11,14 @@ function medianBigInt(values) {
11
11
  const mid = Math.floor(arr.length / 2);
12
12
  return arr.length % 2 === 0 ? (arr[mid - 1] + arr[mid]) / 2n : arr[mid];
13
13
  }
14
- export function estimateSizeAndConfidence(store, m) {
15
- const peers = store.list();
14
+ /**
15
+ * Online network-size estimate from inter-peer ring gaps. An optional `filter` scopes the
16
+ * estimate to a subset of the store (e.g. same-network members only) so foreign peers from
17
+ * a co-resident network can't inflate `n_est` and the derived cluster span / near-radius.
18
+ * Defaults to counting every entry, leaving the standalone/simulator path unchanged.
19
+ */
20
+ export function estimateSizeAndConfidence(store, m, filter) {
21
+ const peers = filter ? store.list().filter(filter) : store.list();
16
22
  const count = peers.length;
17
23
  if (count === 0)
18
24
  return { n: 0, confidence: 0 };
@@ -120,7 +120,7 @@ export { hashKey, hashPeerId } from './ring/hash.js';
120
120
  export type { RingCoord } from './ring/hash.js';
121
121
  export { xorDistance, clockwiseDistance, minDistance, lexLess } from './ring/distance.js';
122
122
  export { DigitreeStore } from './store/digitree-store.js';
123
- export type { PeerEntry, PeerState } from './store/digitree-store.js';
123
+ export type { PeerEntry, PeerState, MembershipState } from './store/digitree-store.js';
124
124
  export { estimateSizeAndConfidence } from './estimate/size-estimator.js';
125
125
  export type { SizeEstimate } from './estimate/size-estimator.js';
126
126
  export { assembleCohort } from './service/cohort.js';
@@ -1,5 +1,5 @@
1
1
  import { peerIdFromString } from '@libp2p/peer-id';
2
- import { PROTOCOL_LEAVE, encodeJson, decodeJson, readAllBounded } from './protocols.js';
2
+ import { PROTOCOL_LEAVE, encodeJson, decodeJson, readAllBounded, openRpcStream } from './protocols.js';
3
3
  import { createLogger } from '../logger.js';
4
4
  const log = createLogger('rpc:leave');
5
5
  const MAX_REPLACEMENTS = 12;
@@ -33,15 +33,9 @@ export function registerLeave(node, onLeave, protocol = PROTOCOL_LEAVE) {
33
33
  }
34
34
  export async function sendLeave(node, peerIdStr, notice, protocol = PROTOCOL_LEAVE) {
35
35
  const pid = peerIdFromString(peerIdStr);
36
- const conns = node.getConnections(pid);
37
36
  let stream;
38
37
  try {
39
- if (conns.length > 0) {
40
- stream = await conns[0].newStream([protocol]);
41
- }
42
- else {
43
- stream = await node.dialProtocol(pid, [protocol]);
44
- }
38
+ stream = await openRpcStream(node, pid, [protocol]);
45
39
  stream.send(await encodeJson(notice));
46
40
  await stream.close();
47
41
  }
@@ -1,5 +1,5 @@
1
1
  import { peerIdFromString } from '@libp2p/peer-id';
2
- import { PROTOCOL_MAYBE_ACT, encodeJson, decodeJson, readAllBounded } from './protocols.js';
2
+ import { PROTOCOL_MAYBE_ACT, encodeJson, decodeJson, readAllBounded, openRpcStream } from './protocols.js';
3
3
  import { createLogger } from '../logger.js';
4
4
  const log = createLogger('rpc:maybeAct');
5
5
  export function registerMaybeAct(node, handle, protocol = PROTOCOL_MAYBE_ACT, maxBytes = 512 * 1024) {
@@ -18,15 +18,9 @@ export function registerMaybeAct(node, handle, protocol = PROTOCOL_MAYBE_ACT, ma
18
18
  }
19
19
  export async function sendMaybeAct(node, peerIdStr, msg, protocol = PROTOCOL_MAYBE_ACT) {
20
20
  const pid = peerIdFromString(peerIdStr);
21
- const conns = node.getConnections(pid);
22
21
  let stream;
23
22
  try {
24
- if (conns.length > 0) {
25
- stream = await conns[0].newStream([protocol]);
26
- }
27
- else {
28
- stream = await node.dialProtocol(pid, [protocol]);
29
- }
23
+ stream = await openRpcStream(node, pid, [protocol]);
30
24
  stream.send(await encodeJson(msg));
31
25
  await stream.close();
32
26
  const bytes = await readAllBounded(stream, 512 * 1024);
@@ -1,5 +1,5 @@
1
1
  import { peerIdFromString } from '@libp2p/peer-id';
2
- import { PROTOCOL_NEIGHBORS, PROTOCOL_NEIGHBORS_ANNOUNCE, encodeJson, decodeJson, readAllBounded, } from './protocols.js';
2
+ import { PROTOCOL_NEIGHBORS, PROTOCOL_NEIGHBORS_ANNOUNCE, encodeJson, decodeJson, readAllBounded, openRpcStream, } from './protocols.js';
3
3
  import { createLogger } from '../logger.js';
4
4
  const log = createLogger('rpc:neighbors');
5
5
  export function registerNeighbors(node, getSnapshot, onAnnounce, protocols = { PROTOCOL_NEIGHBORS, PROTOCOL_NEIGHBORS_ANNOUNCE }, maxBytes = 128 * 1024) {
@@ -30,14 +30,13 @@ export function registerNeighbors(node, getSnapshot, onAnnounce, protocols = { P
30
30
  }
31
31
  export async function fetchNeighbors(node, peerIdOrStr, protocol = PROTOCOL_NEIGHBORS) {
32
32
  const pid = peerIdFromString(peerIdOrStr);
33
- const conns = node.getConnections(pid);
34
- if (conns.length === 0) {
35
- // No existing connection - skip to reduce churn
36
- return { v: 1, from: peerIdOrStr, timestamp: Date.now(), successors: [], predecessors: [], sig: '' };
37
- }
38
33
  let stream;
39
34
  try {
40
- stream = await conns[0].newStream([protocol]);
35
+ stream = await openRpcStream(node, pid, [protocol], { requireExisting: true });
36
+ if (stream == null) {
37
+ // No existing connection - skip to reduce churn
38
+ return { v: 1, from: peerIdOrStr, timestamp: Date.now(), successors: [], predecessors: [], sig: '' };
39
+ }
41
40
  const bytes = await readAllBounded(stream, 128 * 1024);
42
41
  const res = await decodeJson(bytes);
43
42
  if ('busy' in res && res.busy) {
@@ -60,13 +59,12 @@ export async function fetchNeighbors(node, peerIdOrStr, protocol = PROTOCOL_NEIG
60
59
  }
61
60
  export async function announceNeighbors(node, peerIdOrStr, snapshot, protocol = PROTOCOL_NEIGHBORS_ANNOUNCE) {
62
61
  const pid = peerIdFromString(peerIdOrStr);
63
- const conns = node.getConnections(pid);
64
- if (conns.length === 0) {
65
- return; // skip if not connected
66
- }
67
62
  let stream;
68
63
  try {
69
- stream = await conns[0].newStream([protocol]);
64
+ stream = await openRpcStream(node, pid, [protocol], { requireExisting: true });
65
+ if (stream == null) {
66
+ return; // skip if not connected
67
+ }
70
68
  stream.send(await encodeJson(snapshot));
71
69
  await stream.close();
72
70
  }
@@ -1,5 +1,5 @@
1
1
  import { peerIdFromString } from '@libp2p/peer-id';
2
- import { PROTOCOL_PING, encodeJson, decodeJson, readAllBounded } from './protocols.js';
2
+ import { PROTOCOL_PING, encodeJson, decodeJson, readAllBounded, openRpcStream } from './protocols.js';
3
3
  import { createLogger } from '../logger.js';
4
4
  const log = createLogger('rpc:ping');
5
5
  function isBusy(res) {
@@ -37,13 +37,7 @@ export async function sendPing(node, peer, protocol = PROTOCOL_PING) {
37
37
  const pid = peerIdFromString(peer);
38
38
  let stream;
39
39
  try {
40
- const conns = node.getConnections(pid);
41
- if (conns.length > 0) {
42
- stream = await conns[0].newStream([protocol]);
43
- }
44
- else {
45
- stream = await node.dialProtocol(pid, [protocol]);
46
- }
40
+ stream = await openRpcStream(node, pid, [protocol]);
47
41
  const bytes = await readAllBounded(stream, 1024);
48
42
  const rttMs = Math.max(0, Date.now() - start);
49
43
  if (bytes.length === 0)
@@ -1,3 +1,5 @@
1
+ import type { Libp2p } from 'libp2p';
2
+ import type { PeerId, Stream } from '@libp2p/interface';
1
3
  export declare function makeProtocols(networkName?: string): {
2
4
  PROTOCOL_NEIGHBORS: string;
3
5
  PROTOCOL_NEIGHBORS_ANNOUNCE: string;
@@ -10,6 +12,17 @@ export declare const PROTOCOL_NEIGHBORS_ANNOUNCE = "/optimystic/default/fret/1.0
10
12
  export declare const PROTOCOL_MAYBE_ACT = "/optimystic/default/fret/1.0.0/maybeAct";
11
13
  export declare const PROTOCOL_LEAVE = "/optimystic/default/fret/1.0.0/leave";
12
14
  export declare const PROTOCOL_PING = "/optimystic/default/fret/1.0.0/ping";
15
+ /**
16
+ * True when `err` is libp2p's protocol-negotiation failure — i.e. the remote does
17
+ * not support the dialed protocol. Thrown by `dialProtocol`/`newStream` (via
18
+ * multistream-select) as `UnsupportedProtocolError`.
19
+ *
20
+ * This is the definitive "foreign network" signal for namespaced FRET RPCs: it must
21
+ * be distinguished from a generic timeout / transient failure (which leaves a peer
22
+ * unclassified). Matches by `name` (modern libp2p) and `code` (legacy
23
+ * `ERR_UNSUPPORTED_PROTOCOL`), with a message-substring fallback.
24
+ */
25
+ export declare function isUnsupportedProtocolError(err: unknown): boolean;
13
26
  export declare function encodeJson(obj: unknown): Promise<Uint8Array>;
14
27
  export declare function decodeJson<T = unknown>(bytes: Uint8Array): Promise<T>;
15
28
  export declare function toBytes(chunk: Uint8Array | {
@@ -19,3 +32,20 @@ export declare function readAllBounded(stream: AsyncIterable<Uint8Array | {
19
32
  subarray(): Uint8Array;
20
33
  }>, maxBytes: number, timeoutMs?: number): Promise<Uint8Array>;
21
34
  export declare function validateTimestamp(ts: number, maxDriftMs?: number): boolean;
35
+ /**
36
+ * Open an RPC stream to `pid`, preferring a DIRECT open connection and falling
37
+ * back to a limited (circuit-relay) one.
38
+ *
39
+ * `runOnLimitedConnection: true` is REQUIRED for the relayed path — libp2p
40
+ * rejects a stream over a limited connection without it — and is a harmless
41
+ * no-op on a direct connection. Preferring a direct connection avoids riding a
42
+ * circuit that the relay can reset once a per-circuit cap or reservation lapses
43
+ * (and which briefly coexists with the upgraded direct link after DCUtR).
44
+ *
45
+ * When `requireExisting` is set the caller skips dialing if no connection
46
+ * exists (neighbors fetch/announce reduce churn this way) and `undefined` is
47
+ * returned; otherwise we `dialProtocol`.
48
+ */
49
+ export declare function openRpcStream(node: Libp2p, pid: PeerId, protocols: string[], opts?: {
50
+ requireExisting?: boolean;
51
+ }): Promise<Stream | undefined>;
@@ -14,6 +14,31 @@ export const PROTOCOL_NEIGHBORS_ANNOUNCE = '/optimystic/default/fret/1.0.0/neigh
14
14
  export const PROTOCOL_MAYBE_ACT = '/optimystic/default/fret/1.0.0/maybeAct';
15
15
  export const PROTOCOL_LEAVE = '/optimystic/default/fret/1.0.0/leave';
16
16
  export const PROTOCOL_PING = '/optimystic/default/fret/1.0.0/ping';
17
+ /**
18
+ * True when `err` is libp2p's protocol-negotiation failure — i.e. the remote does
19
+ * not support the dialed protocol. Thrown by `dialProtocol`/`newStream` (via
20
+ * multistream-select) as `UnsupportedProtocolError`.
21
+ *
22
+ * This is the definitive "foreign network" signal for namespaced FRET RPCs: it must
23
+ * be distinguished from a generic timeout / transient failure (which leaves a peer
24
+ * unclassified). Matches by `name` (modern libp2p) and `code` (legacy
25
+ * `ERR_UNSUPPORTED_PROTOCOL`), with a message-substring fallback.
26
+ */
27
+ export function isUnsupportedProtocolError(err) {
28
+ if (err == null || typeof err !== 'object')
29
+ return false;
30
+ const e = err;
31
+ if (e.name === 'UnsupportedProtocolError')
32
+ return true;
33
+ if (e.code === 'ERR_UNSUPPORTED_PROTOCOL')
34
+ return true;
35
+ if (typeof e.message === 'string') {
36
+ const m = e.message.toLowerCase();
37
+ if (m.includes('could not negotiate') || m.includes('protocol selection failed'))
38
+ return true;
39
+ }
40
+ return false;
41
+ }
17
42
  export async function encodeJson(obj) {
18
43
  const text = JSON.stringify(obj);
19
44
  return new TextEncoder().encode(text);
@@ -79,3 +104,42 @@ export async function readAllBounded(stream, maxBytes, timeoutMs = 5000) {
79
104
  export function validateTimestamp(ts, maxDriftMs = 300_000) {
80
105
  return Math.abs(Date.now() - ts) <= maxDriftMs;
81
106
  }
107
+ /**
108
+ * True for a circuit-relay ("limited") connection. libp2p stamps a relayed
109
+ * connection with `limits` (per-circuit data/duration caps); we additionally
110
+ * sniff the multiaddr for `/p2p-circuit` as a fallback for transports/versions
111
+ * that don't populate `limits`.
112
+ */
113
+ function isLimitedConnection(c) {
114
+ if (c.limits != null)
115
+ return true;
116
+ const addr = c.remoteAddr?.toString?.();
117
+ return addr != null && addr.includes('/p2p-circuit');
118
+ }
119
+ /**
120
+ * Open an RPC stream to `pid`, preferring a DIRECT open connection and falling
121
+ * back to a limited (circuit-relay) one.
122
+ *
123
+ * `runOnLimitedConnection: true` is REQUIRED for the relayed path — libp2p
124
+ * rejects a stream over a limited connection without it — and is a harmless
125
+ * no-op on a direct connection. Preferring a direct connection avoids riding a
126
+ * circuit that the relay can reset once a per-circuit cap or reservation lapses
127
+ * (and which briefly coexists with the upgraded direct link after DCUtR).
128
+ *
129
+ * When `requireExisting` is set the caller skips dialing if no connection
130
+ * exists (neighbors fetch/announce reduce churn this way) and `undefined` is
131
+ * returned; otherwise we `dialProtocol`.
132
+ */
133
+ export async function openRpcStream(node, pid, protocols, opts = {}) {
134
+ const open = node.getConnections(pid)
135
+ .filter(c => c?.status === 'open' && typeof c?.newStream === 'function');
136
+ // Prefer a direct connection; fall back to the limited one only when it is
137
+ // the only open path (the steady state for browsers and NATed peers).
138
+ const chosen = open.find(c => !isLimitedConnection(c)) ?? open[0];
139
+ const streamOpts = { runOnLimitedConnection: true, negotiateFully: false };
140
+ if (chosen)
141
+ return chosen.newStream(protocols, streamOpts);
142
+ if (opts.requireExisting)
143
+ return undefined;
144
+ return node.dialProtocol(pid, protocols, streamOpts);
145
+ }
@@ -1,4 +1,4 @@
1
- import type { DigitreeStore } from '../store/digitree-store.js';
1
+ import type { DigitreeStore, PeerEntry } from '../store/digitree-store.js';
2
2
  /**
3
3
  * Two-sided cohort assembly: an alternating successor/predecessor walk outward from
4
4
  * `hashedCoord`, returning up to `wants` distinct peer ids. Auto-adapts when fewer than
@@ -7,5 +7,10 @@ import type { DigitreeStore } from '../store/digitree-store.js';
7
7
  * Pure over the store — no libp2p/node state — so the same selection logic is shared by
8
8
  * `FretService.assembleCohort` and out-of-band consumers (e.g. the Optimystic design
9
9
  * simulator) with zero divergence.
10
+ *
11
+ * `filter` is forwarded to the underlying ring walks: with it, the walk skips non-matching
12
+ * entries and keeps advancing (so a cluster of foreign peers near the key can't starve the
13
+ * cohort — the over-fetch of `wants * 2` plus skip-and-continue still reaches `wants`
14
+ * matching members). Defaults to no filter, leaving the standalone/simulator path unchanged.
10
15
  */
11
- export declare function assembleCohort(store: DigitreeStore, hashedCoord: Uint8Array, wants: number, exclude?: Set<string>): string[];
16
+ export declare function assembleCohort(store: DigitreeStore, hashedCoord: Uint8Array, wants: number, exclude?: Set<string>, filter?: (e: PeerEntry) => boolean): string[];
@@ -6,12 +6,17 @@
6
6
  * Pure over the store — no libp2p/node state — so the same selection logic is shared by
7
7
  * `FretService.assembleCohort` and out-of-band consumers (e.g. the Optimystic design
8
8
  * simulator) with zero divergence.
9
+ *
10
+ * `filter` is forwarded to the underlying ring walks: with it, the walk skips non-matching
11
+ * entries and keeps advancing (so a cluster of foreign peers near the key can't starve the
12
+ * cohort — the over-fetch of `wants * 2` plus skip-and-continue still reaches `wants`
13
+ * matching members). Defaults to no filter, leaving the standalone/simulator path unchanged.
9
14
  */
10
- export function assembleCohort(store, hashedCoord, wants, exclude) {
15
+ export function assembleCohort(store, hashedCoord, wants, exclude, filter) {
11
16
  const out = [];
12
17
  const ex = exclude ?? new Set();
13
- const succIds = store.neighborsRight(hashedCoord, wants * 2);
14
- const predIds = store.neighborsLeft(hashedCoord, wants * 2);
18
+ const succIds = store.neighborsRight(hashedCoord, wants * 2, filter);
19
+ const predIds = store.neighborsLeft(hashedCoord, wants * 2, filter);
15
20
  let si = 0, pi = 0;
16
21
  while (out.length < wants && (si < succIds.length || pi < predIds.length)) {
17
22
  if (out.length % 2 === 0 && si < succIds.length) {
@@ -1,13 +1,17 @@
1
1
  import type { Startable } from '@libp2p/interface';
2
2
  import type { FretService as IFretService, FretMode, FretConfig, ReportEvent, RouteAndMaybeActV1, NearAnchorV1, SerializedTable, ActivityHandler, RouteProgress, LookupOptions } from '../index.js';
3
- import { DigitreeStore } from '../store/digitree-store.js';
3
+ import { DigitreeStore, type PeerEntry } from '../store/digitree-store.js';
4
4
  import type { Libp2p } from 'libp2p';
5
5
  import { type SparsityModel } from '../store/relevance.js';
6
6
  /**
7
7
  * Select sample entries spread across diverse ring positions using sparsity-biased scoring.
8
8
  * Excludes self and entries already in successors/predecessors (they're redundant).
9
+ *
10
+ * `filter` scopes the candidate set (FretService passes the member predicate so an outgoing
11
+ * snapshot never advertises a foreign peer to same-network neighbors). Defaults to no filter,
12
+ * leaving the exported standalone unchanged.
9
13
  */
10
- export declare function selectDiverseSample(store: DigitreeStore, selfCoord: Uint8Array, sparsity: SparsityModel, excludeIds: Set<string>, cap: number): Array<{
14
+ export declare function selectDiverseSample(store: DigitreeStore, selfCoord: Uint8Array, sparsity: SparsityModel, excludeIds: Set<string>, cap: number, filter?: (e: PeerEntry) => boolean): Array<{
11
15
  id: string;
12
16
  coord: string;
13
17
  relevance: number;
@@ -18,6 +22,8 @@ export declare class FretService implements IFretService, Startable {
18
22
  private readonly cfg;
19
23
  private readonly node;
20
24
  private stabilizing;
25
+ private stopped;
26
+ private readonly nodeListeners;
21
27
  private inflightAct;
22
28
  private readonly bucketNeighbors;
23
29
  private readonly bucketMaybeAct;
@@ -51,8 +57,27 @@ export declare class FretService implements IFretService, Startable {
51
57
  private applyTouch;
52
58
  private applySuccess;
53
59
  private applyFailure;
60
+ /** Confirm `id` serves this network (idempotent; no-op if absent or already member). */
61
+ private markMember;
62
+ /**
63
+ * Confirm `id` does NOT serve this network (idempotent). Foreign is reversible —
64
+ * a later successful namespaced RPC or re-identify promotes it back to member.
65
+ * We tag and retain rather than evict: an evicted foreign peer is just re-added by
66
+ * the next peer:connect/peerStore seed and re-probed in a loop.
67
+ */
68
+ private markForeign;
69
+ /**
70
+ * Classify `id` from a libp2p-reported protocol list (available once identify has
71
+ * run). Member if any of our namespaced protocols appears; foreign if the list is
72
+ * non-empty but contains none of them; left unknown if empty (identify pending).
73
+ */
74
+ private classifyByProtocols;
54
75
  start(): Promise<void>;
55
76
  stop(): Promise<void>;
77
+ /** Register a node event listener and track it so stop() can detach it. */
78
+ private addNodeListener;
79
+ /** Detach all node event listeners registered via addNodeListener. */
80
+ private removeNodeListeners;
56
81
  setMode(mode: FretMode): void;
57
82
  ready(): Promise<void>;
58
83
  private maxBytesNeighbors;
@@ -75,12 +100,50 @@ export declare class FretService implements IFretService, Startable {
75
100
  private announceOnDeparture;
76
101
  private isNearNeighbor;
77
102
  private announceToNewPeers;
103
+ /**
104
+ * Feed a received snapshot's network-size estimate into the local estimator.
105
+ * No-op unless both estimate and confidence are present and positive.
106
+ */
107
+ private calibrateSizeFromSnapshot;
78
108
  private mergeAnnounceSnapshot;
79
109
  private seedFromPeerStore;
80
110
  private startStabilizationLoop;
81
111
  private seedFromBootstraps;
82
112
  private stabilizeOnce;
83
113
  private probeNeighborsLatency;
114
+ /**
115
+ * Bounded classification probe pass over `unknown` peers.
116
+ *
117
+ * Normal traffic only touches peers the ring already selects, but the follow-on
118
+ * gating work will exclude `unknown` peers from the ring — so without a dedicated
119
+ * pass an unknown same-network peer would never be selected, never probed, and be
120
+ * permanently starved. This iterates unknowns directly from the store (not through
121
+ * ring views), prefers connected / has-addresses ones, and sends a namespaced ping
122
+ * to at most N per tick. It runs only while unknowns exist; in single-network
123
+ * steady state every peer becomes member and this is a no-op (no extra traffic).
124
+ */
125
+ private classifyUnknownPeers;
126
+ /**
127
+ * Occasionally re-probe a few `foreign` peers so a same-network peer that was *mislabeled*
128
+ * foreign (e.g. identify completed before the peer registered our protocol handlers) is
129
+ * re-admitted via a successful namespaced ping. Before ring-membership gating this self-
130
+ * healed for free — `probeNeighborsLatency` pinged near ring peers regardless of label —
131
+ * but gating excludes foreign peers from the ring, closing that path. The `peer:update`
132
+ * identify path also re-admits, but it is not guaranteed (it depends on the remote pushing
133
+ * an identify update), so this RPC path is the backstop the gating work owes (per the
134
+ * `ring-membership-classification` review tripwire).
135
+ *
136
+ * Bounded and self-limiting: only a couple of off-backoff foreign peers per tick, and a
137
+ * confirmed-foreign probe records a growing backoff (see `probeMembership`), so a genuinely
138
+ * foreign peer is re-probed at most ~once per backoff window rather than every tick.
139
+ */
140
+ private reprobeForeignPeers;
141
+ /**
142
+ * Probe a single peer's membership with a namespaced ping. Success → member;
143
+ * unsupported-protocol → foreign; timeout / transient → stay unknown but back off
144
+ * so we don't hammer an unreachable-but-connected peer every tick.
145
+ */
146
+ private probeMembership;
84
147
  private mergeNeighborSnapshots;
85
148
  private snapshot;
86
149
  neighborDistance(selfId: string, hashedCoord: Uint8Array, k: number): number;
@@ -101,6 +164,7 @@ export declare class FretService implements IFretService, Startable {
101
164
  private recordBackoff;
102
165
  private clearBackoff;
103
166
  private getBackoffPenalty;
167
+ private pruneBackoffMap;
104
168
  report(_evt: ReportEvent): void;
105
169
  /**
106
170
  * Add an external network size observation (e.g., from cluster messages, peer queries)