@decentnetwork/peer 0.1.71 → 0.1.73

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;
@@ -42,15 +45,38 @@ export const FILEKIND = { DATA: 0, AVATAR: 1 };
42
45
  // the window ramps back to WINDOW_CHUNKS within a few RTTs → same speed.
43
46
  // The 4 MB UDP socket buffer absorbs bursts; the receiver buffers by offset.
44
47
  const WINDOW_CHUNKS = 768; // ~1 MB — the window CAP (fills a 200ms-RTT path at ~5 MB/s)
45
- const CWND_INIT_CHUNKS = 96; // ~131 KB start — ramps to the cap fast on a clean path
46
- const CWND_MIN_CHUNKS = 24; // ~33 KB floor — still enough to keep a thin path busy
47
- const CWND_GROW_CHUNKS = 48; // additive increase per ack-progress event
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
+ // The budget is generous on purpose: for a bulk transfer to a flaky peer,
66
+ // ~0.5s of added latency keeps ping well under a second (fine — the peer stays
67
+ // usable), and buys far more throughput on a jittery path than a tight 150ms
68
+ // bound, which collapsed the window to the floor (~17 KB/s observed on lili).
69
+ const MAX_QUEUE_MS = 350; // raw-queue ceiling before draining (bulk transfer tolerates ~0.35s added latency)
70
+ const QUEUE_LOW_MS = 150; // grow while the smoothed (jitter-free) queue is under this
71
+ const VEGAS_GROW = 1.25; // window ×this per RTT when under-filled
72
+ const VEGAS_SHRINK = 0.8; // window ×this per RTT when the queue is too deep
48
73
  const WATCHDOG_MS = 150; // stall poll cadence / base no-progress patience
49
- const WATCHDOG_MAX_MS = 2400; // cap the RTT-adaptive stall patience (high-RTT relay paths)
74
+ 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)
50
75
  const FAST_RT_MIN_MS = 40; // min gap between fast-retransmits of the same hole (≈1 RTT)
51
76
  const ACK_THROTTLE_MS = 15; // coalesce receiver acks (fast enough to drive fast-retransmit)
52
77
  const REACK_MS = 200; // receiver keep-alive re-ack cadence
53
78
  const SEND_GIVEUP_MS = 60000; // abandon a send after this long with no ack progress (peer gone)
79
+ const PERSIST_INTERVAL_BYTES = 1024 * 1024; // flush the resume file at most once per this much progress
54
80
  function u32be(n) {
55
81
  const b = new Uint8Array(4);
56
82
  b[0] = (n >>> 24) & 0xff;
@@ -119,10 +145,27 @@ export class FileTransferManager {
119
145
  emit;
120
146
  #sending = new Map();
121
147
  #receiving = new Map();
148
+ // When set, incoming transfers persist their contiguous prefix here so a
149
+ // dropped/restarted transfer resumes instead of restarting (断点续传).
150
+ #resumeDir;
122
151
  constructor(send, emit) {
123
152
  this.send = send;
124
153
  this.emit = emit;
125
154
  }
155
+ /** Enable resumable receives: partials are persisted under `dir` and matched
156
+ * on re-offer by content-hash fileId. Unset = in-memory only (no resume). */
157
+ setResumeDir(dir) {
158
+ try {
159
+ mkdirSync(dir, { recursive: true });
160
+ this.#resumeDir = dir;
161
+ }
162
+ catch {
163
+ this.#resumeDir = undefined;
164
+ }
165
+ }
166
+ #partPath(fileId) {
167
+ return this.#resumeDir ? join(this.#resumeDir, hex(fileId) + ".part") : undefined;
168
+ }
126
169
  #map(m, friendId) {
127
170
  let inner = m.get(friendId);
128
171
  if (!inner) {
@@ -142,13 +185,20 @@ export class FileTransferManager {
142
185
  const fileNumber = this.#freeNumber(friendId);
143
186
  if (fileNumber < 0)
144
187
  return null;
145
- const fileId = randomBytes(FILE_ID_LENGTH);
188
+ // Content-addressed fileId (sha256): re-sending the SAME file after a
189
+ // restart or a dropped peer yields the SAME id, so the receiver can match
190
+ // it against its persisted partial and resume from where it left off — the
191
+ // key to 断点续传 (resumable transfer). Falls back to random for empty data.
192
+ const fileId = data.length > 0
193
+ ? new Uint8Array(createHash("sha256").update(data).digest()).slice(0, FILE_ID_LENGTH)
194
+ : randomBytes(FILE_ID_LENGTH);
146
195
  const req = encodeFileSendRequest(fileNumber, opts.kind ?? FILEKIND.DATA, BigInt(data.length), fileId, opts.name);
147
196
  const st = {
148
197
  fileNumber, fileId, name: opts.name, data, size: data.length,
149
198
  acked: 0, nextSend: 0, pumping: false, started: false, req, startMs: nowMs(),
150
199
  lastProgressAcked: 0, lastAckAdvanceMs: nowMs(), lastResendMs: 0,
151
200
  cwnd: CWND_INIT_CHUNKS, stalls: 0,
201
+ minRttMs: Infinity, minSrttMs: Infinity, srttMs: 0, probeOffset: -1, probeSentMs: 0,
152
202
  };
153
203
  this.#map(this.#sending, friendId).set(fileNumber, st);
154
204
  void this.send(friendId, PACKET_ID_FILE_SENDREQUEST, req).catch(() => undefined);
@@ -234,10 +284,42 @@ export class FileTransferManager {
234
284
  const st = {
235
285
  fileNumber: r.fileNumber, fileId: r.fileId, name: r.filename, size,
236
286
  buf: new Uint8Array(size), got: new Uint8Array(chunkCount(size)),
237
- contiguous: 0, maxByte: 0, done: false, ackDirty: false,
287
+ contiguous: 0, maxByte: 0, persisted: 0, partPath: this.#partPath(r.fileId), done: false, ackDirty: false,
238
288
  };
289
+ // 断点续传: if we persisted a contiguous prefix of this exact file (matched
290
+ // by content-hash fileId) before a drop/restart, load it and resume — our
291
+ // ack will tell the sender to skip past what we already have.
292
+ if (st.partPath && existsSync(st.partPath)) {
293
+ try {
294
+ const have = Math.min(statSync(st.partPath).size, size);
295
+ // Only the whole-chunk prefix is trustworthy; drop any tail into a
296
+ // partial last chunk. contiguous must sit on a chunk boundary unless
297
+ // it's the final byte.
298
+ const chunks = Math.floor(have / MAX_FILE_DATA_SIZE);
299
+ const usable = Math.min(chunks * MAX_FILE_DATA_SIZE, size);
300
+ if (usable > 0) {
301
+ const partial = readFileSync(st.partPath);
302
+ st.buf.set(partial.subarray(0, usable), 0);
303
+ for (let i = 0; i < chunks; i++)
304
+ st.got[i] = 1;
305
+ st.contiguous = usable;
306
+ st.maxByte = usable;
307
+ st.persisted = usable;
308
+ // Truncate the file to exactly the whole-chunk prefix, so subsequent
309
+ // appends continue from `usable` (a leftover partial last chunk would
310
+ // otherwise desync the append offset from st.persisted).
311
+ if (have > usable)
312
+ writeFileSync(st.partPath, st.buf.subarray(0, usable));
313
+ }
314
+ }
315
+ catch { /* corrupt/unreadable partial → start fresh */ }
316
+ }
239
317
  this.#map(this.#receiving, friendId).set(r.fileNumber, st);
240
318
  this.emit("file-offer", { friendId, fileNumber: r.fileNumber, fileId: hex(r.fileId), name: r.filename, size, kind: r.fileType });
319
+ if (st.contiguous >= size) {
320
+ this.#finishRecv(friendId, st);
321
+ return;
322
+ }
241
323
  if (size === 0)
242
324
  this.#finishRecv(friendId, st);
243
325
  }
@@ -309,6 +391,7 @@ export class FileTransferManager {
309
391
  i++;
310
392
  st.contiguous = Math.min(i * MAX_FILE_DATA_SIZE, st.size);
311
393
  this.emit("file-progress", { friendId, fileId: hex(st.fileId), received: st.contiguous, total: st.size });
394
+ this.#persistPartial(st);
312
395
  }
313
396
  if (st.contiguous >= st.size) {
314
397
  this.#finishRecv(friendId, st);
@@ -316,6 +399,22 @@ export class FileTransferManager {
316
399
  }
317
400
  this.#scheduleAck(friendId, st);
318
401
  }
402
+ // Flush the newly-contiguous bytes to the resume file, throttled so we do at
403
+ // most one append per PERSIST_INTERVAL_BYTES of progress (not per chunk).
404
+ #persistPartial(st, force = false) {
405
+ if (!st.partPath)
406
+ return;
407
+ const pending = st.contiguous - st.persisted;
408
+ if (pending <= 0)
409
+ return;
410
+ if (!force && pending < PERSIST_INTERVAL_BYTES)
411
+ return;
412
+ try {
413
+ appendFileSync(st.partPath, st.buf.subarray(st.persisted, st.contiguous));
414
+ st.persisted = st.contiguous;
415
+ }
416
+ catch { /* disk issue → keep going in memory; resume just won't cover this gap */ }
417
+ }
319
418
  #finishRecv(friendId, st) {
320
419
  st.done = true;
321
420
  if (st.ackTimer) {
@@ -328,6 +427,13 @@ export class FileTransferManager {
328
427
  }
329
428
  this.#sendAck(friendId, st); // final ack so the sender completes
330
429
  const data = st.buf;
430
+ // Transfer complete → the resume file is no longer needed.
431
+ if (st.partPath) {
432
+ try {
433
+ unlinkSync(st.partPath);
434
+ }
435
+ catch { /* already gone */ }
436
+ }
331
437
  this.emit("file-complete", { friendId, fileId: hex(st.fileId), name: st.name, size: st.size, data });
332
438
  // Keep a lightweight done-marker (st.contiguous == size) so a LOST final
333
439
  // ack can be recovered: any further FILE_DATA for this slot re-acks. Free
@@ -371,10 +477,50 @@ export class FileTransferManager {
371
477
  if (ackedOffset > st.acked) {
372
478
  // Progress. The path is delivering → additively re-open the window (up to
373
479
  // the cap) and clear the stall streak (patience shrinks back to the base).
480
+ const nowT = nowMs();
374
481
  st.acked = Math.min(ackedOffset, st.size);
375
- st.lastAckAdvanceMs = nowMs();
376
- st.cwnd = Math.min(WINDOW_CHUNKS, st.cwnd + CWND_GROW_CHUNKS);
482
+ st.lastAckAdvanceMs = nowT;
377
483
  st.stalls = 0;
484
+ // Resume: if the receiver's ack jumps ahead of where we've sent (it had a
485
+ // persisted partial), skip the send cursor past the bytes it already has
486
+ // instead of re-transmitting them.
487
+ if (st.nextSend < st.acked)
488
+ st.nextSend = st.acked;
489
+ // Vegas: adjust the window ONCE PER RTT, when the timed frontier byte's ack
490
+ // returns (per-ack would fire several times before a slow path's probe
491
+ // reported, overshooting). Estimate chunks queued from RTT inflation and
492
+ // steer to keep it in [ALPHA, BETA].
493
+ if (st.probeOffset >= 0 && st.acked >= st.probeOffset) {
494
+ const rtt = nowT - st.probeSentMs;
495
+ st.probeOffset = -1;
496
+ // Reject glitch samples: a batched/late ack can make a probe byte look
497
+ // acked far sooner than it was really delivered, producing an RTT far
498
+ // below the smoothed value — which would corrupt minRtt and wreck the
499
+ // queue estimate. Only trust a sample within a sane band of srtt.
500
+ const glitch = st.srttMs > 0 && rtt < st.srttMs * 0.4;
501
+ if (!glitch && rtt > 0) {
502
+ st.srttMs = st.srttMs === 0 ? rtt : 0.85 * st.srttMs + 0.15 * rtt;
503
+ if (rtt < st.minRttMs)
504
+ st.minRttMs = rtt;
505
+ if (st.srttMs < st.minSrttMs)
506
+ st.minSrttMs = st.srttMs;
507
+ // Hybrid signal, so per-packet JITTER doesn't masquerade as a queue:
508
+ // • GROW on the SMOOTHED queue (srtt − minSrtt): jitter averages out,
509
+ // so a flaky path that isn't really queuing keeps growing instead
510
+ // of collapsing to the floor (~17 KB/s observed on lili).
511
+ // • SHRINK on the RAW queue (rtt − minRtt) against a higher ceiling:
512
+ // fast reaction to genuine bufferbloat, but the ceiling sits above
513
+ // the jitter band so a lone jittery sample doesn't trigger it.
514
+ const smoothQueue = st.minSrttMs !== Infinity ? st.srttMs - st.minSrttMs : 0;
515
+ const rawQueue = st.minRttMs !== Infinity ? rtt - st.minRttMs : 0;
516
+ if (rawQueue > MAX_QUEUE_MS) {
517
+ st.cwnd = Math.max(CWND_MIN_CHUNKS, Math.floor(st.cwnd * VEGAS_SHRINK)); // real queue too deep → drain
518
+ }
519
+ else if (smoothQueue < QUEUE_LOW_MS) {
520
+ st.cwnd = Math.min(WINDOW_CHUNKS, Math.ceil(st.cwnd * VEGAS_GROW)); // spare capacity → grow
521
+ } // else hold
522
+ }
523
+ }
378
524
  if (st.acked - st.lastProgressAcked >= 256 * 1024 || st.acked >= st.size) {
379
525
  st.lastProgressAcked = st.acked;
380
526
  this.emit("file-progress", { friendId, fileId: hex(st.fileId), received: st.acked, total: st.size, sending: true });
@@ -414,11 +560,13 @@ export class FileTransferManager {
414
560
  }
415
561
  if (st.acked >= st.size)
416
562
  return;
417
- // RTT-adaptive patience: after consecutive stalls, wait longer before
418
- // deciding the path stalled — so a high-RTT relay hop doesn't trigger a
419
- // spurious go-back-N re-blast before an ACK could even have returned
420
- // (that spurious re-blast is exactly what collapses a thin path).
421
- const patience = Math.min(WATCHDOG_MAX_MS, WATCHDOG_MS << Math.min(st.stalls, 4));
563
+ // RTT-adaptive patience: NEVER declare a stall before ~3 RTTs have
564
+ // passed, or a high-RTT path (acks legitimately arrive every 800ms+)
565
+ // false-stalls every 150ms, collapsing the window to the floor and
566
+ // go-back-N-re-blasting exactly what pinned a thin path at the minimum.
567
+ // Take the max of the exponential backoff and 3× the smoothed RTT.
568
+ const rttFloor = st.srttMs > 0 ? st.srttMs * 3 : WATCHDOG_MS;
569
+ const patience = Math.min(WATCHDOG_MAX_MS, Math.max(WATCHDOG_MS << Math.min(st.stalls, 4), rttFloor));
422
570
  if (nowMs() - st.lastAckAdvanceMs < patience) {
423
571
  stalledSinceMs = nowMs();
424
572
  return;
@@ -461,6 +609,12 @@ export class FileTransferManager {
461
609
  }
462
610
  if (st.nextSend === off)
463
611
  st.nextSend = end; // unless a rewind moved it
612
+ // Arm one RTT probe per round-trip: time this fresh frontier byte until
613
+ // its ack lands, so #onAck can read the path's current queue delay.
614
+ if (st.probeOffset < 0 && end > st.acked) {
615
+ st.probeOffset = end;
616
+ st.probeSentMs = nowMs();
617
+ }
464
618
  }
465
619
  }
466
620
  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.71",
3
+ "version": "0.1.73",
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",