@decentnetwork/peer 0.1.53 → 0.1.55

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, nextSend: 0, pumping: false, 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,37 @@ 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. Rewind the send
373
+ // cursor to the ack point so the loop re-sends the hole (rate-limited to
374
+ // ~1 RTT so dup-ack bursts for one loss don't storm). No window backoff:
375
+ // file-path loss is treated as random, not congestion.
376
+ st.lastResendMs = nowMs();
377
+ if (st.nextSend > st.acked)
378
+ st.nextSend = st.acked;
379
+ }
353
380
  if (st.acked >= st.size) {
354
381
  this.#completeSend(friendId, st);
355
382
  return;
356
383
  }
357
- void this.#pump(friendId, st); // window slid → send more
384
+ void this.#pump(friendId, st);
358
385
  }
386
+ // Single send loop. Awaits each send so net_crypto / the UDP socket applies
387
+ // backpressure (fire-and-forget blasted the whole window into the macOS UDP
388
+ // buffer at once → systematic overflow/drops the retransmit couldn't dig out
389
+ // of → a big file "failed"). Sends from `nextSend`, which the ack handler and
390
+ // watchdog rewind to `acked` to retransmit a hole (go-back-N). The fixed
391
+ // window bounds in-flight bytes; throughput self-clocks to the path.
359
392
  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.
366
393
  if (!st.timer) {
367
394
  let stalledSinceMs = nowMs();
368
395
  st.timer = setInterval(() => {
@@ -375,57 +402,54 @@ export class FileTransferManager {
375
402
  }
376
403
  if (st.acked >= st.size)
377
404
  return;
378
- const sinceAck = nowMs() - st.lastAckAdvanceMs;
379
- if (sinceAck < RETRANSMIT_MS) {
405
+ if (nowMs() - st.lastAckAdvanceMs < WATCHDOG_MS) {
380
406
  stalledSinceMs = nowMs();
381
407
  return;
382
408
  }
383
- // Give up if the receiver has gone silent for too long (disconnected /
384
- // closed the app) rather than retransmitting forever.
385
409
  if (nowMs() - stalledSinceMs >= SEND_GIVEUP_MS) {
386
410
  this.#endSend(friendId, st.fileNumber);
387
411
  this.emit("file-cancel", { friendId, fileId: hex(st.fileId), sending: true, reason: "timeout" });
388
412
  return;
389
413
  }
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
414
  st.lastAckAdvanceMs = nowMs();
395
- void this.#pumpFrom(friendId, st, st.acked);
396
- }, Math.ceil(RETRANSMIT_MS / 2));
415
+ st.lastResendMs = nowMs();
416
+ if (st.nextSend > st.acked)
417
+ st.nextSend = st.acked; // go-back-N
418
+ void this.#pump(friendId, st);
419
+ }, WATCHDOG_MS);
397
420
  if (typeof st.timer.unref === "function")
398
421
  st.timer.unref();
399
422
  }
423
+ if (st.pumping)
424
+ return;
425
+ st.pumping = true;
400
426
  try {
401
- await this.#pumpFrom(friendId, st, st.acked);
427
+ 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);
429
+ if (st.nextSend >= windowEnd)
430
+ break; // window full / all sent — wait for acks
431
+ const off = st.nextSend;
432
+ const end = Math.min(off + MAX_FILE_DATA_SIZE, st.size);
433
+ try {
434
+ await this.send(friendId, PACKET_ID_FILE_DATA, encodeFileData(st.fileNumber, off, st.data.subarray(off, end)));
435
+ }
436
+ catch {
437
+ break; // transport down; the watchdog re-kicks
438
+ }
439
+ if (st.nextSend === off)
440
+ st.nextSend = end; // unless a rewind moved it
441
+ }
402
442
  }
403
443
  finally {
404
444
  st.pumping = false;
405
445
  }
406
446
  }
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);
410
- let off = from;
411
- while (off < windowEnd) {
412
- if (!this.#map(this.#sending, friendId).has(st.fileNumber))
413
- return; // cancelled
414
- 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
- }
422
- off = end;
423
- }
424
- }
425
447
  #completeSend(friendId, st) {
448
+ const durationMs = nowMs() - st.startMs;
449
+ const mbps = durationMs > 0 ? st.size / 1024 / 1024 / (durationMs / 1000) : 0;
426
450
  this.#endSend(friendId, st.fileNumber);
427
451
  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 });
452
+ this.emit("file-complete", { friendId, fileId: hex(st.fileId), name: st.name, size: st.size, sending: true, durationMs, mbps });
429
453
  }
430
454
  #endSend(friendId, fileNumber) {
431
455
  const st = this.#map(this.#sending, friendId).get(fileNumber);
@@ -433,6 +457,10 @@ export class FileTransferManager {
433
457
  clearInterval(st.timer);
434
458
  st.timer = undefined;
435
459
  }
460
+ if (st?.offerTimer) {
461
+ clearInterval(st.offerTimer);
462
+ st.offerTimer = undefined;
463
+ }
436
464
  this.#map(this.#sending, friendId).delete(fileNumber);
437
465
  }
438
466
  #endRecv(friendId, fileNumber) {
@@ -448,9 +476,12 @@ export class FileTransferManager {
448
476
  this.#map(this.#receiving, friendId).delete(fileNumber);
449
477
  }
450
478
  clearFriend(friendId) {
451
- for (const st of this.#map(this.#sending, friendId).values())
479
+ for (const st of this.#map(this.#sending, friendId).values()) {
452
480
  if (st.timer)
453
481
  clearInterval(st.timer);
482
+ if (st.offerTimer)
483
+ clearInterval(st.offerTimer);
484
+ }
454
485
  for (const st of this.#map(this.#receiving, friendId).values()) {
455
486
  if (st.ackTimer)
456
487
  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.55",
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",