@decentnetwork/peer 0.1.76 → 0.1.78

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.
@@ -109,6 +109,19 @@ const MAX_QUEUE_MS = 350; // raw-queue ceiling before draining (bulk transfer to
109
109
  const QUEUE_LOW_MS = 150; // grow while the smoothed (jitter-free) queue is under this
110
110
  const VEGAS_GROW = 1.25; // window ×this per RTT when under-filled
111
111
  const VEGAS_SHRINK = 0.8; // window ×this per RTT when the queue is too deep
112
+ // PACED control (BBR-style). The window-based Vegas controller blasted the whole
113
+ // cwnd into the buffer each round, which on a DEEP buffer (the TCP relay's
114
+ // China↔US queue) piled up to 7-15 s of latency — the feedback loop is longer
115
+ // than a human's patience, so it can't react. The fix is to PACE: send at the
116
+ // estimated bottleneck bandwidth so the buffer stays near-empty regardless of
117
+ // its depth, and RAMP the rate in a startup phase to fill the pipe for speed.
118
+ const PACE_STARTUP_GAIN = 2.0; // startup: pace at 2× measured rate to probe up fast (exits when bandwidth plateaus)
119
+ const PACE_CYCLE_GAINS = [1.25, 0.8, 1, 1, 1, 1, 1, 1]; // steady PROBE_BW cycle: one round up, one round DRAINS the queue, six cruise → avg≈bandwidth, shallow queue
120
+ const PACE_FULLBW_GROWTH = 1.20; // bandwidth still "rising" if ≥20% over the plateau mark
121
+ const PACE_FULLBW_ROUNDS = 3; // leave startup after this many non-rising rounds
122
+ const PACE_BW_WINDOW = 10; // rounds of delivered-rate history for the max-filter
123
+ const PACE_INIT_BPS = 120_000; // initial pace rate (~120 KB/s) before any bandwidth estimate
124
+ const PACE_BURST_MS = 20; // token-bucket burst allowance (keeps sends smooth, not bunched)
112
125
  const WATCHDOG_MS = 150; // stall poll cadence / base no-progress patience
113
126
  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)
114
127
  const FAST_RT_MIN_MS = 40; // min gap between fast-retransmits of the same hole (≈1 RTT)
@@ -238,6 +251,9 @@ export class FileTransferManager {
238
251
  lastProgressAcked: 0, lastAckAdvanceMs: nowMs(), lastResendMs: 0,
239
252
  cwnd: CWND_INIT_CHUNKS, stalls: 0, parityHighBlock: -1, lastLossMs: 0, lossEwma: 0,
240
253
  minRttMs: Infinity, minSrttMs: Infinity, srttMs: 0, probeOffset: -1, probeSentMs: 0,
254
+ paceRateBps: PACE_INIT_BPS, paceTokens: 0, lastPaceMs: nowMs(),
255
+ btlBwBps: 0, bwSamples: [], fullBwBps: 0, fullBwRounds: 0, bbrPhase: 0, roundCount: 0,
256
+ rateMarkMs: nowMs(), rateMarkAcked: 0,
241
257
  };
242
258
  this.#map(this.#sending, friendId).set(fileNumber, st);
243
259
  void this.send(friendId, PACKET_ID_FILE_SENDREQUEST, req).catch(() => undefined);
@@ -660,30 +676,62 @@ export class FileTransferManager {
660
676
  // reported, overshooting). Estimate chunks queued from RTT inflation and
661
677
  // steer to keep it in [ALPHA, BETA].
662
678
  if (st.probeOffset >= 0 && st.acked >= st.probeOffset) {
679
+ // One paced round completed (the timed frontier byte's ack landed).
663
680
  const rtt = nowT - st.probeSentMs;
664
681
  st.probeOffset = -1;
665
- // Reject glitch samples: a batched/late ack can make a probe byte look
666
- // acked far sooner than it was really delivered, producing an RTT far
667
- // below the smoothed value which would corrupt minRtt and wreck the
668
- // queue estimate. Only trust a sample within a sane band of srtt.
682
+ // Reject glitch samples: a batched / late / resume ack can make a probe
683
+ // byte look acked far sooner than it really was, giving a tiny rtt that
684
+ // would corrupt minRtt (RTprop) shrink the BDP and collapse cwnd to the
685
+ // floor. Only trust a sample within a sane band of the smoothed RTT.
669
686
  const glitch = st.srttMs > 0 && rtt < st.srttMs * 0.4;
670
687
  if (!glitch && rtt > 0) {
688
+ st.roundCount++;
689
+ // RTprop = min RTT ever seen (loss/queue only RAISE rtt → min is robust).
671
690
  st.srttMs = st.srttMs === 0 ? rtt : 0.85 * st.srttMs + 0.15 * rtt;
672
691
  if (rtt < st.minRttMs)
673
692
  st.minRttMs = rtt;
674
- if (st.srttMs < st.minSrttMs)
675
- st.minSrttMs = st.srttMs;
676
- // Hybrid signal, so per-packet JITTER doesn't masquerade as a queue:
677
- // • GROW on the SMOOTHED queue (srtt minSrtt): jitter averages out.
678
- // • SHRINK on the RAW queue (rtt minRtt) against a higher ceiling.
679
- const smoothQueue = st.minSrttMs !== Infinity ? st.srttMs - st.minSrttMs : 0;
680
- const rawQueue = st.minRttMs !== Infinity ? rtt - st.minRttMs : 0;
681
- if (rawQueue > MAX_QUEUE_MS) {
682
- st.cwnd = Math.max(CWND_MIN_CHUNKS, Math.floor(st.cwnd * VEGAS_SHRINK)); // real queue too deep → drain
693
+ // BtlBw = max-filtered delivered GOODPUT rate. Loss/reorder ignored as a
694
+ // rate signal (they don't reduce bytes/sec; FEC + reorder-tolerant
695
+ // retransmit fix correctness), so the rate model never collapses.
696
+ const dtSec = Math.max(0.05, (nowT - st.rateMarkMs) / 1000);
697
+ const roundRate = (st.acked - st.rateMarkAcked) / dtSec;
698
+ st.rateMarkMs = nowT;
699
+ st.rateMarkAcked = st.acked;
700
+ if (roundRate > 0) {
701
+ st.bwSamples.push(roundRate);
702
+ if (st.bwSamples.length > PACE_BW_WINDOW)
703
+ st.bwSamples.shift();
704
+ st.btlBwBps = Math.max(...st.bwSamples);
683
705
  }
684
- else if (smoothQueue < QUEUE_LOW_MS) {
685
- st.cwnd = Math.min(WINDOW_CHUNKS, Math.ceil(st.cwnd * VEGAS_GROW)); // spare capacity grow
686
- } // else hold
706
+ // Wire carries goodput + FEC parity + retransmits, so to fill the LINK
707
+ // we pace above goodput by the loss overhead: wire = goodput/(1 − loss).
708
+ const lossMult = 1 / (1 - Math.min(0.5, st.lossEwma));
709
+ // Two-phase paced control, tuned for STABILITY first (never bloat).
710
+ if (st.bbrPhase === 0) {
711
+ // STARTUP: ramp ×1.4/round until a real queue builds (rtt ≥1.35×RTprop
712
+ // → pipe full), then cruise. The queue-based exit keeps latency
713
+ // bounded: on a lossy path loss-recovery trips it sooner, so the fill
714
+ // is slower — but it NEVER bloats. That's the right trade — fast on a
715
+ // good path, graceful (stable, not 15 s of queue) on a bad one.
716
+ const rawQueue = st.minRttMs !== Infinity ? rtt - st.minRttMs : 0;
717
+ if (st.minRttMs !== Infinity && rawQueue > st.minRttMs * 0.35)
718
+ st.bbrPhase = 2;
719
+ st.paceRateBps = Math.max(st.paceRateBps * 1.4, st.btlBwBps * PACE_STARTUP_GAIN * lossMult);
720
+ }
721
+ else {
722
+ // PROBE_BW cruise: one round probes up, one DRAINS (0.8×), six at 1× →
723
+ // the AVERAGE pace is exactly bandwidth, so the queue stays shallow.
724
+ const gain = PACE_CYCLE_GAINS[st.roundCount % PACE_CYCLE_GAINS.length];
725
+ st.paceRateBps = Math.max(PACE_INIT_BPS * 0.5, st.btlBwBps * gain * lossMult);
726
+ } // Hard safety cap: never pace faster than a full window drains in 1 RTT.
727
+ if (st.minRttMs !== Infinity && st.minRttMs > 0) {
728
+ st.paceRateBps = Math.min(st.paceRateBps, (WINDOW_CHUNKS * MAX_FILE_DATA_SIZE) / (st.minRttMs / 1000));
729
+ }
730
+ // cwnd = SAFETY ceiling sized to the PACE rate (2×paceRate×RTprop), NOT
731
+ // the measured bandwidth (which would self-limit). Pacing is primary.
732
+ const paceBdp = (st.minRttMs !== Infinity && st.minRttMs > 0)
733
+ ? (st.paceRateBps * (st.minRttMs / 1000)) / MAX_FILE_DATA_SIZE : WINDOW_CHUNKS;
734
+ st.cwnd = Math.max(CWND_MIN_CHUNKS, Math.min(WINDOW_CHUNKS, Math.ceil(paceBdp * 2)));
687
735
  }
688
736
  }
689
737
  if (st.acked - st.lastProgressAcked >= 256 * 1024 || st.acked >= st.size) {
@@ -703,8 +751,7 @@ export class FileTransferManager {
703
751
  // so a dup-ack burst can't storm). No window backoff: loss ≠ congestion.
704
752
  st.lastResendMs = nowMs();
705
753
  st.lastLossMs = nowMs(); // the path is lossy → turn adaptive FEC on
706
- if (st.nextSend > st.acked)
707
- st.nextSend = st.acked;
754
+ this.#rewindRetransmit(st);
708
755
  }
709
756
  if (st.acked >= st.size) {
710
757
  this.#completeSend(friendId, st);
@@ -712,6 +759,21 @@ export class FileTransferManager {
712
759
  }
713
760
  void this.#pump(friendId, st);
714
761
  }
762
+ // Rewind for a go-back-N retransmit: move the send cursor back to the ack point
763
+ // AND rewind the FEC high-water mark so the re-sent blocks get FRESH parity.
764
+ // Without the parity rewind a lossy TAIL freezes forever — re-sending the
765
+ // region drops new chunks faster than it fills the old holes, and the receiver
766
+ // has no parity to recover them locally (parity was emitted once, long gone).
767
+ // Re-emitting parity lets the receiver FEC-recover the holes, so the tail
768
+ // converges instead of stalling at 93% (observed on 100 MB+ files).
769
+ #rewindRetransmit(st) {
770
+ if (st.nextSend > st.acked)
771
+ st.nextSend = st.acked;
772
+ const ackBlock = Math.floor(st.acked / MAX_FILE_DATA_SIZE / FEC_K);
773
+ if (ackBlock - 1 < st.parityHighBlock)
774
+ st.parityHighBlock = ackBlock - 1;
775
+ st.lastLossMs = nowMs(); // keep FEC ON so the re-emit actually fires
776
+ }
715
777
  // Single send loop. Awaits each send so net_crypto / the UDP socket applies
716
778
  // backpressure (fire-and-forget blasted the whole window into the macOS UDP
717
779
  // buffer at once → systematic overflow/drops the retransmit couldn't dig out
@@ -755,8 +817,7 @@ export class FileTransferManager {
755
817
  st.cwnd = Math.max(CWND_MIN_CHUNKS, Math.floor(st.cwnd / 2));
756
818
  st.lastAckAdvanceMs = nowMs();
757
819
  st.lastResendMs = nowMs();
758
- if (st.nextSend > st.acked)
759
- st.nextSend = st.acked; // go-back-N
820
+ this.#rewindRetransmit(st); // go-back-N + re-emit tail parity
760
821
  void this.#pump(friendId, st);
761
822
  }, WATCHDOG_MS);
762
823
  if (typeof st.timer.unref === "function")
@@ -769,7 +830,18 @@ export class FileTransferManager {
769
830
  while (this.#map(this.#sending, friendId).has(st.fileNumber) && st.acked < st.size) {
770
831
  const windowEnd = Math.min(st.acked + st.cwnd * MAX_FILE_DATA_SIZE, st.size);
771
832
  if (st.nextSend >= windowEnd)
772
- break; // window full / all sent — wait for acks
833
+ break; // window (safety cap) full — wait for acks
834
+ // Pacing gate: refill the token bucket; if we can't afford a chunk yet,
835
+ // re-arm the pump for exactly when we can. THIS is what keeps a deep
836
+ // buffer near-empty (bounded ping) instead of blasting the whole window.
837
+ const nowP = nowMs();
838
+ st.paceTokens = Math.min(st.paceTokens + ((nowP - st.lastPaceMs) / 1000) * st.paceRateBps, st.paceRateBps * (PACE_BURST_MS / 1000) + MAX_FILE_DATA_SIZE);
839
+ st.lastPaceMs = nowP;
840
+ if (st.paceTokens < MAX_FILE_DATA_SIZE) {
841
+ const waitMs = Math.max(2, Math.ceil(((MAX_FILE_DATA_SIZE - st.paceTokens) / st.paceRateBps) * 1000));
842
+ this.#schedulePace(friendId, st, waitMs);
843
+ break;
844
+ }
773
845
  const off = st.nextSend;
774
846
  const end = Math.min(off + MAX_FILE_DATA_SIZE, st.size);
775
847
  try {
@@ -778,6 +850,7 @@ export class FileTransferManager {
778
850
  catch {
779
851
  break; // transport down; the watchdog re-kicks
780
852
  }
853
+ st.paceTokens -= end - off;
781
854
  if (st.nextSend === off)
782
855
  st.nextSend = end; // unless a rewind moved it
783
856
  // Arm one RTT probe per round-trip: time this fresh frontier byte until
@@ -795,6 +868,19 @@ export class FileTransferManager {
795
868
  st.pumping = false;
796
869
  }
797
870
  }
871
+ // Re-kick the pump after `waitMs`, when the token bucket will have refilled
872
+ // enough for the next chunk. One timer at a time — pacing, not spinning.
873
+ #schedulePace(friendId, st, waitMs) {
874
+ if (st.paceTimer)
875
+ return;
876
+ st.paceTimer = setTimeout(() => {
877
+ st.paceTimer = undefined;
878
+ if (this.#map(this.#sending, friendId).has(st.fileNumber))
879
+ void this.#pump(friendId, st);
880
+ }, waitMs);
881
+ if (typeof st.paceTimer.unref === "function")
882
+ st.paceTimer.unref();
883
+ }
798
884
  // Send M parity shards for each FEC block whose K data chunks have all been
799
885
  // sent (once per block). Parity is computed from the source file, so no state
800
886
  // is kept beyond the high-water block index.
@@ -839,6 +925,9 @@ export class FileTransferManager {
839
925
  for (let pi = 0; pi < parity.length; pi++) {
840
926
  void this.send(friendId, PACKET_ID_FILE_FEC, encodeFec(st.fileNumber, b, pi, parity[pi])).catch(() => undefined);
841
927
  }
928
+ // Parity rides the same paced budget as data — deduct its bytes so the
929
+ // TOTAL wire rate stays at paceRateBps and parity can't flood the buffer.
930
+ st.paceTokens -= parity.length * MAX_FILE_DATA_SIZE;
842
931
  st.parityHighBlock = b;
843
932
  }
844
933
  }
@@ -859,6 +948,10 @@ export class FileTransferManager {
859
948
  clearInterval(st.offerTimer);
860
949
  st.offerTimer = undefined;
861
950
  }
951
+ if (st?.paceTimer) {
952
+ clearTimeout(st.paceTimer);
953
+ st.paceTimer = undefined;
954
+ }
862
955
  this.#map(this.#sending, friendId).delete(fileNumber);
863
956
  }
864
957
  #endRecv(friendId, fileNumber) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.76",
3
+ "version": "0.1.78",
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",