@decentnetwork/peer 0.1.95 → 0.1.97

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;
@@ -120,6 +120,7 @@ const PACE_CYCLE_GAINS = [1.25, 0.8, 1, 1, 1, 1, 1, 1]; // steady PROBE_BW cycle
120
120
  const PACE_FULLBW_GROWTH = 1.20; // bandwidth still "rising" if ≥20% over the plateau mark
121
121
  const PACE_FULLBW_ROUNDS = 3; // leave startup after this many non-rising rounds
122
122
  const PACE_BW_WINDOW = 10; // rounds of delivered-rate history for the max-filter
123
+ const PACE_SAMPLE_MS = 200; // aggregate ACKs so one scheduler-jittered packet is not a "round"
123
124
  const PACE_INIT_BPS = 120_000; // initial pace rate (~120 KB/s) before any bandwidth estimate
124
125
  const PACE_BURST_MS = 20; // token-bucket burst allowance (keeps sends smooth, not bunched)
125
126
  const WATCHDOG_MS = 150; // stall poll cadence / base no-progress patience
@@ -195,14 +196,16 @@ const chunkCount = (size) => (size <= 0 ? 0 : Math.ceil(size / MAX_FILE_DATA_SIZ
195
196
  export class FileTransferManager {
196
197
  send;
197
198
  emit;
199
+ isLanPath;
198
200
  #sending = new Map();
199
201
  #receiving = new Map();
200
202
  // When set, incoming transfers persist their contiguous prefix here so a
201
203
  // dropped/restarted transfer resumes instead of restarting (断点续传).
202
204
  #resumeDir;
203
- constructor(send, emit) {
205
+ constructor(send, emit, isLanPath = () => false) {
204
206
  this.send = send;
205
207
  this.emit = emit;
208
+ this.isLanPath = isLanPath;
206
209
  }
207
210
  /** Enable resumable receives: partials are persisted under `dir` and matched
208
211
  * on re-offer by content-hash fileId. Unset = in-memory only (no resume). */
@@ -253,7 +256,8 @@ export class FileTransferManager {
253
256
  minRttMs: Infinity, minSrttMs: Infinity, srttMs: 0, probeOffset: -1, probeSentMs: 0,
254
257
  paceRateBps: PACE_INIT_BPS, paceTokens: 0, lastPaceMs: nowMs(),
255
258
  btlBwBps: 0, bwSamples: [], fullBwBps: 0, fullBwRounds: 0, bbrPhase: 0, roundCount: 0,
256
- rateMarkMs: nowMs(), rateMarkAcked: 0,
259
+ rateMarkMs: nowMs(), rateMarkAcked: 0, lowRttCount: 0, lowRttMinMs: Infinity, lastCcLogMs: 0,
260
+ lanPath: this.isLanPath(friendId),
257
261
  };
258
262
  this.#map(this.#sending, friendId).set(fileNumber, st);
259
263
  void this.send(friendId, PACKET_ID_FILE_SENDREQUEST, req).catch(() => undefined);
@@ -677,6 +681,11 @@ export class FileTransferManager {
677
681
  // Progress. The path is delivering → additively re-open the window (up to
678
682
  // the cap) and clear the stall streak (patience shrinks back to the base).
679
683
  const nowT = nowMs();
684
+ // A receiver may already have a persisted prefix from an earlier UI/CLI
685
+ // attempt. Do not count that old data as bytes delivered by this new
686
+ // connection: doing so creates a fictitious multi-MB/s bandwidth sample
687
+ // and an unsafe startup burst.
688
+ const resumedPrefix = st.acked === 0 && ackedOffset > st.nextSend;
680
689
  st.acked = Math.min(ackedOffset, st.size);
681
690
  st.lastAckAdvanceMs = nowT;
682
691
  st.stalls = 0;
@@ -685,67 +694,129 @@ export class FileTransferManager {
685
694
  // instead of re-transmitting them.
686
695
  if (st.nextSend < st.acked)
687
696
  st.nextSend = st.acked;
697
+ if (resumedPrefix) {
698
+ st.rateMarkMs = nowT;
699
+ st.rateMarkAcked = st.acked;
700
+ st.probeOffset = -1;
701
+ }
688
702
  // Vegas: adjust the window ONCE PER RTT, when the timed frontier byte's ack
689
703
  // returns (per-ack would fire several times before a slow path's probe
690
704
  // reported, overshooting). Estimate chunks queued from RTT inflation and
691
705
  // steer to keep it in [ALPHA, BETA].
692
- if (st.probeOffset >= 0 && st.acked >= st.probeOffset) {
706
+ if (!resumedPrefix && st.probeOffset >= 0 && st.acked >= st.probeOffset) {
693
707
  // One paced round completed (the timed frontier byte's ack landed).
694
708
  const rtt = nowT - st.probeSentMs;
695
709
  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++;
710
+ // A single very-low sample can be a batched/late ACK that makes a probe
711
+ // look newer than it is. But several consecutive low samples mean the
712
+ // path genuinely improved (most importantly tcp-relay LAN UDP). The
713
+ // old code rejected every such sample against the permanently-high srtt,
714
+ // so startup stayed pinned near 120 KB/s forever. Promote a stable lower
715
+ // RTT after three samples and rebase srtt/RTprop to the new path.
716
+ const lowSample = st.srttMs > 0 && rtt > 0 && rtt < st.srttMs * 0.4;
717
+ if (lowSample) {
718
+ st.lowRttCount++;
719
+ st.lowRttMinMs = Math.min(st.lowRttMinMs, rtt);
720
+ }
721
+ else {
722
+ st.lowRttCount = 0;
723
+ st.lowRttMinMs = Infinity;
724
+ }
725
+ const promoteLowPath = lowSample && st.lowRttCount >= 3;
726
+ const acceptedRtt = promoteLowPath ? st.lowRttMinMs : rtt;
727
+ if (promoteLowPath) {
728
+ st.srttMs = acceptedRtt;
729
+ st.minRttMs = acceptedRtt;
730
+ st.lowRttCount = 0;
731
+ st.lowRttMinMs = Infinity;
732
+ }
733
+ if ((!lowSample || promoteLowPath) && acceptedRtt > 0) {
703
734
  // 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)
735
+ st.srttMs = st.srttMs === 0 ? acceptedRtt : 0.85 * st.srttMs + 0.15 * acceptedRtt;
736
+ if (acceptedRtt < st.minRttMs)
737
+ st.minRttMs = acceptedRtt;
738
+ // Aggregate ACKs into a time-sized delivery sample. Previously every
739
+ // single ACK was treated as a BBR round and dt was clamped to 50 ms;
740
+ // on a 1–2 ms LAN that made the measured rate look permanently tiny
741
+ // and normal scheduler jitter immediately ended STARTUP.
742
+ const sampleMs = nowT - st.rateMarkMs;
743
+ if (sampleMs >= PACE_SAMPLE_MS) {
744
+ st.roundCount++;
745
+ const roundRate = ((st.acked - st.rateMarkAcked) * 1000) / sampleMs;
746
+ st.rateMarkMs = nowT;
747
+ st.rateMarkAcked = st.acked;
748
+ if (roundRate > 0) {
749
+ st.bwSamples.push(roundRate);
750
+ if (st.bwSamples.length > PACE_BW_WINDOW)
751
+ st.bwSamples.shift();
752
+ st.btlBwBps = Math.max(...st.bwSamples);
753
+ }
754
+ // Wire carries goodput + FEC parity + retransmits, so to fill the LINK
755
+ // we pace above goodput by the loss overhead: wire = goodput/(1 loss).
756
+ const lossMult = 1 / (1 - Math.min(0.5, st.lossEwma));
757
+ const lanNow = this.isLanPath(friendId);
758
+ if (lanNow && !st.lanPath) {
759
+ // A transfer can begin on relay and then promote to a validated
760
+ // physical-LAN endpoint. Restart STARTUP from the measured rate.
761
+ st.bbrPhase = 0;
762
+ st.fullBwBps = 0;
763
+ st.fullBwRounds = 0;
764
+ }
765
+ st.lanPath = lanNow;
766
+ if (!lanNow) {
767
+ // Relay/public-NAT file traffic must remain conservative. A short
768
+ // ACK burst can wildly overstate bandwidth there; allowing LAN's
769
+ // exponential STARTUP filled the TCP relay with an 8.7 s queue.
770
+ // This affects only file DATA/FEC — IP/video has its own path.
732
771
  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));
772
+ const gain = PACE_CYCLE_GAINS[st.roundCount % PACE_CYCLE_GAINS.length];
773
+ st.paceRateBps = Math.max(PACE_INIT_BPS, Math.min(PACE_INIT_BPS * 2, st.btlBwBps * gain * lossMult));
774
+ }
775
+ else if (st.bbrPhase === 0) {
776
+ // BBR full-bandwidth detection: remain in STARTUP while delivered
777
+ // bandwidth grows. A relative RTT threshold is unusable on LAN —
778
+ // 1 ms 2 ms scheduler jitter looked like a 100% queue and pinned
779
+ // every transfer near PACE_INIT_BPS. The absolute, smoothed queue
780
+ // 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;
783
+ st.fullBwRounds = 0;
784
+ }
785
+ else {
786
+ st.fullBwRounds++;
787
+ }
788
+ const rawQueue = st.minRttMs !== Infinity ? st.srttMs - st.minRttMs : 0;
789
+ if (st.fullBwRounds >= PACE_FULLBW_ROUNDS || rawQueue > MAX_QUEUE_MS) {
790
+ st.bbrPhase = 2;
791
+ }
792
+ if (st.bbrPhase === 0) {
793
+ st.paceRateBps = Math.max(st.paceRateBps * 1.4, st.btlBwBps * PACE_STARTUP_GAIN * lossMult);
794
+ }
795
+ else {
796
+ st.paceRateBps = Math.max(PACE_INIT_BPS * 0.5, st.btlBwBps * lossMult);
797
+ }
798
+ }
799
+ else {
800
+ // PROBE_BW cruise: one sample probes up, one drains, six cruise.
801
+ const gain = PACE_CYCLE_GAINS[st.roundCount % PACE_CYCLE_GAINS.length];
802
+ st.paceRateBps = Math.max(PACE_INIT_BPS * 0.5, st.btlBwBps * gain * lossMult);
803
+ }
804
+ // Hard safety cap: never pace faster than a full window drains in 1 RTT.
805
+ if (st.minRttMs !== Infinity && st.minRttMs > 0) {
806
+ st.paceRateBps = Math.min(st.paceRateBps, (WINDOW_CHUNKS * MAX_FILE_DATA_SIZE) / (st.minRttMs / 1000));
807
+ }
808
+ // 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)));
743
812
  }
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)));
813
+ }
814
+ if (process.env.DECENT_DEBUG && nowT - st.lastCcLogMs >= 1000) {
815
+ st.lastCcLogMs = nowT;
816
+ console.error(`[file-cc] friend=${friendId.slice(0, 8)} ack=${st.acked}/${st.size}` +
817
+ ` rtt=${rtt}ms srtt=${st.srttMs.toFixed(1)}ms low=${st.lowRttCount}` +
818
+ ` accepted=${(!lowSample || promoteLowPath) ? 1 : 0}` +
819
+ ` pace=${Math.round(st.paceRateBps)}Bps cwnd=${st.cwnd} rounds=${st.roundCount}`);
749
820
  }
750
821
  }
751
822
  if (st.acked - st.lastProgressAcked >= 256 * 1024 || st.acked >= st.size) {
@@ -861,7 +932,11 @@ export class FileTransferManager {
861
932
  try {
862
933
  await this.send(friendId, PACKET_ID_FILE_DATA, encodeFileData(st.fileNumber, off, st.data.subarray(off, end)));
863
934
  }
864
- catch {
935
+ catch (error) {
936
+ if (process.env.DECENT_DEBUG || process.env.DECENT_DEBUG_VERBOSE) {
937
+ console.error(`[file-ft] data-send-error friend=${friendId.slice(0, 8)} num=${st.fileNumber} ` +
938
+ `off=${off} error=${error instanceof Error ? error.message : String(error)}`);
939
+ }
865
940
  break; // transport down; the watchdog re-kicks
866
941
  }
867
942
  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.97",
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",