@decentnetwork/peer 0.1.74 → 0.1.76

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.
@@ -39,7 +39,16 @@ export const MAX_FILE_DATA_SIZE = 1367;
39
39
  // overhead handles bursty ~15% loss with margin; the retransmit path remains a
40
40
  // backstop for blocks that exceed FEC capacity.
41
41
  const FEC_K = 16;
42
- const FEC_M = 6;
42
+ // Parity is ADAPTIVE per block: the sender sizes it to the loss rate the
43
+ // receiver reports (piggybacked on each ack). A fixed M=6 tolerated only
44
+ // ~27% loss, so a 40%-loss path (measured on lili) blew past FEC capacity and
45
+ // fell back to go-back-N retransmit — ~1% efficient. Now the sender sends just
46
+ // enough parity that blocks recover LOCALLY at the current loss, so the
47
+ // expensive retransmit path almost never fires.
48
+ const FEC_M_MIN = 4; // parity the instant loss is first detected (before the report warms up)
49
+ const FEC_M_MAX = 16; // ceiling on parity per K=16 block (covers ~50% loss)
50
+ const FEC_MARGIN = 2; // extra shards over the estimate — absorbs burst variance
51
+ const FEC_SAFETY = 1.15; // over-provision the loss estimate by 15%
43
52
  // Adaptive: only spend parity bandwidth while the path is actually losing
44
53
  // packets (a dup-ack was seen within this window). A clean path sends zero
45
54
  // parity → no overhead; a lossy path (lili) gets full protection.
@@ -96,7 +105,7 @@ const CWND_MIN_CHUNKS = 8; // ~11 KB floor — keeps a slow/flaky path's buffer
96
105
  // ~0.5s of added latency keeps ping well under a second (fine — the peer stays
97
106
  // usable), and buys far more throughput on a jittery path than a tight 150ms
98
107
  // bound, which collapsed the window to the floor (~17 KB/s observed on lili).
99
- const MAX_QUEUE_MS = 350; // raw-queue ceiling before draining (bulk transfer tolerates ~0.35s added latency)
108
+ const MAX_QUEUE_MS = 350; // raw-queue ceiling before draining (bulk transfer tolerates ~0.35s added latency; a tighter bound collapses the window on a 40%-loss path where recovery makes RTT noisy)
100
109
  const QUEUE_LOW_MS = 150; // grow while the smoothed (jitter-free) queue is under this
101
110
  const VEGAS_GROW = 1.25; // window ×this per RTT when under-filled
102
111
  const VEGAS_SHRINK = 0.8; // window ×this per RTT when the queue is too deep
@@ -227,7 +236,7 @@ export class FileTransferManager {
227
236
  fileNumber, fileId, name: opts.name, data, size: data.length,
228
237
  acked: 0, nextSend: 0, pumping: false, started: false, req, startMs: nowMs(),
229
238
  lastProgressAcked: 0, lastAckAdvanceMs: nowMs(), lastResendMs: 0,
230
- cwnd: CWND_INIT_CHUNKS, stalls: 0, parityHighBlock: -1, lastLossMs: 0,
239
+ cwnd: CWND_INIT_CHUNKS, stalls: 0, parityHighBlock: -1, lastLossMs: 0, lossEwma: 0,
231
240
  minRttMs: Infinity, minSrttMs: Infinity, srttMs: 0, probeOffset: -1, probeSentMs: 0,
232
241
  };
233
242
  this.#map(this.#sending, friendId).set(fileNumber, st);
@@ -317,7 +326,7 @@ export class FileTransferManager {
317
326
  } // re-offer (our accept/ack lost) → re-ack
318
327
  const st = {
319
328
  fileNumber: r.fileNumber, fileId: r.fileId, name: r.filename, size,
320
- buf: new Uint8Array(size), got: new Uint8Array(chunkCount(size)),
329
+ buf: new Uint8Array(size), got: new Uint8Array(chunkCount(size)), netGot: new Uint8Array(chunkCount(size)), lossPctEwma: 0,
321
330
  contiguous: 0, maxByte: 0, persisted: 0, partPath: this.#partPath(r.fileId), fecParity: new Map(), done: false, ackDirty: false,
322
331
  };
323
332
  // 断点续传: if we persisted a contiguous prefix of this exact file (matched
@@ -334,8 +343,10 @@ export class FileTransferManager {
334
343
  if (usable > 0) {
335
344
  const partial = readFileSync(st.partPath);
336
345
  st.buf.set(partial.subarray(0, usable), 0);
337
- for (let i = 0; i < chunks; i++)
346
+ for (let i = 0; i < chunks; i++) {
338
347
  st.got[i] = 1;
348
+ st.netGot[i] = 1;
349
+ }
339
350
  st.contiguous = usable;
340
351
  st.maxByte = usable;
341
352
  st.persisted = usable;
@@ -416,6 +427,7 @@ export class FileTransferManager {
416
427
  return; // not a chunk boundary
417
428
  if (offset + data.length > st.maxByte)
418
429
  st.maxByte = offset + data.length;
430
+ st.netGot[idx] = 1; // genuine network arrival (even if a dup) → raw-loss denominator
419
431
  if (!st.got[idx]) {
420
432
  st.buf.set(data, offset);
421
433
  st.got[idx] = 1;
@@ -455,11 +467,11 @@ export class FileTransferManager {
455
467
  const st = this.#map(this.#receiving, friendId).get(f.fileNumber);
456
468
  if (!st || st.done)
457
469
  return;
458
- if (f.parity.length !== MAX_FILE_DATA_SIZE || f.parityIndex >= FEC_M)
470
+ if (f.parity.length !== MAX_FILE_DATA_SIZE || f.parityIndex >= FEC_M_MAX)
459
471
  return;
460
472
  let arr = st.fecParity.get(f.blockIndex);
461
473
  if (!arr) {
462
- arr = new Array(FEC_M).fill(undefined);
474
+ arr = new Array(FEC_M_MAX).fill(undefined);
463
475
  st.fecParity.set(f.blockIndex, arr);
464
476
  }
465
477
  if (!arr[f.parityIndex])
@@ -509,9 +521,9 @@ export class FileTransferManager {
509
521
  else
510
522
  shards.push(null);
511
523
  }
512
- for (let pi = 0; pi < FEC_M; pi++)
524
+ for (let pi = 0; pi < FEC_M_MAX; pi++)
513
525
  shards.push(parityArr?.[pi] ?? null);
514
- const recovered = rsDecode(shards, k, FEC_M, MAX_FILE_DATA_SIZE);
526
+ const recovered = rsDecode(shards, k, FEC_M_MAX, MAX_FILE_DATA_SIZE);
515
527
  if (!recovered)
516
528
  return;
517
529
  for (const i of missing) {
@@ -586,14 +598,37 @@ export class FileTransferManager {
586
598
  this.#sendAck(friendId, st);
587
599
  }, ACK_THROTTLE_MS);
588
600
  }
601
+ // Raw network loss (0..100) behind the frontier: the fraction of chunk slots
602
+ // between the contiguous prefix and the highest byte seen that have NOT arrived
603
+ // over the network (netGot=0). FEC-recovered holes still count as losses here
604
+ // (netGot stays 0), so the estimate doesn't collapse to zero the moment FEC
605
+ // patches a block — which would starve parity and reopen the hole. Self-
606
+ // correcting: under-provisioned parity leaves holes → the window fills with
607
+ // gaps → the reported loss rises → the sender adds parity.
608
+ #measureLoss(st) {
609
+ const lo = Math.floor(st.contiguous / MAX_FILE_DATA_SIZE);
610
+ const hi = Math.min(st.got.length, Math.ceil(st.maxByte / MAX_FILE_DATA_SIZE));
611
+ let span = 0, holes = 0;
612
+ for (let i = lo; i < hi && span < 4096; i++) {
613
+ span++;
614
+ if (!st.netGot[i])
615
+ holes++;
616
+ }
617
+ const inst = span > 0 ? holes / span : 0;
618
+ st.lossPctEwma = st.lossPctEwma === 0 ? inst : 0.7 * st.lossPctEwma + 0.3 * inst;
619
+ return Math.max(0, Math.min(100, Math.round(st.lossPctEwma * 100)));
620
+ }
589
621
  #sendAck(friendId, st) {
590
622
  st.ackDirty = false;
591
623
  // send_receive=1: the ACK refers to a file the PEER is SENDING (it lives in
592
- // the peer's #sending table). Carries [contiguous][maxByte] so the sender
593
- // sees the gap (maxByte contiguous) = loss/reorder, for congestion control.
594
- const body = new Uint8Array(8);
624
+ // the peer's #sending table). Carries [contiguous][maxByte][lossPct] so the
625
+ // sender sees the gap (for congestion control) and the raw loss rate (to size
626
+ // adaptive FEC parity). The 9th byte is additive — a pre-0.1.75 sender that
627
+ // reads only 8 bytes just ignores it.
628
+ const body = new Uint8Array(9);
595
629
  body.set(u32be(st.contiguous), 0);
596
630
  body.set(u32be(st.maxByte), 4);
631
+ body[8] = this.#measureLoss(st);
597
632
  void this.send(friendId, PACKET_ID_FILE_CONTROL, encodeFileControl(1, st.fileNumber, FILECONTROL.ACK, body)).catch(() => undefined);
598
633
  }
599
634
  // ── sender ────────────────────────────────────────────────────────
@@ -602,6 +637,12 @@ export class FileTransferManager {
602
637
  return;
603
638
  const ackedOffset = readU32be(data, 0);
604
639
  const maxRecv = data.length >= 8 ? readU32be(data, 4) : ackedOffset;
640
+ // Receiver-reported raw loss (0..100) → drives adaptive FEC parity. Lightly
641
+ // re-smoothed here so a single ack can't swing the parity budget.
642
+ if (data.length >= 9) {
643
+ const reported = data[8] / 100;
644
+ st.lossEwma = 0.6 * st.lossEwma + 0.4 * reported;
645
+ }
605
646
  if (ackedOffset > st.acked) {
606
647
  // Progress. The path is delivering → additively re-open the window (up to
607
648
  // the cap) and clear the stall streak (patience shrinks back to the base).
@@ -633,12 +674,8 @@ export class FileTransferManager {
633
674
  if (st.srttMs < st.minSrttMs)
634
675
  st.minSrttMs = st.srttMs;
635
676
  // Hybrid signal, so per-packet JITTER doesn't masquerade as a queue:
636
- // • GROW on the SMOOTHED queue (srtt − minSrtt): jitter averages out,
637
- // so a flaky path that isn't really queuing keeps growing instead
638
- // of collapsing to the floor (~17 KB/s observed on lili).
639
- // • SHRINK on the RAW queue (rtt − minRtt) against a higher ceiling:
640
- // fast reaction to genuine bufferbloat, but the ceiling sits above
641
- // the jitter band so a lone jittery sample doesn't trigger it.
677
+ // • GROW on the SMOOTHED queue (srtt − minSrtt): jitter averages out.
678
+ // SHRINK on the RAW queue (rtt minRtt) against a higher ceiling.
642
679
  const smoothQueue = st.minSrttMs !== Infinity ? st.srttMs - st.minSrttMs : 0;
643
680
  const rawQueue = st.minRttMs !== Infinity ? rtt - st.minRttMs : 0;
644
681
  if (rawQueue > MAX_QUEUE_MS) {
@@ -654,11 +691,16 @@ export class FileTransferManager {
654
691
  this.emit("file-progress", { friendId, fileId: hex(st.fileId), received: st.acked, total: st.size, sending: true });
655
692
  }
656
693
  }
657
- else if (maxRecv > st.acked + MAX_FILE_DATA_SIZE && nowMs() - st.lastResendMs >= FAST_RT_MIN_MS) {
658
- // Duplicate ack WITH a gap the chunk at `acked` was lost. Rewind the send
659
- // cursor to the ack point so the loop re-sends the hole (rate-limited to
660
- // ~1 RTT so dup-ack bursts for one loss don't storm). No window backoff:
661
- // file-path loss is treated as random, not congestion.
694
+ else if (maxRecv > st.acked + Math.max(4, st.cwnd >> 2) * MAX_FILE_DATA_SIZE && nowMs() - st.lastResendMs >= FAST_RT_MIN_MS) {
695
+ // Duplicate ack WITH a gap bigger than the REORDER window (≈¼ of the flight)
696
+ // the chunk at `acked` is genuinely lost, not just reordered. Requiring a
697
+ // real gap is what unlocked throughput on jittery GFW paths: a 1-chunk
698
+ // trigger fired constantly on per-packet jitter (packets arrive out of
699
+ // order), each firing a go-back-N rewind that re-sent bytes the receiver
700
+ // already had and pinned the window near the floor. Now FEC repairs the
701
+ // occasional true hole locally, so waiting a few chunks before retransmit
702
+ // costs nothing. Rewind the send cursor to the hole (rate-limited to ~1 RTT
703
+ // so a dup-ack burst can't storm). No window backoff: loss ≠ congestion.
662
704
  st.lastResendMs = nowMs();
663
705
  st.lastLossMs = nowMs(); // the path is lossy → turn adaptive FEC on
664
706
  if (st.nextSend > st.acked)
@@ -773,7 +815,14 @@ export class FileTransferManager {
773
815
  st.parityHighBlock = b;
774
816
  continue;
775
817
  } // clean path → skip parity
776
- // Build the k data shards (zero-padded to a full chunk) and encode M parity.
818
+ // Size parity to the reported loss p: to land k of (k+m) shards through a
819
+ // path dropping fraction p, need k+m ≥ k/(1−p) → m ≥ k·p/(1−p). Over-
820
+ // provision (SAFETY) and add a fixed MARGIN for burst variance; a fresh
821
+ // dup-ack (report not yet in) still gets FEC_M_MIN so we react immediately.
822
+ const p = Math.min(0.6, st.lossEwma); // cap so the divisor can't explode
823
+ const mNeed = Math.ceil((kActual * p / (1 - p)) * FEC_SAFETY) + FEC_MARGIN;
824
+ const mSend = Math.max(FEC_M_MIN, Math.min(FEC_M_MAX, mNeed));
825
+ // Build the k data shards (zero-padded to a full chunk) and encode mSend parity.
777
826
  const shards = [];
778
827
  for (let i = 0; i < kActual; i++) {
779
828
  const off = (b * FEC_K + i) * MAX_FILE_DATA_SIZE;
@@ -786,7 +835,7 @@ export class FileTransferManager {
786
835
  shards.push(padded);
787
836
  }
788
837
  }
789
- const parity = rsEncode(shards, FEC_M);
838
+ const parity = rsEncode(shards, mSend);
790
839
  for (let pi = 0; pi < parity.length; pi++) {
791
840
  void this.send(friendId, PACKET_ID_FILE_FEC, encodeFec(st.fileNumber, b, pi, parity[pi])).catch(() => undefined);
792
841
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.74",
3
+ "version": "0.1.76",
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",