p2p-fret 1.2.0 → 1.2.1

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.
@@ -638,7 +638,9 @@ export declare class FretService implements IFretService, Startable {
638
638
  * Classify `id` from a libp2p-reported protocol list (available once identify has
639
639
  * run). Member if any of our namespaced protocols appears; foreign if the list is
640
640
  * non-empty but contains none of them; left unknown if empty (identify pending).
641
- * The demotion arm is deliberately weak — see `applyMembershipSignal`.
641
+ * The demotion arm is deliberately weak — see `applyMembershipSignal`. A non-empty list also
642
+ * records whether it named ours (`PeerEntry.advertisedForeign`), which the label alone cannot
643
+ * say for a member — see {@link noteAdvertisedProtocols}.
642
644
  *
643
645
  * NOTE: the positive arm resets the negotiate-failure run (identify listing one of our protocols
644
646
  * is strong evidence), so a stale-positive list re-applied often enough would keep a peer that
@@ -656,6 +658,16 @@ export declare class FretService implements IFretService, Startable {
656
658
  * service-only-outage block still runs on identify nodes, the realistic rig.
657
659
  */
658
660
  private classifyByProtocols;
661
+ /**
662
+ * Record on `id`'s entry whether its latest non-empty advertised protocol list named ours
663
+ * (`PeerEntry.advertisedForeign`) — whatever its label, which the evidence-strength ordering
664
+ * settles separately: a confirmed member stays `member` on a list lacking ours, but the flag
665
+ * still says what it now advertises. Never a change on its own: clearing it coincides with the
666
+ * `identify-member` promotion, which counts for itself, and setting it on a member moves nothing
667
+ * a pass acts on — the flag only helps keep an *off-ring* peer out of the re-probe arms (see
668
+ * {@link isSettledForeign}).
669
+ */
670
+ private noteAdvertisedProtocols;
659
671
  /**
660
672
  * Admit a peer libp2p's peerStore told us about, then classify it off `protocols` — the shared
661
673
  * step of the `peer:update` / `peer:identify` listeners and the start walk.
@@ -1400,6 +1412,9 @@ export declare class FretService implements IFretService, Startable {
1400
1412
  *
1401
1413
  * The gate lives here rather than inside the selectors so a bare selector call (from a test)
1402
1414
  * still exercises the real selection logic against the live table.
1415
+ *
1416
+ * The two selections come back apart ({@link PhaseTwoTargets}) because only the classify arm
1417
+ * holds the idle backoff at its base; the tick pools them together.
1403
1418
  */
1404
1419
  private phaseTwoTargets;
1405
1420
  /**
@@ -1438,9 +1453,12 @@ export declare class FretService implements IFretService, Startable {
1438
1453
  * - **`foreign`** — a same-network peer *mislabeled* foreign (e.g. identify completed before
1439
1454
  * it registered our protocol handlers) is re-admitted by a successful namespaced ping.
1440
1455
  * Before ring-membership gating this self-healed for free (`probeNeighborLatency` pinged
1441
- * near ring peers regardless of label); gating closed that path. The `peer:update` identify
1442
- * path also re-admits, but only if the remote pushes an update, so this is the dependable
1443
- * backstop.
1456
+ * near ring peers regardless of label); gating closed that path. For a peer demoted by
1457
+ * negotiate strikes whose latest advertised list still names ours (a same-network peer
1458
+ * mid-restart on a host without `identifyPush`) this is the dependable backstop. A peer whose
1459
+ * latest list names none of ours is re-probed only until it has refused a full negotiate run
1460
+ * (see {@link isSettledForeign}); after that it comes back through identify delivering a list
1461
+ * that does, or its own inbound RPC.
1444
1462
  * - **`dead`** — a peer marked dead by a run of failed contacts is out of every ring view, so
1445
1463
  * `stabilizeOnce` (which draws its probe targets from `getNeighbors`) never touches it
1446
1464
  * again. Without this arm a peer that recovers but never dials us and never forms a
@@ -1458,6 +1476,18 @@ export declare class FretService implements IFretService, Startable {
1458
1476
  * `entries` defaults to a fresh `store.list()`, exactly as {@link classifyTargets}'s does.
1459
1477
  */
1460
1478
  private reprobeExcludedTargets;
1479
+ /**
1480
+ * Whether `e` is foreign on both kinds of evidence at once: its latest advertised protocol list
1481
+ * names none of ours (`advertisedForeign`), *and* a full run of spaced negotiate refusals since
1482
+ * the last positive proof says it really answers none of them. Neither alone stops re-probing.
1483
+ * The list can be stale — captured before the remote's FRET registered its handlers, the
1484
+ * ordinary shape for a host that starts the core service by hand after its node — and without
1485
+ * `identifyPush` nothing refreshes it: two such peers trusting the list alone would never contact
1486
+ * each other at all (`test/address-hints.relay.spec.ts` is that rig). A refusal run alone is what
1487
+ * a same-network peer mid-restart produces, which is what the foreign arm exists to recover.
1488
+ * Costs a relay {@link NEGOTIATE_FAILURE_THRESHOLD} failed pings per run, then silence.
1489
+ */
1490
+ private isSettledForeign;
1461
1491
  /**
1462
1492
  * Select one bounded re-probe arm's targets — peers matching `isCandidate` — the shared
1463
1493
  * mechanics of both arms of {@link reprobeExcludedTargets}: off-backoff and reachable candidates
@@ -1469,6 +1499,14 @@ export declare class FretService implements IFretService, Startable {
1469
1499
  * re-probed at most ~once per backoff window (doubling to a 32× cap) rather than every tick.
1470
1500
  * That growing factor is also what rotates the list under tick truncation: a probed peer's
1471
1501
  * factor grows, so it sorts behind the ones a truncated tick never reached.
1502
+ *
1503
+ * A peer that is foreign on both kinds of evidence ({@link isSettledForeign} — a relay, another
1504
+ * network's node, once it has refused a run of our probes) is skipped by **both** arms: answering
1505
+ * our ping cannot make it a ring member, so probing it — and redialing it when disconnected —
1506
+ * was traffic forever with nothing to recover. If it does start serving this network, identify
1507
+ * delivers the new list (`identifyPush`, or a reconnect re-running identify), or it contacts us
1508
+ * itself; a dead one that comes back is revived by `peer:connect`. The filter only removes
1509
+ * candidates, so the separate per-arm budgets stand.
1472
1510
  */
1473
1511
  private reprobeOffRingTargets;
1474
1512
  /**
@@ -1283,7 +1283,9 @@ export class FretService {
1283
1283
  * Classify `id` from a libp2p-reported protocol list (available once identify has
1284
1284
  * run). Member if any of our namespaced protocols appears; foreign if the list is
1285
1285
  * non-empty but contains none of them; left unknown if empty (identify pending).
1286
- * The demotion arm is deliberately weak — see `applyMembershipSignal`.
1286
+ * The demotion arm is deliberately weak — see `applyMembershipSignal`. A non-empty list also
1287
+ * records whether it named ours (`PeerEntry.advertisedForeign`), which the label alone cannot
1288
+ * say for a member — see {@link noteAdvertisedProtocols}.
1287
1289
  *
1288
1290
  * NOTE: the positive arm resets the negotiate-failure run (identify listing one of our protocols
1289
1291
  * is strong evidence), so a stale-positive list re-applied often enough would keep a peer that
@@ -1304,8 +1306,24 @@ export class FretService {
1304
1306
  if (!protocols || protocols.length === 0)
1305
1307
  return; // identify not complete → stay unknown
1306
1308
  const mine = Object.values(this.protocols);
1307
- const signal = protocols.some((p) => mine.includes(p)) ? 'identify-member' : 'identify-foreign';
1308
- this.applyMembershipSignal(id, signal);
1309
+ const advertisesOurs = protocols.some((p) => mine.includes(p));
1310
+ this.noteAdvertisedProtocols(id, advertisesOurs);
1311
+ this.applyMembershipSignal(id, advertisesOurs ? 'identify-member' : 'identify-foreign');
1312
+ }
1313
+ /**
1314
+ * Record on `id`'s entry whether its latest non-empty advertised protocol list named ours
1315
+ * (`PeerEntry.advertisedForeign`) — whatever its label, which the evidence-strength ordering
1316
+ * settles separately: a confirmed member stays `member` on a list lacking ours, but the flag
1317
+ * still says what it now advertises. Never a change on its own: clearing it coincides with the
1318
+ * `identify-member` promotion, which counts for itself, and setting it on a member moves nothing
1319
+ * a pass acts on — the flag only helps keep an *off-ring* peer out of the re-probe arms (see
1320
+ * {@link isSettledForeign}).
1321
+ */
1322
+ noteAdvertisedProtocols(id, advertisesOurs) {
1323
+ const advertisedForeign = !advertisesOurs;
1324
+ const e = this.store.getById(id);
1325
+ if (e && e.advertisedForeign !== advertisedForeign)
1326
+ this.store.update(id, { advertisedForeign });
1309
1327
  }
1310
1328
  /**
1311
1329
  * Admit a peer libp2p's peerStore told us about, then classify it off `protocols` — the shared
@@ -2997,26 +3015,36 @@ export class FretService {
2997
3015
  // buys phase 2 its turn. This return still fires for a `stop()` or a tick that really
2998
3016
  // is out of time.
2999
3017
  if (budget.signal.aborted) {
3000
- // Work left undone that no event will announce — the same reason a non-empty phase
3001
- // 2 is pending below. Harmless on a `stop()`, which resets the backoff anyway.
3018
+ // Work left undone that no event will announce — phase 2 never selected, so nothing
3019
+ // can tell whether unknowns were waiting. Harmless on a `stop()`, which resets the
3020
+ // backoff anyway.
3002
3021
  this.idle.notePending();
3003
3022
  return;
3004
3023
  }
3005
- const targets = this.phaseTwoTargets();
3006
- // Pending on the *selection*, not on the label counts: a ring that ever lost a phone
3007
- // ungracefully holds a `dead` entry until capacity evicts it (never, on a small ring),
3008
- // and "a dead peer exists" would keep the loop from ever backing off. The selection is
3009
- // empty whenever every off-ring peer is inside its backoff window, so the loop sleeps
3010
- // over those windows — and re-probes a peer whose window expired meanwhile up to one
3011
- // interval late, which the docs state.
3012
- // NOTE: the ladder restarts at every probe of a *permanently* off-ring peer, and climbs
3013
- // 1.5 + 3 + 6 + 12 + 24 s = 46.5 s before that peer's 32 s window has passed, so a ring
3014
- // holding one settles at about five passes per window (~9 s apart) rather than at the
3015
- // cap. If idle rings with a dead entry still show up in profiles, hold pending for the
3016
- // classify arm only: the dead and foreign arms' probe outcomes land inside the pass (a
3017
- // strike that progresses nothing needs no follow-up pass), so they need no hold.
3018
- if (targets.length > 0)
3024
+ const { classify, reprobe } = this.phaseTwoTargets();
3025
+ // Pending on the classify arm's selection alone. An `unknown` peer is a transient
3026
+ // population the ring is waiting on, so the next pass should carry on classifying at the
3027
+ // base. A re-probe's outcome lands inside this pass: a success is a membership or revival
3028
+ // change, a failure that progresses a run is a change, and one that progresses nothing
3029
+ // needs no follow-up pass — so holding for it only kept a ring with a permanently
3030
+ // off-ring peer (a dead phone, a relay) restarting the ladder every backoff window. A
3031
+ // redial this pass outlives is held by `runStabilizationPass` on its own.
3032
+ // NOTE: accepted tradeoff — once the loop has backed off, the re-probe arms service their
3033
+ // budget (Core 2 / Edge 1 per arm) per *interval*, not per 1.5 s: a dead or
3034
+ // negotiate-foreign peer whose window expires during a backed-off interval is re-probed up
3035
+ // to one interval late, and with more such peers than the budget each waits about
3036
+ // ceil(count / budget) intervals — at the 60 s cap, a Core ring holding 10 dead entries
3037
+ // re-probes each about every 5 minutes while idle. Accepted because a recovered neighbour
3038
+ // contacts us itself (an inbound RPC revives it), a reconnect revives it through
3039
+ // `peer:connect`, and any change returns the loop to the base. Holding pending whenever an
3040
+ // arm was truncated by its budget was rejected: at Edge's budget of 1, two dead entries
3041
+ // whose windows align truncate every window and restart the ladder about every 32 s.
3042
+ // Revisit if dead-recovery latency on large idle rings shows up as a problem — then hold
3043
+ // pending when an off-ring arm was budget-truncated, accepting that saturated arms keep the
3044
+ // loop awake.
3045
+ if (classify.length > 0)
3019
3046
  this.idle.notePending();
3047
+ const targets = [...classify, ...reprobe];
3020
3048
  const phaseTwoPool = { concurrency: this.maintenanceConcurrency, signal: budget.signal };
3021
3049
  const probed = await runPooled(targets.map((id) => () => this.probeMembership(id, budget.signal)), phaseTwoPool);
3022
3050
  logRejected(probed, targets, 'probeMembership');
@@ -3363,12 +3391,15 @@ export class FretService {
3363
3391
  *
3364
3392
  * The gate lives here rather than inside the selectors so a bare selector call (from a test)
3365
3393
  * still exercises the real selection logic against the live table.
3394
+ *
3395
+ * The two selections come back apart ({@link PhaseTwoTargets}) because only the classify arm
3396
+ * holds the idle backoff at its base; the tick pools them together.
3366
3397
  */
3367
3398
  phaseTwoTargets() {
3368
3399
  if (this.store.countByMembership('unknown') === 0
3369
3400
  && this.store.countByMembership('foreign') === 0
3370
3401
  && this.store.countByState('dead') === 0)
3371
- return [];
3402
+ return { classify: [], reprobe: [] };
3372
3403
  // NOTE: the walk is captured once and `put` writes a *new* entry object rather than editing
3373
3404
  // in place, so a store mutation made after this line is invisible to the arms that run
3374
3405
  // after it. Sound today: all three arms are pure selection — they read the backoff state,
@@ -3376,7 +3407,7 @@ export class FretService {
3376
3407
  // happens later, in the pooled phase. If an arm ever writes to the store while selecting,
3377
3408
  // re-walk per arm rather than leaving the later arms on a stale array.
3378
3409
  const entries = this.store.list();
3379
- return [...this.classifyTargets(entries), ...this.reprobeExcludedTargets(entries)];
3410
+ return { classify: this.classifyTargets(entries), reprobe: this.reprobeExcludedTargets(entries) };
3380
3411
  }
3381
3412
  /**
3382
3413
  * Bounded classification probe pass over `unknown` peers.
@@ -3427,9 +3458,12 @@ export class FretService {
3427
3458
  * - **`foreign`** — a same-network peer *mislabeled* foreign (e.g. identify completed before
3428
3459
  * it registered our protocol handlers) is re-admitted by a successful namespaced ping.
3429
3460
  * Before ring-membership gating this self-healed for free (`probeNeighborLatency` pinged
3430
- * near ring peers regardless of label); gating closed that path. The `peer:update` identify
3431
- * path also re-admits, but only if the remote pushes an update, so this is the dependable
3432
- * backstop.
3461
+ * near ring peers regardless of label); gating closed that path. For a peer demoted by
3462
+ * negotiate strikes whose latest advertised list still names ours (a same-network peer
3463
+ * mid-restart on a host without `identifyPush`) this is the dependable backstop. A peer whose
3464
+ * latest list names none of ours is re-probed only until it has refused a full negotiate run
3465
+ * (see {@link isSettledForeign}); after that it comes back through identify delivering a list
3466
+ * that does, or its own inbound RPC.
3433
3467
  * - **`dead`** — a peer marked dead by a run of failed contacts is out of every ring view, so
3434
3468
  * `stabilizeOnce` (which draws its probe targets from `getNeighbors`) never touches it
3435
3469
  * again. Without this arm a peer that recovers but never dials us and never forms a
@@ -3465,6 +3499,20 @@ export class FretService {
3465
3499
  ...this.reprobeOffRingTargets(entries, (e) => e.state === 'dead', budget),
3466
3500
  ];
3467
3501
  }
3502
+ /**
3503
+ * Whether `e` is foreign on both kinds of evidence at once: its latest advertised protocol list
3504
+ * names none of ours (`advertisedForeign`), *and* a full run of spaced negotiate refusals since
3505
+ * the last positive proof says it really answers none of them. Neither alone stops re-probing.
3506
+ * The list can be stale — captured before the remote's FRET registered its handlers, the
3507
+ * ordinary shape for a host that starts the core service by hand after its node — and without
3508
+ * `identifyPush` nothing refreshes it: two such peers trusting the list alone would never contact
3509
+ * each other at all (`test/address-hints.relay.spec.ts` is that rig). A refusal run alone is what
3510
+ * a same-network peer mid-restart produces, which is what the foreign arm exists to recover.
3511
+ * Costs a relay {@link NEGOTIATE_FAILURE_THRESHOLD} failed pings per run, then silence.
3512
+ */
3513
+ isSettledForeign(e) {
3514
+ return e.advertisedForeign && e.negotiateFailures >= FretService.NEGOTIATE_FAILURE_THRESHOLD;
3515
+ }
3468
3516
  /**
3469
3517
  * Select one bounded re-probe arm's targets — peers matching `isCandidate` — the shared
3470
3518
  * mechanics of both arms of {@link reprobeExcludedTargets}: off-backoff and reachable candidates
@@ -3476,10 +3524,18 @@ export class FretService {
3476
3524
  * re-probed at most ~once per backoff window (doubling to a 32× cap) rather than every tick.
3477
3525
  * That growing factor is also what rotates the list under tick truncation: a probed peer's
3478
3526
  * factor grows, so it sorts behind the ones a truncated tick never reached.
3527
+ *
3528
+ * A peer that is foreign on both kinds of evidence ({@link isSettledForeign} — a relay, another
3529
+ * network's node, once it has refused a run of our probes) is skipped by **both** arms: answering
3530
+ * our ping cannot make it a ring member, so probing it — and redialing it when disconnected —
3531
+ * was traffic forever with nothing to recover. If it does start serving this network, identify
3532
+ * delivers the new list (`identifyPush`, or a reconnect re-running identify), or it contacts us
3533
+ * itself; a dead one that comes back is revived by `peer:connect`. The filter only removes
3534
+ * candidates, so the separate per-arm budgets stand.
3479
3535
  */
3480
3536
  reprobeOffRingTargets(entries, isCandidate, budget) {
3481
3537
  const selfStr = this.selfIdStr;
3482
- const candidates = entries.filter((e) => e.id !== selfStr && !this.backoff.isBackedOff(e.id) && isCandidate(e));
3538
+ const candidates = entries.filter((e) => e.id !== selfStr && !this.isSettledForeign(e) && !this.backoff.isBackedOff(e.id) && isCandidate(e));
3483
3539
  if (candidates.length === 0)
3484
3540
  return [];
3485
3541
  // Only reachable peers are probeable; prefer connected over has-addresses.
@@ -9,7 +9,8 @@
9
9
  * base the moment a **change** is noted — anything the next pass could act on (a connection
10
10
  * forming or dropping, a new entry, a label or liveness transition, an address becoming known) —
11
11
  * and holds at the base, without counting as a change, while a pass left work **pending**
12
- * (off-ring peers it selected for probing, a redial it outlived).
12
+ * (a near probe it skipped on recent evidence, unknown peers it selected for classification, a
13
+ * redial it outlived, a phase its tick budget cut off).
13
14
  *
14
15
  * A change is recorded as a bump of a monotonic counter, and a pass snapshots that counter when it
15
16
  * opens: {@link endPass} compares the two, so a change that lands *during* a pass is seen at that
@@ -52,10 +53,10 @@ export declare class IdleBackoff {
52
53
  /** Open a pass: snapshot the change counter and clear the pending flag. */
53
54
  beginPass(): void;
54
55
  /**
55
- * The open pass left work no event will announce the outcome of — it selected off-ring peers to
56
- * probe, or a redial it started is still running. Holds the next delay at the base. Not a
56
+ * The open pass left work no event will announce the outcome of — it selected unknown peers to
57
+ * classify, or a redial it started is still running. Holds the next delay at the base. Not a
57
58
  * change: nothing happened yet, and counting it would reset the quiet count on every pass that
58
- * merely *looked* at a dead peer inside its backoff window.
59
+ * merely *selected* a peer to probe.
59
60
  */
60
61
  notePending(): void;
61
62
  /**
@@ -9,7 +9,8 @@
9
9
  * base the moment a **change** is noted — anything the next pass could act on (a connection
10
10
  * forming or dropping, a new entry, a label or liveness transition, an address becoming known) —
11
11
  * and holds at the base, without counting as a change, while a pass left work **pending**
12
- * (off-ring peers it selected for probing, a redial it outlived).
12
+ * (a near probe it skipped on recent evidence, unknown peers it selected for classification, a
13
+ * redial it outlived, a phase its tick budget cut off).
13
14
  *
14
15
  * A change is recorded as a bump of a monotonic counter, and a pass snapshots that counter when it
15
16
  * opens: {@link endPass} compares the two, so a change that lands *during* a pass is seen at that
@@ -66,10 +67,10 @@ export class IdleBackoff {
66
67
  this.pending = false;
67
68
  }
68
69
  /**
69
- * The open pass left work no event will announce the outcome of — it selected off-ring peers to
70
- * probe, or a redial it started is still running. Holds the next delay at the base. Not a
70
+ * The open pass left work no event will announce the outcome of — it selected unknown peers to
71
+ * classify, or a redial it started is still running. Holds the next delay at the base. Not a
71
72
  * change: nothing happened yet, and counting it would reset the quiet count on every pass that
72
- * merely *looked* at a dead peer inside its backoff window.
73
+ * merely *selected* a peer to probe.
73
74
  */
74
75
  notePending() {
75
76
  this.pending = true;
@@ -31,6 +31,18 @@ export interface PeerEntry {
31
31
  * the store only stores and exposes the count; it never branches on it.
32
32
  */
33
33
  negotiateFailures: number;
34
+ /**
35
+ * Whether the most recent non-empty protocol list libp2p delivered for this peer (identify,
36
+ * `peer:update`, or the start walk) named none of this network's protocols.
37
+ *
38
+ * Independent of `membership`: a confirmed `member` keeps its label when a later list drops our
39
+ * protocols (that list may be stale), but the flag still records what the peer now advertises.
40
+ * Callers use it, beside a completed `negotiateFailures` run, to stop re-probing an off-ring
41
+ * peer that cannot be serving this network. Not serialized — it is re-derived from the
42
+ * peerStore — so `false` on insert and on import. Like `membership`, the store only stores and
43
+ * exposes it; it never branches on it.
44
+ */
45
+ advertisedForeign: boolean;
34
46
  /**
35
47
  * Unix ms of the last handshake failure that was *counted* toward `negotiateFailures`.
36
48
  *
@@ -206,6 +206,7 @@ export class DigitreeStore {
206
206
  state: 'disconnected',
207
207
  membership: 'unknown',
208
208
  negotiateFailures: 0,
209
+ advertisedForeign: false,
209
210
  lastNegotiateFailureAt: 0,
210
211
  contactFailures: 0,
211
212
  lastContactFailureAt: 0,
@@ -485,6 +486,10 @@ export class DigitreeStore {
485
486
  // too. Reset for the same reason `state` is forced to 'disconnected'.
486
487
  negotiateFailures: 0,
487
488
  lastNegotiateFailureAt: 0,
489
+ // Re-derived from the peerStore, not restored: the start walk re-applies each peer's
490
+ // protocol list. A table imported *after* start() therefore re-probes a replaced
491
+ // foreign entry until its next `peer:update` — a few cheap pings, accepted.
492
+ advertisedForeign: false,
488
493
  // Likewise unreachability: a peer we could not contact before the restart may be
489
494
  // reachable now. `state` is already forced to 'disconnected', so resetting the
490
495
  // counter alongside it is what stops an imported table from re-killing that peer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "p2p-fret",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "type": "module",
5
5
  "description": "FRET: Finger Ring Ensemble Topology (libp2p ring overlay with Digitree)",
6
6
  "main": "dist/src/index.js",
@@ -152,6 +152,17 @@ interface NearWindow {
152
152
  uncontactable: string[];
153
153
  }
154
154
 
155
+ /**
156
+ * A tick's phase-2 selection, kept apart by arm because only one of them holds the idle backoff at
157
+ * its base (see `stabilizeOnce`). Disjoint, and pooled together for execution.
158
+ */
159
+ interface PhaseTwoTargets {
160
+ /** `unknown` peers to classify — a transient population the ring is waiting on. */
161
+ classify: string[];
162
+ /** `foreign` and `dead` peers to re-probe — each probe's outcome lands inside the pass. */
163
+ reprobe: string[];
164
+ }
165
+
155
166
  /**
156
167
  * The one verdict a maintenance redial ({@link FretService.redial}) reaches, shared by everyone
157
168
  * who awaited it.
@@ -1409,7 +1420,9 @@ export class FretService implements IFretService, Startable {
1409
1420
  * Classify `id` from a libp2p-reported protocol list (available once identify has
1410
1421
  * run). Member if any of our namespaced protocols appears; foreign if the list is
1411
1422
  * non-empty but contains none of them; left unknown if empty (identify pending).
1412
- * The demotion arm is deliberately weak — see `applyMembershipSignal`.
1423
+ * The demotion arm is deliberately weak — see `applyMembershipSignal`. A non-empty list also
1424
+ * records whether it named ours (`PeerEntry.advertisedForeign`), which the label alone cannot
1425
+ * say for a member — see {@link noteAdvertisedProtocols}.
1413
1426
  *
1414
1427
  * NOTE: the positive arm resets the negotiate-failure run (identify listing one of our protocols
1415
1428
  * is strong evidence), so a stale-positive list re-applied often enough would keep a peer that
@@ -1429,8 +1442,24 @@ export class FretService implements IFretService, Startable {
1429
1442
  private classifyByProtocols(id: string, protocols: string[] | undefined): void {
1430
1443
  if (!protocols || protocols.length === 0) return; // identify not complete → stay unknown
1431
1444
  const mine = Object.values(this.protocols);
1432
- const signal = protocols.some((p) => mine.includes(p)) ? 'identify-member' : 'identify-foreign';
1433
- this.applyMembershipSignal(id, signal);
1445
+ const advertisesOurs = protocols.some((p) => mine.includes(p));
1446
+ this.noteAdvertisedProtocols(id, advertisesOurs);
1447
+ this.applyMembershipSignal(id, advertisesOurs ? 'identify-member' : 'identify-foreign');
1448
+ }
1449
+
1450
+ /**
1451
+ * Record on `id`'s entry whether its latest non-empty advertised protocol list named ours
1452
+ * (`PeerEntry.advertisedForeign`) — whatever its label, which the evidence-strength ordering
1453
+ * settles separately: a confirmed member stays `member` on a list lacking ours, but the flag
1454
+ * still says what it now advertises. Never a change on its own: clearing it coincides with the
1455
+ * `identify-member` promotion, which counts for itself, and setting it on a member moves nothing
1456
+ * a pass acts on — the flag only helps keep an *off-ring* peer out of the re-probe arms (see
1457
+ * {@link isSettledForeign}).
1458
+ */
1459
+ private noteAdvertisedProtocols(id: string, advertisesOurs: boolean): void {
1460
+ const advertisedForeign = !advertisesOurs;
1461
+ const e = this.store.getById(id);
1462
+ if (e && e.advertisedForeign !== advertisedForeign) this.store.update(id, { advertisedForeign });
1434
1463
  }
1435
1464
 
1436
1465
  /**
@@ -3064,25 +3093,35 @@ export class FretService implements IFretService, Startable {
3064
3093
  // buys phase 2 its turn. This return still fires for a `stop()` or a tick that really
3065
3094
  // is out of time.
3066
3095
  if (budget.signal.aborted) {
3067
- // Work left undone that no event will announce — the same reason a non-empty phase
3068
- // 2 is pending below. Harmless on a `stop()`, which resets the backoff anyway.
3096
+ // Work left undone that no event will announce — phase 2 never selected, so nothing
3097
+ // can tell whether unknowns were waiting. Harmless on a `stop()`, which resets the
3098
+ // backoff anyway.
3069
3099
  this.idle.notePending();
3070
3100
  return;
3071
3101
  }
3072
- const targets = this.phaseTwoTargets();
3073
- // Pending on the *selection*, not on the label counts: a ring that ever lost a phone
3074
- // ungracefully holds a `dead` entry until capacity evicts it (never, on a small ring),
3075
- // and "a dead peer exists" would keep the loop from ever backing off. The selection is
3076
- // empty whenever every off-ring peer is inside its backoff window, so the loop sleeps
3077
- // over those windows — and re-probes a peer whose window expired meanwhile up to one
3078
- // interval late, which the docs state.
3079
- // NOTE: the ladder restarts at every probe of a *permanently* off-ring peer, and climbs
3080
- // 1.5 + 3 + 6 + 12 + 24 s = 46.5 s before that peer's 32 s window has passed, so a ring
3081
- // holding one settles at about five passes per window (~9 s apart) rather than at the
3082
- // cap. If idle rings with a dead entry still show up in profiles, hold pending for the
3083
- // classify arm only: the dead and foreign arms' probe outcomes land inside the pass (a
3084
- // strike that progresses nothing needs no follow-up pass), so they need no hold.
3085
- if (targets.length > 0) this.idle.notePending();
3102
+ const { classify, reprobe } = this.phaseTwoTargets();
3103
+ // Pending on the classify arm's selection alone. An `unknown` peer is a transient
3104
+ // population the ring is waiting on, so the next pass should carry on classifying at the
3105
+ // base. A re-probe's outcome lands inside this pass: a success is a membership or revival
3106
+ // change, a failure that progresses a run is a change, and one that progresses nothing
3107
+ // needs no follow-up pass — so holding for it only kept a ring with a permanently
3108
+ // off-ring peer (a dead phone, a relay) restarting the ladder every backoff window. A
3109
+ // redial this pass outlives is held by `runStabilizationPass` on its own.
3110
+ // NOTE: accepted tradeoff — once the loop has backed off, the re-probe arms service their
3111
+ // budget (Core 2 / Edge 1 per arm) per *interval*, not per 1.5 s: a dead or
3112
+ // negotiate-foreign peer whose window expires during a backed-off interval is re-probed up
3113
+ // to one interval late, and with more such peers than the budget each waits about
3114
+ // ceil(count / budget) intervals — at the 60 s cap, a Core ring holding 10 dead entries
3115
+ // re-probes each about every 5 minutes while idle. Accepted because a recovered neighbour
3116
+ // contacts us itself (an inbound RPC revives it), a reconnect revives it through
3117
+ // `peer:connect`, and any change returns the loop to the base. Holding pending whenever an
3118
+ // arm was truncated by its budget was rejected: at Edge's budget of 1, two dead entries
3119
+ // whose windows align truncate every window and restart the ladder about every 32 s.
3120
+ // Revisit if dead-recovery latency on large idle rings shows up as a problem — then hold
3121
+ // pending when an off-ring arm was budget-truncated, accepting that saturated arms keep the
3122
+ // loop awake.
3123
+ if (classify.length > 0) this.idle.notePending();
3124
+ const targets = [...classify, ...reprobe];
3086
3125
  const phaseTwoPool = { concurrency: this.maintenanceConcurrency, signal: budget.signal };
3087
3126
  const probed = await runPooled(targets.map((id) => () => this.probeMembership(id, budget.signal)), phaseTwoPool);
3088
3127
  logRejected(probed, targets, 'probeMembership');
@@ -3424,11 +3463,14 @@ export class FretService implements IFretService, Startable {
3424
3463
  *
3425
3464
  * The gate lives here rather than inside the selectors so a bare selector call (from a test)
3426
3465
  * still exercises the real selection logic against the live table.
3466
+ *
3467
+ * The two selections come back apart ({@link PhaseTwoTargets}) because only the classify arm
3468
+ * holds the idle backoff at its base; the tick pools them together.
3427
3469
  */
3428
- private phaseTwoTargets(): string[] {
3470
+ private phaseTwoTargets(): PhaseTwoTargets {
3429
3471
  if (this.store.countByMembership('unknown') === 0
3430
3472
  && this.store.countByMembership('foreign') === 0
3431
- && this.store.countByState('dead') === 0) return [];
3473
+ && this.store.countByState('dead') === 0) return { classify: [], reprobe: [] };
3432
3474
  // NOTE: the walk is captured once and `put` writes a *new* entry object rather than editing
3433
3475
  // in place, so a store mutation made after this line is invisible to the arms that run
3434
3476
  // after it. Sound today: all three arms are pure selection — they read the backoff state,
@@ -3436,7 +3478,7 @@ export class FretService implements IFretService, Startable {
3436
3478
  // happens later, in the pooled phase. If an arm ever writes to the store while selecting,
3437
3479
  // re-walk per arm rather than leaving the later arms on a stale array.
3438
3480
  const entries = this.store.list();
3439
- return [...this.classifyTargets(entries), ...this.reprobeExcludedTargets(entries)];
3481
+ return { classify: this.classifyTargets(entries), reprobe: this.reprobeExcludedTargets(entries) };
3440
3482
  }
3441
3483
 
3442
3484
  /**
@@ -3490,9 +3532,12 @@ export class FretService implements IFretService, Startable {
3490
3532
  * - **`foreign`** — a same-network peer *mislabeled* foreign (e.g. identify completed before
3491
3533
  * it registered our protocol handlers) is re-admitted by a successful namespaced ping.
3492
3534
  * Before ring-membership gating this self-healed for free (`probeNeighborLatency` pinged
3493
- * near ring peers regardless of label); gating closed that path. The `peer:update` identify
3494
- * path also re-admits, but only if the remote pushes an update, so this is the dependable
3495
- * backstop.
3535
+ * near ring peers regardless of label); gating closed that path. For a peer demoted by
3536
+ * negotiate strikes whose latest advertised list still names ours (a same-network peer
3537
+ * mid-restart on a host without `identifyPush`) this is the dependable backstop. A peer whose
3538
+ * latest list names none of ours is re-probed only until it has refused a full negotiate run
3539
+ * (see {@link isSettledForeign}); after that it comes back through identify delivering a list
3540
+ * that does, or its own inbound RPC.
3496
3541
  * - **`dead`** — a peer marked dead by a run of failed contacts is out of every ring view, so
3497
3542
  * `stabilizeOnce` (which draws its probe targets from `getNeighbors`) never touches it
3498
3543
  * again. Without this arm a peer that recovers but never dials us and never forms a
@@ -3529,6 +3574,21 @@ export class FretService implements IFretService, Startable {
3529
3574
  ];
3530
3575
  }
3531
3576
 
3577
+ /**
3578
+ * Whether `e` is foreign on both kinds of evidence at once: its latest advertised protocol list
3579
+ * names none of ours (`advertisedForeign`), *and* a full run of spaced negotiate refusals since
3580
+ * the last positive proof says it really answers none of them. Neither alone stops re-probing.
3581
+ * The list can be stale — captured before the remote's FRET registered its handlers, the
3582
+ * ordinary shape for a host that starts the core service by hand after its node — and without
3583
+ * `identifyPush` nothing refreshes it: two such peers trusting the list alone would never contact
3584
+ * each other at all (`test/address-hints.relay.spec.ts` is that rig). A refusal run alone is what
3585
+ * a same-network peer mid-restart produces, which is what the foreign arm exists to recover.
3586
+ * Costs a relay {@link NEGOTIATE_FAILURE_THRESHOLD} failed pings per run, then silence.
3587
+ */
3588
+ private isSettledForeign(e: PeerEntry): boolean {
3589
+ return e.advertisedForeign && e.negotiateFailures >= FretService.NEGOTIATE_FAILURE_THRESHOLD;
3590
+ }
3591
+
3532
3592
  /**
3533
3593
  * Select one bounded re-probe arm's targets — peers matching `isCandidate` — the shared
3534
3594
  * mechanics of both arms of {@link reprobeExcludedTargets}: off-backoff and reachable candidates
@@ -3540,11 +3600,19 @@ export class FretService implements IFretService, Startable {
3540
3600
  * re-probed at most ~once per backoff window (doubling to a 32× cap) rather than every tick.
3541
3601
  * That growing factor is also what rotates the list under tick truncation: a probed peer's
3542
3602
  * factor grows, so it sorts behind the ones a truncated tick never reached.
3603
+ *
3604
+ * A peer that is foreign on both kinds of evidence ({@link isSettledForeign} — a relay, another
3605
+ * network's node, once it has refused a run of our probes) is skipped by **both** arms: answering
3606
+ * our ping cannot make it a ring member, so probing it — and redialing it when disconnected —
3607
+ * was traffic forever with nothing to recover. If it does start serving this network, identify
3608
+ * delivers the new list (`identifyPush`, or a reconnect re-running identify), or it contacts us
3609
+ * itself; a dead one that comes back is revived by `peer:connect`. The filter only removes
3610
+ * candidates, so the separate per-arm budgets stand.
3543
3611
  */
3544
3612
  private reprobeOffRingTargets(entries: PeerEntry[], isCandidate: (e: PeerEntry) => boolean, budget: number): string[] {
3545
3613
  const selfStr = this.selfIdStr;
3546
3614
  const candidates = entries.filter(
3547
- (e) => e.id !== selfStr && !this.backoff.isBackedOff(e.id) && isCandidate(e)
3615
+ (e) => e.id !== selfStr && !this.isSettledForeign(e) && !this.backoff.isBackedOff(e.id) && isCandidate(e)
3548
3616
  );
3549
3617
  if (candidates.length === 0) return [];
3550
3618
  // Only reachable peers are probeable; prefer connected over has-addresses.
@@ -9,7 +9,8 @@
9
9
  * base the moment a **change** is noted — anything the next pass could act on (a connection
10
10
  * forming or dropping, a new entry, a label or liveness transition, an address becoming known) —
11
11
  * and holds at the base, without counting as a change, while a pass left work **pending**
12
- * (off-ring peers it selected for probing, a redial it outlived).
12
+ * (a near probe it skipped on recent evidence, unknown peers it selected for classification, a
13
+ * redial it outlived, a phase its tick budget cut off).
13
14
  *
14
15
  * A change is recorded as a bump of a monotonic counter, and a pass snapshots that counter when it
15
16
  * opens: {@link endPass} compares the two, so a change that lands *during* a pass is seen at that
@@ -73,10 +74,10 @@ export class IdleBackoff {
73
74
  }
74
75
 
75
76
  /**
76
- * The open pass left work no event will announce the outcome of — it selected off-ring peers to
77
- * probe, or a redial it started is still running. Holds the next delay at the base. Not a
77
+ * The open pass left work no event will announce the outcome of — it selected unknown peers to
78
+ * classify, or a redial it started is still running. Holds the next delay at the base. Not a
78
79
  * change: nothing happened yet, and counting it would reset the quiet count on every pass that
79
- * merely *looked* at a dead peer inside its backoff window.
80
+ * merely *selected* a peer to probe.
80
81
  */
81
82
  notePending(): void {
82
83
  this.pending = true;
@@ -38,6 +38,18 @@ export interface PeerEntry {
38
38
  * the store only stores and exposes the count; it never branches on it.
39
39
  */
40
40
  negotiateFailures: number;
41
+ /**
42
+ * Whether the most recent non-empty protocol list libp2p delivered for this peer (identify,
43
+ * `peer:update`, or the start walk) named none of this network's protocols.
44
+ *
45
+ * Independent of `membership`: a confirmed `member` keeps its label when a later list drops our
46
+ * protocols (that list may be stale), but the flag still records what the peer now advertises.
47
+ * Callers use it, beside a completed `negotiateFailures` run, to stop re-probing an off-ring
48
+ * peer that cannot be serving this network. Not serialized — it is re-derived from the
49
+ * peerStore — so `false` on insert and on import. Like `membership`, the store only stores and
50
+ * exposes it; it never branches on it.
51
+ */
52
+ advertisedForeign: boolean;
41
53
  /**
42
54
  * Unix ms of the last handshake failure that was *counted* toward `negotiateFailures`.
43
55
  *
@@ -408,6 +420,7 @@ export class DigitreeStore {
408
420
  state: 'disconnected',
409
421
  membership: 'unknown',
410
422
  negotiateFailures: 0,
423
+ advertisedForeign: false,
411
424
  lastNegotiateFailureAt: 0,
412
425
  contactFailures: 0,
413
426
  lastContactFailureAt: 0,
@@ -702,6 +715,10 @@ export class DigitreeStore {
702
715
  // too. Reset for the same reason `state` is forced to 'disconnected'.
703
716
  negotiateFailures: 0,
704
717
  lastNegotiateFailureAt: 0,
718
+ // Re-derived from the peerStore, not restored: the start walk re-applies each peer's
719
+ // protocol list. A table imported *after* start() therefore re-probes a replaced
720
+ // foreign entry until its next `peer:update` — a few cheap pings, accepted.
721
+ advertisedForeign: false,
705
722
  // Likewise unreachability: a peer we could not contact before the restart may be
706
723
  // reachable now. `state` is already forced to 'disconnected', so resetting the
707
724
  // counter alongside it is what stops an imported table from re-killing that peer