@decentnetwork/peer 0.1.112 → 0.1.114

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,18 @@ 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();
343
+ /** Exclusive turn-taking for OWN-HOST probe targets. Own-host targets
344
+ * (our IPs + loopback at native ports) are shared by every stranded
345
+ * friend, so concurrent probes overwrite each other's #lanProbeTargets
346
+ * mapping and the reply gets credited to whoever probed last (observed
347
+ * live: the simulator's key landed on an unrelated friend). One friend
348
+ * holds the slot at a time; per-friend candidate probes are unaffected. */
349
+ #ownHostProbeFriendId;
350
+ #ownHostProbeUntilMs = 0;
306
351
  #dhtMaintenanceTimer;
307
352
  // Per-friend last DHT-PK send time, keyed by friendId, used even when no
308
353
  // session entry exists yet so the connection loop does not flood DHT-PK
@@ -621,6 +666,13 @@ export class Peer {
621
666
  }
622
667
  }
623
668
  }
669
+ // Run the friend-connection loop from start(), not only after a
670
+ // successful joinNetwork(): when every bootstrap is unreachable
671
+ // (offline LAN, walled network), persisted same-LAN/same-host friends
672
+ // must still get their cookie retries + rescue probes — otherwise a
673
+ // daemon with dead bootstraps never reconnects to peers sitting right
674
+ // next to it. Idempotent with the joinNetwork() call.
675
+ this.#ensureFriendConnectionLoop();
624
676
  this.#started = true;
625
677
  }
626
678
  /** Lazy session shell used when we want to attach state before any handshake. */
@@ -1690,6 +1742,22 @@ export class Peer {
1690
1742
  catch { /* best-effort */ }
1691
1743
  }).catch(() => { });
1692
1744
  }
1745
+ // toxcore LAN discovery (0x21, plaintext [id][sender dht pk]): answer like
1746
+ // native DHT_bootstrap does — DHT-ping the announcer. Our ping reveals OUR
1747
+ // current DHT pk + live endpoint to them, and their ping/response feeds
1748
+ // #refreshFriendDhtKeyFromDht on their side. This is the reply half of the
1749
+ // stranded-friend rescue probes (see #initiateSession).
1750
+ if (packet[0] === NET_PACKET_LAN_DISCOVERY &&
1751
+ packet.length === 33 &&
1752
+ this.#keyPair &&
1753
+ !this.#remoteIsTcp(remote)) {
1754
+ const announcedPk = packet.slice(1, 33);
1755
+ if (!Buffer.from(announcedPk).equals(Buffer.from(this.#keyPair.publicKey))) {
1756
+ const announcerId = carrierIdFromPublicKey(announcedPk);
1757
+ void this.#sendDhtPing({ host: remote.address, port: remote.port, pk: announcerId, isTcp: false }).catch(() => undefined);
1758
+ }
1759
+ return;
1760
+ }
1693
1761
  // Classic toxcore DHT RPC (ping / get_nodes / send_nodes). Answering these
1694
1762
  // is what makes us *findable* over UDP: a native iOS/Android peer searches
1695
1763
  // the DHT for our key, a neighbour that holds our address returns it, then
@@ -4466,7 +4534,13 @@ export class Peer {
4466
4534
  }
4467
4535
  if (session.lanRemoteHost &&
4468
4536
  session.remote?.host === session.lanRemoteHost &&
4469
- host !== session.lanRemoteHost) {
4537
+ host !== session.lanRemoteHost &&
4538
+ // Same-host exception: a peer on THIS machine (iOS Simulator) moves
4539
+ // with us when our DHCP address changes — the old LAN lock target is
4540
+ // dead and the peer now answers from our own current address (or
4541
+ // loopback). Refusing that move pins the session to the dead IP
4542
+ // forever. isOwnAddress is cached; no syscall on the hot path.
4543
+ !(host === "127.0.0.1" || isOwnAddress(host))) {
4470
4544
  return; // stay locked on the physical-LAN path
4471
4545
  }
4472
4546
  session.remote = { host, port };
@@ -5077,12 +5151,88 @@ export class Peer {
5077
5151
  // Keep best-effort behavior.
5078
5152
  }
5079
5153
  }
5154
+ // Rescue probes (retries only). Two stale-state failure modes keep a
5155
+ // native friend unreachable forever even though it is alive nearby:
5156
+ // 1. Stale ENDPOINT, same host: an iOS Simulator shares this
5157
+ // machine's IP, so after a DHCP change both sides hold the dead
5158
+ // old IP and hairpin blocks every refresh path — but the sim is
5159
+ // always reachable at our current own IP.
5160
+ // 2. Stale DHT KEY, live endpoint: a native's DHT keypair rotates
5161
+ // every app restart; cookie requests encrypted to the old key
5162
+ // are silently dropped (iPhone ignored 780+ retries from
5163
+ // mac-dev while answering mini from the same endpoint).
5164
+ // So on each retry, (a) fan the cookie request out to our own
5165
+ // addresses at the native ports (fixes 1 when the key is current),
5166
+ // and (b) send plaintext LAN-discovery probes to every candidate and
5167
+ // own-host target — a native answers with a DHT packet that carries
5168
+ // its CURRENT DHT key, which #refreshFriendDhtKeyFromDht picks up
5169
+ // (fixes 2, and 1+2 combined). All local/LAN unicast, a handful of
5170
+ // packets per retry cycle.
5171
+ let selfSent = 0;
5172
+ if (LAN_SELF_PROBE_ENABLED && (this.#cookieRetryCount.get(friendId) ?? 0) >= 1) {
5173
+ const ourLocalPort = this.#udp.localPort();
5174
+ const lanDiscovery = new Uint8Array(33);
5175
+ lanDiscovery[0] = NET_PACKET_LAN_DISCOVERY;
5176
+ lanDiscovery.set(this.#keyPair.publicKey, 1);
5177
+ const probeNow = Date.now();
5178
+ // Prune stale probe bookkeeping so the map stays bounded.
5179
+ for (const [k, v] of this.#lanProbeTargets) {
5180
+ if (probeNow - v.sentMs > 300_000)
5181
+ this.#lanProbeTargets.delete(k);
5182
+ }
5183
+ const probe = async (host, port, alsoCookie) => {
5184
+ const key = `${host}:${port}`;
5185
+ this.#lanProbeTargets.set(key, { friendId, sentMs: probeNow });
5186
+ try {
5187
+ await this.#sendPacket(lanDiscovery, { host, port });
5188
+ if (alsoCookie) {
5189
+ await this.#sendPacket(packet, { host, port });
5190
+ selfSent += 1;
5191
+ }
5192
+ }
5193
+ catch {
5194
+ // best-effort
5195
+ }
5196
+ };
5197
+ const tried = new Set();
5198
+ // (b) known candidates — refresh a rotated DHT key at a live endpoint.
5199
+ // Per-friend targets: no mapping ambiguity, always allowed.
5200
+ for (const candidate of connectCandidates) {
5201
+ const key = `${candidate.host}:${candidate.port}`;
5202
+ if (tried.has(key))
5203
+ continue;
5204
+ tried.add(key);
5205
+ await probe(candidate.host, candidate.port, false); // cookie already sent above
5206
+ }
5207
+ // (a)+(b) own-host targets — find a same-host peer after an IP change.
5208
+ // SHARED targets: only the current slot holder probes them, so the
5209
+ // reply maps to exactly one friend. Slot rotates by expiry; the
5210
+ // cookie backoff staggers contenders so every stranded friend
5211
+ // eventually gets a turn.
5212
+ if (this.#ownHostProbeFriendId === friendId || probeNow >= this.#ownHostProbeUntilMs) {
5213
+ this.#ownHostProbeFriendId = friendId;
5214
+ this.#ownHostProbeUntilMs = probeNow + 10_000;
5215
+ for (const host of [...getLocalIpv4Addresses(), "127.0.0.1"]) {
5216
+ if (isOwnVirtualAddress(host))
5217
+ continue; // never probe into the overlay
5218
+ for (const port of LAN_SWEEP_PORTS) {
5219
+ if (port === ourLocalPort)
5220
+ continue; // our own socket
5221
+ const key = `${host}:${port}`;
5222
+ if (tried.has(key))
5223
+ continue;
5224
+ tried.add(key);
5225
+ await probe(host, port, true);
5226
+ }
5227
+ }
5228
+ }
5229
+ }
5080
5230
  // Also send via TCP relay if available, in parallel. Whichever
5081
5231
  // arrives at the friend first triggers their cookie response.
5082
5232
  if (tcpAvailable && this.#tcpRelays) {
5083
5233
  tcpSent = this.#tcpRelays.sendToFriend(friendRealPk, packet);
5084
5234
  }
5085
- if (sent === 0 && tcpSent === 0) {
5235
+ if (sent === 0 && tcpSent === 0 && selfSent === 0) {
5086
5236
  throw new Error("no cookie request packet was sent");
5087
5237
  }
5088
5238
  // Each unmatched attempt grows the per-friend backoff. Resets when a
@@ -5092,21 +5242,30 @@ export class Peer {
5092
5242
  const primaryDesc = connectCandidates.length > 0
5093
5243
  ? `${connectCandidates[0].host}:${connectCandidates[0].port}`
5094
5244
  : `tcp-relay`;
5095
- const cookieKey = `${primaryDesc}|udp=${sent}|tcp=${tcpSent}`;
5245
+ const cookieKey = `${primaryDesc}|udp=${sent}|tcp=${tcpSent}|self=${selfSent}`;
5096
5246
  if (this.#lastCookieSentKey.get(friendId) !== cookieKey) {
5097
- this.#debugLog(`cookie_sent friend=${friendId} udp=${sent} tcp=${tcpSent} primary=${primaryDesc}`);
5247
+ this.#debugLog(`cookie_sent friend=${friendId} udp=${sent} tcp=${tcpSent} self=${selfSent} primary=${primaryDesc}`);
5098
5248
  this.#lastCookieSentKey.set(friendId, cookieKey);
5099
5249
  }
5100
5250
  else {
5101
- this.#debugVerboseLog(`cookie_sent friend=${friendId} udp=${sent} tcp=${tcpSent} primary=${primaryDesc} (retry ${this.#cookieRetryCount.get(friendId)})`);
5251
+ this.#debugVerboseLog(`cookie_sent friend=${friendId} udp=${sent} tcp=${tcpSent} self=${selfSent} primary=${primaryDesc} (retry ${this.#cookieRetryCount.get(friendId)})`);
5102
5252
  }
5103
5253
  return true;
5104
5254
  }
5105
5255
  catch (error) {
5106
5256
  this.#debugLog(`cookie request send failed for ${friendId}: ${error.message}`);
5257
+ // A failed send is still a failed ATTEMPT: count it so (a) backoff
5258
+ // grows instead of hammering a dead address at base cadence forever,
5259
+ // and (b) the rescue probes (gated on retryCount >= 1) still fire for
5260
+ // a friend whose sole candidate errors at the syscall level — before
5261
+ // this, such a friend never probed and could never be rescued.
5262
+ this.#cookieRetryCount.set(friendId, (this.#cookieRetryCount.get(friendId) ?? 0) + 1);
5107
5263
  session.pendingEcho = undefined;
5108
5264
  session.pendingCookiePeerDhtPublicKey = undefined;
5109
- session.cookieRequestSentMs = undefined;
5265
+ // Keep cookieRequestSentMs (set at attempt start): it is the loop's
5266
+ // `lastAttempt`. Clearing it made lastAttempt 0, so the cooldown gate
5267
+ // always passed and a friend whose sends error retried every 250ms
5268
+ // tick forever — the "cookie request send failed" log flood.
5110
5269
  return false;
5111
5270
  }
5112
5271
  }
@@ -5147,9 +5306,9 @@ export class Peer {
5147
5306
  // default so this is invisible for real-device tests, opt-in for
5148
5307
  // loopback or other known-fixed targets via env var.
5149
5308
  for (const host of LAN_SWEEP_EXTRA_HOSTS) {
5150
- if (ourLocalIps.includes(host))
5151
- continue;
5152
5309
  for (const port of LAN_SWEEP_PORTS) {
5310
+ // Skip only our own socket — a same-host peer (iOS Simulator) lives
5311
+ // at our own IP on a different port, so own-IP hosts must be probed.
5153
5312
  if (port === ourLocalPort && ourLocalIps.includes(host))
5154
5313
  continue;
5155
5314
  try {
@@ -5170,9 +5329,9 @@ export class Peer {
5170
5329
  continue;
5171
5330
  for (let addr = (network + 1) >>> 0; addr < broadcast; addr = (addr + 1) >>> 0) {
5172
5331
  const host = `${(addr >>> 24) & 0xff}.${(addr >>> 16) & 0xff}.${(addr >>> 8) & 0xff}.${addr & 0xff}`;
5173
- if (ourLocalIps.includes(host))
5174
- continue;
5175
5332
  for (const port of LAN_SWEEP_PORTS) {
5333
+ // Skip only our own socket, not our whole IP — the iOS Simulator
5334
+ // shares this host's address on its own port.
5176
5335
  if (ourLocalPort === port && ourLocalIps.includes(host))
5177
5336
  continue;
5178
5337
  try {
@@ -5246,6 +5405,10 @@ export class Peer {
5246
5405
  // keeps mutual reachability alive across the mesh.
5247
5406
  if (remote.port > 0 && !this.#remoteIsTcp(remote)) {
5248
5407
  this.addKnownNodes([{ host: remote.address, port: remote.port, pk: senderId, isTcp: false }]);
5408
+ // A DHT packet carries the sender's CURRENT DHT pk in the clear — if
5409
+ // this source answers one of our LAN-discovery rescue probes, harvest
5410
+ // the key: it un-strands friends whose ephemeral DHT key rotated.
5411
+ this.#refreshFriendDhtKeyFromDht(decoded.senderPublicKey, senderId, remote.address, remote.port);
5249
5412
  }
5250
5413
  if (decoded.type === NET_PACKET_PING_REQUEST) {
5251
5414
  const ping = parsePingPlain(decoded.plain);
@@ -5317,6 +5480,72 @@ export class Peer {
5317
5480
  return;
5318
5481
  }
5319
5482
  }
5483
+ /** A DHT packet arrived from `host:port` with `senderDhtPk` in the clear.
5484
+ * If that source matches a LAN-discovery rescue probe we sent (or an
5485
+ * endpoint candidate of a stranded friend), adopt the pk as the friend's
5486
+ * CURRENT DHT key. Natives rotate their DHT keypair every app restart, so
5487
+ * a peer that only holds the old key sends cookie requests the friend
5488
+ * cannot decrypt — correct endpoint, silent drop, stuck forever.
5489
+ *
5490
+ * Safety: only unestablished sessions are touched (a live session's key
5491
+ * is by definition correct), and if the adopted key turns out to belong
5492
+ * to a different node, the crypto handshake cannot complete against the
5493
+ * friend's REAL key — the rescue just fails and normal retries continue. */
5494
+ #refreshFriendDhtKeyFromDht(senderDhtPk, senderId, host, port) {
5495
+ const key = `${host}:${port}`;
5496
+ let friendId;
5497
+ const probed = this.#lanProbeTargets.get(key);
5498
+ if (probed && Date.now() - probed.sentMs < 300_000) {
5499
+ friendId = probed.friendId;
5500
+ }
5501
+ else {
5502
+ // No probe bookkeeping — still match a stranded friend's known
5503
+ // endpoint candidates (the friend may DHT-ping us spontaneously).
5504
+ for (const [fid, session] of this.#friendSessions) {
5505
+ if (session.established)
5506
+ continue;
5507
+ if (session.endpointCandidates?.some((c) => c.host === host && c.port === port)) {
5508
+ friendId = fid;
5509
+ break;
5510
+ }
5511
+ }
5512
+ }
5513
+ if (!friendId)
5514
+ return;
5515
+ // The sender pk currently maps to a DIFFERENT friend. If that friend's
5516
+ // session is ESTABLISHED, the key genuinely belongs to them — don't
5517
+ // cross-wire. If it is NOT established, the mapping itself may be a
5518
+ // mis-credited earlier probe reply (shared own-host targets), and
5519
+ // refusing here would permanently lock the true owner out — steal it
5520
+ // and scrub the other friend's stale copy so routing stops confusing
5521
+ // the two. A wrong steal self-corrects: the real-key handshake cannot
5522
+ // complete against the wrong node.
5523
+ const byDht = this.#friendByDhtPk(senderId);
5524
+ if (byDht && byDht.friendId !== friendId) {
5525
+ const otherSession = this.#friendSessions.get(byDht.friendId);
5526
+ if (otherSession?.established)
5527
+ return;
5528
+ this.#debugLog(`dht_key_steal ${senderId} from=${byDht.friendId} to=${friendId} (other unestablished)`);
5529
+ if (otherSession?.friendDhtPublicKey && Buffer.from(otherSession.friendDhtPublicKey).equals(Buffer.from(senderDhtPk))) {
5530
+ otherSession.friendDhtPublicKey = undefined;
5531
+ }
5532
+ this.#friendDhtKeys.delete(byDht.friendId);
5533
+ }
5534
+ if (senderId === friendId)
5535
+ return; // already keyed by real pk — nothing to refresh
5536
+ const session = this.#friendSessions.get(friendId);
5537
+ if (!session || session.established)
5538
+ return;
5539
+ const current = session.friendDhtPublicKey;
5540
+ if (current && Buffer.from(current).equals(Buffer.from(senderDhtPk)))
5541
+ return;
5542
+ this.#debugLog(`dht_key_refreshed friend=${friendId} via=${key} old=${current ? carrierIdFromPublicKey(current) : "none"} new=${senderId}`);
5543
+ this.#cacheFriendRemote(friendId, host, port, undefined, senderDhtPk);
5544
+ // Drop the backoff so the next cookie request (now decryptable) goes out
5545
+ // promptly instead of waiting out minutes of accumulated failures.
5546
+ this.#cookieRetryCount.delete(friendId);
5547
+ void this.#initiateSession(friendId).catch(() => undefined);
5548
+ }
5320
5549
  async #sendDhtGetNodes(node, targetPublicKey) {
5321
5550
  if (!this.#keyPair || !node.pk || node.isTcp)
5322
5551
  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.114",
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",