@decentnetwork/peer 0.1.152 → 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.
@@ -2,6 +2,7 @@ import { ByteBuffer, Encoding } from "flatbuffers";
2
2
  import nacl from "tweetnacl";
3
3
  import { base58ToBytes, bytesToBase58 } from "../utils/base58.js";
4
4
  import { randomBytes } from "../utils/bytes.js";
5
+ import { sharedKey as sharedKeyFor } from "../crypto/shared-key.js";
5
6
  const EXPRESS_MAGIC = 0xca6ee595;
6
7
  const NONCE_SIZE = 24;
7
8
  const HTTP_TIMEOUT_MS = 15000;
@@ -34,7 +35,7 @@ export class LegacyExpressClient {
34
35
  host: node.host,
35
36
  port: node.port,
36
37
  tls: node.tls !== false, // default HTTPS; opt into HTTP with tls:false
37
- sharedKey: nacl.box.before(expressPk, this.#selfKeyPair.secretKey)
38
+ sharedKey: sharedKeyFor(expressPk, this.#selfKeyPair.secretKey)
38
39
  };
39
40
  });
40
41
  }
@@ -49,7 +50,7 @@ export class LegacyExpressClient {
49
50
  if (friendPk.length !== 32) {
50
51
  throw new Error("friend user id must decode to 32-byte public key");
51
52
  }
52
- const friendSharedKey = nacl.box.before(friendPk, this.#selfKeyPair.secretKey);
53
+ const friendSharedKey = sharedKeyFor(friendPk, this.#selfKeyPair.secretKey);
53
54
  const friendEncrypted = encrypt(friendSharedKey, carrierPacket);
54
55
  await this.#postEncrypted(friendUserId, friendEncrypted);
55
56
  }
@@ -112,7 +113,7 @@ export class LegacyExpressClient {
112
113
  if (friendPk.length !== 32) {
113
114
  continue;
114
115
  }
115
- const friendSharedKey = nacl.box.before(friendPk, this.#selfKeyPair.secretKey);
116
+ const friendSharedKey = sharedKeyFor(friendPk, this.#selfKeyPair.secretKey);
116
117
  const packet = decrypt(friendSharedKey, msg.payload);
117
118
  if (!packet) {
118
119
  continue;
@@ -1,6 +1,7 @@
1
1
  import nacl from "tweetnacl";
2
2
  import { createHash } from "node:crypto";
3
3
  import { concatBytes, randomBytes } from "../utils/bytes.js";
4
+ import { sharedKey as sharedKeyFor } from "../crypto/shared-key.js";
4
5
  export const NET_PACKET_COOKIE_REQUEST = 0x18;
5
6
  export const NET_PACKET_COOKIE_RESPONSE = 0x19;
6
7
  export const NET_PACKET_CRYPTO_HS = 0x1a;
@@ -44,7 +45,7 @@ export function createCookieRequest(opts) {
44
45
  throw new Error("cookie request plaintext size mismatch");
45
46
  }
46
47
  const nonce = randomBytes(NONCE_SIZE);
47
- const sharedKey = nacl.box.before(opts.receiverDhtPublicKey, opts.senderDhtSecretKey);
48
+ const sharedKey = sharedKeyFor(opts.receiverDhtPublicKey, opts.senderDhtSecretKey);
48
49
  const cipher = nacl.secretbox(plain, nonce, sharedKey);
49
50
  return concatBytes([
50
51
  Uint8Array.of(NET_PACKET_COOKIE_REQUEST),
@@ -60,7 +61,7 @@ export function openCookieRequest(packet, opts) {
60
61
  const senderDhtPublicKey = packet.slice(1, 1 + KEY_SIZE);
61
62
  const nonce = packet.slice(1 + KEY_SIZE, 1 + KEY_SIZE + NONCE_SIZE);
62
63
  const cipher = packet.slice(1 + KEY_SIZE + NONCE_SIZE);
63
- const sharedKey = nacl.box.before(senderDhtPublicKey, opts.receiverDhtSecretKey);
64
+ const sharedKey = sharedKeyFor(senderDhtPublicKey, opts.receiverDhtSecretKey);
64
65
  const plain = nacl.secretbox.open(cipher, nonce, sharedKey);
65
66
  if (!plain || plain.length !== COOKIE_REQUEST_PLAIN_LENGTH) {
66
67
  return undefined;
@@ -79,7 +80,7 @@ export function createCookieResponse(opts) {
79
80
  });
80
81
  const plain = concatBytes([cookie, writeUint64Le(opts.request.echo)]);
81
82
  const nonce = randomBytes(NONCE_SIZE);
82
- const sharedKey = nacl.box.before(opts.request.senderDhtPublicKey, opts.receiverDhtSecretKey);
83
+ const sharedKey = sharedKeyFor(opts.request.senderDhtPublicKey, opts.receiverDhtSecretKey);
83
84
  const cipher = nacl.secretbox(plain, nonce, sharedKey);
84
85
  return concatBytes([Uint8Array.of(NET_PACKET_COOKIE_RESPONSE), nonce, cipher]);
85
86
  }
@@ -89,7 +90,7 @@ export function openCookieResponse(packet, opts) {
89
90
  }
90
91
  const nonce = packet.slice(1, 1 + NONCE_SIZE);
91
92
  const cipher = packet.slice(1 + NONCE_SIZE);
92
- const sharedKey = nacl.box.before(opts.receiverDhtPublicKey, opts.senderDhtSecretKey);
93
+ const sharedKey = sharedKeyFor(opts.receiverDhtPublicKey, opts.senderDhtSecretKey);
93
94
  const plain = nacl.secretbox.open(cipher, nonce, sharedKey);
94
95
  if (!plain || plain.length !== COOKIE_RESPONSE_PLAIN_LENGTH) {
95
96
  return undefined;
@@ -135,7 +136,7 @@ export function createCryptoHandshake(opts) {
135
136
  sha512(opts.recipientCookie),
136
137
  ownCookie
137
138
  ]);
138
- const sharedKey = nacl.box.before(opts.receiverRealPublicKey, opts.senderRealSecretKey);
139
+ const sharedKey = sharedKeyFor(opts.receiverRealPublicKey, opts.senderRealSecretKey);
139
140
  const innerCipher = nacl.secretbox(innerPlain, innerNonce, sharedKey);
140
141
  return concatBytes([Uint8Array.of(NET_PACKET_CRYPTO_HS), opts.recipientCookie, innerNonce, innerCipher]);
141
142
  }
@@ -152,7 +153,7 @@ export function openCryptoHandshake(packet, opts) {
152
153
  const senderDhtPublicKey = cookieParsed.dhtPublicKey;
153
154
  const nonce = packet.slice(1 + COOKIE_LENGTH, 1 + COOKIE_LENGTH + NONCE_SIZE);
154
155
  const cipher = packet.slice(1 + COOKIE_LENGTH + NONCE_SIZE);
155
- const sharedKey = nacl.box.before(senderRealPublicKey, opts.receiverRealSecretKey);
156
+ const sharedKey = sharedKeyFor(senderRealPublicKey, opts.receiverRealSecretKey);
156
157
  const inner = nacl.secretbox.open(cipher, nonce, sharedKey);
157
158
  if (!inner || inner.length !== HANDSHAKE_INNER_LENGTH) {
158
159
  return undefined;
@@ -0,0 +1,4 @@
1
+ export declare function sharedKey(theirPublicKey: Uint8Array, ourSecretKey: Uint8Array): Uint8Array;
2
+ /** Drop everything — for tests, and for a key rotation that reuses the same
3
+ * Uint8Array instance. */
4
+ export declare function clearSharedKeyCache(): void;
@@ -0,0 +1,45 @@
1
+ import nacl from "tweetnacl";
2
+ import { bytesToHex } from "../utils/bytes.js";
3
+ /**
4
+ * X25519 shared-secret derivation, memoised.
5
+ *
6
+ * `nacl.box.before(theirPk, ourSk)` is a scalar multiplication — the single
7
+ * most expensive thing tweetnacl does, and it is pure JS. Every packet that
8
+ * derived its key inline paid for one.
9
+ *
10
+ * That cost is invisible until a node has friends. Measured with a 53-friend
11
+ * list against a peer that could reach none of them (the ordinary case: a
12
+ * laptop whose friends are asleep), the connection loop's cookie requests put
13
+ *
14
+ * 60.8% tweetnacl M (bignum multiply)
15
+ * 11.4% crypto_scalarmult
16
+ *
17
+ * of the process on one function — 72% of the CPU, for keys that never change.
18
+ * A browser tab showed the same shape at 100% of a core for 25 hours.
19
+ *
20
+ * The result depends only on (theirPk, ourSk), so it is cached. Keyed on the
21
+ * public key, with the secret compared by IDENTITY rather than content: a peer
22
+ * passes the same Uint8Array every time, and a different keypair recomputes
23
+ * rather than risking a stale hit. Nothing secret is used as a map key.
24
+ */
25
+ const cache = new Map();
26
+ const MAX_ENTRIES = 1024;
27
+ export function sharedKey(theirPublicKey, ourSecretKey) {
28
+ const id = bytesToHex(theirPublicKey);
29
+ const hit = cache.get(id);
30
+ if (hit && hit.sk === ourSecretKey)
31
+ return hit.key;
32
+ const key = nacl.box.before(theirPublicKey, ourSecretKey);
33
+ if (cache.size >= MAX_ENTRIES) {
34
+ const oldest = cache.keys().next().value;
35
+ if (oldest !== undefined)
36
+ cache.delete(oldest);
37
+ }
38
+ cache.set(id, { sk: ourSecretKey, key });
39
+ return key;
40
+ }
41
+ /** Drop everything — for tests, and for a key rotation that reuses the same
42
+ * Uint8Array instance. */
43
+ export function clearSharedKeyCache() {
44
+ cache.clear();
45
+ }
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;
@@ -6960,7 +7022,41 @@ export class Peer {
6960
7022
  const wrapped = concatBytes([Uint8Array.of(0x69, 0x76, 0x65, 0x67), packet]);
6961
7023
  await this.#udp.send(Buffer.from(wrapped), node.host, node.port);
6962
7024
  }
7025
+ /** Hand an onion request to the connected relays, which stand in for hop A. */
7026
+ #sendOnionOverRelays(nodeB, nodeC, nodeD, payloadForNodeD) {
7027
+ if (!this.#tcpRelays || this.#tcpRelays.connectedCount() === 0)
7028
+ return;
7029
+ try {
7030
+ const tcpPacket = createOnionRequest0Tcp({
7031
+ nodeBHost: nodeB.host,
7032
+ nodeBPort: nodeB.port,
7033
+ nodeBPublicKey: nodeB.publicKey,
7034
+ nodeCHost: nodeC.host,
7035
+ nodeCPort: nodeC.port,
7036
+ nodeCPublicKey: nodeC.publicKey,
7037
+ nodeDHost: nodeD.host,
7038
+ nodeDPort: nodeD.port,
7039
+ payloadForNodeD
7040
+ });
7041
+ const sent = this.#tcpRelays.sendOnionRequest(tcpPacket);
7042
+ if (sent > 0) {
7043
+ this.#diagTcpOnionSent += 1;
7044
+ this.#debugVerboseLog(`tcp onion request sent via ${sent} relay(s) to ${nodeD.host}:${nodeD.port}`);
7045
+ }
7046
+ }
7047
+ catch {
7048
+ /* malformed hop key — nothing to send */
7049
+ }
7050
+ }
6963
7051
  async #sendThroughOnionPath(payloadForNodeD, nodeD, pathOffset = 0, forcedPath) {
7052
+ // Does this host have real UDP? The browser shim binds and drops, and its
7053
+ // socket reports port 0. Building the UDP onion request there is six
7054
+ // scalar multiplications — three ephemeral keypairs and three
7055
+ // Diffie-Hellmans, in pure JS — for a packet that goes nowhere. Measured:
7056
+ // onion construction was 79% of all crypto time, split almost exactly
7057
+ // between the UDP variant and its TCP twin, and a browser tab held a core
7058
+ // at 100% for 25 hours doing it. Decide BEFORE constructing anything.
7059
+ const udpUsable = (this.#udp?.localPort() ?? 0) > 0;
6964
7060
  const path = forcedPath === "direct" ? undefined : forcedPath ?? this.#selectOnionPath(nodeD, pathOffset);
6965
7061
  if (!path) {
6966
7062
  // No 3-hop UDP path. "Direct" then meant a plain UDP send — which on a
@@ -6971,37 +7067,29 @@ export class Peer {
6971
7067
  // A RELAYED onion request needs only two hops: the relay is node A. So
6972
7068
  // try that before giving up, and only fall through to the UDP send when
6973
7069
  // there are no relays or not even two usable hops.
6974
- if (this.#tcpRelays && this.#tcpRelays.connectedCount() > 0) {
6975
- const hops = this.#selectTcpOnionHops(nodeD, pathOffset);
6976
- if (hops) {
6977
- try {
6978
- const tcpPacket = createOnionRequest0Tcp({
6979
- nodeBHost: hops.nodeB.host,
6980
- nodeBPort: hops.nodeB.port,
6981
- nodeBPublicKey: hops.nodeB.publicKey,
6982
- nodeCHost: hops.nodeC.host,
6983
- nodeCPort: hops.nodeC.port,
6984
- nodeCPublicKey: hops.nodeC.publicKey,
6985
- nodeDHost: nodeD.host,
6986
- nodeDPort: nodeD.port,
6987
- payloadForNodeD
6988
- });
6989
- const sent = this.#tcpRelays.sendOnionRequest(tcpPacket);
6990
- if (sent > 0) {
6991
- this.#diagTcpOnionSent += 1;
6992
- this.#debugLog(`no 3-hop path for ${nodeD.host}:${nodeD.port} — relayed 2-hop onion via ${sent} relay(s) (B=${hops.nodeB.host} C=${hops.nodeC.host})`);
6993
- return "direct";
6994
- }
6995
- }
6996
- catch {
6997
- /* malformed hop key — fall through to the UDP attempt */
6998
- }
6999
- }
7070
+ const hops = this.#tcpRelays && this.#tcpRelays.connectedCount() > 0
7071
+ ? this.#selectTcpOnionHops(nodeD, pathOffset)
7072
+ : undefined;
7073
+ if (hops) {
7074
+ this.#sendOnionOverRelays(hops.nodeB, hops.nodeC, nodeD, payloadForNodeD);
7075
+ return "direct";
7076
+ }
7077
+ if (!udpUsable) {
7078
+ // Nothing can carry it: no relay hops and no UDP. Say so instead of
7079
+ // handing the packet to a socket that drops it silently.
7080
+ this.#debugLog(`no route for ${nodeD.host}:${nodeD.port} — no relay hops and no UDP`);
7081
+ return "direct";
7000
7082
  }
7001
7083
  this.#debugLog(`no onion path for ${nodeD.host}:${nodeD.port}, sending direct`);
7002
7084
  await this.#sendPacket(payloadForNodeD, nodeD);
7003
7085
  return "direct";
7004
7086
  }
7087
+ if (!udpUsable) {
7088
+ // No UDP: go straight to the relay form, which is the only one that can
7089
+ // actually leave this host.
7090
+ this.#sendOnionOverRelays(path.nodeB, path.nodeC, nodeD, payloadForNodeD);
7091
+ return path;
7092
+ }
7005
7093
  this.#debugLog(`sending onion initial via ${path.nodeA.node.host}:${path.nodeA.node.port} to ${nodeD.host}:${nodeD.port}`);
7006
7094
  const packet = createOnionRequest0({
7007
7095
  nodeAPublicKey: path.nodeA.publicKey,
@@ -7269,12 +7357,48 @@ export class Peer {
7269
7357
  if (this.#selfAnnouncePromise) {
7270
7358
  await this.#selfAnnouncePromise.catch(() => { });
7271
7359
  }
7272
- 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;
7273
7395
  try {
7274
- const result = await this.#selfAnnouncePromise;
7396
+ const result = await guarded;
7275
7397
  return result ?? [];
7276
7398
  }
7277
7399
  finally {
7400
+ if (watchdog)
7401
+ clearTimeout(watchdog);
7278
7402
  this.#selfAnnouncePromise = undefined;
7279
7403
  }
7280
7404
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.152",
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",