@decentnetwork/peer 0.1.97 → 0.1.98

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.
@@ -86,48 +86,43 @@ export const FILEKIND = { DATA: 0, AVATAR: 1 };
86
86
  const WINDOW_CHUNKS = 768; // ~1 MB — the window CAP (fills a 200ms-RTT path at ~5 MB/s)
87
87
  const CWND_INIT_CHUNKS = 16; // ~22 KB start — small so the opening burst can't bloat a thin path
88
88
  const CWND_MIN_CHUNKS = 8; // ~11 KB floor — keeps a slow/flaky path's buffer shallow
89
- // Congestion control (TCP Vegas — DELAY-based). Loss/AIMD control fails on a
90
- // slow peer with a deep buffer (a user's laptop): ~1 MB in flight piles up as
91
- // 12-23 s of queue latency with NO loss, so it never backs off, and the stalls
92
- // trigger go-back-N resends that balloon the queue further. Vegas instead reads
93
- // how many chunks are QUEUED from the RTT inflation — cwnd × (1 − minRtt/RTT) —
94
- // and steers the window to keep that between ALPHA and BETA. When the queue is
95
- // below ALPHA there's spare capacity → grow (no self-limiting, unlike a
96
- // rate-based cap); above BETA the queue is too deep → shrink. It converges to
97
- // "pipe full + a couple chunks queued" on ANY path: a fast link gets a big
98
- // window, a thin flaky one a small window and a shallow queue, automatically.
99
- // Target the ABSOLUTE queue delay (rtt − minRtt), not a packet count: bound the
100
- // latency the transfer ADDS to the path to ≈ MAX_QUEUE_MS on ANY path. A fast
101
- // link keeps a big window (it drains quickly, so a big window adds little
102
- // delay); a thin flaky one keeps a small window (a big window there would add
103
- // seconds). Growing whenever the added delay is low means no self-limiting.
104
- // The budget is generous on purpose: for a bulk transfer to a flaky peer,
105
- // ~0.5s of added latency keeps ping well under a second (fine — the peer stays
106
- // usable), and buys far more throughput on a jittery path than a tight 150ms
107
- // bound, which collapsed the window to the floor (~17 KB/s observed on lili).
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)
109
- const QUEUE_LOW_MS = 150; // grow while the smoothed (jitter-free) queue is under this
110
- const VEGAS_GROW = 1.25; // window ×this per RTT when under-filled
111
- const VEGAS_SHRINK = 0.8; // window ×this per RTT when the queue is too deep
112
89
  // PACED control (BBR-style). The window-based Vegas controller blasted the whole
113
90
  // cwnd into the buffer each round, which on a DEEP buffer (the TCP relay's
114
91
  // China↔US queue) piled up to 7-15 s of latency — the feedback loop is longer
115
92
  // than a human's patience, so it can't react. The fix is to PACE: send at the
116
93
  // estimated bottleneck bandwidth so the buffer stays near-empty regardless of
117
94
  // 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
95
  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
96
  const PACE_BW_WINDOW = 10; // rounds of delivered-rate history for the max-filter
123
97
  const PACE_SAMPLE_MS = 200; // aggregate ACKs so one scheduler-jittered packet is not a "round"
124
98
  const PACE_INIT_BPS = 120_000; // initial pace rate (~120 KB/s) before any bandwidth estimate
125
99
  const PACE_BURST_MS = 20; // token-bucket burst allowance (keeps sends smooth, not bunched)
100
+ // A LAN receiver can coalesce a backlog of ACKs after its event loop wakes. The
101
+ // resulting delivery sample is ACK rate, not link capacity: in practice a
102
+ // 4 MB/s Windows path briefly reported 23 MB/s, startup chased that sample,
103
+ // overflowed the receiver, and then collapsed to the minimum window. Bound how
104
+ // quickly a validated-LAN estimate and startup pace may grow. Real capacity is
105
+ // still discovered quickly (25% every 200 ms), without one ACK burst taking
106
+ // over the controller.
107
+ const LAN_SAMPLE_MAX_GAIN = 1.25;
108
+ const LAN_STARTUP_MAX_GAIN = 1.15;
109
+ const LAN_STARTUP_DELIVERY_GAIN = 1.5; // never probe above 1.5x recently delivered goodput
110
+ const LAN_STARTUP_QUEUE_MS = 500; // unmistakable raw queue spike; smaller app-level RTT outliers are normal on Windows
111
+ const LAN_STARTUP_KEEPUP = 0.70; // delivered goodput below 70% of pace means the receiver is no longer keeping up
112
+ const LAN_STARTUP_SLOW_ROUNDS = 5; // require sustained saturation across Windows scheduler/ACK jitter
113
+ // FILE ACKs are coalesced and handled by the remote JS event loop. On Windows
114
+ // the effective ACK clock is commonly 20–120 ms even when an occasional probe
115
+ // reports a 7 ms network RTT. Sizing cwnd from that lucky minimum made a
116
+ // 1.7 MB/s pacer use an 18-chunk (~24 KB) window, which itself capped goodput
117
+ // and falsely ended startup. Pacing remains the primary throttle, so this floor
118
+ // only prevents the safety window from starving a validated LAN flow.
119
+ const LAN_BDP_RTT_FLOOR_MS = 50;
126
120
  const WATCHDOG_MS = 150; // stall poll cadence / base no-progress patience
127
121
  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)
128
122
  const FAST_RT_MIN_MS = 40; // min gap between fast-retransmits of the same hole (≈1 RTT)
129
123
  const ACK_THROTTLE_MS = 15; // coalesce receiver acks (fast enough to drive fast-retransmit)
130
124
  const REACK_MS = 200; // receiver keep-alive re-ack cadence
125
+ const FINAL_ACK_GRACE_MS = 10000; // retry final cumulative ACK across loss/reorder/short session handover
131
126
  const SEND_GIVEUP_MS = 60000; // abandon a send after this long with no ack progress (peer gone)
132
127
  const PERSIST_INTERVAL_BYTES = 1024 * 1024; // flush the resume file at most once per this much progress
133
128
  function u32be(n) {
@@ -253,9 +248,9 @@ export class FileTransferManager {
253
248
  acked: 0, nextSend: 0, pumping: false, started: false, req, startMs: nowMs(),
254
249
  lastProgressAcked: 0, lastAckAdvanceMs: nowMs(), lastResendMs: 0,
255
250
  cwnd: CWND_INIT_CHUNKS, stalls: 0, parityHighBlock: -1, lastLossMs: 0, lossEwma: 0,
256
- minRttMs: Infinity, minSrttMs: Infinity, srttMs: 0, probeOffset: -1, probeSentMs: 0,
251
+ minRttMs: Infinity, srttMs: 0, probeOffset: -1, probeSentMs: 0,
257
252
  paceRateBps: PACE_INIT_BPS, paceTokens: 0, lastPaceMs: nowMs(),
258
- btlBwBps: 0, bwSamples: [], fullBwBps: 0, fullBwRounds: 0, bbrPhase: 0, roundCount: 0,
253
+ btlBwBps: 0, bwSamples: [], fullBwRounds: 0, bbrPhase: 0, roundCount: 0,
259
254
  rateMarkMs: nowMs(), rateMarkAcked: 0, lowRttCount: 0, lowRttMinMs: Infinity, lastCcLogMs: 0,
260
255
  lanPath: this.isLanPath(friendId),
261
256
  };
@@ -599,7 +594,25 @@ export class FileTransferManager {
599
594
  clearInterval(st.reackTimer);
600
595
  st.reackTimer = undefined;
601
596
  }
602
- this.#sendAck(friendId, st); // final ack so the sender completes
597
+ this.#sendAck(friendId, st); // first final ack so the sender completes
598
+ // A final ACK used to be the only ACK we sent exactly once. If it landed
599
+ // during UDP loss, packet reordering, or a short crypto-session handover,
600
+ // the receiver had the complete byte-exact file but the sender/UI remained
601
+ // stuck at its previous cumulative offset until timeout. Keep retrying the
602
+ // tiny cumulative ACK while the lightweight done-marker exists. This is
603
+ // idempotent, and the sender drops its state after the first one it receives.
604
+ st.reackTimer = setInterval(() => {
605
+ const cur = this.#map(this.#receiving, friendId).get(st.fileNumber);
606
+ if (cur !== st) {
607
+ if (st.reackTimer)
608
+ clearInterval(st.reackTimer);
609
+ st.reackTimer = undefined;
610
+ return;
611
+ }
612
+ this.#sendAck(friendId, st);
613
+ }, REACK_MS);
614
+ if (typeof st.reackTimer.unref === "function")
615
+ st.reackTimer.unref();
603
616
  const data = st.buf;
604
617
  // Transfer complete → the resume file is no longer needed.
605
618
  if (st.partPath) {
@@ -616,9 +629,14 @@ export class FileTransferManager {
616
629
  st.got = new Uint8Array(0);
617
630
  const t = setTimeout(() => {
618
631
  const cur = this.#map(this.#receiving, friendId).get(st.fileNumber);
619
- if (cur === st)
632
+ if (cur === st) {
633
+ if (st.reackTimer) {
634
+ clearInterval(st.reackTimer);
635
+ st.reackTimer = undefined;
636
+ }
620
637
  this.#map(this.#receiving, friendId).delete(st.fileNumber);
621
- }, 10000);
638
+ }
639
+ }, FINAL_ACK_GRACE_MS);
622
640
  if (typeof t.unref === "function")
623
641
  t.unref();
624
642
  }
@@ -745,21 +763,29 @@ export class FileTransferManager {
745
763
  const roundRate = ((st.acked - st.rateMarkAcked) * 1000) / sampleMs;
746
764
  st.rateMarkMs = nowT;
747
765
  st.rateMarkAcked = st.acked;
748
- if (roundRate > 0) {
749
- st.bwSamples.push(roundRate);
766
+ const lanNow = this.isLanPath(friendId);
767
+ // On a paced LAN flow, sustainable delivery cannot suddenly be
768
+ // many times faster than the bytes we are putting on the wire.
769
+ // Such a sample is delayed/coalesced ACKs draining, not new
770
+ // capacity. Relay samples keep their existing conservative cap.
771
+ const measuredRate = lanNow
772
+ ? Math.min(roundRate, Math.max(PACE_INIT_BPS, st.paceRateBps * LAN_SAMPLE_MAX_GAIN))
773
+ : roundRate;
774
+ if (measuredRate > 0) {
775
+ st.bwSamples.push(measuredRate);
750
776
  if (st.bwSamples.length > PACE_BW_WINDOW)
751
777
  st.bwSamples.shift();
752
778
  st.btlBwBps = Math.max(...st.bwSamples);
753
779
  }
754
- // Wire carries goodput + FEC parity + retransmits, so to fill the LINK
755
- // we pace above goodput by the loss overhead: wire = goodput/(1loss).
756
- const lossMult = 1 / (1 - Math.min(0.5, st.lossEwma));
757
- const lanNow = this.isLanPath(friendId);
780
+ // On a lossy public/relay path, wire carries goodput + FEC parity +
781
+ // retransmits, so compensate by 1/(1-loss). On a physical LAN,
782
+ // loss is normally self-induced receiver/socket overflow; raising
783
+ // pace in response creates a positive feedback loop (up to 2x).
784
+ const lossMult = lanNow ? 1 : 1 / (1 - Math.min(0.5, st.lossEwma));
758
785
  if (lanNow && !st.lanPath) {
759
786
  // A transfer can begin on relay and then promote to a validated
760
787
  // physical-LAN endpoint. Restart STARTUP from the measured rate.
761
788
  st.bbrPhase = 0;
762
- st.fullBwBps = 0;
763
789
  st.fullBwRounds = 0;
764
790
  }
765
791
  st.lanPath = lanNow;
@@ -778,22 +804,37 @@ export class FileTransferManager {
778
804
  // 1 ms → 2 ms scheduler jitter looked like a 100% queue and pinned
779
805
  // every transfer near PACE_INIT_BPS. The absolute, smoothed queue
780
806
  // ceiling still protects a deep relay from runaway buffering.
781
- if (st.fullBwBps === 0 || st.btlBwBps >= st.fullBwBps * PACE_FULLBW_GROWTH) {
782
- st.fullBwBps = st.btlBwBps;
807
+ // A paced flow initially delivers slightly less than its wire
808
+ // pace, so max-filter plateau detection can exit at 120 KB/s
809
+ // before it has actually probed the LAN. Instead, keep ramping
810
+ // while the receiver sustains most of the offered rate. Stop
811
+ // only after several under-delivery samples or a clear queue
812
+ // spike. This is a direct closed loop: no guessed LAN speed cap.
813
+ if (measuredRate >= st.paceRateBps * LAN_STARTUP_KEEPUP) {
783
814
  st.fullBwRounds = 0;
784
815
  }
785
816
  else {
786
817
  st.fullBwRounds++;
787
818
  }
788
- const rawQueue = st.minRttMs !== Infinity ? st.srttMs - st.minRttMs : 0;
789
- if (st.fullBwRounds >= PACE_FULLBW_ROUNDS || rawQueue > MAX_QUEUE_MS) {
819
+ const rawQueue = st.minRttMs !== Infinity ? acceptedRtt - st.minRttMs : 0;
820
+ const startupOvershot = rawQueue > LAN_STARTUP_QUEUE_MS;
821
+ const startupUnderDelivering = st.fullBwRounds >= LAN_STARTUP_SLOW_ROUNDS;
822
+ if (startupUnderDelivering || startupOvershot) {
790
823
  st.bbrPhase = 2;
791
824
  }
792
- if (st.bbrPhase === 0) {
793
- st.paceRateBps = Math.max(st.paceRateBps * 1.4, st.btlBwBps * PACE_STARTUP_GAIN * lossMult);
825
+ if (startupUnderDelivering || startupOvershot) {
826
+ // Forget ACK-compressed highs immediately and drain at 80% of
827
+ // the pre-spike pace. Keeping the max-filtered high here made
828
+ // the sender continue flooding for ~2 s and collapse cwnd.
829
+ st.bwSamples = measuredRate > 0 ? [measuredRate] : [];
830
+ st.btlBwBps = measuredRate > 0 ? measuredRate : PACE_INIT_BPS;
831
+ st.paceRateBps = Math.max(PACE_INIT_BPS, Math.min(st.paceRateBps * 0.8, st.btlBwBps * 1.1));
832
+ }
833
+ else if (st.bbrPhase === 0) {
834
+ st.paceRateBps = Math.max(st.paceRateBps, Math.min(st.paceRateBps * LAN_STARTUP_MAX_GAIN, Math.max(PACE_INIT_BPS, measuredRate * LAN_STARTUP_DELIVERY_GAIN)));
794
835
  }
795
836
  else {
796
- st.paceRateBps = Math.max(PACE_INIT_BPS * 0.5, st.btlBwBps * lossMult);
837
+ st.paceRateBps = Math.max(PACE_INIT_BPS * 0.5, Math.min(st.paceRateBps, st.btlBwBps * lossMult));
797
838
  }
798
839
  }
799
840
  else {
@@ -806,9 +847,13 @@ export class FileTransferManager {
806
847
  st.paceRateBps = Math.min(st.paceRateBps, (WINDOW_CHUNKS * MAX_FILE_DATA_SIZE) / (st.minRttMs / 1000));
807
848
  }
808
849
  // cwnd = safety ceiling sized to 2× pace BDP. Pacing is primary.
809
- const paceBdp = (st.minRttMs !== Infinity && st.minRttMs > 0)
810
- ? (st.paceRateBps * (st.minRttMs / 1000)) / MAX_FILE_DATA_SIZE : WINDOW_CHUNKS;
811
- st.cwnd = Math.max(CWND_MIN_CHUNKS, Math.min(WINDOW_CHUNKS, Math.ceil(paceBdp * 2)));
850
+ const bdpRttMs = st.minRttMs !== Infinity && st.minRttMs > 0
851
+ ? (lanNow ? Math.max(st.minRttMs, LAN_BDP_RTT_FLOOR_MS) : st.minRttMs)
852
+ : 0;
853
+ const paceBdp = bdpRttMs > 0
854
+ ? (st.paceRateBps * (bdpRttMs / 1000)) / MAX_FILE_DATA_SIZE : WINDOW_CHUNKS;
855
+ const cwndFloor = lanNow ? CWND_INIT_CHUNKS : CWND_MIN_CHUNKS;
856
+ st.cwnd = Math.max(cwndFloor, Math.min(WINDOW_CHUNKS, Math.ceil(paceBdp * 2)));
812
857
  }
813
858
  }
814
859
  if (process.env.DECENT_DEBUG && nowT - st.lastCcLogMs >= 1000) {
@@ -816,7 +861,9 @@ export class FileTransferManager {
816
861
  console.error(`[file-cc] friend=${friendId.slice(0, 8)} ack=${st.acked}/${st.size}` +
817
862
  ` rtt=${rtt}ms srtt=${st.srttMs.toFixed(1)}ms low=${st.lowRttCount}` +
818
863
  ` accepted=${(!lowSample || promoteLowPath) ? 1 : 0}` +
819
- ` pace=${Math.round(st.paceRateBps)}Bps cwnd=${st.cwnd} rounds=${st.roundCount}`);
864
+ ` pace=${Math.round(st.paceRateBps)}Bps bw=${Math.round(st.btlBwBps)}Bps` +
865
+ ` cwnd=${st.cwnd} phase=${st.bbrPhase} slow=${st.fullBwRounds}` +
866
+ ` loss=${(st.lossEwma * 100).toFixed(1)}% rounds=${st.roundCount}`);
820
867
  }
821
868
  }
822
869
  if (st.acked - st.lastProgressAcked >= 256 * 1024 || st.acked >= st.size) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.97",
3
+ "version": "0.1.98",
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",