@decentnetwork/peer 0.1.95 → 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.
@@ -40,11 +40,13 @@ export declare function parseFileData(p: Uint8Array): {
40
40
  } | undefined;
41
41
  export type FtSend = (friendId: string, packetId: number, payload: Uint8Array) => Promise<unknown>;
42
42
  export type FtEmit = (event: string, payload: Record<string, unknown>) => void;
43
+ export type FtIsLanPath = (friendId: string) => boolean;
43
44
  export declare class FileTransferManager {
44
45
  #private;
45
46
  private readonly send;
46
47
  private readonly emit;
47
- constructor(send: FtSend, emit: FtEmit);
48
+ private readonly isLanPath;
49
+ constructor(send: FtSend, emit: FtEmit, isLanPath?: FtIsLanPath);
48
50
  /** Enable resumable receives: partials are persisted under `dir` and matched
49
51
  * on re-offer by content-hash fileId. Unset = in-memory only (no resume). */
50
52
  setResumeDir(dir: string): void;
@@ -86,47 +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
97
+ const PACE_SAMPLE_MS = 200; // aggregate ACKs so one scheduler-jittered packet is not a "round"
123
98
  const PACE_INIT_BPS = 120_000; // initial pace rate (~120 KB/s) before any bandwidth estimate
124
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;
125
120
  const WATCHDOG_MS = 150; // stall poll cadence / base no-progress patience
126
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)
127
122
  const FAST_RT_MIN_MS = 40; // min gap between fast-retransmits of the same hole (≈1 RTT)
128
123
  const ACK_THROTTLE_MS = 15; // coalesce receiver acks (fast enough to drive fast-retransmit)
129
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
130
126
  const SEND_GIVEUP_MS = 60000; // abandon a send after this long with no ack progress (peer gone)
131
127
  const PERSIST_INTERVAL_BYTES = 1024 * 1024; // flush the resume file at most once per this much progress
132
128
  function u32be(n) {
@@ -195,14 +191,16 @@ const chunkCount = (size) => (size <= 0 ? 0 : Math.ceil(size / MAX_FILE_DATA_SIZ
195
191
  export class FileTransferManager {
196
192
  send;
197
193
  emit;
194
+ isLanPath;
198
195
  #sending = new Map();
199
196
  #receiving = new Map();
200
197
  // When set, incoming transfers persist their contiguous prefix here so a
201
198
  // dropped/restarted transfer resumes instead of restarting (断点续传).
202
199
  #resumeDir;
203
- constructor(send, emit) {
200
+ constructor(send, emit, isLanPath = () => false) {
204
201
  this.send = send;
205
202
  this.emit = emit;
203
+ this.isLanPath = isLanPath;
206
204
  }
207
205
  /** Enable resumable receives: partials are persisted under `dir` and matched
208
206
  * on re-offer by content-hash fileId. Unset = in-memory only (no resume). */
@@ -250,10 +248,11 @@ export class FileTransferManager {
250
248
  acked: 0, nextSend: 0, pumping: false, started: false, req, startMs: nowMs(),
251
249
  lastProgressAcked: 0, lastAckAdvanceMs: nowMs(), lastResendMs: 0,
252
250
  cwnd: CWND_INIT_CHUNKS, stalls: 0, parityHighBlock: -1, lastLossMs: 0, lossEwma: 0,
253
- minRttMs: Infinity, minSrttMs: Infinity, srttMs: 0, probeOffset: -1, probeSentMs: 0,
251
+ minRttMs: Infinity, srttMs: 0, probeOffset: -1, probeSentMs: 0,
254
252
  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,
253
+ btlBwBps: 0, bwSamples: [], fullBwRounds: 0, bbrPhase: 0, roundCount: 0,
254
+ rateMarkMs: nowMs(), rateMarkAcked: 0, lowRttCount: 0, lowRttMinMs: Infinity, lastCcLogMs: 0,
255
+ lanPath: this.isLanPath(friendId),
257
256
  };
258
257
  this.#map(this.#sending, friendId).set(fileNumber, st);
259
258
  void this.send(friendId, PACKET_ID_FILE_SENDREQUEST, req).catch(() => undefined);
@@ -595,7 +594,25 @@ export class FileTransferManager {
595
594
  clearInterval(st.reackTimer);
596
595
  st.reackTimer = undefined;
597
596
  }
598
- 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();
599
616
  const data = st.buf;
600
617
  // Transfer complete → the resume file is no longer needed.
601
618
  if (st.partPath) {
@@ -612,9 +629,14 @@ export class FileTransferManager {
612
629
  st.got = new Uint8Array(0);
613
630
  const t = setTimeout(() => {
614
631
  const cur = this.#map(this.#receiving, friendId).get(st.fileNumber);
615
- if (cur === st)
632
+ if (cur === st) {
633
+ if (st.reackTimer) {
634
+ clearInterval(st.reackTimer);
635
+ st.reackTimer = undefined;
636
+ }
616
637
  this.#map(this.#receiving, friendId).delete(st.fileNumber);
617
- }, 10000);
638
+ }
639
+ }, FINAL_ACK_GRACE_MS);
618
640
  if (typeof t.unref === "function")
619
641
  t.unref();
620
642
  }
@@ -677,6 +699,11 @@ export class FileTransferManager {
677
699
  // Progress. The path is delivering → additively re-open the window (up to
678
700
  // the cap) and clear the stall streak (patience shrinks back to the base).
679
701
  const nowT = nowMs();
702
+ // A receiver may already have a persisted prefix from an earlier UI/CLI
703
+ // attempt. Do not count that old data as bytes delivered by this new
704
+ // connection: doing so creates a fictitious multi-MB/s bandwidth sample
705
+ // and an unsafe startup burst.
706
+ const resumedPrefix = st.acked === 0 && ackedOffset > st.nextSend;
680
707
  st.acked = Math.min(ackedOffset, st.size);
681
708
  st.lastAckAdvanceMs = nowT;
682
709
  st.stalls = 0;
@@ -685,67 +712,158 @@ export class FileTransferManager {
685
712
  // instead of re-transmitting them.
686
713
  if (st.nextSend < st.acked)
687
714
  st.nextSend = st.acked;
715
+ if (resumedPrefix) {
716
+ st.rateMarkMs = nowT;
717
+ st.rateMarkAcked = st.acked;
718
+ st.probeOffset = -1;
719
+ }
688
720
  // Vegas: adjust the window ONCE PER RTT, when the timed frontier byte's ack
689
721
  // returns (per-ack would fire several times before a slow path's probe
690
722
  // reported, overshooting). Estimate chunks queued from RTT inflation and
691
723
  // steer to keep it in [ALPHA, BETA].
692
- if (st.probeOffset >= 0 && st.acked >= st.probeOffset) {
724
+ if (!resumedPrefix && st.probeOffset >= 0 && st.acked >= st.probeOffset) {
693
725
  // One paced round completed (the timed frontier byte's ack landed).
694
726
  const rtt = nowT - st.probeSentMs;
695
727
  st.probeOffset = -1;
696
- // Reject glitch samples: a batched / late / resume ack can make a probe
697
- // byte look acked far sooner than it really was, giving a tiny rtt that
698
- // would corrupt minRtt (RTprop) shrink the BDP and collapse cwnd to the
699
- // floor. Only trust a sample within a sane band of the smoothed RTT.
700
- const glitch = st.srttMs > 0 && rtt < st.srttMs * 0.4;
701
- if (!glitch && rtt > 0) {
702
- st.roundCount++;
728
+ // A single very-low sample can be a batched/late ACK that makes a probe
729
+ // look newer than it is. But several consecutive low samples mean the
730
+ // path genuinely improved (most importantly tcp-relay LAN UDP). The
731
+ // old code rejected every such sample against the permanently-high srtt,
732
+ // so startup stayed pinned near 120 KB/s forever. Promote a stable lower
733
+ // RTT after three samples and rebase srtt/RTprop to the new path.
734
+ const lowSample = st.srttMs > 0 && rtt > 0 && rtt < st.srttMs * 0.4;
735
+ if (lowSample) {
736
+ st.lowRttCount++;
737
+ st.lowRttMinMs = Math.min(st.lowRttMinMs, rtt);
738
+ }
739
+ else {
740
+ st.lowRttCount = 0;
741
+ st.lowRttMinMs = Infinity;
742
+ }
743
+ const promoteLowPath = lowSample && st.lowRttCount >= 3;
744
+ const acceptedRtt = promoteLowPath ? st.lowRttMinMs : rtt;
745
+ if (promoteLowPath) {
746
+ st.srttMs = acceptedRtt;
747
+ st.minRttMs = acceptedRtt;
748
+ st.lowRttCount = 0;
749
+ st.lowRttMinMs = Infinity;
750
+ }
751
+ if ((!lowSample || promoteLowPath) && acceptedRtt > 0) {
703
752
  // RTprop = min RTT ever seen (loss/queue only RAISE rtt → min is robust).
704
- st.srttMs = st.srttMs === 0 ? rtt : 0.85 * st.srttMs + 0.15 * rtt;
705
- if (rtt < st.minRttMs)
706
- st.minRttMs = rtt;
707
- // BtlBw = max-filtered delivered GOODPUT rate. Loss/reorder ignored as a
708
- // rate signal (they don't reduce bytes/sec; FEC + reorder-tolerant
709
- // retransmit fix correctness), so the rate model never collapses.
710
- const dtSec = Math.max(0.05, (nowT - st.rateMarkMs) / 1000);
711
- const roundRate = (st.acked - st.rateMarkAcked) / dtSec;
712
- st.rateMarkMs = nowT;
713
- st.rateMarkAcked = st.acked;
714
- if (roundRate > 0) {
715
- st.bwSamples.push(roundRate);
716
- if (st.bwSamples.length > PACE_BW_WINDOW)
717
- st.bwSamples.shift();
718
- st.btlBwBps = Math.max(...st.bwSamples);
719
- }
720
- // Wire carries goodput + FEC parity + retransmits, so to fill the LINK
721
- // we pace above goodput by the loss overhead: wire = goodput/(1 − loss).
722
- const lossMult = 1 / (1 - Math.min(0.5, st.lossEwma));
723
- // Two-phase paced control, tuned for STABILITY first (never bloat).
724
- if (st.bbrPhase === 0) {
725
- // STARTUP: ramp ×1.4/round until a real queue builds (rtt ≥1.35×RTprop
726
- // → pipe full), then cruise. The queue-based exit keeps latency
727
- // bounded: on a lossy path loss-recovery trips it sooner, so the fill
728
- // is slower — but it NEVER bloats. That's the right trade — fast on a
729
- // good path, graceful (stable, not 15 s of queue) on a bad one.
730
- const rawQueue = st.minRttMs !== Infinity ? rtt - st.minRttMs : 0;
731
- if (st.minRttMs !== Infinity && rawQueue > st.minRttMs * 0.35)
753
+ st.srttMs = st.srttMs === 0 ? acceptedRtt : 0.85 * st.srttMs + 0.15 * acceptedRtt;
754
+ if (acceptedRtt < st.minRttMs)
755
+ st.minRttMs = acceptedRtt;
756
+ // Aggregate ACKs into a time-sized delivery sample. Previously every
757
+ // single ACK was treated as a BBR round and dt was clamped to 50 ms;
758
+ // on a 1–2 ms LAN that made the measured rate look permanently tiny
759
+ // and normal scheduler jitter immediately ended STARTUP.
760
+ const sampleMs = nowT - st.rateMarkMs;
761
+ if (sampleMs >= PACE_SAMPLE_MS) {
762
+ st.roundCount++;
763
+ const roundRate = ((st.acked - st.rateMarkAcked) * 1000) / sampleMs;
764
+ st.rateMarkMs = nowT;
765
+ st.rateMarkAcked = st.acked;
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);
776
+ if (st.bwSamples.length > PACE_BW_WINDOW)
777
+ st.bwSamples.shift();
778
+ st.btlBwBps = Math.max(...st.bwSamples);
779
+ }
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));
785
+ if (lanNow && !st.lanPath) {
786
+ // A transfer can begin on relay and then promote to a validated
787
+ // physical-LAN endpoint. Restart STARTUP from the measured rate.
788
+ st.bbrPhase = 0;
789
+ st.fullBwRounds = 0;
790
+ }
791
+ st.lanPath = lanNow;
792
+ if (!lanNow) {
793
+ // Relay/public-NAT file traffic must remain conservative. A short
794
+ // ACK burst can wildly overstate bandwidth there; allowing LAN's
795
+ // exponential STARTUP filled the TCP relay with an 8.7 s queue.
796
+ // This affects only file DATA/FEC — IP/video has its own path.
732
797
  st.bbrPhase = 2;
733
- st.paceRateBps = Math.max(st.paceRateBps * 1.4, st.btlBwBps * PACE_STARTUP_GAIN * lossMult);
734
- }
735
- else {
736
- // PROBE_BW cruise: one round probes up, one DRAINS (0.8×), six at 1× →
737
- // the AVERAGE pace is exactly bandwidth, so the queue stays shallow.
738
- const gain = PACE_CYCLE_GAINS[st.roundCount % PACE_CYCLE_GAINS.length];
739
- st.paceRateBps = Math.max(PACE_INIT_BPS * 0.5, st.btlBwBps * gain * lossMult);
740
- } // Hard safety cap: never pace faster than a full window drains in 1 RTT.
741
- if (st.minRttMs !== Infinity && st.minRttMs > 0) {
742
- st.paceRateBps = Math.min(st.paceRateBps, (WINDOW_CHUNKS * MAX_FILE_DATA_SIZE) / (st.minRttMs / 1000));
798
+ const gain = PACE_CYCLE_GAINS[st.roundCount % PACE_CYCLE_GAINS.length];
799
+ st.paceRateBps = Math.max(PACE_INIT_BPS, Math.min(PACE_INIT_BPS * 2, st.btlBwBps * gain * lossMult));
800
+ }
801
+ else if (st.bbrPhase === 0) {
802
+ // BBR full-bandwidth detection: remain in STARTUP while delivered
803
+ // bandwidth grows. A relative RTT threshold is unusable on LAN —
804
+ // 1 ms 2 ms scheduler jitter looked like a 100% queue and pinned
805
+ // every transfer near PACE_INIT_BPS. The absolute, smoothed queue
806
+ // ceiling still protects a deep relay from runaway buffering.
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) {
814
+ st.fullBwRounds = 0;
815
+ }
816
+ else {
817
+ st.fullBwRounds++;
818
+ }
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) {
823
+ st.bbrPhase = 2;
824
+ }
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)));
835
+ }
836
+ else {
837
+ st.paceRateBps = Math.max(PACE_INIT_BPS * 0.5, Math.min(st.paceRateBps, st.btlBwBps * lossMult));
838
+ }
839
+ }
840
+ else {
841
+ // PROBE_BW cruise: one sample probes up, one drains, six cruise.
842
+ const gain = PACE_CYCLE_GAINS[st.roundCount % PACE_CYCLE_GAINS.length];
843
+ st.paceRateBps = Math.max(PACE_INIT_BPS * 0.5, st.btlBwBps * gain * lossMult);
844
+ }
845
+ // Hard safety cap: never pace faster than a full window drains in 1 RTT.
846
+ if (st.minRttMs !== Infinity && st.minRttMs > 0) {
847
+ st.paceRateBps = Math.min(st.paceRateBps, (WINDOW_CHUNKS * MAX_FILE_DATA_SIZE) / (st.minRttMs / 1000));
848
+ }
849
+ // cwnd = safety ceiling sized to 2× pace BDP. Pacing is primary.
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)));
743
857
  }
744
- // cwnd = SAFETY ceiling sized to the PACE rate (2×paceRate×RTprop), NOT
745
- // the measured bandwidth (which would self-limit). Pacing is primary.
746
- const paceBdp = (st.minRttMs !== Infinity && st.minRttMs > 0)
747
- ? (st.paceRateBps * (st.minRttMs / 1000)) / MAX_FILE_DATA_SIZE : WINDOW_CHUNKS;
748
- st.cwnd = Math.max(CWND_MIN_CHUNKS, Math.min(WINDOW_CHUNKS, Math.ceil(paceBdp * 2)));
858
+ }
859
+ if (process.env.DECENT_DEBUG && nowT - st.lastCcLogMs >= 1000) {
860
+ st.lastCcLogMs = nowT;
861
+ console.error(`[file-cc] friend=${friendId.slice(0, 8)} ack=${st.acked}/${st.size}` +
862
+ ` rtt=${rtt}ms srtt=${st.srttMs.toFixed(1)}ms low=${st.lowRttCount}` +
863
+ ` accepted=${(!lowSample || promoteLowPath) ? 1 : 0}` +
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}`);
749
867
  }
750
868
  }
751
869
  if (st.acked - st.lastProgressAcked >= 256 * 1024 || st.acked >= st.size) {
@@ -861,7 +979,11 @@ export class FileTransferManager {
861
979
  try {
862
980
  await this.send(friendId, PACKET_ID_FILE_DATA, encodeFileData(st.fileNumber, off, st.data.subarray(off, end)));
863
981
  }
864
- catch {
982
+ catch (error) {
983
+ if (process.env.DECENT_DEBUG || process.env.DECENT_DEBUG_VERBOSE) {
984
+ console.error(`[file-ft] data-send-error friend=${friendId.slice(0, 8)} num=${st.fileNumber} ` +
985
+ `off=${off} error=${error instanceof Error ? error.message : String(error)}`);
986
+ }
865
987
  break; // transport down; the watchdog re-kicks
866
988
  }
867
989
  st.paceTokens -= end - off;
package/dist/peer.js CHANGED
@@ -211,7 +211,10 @@ export class Peer {
211
211
  #opts;
212
212
  #events = new EventEmitter();
213
213
  // Toxcore-standard file transfer (wire-compatible with native Carrier/toxcore).
214
- #fileTransfer = new FileTransferManager((friendId, packetId, payload) => this.#sendMessengerPacket(friendId, packetId, payload), (event, payload) => { this.#events.emit(event, payload); });
214
+ #fileTransfer = new FileTransferManager((friendId, packetId, payload) => this.#sendMessengerPacket(friendId, packetId, payload), (event, payload) => { this.#events.emit(event, payload); }, (friendId) => {
215
+ const session = this.#friendSessions.get(friendId);
216
+ return !!session?.lanRemoteHost && session.remote?.host === session.lanRemoteHost;
217
+ });
215
218
  #keyPair;
216
219
  #udp = new UdpTransport();
217
220
  // TURN relay (stable fallback path). One node-level allocation on its own
@@ -2063,17 +2066,14 @@ export class Peer {
2063
2066
  // low-water mark, like toxcore net_crypto drops below buffer_start.
2064
2067
  //
2065
2068
  // EXCEPT single-transport channels (bulk IP = bulkDataPacketId, and
2066
- // file transfer 80-82): they are NOT dual-sent, so they never produce
2067
- // duplicates — and the low-water mark would then only drop a
2068
- // legitimately REORDERED packet, which over a high-rate stream (CCTV
2069
- // over a long-haul China↔US path that reorders) is pure added loss /
2070
- // jitter. IP forwarding is order-independent (the TUN/TCP above
2071
- // reassembles), so deliver every packet. Must run BEFORE the nonce
2072
- // advance so a real duplicate doesn't desync the receive nonce.
2069
+ // file DATA/FEC): they are NOT dual-sent, so they never produce
2070
+ // transport duplicates — and the low-water mark would then only drop a
2071
+ // legitimately REORDERED packet. File SENDREQUEST/CONTROL are sparse
2072
+ // control traffic and intentionally remain dual-sent so they can confirm
2073
+ // an optimistic UDP path before the bulk data pump begins. Must run
2074
+ // BEFORE the nonce advance so a real duplicate doesn't desync it.
2073
2075
  const innerKind = opened.payload.length > 0 ? opened.payload[0] : -1;
2074
2076
  const isSingleTransportKind = (this.#opts.bulkDataPacketId !== undefined && innerKind === this.#opts.bulkDataPacketId) ||
2075
- innerKind === PACKET_ID_FILE_SENDREQUEST ||
2076
- innerKind === PACKET_ID_FILE_CONTROL ||
2077
2077
  innerKind === PACKET_ID_FILE_DATA ||
2078
2078
  innerKind === PACKET_ID_FILE_FEC;
2079
2079
  // Confirm the transport path + liveness for EVERY decrypted packet —
@@ -3818,7 +3818,15 @@ export class Peer {
3818
3818
  // bulk-IP channel and lossy packets; the crypto nonce still advances above so
3819
3819
  // decryption stays in sync regardless.
3820
3820
  const isFileTransfer = kind === PACKET_ID_FILE_SENDREQUEST || kind === PACKET_ID_FILE_CONTROL || kind === PACKET_ID_FILE_DATA || kind === PACKET_ID_FILE_FEC;
3821
- const isBulkData = (this.#opts.bulkDataPacketId !== undefined && kind === this.#opts.bulkDataPacketId) || isFileTransfer;
3821
+ // Offers and control/ACK packets are sparse path-establishment traffic, not
3822
+ // bulk. Dual-send them over an optimistic UDP candidate plus the reliable
3823
+ // relay copy, just like chat/control traffic. A direct FILE_SENDREQUEST or
3824
+ // FILE_CONTROL arrival confirms UDP before FILE_DATA starts, so the data
3825
+ // stream can select the LAN path instead of remaining pinned to tcp-relay.
3826
+ // If UDP is not viable, the relay copy still completes the negotiation and
3827
+ // FILE_DATA falls back to its existing reliable single-transport route.
3828
+ const isFileBulk = kind === PACKET_ID_FILE_DATA || kind === PACKET_ID_FILE_FEC;
3829
+ const isBulkData = (this.#opts.bulkDataPacketId !== undefined && kind === this.#opts.bulkDataPacketId) || isFileBulk;
3822
3830
  const isDroppable = (isBulkData && !isFileTransfer) || kind >= 192;
3823
3831
  if (!isDroppable) {
3824
3832
  session.sendPacketNumber = (packetNumber + 1) >>> 0;
@@ -3859,20 +3867,12 @@ export class Peer {
3859
3867
  // e.g. decentlan IP=163) stays bulk / single-transport to avoid the relay
3860
3868
  // backlog + duplicate fan-out that high packet rates cause. Chat is sparse
3861
3869
  // and net_crypto dedups by packet number, so dual-send is safe here.
3862
- // File transfer (80 sendrequest / 81 control / 82 data) is ALSO treated as
3863
- // bulk single-transport. It is high-volume AND order-sensitive: the
3864
- // receiver reassembles chunks SEQUENTIALLY (position is implicit) and there
3865
- // is no retransmit queue. Dual-sending it over UDP + relay (two paths with
3866
- // different latency) reorders the chunks, and the receive-side low-water
3867
- // dedup then drops the late copies — so a large file (mp4) gets gaps /
3868
- // wrong order and never completes, while a tiny one (png, 1-2 chunks)
3869
- // happens to finish before reordering bites. Pinning file transfer to a
3870
- // single transport keeps the chunks in send order. (Reliability across a
3871
- // lossy path still wants a reorder buffer + retransmit — tracked separately.)
3872
- // File transfer rides the relay RELIABLY (never dropped under backpressure):
3873
- // a dropped IP/CCTV packet is fine (the stream moves on), but a dropped file
3874
- // chunk corrupts the file (no retransmit). So bulk IP data stays droppable
3875
- // while file transfer is queued. (isBulkData / isFileTransfer computed above.)
3870
+ // File DATA/FEC is bulk and stays single-transport. SENDREQUEST and CONTROL
3871
+ // (accept/ACK/kill) are low-volume control traffic: dual-sending those is
3872
+ // what confirms an optimistic LAN candidate before the data pump starts.
3873
+ // File packets ride the relay RELIABLY (never dropped under backpressure):
3874
+ // a dropped IP/CCTV packet is fine, but a dropped file chunk can stall the
3875
+ // transfer. (isBulkData / isFileTransfer computed above.)
3876
3876
  await this.#sendToFriend(friendId, encrypted, session, isBulkData, isFileTransfer);
3877
3877
  }
3878
3878
  /**
@@ -3896,6 +3896,15 @@ export class Peer {
3896
3896
  const udpFresh = !!realUdpRemote &&
3897
3897
  s?.lastUdpRecvMs !== undefined &&
3898
3898
  Date.now() - s.lastUdpRecvMs < 4_000;
3899
+ // A freshly-received UDP packet proves only the peer -> us direction.
3900
+ // That is enough for lossy IP/video bulk, whose inner TCP stream can
3901
+ // recover a dropped packet, but not for reliable file data: returning
3902
+ // UDP-only on a one-way public/NAT endpoint silently black-holes every
3903
+ // chunk. A physical-LAN lock is stronger evidence because the candidate
3904
+ // was validated against the local subnet; otherwise file bulk continues
3905
+ // to TURN/TCP below.
3906
+ const fileUdpOnlyConfirmed = !reliableOnRelay ||
3907
+ (!!s?.lanRemoteHost && s.remote?.host === s.lanRemoteHost);
3899
3908
  // Bulk IP data rides the direct path ONLY while it's fresh. When it
3900
3909
  // goes stale (likely a NAT remap mid-stream), we do NOT keep firing
3901
3910
  // into the dead endpoint and we do NOT duplicate onto UDP — we bridge
@@ -3903,7 +3912,8 @@ export class Peer {
3903
3912
  // lastUdpRecvMs). This converts a multi-second blackout into a few
3904
3913
  // seconds of higher relay latency. Control packets always probe UDP
3905
3914
  // (to keep the NAT mapping warm) so we still try UDP for them.
3906
- const tryUdp = realUdpRemote && (isBulkData ? udpFresh : true);
3915
+ const tryUdp = realUdpRemote &&
3916
+ (isBulkData ? udpFresh && fileUdpOnlyConfirmed : true);
3907
3917
  if (tryUdp && s?.remote) {
3908
3918
  try {
3909
3919
  await this.#sendPacket(packet, s.remote);
@@ -3913,9 +3923,9 @@ export class Peer {
3913
3923
  firstError = error;
3914
3924
  }
3915
3925
  }
3916
- // Fresh direct path → send over UDP only but ONLY for bulk data.
3917
- // Bulk is high-volume and must avoid relay fan-out / backlog, so once
3918
- // the direct path is fresh it rides UDP exclusively.
3926
+ // Fresh confirmed direct path → send over UDP only, but ONLY for bulk.
3927
+ // For files, `tryUdp` above requires the physical-LAN lock; a merely-fresh
3928
+ // public endpoint deliberately falls through to reliable TURN/TCP.
3919
3929
  //
3920
3930
  // Low-volume traffic (chat 64 + control keepalives / endpoint offers /
3921
3931
  // profile) instead ALSO goes over the relay even when the direct path
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.95",
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",