@decentnetwork/peer 0.1.94 → 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
@@ -358,6 +361,8 @@ export class Peer {
358
361
  // Per-friend tracking sets so we send our nickname/status/greeting once
359
362
  // per session rather than on every PACKET_ID_ONLINE arrival.
360
363
  #profileSentTo = new Set();
364
+ #profileRetryAttempts = new Map();
365
+ #profileRetryTimers = new Map();
361
366
  #greetingSentTo = new Set();
362
367
  #selfAnnouncePromise;
363
368
  #selfAnnouncePauseDepth = 0;
@@ -595,6 +600,10 @@ export class Peer {
595
600
  async stop() {
596
601
  // Stop background producers first so no keepalive/profile packet can race
597
602
  // behind the shutdown notice and make the remote session look live again.
603
+ for (const timer of this.#profileRetryTimers.values())
604
+ clearTimeout(timer);
605
+ this.#profileRetryTimers.clear();
606
+ this.#profileRetryAttempts.clear();
598
607
  if (this.#expressPollTimer) {
599
608
  clearInterval(this.#expressPollTimer);
600
609
  this.#expressPollTimer = undefined;
@@ -981,6 +990,11 @@ export class Peer {
981
990
  this.#tcpOnlyWarningShown.delete(friendId);
982
991
  this.#noEndpointWarned.delete(friendId);
983
992
  this.#profileSentTo.delete(friendId);
993
+ const profileRetry = this.#profileRetryTimers.get(friendId);
994
+ if (profileRetry)
995
+ clearTimeout(profileRetry);
996
+ this.#profileRetryTimers.delete(friendId);
997
+ this.#profileRetryAttempts.delete(friendId);
984
998
  this.#greetingSentTo.delete(friendId);
985
999
  if (existed) {
986
1000
  this.#persistFriends();
@@ -2052,17 +2066,14 @@ export class Peer {
2052
2066
  // low-water mark, like toxcore net_crypto drops below buffer_start.
2053
2067
  //
2054
2068
  // EXCEPT single-transport channels (bulk IP = bulkDataPacketId, and
2055
- // file transfer 80-82): they are NOT dual-sent, so they never produce
2056
- // duplicates — and the low-water mark would then only drop a
2057
- // legitimately REORDERED packet, which over a high-rate stream (CCTV
2058
- // over a long-haul China↔US path that reorders) is pure added loss /
2059
- // jitter. IP forwarding is order-independent (the TUN/TCP above
2060
- // reassembles), so deliver every packet. Must run BEFORE the nonce
2061
- // 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.
2062
2075
  const innerKind = opened.payload.length > 0 ? opened.payload[0] : -1;
2063
2076
  const isSingleTransportKind = (this.#opts.bulkDataPacketId !== undefined && innerKind === this.#opts.bulkDataPacketId) ||
2064
- innerKind === PACKET_ID_FILE_SENDREQUEST ||
2065
- innerKind === PACKET_ID_FILE_CONTROL ||
2066
2077
  innerKind === PACKET_ID_FILE_DATA ||
2067
2078
  innerKind === PACKET_ID_FILE_FEC;
2068
2079
  // Confirm the transport path + liveness for EVERY decrypted packet —
@@ -3807,7 +3818,15 @@ export class Peer {
3807
3818
  // bulk-IP channel and lossy packets; the crypto nonce still advances above so
3808
3819
  // decryption stays in sync regardless.
3809
3820
  const isFileTransfer = kind === PACKET_ID_FILE_SENDREQUEST || kind === PACKET_ID_FILE_CONTROL || kind === PACKET_ID_FILE_DATA || kind === PACKET_ID_FILE_FEC;
3810
- 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;
3811
3830
  const isDroppable = (isBulkData && !isFileTransfer) || kind >= 192;
3812
3831
  if (!isDroppable) {
3813
3832
  session.sendPacketNumber = (packetNumber + 1) >>> 0;
@@ -3848,20 +3867,12 @@ export class Peer {
3848
3867
  // e.g. decentlan IP=163) stays bulk / single-transport to avoid the relay
3849
3868
  // backlog + duplicate fan-out that high packet rates cause. Chat is sparse
3850
3869
  // and net_crypto dedups by packet number, so dual-send is safe here.
3851
- // File transfer (80 sendrequest / 81 control / 82 data) is ALSO treated as
3852
- // bulk single-transport. It is high-volume AND order-sensitive: the
3853
- // receiver reassembles chunks SEQUENTIALLY (position is implicit) and there
3854
- // is no retransmit queue. Dual-sending it over UDP + relay (two paths with
3855
- // different latency) reorders the chunks, and the receive-side low-water
3856
- // dedup then drops the late copies — so a large file (mp4) gets gaps /
3857
- // wrong order and never completes, while a tiny one (png, 1-2 chunks)
3858
- // happens to finish before reordering bites. Pinning file transfer to a
3859
- // single transport keeps the chunks in send order. (Reliability across a
3860
- // lossy path still wants a reorder buffer + retransmit — tracked separately.)
3861
- // File transfer rides the relay RELIABLY (never dropped under backpressure):
3862
- // a dropped IP/CCTV packet is fine (the stream moves on), but a dropped file
3863
- // chunk corrupts the file (no retransmit). So bulk IP data stays droppable
3864
- // 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.)
3865
3876
  await this.#sendToFriend(friendId, encrypted, session, isBulkData, isFileTransfer);
3866
3877
  }
3867
3878
  /**
@@ -3885,6 +3896,15 @@ export class Peer {
3885
3896
  const udpFresh = !!realUdpRemote &&
3886
3897
  s?.lastUdpRecvMs !== undefined &&
3887
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);
3888
3908
  // Bulk IP data rides the direct path ONLY while it's fresh. When it
3889
3909
  // goes stale (likely a NAT remap mid-stream), we do NOT keep firing
3890
3910
  // into the dead endpoint and we do NOT duplicate onto UDP — we bridge
@@ -3892,7 +3912,8 @@ export class Peer {
3892
3912
  // lastUdpRecvMs). This converts a multi-second blackout into a few
3893
3913
  // seconds of higher relay latency. Control packets always probe UDP
3894
3914
  // (to keep the NAT mapping warm) so we still try UDP for them.
3895
- const tryUdp = realUdpRemote && (isBulkData ? udpFresh : true);
3915
+ const tryUdp = realUdpRemote &&
3916
+ (isBulkData ? udpFresh && fileUdpOnlyConfirmed : true);
3896
3917
  if (tryUdp && s?.remote) {
3897
3918
  try {
3898
3919
  await this.#sendPacket(packet, s.remote);
@@ -3902,9 +3923,9 @@ export class Peer {
3902
3923
  firstError = error;
3903
3924
  }
3904
3925
  }
3905
- // Fresh direct path → send over UDP only but ONLY for bulk data.
3906
- // Bulk is high-volume and must avoid relay fan-out / backlog, so once
3907
- // 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.
3908
3929
  //
3909
3930
  // Low-volume traffic (chat 64 + control keepalives / endpoint offers /
3910
3931
  // profile) instead ALSO goes over the relay even when the direct path
@@ -3992,6 +4013,10 @@ export class Peer {
3992
4013
  if (info.description !== undefined)
3993
4014
  opts.statusMessage = info.description;
3994
4015
  this.#profileSentTo.clear();
4016
+ for (const timer of this.#profileRetryTimers.values())
4017
+ clearTimeout(timer);
4018
+ this.#profileRetryTimers.clear();
4019
+ this.#profileRetryAttempts.clear();
3995
4020
  for (const [friendId, s] of this.#friendSessions.entries()) {
3996
4021
  if (s.established)
3997
4022
  this.#sendProfileAndGreeting(friendId);
@@ -4005,6 +4030,25 @@ export class Peer {
4005
4030
  description: opts.statusMessage ?? PEER_STATUS_MESSAGE,
4006
4031
  };
4007
4032
  }
4033
+ #scheduleProfileRetry(friendId) {
4034
+ if (this.#profileRetryTimers.has(friendId))
4035
+ return;
4036
+ const session = this.#friendSessions.get(friendId);
4037
+ if (!this.#started || !session?.established)
4038
+ return;
4039
+ const attempt = (this.#profileRetryAttempts.get(friendId) ?? 0) + 1;
4040
+ this.#profileRetryAttempts.set(friendId, attempt);
4041
+ const delayMs = Math.min(30_000, 1_000 * (2 ** Math.min(attempt - 1, 5)));
4042
+ const timer = setTimeout(() => {
4043
+ this.#profileRetryTimers.delete(friendId);
4044
+ if (this.#started && this.#friendSessions.get(friendId)?.established) {
4045
+ this.#sendProfileAndGreeting(friendId);
4046
+ }
4047
+ }, delayMs);
4048
+ timer.unref?.();
4049
+ this.#profileRetryTimers.set(friendId, timer);
4050
+ this.#debugLog(`profile retry ${attempt} for ${friendId} scheduled in ${delayMs}ms`);
4051
+ }
4008
4052
  #sendProfileAndGreeting(friendId) {
4009
4053
  if (!this.#profileSentTo.has(friendId)) {
4010
4054
  this.#profileSentTo.add(friendId);
@@ -4044,9 +4088,16 @@ export class Peer {
4044
4088
  });
4045
4089
  await this.#sendMessengerPacket(friendId, PACKET_ID_STATUSMESSAGE, userInfo);
4046
4090
  this.#debugLog(`userinfo sent to ${friendId} (name="${nick}", descr="${descr}", proto=${AGENTNET_PROTO_VERSION}, platform=${this.#opts.platform ?? process.platform})`);
4091
+ this.#profileRetryAttempts.delete(friendId);
4047
4092
  }
4048
4093
  catch (error) {
4094
+ // PACKET_ID_ONLINE can arrive a fraction before every transport is
4095
+ // ready to accept messenger packets. Do not leave the optimistic
4096
+ // "sent" marker wedged after that race: re-arm it and retry with
4097
+ // bounded backoff while this same session remains established.
4098
+ this.#profileSentTo.delete(friendId);
4049
4099
  this.#debugLog(`send profile failed for ${friendId}: ${error.message}`);
4100
+ this.#scheduleProfileRetry(friendId);
4050
4101
  }
4051
4102
  })();
4052
4103
  }
@@ -5378,6 +5429,11 @@ export class Peer {
5378
5429
  // transitions prevents the user from seeing the same greeting message
5379
5430
  // repeated each time the session bounces.
5380
5431
  this.#profileSentTo.delete(friendId);
5432
+ const profileRetry = this.#profileRetryTimers.get(friendId);
5433
+ if (profileRetry)
5434
+ clearTimeout(profileRetry);
5435
+ this.#profileRetryTimers.delete(friendId);
5436
+ this.#profileRetryAttempts.delete(friendId);
5381
5437
  const friend = this.#friends.get(friendId);
5382
5438
  if (!friend) {
5383
5439
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.94",
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",