@decentnetwork/peer 0.1.53 → 0.1.54

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.
@@ -30,18 +30,18 @@ export const MAX_CONCURRENT_FILE_PIPES = 256;
30
30
  export const MAX_FILE_DATA_SIZE = 1367;
31
31
  export const FILECONTROL = { ACCEPT: 0, PAUSE: 1, KILL: 2, SEEK: 3, ACK: 4 };
32
32
  export const FILEKIND = { DATA: 0, AVATAR: 1 };
33
- // Reliability + congestion control tuning. The window is a CONGESTION WINDOW
34
- // (cwnd, in chunks) that slow-starts from small and grows with acks, halving on
35
- // a loss/stall — TCP-style AIMD. Starting at a fixed large window blasted an
36
- // initial burst that a slow/lossy long-haul path (China↔US) couldn't absorb, so
37
- // the front chunks kept dropping and go-back-N re-burst the same a big file
38
- // stuck at 0%. Slow-start makes the first send tiny (fits any path), then it
39
- // self-clocks to the path's capacity via the ack stream.
40
- const INITIAL_CWND = 8; // chunks (~11 KB)small enough for any path
41
- const MIN_CWND = 4;
42
- const MAX_CWND = 1024; // ~1.4 MB in flight cap
43
- const RETRANSMIT_MS = 600; // resend window from the ack point if no progress in this long
44
- const ACK_THROTTLE_MS = 25; // coalesce receiver acks
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
42
+ const FAST_RT_MIN_MS = 40; // min gap between fast-retransmits of the same hole (≈1 RTT)
43
+ const ACK_THROTTLE_MS = 15; // coalesce receiver acks (fast enough to drive fast-retransmit)
44
+ const REACK_MS = 200; // receiver keep-alive re-ack cadence
45
45
  const SEND_GIVEUP_MS = 60000; // abandon a send after this long with no ack progress (peer gone)
46
46
  function u32be(n) {
47
47
  const b = new Uint8Array(4);
@@ -135,14 +135,34 @@ export class FileTransferManager {
135
135
  if (fileNumber < 0)
136
136
  return null;
137
137
  const fileId = randomBytes(FILE_ID_LENGTH);
138
+ const req = encodeFileSendRequest(fileNumber, opts.kind ?? FILEKIND.DATA, BigInt(data.length), fileId, opts.name);
138
139
  const st = {
139
140
  fileNumber, fileId, name: opts.name, data, size: data.length,
140
- acked: 0, cwnd: INITIAL_CWND, ssthresh: MAX_CWND,
141
- pumping: false, lastProgressAcked: 0, lastAckAdvanceMs: nowMs(),
141
+ acked: 0, highestSent: 0, started: false, req, startMs: nowMs(),
142
+ lastProgressAcked: 0, lastAckAdvanceMs: nowMs(), lastResendMs: 0,
142
143
  };
143
144
  this.#map(this.#sending, friendId).set(fileNumber, st);
144
- const req = encodeFileSendRequest(fileNumber, opts.kind ?? FILEKIND.DATA, BigInt(data.length), fileId, opts.name);
145
145
  void this.send(friendId, PACKET_ID_FILE_SENDREQUEST, req).catch(() => undefined);
146
+ // Retransmit the offer until the receiver responds — on a lossy path the
147
+ // offer itself can drop and nothing else would start the transfer.
148
+ let offerSinceMs = nowMs();
149
+ st.offerTimer = setInterval(() => {
150
+ const cur = this.#map(this.#sending, friendId).get(fileNumber);
151
+ if (!cur || cur.started) {
152
+ if (st.offerTimer)
153
+ clearInterval(st.offerTimer);
154
+ st.offerTimer = undefined;
155
+ return;
156
+ }
157
+ if (nowMs() - offerSinceMs >= SEND_GIVEUP_MS) {
158
+ this.#endSend(friendId, fileNumber);
159
+ this.emit("file-cancel", { friendId, fileId: hex(fileId), sending: true, reason: "no-response" });
160
+ return;
161
+ }
162
+ void this.send(friendId, PACKET_ID_FILE_SENDREQUEST, cur.req).catch(() => undefined);
163
+ }, WATCHDOG_MS * 3);
164
+ if (typeof st.offerTimer.unref === "function")
165
+ st.offerTimer.unref();
146
166
  return hex(fileId);
147
167
  }
148
168
  accept(friendId, fileNumber) {
@@ -164,7 +184,7 @@ export class FileTransferManager {
164
184
  return;
165
185
  }
166
186
  this.#sendAck(friendId, cur);
167
- }, RETRANSMIT_MS);
187
+ }, REACK_MS);
168
188
  if (typeof st.reackTimer.unref === "function")
169
189
  st.reackTimer.unref();
170
190
  }
@@ -198,8 +218,10 @@ export class FileTransferManager {
198
218
  return;
199
219
  const size = Number(r.fileSize);
200
220
  const existing = this.#map(this.#receiving, friendId).get(r.fileNumber);
201
- if (existing && !existing.done)
202
- return; // active transfer already on this slot
221
+ if (existing && !existing.done) {
222
+ this.#sendAck(friendId, existing);
223
+ return;
224
+ } // re-offer (our accept/ack lost) → re-ack
203
225
  const st = {
204
226
  fileNumber: r.fileNumber, fileId: r.fileId, name: r.filename, size,
205
227
  buf: new Uint8Array(size), got: new Uint8Array(chunkCount(size)),
@@ -219,8 +241,17 @@ export class FileTransferManager {
219
241
  const st = this.#map(this.#sending, friendId).get(c.fileNumber);
220
242
  if (!st)
221
243
  return;
244
+ if (c.controlType === FILECONTROL.ACCEPT || c.controlType === FILECONTROL.ACK) {
245
+ if (!st.started) {
246
+ st.started = true;
247
+ if (st.offerTimer) {
248
+ clearInterval(st.offerTimer);
249
+ st.offerTimer = undefined;
250
+ }
251
+ }
252
+ }
222
253
  if (c.controlType === FILECONTROL.ACCEPT) {
223
- void this.#pump(friendId, st);
254
+ this.#pump(friendId, st);
224
255
  }
225
256
  else if (c.controlType === FILECONTROL.ACK) {
226
257
  this.#onAck(friendId, st, c.data);
@@ -328,41 +359,33 @@ export class FileTransferManager {
328
359
  return;
329
360
  const ackedOffset = readU32be(data, 0);
330
361
  const maxRecv = data.length >= 8 ? readU32be(data, 4) : ackedOffset;
331
- // Gap = bytes the receiver has seen BEYOND the contiguous point that are
332
- // still missing below maxRecv → loss (or reorder). A gap bigger than half
333
- // the window means we're overrunning the path → back off (multiplicative
334
- // decrease). Otherwise the path is keeping up → grow (slow-start / linear).
335
- const gapChunks = Math.max(0, maxRecv - ackedOffset) / MAX_FILE_DATA_SIZE;
336
362
  if (ackedOffset > st.acked) {
363
+ // Progress.
337
364
  st.acked = Math.min(ackedOffset, st.size);
338
365
  st.lastAckAdvanceMs = nowMs();
339
- if (gapChunks > st.cwnd * 0.5) {
340
- st.ssthresh = Math.max(MIN_CWND, st.cwnd >> 1);
341
- st.cwnd = st.ssthresh;
342
- }
343
- else {
344
- st.cwnd = st.cwnd < st.ssthresh
345
- ? Math.min(st.cwnd * 2, st.ssthresh)
346
- : Math.min(st.cwnd + 1, MAX_CWND);
347
- }
348
- if (st.acked - st.lastProgressAcked >= 64 * 1024 || st.acked >= st.size) {
366
+ if (st.acked - st.lastProgressAcked >= 256 * 1024 || st.acked >= st.size) {
349
367
  st.lastProgressAcked = st.acked;
350
368
  this.emit("file-progress", { friendId, fileId: hex(st.fileId), received: st.acked, total: st.size, sending: true });
351
369
  }
352
370
  }
371
+ else if (maxRecv > st.acked + MAX_FILE_DATA_SIZE && nowMs() - st.lastResendMs >= FAST_RT_MIN_MS) {
372
+ // Duplicate ack WITH a gap → the chunk at `acked` was lost. Fast-retransmit
373
+ // the hole region immediately (rate-limited to ~1 RTT so a burst of
374
+ // dup-acks for one loss doesn't storm). No window backoff: file-path loss
375
+ // is treated as random, not congestion.
376
+ st.lastResendMs = nowMs();
377
+ this.#resendRange(friendId, st, st.acked, maxRecv);
378
+ }
353
379
  if (st.acked >= st.size) {
354
380
  this.#completeSend(friendId, st);
355
381
  return;
356
382
  }
357
- void this.#pump(friendId, st); // window slid → send more
383
+ this.#pump(friendId, st); // send new data
358
384
  }
359
- async #pump(friendId, st) {
360
- if (st.pumping)
361
- return;
362
- st.pumping = true;
363
- // Retransmit watchdog: if the ack hasn't advanced within RETRANSMIT_MS,
364
- // rewind the send cursor to the ack point (go-back-N) so the lost chunk and
365
- // anything after it are re-sent. Cumulative acks make this self-correcting.
385
+ #pump(friendId, st) {
386
+ // Watchdog: if the ack hasn't advanced within WATCHDOG_MS, a chunk (or the
387
+ // fast-retransmit) was lost → go-back-N resend from the ack point. Backstop
388
+ // to the dup-ack fast-retransmit above.
366
389
  if (!st.timer) {
367
390
  let stalledSinceMs = nowMs();
368
391
  st.timer = setInterval(() => {
@@ -375,57 +398,62 @@ export class FileTransferManager {
375
398
  }
376
399
  if (st.acked >= st.size)
377
400
  return;
378
- const sinceAck = nowMs() - st.lastAckAdvanceMs;
379
- if (sinceAck < RETRANSMIT_MS) {
401
+ if (nowMs() - st.lastAckAdvanceMs < WATCHDOG_MS) {
380
402
  stalledSinceMs = nowMs();
381
403
  return;
382
404
  }
383
- // Give up if the receiver has gone silent for too long (disconnected /
384
- // closed the app) rather than retransmitting forever.
385
405
  if (nowMs() - stalledSinceMs >= SEND_GIVEUP_MS) {
386
406
  this.#endSend(friendId, st.fileNumber);
387
407
  this.emit("file-cancel", { friendId, fileId: hex(st.fileId), sending: true, reason: "timeout" });
388
408
  return;
389
409
  }
390
- // Loss/stall detected → multiplicative decrease (back off), then resend
391
- // from the ack point. This is the AIMD that paces us to the path.
392
- st.ssthresh = Math.max(MIN_CWND, st.cwnd >> 1);
393
- st.cwnd = st.ssthresh;
394
410
  st.lastAckAdvanceMs = nowMs();
395
- void this.#pumpFrom(friendId, st, st.acked);
396
- }, Math.ceil(RETRANSMIT_MS / 2));
411
+ st.highestSent = st.acked; // go-back-N: re-send from the ack point
412
+ st.lastResendMs = nowMs();
413
+ this.#pumpFrom(friendId, st, st.acked);
414
+ }, WATCHDOG_MS);
397
415
  if (typeof st.timer.unref === "function")
398
416
  st.timer.unref();
399
417
  }
400
- try {
401
- await this.#pumpFrom(friendId, st, st.acked);
402
- }
403
- finally {
404
- st.pumping = false;
405
- }
418
+ // Send only NEW data, from the high-water mark up to the fixed window edge.
419
+ this.#pumpFrom(friendId, st, Math.max(st.acked, st.highestSent));
406
420
  }
407
- // Send chunks from `from` up to the congestion-window edge (acked + cwnd).
408
- async #pumpFrom(friendId, st, from) {
409
- const windowEnd = Math.min(st.acked + Math.max(MIN_CWND, st.cwnd | 0) * MAX_FILE_DATA_SIZE, st.size);
421
+ // Fire chunks from `from` up to the window edge. Fire-and-forget: the fixed
422
+ // window bounds in-flight bytes and the 4 MB socket buffer absorbs the burst,
423
+ // so we don't serialize on a per-chunk await (which capped throughput at one
424
+ // chunk per event-loop turn → minutes for a big file).
425
+ #pumpFrom(friendId, st, from) {
426
+ const windowEnd = Math.min(st.acked + WINDOW_CHUNKS * MAX_FILE_DATA_SIZE, st.size);
410
427
  let off = from;
411
428
  while (off < windowEnd) {
412
429
  if (!this.#map(this.#sending, friendId).has(st.fileNumber))
413
430
  return; // cancelled
414
431
  const end = Math.min(off + MAX_FILE_DATA_SIZE, st.size);
415
- const chunk = st.data.subarray(off, end);
416
- try {
417
- await this.send(friendId, PACKET_ID_FILE_DATA, encodeFileData(st.fileNumber, off, chunk));
418
- }
419
- catch {
420
- return; // transport down; the watchdog will retry from the ack point
421
- }
432
+ void this.send(friendId, PACKET_ID_FILE_DATA, encodeFileData(st.fileNumber, off, st.data.subarray(off, end))).catch(() => undefined);
433
+ off = end;
434
+ }
435
+ if (off > st.highestSent)
436
+ st.highestSent = off;
437
+ }
438
+ // Re-send the chunks in [from, to) — the gap region the receiver is missing.
439
+ // Already-received chunks are idempotent (placed by offset on the receiver).
440
+ #resendRange(friendId, st, from, to) {
441
+ let off = from;
442
+ const end0 = Math.min(to, st.size);
443
+ while (off < end0) {
444
+ if (!this.#map(this.#sending, friendId).has(st.fileNumber))
445
+ return;
446
+ const end = Math.min(off + MAX_FILE_DATA_SIZE, st.size);
447
+ void this.send(friendId, PACKET_ID_FILE_DATA, encodeFileData(st.fileNumber, off, st.data.subarray(off, end))).catch(() => undefined);
422
448
  off = end;
423
449
  }
424
450
  }
425
451
  #completeSend(friendId, st) {
452
+ const durationMs = nowMs() - st.startMs;
453
+ const mbps = durationMs > 0 ? st.size / 1024 / 1024 / (durationMs / 1000) : 0;
426
454
  this.#endSend(friendId, st.fileNumber);
427
455
  this.emit("file-progress", { friendId, fileId: hex(st.fileId), received: st.size, total: st.size, sending: true });
428
- this.emit("file-complete", { friendId, fileId: hex(st.fileId), name: st.name, size: st.size, sending: true });
456
+ this.emit("file-complete", { friendId, fileId: hex(st.fileId), name: st.name, size: st.size, sending: true, durationMs, mbps });
429
457
  }
430
458
  #endSend(friendId, fileNumber) {
431
459
  const st = this.#map(this.#sending, friendId).get(fileNumber);
@@ -433,6 +461,10 @@ export class FileTransferManager {
433
461
  clearInterval(st.timer);
434
462
  st.timer = undefined;
435
463
  }
464
+ if (st?.offerTimer) {
465
+ clearInterval(st.offerTimer);
466
+ st.offerTimer = undefined;
467
+ }
436
468
  this.#map(this.#sending, friendId).delete(fileNumber);
437
469
  }
438
470
  #endRecv(friendId, fileNumber) {
@@ -448,9 +480,12 @@ export class FileTransferManager {
448
480
  this.#map(this.#receiving, friendId).delete(fileNumber);
449
481
  }
450
482
  clearFriend(friendId) {
451
- for (const st of this.#map(this.#sending, friendId).values())
483
+ for (const st of this.#map(this.#sending, friendId).values()) {
452
484
  if (st.timer)
453
485
  clearInterval(st.timer);
486
+ if (st.offerTimer)
487
+ clearInterval(st.offerTimer);
488
+ }
454
489
  for (const st of this.#map(this.#receiving, friendId).values()) {
455
490
  if (st.ackTimer)
456
491
  clearTimeout(st.ackTimer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.53",
3
+ "version": "0.1.54",
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",