@decentnetwork/peer 0.1.154 → 0.1.155

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.
Files changed (2) hide show
  1. package/dist/peer.js +111 -13
  2. package/package.json +1 -1
package/dist/peer.js CHANGED
@@ -89,6 +89,15 @@ const NODE_BLACKLIST_MAX_TTL_MS = readEnvInt("DECENT_NODE_BLACKLIST_MAX_TTL_MS",
89
89
  const FRIEND_ANNOUNCE_ATTEMPTS = readEnvInt("DECENT_FRIEND_ANNOUNCE_ATTEMPTS", 1);
90
90
  const JOIN_ANNOUNCE_TIMEOUT_MS = readEnvInt("DECENT_JOIN_ANNOUNCE_TIMEOUT_MS", 12000);
91
91
  const SELF_ANNOUNCE_INTERVAL_MS = readEnvInt("DECENT_SELF_ANNOUNCE_INTERVAL_MS", 20000);
92
+ // Slack on top of a run's own deadline before the watchdog abandons it. The
93
+ // abandoned run is left to finish or hang on its own; what matters is that the
94
+ // NEXT tick is allowed to start.
95
+ const SELF_ANNOUNCE_WATCHDOG_MARGIN_MS = readEnvInt("DECENT_SELF_ANNOUNCE_WATCHDOG_MARGIN_MS", 15000);
96
+ // How long a stored announce entry stays useful to someone looking us up.
97
+ // toxcore expires announce entries on roughly this timescale, so a node that
98
+ // acknowledged a store longer ago than this can no longer be counted on.
99
+ const ANNOUNCE_ENTRY_TTL_MS = readEnvInt("DECENT_ANNOUNCE_ENTRY_TTL_MS", 300_000);
100
+ const FAULT_ANNOUNCE_HANG_RUN = readEnvInt("DECENT_FAULT_ANNOUNCE_HANG", 0);
92
101
  // Classic-DHT maintenance cadence. Every tick we get_nodes toward our own key
93
102
  // (so neighbours store our address and native peers can find our UDP endpoint)
94
103
  // and toward each not-yet-UDP friend's key (so we find theirs and punch).
@@ -475,6 +484,11 @@ export class Peer {
475
484
  // 25s forever for stale persisted entries.
476
485
  #dhtPkConsecutiveFailures = new Map();
477
486
  #lastSelfAnnounceStoredCount = -1;
487
+ // nodeId -> when that node last acknowledged STORING our announce. What
488
+ // callers actually want to know is "can anyone look me up right now", and
489
+ // that is a property of live storage across rounds, not of the round in
490
+ // flight. Counted against ANNOUNCE_ENTRY_TTL_MS on read.
491
+ #selfAnnounceStoredAt = new Map();
478
492
  // Diagnostics for the TCP-relay onion path (announce/discovery over TCP).
479
493
  // Surfaced in dhtHealth so `agentnet diag` shows whether it's active without
480
494
  // needing verbose logs: sent = onion requests handed to relays, recv = onion
@@ -536,6 +550,13 @@ export class Peer {
536
550
  #profileRetryTimers = new Map();
537
551
  #greetingSentTo = new Set();
538
552
  #selfAnnouncePromise;
553
+ #selfAnnounceRunCount = 0;
554
+ // Bumped when the watchdog abandons a run. The abandoned run is still alive
555
+ // and would otherwise keep writing #announceRouteUsed underneath the round
556
+ // that replaced it — and step2 rejects a ping_id whose route changed, so
557
+ // that interference would cost stores. It cannot be killed, but it can be
558
+ // told to stop at its next wave boundary.
559
+ #selfAnnounceEpoch = 0;
539
560
  #selfAnnouncePauseDepth = 0;
540
561
  #started = false;
541
562
  constructor(opts) {
@@ -3633,6 +3654,17 @@ export class Peer {
3633
3654
  return [];
3634
3655
  }
3635
3656
  this.#lastSelfAnnounceMs = now;
3657
+ // Fault injection, off unless DECENT_FAULT_ANNOUNCE_HANG names a run
3658
+ // index. Reproduces the browser wedge — a run that never settles — so the
3659
+ // watchdog in #runSelfAnnounce can be tested for real instead of only
3660
+ // reasoned about. Same spirit as DECENT_ANNOUNCE_DEBUG: costs one integer
3661
+ // compare when unset.
3662
+ const myEpoch = this.#selfAnnounceEpoch;
3663
+ this.#selfAnnounceRunCount += 1;
3664
+ if (FAULT_ANNOUNCE_HANG_RUN > 0 && this.#selfAnnounceRunCount === FAULT_ANNOUNCE_HANG_RUN) {
3665
+ this.#debugLog(`FAULT: hanging self-announce run #${this.#selfAnnounceRunCount} forever`);
3666
+ await new Promise(() => { });
3667
+ }
3636
3668
  const storedNodes = [];
3637
3669
  // An onion announce only STORES on the nodes whose key is closest to OURS,
3638
3670
  // and toxcore reaches them by following the "here are closer nodes" lists
@@ -3671,15 +3703,27 @@ export class Peer {
3671
3703
  queue.sort((x, y) => xorCloser(selfPk, x.nodePk, y.nodePk));
3672
3704
  };
3673
3705
  enqueueNodes(this.#knownNodes.length > 0 ? this.#knownNodes : this.#opts.bootstrapNodes);
3674
- // Reset counter at start so dhtHealth can distinguish "never ran" (-1)
3675
- // from "ran but stored on 0 nodes" (0).
3676
- this.#lastSelfAnnounceStoredCount = 0;
3706
+ // No reset here. Zeroing at the START of every round is why dhtHealth
3707
+ // read 0 for the first seconds of each round, and why a round cut short by
3708
+ // the deadline published 0 even though ten nodes were still holding our
3709
+ // announce from 10s earlier. Measured before this change: a healthy peer
3710
+ // reported 0 / 10 / 0 / 0 / 10 across a 135s poll. Anyone sampling at the
3711
+ // wrong moment — a user reading the UI, or me reading it and reporting it
3712
+ // as a finding — concluded the peer was unfindable when it was not.
3713
+ // "Never ran" is still distinguishable: #lastSelfAnnounceStoredCount
3714
+ // stays -1 until the first round records something.
3715
+ if (this.#lastSelfAnnounceStoredCount < 0)
3716
+ this.#lastSelfAnnounceStoredCount = 0;
3677
3717
  const STORE_TARGET = 4;
3678
3718
  let waves = 0;
3679
3719
  while (queue.length > 0 && storedNodes.length < STORE_TARGET && waves < 16) {
3680
3720
  if (Date.now() >= deadlineMs) {
3681
3721
  this.#debugLog("self announce stopped at deadline");
3682
- this.#lastSelfAnnounceStoredCount = storedNodes.length;
3722
+ this.#publishSelfAnnounceStoredCount();
3723
+ return storedNodes;
3724
+ }
3725
+ if (this.#selfAnnounceEpoch !== myEpoch) {
3726
+ this.#debugLog("self announce abandoned by watchdog — stopping at wave boundary");
3683
3727
  return storedNodes;
3684
3728
  }
3685
3729
  waves += 1;
@@ -3784,9 +3828,17 @@ export class Peer {
3784
3828
  if (final.isStored === 2) {
3785
3829
  storedNodes.push(c.node);
3786
3830
  this.#debugLog(`self announce STORED on ${c.node.host}:${c.node.port} (total ${storedNodes.length})`);
3787
- // Keep dhtHealth.selfAnnounceStoredOn live within the loop,
3788
- // not just at the end, so we see growth in real time.
3789
- this.#lastSelfAnnounceStoredCount = storedNodes.length;
3831
+ // Publish the running count only once it EXCEEDS the last completed
3832
+ // round's. Each round starts from an empty storedNodes, so writing it
3833
+ // through unconditionally made dhtHealth read 0 for the first second
3834
+ // or so of every round — and anyone who polled in that window saw
3835
+ // "stored on 0 nodes" and concluded the peer was unfindable. That
3836
+ // reading is what made the Air look permanently broken, and I
3837
+ // reported it as a finding once before checking whether it settled.
3838
+ // Measured on this Mac: the same peer alternates 0 / 9 / 10 / 0
3839
+ // across a two-minute poll while every round stores on ten nodes.
3840
+ this.#selfAnnounceStoredAt.set(`${c.node.host}:${c.node.port}`, Date.now());
3841
+ this.#publishSelfAnnounceStoredCount();
3790
3842
  }
3791
3843
  const discovered = parsePackedNodes(final.nodes);
3792
3844
  if (discovered.length > 0) {
@@ -3797,12 +3849,22 @@ export class Peer {
3797
3849
  }
3798
3850
  }
3799
3851
  }
3800
- // Track the most recent acknowledged-storage count so dhtHealth()
3801
- // can surface "DHT discovery layer alive but our announce isn't
3802
- // landing on any node" vs the normal case.
3803
- this.#lastSelfAnnounceStoredCount = storedNodes.length;
3852
+ // Track live acknowledged storage so dhtHealth() can still surface "DHT
3853
+ // discovery layer alive but our announce isn't landing anywhere" — that
3854
+ // now shows up as the count DECAYING to 0 as entries age out, instead of
3855
+ // flickering to 0 once per round.
3856
+ this.#publishSelfAnnounceStoredCount();
3804
3857
  return storedNodes;
3805
3858
  }
3859
+ /** Count nodes whose acknowledged store is still within its TTL. */
3860
+ #publishSelfAnnounceStoredCount() {
3861
+ const cutoff = Date.now() - ANNOUNCE_ENTRY_TTL_MS;
3862
+ for (const [id, at] of this.#selfAnnounceStoredAt) {
3863
+ if (at < cutoff)
3864
+ this.#selfAnnounceStoredAt.delete(id);
3865
+ }
3866
+ this.#lastSelfAnnounceStoredCount = this.#selfAnnounceStoredAt.size;
3867
+ }
3806
3868
  #ensureSelfAnnounceLoop() {
3807
3869
  if (this.#selfAnnounceTimer || SELF_ANNOUNCE_INTERVAL_MS <= 0) {
3808
3870
  return;
@@ -7295,12 +7357,48 @@ export class Peer {
7295
7357
  if (this.#selfAnnouncePromise) {
7296
7358
  await this.#selfAnnouncePromise.catch(() => { });
7297
7359
  }
7298
- this.#selfAnnouncePromise = this.#announceSelfBestEffort(force, deadlineMs);
7360
+ // Hard deadline around the WHOLE run, not just the per-node waiter.
7361
+ //
7362
+ // #ensureSelfAnnounceLoop skips a tick while #selfAnnouncePromise is set,
7363
+ // so one run that never settles kills self-announce FOREVER — silently,
7364
+ // and with dhtHealth still reporting the last good numbers. Measured on
7365
+ // app.beagle.chat 2026-08-31: lastSelfAnnounceMs frozen 1731s, stored
7366
+ // stuck at 11, nobody able to look us up. lastSelfAnnounceMs is stamped
7367
+ // on ENTRY, so a frozen value proves the function was never re-entered —
7368
+ // the loop was latched, not failing.
7369
+ //
7370
+ // The hang is in the transport: #sendAnnounceAndWait awaits
7371
+ // #sendThroughOnionPath BEFORE it awaits the timeout-guarded waiter, and
7372
+ // in a browser that send goes over a relay WebSocket. A backgrounded tab
7373
+ // can leave that socket neither open nor errored, so the await never
7374
+ // settles and Promise.allSettled underneath waits forever. Native peers
7375
+ // send over UDP and never hit it, which is why this was browser-only.
7376
+ //
7377
+ // Timing out the specific await would fix today's hang; this fixes the
7378
+ // class. A self-healing loop must not depend on every transport path
7379
+ // being timeout-correct to stay alive.
7380
+ const budgetMs = Math.max(0, deadlineMs - Date.now()) + SELF_ANNOUNCE_WATCHDOG_MARGIN_MS;
7381
+ let watchdog;
7382
+ const guarded = Promise.race([
7383
+ this.#announceSelfBestEffort(force, deadlineMs),
7384
+ new Promise((resolve) => {
7385
+ watchdog = setTimeout(() => {
7386
+ this.#debugLog(`self announce watchdog fired after ${Math.round(budgetMs / 1000)}s — ` +
7387
+ `abandoning the run so the loop can retry`);
7388
+ this.#selfAnnounceEpoch += 1;
7389
+ resolve([]);
7390
+ }, budgetMs);
7391
+ watchdog.unref?.();
7392
+ }),
7393
+ ]);
7394
+ this.#selfAnnouncePromise = guarded;
7299
7395
  try {
7300
- const result = await this.#selfAnnouncePromise;
7396
+ const result = await guarded;
7301
7397
  return result ?? [];
7302
7398
  }
7303
7399
  finally {
7400
+ if (watchdog)
7401
+ clearTimeout(watchdog);
7304
7402
  this.#selfAnnouncePromise = undefined;
7305
7403
  }
7306
7404
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.154",
3
+ "version": "0.1.155",
4
4
  "description": "Pure TypeScript port of Elastos Carrier (toxcore-derived) P2P messaging. DHT, onion routing, TCP relay, FlatBuffers app payloads, Express offline relay. Wire-compatible with iOS Beagle and the Carrier C SDK.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",