@decentnetwork/peer 0.1.112 → 0.1.113

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.
@@ -43,13 +43,20 @@ export type FtEmit = (event: string, payload: Record<string, unknown>) => void;
43
43
  export type FtIsLanPath = (friendId: string) => boolean;
44
44
  export type FtPathKind = "lan" | "udp-direct" | "relay";
45
45
  export type FtPathKindFn = (friendId: string) => FtPathKind;
46
+ /** Fired every watchdog tick while the zero-delivery breaker is engaged: the
47
+ * current bulk path is delivering NOTHING (no ack progress). The embedder
48
+ * should fail the transfer over to its reliable path (e.g. pin file traffic
49
+ * to the TCP relay) — inbound-UDP freshness does NOT prove our outbound UDP
50
+ * reaches the peer, and a one-way path looks exactly like this. */
51
+ export type FtDeadPathFn = (friendId: string) => void;
46
52
  export declare class FileTransferManager {
47
53
  #private;
48
54
  private readonly send;
49
55
  private readonly emit;
50
56
  private readonly isLanPath;
51
57
  private readonly pathKind;
52
- constructor(send: FtSend, emit: FtEmit, isLanPath?: FtIsLanPath, pathKind?: FtPathKindFn);
58
+ private readonly onDeadPath;
59
+ constructor(send: FtSend, emit: FtEmit, isLanPath?: FtIsLanPath, pathKind?: FtPathKindFn, onDeadPath?: FtDeadPathFn);
53
60
  /** Enable resumable receives: partials are persisted under `dir` and matched
54
61
  * on re-offer by content-hash fileId. Unset = in-memory only (no resume). */
55
62
  setResumeDir(dir: string): void;
@@ -136,6 +136,17 @@ const LAN_BDP_RTT_FLOOR_MS = 50;
136
136
  const LAN_CWND_INIT_CHUNKS = Math.ceil((LAN_PACE_INIT_BPS * (LAN_BDP_RTT_FLOOR_MS / 1000) * 2) / MAX_FILE_DATA_SIZE);
137
137
  const WATCHDOG_MS = 150; // stall poll cadence / base no-progress patience
138
138
  const WATCHDOG_MAX_MS = 8000; // cap the RTT-adaptive stall patience (must exceed a slow path's RTT so acks aren't mistaken for a stall)
139
+ // Zero-delivery circuit breaker: after this many consecutive stalls with NO ack
140
+ // progress, stop go-back-N re-blasting the window and drop to a single probe
141
+ // chunk per patience tick. File chunks are RELIABLE on the relay (never dropped
142
+ // under backpressure) while IP/ping packets are droppable, so re-blasting a
143
+ // window into a path that is delivering nothing monopolizes the shared relay
144
+ // socket and starves ping/IP for the whole SEND_GIVEUP_MS minute (observed
145
+ // mac→lili: 0 acks in 60 s, ping 100% loss while sending, recovers on giveup).
146
+ // A healthy path clears `stalls` on every ack advance and never reaches this;
147
+ // a genuinely lost hole is probed with exactly the missing chunk, which is
148
+ // strictly more precise than re-sending the whole window anyway.
149
+ const STALL_PROBE_THRESHOLD = 3;
139
150
  const FAST_RT_MIN_MS = 40; // min gap between fast-retransmits of the same hole (≈1 RTT)
140
151
  const ACK_THROTTLE_MS = 15; // coalesce receiver acks (fast enough to drive fast-retransmit)
141
152
  const REACK_MS = 200; // receiver keep-alive re-ack cadence
@@ -210,16 +221,18 @@ export class FileTransferManager {
210
221
  emit;
211
222
  isLanPath;
212
223
  pathKind;
224
+ onDeadPath;
213
225
  #sending = new Map();
214
226
  #receiving = new Map();
215
227
  // When set, incoming transfers persist their contiguous prefix here so a
216
228
  // dropped/restarted transfer resumes instead of restarting (断点续传).
217
229
  #resumeDir;
218
- constructor(send, emit, isLanPath = () => false, pathKind = (friendId) => (this.isLanPath(friendId) ? "lan" : "relay")) {
230
+ constructor(send, emit, isLanPath = () => false, pathKind = (friendId) => (this.isLanPath(friendId) ? "lan" : "relay"), onDeadPath = () => { }) {
219
231
  this.send = send;
220
232
  this.emit = emit;
221
233
  this.isLanPath = isLanPath;
222
234
  this.pathKind = pathKind;
235
+ this.onDeadPath = onDeadPath;
223
236
  }
224
237
  /** Enable resumable receives: partials are persisted under `dir` and matched
225
238
  * on re-offer by content-hash fileId. Unset = in-memory only (no resume). */
@@ -767,6 +780,12 @@ export class FileTransferManager {
767
780
  st.acked = Math.min(ackedOffset, st.size);
768
781
  st.lastAckAdvanceMs = nowT;
769
782
  st.stalls = 0;
783
+ // Once a transfer hit the zero-delivery breaker, keep the embedder's
784
+ // dead-path failover armed for its remainder — recovery happened ON the
785
+ // failover path, so letting the pin lapse would swing DATA back onto the
786
+ // path that was delivering nothing.
787
+ if (st.deadPathTripped)
788
+ this.onDeadPath(friendId);
770
789
  // Resume: if the receiver's ack jumps ahead of where we've sent (it had a
771
790
  // persisted partial), skip the send cursor past the bytes it already has
772
791
  // instead of re-transmitting them.
@@ -1051,6 +1070,26 @@ export class FileTransferManager {
1051
1070
  st.parityHighBlock = ackBlock - 1;
1052
1071
  st.lastLossMs = nowMs(); // keep FEC ON so the re-emit actually fires
1053
1072
  }
1073
+ // Zero-delivery probe: exactly one chunk at the ack frontier, no window
1074
+ // re-blast, no parity. Keeps a dead/black-holed path's cost near zero
1075
+ // (~1.4 KB per patience tick) so the shared relay socket still carries
1076
+ // ping/IP, while still being the precise retransmit for a lost hole.
1077
+ async #sendProbeChunk(friendId, st) {
1078
+ const off = st.acked;
1079
+ const end = Math.min(off + MAX_FILE_DATA_SIZE, st.size);
1080
+ if (end <= off)
1081
+ return;
1082
+ if (st.nextSend < end)
1083
+ st.nextSend = end; // pump resumes AFTER the probed chunk on recovery
1084
+ try {
1085
+ await this.send(friendId, PACKET_ID_FILE_DATA, encodeFileData(st.fileNumber, off, st.data.subarray(off, end)));
1086
+ }
1087
+ catch { /* transport down — the watchdog re-probes on its next tick */ }
1088
+ if (process.env.DECENT_DEBUG || process.env.DECENT_DEBUG_VERBOSE) {
1089
+ console.error(`[file-ft] dead-path-probe friend=${friendId.slice(0, 8)} num=${st.fileNumber} ` +
1090
+ `stalls=${st.stalls} acked=${st.acked}/${st.size}`);
1091
+ }
1092
+ }
1054
1093
  // Single send loop. Awaits each send so net_crypto / the UDP socket applies
1055
1094
  // backpressure (fire-and-forget blasted the whole window into the macOS UDP
1056
1095
  // buffer at once → systematic overflow/drops the retransmit couldn't dig out
@@ -1094,6 +1133,19 @@ export class FileTransferManager {
1094
1133
  st.cwnd = Math.max(CWND_MIN_CHUNKS, Math.floor(st.cwnd / 2));
1095
1134
  st.lastAckAdvanceMs = nowMs();
1096
1135
  st.lastResendMs = nowMs();
1136
+ if (st.stalls >= STALL_PROBE_THRESHOLD) {
1137
+ // Path is delivering nothing — probe with one chunk instead of
1138
+ // re-blasting the window, so the shared relay keeps carrying
1139
+ // ping/IP. Any ack advance resets `stalls` and resumes the pump.
1140
+ // Also tell the embedder to fail DATA over to its reliable path:
1141
+ // the classic cause here is one-way UDP (their packets keep our
1142
+ // udpFresh alive while ours are black-holed), which no amount of
1143
+ // probing on the same path can fix.
1144
+ st.deadPathTripped = true;
1145
+ this.onDeadPath(friendId);
1146
+ void this.#sendProbeChunk(friendId, st);
1147
+ return;
1148
+ }
1097
1149
  this.#rewindRetransmit(st); // go-back-N + re-emit tail parity
1098
1150
  void this.#pump(friendId, st);
1099
1151
  }, WATCHDOG_MS);
package/dist/peer.js CHANGED
@@ -160,6 +160,24 @@ const LAN_SWEEP_EXTRA_HOSTS = (process.env.DECENT_LAN_SWEEP_EXTRA_HOSTS ?? "")
160
160
  .split(",")
161
161
  .map((s) => s.trim())
162
162
  .filter((s) => s.length > 0);
163
+ // Same-host probe: on cookie-request RETRIES, also send the request to our
164
+ // OWN physical IPv4 addresses (plus loopback) at the native default ports.
165
+ // This is the iOS-Simulator-on-this-Mac case: the sim shares the host's
166
+ // IP, so when the host's DHCP address changes BOTH sides keep retrying the
167
+ // dead stored endpoint forever, and DHT/onion refresh can't rescue a
168
+ // same-host peer (router hairpin). Unlike the full /24 sweep (default OFF
169
+ // — its burst saturated the NAT table and killed onion announces), these
170
+ // few unicast packets to ourselves never leave the machine, so this is
171
+ // safe to keep always-on. DECENT_LAN_SELF_PROBE=0 disables.
172
+ const LAN_SELF_PROBE_ENABLED = readEnvInt("DECENT_LAN_SELF_PROBE", 1) !== 0;
173
+ // toxcore NET_PACKET_LAN_DISCOVERY: [0x21][sender DHT pk (32)] — plaintext.
174
+ // A native node that receives one DHT-bootstraps back to the source, i.e. it
175
+ // sends us a getnodes/ping carrying its CURRENT (per-run ephemeral) DHT pk in
176
+ // the clear. That round-trip is the only way to re-learn a native friend's
177
+ // DHT key without onion announces: cookie requests encrypted to a stale DHT
178
+ // key are silently undecryptable (observed live: iPhone answered mini but
179
+ // ignored 780+ cookie retries from mac-dev — same endpoint, stale key).
180
+ const NET_PACKET_LAN_DISCOVERY = 0x21;
163
181
  const LAN_DISCOVERY_PORTS = (process.env.DECENT_LAN_DISCOVERY_PORTS ?? "33445,33446,33447,33448,33449")
164
182
  .split(",")
165
183
  .map((s) => Number.parseInt(s.trim(), 10))
@@ -243,6 +261,21 @@ export class Peer {
243
261
  Date.now() - session.lastUdpRecvMs < 4_000)
244
262
  return "udp-direct";
245
263
  return "relay";
264
+ }, (friendId) => {
265
+ // Zero-delivery breaker tripped: the bulk path is delivering nothing.
266
+ // The classic cause is one-way UDP — the peer's inbound packets keep
267
+ // udpFresh alive while our outbound UDP is black-holed (their ACCEPT
268
+ // arrived over the TCP relay, which proves the SESSION, not the UDP
269
+ // path). Pin this friend's file traffic to the reliable relay so the
270
+ // probes — and the resumed pump once acks return — actually arrive.
271
+ // Rolling window: re-armed every tick while dead; expires after the
272
+ // transfer finishes or gives up, so a later transfer re-tries UDP.
273
+ const session = this.#friendSessions.get(friendId);
274
+ const lan = !!session?.lanRemoteHost && session.remote?.host === session.lanRemoteHost;
275
+ if (!lan) {
276
+ this.#fileRelayNegotiationUntil.set(friendId, Date.now() + 30_000);
277
+ this.#debugLog(`file dead-path failover: pinning ${friendId} file traffic to relay for 30s`);
278
+ }
246
279
  });
247
280
  #keyPair;
248
281
  #udp = new UdpTransport();
@@ -303,6 +336,10 @@ export class Peer {
303
336
  #selfAnnounceTimer;
304
337
  #friendConnectionTimer;
305
338
  #lanDiscoveryTimer;
339
+ /** Recent LAN-discovery probe targets ("host:port" → friend we probed for).
340
+ * When a DHT packet later arrives from one of these sources, its sender pk
341
+ * is that friend's CURRENT DHT key — see #refreshFriendDhtKeyFromDht. */
342
+ #lanProbeTargets = new Map();
306
343
  #dhtMaintenanceTimer;
307
344
  // Per-friend last DHT-PK send time, keyed by friendId, used even when no
308
345
  // session entry exists yet so the connection loop does not flood DHT-PK
@@ -621,6 +658,13 @@ export class Peer {
621
658
  }
622
659
  }
623
660
  }
661
+ // Run the friend-connection loop from start(), not only after a
662
+ // successful joinNetwork(): when every bootstrap is unreachable
663
+ // (offline LAN, walled network), persisted same-LAN/same-host friends
664
+ // must still get their cookie retries + rescue probes — otherwise a
665
+ // daemon with dead bootstraps never reconnects to peers sitting right
666
+ // next to it. Idempotent with the joinNetwork() call.
667
+ this.#ensureFriendConnectionLoop();
624
668
  this.#started = true;
625
669
  }
626
670
  /** Lazy session shell used when we want to attach state before any handshake. */
@@ -1690,6 +1734,22 @@ export class Peer {
1690
1734
  catch { /* best-effort */ }
1691
1735
  }).catch(() => { });
1692
1736
  }
1737
+ // toxcore LAN discovery (0x21, plaintext [id][sender dht pk]): answer like
1738
+ // native DHT_bootstrap does — DHT-ping the announcer. Our ping reveals OUR
1739
+ // current DHT pk + live endpoint to them, and their ping/response feeds
1740
+ // #refreshFriendDhtKeyFromDht on their side. This is the reply half of the
1741
+ // stranded-friend rescue probes (see #initiateSession).
1742
+ if (packet[0] === NET_PACKET_LAN_DISCOVERY &&
1743
+ packet.length === 33 &&
1744
+ this.#keyPair &&
1745
+ !this.#remoteIsTcp(remote)) {
1746
+ const announcedPk = packet.slice(1, 33);
1747
+ if (!Buffer.from(announcedPk).equals(Buffer.from(this.#keyPair.publicKey))) {
1748
+ const announcerId = carrierIdFromPublicKey(announcedPk);
1749
+ void this.#sendDhtPing({ host: remote.address, port: remote.port, pk: announcerId, isTcp: false }).catch(() => undefined);
1750
+ }
1751
+ return;
1752
+ }
1693
1753
  // Classic toxcore DHT RPC (ping / get_nodes / send_nodes). Answering these
1694
1754
  // is what makes us *findable* over UDP: a native iOS/Android peer searches
1695
1755
  // the DHT for our key, a neighbour that holds our address returns it, then
@@ -4466,7 +4526,13 @@ export class Peer {
4466
4526
  }
4467
4527
  if (session.lanRemoteHost &&
4468
4528
  session.remote?.host === session.lanRemoteHost &&
4469
- host !== session.lanRemoteHost) {
4529
+ host !== session.lanRemoteHost &&
4530
+ // Same-host exception: a peer on THIS machine (iOS Simulator) moves
4531
+ // with us when our DHCP address changes — the old LAN lock target is
4532
+ // dead and the peer now answers from our own current address (or
4533
+ // loopback). Refusing that move pins the session to the dead IP
4534
+ // forever. isOwnAddress is cached; no syscall on the hot path.
4535
+ !(host === "127.0.0.1" || isOwnAddress(host))) {
4470
4536
  return; // stay locked on the physical-LAN path
4471
4537
  }
4472
4538
  session.remote = { host, port };
@@ -5077,12 +5143,79 @@ export class Peer {
5077
5143
  // Keep best-effort behavior.
5078
5144
  }
5079
5145
  }
5146
+ // Rescue probes (retries only). Two stale-state failure modes keep a
5147
+ // native friend unreachable forever even though it is alive nearby:
5148
+ // 1. Stale ENDPOINT, same host: an iOS Simulator shares this
5149
+ // machine's IP, so after a DHCP change both sides hold the dead
5150
+ // old IP and hairpin blocks every refresh path — but the sim is
5151
+ // always reachable at our current own IP.
5152
+ // 2. Stale DHT KEY, live endpoint: a native's DHT keypair rotates
5153
+ // every app restart; cookie requests encrypted to the old key
5154
+ // are silently dropped (iPhone ignored 780+ retries from
5155
+ // mac-dev while answering mini from the same endpoint).
5156
+ // So on each retry, (a) fan the cookie request out to our own
5157
+ // addresses at the native ports (fixes 1 when the key is current),
5158
+ // and (b) send plaintext LAN-discovery probes to every candidate and
5159
+ // own-host target — a native answers with a DHT packet that carries
5160
+ // its CURRENT DHT key, which #refreshFriendDhtKeyFromDht picks up
5161
+ // (fixes 2, and 1+2 combined). All local/LAN unicast, a handful of
5162
+ // packets per retry cycle.
5163
+ let selfSent = 0;
5164
+ if (LAN_SELF_PROBE_ENABLED && (this.#cookieRetryCount.get(friendId) ?? 0) >= 1) {
5165
+ const ourLocalPort = this.#udp.localPort();
5166
+ const lanDiscovery = new Uint8Array(33);
5167
+ lanDiscovery[0] = NET_PACKET_LAN_DISCOVERY;
5168
+ lanDiscovery.set(this.#keyPair.publicKey, 1);
5169
+ const probeNow = Date.now();
5170
+ // Prune stale probe bookkeeping so the map stays bounded.
5171
+ for (const [k, v] of this.#lanProbeTargets) {
5172
+ if (probeNow - v.sentMs > 300_000)
5173
+ this.#lanProbeTargets.delete(k);
5174
+ }
5175
+ const probe = async (host, port, alsoCookie) => {
5176
+ const key = `${host}:${port}`;
5177
+ this.#lanProbeTargets.set(key, { friendId, sentMs: probeNow });
5178
+ try {
5179
+ await this.#sendPacket(lanDiscovery, { host, port });
5180
+ if (alsoCookie) {
5181
+ await this.#sendPacket(packet, { host, port });
5182
+ selfSent += 1;
5183
+ }
5184
+ }
5185
+ catch {
5186
+ // best-effort
5187
+ }
5188
+ };
5189
+ const tried = new Set();
5190
+ // (b) known candidates — refresh a rotated DHT key at a live endpoint.
5191
+ for (const candidate of connectCandidates) {
5192
+ const key = `${candidate.host}:${candidate.port}`;
5193
+ if (tried.has(key))
5194
+ continue;
5195
+ tried.add(key);
5196
+ await probe(candidate.host, candidate.port, false); // cookie already sent above
5197
+ }
5198
+ // (a)+(b) own-host targets — find a same-host peer after an IP change.
5199
+ for (const host of [...getLocalIpv4Addresses(), "127.0.0.1"]) {
5200
+ if (isOwnVirtualAddress(host))
5201
+ continue; // never probe into the overlay
5202
+ for (const port of LAN_SWEEP_PORTS) {
5203
+ if (port === ourLocalPort)
5204
+ continue; // our own socket
5205
+ const key = `${host}:${port}`;
5206
+ if (tried.has(key))
5207
+ continue;
5208
+ tried.add(key);
5209
+ await probe(host, port, true);
5210
+ }
5211
+ }
5212
+ }
5080
5213
  // Also send via TCP relay if available, in parallel. Whichever
5081
5214
  // arrives at the friend first triggers their cookie response.
5082
5215
  if (tcpAvailable && this.#tcpRelays) {
5083
5216
  tcpSent = this.#tcpRelays.sendToFriend(friendRealPk, packet);
5084
5217
  }
5085
- if (sent === 0 && tcpSent === 0) {
5218
+ if (sent === 0 && tcpSent === 0 && selfSent === 0) {
5086
5219
  throw new Error("no cookie request packet was sent");
5087
5220
  }
5088
5221
  // Each unmatched attempt grows the per-friend backoff. Resets when a
@@ -5092,13 +5225,13 @@ export class Peer {
5092
5225
  const primaryDesc = connectCandidates.length > 0
5093
5226
  ? `${connectCandidates[0].host}:${connectCandidates[0].port}`
5094
5227
  : `tcp-relay`;
5095
- const cookieKey = `${primaryDesc}|udp=${sent}|tcp=${tcpSent}`;
5228
+ const cookieKey = `${primaryDesc}|udp=${sent}|tcp=${tcpSent}|self=${selfSent}`;
5096
5229
  if (this.#lastCookieSentKey.get(friendId) !== cookieKey) {
5097
- this.#debugLog(`cookie_sent friend=${friendId} udp=${sent} tcp=${tcpSent} primary=${primaryDesc}`);
5230
+ this.#debugLog(`cookie_sent friend=${friendId} udp=${sent} tcp=${tcpSent} self=${selfSent} primary=${primaryDesc}`);
5098
5231
  this.#lastCookieSentKey.set(friendId, cookieKey);
5099
5232
  }
5100
5233
  else {
5101
- this.#debugVerboseLog(`cookie_sent friend=${friendId} udp=${sent} tcp=${tcpSent} primary=${primaryDesc} (retry ${this.#cookieRetryCount.get(friendId)})`);
5234
+ this.#debugVerboseLog(`cookie_sent friend=${friendId} udp=${sent} tcp=${tcpSent} self=${selfSent} primary=${primaryDesc} (retry ${this.#cookieRetryCount.get(friendId)})`);
5102
5235
  }
5103
5236
  return true;
5104
5237
  }
@@ -5147,9 +5280,9 @@ export class Peer {
5147
5280
  // default so this is invisible for real-device tests, opt-in for
5148
5281
  // loopback or other known-fixed targets via env var.
5149
5282
  for (const host of LAN_SWEEP_EXTRA_HOSTS) {
5150
- if (ourLocalIps.includes(host))
5151
- continue;
5152
5283
  for (const port of LAN_SWEEP_PORTS) {
5284
+ // Skip only our own socket — a same-host peer (iOS Simulator) lives
5285
+ // at our own IP on a different port, so own-IP hosts must be probed.
5153
5286
  if (port === ourLocalPort && ourLocalIps.includes(host))
5154
5287
  continue;
5155
5288
  try {
@@ -5170,9 +5303,9 @@ export class Peer {
5170
5303
  continue;
5171
5304
  for (let addr = (network + 1) >>> 0; addr < broadcast; addr = (addr + 1) >>> 0) {
5172
5305
  const host = `${(addr >>> 24) & 0xff}.${(addr >>> 16) & 0xff}.${(addr >>> 8) & 0xff}.${addr & 0xff}`;
5173
- if (ourLocalIps.includes(host))
5174
- continue;
5175
5306
  for (const port of LAN_SWEEP_PORTS) {
5307
+ // Skip only our own socket, not our whole IP — the iOS Simulator
5308
+ // shares this host's address on its own port.
5176
5309
  if (ourLocalPort === port && ourLocalIps.includes(host))
5177
5310
  continue;
5178
5311
  try {
@@ -5246,6 +5379,10 @@ export class Peer {
5246
5379
  // keeps mutual reachability alive across the mesh.
5247
5380
  if (remote.port > 0 && !this.#remoteIsTcp(remote)) {
5248
5381
  this.addKnownNodes([{ host: remote.address, port: remote.port, pk: senderId, isTcp: false }]);
5382
+ // A DHT packet carries the sender's CURRENT DHT pk in the clear — if
5383
+ // this source answers one of our LAN-discovery rescue probes, harvest
5384
+ // the key: it un-strands friends whose ephemeral DHT key rotated.
5385
+ this.#refreshFriendDhtKeyFromDht(decoded.senderPublicKey, senderId, remote.address, remote.port);
5249
5386
  }
5250
5387
  if (decoded.type === NET_PACKET_PING_REQUEST) {
5251
5388
  const ping = parsePingPlain(decoded.plain);
@@ -5317,6 +5454,58 @@ export class Peer {
5317
5454
  return;
5318
5455
  }
5319
5456
  }
5457
+ /** A DHT packet arrived from `host:port` with `senderDhtPk` in the clear.
5458
+ * If that source matches a LAN-discovery rescue probe we sent (or an
5459
+ * endpoint candidate of a stranded friend), adopt the pk as the friend's
5460
+ * CURRENT DHT key. Natives rotate their DHT keypair every app restart, so
5461
+ * a peer that only holds the old key sends cookie requests the friend
5462
+ * cannot decrypt — correct endpoint, silent drop, stuck forever.
5463
+ *
5464
+ * Safety: only unestablished sessions are touched (a live session's key
5465
+ * is by definition correct), and if the adopted key turns out to belong
5466
+ * to a different node, the crypto handshake cannot complete against the
5467
+ * friend's REAL key — the rescue just fails and normal retries continue. */
5468
+ #refreshFriendDhtKeyFromDht(senderDhtPk, senderId, host, port) {
5469
+ const key = `${host}:${port}`;
5470
+ let friendId;
5471
+ const probed = this.#lanProbeTargets.get(key);
5472
+ if (probed && Date.now() - probed.sentMs < 300_000) {
5473
+ friendId = probed.friendId;
5474
+ }
5475
+ else {
5476
+ // No probe bookkeeping — still match a stranded friend's known
5477
+ // endpoint candidates (the friend may DHT-ping us spontaneously).
5478
+ for (const [fid, session] of this.#friendSessions) {
5479
+ if (session.established)
5480
+ continue;
5481
+ if (session.endpointCandidates?.some((c) => c.host === host && c.port === port)) {
5482
+ friendId = fid;
5483
+ break;
5484
+ }
5485
+ }
5486
+ }
5487
+ if (!friendId)
5488
+ return;
5489
+ // The sender identifies as an existing DIFFERENT friend (JS peers use
5490
+ // their stable real key as DHT key) — don't cross-wire.
5491
+ const byDht = this.#friendByDhtPk(senderId);
5492
+ if (byDht && byDht.friendId !== friendId)
5493
+ return;
5494
+ if (senderId === friendId)
5495
+ return; // already keyed by real pk — nothing to refresh
5496
+ const session = this.#friendSessions.get(friendId);
5497
+ if (!session || session.established)
5498
+ return;
5499
+ const current = session.friendDhtPublicKey;
5500
+ if (current && Buffer.from(current).equals(Buffer.from(senderDhtPk)))
5501
+ return;
5502
+ this.#debugLog(`dht_key_refreshed friend=${friendId} via=${key} old=${current ? carrierIdFromPublicKey(current) : "none"} new=${senderId}`);
5503
+ this.#cacheFriendRemote(friendId, host, port, undefined, senderDhtPk);
5504
+ // Drop the backoff so the next cookie request (now decryptable) goes out
5505
+ // promptly instead of waiting out minutes of accumulated failures.
5506
+ this.#cookieRetryCount.delete(friendId);
5507
+ void this.#initiateSession(friendId).catch(() => undefined);
5508
+ }
5320
5509
  async #sendDhtGetNodes(node, targetPublicKey) {
5321
5510
  if (!this.#keyPair || !node.pk || node.isTcp)
5322
5511
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.112",
3
+ "version": "0.1.113",
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",