@decentnetwork/peer 0.1.70 → 0.1.72

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.
@@ -44,6 +44,9 @@ export declare class FileTransferManager {
44
44
  private readonly send;
45
45
  private readonly emit;
46
46
  constructor(send: FtSend, emit: FtEmit);
47
+ /** Enable resumable receives: partials are persisted under `dir` and matched
48
+ * on re-offer by content-hash fileId. Unset = in-memory only (no resume). */
49
+ setResumeDir(dir: string): void;
47
50
  sendFile(friendId: string, data: Uint8Array, opts: {
48
51
  name: string;
49
52
  kind?: number;
@@ -20,6 +20,9 @@
20
20
  // implicit position); both ends here are the JS SDK. A native-interop mode
21
21
  // (Phase 2) can negotiate the plain format via the send-request.
22
22
  import { concatBytes, randomBytes } from "../utils/bytes.js";
23
+ import { createHash } from "node:crypto";
24
+ import { existsSync, statSync, readFileSync, appendFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs";
25
+ import { join } from "node:path";
23
26
  export const PACKET_ID_FILE_SENDREQUEST = 80;
24
27
  export const PACKET_ID_FILE_CONTROL = 81;
25
28
  export const PACKET_ID_FILE_DATA = 82;
@@ -30,19 +33,46 @@ export const MAX_CONCURRENT_FILE_PIPES = 256;
30
33
  export const MAX_FILE_DATA_SIZE = 1367;
31
34
  export const FILECONTROL = { ACCEPT: 0, PAUSE: 1, KILL: 2, SEEK: 3, ACK: 4 };
32
35
  export const FILEKIND = { DATA: 0, AVATAR: 1 };
33
- // Reliability tuning. We use a FIXED in-flight window (not TCP-style AIMD): a
34
- // file transfer over a private direct path should treat packet loss as RANDOM
35
- // (lossy Wi-Fi / long-haul jitter), NOT as congestion backing the window off
36
- // on every random drop collapsed throughput to a crawl (minutes for a big
37
- // file). Instead, keep the window large and just retransmit the gaps fast. The
38
- // 4 MB UDP socket buffer absorbs the burst; the receiver buffers by offset, so
39
- // reordering is free.
40
- const WINDOW_CHUNKS = 768; // ~1 MB in flight — fills a 200ms-RTT path at ~5 MB/s
41
- const WATCHDOG_MS = 150; // resend from the ack point if no progress this long
36
+ // Reliability tuning. We RAMP an in-flight window (AIMD-lite) rather than pin it:
37
+ // - RANDOM single-packet loss (dup-ack with a gap) is NOT congestion fast-
38
+ // retransmit the hole and DON'T shrink the window, so lossy-but-fat paths
39
+ // (Wi-Fi / long-haul jitter) keep full throughput (the original intent).
40
+ // - A SUSTAINED stall (no ack progress for ~RTT) IS congestion halve the
41
+ // window so we stop flooding the path. This is what keeps the *session* alive
42
+ // during a big transfer over a thin/relayed hop: without it, a fixed 1 MB
43
+ // window blasted go-back-N saturates the path, starves the net_crypto
44
+ // keepalive, and the whole session (ping included) times out. On a good path
45
+ // the window ramps back to WINDOW_CHUNKS within a few RTTs → same speed.
46
+ // The 4 MB UDP socket buffer absorbs bursts; the receiver buffers by offset.
47
+ const WINDOW_CHUNKS = 768; // ~1 MB — the window CAP (fills a 200ms-RTT path at ~5 MB/s)
48
+ const CWND_INIT_CHUNKS = 16; // ~22 KB start — small so the opening burst can't bloat a thin path
49
+ const CWND_MIN_CHUNKS = 8; // ~11 KB floor — keeps a slow/flaky path's buffer shallow
50
+ // Congestion control (TCP Vegas — DELAY-based). Loss/AIMD control fails on a
51
+ // slow peer with a deep buffer (a user's laptop): ~1 MB in flight piles up as
52
+ // 12-23 s of queue latency with NO loss, so it never backs off, and the stalls
53
+ // trigger go-back-N resends that balloon the queue further. Vegas instead reads
54
+ // how many chunks are QUEUED from the RTT inflation — cwnd × (1 − minRtt/RTT) —
55
+ // and steers the window to keep that between ALPHA and BETA. When the queue is
56
+ // below ALPHA there's spare capacity → grow (no self-limiting, unlike a
57
+ // rate-based cap); above BETA the queue is too deep → shrink. It converges to
58
+ // "pipe full + a couple chunks queued" on ANY path: a fast link gets a big
59
+ // window, a thin flaky one a small window and a shallow queue, automatically.
60
+ // Target the ABSOLUTE queue delay (rtt − minRtt), not a packet count: bound the
61
+ // latency the transfer ADDS to the path to ≈ MAX_QUEUE_MS on ANY path. A fast
62
+ // link keeps a big window (it drains quickly, so a big window adds little
63
+ // delay); a thin flaky one keeps a small window (a big window there would add
64
+ // seconds). Growing whenever the added delay is low means no self-limiting.
65
+ const MAX_QUEUE_MS = 150; // ceiling on added latency → when exceeded, drain
66
+ const QUEUE_LOW_MS = 60; // below this there's spare capacity → grow
67
+ const VEGAS_GROW = 1.3; // window ×this per RTT when under-filled
68
+ const VEGAS_SHRINK = 0.85; // window ×this per RTT when the queue is too deep
69
+ const WATCHDOG_MS = 150; // stall poll cadence / base no-progress patience
70
+ 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)
42
71
  const FAST_RT_MIN_MS = 40; // min gap between fast-retransmits of the same hole (≈1 RTT)
43
72
  const ACK_THROTTLE_MS = 15; // coalesce receiver acks (fast enough to drive fast-retransmit)
44
73
  const REACK_MS = 200; // receiver keep-alive re-ack cadence
45
74
  const SEND_GIVEUP_MS = 60000; // abandon a send after this long with no ack progress (peer gone)
75
+ const PERSIST_INTERVAL_BYTES = 1024 * 1024; // flush the resume file at most once per this much progress
46
76
  function u32be(n) {
47
77
  const b = new Uint8Array(4);
48
78
  b[0] = (n >>> 24) & 0xff;
@@ -111,10 +141,27 @@ export class FileTransferManager {
111
141
  emit;
112
142
  #sending = new Map();
113
143
  #receiving = new Map();
144
+ // When set, incoming transfers persist their contiguous prefix here so a
145
+ // dropped/restarted transfer resumes instead of restarting (断点续传).
146
+ #resumeDir;
114
147
  constructor(send, emit) {
115
148
  this.send = send;
116
149
  this.emit = emit;
117
150
  }
151
+ /** Enable resumable receives: partials are persisted under `dir` and matched
152
+ * on re-offer by content-hash fileId. Unset = in-memory only (no resume). */
153
+ setResumeDir(dir) {
154
+ try {
155
+ mkdirSync(dir, { recursive: true });
156
+ this.#resumeDir = dir;
157
+ }
158
+ catch {
159
+ this.#resumeDir = undefined;
160
+ }
161
+ }
162
+ #partPath(fileId) {
163
+ return this.#resumeDir ? join(this.#resumeDir, hex(fileId) + ".part") : undefined;
164
+ }
118
165
  #map(m, friendId) {
119
166
  let inner = m.get(friendId);
120
167
  if (!inner) {
@@ -134,12 +181,20 @@ export class FileTransferManager {
134
181
  const fileNumber = this.#freeNumber(friendId);
135
182
  if (fileNumber < 0)
136
183
  return null;
137
- const fileId = randomBytes(FILE_ID_LENGTH);
184
+ // Content-addressed fileId (sha256): re-sending the SAME file after a
185
+ // restart or a dropped peer yields the SAME id, so the receiver can match
186
+ // it against its persisted partial and resume from where it left off — the
187
+ // key to 断点续传 (resumable transfer). Falls back to random for empty data.
188
+ const fileId = data.length > 0
189
+ ? new Uint8Array(createHash("sha256").update(data).digest()).slice(0, FILE_ID_LENGTH)
190
+ : randomBytes(FILE_ID_LENGTH);
138
191
  const req = encodeFileSendRequest(fileNumber, opts.kind ?? FILEKIND.DATA, BigInt(data.length), fileId, opts.name);
139
192
  const st = {
140
193
  fileNumber, fileId, name: opts.name, data, size: data.length,
141
194
  acked: 0, nextSend: 0, pumping: false, started: false, req, startMs: nowMs(),
142
195
  lastProgressAcked: 0, lastAckAdvanceMs: nowMs(), lastResendMs: 0,
196
+ cwnd: CWND_INIT_CHUNKS, stalls: 0,
197
+ minRttMs: Infinity, srttMs: 0, probeOffset: -1, probeSentMs: 0,
143
198
  };
144
199
  this.#map(this.#sending, friendId).set(fileNumber, st);
145
200
  void this.send(friendId, PACKET_ID_FILE_SENDREQUEST, req).catch(() => undefined);
@@ -225,10 +280,42 @@ export class FileTransferManager {
225
280
  const st = {
226
281
  fileNumber: r.fileNumber, fileId: r.fileId, name: r.filename, size,
227
282
  buf: new Uint8Array(size), got: new Uint8Array(chunkCount(size)),
228
- contiguous: 0, maxByte: 0, done: false, ackDirty: false,
283
+ contiguous: 0, maxByte: 0, persisted: 0, partPath: this.#partPath(r.fileId), done: false, ackDirty: false,
229
284
  };
285
+ // 断点续传: if we persisted a contiguous prefix of this exact file (matched
286
+ // by content-hash fileId) before a drop/restart, load it and resume — our
287
+ // ack will tell the sender to skip past what we already have.
288
+ if (st.partPath && existsSync(st.partPath)) {
289
+ try {
290
+ const have = Math.min(statSync(st.partPath).size, size);
291
+ // Only the whole-chunk prefix is trustworthy; drop any tail into a
292
+ // partial last chunk. contiguous must sit on a chunk boundary unless
293
+ // it's the final byte.
294
+ const chunks = Math.floor(have / MAX_FILE_DATA_SIZE);
295
+ const usable = Math.min(chunks * MAX_FILE_DATA_SIZE, size);
296
+ if (usable > 0) {
297
+ const partial = readFileSync(st.partPath);
298
+ st.buf.set(partial.subarray(0, usable), 0);
299
+ for (let i = 0; i < chunks; i++)
300
+ st.got[i] = 1;
301
+ st.contiguous = usable;
302
+ st.maxByte = usable;
303
+ st.persisted = usable;
304
+ // Truncate the file to exactly the whole-chunk prefix, so subsequent
305
+ // appends continue from `usable` (a leftover partial last chunk would
306
+ // otherwise desync the append offset from st.persisted).
307
+ if (have > usable)
308
+ writeFileSync(st.partPath, st.buf.subarray(0, usable));
309
+ }
310
+ }
311
+ catch { /* corrupt/unreadable partial → start fresh */ }
312
+ }
230
313
  this.#map(this.#receiving, friendId).set(r.fileNumber, st);
231
314
  this.emit("file-offer", { friendId, fileNumber: r.fileNumber, fileId: hex(r.fileId), name: r.filename, size, kind: r.fileType });
315
+ if (st.contiguous >= size) {
316
+ this.#finishRecv(friendId, st);
317
+ return;
318
+ }
232
319
  if (size === 0)
233
320
  this.#finishRecv(friendId, st);
234
321
  }
@@ -300,6 +387,7 @@ export class FileTransferManager {
300
387
  i++;
301
388
  st.contiguous = Math.min(i * MAX_FILE_DATA_SIZE, st.size);
302
389
  this.emit("file-progress", { friendId, fileId: hex(st.fileId), received: st.contiguous, total: st.size });
390
+ this.#persistPartial(st);
303
391
  }
304
392
  if (st.contiguous >= st.size) {
305
393
  this.#finishRecv(friendId, st);
@@ -307,6 +395,22 @@ export class FileTransferManager {
307
395
  }
308
396
  this.#scheduleAck(friendId, st);
309
397
  }
398
+ // Flush the newly-contiguous bytes to the resume file, throttled so we do at
399
+ // most one append per PERSIST_INTERVAL_BYTES of progress (not per chunk).
400
+ #persistPartial(st, force = false) {
401
+ if (!st.partPath)
402
+ return;
403
+ const pending = st.contiguous - st.persisted;
404
+ if (pending <= 0)
405
+ return;
406
+ if (!force && pending < PERSIST_INTERVAL_BYTES)
407
+ return;
408
+ try {
409
+ appendFileSync(st.partPath, st.buf.subarray(st.persisted, st.contiguous));
410
+ st.persisted = st.contiguous;
411
+ }
412
+ catch { /* disk issue → keep going in memory; resume just won't cover this gap */ }
413
+ }
310
414
  #finishRecv(friendId, st) {
311
415
  st.done = true;
312
416
  if (st.ackTimer) {
@@ -319,6 +423,13 @@ export class FileTransferManager {
319
423
  }
320
424
  this.#sendAck(friendId, st); // final ack so the sender completes
321
425
  const data = st.buf;
426
+ // Transfer complete → the resume file is no longer needed.
427
+ if (st.partPath) {
428
+ try {
429
+ unlinkSync(st.partPath);
430
+ }
431
+ catch { /* already gone */ }
432
+ }
322
433
  this.emit("file-complete", { friendId, fileId: hex(st.fileId), name: st.name, size: st.size, data });
323
434
  // Keep a lightweight done-marker (st.contiguous == size) so a LOST final
324
435
  // ack can be recovered: any further FILE_DATA for this slot re-acks. Free
@@ -360,9 +471,44 @@ export class FileTransferManager {
360
471
  const ackedOffset = readU32be(data, 0);
361
472
  const maxRecv = data.length >= 8 ? readU32be(data, 4) : ackedOffset;
362
473
  if (ackedOffset > st.acked) {
363
- // Progress.
474
+ // Progress. The path is delivering → additively re-open the window (up to
475
+ // the cap) and clear the stall streak (patience shrinks back to the base).
476
+ const nowT = nowMs();
364
477
  st.acked = Math.min(ackedOffset, st.size);
365
- st.lastAckAdvanceMs = nowMs();
478
+ st.lastAckAdvanceMs = nowT;
479
+ st.stalls = 0;
480
+ // Resume: if the receiver's ack jumps ahead of where we've sent (it had a
481
+ // persisted partial), skip the send cursor past the bytes it already has
482
+ // instead of re-transmitting them.
483
+ if (st.nextSend < st.acked)
484
+ st.nextSend = st.acked;
485
+ // Vegas: adjust the window ONCE PER RTT, when the timed frontier byte's ack
486
+ // returns (per-ack would fire several times before a slow path's probe
487
+ // reported, overshooting). Estimate chunks queued from RTT inflation and
488
+ // steer to keep it in [ALPHA, BETA].
489
+ if (st.probeOffset >= 0 && st.acked >= st.probeOffset) {
490
+ const rtt = nowT - st.probeSentMs;
491
+ st.probeOffset = -1;
492
+ // Reject glitch samples: a batched/late ack can make a probe byte look
493
+ // acked far sooner than it was really delivered, producing an RTT far
494
+ // below the smoothed value — which would corrupt minRtt and wreck the
495
+ // queue estimate. Only trust a sample within a sane band of srtt.
496
+ const glitch = st.srttMs > 0 && rtt < st.srttMs * 0.4;
497
+ if (!glitch && rtt > 0) {
498
+ st.srttMs = st.srttMs === 0 ? rtt : 0.85 * st.srttMs + 0.15 * rtt;
499
+ if (rtt < st.minRttMs)
500
+ st.minRttMs = rtt;
501
+ // Added latency this transfer is putting on the path = how deep the
502
+ // queue is. Steer the window to keep it under MAX_QUEUE_MS.
503
+ const queueDelayMs = st.minRttMs !== Infinity ? rtt - st.minRttMs : 0;
504
+ if (queueDelayMs < QUEUE_LOW_MS) {
505
+ st.cwnd = Math.min(WINDOW_CHUNKS, Math.ceil(st.cwnd * VEGAS_GROW)); // spare capacity → grow
506
+ }
507
+ else if (queueDelayMs > MAX_QUEUE_MS) {
508
+ st.cwnd = Math.max(CWND_MIN_CHUNKS, Math.floor(st.cwnd * VEGAS_SHRINK)); // queue too deep → drain
509
+ } // else hold: pipe full, queue shallow
510
+ }
511
+ }
366
512
  if (st.acked - st.lastProgressAcked >= 256 * 1024 || st.acked >= st.size) {
367
513
  st.lastProgressAcked = st.acked;
368
514
  this.emit("file-progress", { friendId, fileId: hex(st.fileId), received: st.acked, total: st.size, sending: true });
@@ -402,7 +548,14 @@ export class FileTransferManager {
402
548
  }
403
549
  if (st.acked >= st.size)
404
550
  return;
405
- if (nowMs() - st.lastAckAdvanceMs < WATCHDOG_MS) {
551
+ // RTT-adaptive patience: NEVER declare a stall before ~3 RTTs have
552
+ // passed, or a high-RTT path (acks legitimately arrive every 800ms+)
553
+ // false-stalls every 150ms, collapsing the window to the floor and
554
+ // go-back-N-re-blasting — exactly what pinned a thin path at the minimum.
555
+ // Take the max of the exponential backoff and 3× the smoothed RTT.
556
+ const rttFloor = st.srttMs > 0 ? st.srttMs * 3 : WATCHDOG_MS;
557
+ const patience = Math.min(WATCHDOG_MAX_MS, Math.max(WATCHDOG_MS << Math.min(st.stalls, 4), rttFloor));
558
+ if (nowMs() - st.lastAckAdvanceMs < patience) {
406
559
  stalledSinceMs = nowMs();
407
560
  return;
408
561
  }
@@ -411,6 +564,12 @@ export class FileTransferManager {
411
564
  this.emit("file-cancel", { friendId, fileId: hex(st.fileId), sending: true, reason: "timeout" });
412
565
  return;
413
566
  }
567
+ // Sustained stall = congestion/path signal → multiplicatively cut the
568
+ // window so we STOP flooding. This frees the path for the net_crypto
569
+ // keepalive + acks, keeping the session (and ping) alive; the transfer
570
+ // then self-clocks back up as acks resume.
571
+ st.stalls++;
572
+ st.cwnd = Math.max(CWND_MIN_CHUNKS, Math.floor(st.cwnd / 2));
414
573
  st.lastAckAdvanceMs = nowMs();
415
574
  st.lastResendMs = nowMs();
416
575
  if (st.nextSend > st.acked)
@@ -425,7 +584,7 @@ export class FileTransferManager {
425
584
  st.pumping = true;
426
585
  try {
427
586
  while (this.#map(this.#sending, friendId).has(st.fileNumber) && st.acked < st.size) {
428
- const windowEnd = Math.min(st.acked + WINDOW_CHUNKS * MAX_FILE_DATA_SIZE, st.size);
587
+ const windowEnd = Math.min(st.acked + st.cwnd * MAX_FILE_DATA_SIZE, st.size);
429
588
  if (st.nextSend >= windowEnd)
430
589
  break; // window full / all sent — wait for acks
431
590
  const off = st.nextSend;
@@ -438,6 +597,12 @@ export class FileTransferManager {
438
597
  }
439
598
  if (st.nextSend === off)
440
599
  st.nextSend = end; // unless a rewind moved it
600
+ // Arm one RTT probe per round-trip: time this fresh frontier byte until
601
+ // its ack lands, so #onAck can read the path's current queue delay.
602
+ if (st.probeOffset < 0 && end > st.acked) {
603
+ st.probeOffset = end;
604
+ st.probeSentMs = nowMs();
605
+ }
441
606
  }
442
607
  }
443
608
  finally {
package/dist/peer.js CHANGED
@@ -312,6 +312,10 @@ export class Peer {
312
312
  };
313
313
  this.#knownNodes = [...opts.bootstrapNodes];
314
314
  this.#friendStoreFile = opts.friendStoreFile ?? `${opts.keyFile}.friends.json`;
315
+ // Enable resumable (断点续传) receives when the app gives us a directory to
316
+ // persist partials in.
317
+ if (opts.fileResumeDir)
318
+ this.#fileTransfer.setResumeDir(opts.fileResumeDir);
315
319
  }
316
320
  static async create(opts) {
317
321
  if (opts.compatibilityMode && opts.compatibilityMode !== "legacy") {
@@ -44,6 +44,13 @@ export type PeerOptions = {
44
44
  * optimisation only applies to packet 64 and custom-id data is duplicated.
45
45
  */
46
46
  bulkDataPacketId?: number;
47
+ /**
48
+ * Directory for persisting partially-received files so a dropped or
49
+ * restarted transfer resumes instead of restarting (断点续传). Unset =
50
+ * in-memory only (no resume). The fileId is content-addressed (sha256), so a
51
+ * re-send of the same file matches its persisted partial automatically.
52
+ */
53
+ fileResumeDir?: string;
47
54
  /**
48
55
  * Display name this peer advertises to friends (Carrier nickname, packet 48 +
49
56
  * the userinfo profile). Without it every node reports the build default
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.70",
3
+ "version": "0.1.72",
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",