@decentnetwork/peer 0.1.104 → 0.1.107

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.
@@ -57,6 +57,12 @@ export declare class FileTransferManager {
57
57
  accept(friendId: string, fileNumber: number): void;
58
58
  cancel(friendId: string, fileNumber: number, isSending: boolean): void;
59
59
  cancelByFileId(friendId: string, fileIdHex: string, isSending: boolean): boolean;
60
+ /** Cancel in-flight sends matching both name and size.
61
+ * This is a conservative registry-desync fallback after fileId/hash lookup. */
62
+ cancelSendsMatching(friendId: string, match: {
63
+ name?: string;
64
+ size: number;
65
+ }): string[];
60
66
  handlePacket(friendId: string, packetId: number, payload: Uint8Array): boolean;
61
67
  clearFriend(friendId: string): void;
62
68
  }
@@ -239,17 +239,23 @@ export class FileTransferManager {
239
239
  }
240
240
  return inner;
241
241
  }
242
- #freeNumber(friendId) {
242
+ #freeNumber(friendId, preferred = 0) {
243
243
  const inner = this.#map(this.#sending, friendId);
244
- for (let i = 0; i < MAX_CONCURRENT_FILE_PIPES; i++)
244
+ // Start at a content-derived slot. Older receivers retain an incomplete
245
+ // receive slot in memory until it finishes or is killed. After the sender
246
+ // restarts, always reusing slot 0 makes a DIFFERENT new file look like a
247
+ // retransmitted offer for that stale slot, so the receiver silently ACKs
248
+ // the old file and the new UI stays at 0%. The same content keeps the same
249
+ // preferred slot (resume), while different content naturally avoids stale
250
+ // collisions; circular search still supports concurrent transfers.
251
+ for (let n = 0; n < MAX_CONCURRENT_FILE_PIPES; n++) {
252
+ const i = (preferred + n) % MAX_CONCURRENT_FILE_PIPES;
245
253
  if (!inner.has(i))
246
254
  return i;
255
+ }
247
256
  return -1;
248
257
  }
249
258
  sendFile(friendId, data, opts) {
250
- const fileNumber = this.#freeNumber(friendId);
251
- if (fileNumber < 0)
252
- return null;
253
259
  // Content-addressed fileId (sha256): re-sending the SAME file after a
254
260
  // restart or a dropped peer yields the SAME id, so the receiver can match
255
261
  // it against its persisted partial and resume from where it left off — the
@@ -257,6 +263,9 @@ export class FileTransferManager {
257
263
  const fileId = data.length > 0
258
264
  ? new Uint8Array(createHash("sha256").update(data).digest()).slice(0, FILE_ID_LENGTH)
259
265
  : randomBytes(FILE_ID_LENGTH);
266
+ const fileNumber = this.#freeNumber(friendId, fileId[0] ?? 0);
267
+ if (fileNumber < 0)
268
+ return null;
260
269
  const req = encodeFileSendRequest(fileNumber, opts.kind ?? FILEKIND.DATA, BigInt(data.length), fileId, opts.name);
261
270
  const lanPath = this.isLanPath(friendId);
262
271
  const st = {
@@ -271,6 +280,8 @@ export class FileTransferManager {
271
280
  btlBwBps: 0, bwSamples: [], fullBwRounds: 0, startupQueueRounds: 0, bbrPhase: 0, roundCount: 0,
272
281
  rateMarkMs: nowMs(), rateMarkAcked: 0, lowRttCount: 0, lowRttMinMs: Infinity, lastCcLogMs: 0,
273
282
  lanPath,
283
+ dbgLanNow: lanPath ? 1 : 0, dbgSampleMs: 0, dbgRoundRate: 0, dbgMeasuredRate: 0,
284
+ dbgRawQueue: 0, dbgUnder: 0, dbgOvershot: 0,
274
285
  };
275
286
  this.#map(this.#sending, friendId).set(fileNumber, st);
276
287
  void this.send(friendId, PACKET_ID_FILE_SENDREQUEST, req).catch(() => undefined);
@@ -321,27 +332,54 @@ export class FileTransferManager {
321
332
  }
322
333
  }
323
334
  cancel(friendId, fileNumber, isSending) {
335
+ const inner = (isSending ? this.#sending : this.#receiving).get(friendId);
336
+ const st = inner?.get(fileNumber);
337
+ const fileId = st ? hex(st.fileId) : undefined;
324
338
  const sr = isSending ? 0 : 1;
325
339
  void this.send(friendId, PACKET_ID_FILE_CONTROL, encodeFileControl(sr, fileNumber, FILECONTROL.KILL)).catch(() => undefined);
326
340
  if (isSending)
327
341
  this.#endSend(friendId, fileNumber);
328
342
  else
329
343
  this.#endRecv(friendId, fileNumber);
344
+ // Local cancel used to tear down silently. The daemon tracks sends in
345
+ // activeSends and needs this event when the registry entry is already gone
346
+ // (or cancel raced with a progress tick) so the UI chip cannot stay "sending".
347
+ if (fileId)
348
+ this.emit("file-cancel", { friendId, fileId, sending: isSending, reason: "local" });
330
349
  }
331
350
  // Cancel by content fileId (hex) — the daemon/UI tracks transfers by fileId,
332
351
  // not the internal fileNumber, so it can stop a specific in-flight transfer.
333
352
  cancelByFileId(friendId, fileIdHex, isSending) {
353
+ const want = fileIdHex.trim().toLowerCase();
334
354
  const inner = (isSending ? this.#sending : this.#receiving).get(friendId);
335
355
  if (!inner)
336
356
  return false;
337
357
  for (const [fileNumber, st] of inner) {
338
- if (hex(st.fileId) === fileIdHex) {
358
+ if (hex(st.fileId) === want) {
339
359
  this.cancel(friendId, fileNumber, isSending);
340
360
  return true;
341
361
  }
342
362
  }
343
363
  return false;
344
364
  }
365
+ /** Cancel in-flight sends matching both name and size.
366
+ * This is a conservative registry-desync fallback after fileId/hash lookup. */
367
+ cancelSendsMatching(friendId, match) {
368
+ const inner = this.#sending.get(friendId);
369
+ if (!inner)
370
+ return [];
371
+ const candidates = [...inner].filter(([, st]) => st.size === match.size);
372
+ const named = match.name
373
+ ? candidates.filter(([, st]) => st.name === match.name)
374
+ : [];
375
+ const picked = named;
376
+ const killed = [];
377
+ for (const [fileNumber, st] of picked) {
378
+ killed.push(hex(st.fileId));
379
+ this.cancel(friendId, fileNumber, true);
380
+ }
381
+ return killed;
382
+ }
345
383
  handlePacket(friendId, packetId, payload) {
346
384
  if (packetId === PACKET_ID_FILE_SENDREQUEST) {
347
385
  this.#onSendRequest(friendId, payload);
@@ -789,6 +827,12 @@ export class FileTransferManager {
789
827
  const measuredRate = lanNow
790
828
  ? Math.min(roundRate, Math.max(PACE_INIT_BPS, st.paceRateBps * LAN_SAMPLE_MAX_GAIN))
791
829
  : roundRate;
830
+ st.dbgLanNow = lanNow ? 1 : 0;
831
+ st.dbgSampleMs = Math.round(sampleMs);
832
+ st.dbgRoundRate = Math.round(roundRate);
833
+ st.dbgMeasuredRate = Math.round(measuredRate);
834
+ st.dbgUnder = 0;
835
+ st.dbgOvershot = 0;
792
836
  if (measuredRate > 0) {
793
837
  st.bwSamples.push(measuredRate);
794
838
  if (st.bwSamples.length > PACE_BW_WINDOW)
@@ -813,6 +857,8 @@ export class FileTransferManager {
813
857
  st.cwnd = Math.max(st.cwnd, LAN_CWND_INIT_CHUNKS);
814
858
  }
815
859
  st.lanPath = lanNow;
860
+ const rawQueue = st.minRttMs !== Infinity ? acceptedRtt - st.minRttMs : 0;
861
+ st.dbgRawQueue = Math.round(rawQueue);
816
862
  if (!lanNow) {
817
863
  // Relay/public-NAT file traffic must remain conservative. A short
818
864
  // ACK burst can wildly overstate bandwidth there; allowing LAN's
@@ -840,7 +886,6 @@ export class FileTransferManager {
840
886
  else {
841
887
  st.fullBwRounds++;
842
888
  }
843
- const rawQueue = st.minRttMs !== Infinity ? acceptedRtt - st.minRttMs : 0;
844
889
  if (rawQueue > LAN_STARTUP_QUEUE_MS) {
845
890
  st.startupQueueRounds++;
846
891
  }
@@ -855,6 +900,8 @@ export class FileTransferManager {
855
900
  const startupOvershot = rawQueue > LAN_STARTUP_HARD_QUEUE_MS ||
856
901
  st.startupQueueRounds >= LAN_STARTUP_QUEUE_ROUNDS;
857
902
  const startupUnderDelivering = st.fullBwRounds >= LAN_STARTUP_SLOW_ROUNDS;
903
+ st.dbgUnder = startupUnderDelivering ? 1 : 0;
904
+ st.dbgOvershot = startupOvershot ? 1 : 0;
858
905
  if (startupUnderDelivering || startupOvershot) {
859
906
  st.bbrPhase = 2;
860
907
  }
@@ -895,15 +942,26 @@ export class FileTransferManager {
895
942
  }
896
943
  if (process.env.DECENT_DEBUG && nowT - st.lastCcLogMs >= 1000) {
897
944
  st.lastCcLogMs = nowT;
945
+ const windowEnd = Math.min(st.acked + st.cwnd * MAX_FILE_DATA_SIZE, st.size);
898
946
  console.error(`[file-cc] friend=${friendId.slice(0, 8)} ack=${st.acked}/${st.size}` +
899
947
  ` rtt=${rtt}ms srtt=${st.srttMs.toFixed(1)}ms low=${st.lowRttCount}` +
900
948
  ` accepted=${(!lowSample || promoteLowPath) ? 1 : 0}` +
901
949
  ` pace=${Math.round(st.paceRateBps)}Bps bw=${Math.round(st.btlBwBps)}Bps` +
902
950
  ` cwnd=${st.cwnd} phase=${st.bbrPhase} slow=${st.fullBwRounds}` +
903
- ` loss=${(st.lossEwma * 100).toFixed(1)}% rounds=${st.roundCount}`);
951
+ ` loss=${(st.lossEwma * 100).toFixed(1)}% rounds=${st.roundCount}` +
952
+ ` lanNow=${st.dbgLanNow} lanPath=${st.lanPath ? 1 : 0}` +
953
+ ` sampleMs=${st.dbgSampleMs} roundRate=${st.dbgRoundRate}` +
954
+ ` measuredRate=${st.dbgMeasuredRate} rawQueue=${st.dbgRawQueue}` +
955
+ ` startupQueueRounds=${st.startupQueueRounds}` +
956
+ ` under=${st.dbgUnder} overshot=${st.dbgOvershot}` +
957
+ ` rateMarkAcked=${st.rateMarkAcked} nextSend=${st.nextSend}` +
958
+ ` windowEnd=${windowEnd} tokens=${Math.round(st.paceTokens)}`);
904
959
  }
905
960
  }
906
- if (st.acked - st.lastProgressAcked >= 256 * 1024 || st.acked >= st.size) {
961
+ // Small files (UI screenshots etc.) used to sit at 0% until the final
962
+ // byte because progress only fired every 256 KB. Emit at least every
963
+ // 16 KB or on completion so a 100–200 KB send shows movement.
964
+ if (st.acked - st.lastProgressAcked >= 16 * 1024 || st.acked >= st.size) {
907
965
  st.lastProgressAcked = st.acked;
908
966
  this.emit("file-progress", { friendId, fileId: hex(st.fileId), received: st.acked, total: st.size, sending: true });
909
967
  }
package/dist/peer.d.ts CHANGED
@@ -164,6 +164,11 @@ export declare class Peer {
164
164
  cancelFile(userid: string, fileNumber: number, isSending?: boolean): void;
165
165
  /** Cancel an in-flight transfer by its content fileId (hex). Returns true if found. */
166
166
  cancelFileById(userid: string, fileId: string, isSending?: boolean): boolean;
167
+ /** Cancel in-flight sends that match size/name (registry-desync fallback). */
168
+ cancelSendsMatching(userid: string, match: {
169
+ name?: string;
170
+ size: number;
171
+ }): string[];
167
172
  /** Incoming file offer: { friendId, fileNumber, fileId, name, size, kind }. */
168
173
  onFile(cb: (offer: {
169
174
  friendId: string;
package/dist/peer.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { EventEmitter } from "node:events";
2
+ import { execFileSync } from "node:child_process";
3
+ import { existsSync } from "node:fs";
2
4
  import { mkdir, readFile, writeFile, rename } from "node:fs/promises";
3
5
  import { networkInterfaces } from "node:os";
4
6
  import { dirname, join } from "node:path";
@@ -1348,6 +1350,10 @@ export class Peer {
1348
1350
  cancelFile(userid, fileNumber, isSending = false) { this.#fileTransfer.cancel(userid, fileNumber, isSending); }
1349
1351
  /** Cancel an in-flight transfer by its content fileId (hex). Returns true if found. */
1350
1352
  cancelFileById(userid, fileId, isSending = false) { return this.#fileTransfer.cancelByFileId(userid, fileId, isSending); }
1353
+ /** Cancel in-flight sends that match size/name (registry-desync fallback). */
1354
+ cancelSendsMatching(userid, match) {
1355
+ return this.#fileTransfer.cancelSendsMatching(userid, match);
1356
+ }
1351
1357
  /** Incoming file offer: { friendId, fileNumber, fileId, name, size, kind }. */
1352
1358
  onFile(cb) { this.#events.on("file-offer", cb); }
1353
1359
  /** Transfer progress: { friendId, fileId, received, total, sending? }. */
@@ -2163,6 +2169,21 @@ export class Peer {
2163
2169
  // syscall (that was the CCTV CPU regression) — safe at CCTV packet
2164
2170
  // rates.
2165
2171
  this.#adoptRemote(state, remote.address, remote.port);
2172
+ // A successfully decrypted packet arriving from a private address in
2173
+ // one of our physical subnets is stronger LAN proof than an endpoint
2174
+ // hint: it demonstrates that this exact host:port already reaches us.
2175
+ // Lock it immediately. Without this, duplicate public/relay copies
2176
+ // arriving a moment later overwrite `remote`, so file DATA travels on
2177
+ // the LAN but ACKs hairpin through the public Internet (observed as
2178
+ // ~38KB/s between mac-dev and WSL on the same 10.0.0.0/24).
2179
+ if (!state.lanRemoteHost &&
2180
+ isPrivateAddress(remote.address) &&
2181
+ !isCgnatAddress(remote.address) &&
2182
+ getPhysicalLanSubnets().some((s) => isInIpv4Subnet(remote.address, s))) {
2183
+ state.lanRemoteHost = remote.address;
2184
+ this.#rememberEndpointCandidate(state, remote.address, remote.port);
2185
+ this.#debugLog(`lan_lock_confirmed friend=${friendId} host=${remote.address}:${remote.port} (decrypted UDP source)`);
2186
+ }
2166
2187
  if (!state.lastUdpRecvMs) {
2167
2188
  this.#debugLog(`udp_confirmed friend=${friendId} via=${remote.address}:${remote.port} (direct UDP path live)`);
2168
2189
  }
@@ -4474,7 +4495,11 @@ export class Peer {
4474
4495
  // office silently fall back to the TURN relay (~260ms for a <1ms hop).
4475
4496
  const lanIp = getPhysicalLanAddresses().find((ip) => isPrivateAddress(ip));
4476
4497
  const lanOctets = lanIp ? lanIp.split(".").map((s) => parseInt(s, 10)) : undefined;
4477
- const lanPort = this.#udp.localPort() ?? 0;
4498
+ // WSL packets are NATed by Windows, so the physical host NIC must be paired
4499
+ // with the observed server-reflexive port, not WSL's internal socket port.
4500
+ const lanPort = lanIp && _wslHostAddrsCache.has(lanIp)
4501
+ ? srflx.port
4502
+ : (this.#udp.localPort() ?? 0);
4478
4503
  const lanValid = !!lanOctets && lanOctets.length === 4 && !lanOctets.some((o) => Number.isNaN(o)) && lanPort > 0;
4479
4504
  const size = lanValid ? 18 : relayValid ? 12 : 6;
4480
4505
  const payload = new Uint8Array(size);
@@ -4559,10 +4584,17 @@ export class Peer {
4559
4584
  if (payload.length >= 18) {
4560
4585
  const lanHost = `${payload[12]}.${payload[13]}.${payload[14]}.${payload[15]}`;
4561
4586
  const lanPort = ((payload[16] << 8) | payload[17]) >>> 0;
4587
+ // WSL sees only its 172.x virtual NIC, not the Windows host's physical
4588
+ // 10.x subnet. In that topology a peer on the same real LAN is still
4589
+ // safely identifiable because its server-reflexive address equals ours.
4590
+ // Permit the advertised private candidate when both peers share the same
4591
+ // public NAT; if it is a CGNAT/private collision and never validates, the
4592
+ // normal 12s stale-LAN-lock breaker restores the public/relay path.
4593
+ const samePublicNat = !!this.#srflxCache?.addr.host && this.#srflxCache.addr.host === host;
4562
4594
  const sameLan = lanPort !== 0 &&
4563
4595
  isPrivateAddress(lanHost) &&
4564
4596
  !isCgnatAddress(lanHost) &&
4565
- getPhysicalLanSubnets().some((s) => isInIpv4Subnet(lanHost, s)) &&
4597
+ (getPhysicalLanSubnets().some((s) => isInIpv4Subnet(lanHost, s)) || samePublicNat) &&
4566
4598
  !(getPhysicalLanAddresses().includes(lanHost) && this.#udp.localPort() === lanPort);
4567
4599
  if (sameLan) {
4568
4600
  this.#rememberEndpointCandidate(session, lanHost, lanPort);
@@ -6305,6 +6337,45 @@ let _lanAddrsCache = [];
6305
6337
  let _lanSubnetsCache = [];
6306
6338
  let _allOwnAddrsCache = new Set();
6307
6339
  let _ownVirtualAddrsCache = new Set();
6340
+ let _wslHostAddrsCache = new Set();
6341
+ let _wslHostCacheMs = 0;
6342
+ /** Discover the Windows host's physical NIC from WSL2. WSL itself only sees
6343
+ * its 172.x Hyper-V NIC, which is unreachable from another LAN machine. */
6344
+ function getWslWindowsHostAddresses(localSubnets) {
6345
+ const now = Date.now();
6346
+ if (now - _wslHostCacheMs < 60_000)
6347
+ return [..._wslHostAddrsCache];
6348
+ _wslHostCacheMs = now;
6349
+ const found = new Set();
6350
+ const ipconfig = "/mnt/c/Windows/System32/ipconfig.exe";
6351
+ if (process.platform !== "linux" || !existsSync(ipconfig)) {
6352
+ _wslHostAddrsCache = found;
6353
+ return [];
6354
+ }
6355
+ try {
6356
+ const output = execFileSync(ipconfig, [], {
6357
+ encoding: "utf8",
6358
+ timeout: 2000,
6359
+ windowsHide: true,
6360
+ stdio: ["ignore", "pipe", "ignore"]
6361
+ });
6362
+ // "IPv4" remains in localized ipconfig output (including Chinese).
6363
+ for (const match of output.matchAll(/IPv4[^:\r\n]*:\s*(\d+\.\d+\.\d+\.\d+)/gi)) {
6364
+ const host = match[1];
6365
+ if (!isPrivateAddress(host) || isCgnatAddress(host) || host.startsWith("127.") || host.startsWith("169.254."))
6366
+ continue;
6367
+ // Exclude WSL/Hyper-V addresses already visible inside Linux.
6368
+ if (localSubnets.some((s) => isInIpv4Subnet(host, s)))
6369
+ continue;
6370
+ found.add(host);
6371
+ }
6372
+ }
6373
+ catch {
6374
+ // WSL interop can be disabled; ordinary interface discovery remains valid.
6375
+ }
6376
+ _wslHostAddrsCache = found;
6377
+ return [...found];
6378
+ }
6308
6379
  function refreshLanIfaceCache() {
6309
6380
  const now = Date.now();
6310
6381
  if (_lanIfaceCacheMs !== 0 && now - _lanIfaceCacheMs < 5000)
@@ -6341,6 +6412,11 @@ function refreshLanIfaceCache() {
6341
6412
  catch {
6342
6413
  // best-effort
6343
6414
  }
6415
+ // Prefer the physical Windows host NIC over WSL's internal eth0 address.
6416
+ const wslHostAddrs = getWslWindowsHostAddresses(subnets);
6417
+ for (const host of wslHostAddrs)
6418
+ allOwn.add(host);
6419
+ addrs.unshift(...wslHostAddrs);
6344
6420
  _lanAddrsCache = addrs;
6345
6421
  _lanSubnetsCache = subnets;
6346
6422
  _allOwnAddrsCache = allOwn;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.104",
3
+ "version": "0.1.107",
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",