@decentnetwork/peer 0.1.75 → 0.1.77
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.
- package/dist/compat/filetransfer.js +108 -27
- package/package.json +1 -1
|
@@ -105,10 +105,23 @@ const CWND_MIN_CHUNKS = 8; // ~11 KB floor — keeps a slow/flaky path's buffer
|
|
|
105
105
|
// ~0.5s of added latency keeps ping well under a second (fine — the peer stays
|
|
106
106
|
// usable), and buys far more throughput on a jittery path than a tight 150ms
|
|
107
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)
|
|
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
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,34 +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
|
|
666
|
-
// acked far sooner than it
|
|
667
|
-
//
|
|
668
|
-
//
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
//
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
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);
|
|
687
705
|
}
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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)));
|
|
691
735
|
}
|
|
692
736
|
}
|
|
693
737
|
if (st.acked - st.lastProgressAcked >= 256 * 1024 || st.acked >= st.size) {
|
|
@@ -695,11 +739,16 @@ export class FileTransferManager {
|
|
|
695
739
|
this.emit("file-progress", { friendId, fileId: hex(st.fileId), received: st.acked, total: st.size, sending: true });
|
|
696
740
|
}
|
|
697
741
|
}
|
|
698
|
-
else if (maxRecv > st.acked + MAX_FILE_DATA_SIZE && nowMs() - st.lastResendMs >= FAST_RT_MIN_MS) {
|
|
699
|
-
// Duplicate ack WITH a gap
|
|
700
|
-
//
|
|
701
|
-
//
|
|
702
|
-
//
|
|
742
|
+
else if (maxRecv > st.acked + Math.max(4, st.cwnd >> 2) * MAX_FILE_DATA_SIZE && nowMs() - st.lastResendMs >= FAST_RT_MIN_MS) {
|
|
743
|
+
// Duplicate ack WITH a gap bigger than the REORDER window (≈¼ of the flight)
|
|
744
|
+
// → the chunk at `acked` is genuinely lost, not just reordered. Requiring a
|
|
745
|
+
// real gap is what unlocked throughput on jittery GFW paths: a 1-chunk
|
|
746
|
+
// trigger fired constantly on per-packet jitter (packets arrive out of
|
|
747
|
+
// order), each firing a go-back-N rewind that re-sent bytes the receiver
|
|
748
|
+
// already had and pinned the window near the floor. Now FEC repairs the
|
|
749
|
+
// occasional true hole locally, so waiting a few chunks before retransmit
|
|
750
|
+
// costs nothing. Rewind the send cursor to the hole (rate-limited to ~1 RTT
|
|
751
|
+
// so a dup-ack burst can't storm). No window backoff: loss ≠ congestion.
|
|
703
752
|
st.lastResendMs = nowMs();
|
|
704
753
|
st.lastLossMs = nowMs(); // the path is lossy → turn adaptive FEC on
|
|
705
754
|
if (st.nextSend > st.acked)
|
|
@@ -768,7 +817,18 @@ export class FileTransferManager {
|
|
|
768
817
|
while (this.#map(this.#sending, friendId).has(st.fileNumber) && st.acked < st.size) {
|
|
769
818
|
const windowEnd = Math.min(st.acked + st.cwnd * MAX_FILE_DATA_SIZE, st.size);
|
|
770
819
|
if (st.nextSend >= windowEnd)
|
|
771
|
-
break; // window
|
|
820
|
+
break; // window (safety cap) full — wait for acks
|
|
821
|
+
// Pacing gate: refill the token bucket; if we can't afford a chunk yet,
|
|
822
|
+
// re-arm the pump for exactly when we can. THIS is what keeps a deep
|
|
823
|
+
// buffer near-empty (bounded ping) instead of blasting the whole window.
|
|
824
|
+
const nowP = nowMs();
|
|
825
|
+
st.paceTokens = Math.min(st.paceTokens + ((nowP - st.lastPaceMs) / 1000) * st.paceRateBps, st.paceRateBps * (PACE_BURST_MS / 1000) + MAX_FILE_DATA_SIZE);
|
|
826
|
+
st.lastPaceMs = nowP;
|
|
827
|
+
if (st.paceTokens < MAX_FILE_DATA_SIZE) {
|
|
828
|
+
const waitMs = Math.max(2, Math.ceil(((MAX_FILE_DATA_SIZE - st.paceTokens) / st.paceRateBps) * 1000));
|
|
829
|
+
this.#schedulePace(friendId, st, waitMs);
|
|
830
|
+
break;
|
|
831
|
+
}
|
|
772
832
|
const off = st.nextSend;
|
|
773
833
|
const end = Math.min(off + MAX_FILE_DATA_SIZE, st.size);
|
|
774
834
|
try {
|
|
@@ -777,6 +837,7 @@ export class FileTransferManager {
|
|
|
777
837
|
catch {
|
|
778
838
|
break; // transport down; the watchdog re-kicks
|
|
779
839
|
}
|
|
840
|
+
st.paceTokens -= end - off;
|
|
780
841
|
if (st.nextSend === off)
|
|
781
842
|
st.nextSend = end; // unless a rewind moved it
|
|
782
843
|
// Arm one RTT probe per round-trip: time this fresh frontier byte until
|
|
@@ -794,6 +855,19 @@ export class FileTransferManager {
|
|
|
794
855
|
st.pumping = false;
|
|
795
856
|
}
|
|
796
857
|
}
|
|
858
|
+
// Re-kick the pump after `waitMs`, when the token bucket will have refilled
|
|
859
|
+
// enough for the next chunk. One timer at a time — pacing, not spinning.
|
|
860
|
+
#schedulePace(friendId, st, waitMs) {
|
|
861
|
+
if (st.paceTimer)
|
|
862
|
+
return;
|
|
863
|
+
st.paceTimer = setTimeout(() => {
|
|
864
|
+
st.paceTimer = undefined;
|
|
865
|
+
if (this.#map(this.#sending, friendId).has(st.fileNumber))
|
|
866
|
+
void this.#pump(friendId, st);
|
|
867
|
+
}, waitMs);
|
|
868
|
+
if (typeof st.paceTimer.unref === "function")
|
|
869
|
+
st.paceTimer.unref();
|
|
870
|
+
}
|
|
797
871
|
// Send M parity shards for each FEC block whose K data chunks have all been
|
|
798
872
|
// sent (once per block). Parity is computed from the source file, so no state
|
|
799
873
|
// is kept beyond the high-water block index.
|
|
@@ -838,6 +912,9 @@ export class FileTransferManager {
|
|
|
838
912
|
for (let pi = 0; pi < parity.length; pi++) {
|
|
839
913
|
void this.send(friendId, PACKET_ID_FILE_FEC, encodeFec(st.fileNumber, b, pi, parity[pi])).catch(() => undefined);
|
|
840
914
|
}
|
|
915
|
+
// Parity rides the same paced budget as data — deduct its bytes so the
|
|
916
|
+
// TOTAL wire rate stays at paceRateBps and parity can't flood the buffer.
|
|
917
|
+
st.paceTokens -= parity.length * MAX_FILE_DATA_SIZE;
|
|
841
918
|
st.parityHighBlock = b;
|
|
842
919
|
}
|
|
843
920
|
}
|
|
@@ -858,6 +935,10 @@ export class FileTransferManager {
|
|
|
858
935
|
clearInterval(st.offerTimer);
|
|
859
936
|
st.offerTimer = undefined;
|
|
860
937
|
}
|
|
938
|
+
if (st?.paceTimer) {
|
|
939
|
+
clearTimeout(st.paceTimer);
|
|
940
|
+
st.paceTimer = undefined;
|
|
941
|
+
}
|
|
861
942
|
this.#map(this.#sending, friendId).delete(fileNumber);
|
|
862
943
|
}
|
|
863
944
|
#endRecv(friendId, fileNumber) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/peer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.77",
|
|
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",
|