p2p-fret 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -253,8 +253,8 @@ export declare class FretService implements IFretService, Startable {
253
253
  * Score a direct interaction with `id`.
254
254
  *
255
255
  * **Scoring never creates.** A peer we hold no entry for is not scored — bookkeeping *about* a
256
- * peer must not be able to re-admit it, or removing a peer would not stick: `handleLeave` drops
257
- * a departing peer, its connection closes a moment later, and the `peer:disconnect` listener's
256
+ * peer must not be able to re-admit it, or removing a peer would not stick: `enforceCapacity`
257
+ * evicts a connected peer, its connection closes later, and the `peer:disconnect` listener's
258
258
  * `applyFailure` would resurrect it as an unclassified stranger the classification pass then
259
259
  * spends probes on. That is why the helper takes no coordinate: with the entry required to
260
260
  * exist, `entry.coord` *is* the value every caller used to compute, so "create this peer at
@@ -320,8 +320,8 @@ export declare class FretService implements IFretService, Startable {
320
320
  * Record a failed interaction with `id`.
321
321
  *
322
322
  * **Scoring never creates** — see {@link applyTouch}. This is the helper the rule exists for:
323
- * the `peer:disconnect` listener calls it for a peer `handleLeave` has just removed, and the
324
- * old create-on-miss arm brought that peer straight back.
323
+ * the `peer:disconnect` listener calls it for a peer capacity eviction may already have
324
+ * removed, and the old create-on-miss arm brought that peer straight back.
325
325
  */
326
326
  private applyFailure;
327
327
  /** This peer's stored ring coordinate, hashing its id when we hold no entry for it yet. */
@@ -766,6 +766,22 @@ export declare class FretService implements IFretService, Startable {
766
766
  private computeReplacements;
767
767
  private sendLeaveToNeighbors;
768
768
  private handleLeave;
769
+ /**
770
+ * A peer told us it is leaving: mark it `dead` — the immediate counterpart of a completed run
771
+ * of failed contacts — rather than removing it.
772
+ *
773
+ * Removal did not stick. libp2p keeps the departed peer's peerStore record, negotiated
774
+ * protocols included, so the next tick's `seedFromPeerStore` (or a `peer:update`) re-created
775
+ * the entry and labelled it `member` again — a live ring member until three probes failed, and
776
+ * forever if it had no address to probe. `dead` survives that re-seed because `upsert`
777
+ * preserves `state` and classification touches only `membership`, and it already carries every
778
+ * other consequence a departure needs: out of every ring view, unprotected at eviction, skipped
779
+ * by the maintenance fan-outs, and restored by any proof of life if the peer comes back.
780
+ *
781
+ * The contact-failure run is left alone: nothing reads it while the peer is dead, and proof of
782
+ * life clears it together with the label.
783
+ */
784
+ private markDeparted;
769
785
  /**
770
786
  * Record a leave notice's suggested replacements as untrusted local hints — and nothing more.
771
787
  *
@@ -901,12 +917,45 @@ export declare class FretService implements IFretService, Startable {
901
917
  */
902
918
  private stabilizeOnce;
903
919
  /**
904
- * The near peers a tick pings and snapshot-fetches: the dialable live members nearest self on
905
- * either side, at most 4, in **ring order** (closest first) — deliberately not rotated like the
906
- * other candidate lists. These are the peers ring correctness depends on most, so a truncated
907
- * tick should skip the 4th-closest and never the immediate successor; it self-corrects next tick.
920
+ * The S/P window a tick verifies — the live members nearest self, `max(2, m)` per side — split
921
+ * by whether we can contact them at all.
922
+ *
923
+ * `targets` are the dialable ones a tick pings and snapshot-fetches: at most 4, in **ring
924
+ * order** (closest first, sides interleaved) — deliberately not rotated like the other
925
+ * candidate lists. These are the peers ring correctness depends on most, so a truncated tick
926
+ * should skip the outermost and never the immediate successor or predecessor; it
927
+ * self-corrects next tick. `uncontactable` is the rest of the window, uncapped: the 4 bounds
928
+ * RPCs, and none is spent on these (see {@link strikeUncontactable}).
929
+ *
930
+ * Walked through `ringNeighborsBothSides` rather than the public `getNeighbors`, whose `both`
931
+ * direction is successor-biased (see the NOTE there): at m = 8 it returned self plus seven
932
+ * successors on any ring of more than nine live members, so no predecessor was ever verified.
908
933
  */
909
934
  private nearProbeTargets;
935
+ /**
936
+ * Count one failed contact against each live member of the S/P window that no pass could
937
+ * reach: not connected, and no address libp2p could dial.
938
+ *
939
+ * Without this such a peer never reaches `dead`, because a strike is booked only when an
940
+ * outbound RPC fails and every pass that sends one filters on `isDialable` first. A phone that
941
+ * dials out and listens nowhere is exactly that peer once its connection drops — so it stayed a
942
+ * live member, and a cohort member, indefinitely.
943
+ *
944
+ * The observation is the tick, not the disconnect, so "an idle `peer:disconnect` is not a
945
+ * strike" stays true: `applyContactStrike`'s spacing guard makes each passive tick (1.5 s) one
946
+ * independent strike, the peer is `dead` after `deadAfterFailures` ticks, and one that
947
+ * reconnects within a tick or two (`peer:connect` is proof of life) is never killed. Once dead
948
+ * it leaves the live-member window, so the strikes stop by themselves.
949
+ *
950
+ * NOTE: only the S/P window is swept, so on a ring of more than 2·max(2, m) + 1 live members an
951
+ * address-less member outside our window is never struck by us and stays a live member in our
952
+ * cohorts. If cohorts assembled over such rings start waiting on departed phones, widen this to
953
+ * every live member — still local work, one store walk.
954
+ *
955
+ * Local work with no RPC, so the tick budget does not apply; but our own cancellation is still
956
+ * not evidence, so nothing is booked once the run signal has fired.
957
+ */
958
+ private strikeUncontactable;
910
959
  /**
911
960
  * Ping `id`, then fetch and merge its neighbor snapshot — one pooled task per near peer.
912
961
  *
@@ -533,8 +533,8 @@ export class FretService {
533
533
  * Score a direct interaction with `id`.
534
534
  *
535
535
  * **Scoring never creates.** A peer we hold no entry for is not scored — bookkeeping *about* a
536
- * peer must not be able to re-admit it, or removing a peer would not stick: `handleLeave` drops
537
- * a departing peer, its connection closes a moment later, and the `peer:disconnect` listener's
536
+ * peer must not be able to re-admit it, or removing a peer would not stick: `enforceCapacity`
537
+ * evicts a connected peer, its connection closes later, and the `peer:disconnect` listener's
538
538
  * `applyFailure` would resurrect it as an unclassified stranger the classification pass then
539
539
  * spends probes on. That is why the helper takes no coordinate: with the entry required to
540
540
  * exist, `entry.coord` *is* the value every caller used to compute, so "create this peer at
@@ -647,8 +647,8 @@ export class FretService {
647
647
  * Record a failed interaction with `id`.
648
648
  *
649
649
  * **Scoring never creates** — see {@link applyTouch}. This is the helper the rule exists for:
650
- * the `peer:disconnect` listener calls it for a peer `handleLeave` has just removed, and the
651
- * old create-on-miss arm brought that peer straight back.
650
+ * the `peer:disconnect` listener calls it for a peer capacity eviction may already have
651
+ * removed, and the old create-on-miss arm brought that peer straight back.
652
652
  */
653
653
  async applyFailure(id) {
654
654
  const entry = this.store.getById(id);
@@ -1266,9 +1266,9 @@ export class FretService {
1266
1266
  }, this.protocols.PROTOCOL_MAYBE_ACT, this.maxBytesMaybeAct(), () => { this.diag.rejected.malformed++; }, streamCaps),
1267
1267
  // NOTE: leave is deliberately the one inbound handler with no `noteInboundRpc`
1268
1268
  // hook (compare neighbors / maybeAct / ping above and below). That hook applies
1269
- // the `rpc-inbound` membership signal to the sender, which here is the *departing*
1270
- // peer — it would re-insert it as a confirmed `member` immediately after
1271
- // `handleLeave` removed it. The asymmetry is the point; do not "fix" it while
1269
+ // the `rpc-inbound` membership signal and proof of life to the sender, which here
1270
+ // is the *departing* peer — it would restore it as a live `member` immediately after
1271
+ // `handleLeave` marked it dead. The asymmetry is the point; do not "fix" it while
1272
1272
  // tidying these signatures.
1273
1273
  registerLeave(this.node, async (notice) => this.handleLeave(notice), this.protocols.PROTOCOL_LEAVE, () => { this.diag.rejected.identityMismatch++; }, () => { this.diag.rejected.malformed++; }, streamCaps),
1274
1274
  registerPing(this.node, this.protocols.PROTOCOL_PING, () => this.handlePingRequest(), (from) => this.detach(this.noteInboundRpc(from), 'noteInboundRpc(ping)'), streamCaps),
@@ -1899,8 +1899,10 @@ export class FretService {
1899
1899
  try {
1900
1900
  let coord = null;
1901
1901
  const entry = this.store.getById(peerId);
1902
- if (entry)
1902
+ if (entry) {
1903
1903
  coord = entry.coord;
1904
+ this.markDeparted(peerId);
1905
+ }
1904
1906
  else {
1905
1907
  try {
1906
1908
  coord = await hashPeerId(peerIdFromString(peerId));
@@ -1909,8 +1911,6 @@ export class FretService {
1909
1911
  log.error('handleLeave: could not hash departing peer id %s - %e', peerId, e);
1910
1912
  }
1911
1913
  }
1912
- // remove leaving peer from the store
1913
- this.store.remove(peerId);
1914
1914
  if (!coord)
1915
1915
  return;
1916
1916
  await this.recordLeaveReplacements(notice.replacements, peerId);
@@ -1925,6 +1925,28 @@ export class FretService {
1925
1925
  log.error('handleLeave failed for %s - %e', peerId, err);
1926
1926
  }
1927
1927
  }
1928
+ /**
1929
+ * A peer told us it is leaving: mark it `dead` — the immediate counterpart of a completed run
1930
+ * of failed contacts — rather than removing it.
1931
+ *
1932
+ * Removal did not stick. libp2p keeps the departed peer's peerStore record, negotiated
1933
+ * protocols included, so the next tick's `seedFromPeerStore` (or a `peer:update`) re-created
1934
+ * the entry and labelled it `member` again — a live ring member until three probes failed, and
1935
+ * forever if it had no address to probe. `dead` survives that re-seed because `upsert`
1936
+ * preserves `state` and classification touches only `membership`, and it already carries every
1937
+ * other consequence a departure needs: out of every ring view, unprotected at eviction, skipped
1938
+ * by the maintenance fan-outs, and restored by any proof of life if the peer comes back.
1939
+ *
1940
+ * The contact-failure run is left alone: nothing reads it while the peer is dead, and proof of
1941
+ * life clears it together with the label.
1942
+ */
1943
+ markDeparted(id) {
1944
+ // Unreachable for a leave (the transport identity check rejects a notice from self), but a
1945
+ // dead self has no path back short of a restart — the same guard `applyContactStrike` keeps.
1946
+ if (id === this.selfIdStr)
1947
+ return;
1948
+ this.store.update(id, { state: 'dead' });
1949
+ }
1928
1950
  /**
1929
1951
  * Record a leave notice's suggested replacements as untrusted local hints — and nothing more.
1930
1952
  *
@@ -1950,8 +1972,9 @@ export class FretService {
1950
1972
  // Skipping self is a correctness guard, not tidiness: were self ever absent from the
1951
1973
  // store, upserting it here would recreate it as `unknown` and drop self out of every
1952
1974
  // member-only ring view (see *Network-scoped admission* in `docs/fret.md`). Skipping
1953
- // the departed peer stops us re-adding the one `handleLeave` just removed. Duplicates
1954
- // inside one list collapse, so 12 copies of an id cost one hash and one upsert.
1975
+ // the departed peer stops a notice naming its own sender from creating an entry for a
1976
+ // peer we held none for. Duplicates inside one list collapse, so 12 copies of an id
1977
+ // cost one hash and one upsert.
1955
1978
  if (id === selfStr || id === departedId || seen.has(id))
1956
1979
  continue;
1957
1980
  seen.add(id);
@@ -2351,7 +2374,10 @@ export class FretService {
2351
2374
  */
2352
2375
  async stabilizeOnce() {
2353
2376
  this.sweepBoundedMaps();
2354
- const near = await this.nearProbeTargets();
2377
+ const { targets: near, uncontactable } = await this.nearProbeTargets();
2378
+ // Before phase 1, and awaited: these peers are disjoint from every pooled candidate set
2379
+ // (live members, but undialable), so nothing in the pool races these strikes.
2380
+ await this.strikeUncontactable(uncontactable);
2355
2381
  // `runSignal` is undefined before the first start(); `deadline` accepts that. `cancel()` in
2356
2382
  // the finally is mandatory — see `deadline`.
2357
2383
  const budget = deadline(FretService.STABILIZE_TICK_BUDGET_MS, this.runSignal);
@@ -2383,11 +2409,11 @@ export class FretService {
2383
2409
  // tick still enforces.
2384
2410
  // NOTE: a throw *earlier* in the tick skips this, and the seeds no longer trim for
2385
2411
  // themselves, so that tick's inserts stay untrimmed until the next one. The reachable
2386
- // candidates are the two calls above the try (`sweepBoundedMaps`, `nearProbeTargets`),
2387
- // both local and neither able to throw on any input a tick can present; the loop's own
2388
- // try/catch swallows anything that does, and the overshoot self-heals next tick. If a
2389
- // deterministic throw is ever found on that path, move the enforcement into this
2390
- // method's `finally` rather than adding a guard here.
2412
+ // candidates are the three calls above the try (`sweepBoundedMaps`, `nearProbeTargets`,
2413
+ // `strikeUncontactable`), all local and none able to throw on any input a tick can
2414
+ // present; the loop's own try/catch swallows anything that does, and the overshoot
2415
+ // self-heals next tick. If a deterministic throw is ever found on that path, move the
2416
+ // enforcement into this method's `finally` rather than adding a guard here.
2391
2417
  await this.enforceCapacity();
2392
2418
  if (announced.length > 0)
2393
2419
  this.detach(this.announceToNewPeers(announced), 'announceToNewPeers');
@@ -2408,15 +2434,59 @@ export class FretService {
2408
2434
  }
2409
2435
  }
2410
2436
  /**
2411
- * The near peers a tick pings and snapshot-fetches: the dialable live members nearest self on
2412
- * either side, at most 4, in **ring order** (closest first) — deliberately not rotated like the
2413
- * other candidate lists. These are the peers ring correctness depends on most, so a truncated
2414
- * tick should skip the 4th-closest and never the immediate successor; it self-corrects next tick.
2437
+ * The S/P window a tick verifies — the live members nearest self, `max(2, m)` per side — split
2438
+ * by whether we can contact them at all.
2439
+ *
2440
+ * `targets` are the dialable ones a tick pings and snapshot-fetches: at most 4, in **ring
2441
+ * order** (closest first, sides interleaved) — deliberately not rotated like the other
2442
+ * candidate lists. These are the peers ring correctness depends on most, so a truncated tick
2443
+ * should skip the outermost and never the immediate successor or predecessor; it
2444
+ * self-corrects next tick. `uncontactable` is the rest of the window, uncapped: the 4 bounds
2445
+ * RPCs, and none is spent on these (see {@link strikeUncontactable}).
2446
+ *
2447
+ * Walked through `ringNeighborsBothSides` rather than the public `getNeighbors`, whose `both`
2448
+ * direction is successor-biased (see the NOTE there): at m = 8 it returned self plus seven
2449
+ * successors on any ring of more than nine live members, so no predecessor was ever verified.
2415
2450
  */
2416
2451
  async nearProbeTargets() {
2417
- const selfStr = this.selfIdStr;
2418
- const nearAll = this.getNeighbors(await this.selfCoord(), 'both', Math.max(2, this.cfg.m));
2419
- return nearAll.filter((id) => id !== selfStr && this.isDialable(id)).slice(0, 4);
2452
+ const window = ringNeighborsBothSides(this.store, await this.selfCoord(), Math.max(2, this.cfg.m), this.selfIdStr, { filter: isLiveMember });
2453
+ const targets = [];
2454
+ const uncontactable = [];
2455
+ for (const id of window)
2456
+ (this.isDialable(id) ? targets : uncontactable).push(id);
2457
+ return { targets: targets.slice(0, 4), uncontactable };
2458
+ }
2459
+ /**
2460
+ * Count one failed contact against each live member of the S/P window that no pass could
2461
+ * reach: not connected, and no address libp2p could dial.
2462
+ *
2463
+ * Without this such a peer never reaches `dead`, because a strike is booked only when an
2464
+ * outbound RPC fails and every pass that sends one filters on `isDialable` first. A phone that
2465
+ * dials out and listens nowhere is exactly that peer once its connection drops — so it stayed a
2466
+ * live member, and a cohort member, indefinitely.
2467
+ *
2468
+ * The observation is the tick, not the disconnect, so "an idle `peer:disconnect` is not a
2469
+ * strike" stays true: `applyContactStrike`'s spacing guard makes each passive tick (1.5 s) one
2470
+ * independent strike, the peer is `dead` after `deadAfterFailures` ticks, and one that
2471
+ * reconnects within a tick or two (`peer:connect` is proof of life) is never killed. Once dead
2472
+ * it leaves the live-member window, so the strikes stop by themselves.
2473
+ *
2474
+ * NOTE: only the S/P window is swept, so on a ring of more than 2·max(2, m) + 1 live members an
2475
+ * address-less member outside our window is never struck by us and stays a live member in our
2476
+ * cohorts. If cohorts assembled over such rings start waiting on departed phones, widen this to
2477
+ * every live member — still local work, one store walk.
2478
+ *
2479
+ * Local work with no RPC, so the tick budget does not apply; but our own cancellation is still
2480
+ * not evidence, so nothing is booked once the run signal has fired.
2481
+ */
2482
+ async strikeUncontactable(ids) {
2483
+ const signal = this.runSignal;
2484
+ for (const id of ids) {
2485
+ if (this.wasCancelled(signal))
2486
+ return;
2487
+ log('near window: %s has no connection and no address - counted as a failed contact', id);
2488
+ await this.applyContactFailure(id);
2489
+ }
2420
2490
  }
2421
2491
  /**
2422
2492
  * Ping `id`, then fetch and merge its neighbor snapshot — one pooled task per near peer.
@@ -80,7 +80,22 @@ export declare class Libp2pFretService implements Startable, FretService {
80
80
  /** The same instance libp2p reaches via `peerDiscoverySymbol`, for applications that want to listen directly. */
81
81
  getPeerDiscovery(): PeerDiscovery;
82
82
  start(): Promise<void>;
83
+ /**
84
+ * Runs the whole shutdown here, ahead of {@link stop}, because this is the last point at which
85
+ * the leave notices can still go out. libp2p's `stop()` runs every component's `beforeStop()`
86
+ * first, then every component's `stop()` under one `Promise.all` — and the connection manager's
87
+ * `stop()` closes every connection inside that same `Promise.all`. A leave fan-out started from
88
+ * our `stop()` therefore races the closing connections and loses: a peer that listens nowhere
89
+ * can reach its neighbours only over connections it already holds, so its notices were sent to
90
+ * nobody. The registrar is still live here, so unhandling works too.
91
+ */
92
+ beforeStop(): Promise<void>;
93
+ /**
94
+ * A no-op after {@link beforeStop} under libp2p (both halves are idempotent), and the whole
95
+ * shutdown for a host that drives this facade by hand without a `beforeStop` call.
96
+ */
83
97
  stop(): Promise<void>;
98
+ private shutdown;
84
99
  routeAct(msg: RouteAndMaybeActV1): Promise<NearAnchorV1 | {
85
100
  commitCertificate: string;
86
101
  }>;
@@ -121,7 +121,26 @@ export class Libp2pFretService {
121
121
  // explicit start/stop stay ours.
122
122
  await this.discovery.start();
123
123
  }
124
+ /**
125
+ * Runs the whole shutdown here, ahead of {@link stop}, because this is the last point at which
126
+ * the leave notices can still go out. libp2p's `stop()` runs every component's `beforeStop()`
127
+ * first, then every component's `stop()` under one `Promise.all` — and the connection manager's
128
+ * `stop()` closes every connection inside that same `Promise.all`. A leave fan-out started from
129
+ * our `stop()` therefore races the closing connections and loses: a peer that listens nowhere
130
+ * can reach its neighbours only over connections it already holds, so its notices were sent to
131
+ * nobody. The registrar is still live here, so unhandling works too.
132
+ */
133
+ async beforeStop() {
134
+ await this.shutdown();
135
+ }
136
+ /**
137
+ * A no-op after {@link beforeStop} under libp2p (both halves are idempotent), and the whole
138
+ * shutdown for a host that drives this facade by hand without a `beforeStop` call.
139
+ */
124
140
  async stop() {
141
+ await this.shutdown();
142
+ }
143
+ async shutdown() {
125
144
  await this.discovery.stop();
126
145
  await this.inner?.stop();
127
146
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "p2p-fret",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "type": "module",
5
5
  "description": "FRET: Finger Ring Ensemble Topology (libp2p ring overlay with Digitree)",
6
6
  "main": "dist/src/index.js",