@decentnetwork/peer 0.1.154 → 0.1.156

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 +156 -14
  2. package/package.json +1 -1
package/dist/peer.js CHANGED
@@ -89,6 +89,21 @@ 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
+ // Longest a self-announce pause may hold before it releases itself. The only
97
+ // caller wraps a single friend-request send, which has its own 8s announce
98
+ // deadline plus route discovery; 60s is well clear of a slow-but-working send
99
+ // and well short of "the peer is now invisible".
100
+ const SELF_ANNOUNCE_PAUSE_MAX_MS = readEnvInt("DECENT_SELF_ANNOUNCE_PAUSE_MAX_MS", 60_000);
101
+ const FAULT_REQUEST_HANG = process.env.DECENT_FAULT_REQUEST_HANG === "1";
102
+ // How long a stored announce entry stays useful to someone looking us up.
103
+ // toxcore expires announce entries on roughly this timescale, so a node that
104
+ // acknowledged a store longer ago than this can no longer be counted on.
105
+ const ANNOUNCE_ENTRY_TTL_MS = readEnvInt("DECENT_ANNOUNCE_ENTRY_TTL_MS", 300_000);
106
+ const FAULT_ANNOUNCE_HANG_RUN = readEnvInt("DECENT_FAULT_ANNOUNCE_HANG", 0);
92
107
  // Classic-DHT maintenance cadence. Every tick we get_nodes toward our own key
93
108
  // (so neighbours store our address and native peers can find our UDP endpoint)
94
109
  // and toward each not-yet-UDP friend's key (so we find theirs and punch).
@@ -475,6 +490,11 @@ export class Peer {
475
490
  // 25s forever for stale persisted entries.
476
491
  #dhtPkConsecutiveFailures = new Map();
477
492
  #lastSelfAnnounceStoredCount = -1;
493
+ // nodeId -> when that node last acknowledged STORING our announce. What
494
+ // callers actually want to know is "can anyone look me up right now", and
495
+ // that is a property of live storage across rounds, not of the round in
496
+ // flight. Counted against ANNOUNCE_ENTRY_TTL_MS on read.
497
+ #selfAnnounceStoredAt = new Map();
478
498
  // Diagnostics for the TCP-relay onion path (announce/discovery over TCP).
479
499
  // Surfaced in dhtHealth so `agentnet diag` shows whether it's active without
480
500
  // needing verbose logs: sent = onion requests handed to relays, recv = onion
@@ -536,6 +556,13 @@ export class Peer {
536
556
  #profileRetryTimers = new Map();
537
557
  #greetingSentTo = new Set();
538
558
  #selfAnnouncePromise;
559
+ #selfAnnounceRunCount = 0;
560
+ // Bumped when the watchdog abandons a run. The abandoned run is still alive
561
+ // and would otherwise keep writing #announceRouteUsed underneath the round
562
+ // that replaced it — and step2 rejects a ping_id whose route changed, so
563
+ // that interference would cost stores. It cannot be killed, but it can be
564
+ // told to stop at its next wave boundary.
565
+ #selfAnnounceEpoch = 0;
539
566
  #selfAnnouncePauseDepth = 0;
540
567
  #started = false;
541
568
  constructor(opts) {
@@ -1080,6 +1107,13 @@ export class Peer {
1080
1107
  }
1081
1108
  const resumeSelfAnnounce = this.#pauseSelfAnnounce();
1082
1109
  try {
1110
+ // Fault injection, off unless DECENT_FAULT_REQUEST_HANG=1. Hangs inside
1111
+ // the paused region — the shape that killed self-announce in the
1112
+ // browser — so the pause expiry can be tested rather than argued for.
1113
+ if (FAULT_REQUEST_HANG) {
1114
+ this.#debugLog("FAULT: hanging inside the self-announce pause forever");
1115
+ await new Promise(() => { });
1116
+ }
1083
1117
  if (this.#selfAnnouncePromise) {
1084
1118
  await this.#selfAnnouncePromise.catch(() => { });
1085
1119
  }
@@ -3633,6 +3667,17 @@ export class Peer {
3633
3667
  return [];
3634
3668
  }
3635
3669
  this.#lastSelfAnnounceMs = now;
3670
+ // Fault injection, off unless DECENT_FAULT_ANNOUNCE_HANG names a run
3671
+ // index. Reproduces the browser wedge — a run that never settles — so the
3672
+ // watchdog in #runSelfAnnounce can be tested for real instead of only
3673
+ // reasoned about. Same spirit as DECENT_ANNOUNCE_DEBUG: costs one integer
3674
+ // compare when unset.
3675
+ const myEpoch = this.#selfAnnounceEpoch;
3676
+ this.#selfAnnounceRunCount += 1;
3677
+ if (FAULT_ANNOUNCE_HANG_RUN > 0 && this.#selfAnnounceRunCount === FAULT_ANNOUNCE_HANG_RUN) {
3678
+ this.#debugLog(`FAULT: hanging self-announce run #${this.#selfAnnounceRunCount} forever`);
3679
+ await new Promise(() => { });
3680
+ }
3636
3681
  const storedNodes = [];
3637
3682
  // An onion announce only STORES on the nodes whose key is closest to OURS,
3638
3683
  // and toxcore reaches them by following the "here are closer nodes" lists
@@ -3671,15 +3716,27 @@ export class Peer {
3671
3716
  queue.sort((x, y) => xorCloser(selfPk, x.nodePk, y.nodePk));
3672
3717
  };
3673
3718
  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;
3719
+ // No reset here. Zeroing at the START of every round is why dhtHealth
3720
+ // read 0 for the first seconds of each round, and why a round cut short by
3721
+ // the deadline published 0 even though ten nodes were still holding our
3722
+ // announce from 10s earlier. Measured before this change: a healthy peer
3723
+ // reported 0 / 10 / 0 / 0 / 10 across a 135s poll. Anyone sampling at the
3724
+ // wrong moment — a user reading the UI, or me reading it and reporting it
3725
+ // as a finding — concluded the peer was unfindable when it was not.
3726
+ // "Never ran" is still distinguishable: #lastSelfAnnounceStoredCount
3727
+ // stays -1 until the first round records something.
3728
+ if (this.#lastSelfAnnounceStoredCount < 0)
3729
+ this.#lastSelfAnnounceStoredCount = 0;
3677
3730
  const STORE_TARGET = 4;
3678
3731
  let waves = 0;
3679
3732
  while (queue.length > 0 && storedNodes.length < STORE_TARGET && waves < 16) {
3680
3733
  if (Date.now() >= deadlineMs) {
3681
3734
  this.#debugLog("self announce stopped at deadline");
3682
- this.#lastSelfAnnounceStoredCount = storedNodes.length;
3735
+ this.#publishSelfAnnounceStoredCount();
3736
+ return storedNodes;
3737
+ }
3738
+ if (this.#selfAnnounceEpoch !== myEpoch) {
3739
+ this.#debugLog("self announce abandoned by watchdog — stopping at wave boundary");
3683
3740
  return storedNodes;
3684
3741
  }
3685
3742
  waves += 1;
@@ -3784,9 +3841,17 @@ export class Peer {
3784
3841
  if (final.isStored === 2) {
3785
3842
  storedNodes.push(c.node);
3786
3843
  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;
3844
+ // Publish the running count only once it EXCEEDS the last completed
3845
+ // round's. Each round starts from an empty storedNodes, so writing it
3846
+ // through unconditionally made dhtHealth read 0 for the first second
3847
+ // or so of every round — and anyone who polled in that window saw
3848
+ // "stored on 0 nodes" and concluded the peer was unfindable. That
3849
+ // reading is what made the Air look permanently broken, and I
3850
+ // reported it as a finding once before checking whether it settled.
3851
+ // Measured on this Mac: the same peer alternates 0 / 9 / 10 / 0
3852
+ // across a two-minute poll while every round stores on ten nodes.
3853
+ this.#selfAnnounceStoredAt.set(`${c.node.host}:${c.node.port}`, Date.now());
3854
+ this.#publishSelfAnnounceStoredCount();
3790
3855
  }
3791
3856
  const discovered = parsePackedNodes(final.nodes);
3792
3857
  if (discovered.length > 0) {
@@ -3797,12 +3862,22 @@ export class Peer {
3797
3862
  }
3798
3863
  }
3799
3864
  }
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;
3865
+ // Track live acknowledged storage so dhtHealth() can still surface "DHT
3866
+ // discovery layer alive but our announce isn't landing anywhere" — that
3867
+ // now shows up as the count DECAYING to 0 as entries age out, instead of
3868
+ // flickering to 0 once per round.
3869
+ this.#publishSelfAnnounceStoredCount();
3804
3870
  return storedNodes;
3805
3871
  }
3872
+ /** Count nodes whose acknowledged store is still within its TTL. */
3873
+ #publishSelfAnnounceStoredCount() {
3874
+ const cutoff = Date.now() - ANNOUNCE_ENTRY_TTL_MS;
3875
+ for (const [id, at] of this.#selfAnnounceStoredAt) {
3876
+ if (at < cutoff)
3877
+ this.#selfAnnounceStoredAt.delete(id);
3878
+ }
3879
+ this.#lastSelfAnnounceStoredCount = this.#selfAnnounceStoredAt.size;
3880
+ }
3806
3881
  #ensureSelfAnnounceLoop() {
3807
3882
  if (this.#selfAnnounceTimer || SELF_ANNOUNCE_INTERVAL_MS <= 0) {
3808
3883
  return;
@@ -7285,22 +7360,89 @@ export class Peer {
7285
7360
  const recentBonus = Date.now() - h.lastOkMs < 60_000 ? 2 : 0;
7286
7361
  return (h.ok * 2) - h.fail + recentBonus;
7287
7362
  }
7363
+ /**
7364
+ * Pause the self-announce loop, with an expiry.
7365
+ *
7366
+ * The pause is released in a `finally`, which is correct right up until the
7367
+ * guarded body never finishes. sendFriendRequest awaits a relay send inside
7368
+ * that body, and in a browser a backgrounded tab can leave that socket
7369
+ * neither open nor errored — so the `finally` never runs, the depth stays at
7370
+ * 1, and self-announce is dead forever.
7371
+ *
7372
+ * This latch sits BEFORE the watchdog in #ensureSelfAnnounceLoop: the tick
7373
+ * returns on pause depth without ever calling #runSelfAnnounce, so bounding
7374
+ * the run was not enough on its own. Measured on app.beagle.chat after the
7375
+ * watchdog shipped: announce age still climbing past 700s while the page's
7376
+ * other timers ran normally.
7377
+ *
7378
+ * A pause is a short-lived thing — a few seconds around one send. One that
7379
+ * outlives this window is a bug somewhere upstream, and the loop matters
7380
+ * more than the pause does.
7381
+ */
7288
7382
  #pauseSelfAnnounce() {
7289
7383
  this.#selfAnnouncePauseDepth += 1;
7290
- return () => {
7384
+ let released = false;
7385
+ const release = (viaTimeout) => {
7386
+ if (released)
7387
+ return;
7388
+ released = true;
7389
+ clearTimeout(expiry);
7291
7390
  this.#selfAnnouncePauseDepth = Math.max(0, this.#selfAnnouncePauseDepth - 1);
7391
+ if (viaTimeout) {
7392
+ this.#debugLog(`self-announce pause expired after ${Math.round(SELF_ANNOUNCE_PAUSE_MAX_MS / 1000)}s — ` +
7393
+ `releasing it; whoever took it never gave it back`);
7394
+ }
7292
7395
  };
7396
+ const expiry = setTimeout(() => release(true), SELF_ANNOUNCE_PAUSE_MAX_MS);
7397
+ expiry.unref?.();
7398
+ return () => release(false);
7293
7399
  }
7294
7400
  async #runSelfAnnounce(force, deadlineMs) {
7295
7401
  if (this.#selfAnnouncePromise) {
7296
7402
  await this.#selfAnnouncePromise.catch(() => { });
7297
7403
  }
7298
- this.#selfAnnouncePromise = this.#announceSelfBestEffort(force, deadlineMs);
7404
+ // Hard deadline around the WHOLE run, not just the per-node waiter.
7405
+ //
7406
+ // #ensureSelfAnnounceLoop skips a tick while #selfAnnouncePromise is set,
7407
+ // so one run that never settles kills self-announce FOREVER — silently,
7408
+ // and with dhtHealth still reporting the last good numbers. Measured on
7409
+ // app.beagle.chat 2026-08-31: lastSelfAnnounceMs frozen 1731s, stored
7410
+ // stuck at 11, nobody able to look us up. lastSelfAnnounceMs is stamped
7411
+ // on ENTRY, so a frozen value proves the function was never re-entered —
7412
+ // the loop was latched, not failing.
7413
+ //
7414
+ // The hang is in the transport: #sendAnnounceAndWait awaits
7415
+ // #sendThroughOnionPath BEFORE it awaits the timeout-guarded waiter, and
7416
+ // in a browser that send goes over a relay WebSocket. A backgrounded tab
7417
+ // can leave that socket neither open nor errored, so the await never
7418
+ // settles and Promise.allSettled underneath waits forever. Native peers
7419
+ // send over UDP and never hit it, which is why this was browser-only.
7420
+ //
7421
+ // Timing out the specific await would fix today's hang; this fixes the
7422
+ // class. A self-healing loop must not depend on every transport path
7423
+ // being timeout-correct to stay alive.
7424
+ const budgetMs = Math.max(0, deadlineMs - Date.now()) + SELF_ANNOUNCE_WATCHDOG_MARGIN_MS;
7425
+ let watchdog;
7426
+ const guarded = Promise.race([
7427
+ this.#announceSelfBestEffort(force, deadlineMs),
7428
+ new Promise((resolve) => {
7429
+ watchdog = setTimeout(() => {
7430
+ this.#debugLog(`self announce watchdog fired after ${Math.round(budgetMs / 1000)}s — ` +
7431
+ `abandoning the run so the loop can retry`);
7432
+ this.#selfAnnounceEpoch += 1;
7433
+ resolve([]);
7434
+ }, budgetMs);
7435
+ watchdog.unref?.();
7436
+ }),
7437
+ ]);
7438
+ this.#selfAnnouncePromise = guarded;
7299
7439
  try {
7300
- const result = await this.#selfAnnouncePromise;
7440
+ const result = await guarded;
7301
7441
  return result ?? [];
7302
7442
  }
7303
7443
  finally {
7444
+ if (watchdog)
7445
+ clearTimeout(watchdog);
7304
7446
  this.#selfAnnouncePromise = undefined;
7305
7447
  }
7306
7448
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.154",
3
+ "version": "0.1.156",
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",