@decentnetwork/peer 0.1.97 → 0.1.99

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,45 @@ 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_HARD_QUEUE_MS = 2000; // one multi-second queue sample is unsafe
112
+ const LAN_STARTUP_QUEUE_ROUNDS = 2; // tolerate one scheduler/session-handover outlier on LAN
113
+ const LAN_STARTUP_KEEPUP = 0.70; // delivered goodput below 70% of pace means the receiver is no longer keeping up
114
+ const LAN_STARTUP_SLOW_ROUNDS = 5; // require sustained saturation across Windows scheduler/ACK jitter
115
+ // FILE ACKs are coalesced and handled by the remote JS event loop. On Windows
116
+ // the effective ACK clock is commonly 20–120 ms even when an occasional probe
117
+ // reports a 7 ms network RTT. Sizing cwnd from that lucky minimum made a
118
+ // 1.7 MB/s pacer use an 18-chunk (~24 KB) window, which itself capped goodput
119
+ // and falsely ended startup. Pacing remains the primary throttle, so this floor
120
+ // only prevents the safety window from starving a validated LAN flow.
121
+ const LAN_BDP_RTT_FLOOR_MS = 50;
126
122
  const WATCHDOG_MS = 150; // stall poll cadence / base no-progress patience
127
123
  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
124
  const FAST_RT_MIN_MS = 40; // min gap between fast-retransmits of the same hole (≈1 RTT)
129
125
  const ACK_THROTTLE_MS = 15; // coalesce receiver acks (fast enough to drive fast-retransmit)
130
126
  const REACK_MS = 200; // receiver keep-alive re-ack cadence
127
+ const FINAL_ACK_GRACE_MS = 10000; // retry final cumulative ACK across loss/reorder/short session handover
131
128
  const SEND_GIVEUP_MS = 60000; // abandon a send after this long with no ack progress (peer gone)
132
129
  const PERSIST_INTERVAL_BYTES = 1024 * 1024; // flush the resume file at most once per this much progress
133
130
  function u32be(n) {
@@ -253,9 +250,9 @@ export class FileTransferManager {
253
250
  acked: 0, nextSend: 0, pumping: false, started: false, req, startMs: nowMs(),
254
251
  lastProgressAcked: 0, lastAckAdvanceMs: nowMs(), lastResendMs: 0,
255
252
  cwnd: CWND_INIT_CHUNKS, stalls: 0, parityHighBlock: -1, lastLossMs: 0, lossEwma: 0,
256
- minRttMs: Infinity, minSrttMs: Infinity, srttMs: 0, probeOffset: -1, probeSentMs: 0,
253
+ minRttMs: Infinity, srttMs: 0, probeOffset: -1, probeSentMs: 0,
257
254
  paceRateBps: PACE_INIT_BPS, paceTokens: 0, lastPaceMs: nowMs(),
258
- btlBwBps: 0, bwSamples: [], fullBwBps: 0, fullBwRounds: 0, bbrPhase: 0, roundCount: 0,
255
+ btlBwBps: 0, bwSamples: [], fullBwRounds: 0, startupQueueRounds: 0, bbrPhase: 0, roundCount: 0,
259
256
  rateMarkMs: nowMs(), rateMarkAcked: 0, lowRttCount: 0, lowRttMinMs: Infinity, lastCcLogMs: 0,
260
257
  lanPath: this.isLanPath(friendId),
261
258
  };
@@ -599,7 +596,25 @@ export class FileTransferManager {
599
596
  clearInterval(st.reackTimer);
600
597
  st.reackTimer = undefined;
601
598
  }
602
- this.#sendAck(friendId, st); // final ack so the sender completes
599
+ this.#sendAck(friendId, st); // first final ack so the sender completes
600
+ // A final ACK used to be the only ACK we sent exactly once. If it landed
601
+ // during UDP loss, packet reordering, or a short crypto-session handover,
602
+ // the receiver had the complete byte-exact file but the sender/UI remained
603
+ // stuck at its previous cumulative offset until timeout. Keep retrying the
604
+ // tiny cumulative ACK while the lightweight done-marker exists. This is
605
+ // idempotent, and the sender drops its state after the first one it receives.
606
+ st.reackTimer = setInterval(() => {
607
+ const cur = this.#map(this.#receiving, friendId).get(st.fileNumber);
608
+ if (cur !== st) {
609
+ if (st.reackTimer)
610
+ clearInterval(st.reackTimer);
611
+ st.reackTimer = undefined;
612
+ return;
613
+ }
614
+ this.#sendAck(friendId, st);
615
+ }, REACK_MS);
616
+ if (typeof st.reackTimer.unref === "function")
617
+ st.reackTimer.unref();
603
618
  const data = st.buf;
604
619
  // Transfer complete → the resume file is no longer needed.
605
620
  if (st.partPath) {
@@ -616,9 +631,14 @@ export class FileTransferManager {
616
631
  st.got = new Uint8Array(0);
617
632
  const t = setTimeout(() => {
618
633
  const cur = this.#map(this.#receiving, friendId).get(st.fileNumber);
619
- if (cur === st)
634
+ if (cur === st) {
635
+ if (st.reackTimer) {
636
+ clearInterval(st.reackTimer);
637
+ st.reackTimer = undefined;
638
+ }
620
639
  this.#map(this.#receiving, friendId).delete(st.fileNumber);
621
- }, 10000);
640
+ }
641
+ }, FINAL_ACK_GRACE_MS);
622
642
  if (typeof t.unref === "function")
623
643
  t.unref();
624
644
  }
@@ -745,22 +765,31 @@ export class FileTransferManager {
745
765
  const roundRate = ((st.acked - st.rateMarkAcked) * 1000) / sampleMs;
746
766
  st.rateMarkMs = nowT;
747
767
  st.rateMarkAcked = st.acked;
748
- if (roundRate > 0) {
749
- st.bwSamples.push(roundRate);
768
+ const lanNow = this.isLanPath(friendId);
769
+ // On a paced LAN flow, sustainable delivery cannot suddenly be
770
+ // many times faster than the bytes we are putting on the wire.
771
+ // Such a sample is delayed/coalesced ACKs draining, not new
772
+ // capacity. Relay samples keep their existing conservative cap.
773
+ const measuredRate = lanNow
774
+ ? Math.min(roundRate, Math.max(PACE_INIT_BPS, st.paceRateBps * LAN_SAMPLE_MAX_GAIN))
775
+ : roundRate;
776
+ if (measuredRate > 0) {
777
+ st.bwSamples.push(measuredRate);
750
778
  if (st.bwSamples.length > PACE_BW_WINDOW)
751
779
  st.bwSamples.shift();
752
780
  st.btlBwBps = Math.max(...st.bwSamples);
753
781
  }
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);
782
+ // On a lossy public/relay path, wire carries goodput + FEC parity +
783
+ // retransmits, so compensate by 1/(1-loss). On a physical LAN,
784
+ // loss is normally self-induced receiver/socket overflow; raising
785
+ // pace in response creates a positive feedback loop (up to 2x).
786
+ const lossMult = lanNow ? 1 : 1 / (1 - Math.min(0.5, st.lossEwma));
758
787
  if (lanNow && !st.lanPath) {
759
788
  // A transfer can begin on relay and then promote to a validated
760
789
  // physical-LAN endpoint. Restart STARTUP from the measured rate.
761
790
  st.bbrPhase = 0;
762
- st.fullBwBps = 0;
763
791
  st.fullBwRounds = 0;
792
+ st.startupQueueRounds = 0;
764
793
  }
765
794
  st.lanPath = lanNow;
766
795
  if (!lanNow) {
@@ -778,22 +807,50 @@ export class FileTransferManager {
778
807
  // 1 ms → 2 ms scheduler jitter looked like a 100% queue and pinned
779
808
  // every transfer near PACE_INIT_BPS. The absolute, smoothed queue
780
809
  // 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;
810
+ // A paced flow initially delivers slightly less than its wire
811
+ // pace, so max-filter plateau detection can exit at 120 KB/s
812
+ // before it has actually probed the LAN. Instead, keep ramping
813
+ // while the receiver sustains most of the offered rate. Stop
814
+ // only after several under-delivery samples or a clear queue
815
+ // spike. This is a direct closed loop: no guessed LAN speed cap.
816
+ if (measuredRate >= st.paceRateBps * LAN_STARTUP_KEEPUP) {
783
817
  st.fullBwRounds = 0;
784
818
  }
785
819
  else {
786
820
  st.fullBwRounds++;
787
821
  }
788
- const rawQueue = st.minRttMs !== Infinity ? st.srttMs - st.minRttMs : 0;
789
- if (st.fullBwRounds >= PACE_FULLBW_ROUNDS || rawQueue > MAX_QUEUE_MS) {
822
+ const rawQueue = st.minRttMs !== Infinity ? acceptedRtt - st.minRttMs : 0;
823
+ if (rawQueue > LAN_STARTUP_QUEUE_MS) {
824
+ st.startupQueueRounds++;
825
+ }
826
+ else {
827
+ st.startupQueueRounds = 0;
828
+ }
829
+ // File acceptance, an event-loop pause, or a relay-to-LAN
830
+ // handover can produce one 500-1000 ms ACK on Windows/macOS.
831
+ // One such sample is not evidence that a paced LAN is full.
832
+ // Require persistence, while retaining an immediate brake for
833
+ // a genuinely dangerous multi-second queue.
834
+ const startupOvershot = rawQueue > LAN_STARTUP_HARD_QUEUE_MS ||
835
+ st.startupQueueRounds >= LAN_STARTUP_QUEUE_ROUNDS;
836
+ const startupUnderDelivering = st.fullBwRounds >= LAN_STARTUP_SLOW_ROUNDS;
837
+ if (startupUnderDelivering || startupOvershot) {
790
838
  st.bbrPhase = 2;
791
839
  }
792
- if (st.bbrPhase === 0) {
793
- st.paceRateBps = Math.max(st.paceRateBps * 1.4, st.btlBwBps * PACE_STARTUP_GAIN * lossMult);
840
+ if (startupUnderDelivering || startupOvershot) {
841
+ // Forget ACK-compressed highs immediately and drain at 80% of
842
+ // the pre-spike pace. Keeping the max-filtered high here made
843
+ // the sender continue flooding for ~2 s and collapse cwnd.
844
+ st.bwSamples = measuredRate > 0 ? [measuredRate] : [];
845
+ st.btlBwBps = measuredRate > 0 ? measuredRate : PACE_INIT_BPS;
846
+ st.startupQueueRounds = 0;
847
+ st.paceRateBps = Math.max(PACE_INIT_BPS, Math.min(st.paceRateBps * 0.8, st.btlBwBps * 1.1));
848
+ }
849
+ else if (st.bbrPhase === 0) {
850
+ 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
851
  }
795
852
  else {
796
- st.paceRateBps = Math.max(PACE_INIT_BPS * 0.5, st.btlBwBps * lossMult);
853
+ st.paceRateBps = Math.max(PACE_INIT_BPS * 0.5, Math.min(st.paceRateBps, st.btlBwBps * lossMult));
797
854
  }
798
855
  }
799
856
  else {
@@ -806,9 +863,13 @@ export class FileTransferManager {
806
863
  st.paceRateBps = Math.min(st.paceRateBps, (WINDOW_CHUNKS * MAX_FILE_DATA_SIZE) / (st.minRttMs / 1000));
807
864
  }
808
865
  // 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)));
866
+ const bdpRttMs = st.minRttMs !== Infinity && st.minRttMs > 0
867
+ ? (lanNow ? Math.max(st.minRttMs, LAN_BDP_RTT_FLOOR_MS) : st.minRttMs)
868
+ : 0;
869
+ const paceBdp = bdpRttMs > 0
870
+ ? (st.paceRateBps * (bdpRttMs / 1000)) / MAX_FILE_DATA_SIZE : WINDOW_CHUNKS;
871
+ const cwndFloor = lanNow ? CWND_INIT_CHUNKS : CWND_MIN_CHUNKS;
872
+ st.cwnd = Math.max(cwndFloor, Math.min(WINDOW_CHUNKS, Math.ceil(paceBdp * 2)));
812
873
  }
813
874
  }
814
875
  if (process.env.DECENT_DEBUG && nowT - st.lastCcLogMs >= 1000) {
@@ -816,7 +877,9 @@ export class FileTransferManager {
816
877
  console.error(`[file-cc] friend=${friendId.slice(0, 8)} ack=${st.acked}/${st.size}` +
817
878
  ` rtt=${rtt}ms srtt=${st.srttMs.toFixed(1)}ms low=${st.lowRttCount}` +
818
879
  ` accepted=${(!lowSample || promoteLowPath) ? 1 : 0}` +
819
- ` pace=${Math.round(st.paceRateBps)}Bps cwnd=${st.cwnd} rounds=${st.roundCount}`);
880
+ ` pace=${Math.round(st.paceRateBps)}Bps bw=${Math.round(st.btlBwBps)}Bps` +
881
+ ` cwnd=${st.cwnd} phase=${st.bbrPhase} slow=${st.fullBwRounds}` +
882
+ ` loss=${(st.lossEwma * 100).toFixed(1)}% rounds=${st.roundCount}`);
820
883
  }
821
884
  }
822
885
  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.99",
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",