@decentnetwork/peer 0.1.52 → 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,10 +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 tuning.
34
- const WINDOW_CHUNKS = 192; // chunks in flight (≈ 256 KB) for throughput on high-BDP paths
35
- const RETRANSMIT_MS = 600; // resend window from the ack point if no progress in this long
36
- 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
37
45
  const SEND_GIVEUP_MS = 60000; // abandon a send after this long with no ack progress (peer gone)
38
46
  function u32be(n) {
39
47
  const b = new Uint8Array(4);
@@ -127,13 +135,34 @@ export class FileTransferManager {
127
135
  if (fileNumber < 0)
128
136
  return null;
129
137
  const fileId = randomBytes(FILE_ID_LENGTH);
138
+ const req = encodeFileSendRequest(fileNumber, opts.kind ?? FILEKIND.DATA, BigInt(data.length), fileId, opts.name);
130
139
  const st = {
131
140
  fileNumber, fileId, name: opts.name, data, size: data.length,
132
- acked: 0, pumping: false, lastProgressAcked: 0, lastAckAdvanceMs: nowMs(),
141
+ acked: 0, highestSent: 0, started: false, req, startMs: nowMs(),
142
+ lastProgressAcked: 0, lastAckAdvanceMs: nowMs(), lastResendMs: 0,
133
143
  };
134
144
  this.#map(this.#sending, friendId).set(fileNumber, st);
135
- const req = encodeFileSendRequest(fileNumber, opts.kind ?? FILEKIND.DATA, BigInt(data.length), fileId, opts.name);
136
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();
137
166
  return hex(fileId);
138
167
  }
139
168
  accept(friendId, fileNumber) {
@@ -155,7 +184,7 @@ export class FileTransferManager {
155
184
  return;
156
185
  }
157
186
  this.#sendAck(friendId, cur);
158
- }, RETRANSMIT_MS);
187
+ }, REACK_MS);
159
188
  if (typeof st.reackTimer.unref === "function")
160
189
  st.reackTimer.unref();
161
190
  }
@@ -189,12 +218,14 @@ export class FileTransferManager {
189
218
  return;
190
219
  const size = Number(r.fileSize);
191
220
  const existing = this.#map(this.#receiving, friendId).get(r.fileNumber);
192
- if (existing && !existing.done)
193
- 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
194
225
  const st = {
195
226
  fileNumber: r.fileNumber, fileId: r.fileId, name: r.filename, size,
196
227
  buf: new Uint8Array(size), got: new Uint8Array(chunkCount(size)),
197
- contiguous: 0, done: false, ackDirty: false,
228
+ contiguous: 0, maxByte: 0, done: false, ackDirty: false,
198
229
  };
199
230
  this.#map(this.#receiving, friendId).set(r.fileNumber, st);
200
231
  this.emit("file-offer", { friendId, fileNumber: r.fileNumber, fileId: hex(r.fileId), name: r.filename, size, kind: r.fileType });
@@ -210,8 +241,17 @@ export class FileTransferManager {
210
241
  const st = this.#map(this.#sending, friendId).get(c.fileNumber);
211
242
  if (!st)
212
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
+ }
213
253
  if (c.controlType === FILECONTROL.ACCEPT) {
214
- void this.#pump(friendId, st);
254
+ this.#pump(friendId, st);
215
255
  }
216
256
  else if (c.controlType === FILECONTROL.ACK) {
217
257
  this.#onAck(friendId, st, c.data);
@@ -249,6 +289,8 @@ export class FileTransferManager {
249
289
  const idx = Math.floor(offset / MAX_FILE_DATA_SIZE);
250
290
  if (offset % MAX_FILE_DATA_SIZE !== 0 || idx >= st.got.length)
251
291
  return; // not a chunk boundary
292
+ if (offset + data.length > st.maxByte)
293
+ st.maxByte = offset + data.length;
252
294
  if (!st.got[idx]) {
253
295
  st.buf.set(data, offset);
254
296
  st.got[idx] = 1;
@@ -304,35 +346,46 @@ export class FileTransferManager {
304
346
  #sendAck(friendId, st) {
305
347
  st.ackDirty = false;
306
348
  // send_receive=1: the ACK refers to a file the PEER is SENDING (it lives in
307
- // the peer's #sending table), so its #onControl routes it there.
308
- void this.send(friendId, PACKET_ID_FILE_CONTROL, encodeFileControl(1, st.fileNumber, FILECONTROL.ACK, u32be(st.contiguous))).catch(() => undefined);
349
+ // the peer's #sending table). Carries [contiguous][maxByte] so the sender
350
+ // sees the gap (maxByte contiguous) = loss/reorder, for congestion control.
351
+ const body = new Uint8Array(8);
352
+ body.set(u32be(st.contiguous), 0);
353
+ body.set(u32be(st.maxByte), 4);
354
+ void this.send(friendId, PACKET_ID_FILE_CONTROL, encodeFileControl(1, st.fileNumber, FILECONTROL.ACK, body)).catch(() => undefined);
309
355
  }
310
356
  // ── sender ────────────────────────────────────────────────────────
311
357
  #onAck(friendId, st, data) {
312
358
  if (data.length < 4)
313
359
  return;
314
360
  const ackedOffset = readU32be(data, 0);
361
+ const maxRecv = data.length >= 8 ? readU32be(data, 4) : ackedOffset;
315
362
  if (ackedOffset > st.acked) {
363
+ // Progress.
316
364
  st.acked = Math.min(ackedOffset, st.size);
317
365
  st.lastAckAdvanceMs = nowMs();
318
- if (st.acked - st.lastProgressAcked >= 64 * 1024 || st.acked >= st.size) {
366
+ if (st.acked - st.lastProgressAcked >= 256 * 1024 || st.acked >= st.size) {
319
367
  st.lastProgressAcked = st.acked;
320
368
  this.emit("file-progress", { friendId, fileId: hex(st.fileId), received: st.acked, total: st.size, sending: true });
321
369
  }
322
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
+ }
323
379
  if (st.acked >= st.size) {
324
380
  this.#completeSend(friendId, st);
325
381
  return;
326
382
  }
327
- void this.#pump(friendId, st); // window slid → send more
383
+ this.#pump(friendId, st); // send new data
328
384
  }
329
- async #pump(friendId, st) {
330
- if (st.pumping)
331
- return;
332
- st.pumping = true;
333
- // Retransmit watchdog: if the ack hasn't advanced within RETRANSMIT_MS,
334
- // rewind the send cursor to the ack point (go-back-N) so the lost chunk and
335
- // 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.
336
389
  if (!st.timer) {
337
390
  let stalledSinceMs = nowMs();
338
391
  st.timer = setInterval(() => {
@@ -345,53 +398,62 @@ export class FileTransferManager {
345
398
  }
346
399
  if (st.acked >= st.size)
347
400
  return;
348
- const sinceAck = nowMs() - st.lastAckAdvanceMs;
349
- if (sinceAck < RETRANSMIT_MS) {
401
+ if (nowMs() - st.lastAckAdvanceMs < WATCHDOG_MS) {
350
402
  stalledSinceMs = nowMs();
351
403
  return;
352
404
  }
353
- // Give up if the receiver has gone silent for too long (disconnected /
354
- // closed the app) rather than retransmitting forever.
355
405
  if (nowMs() - stalledSinceMs >= SEND_GIVEUP_MS) {
356
406
  this.#endSend(friendId, st.fileNumber);
357
407
  this.emit("file-cancel", { friendId, fileId: hex(st.fileId), sending: true, reason: "timeout" });
358
408
  return;
359
409
  }
360
410
  st.lastAckAdvanceMs = nowMs();
361
- void this.#pumpFrom(friendId, st, st.acked);
362
- }, 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);
363
415
  if (typeof st.timer.unref === "function")
364
416
  st.timer.unref();
365
417
  }
366
- try {
367
- await this.#pumpFrom(friendId, st, st.acked);
368
- }
369
- finally {
370
- st.pumping = false;
371
- }
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));
372
420
  }
373
- // Send chunks from `from` up to the window edge (acked + window).
374
- async #pumpFrom(friendId, st, from) {
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) {
375
426
  const windowEnd = Math.min(st.acked + WINDOW_CHUNKS * MAX_FILE_DATA_SIZE, st.size);
376
427
  let off = from;
377
428
  while (off < windowEnd) {
378
429
  if (!this.#map(this.#sending, friendId).has(st.fileNumber))
379
430
  return; // cancelled
380
431
  const end = Math.min(off + MAX_FILE_DATA_SIZE, st.size);
381
- const chunk = st.data.subarray(off, end);
382
- try {
383
- await this.send(friendId, PACKET_ID_FILE_DATA, encodeFileData(st.fileNumber, off, chunk));
384
- }
385
- catch {
386
- return; // transport down; the watchdog will retry from the ack point
387
- }
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);
388
448
  off = end;
389
449
  }
390
450
  }
391
451
  #completeSend(friendId, st) {
452
+ const durationMs = nowMs() - st.startMs;
453
+ const mbps = durationMs > 0 ? st.size / 1024 / 1024 / (durationMs / 1000) : 0;
392
454
  this.#endSend(friendId, st.fileNumber);
393
455
  this.emit("file-progress", { friendId, fileId: hex(st.fileId), received: st.size, total: st.size, sending: true });
394
- 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 });
395
457
  }
396
458
  #endSend(friendId, fileNumber) {
397
459
  const st = this.#map(this.#sending, friendId).get(fileNumber);
@@ -399,6 +461,10 @@ export class FileTransferManager {
399
461
  clearInterval(st.timer);
400
462
  st.timer = undefined;
401
463
  }
464
+ if (st?.offerTimer) {
465
+ clearInterval(st.offerTimer);
466
+ st.offerTimer = undefined;
467
+ }
402
468
  this.#map(this.#sending, friendId).delete(fileNumber);
403
469
  }
404
470
  #endRecv(friendId, fileNumber) {
@@ -414,9 +480,12 @@ export class FileTransferManager {
414
480
  this.#map(this.#receiving, friendId).delete(fileNumber);
415
481
  }
416
482
  clearFriend(friendId) {
417
- for (const st of this.#map(this.#sending, friendId).values())
483
+ for (const st of this.#map(this.#sending, friendId).values()) {
418
484
  if (st.timer)
419
485
  clearInterval(st.timer);
486
+ if (st.offerTimer)
487
+ clearInterval(st.offerTimer);
488
+ }
420
489
  for (const st of this.#map(this.#receiving, friendId).values()) {
421
490
  if (st.ackTimer)
422
491
  clearTimeout(st.ackTimer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.52",
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",