@decentnetwork/peer 0.1.155 → 0.1.157

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 +106 -3
  2. package/package.json +1 -1
package/dist/peer.js CHANGED
@@ -93,6 +93,12 @@ const SELF_ANNOUNCE_INTERVAL_MS = readEnvInt("DECENT_SELF_ANNOUNCE_INTERVAL_MS",
93
93
  // abandoned run is left to finish or hang on its own; what matters is that the
94
94
  // NEXT tick is allowed to start.
95
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";
96
102
  // How long a stored announce entry stays useful to someone looking us up.
97
103
  // toxcore expires announce entries on roughly this timescale, so a node that
98
104
  // acknowledged a store longer ago than this can no longer be counted on.
@@ -521,6 +527,10 @@ export class Peer {
521
527
  // #initiateSession every few seconds for every friend; without this
522
528
  // dedupe, lower-pubkey side floods the log with "deferring to peer".
523
529
  #initiateSkipLogged = new Set();
530
+ /** Recently COMPLETED bulkmsg assemblies, same key, kept for the same 60s
531
+ * the assembly itself lives. Without it a retransmitted message reassembles
532
+ * a second time and is delivered twice. */
533
+ #bulkCompleted = new Map();
524
534
  // In-flight Carrier BULKMSG reassemblies, keyed `${friendId}:${tid}`.
525
535
  // Fragments are appended in arrival order (the lossless stream keeps
526
536
  // them ordered) and the message completes when totalsz bytes arrived.
@@ -1101,6 +1111,13 @@ export class Peer {
1101
1111
  }
1102
1112
  const resumeSelfAnnounce = this.#pauseSelfAnnounce();
1103
1113
  try {
1114
+ // Fault injection, off unless DECENT_FAULT_REQUEST_HANG=1. Hangs inside
1115
+ // the paused region — the shape that killed self-announce in the
1116
+ // browser — so the pause expiry can be tested rather than argued for.
1117
+ if (FAULT_REQUEST_HANG) {
1118
+ this.#debugLog("FAULT: hanging inside the self-announce pause forever");
1119
+ await new Promise(() => { });
1120
+ }
1104
1121
  if (this.#selfAnnouncePromise) {
1105
1122
  await this.#selfAnnouncePromise.catch(() => { });
1106
1123
  }
@@ -1600,6 +1617,29 @@ export class Peer {
1600
1617
  return;
1601
1618
  }
1602
1619
  }
1620
+ // Inline files ride the TEXT channel, and the delivery envelope wraps them.
1621
+ //
1622
+ // The call sites check for an inline file BEFORE this function, i.e. before
1623
+ // the envelope comes off — so they were looking at "\x1eDNPACK1:<base64>",
1624
+ // which does not start with "{", and every inline file sent with delivery
1625
+ // tracking was reported as a text message instead. Measured 2026-08-31,
1626
+ // peer to peer: a 95-byte PNG arrived as 200 characters of JSON, and a
1627
+ // 300 KB one as 409,672 characters. Both transferred perfectly; both were
1628
+ // handed to the application as a wall of base64.
1629
+ //
1630
+ // The check belongs here, on the unwrapped text, where it sees what the
1631
+ // sender actually wrote. The call-site checks stay for envelope-less
1632
+ // senders (iOS and Android do not speak this envelope).
1633
+ //
1634
+ // ACK first: an inline file is delivered, and skipping the ACK because we
1635
+ // did not run the text handlers would make the sender retransmit forever.
1636
+ if (this.#tryEmitInlineFile(msg.pubkey, text, msg.via === "offline" ? "offline" : "online")) {
1637
+ if (deliveryId) {
1638
+ this.#rememberDeliveredTextId(deliveryId);
1639
+ void this.#sendTextAck(msg.pubkey, deliveryId).catch(() => undefined);
1640
+ }
1641
+ return;
1642
+ }
1603
1643
  if (this.#textHandlers.size === 0) {
1604
1644
  if (deliveryId)
1605
1645
  this.#debugLog(`text delivery ${deliveryId} from ${msg.pubkey} has no onText handlers; not ACKing`);
@@ -3166,13 +3206,22 @@ export class Peer {
3166
3206
  try {
3167
3207
  const decoded = decodeCarrierPacket(carrierPacket);
3168
3208
  if (decoded.type !== PACKET_TYPE_FRIEND_REQUEST) {
3209
+ // Was a bare `return`. An onion friend-request that arrived and did not
3210
+ // parse left NOTHING anywhere — the sender saw success, the recipient
3211
+ // saw no request, and there was no way to tell "never arrived" from
3212
+ // "arrived and was dropped". That is the single most-reported symptom
3213
+ // on this project and it was undiagnosable by construction.
3214
+ this.#debugLog(`friend-request from ${carrierIdFromPublicKey(senderPublicKey)} DROPPED: ` +
3215
+ `decoded as packet type ${decoded.type}, expected ${PACKET_TYPE_FRIEND_REQUEST}`);
3169
3216
  return;
3170
3217
  }
3171
3218
  hello = decoded.hello;
3172
3219
  name = decoded.name;
3173
3220
  description = decoded.descr;
3174
3221
  }
3175
- catch {
3222
+ catch (error) {
3223
+ this.#debugLog(`friend-request from ${carrierIdFromPublicKey(senderPublicKey)} DROPPED: ` +
3224
+ `undecodable (${carrierPacket.length} bytes): ${error.message}`);
3176
3225
  return;
3177
3226
  }
3178
3227
  const userid = carrierIdFromPublicKey(senderPublicKey);
@@ -3214,13 +3263,17 @@ export class Peer {
3214
3263
  try {
3215
3264
  const decoded = decodeCarrierPacket(packet);
3216
3265
  if (decoded.type !== PACKET_TYPE_FRIEND_REQUEST) {
3266
+ this.#debugLog(`offline friend-request from ${fromUserId} DROPPED: decoded as packet ` +
3267
+ `type ${decoded.type}, expected ${PACKET_TYPE_FRIEND_REQUEST}`);
3217
3268
  return;
3218
3269
  }
3219
3270
  helloText = decoded.hello;
3220
3271
  name = decoded.name;
3221
3272
  description = decoded.descr;
3222
3273
  }
3223
- catch {
3274
+ catch (error) {
3275
+ this.#debugLog(`offline friend-request from ${fromUserId} DROPPED: undecodable ` +
3276
+ `(${packet.length} bytes): ${error.message}`);
3224
3277
  return;
3225
3278
  }
3226
3279
  // Already an established friend? Ignore this (re-)request. Auto-friend
@@ -4750,6 +4803,24 @@ export class Peer {
4750
4803
  this.#bulkAssembly.delete(key);
4751
4804
  }
4752
4805
  const key = `${friendId}:${frag.tid.toString()}`;
4806
+ // A completed assembly is deleted, so a second copy of the same fragments
4807
+ // starts a fresh entry and completes AGAIN — the application gets the same
4808
+ // message twice. Duplicate FRAGMENTS were already ignored; a duplicate
4809
+ // whole MESSAGE was not.
4810
+ //
4811
+ // It shows up on the plain send path (a native peer, or a JS peer before
4812
+ // userinfo has settled), because that path carries no delivery id and the
4813
+ // text-level dedupe has nothing to key on. Measured 2026-08-31: one
4814
+ // sendInlineFile call, two `bulkmsg complete (409672 bytes)` and two
4815
+ // identical 300 KB images delivered.
4816
+ for (const [k, at] of this.#bulkCompleted) {
4817
+ if (at < now - 60_000)
4818
+ this.#bulkCompleted.delete(k);
4819
+ }
4820
+ if (this.#bulkCompleted.has(key)) {
4821
+ this.#debugLog(`bulkmsg ${key} already completed — dropping duplicate`);
4822
+ return undefined;
4823
+ }
4753
4824
  let entry = this.#bulkAssembly.get(key);
4754
4825
  if (!entry) {
4755
4826
  entry = { total: 0, frags: new Map(), got: 0, expireAtMs: now + 60_000 };
@@ -4774,6 +4845,7 @@ export class Peer {
4774
4845
  if (entry.total === 0 || entry.got < entry.total)
4775
4846
  return undefined;
4776
4847
  this.#bulkAssembly.delete(key);
4848
+ this.#bulkCompleted.set(key, now);
4777
4849
  if (entry.got > entry.total) {
4778
4850
  this.#debugLog(`bulkmsg from ${friendId} got ${entry.got} > total ${entry.total} — dropped`);
4779
4851
  return undefined;
@@ -7347,11 +7419,42 @@ export class Peer {
7347
7419
  const recentBonus = Date.now() - h.lastOkMs < 60_000 ? 2 : 0;
7348
7420
  return (h.ok * 2) - h.fail + recentBonus;
7349
7421
  }
7422
+ /**
7423
+ * Pause the self-announce loop, with an expiry.
7424
+ *
7425
+ * The pause is released in a `finally`, which is correct right up until the
7426
+ * guarded body never finishes. sendFriendRequest awaits a relay send inside
7427
+ * that body, and in a browser a backgrounded tab can leave that socket
7428
+ * neither open nor errored — so the `finally` never runs, the depth stays at
7429
+ * 1, and self-announce is dead forever.
7430
+ *
7431
+ * This latch sits BEFORE the watchdog in #ensureSelfAnnounceLoop: the tick
7432
+ * returns on pause depth without ever calling #runSelfAnnounce, so bounding
7433
+ * the run was not enough on its own. Measured on app.beagle.chat after the
7434
+ * watchdog shipped: announce age still climbing past 700s while the page's
7435
+ * other timers ran normally.
7436
+ *
7437
+ * A pause is a short-lived thing — a few seconds around one send. One that
7438
+ * outlives this window is a bug somewhere upstream, and the loop matters
7439
+ * more than the pause does.
7440
+ */
7350
7441
  #pauseSelfAnnounce() {
7351
7442
  this.#selfAnnouncePauseDepth += 1;
7352
- return () => {
7443
+ let released = false;
7444
+ const release = (viaTimeout) => {
7445
+ if (released)
7446
+ return;
7447
+ released = true;
7448
+ clearTimeout(expiry);
7353
7449
  this.#selfAnnouncePauseDepth = Math.max(0, this.#selfAnnouncePauseDepth - 1);
7450
+ if (viaTimeout) {
7451
+ this.#debugLog(`self-announce pause expired after ${Math.round(SELF_ANNOUNCE_PAUSE_MAX_MS / 1000)}s — ` +
7452
+ `releasing it; whoever took it never gave it back`);
7453
+ }
7354
7454
  };
7455
+ const expiry = setTimeout(() => release(true), SELF_ANNOUNCE_PAUSE_MAX_MS);
7456
+ expiry.unref?.();
7457
+ return () => release(false);
7355
7458
  }
7356
7459
  async #runSelfAnnounce(force, deadlineMs) {
7357
7460
  if (this.#selfAnnouncePromise) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.155",
3
+ "version": "0.1.157",
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",