@decentnetwork/peer 0.1.69 → 0.1.71

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.
@@ -30,15 +30,23 @@ export const MAX_CONCURRENT_FILE_PIPES = 256;
30
30
  export const MAX_FILE_DATA_SIZE = 1367;
31
31
  export const FILECONTROL = { ACCEPT: 0, PAUSE: 1, KILL: 2, SEEK: 3, ACK: 4 };
32
32
  export const FILEKIND = { DATA: 0, AVATAR: 1 };
33
- // Reliability tuning. We use a FIXED in-flight window (not TCP-style AIMD): a
34
- // file transfer over a private direct path should treat packet loss as RANDOM
35
- // (lossy Wi-Fi / long-haul jitter), NOT as congestion backing the window off
36
- // on every random drop collapsed throughput to a crawl (minutes for a big
37
- // file). Instead, keep the window large and just retransmit the gaps fast. The
38
- // 4 MB UDP socket buffer absorbs the burst; the receiver buffers by offset, so
39
- // reordering is free.
40
- const WINDOW_CHUNKS = 768; // ~1 MB in flight — fills a 200ms-RTT path at ~5 MB/s
41
- const WATCHDOG_MS = 150; // resend from the ack point if no progress this long
33
+ // Reliability tuning. We RAMP an in-flight window (AIMD-lite) rather than pin it:
34
+ // - RANDOM single-packet loss (dup-ack with a gap) is NOT congestion fast-
35
+ // retransmit the hole and DON'T shrink the window, so lossy-but-fat paths
36
+ // (Wi-Fi / long-haul jitter) keep full throughput (the original intent).
37
+ // - A SUSTAINED stall (no ack progress for ~RTT) IS congestion halve the
38
+ // window so we stop flooding the path. This is what keeps the *session* alive
39
+ // during a big transfer over a thin/relayed hop: without it, a fixed 1 MB
40
+ // window blasted go-back-N saturates the path, starves the net_crypto
41
+ // keepalive, and the whole session (ping included) times out. On a good path
42
+ // the window ramps back to WINDOW_CHUNKS within a few RTTs → same speed.
43
+ // The 4 MB UDP socket buffer absorbs bursts; the receiver buffers by offset.
44
+ const WINDOW_CHUNKS = 768; // ~1 MB — the window CAP (fills a 200ms-RTT path at ~5 MB/s)
45
+ const CWND_INIT_CHUNKS = 96; // ~131 KB start — ramps to the cap fast on a clean path
46
+ const CWND_MIN_CHUNKS = 24; // ~33 KB floor — still enough to keep a thin path busy
47
+ const CWND_GROW_CHUNKS = 48; // additive increase per ack-progress event
48
+ const WATCHDOG_MS = 150; // stall poll cadence / base no-progress patience
49
+ const WATCHDOG_MAX_MS = 2400; // cap the RTT-adaptive stall patience (high-RTT relay paths)
42
50
  const FAST_RT_MIN_MS = 40; // min gap between fast-retransmits of the same hole (≈1 RTT)
43
51
  const ACK_THROTTLE_MS = 15; // coalesce receiver acks (fast enough to drive fast-retransmit)
44
52
  const REACK_MS = 200; // receiver keep-alive re-ack cadence
@@ -140,6 +148,7 @@ export class FileTransferManager {
140
148
  fileNumber, fileId, name: opts.name, data, size: data.length,
141
149
  acked: 0, nextSend: 0, pumping: false, started: false, req, startMs: nowMs(),
142
150
  lastProgressAcked: 0, lastAckAdvanceMs: nowMs(), lastResendMs: 0,
151
+ cwnd: CWND_INIT_CHUNKS, stalls: 0,
143
152
  };
144
153
  this.#map(this.#sending, friendId).set(fileNumber, st);
145
154
  void this.send(friendId, PACKET_ID_FILE_SENDREQUEST, req).catch(() => undefined);
@@ -360,9 +369,12 @@ export class FileTransferManager {
360
369
  const ackedOffset = readU32be(data, 0);
361
370
  const maxRecv = data.length >= 8 ? readU32be(data, 4) : ackedOffset;
362
371
  if (ackedOffset > st.acked) {
363
- // Progress.
372
+ // Progress. The path is delivering → additively re-open the window (up to
373
+ // the cap) and clear the stall streak (patience shrinks back to the base).
364
374
  st.acked = Math.min(ackedOffset, st.size);
365
375
  st.lastAckAdvanceMs = nowMs();
376
+ st.cwnd = Math.min(WINDOW_CHUNKS, st.cwnd + CWND_GROW_CHUNKS);
377
+ st.stalls = 0;
366
378
  if (st.acked - st.lastProgressAcked >= 256 * 1024 || st.acked >= st.size) {
367
379
  st.lastProgressAcked = st.acked;
368
380
  this.emit("file-progress", { friendId, fileId: hex(st.fileId), received: st.acked, total: st.size, sending: true });
@@ -402,7 +414,12 @@ export class FileTransferManager {
402
414
  }
403
415
  if (st.acked >= st.size)
404
416
  return;
405
- if (nowMs() - st.lastAckAdvanceMs < WATCHDOG_MS) {
417
+ // RTT-adaptive patience: after consecutive stalls, wait longer before
418
+ // deciding the path stalled — so a high-RTT relay hop doesn't trigger a
419
+ // spurious go-back-N re-blast before an ACK could even have returned
420
+ // (that spurious re-blast is exactly what collapses a thin path).
421
+ const patience = Math.min(WATCHDOG_MAX_MS, WATCHDOG_MS << Math.min(st.stalls, 4));
422
+ if (nowMs() - st.lastAckAdvanceMs < patience) {
406
423
  stalledSinceMs = nowMs();
407
424
  return;
408
425
  }
@@ -411,6 +428,12 @@ export class FileTransferManager {
411
428
  this.emit("file-cancel", { friendId, fileId: hex(st.fileId), sending: true, reason: "timeout" });
412
429
  return;
413
430
  }
431
+ // Sustained stall = congestion/path signal → multiplicatively cut the
432
+ // window so we STOP flooding. This frees the path for the net_crypto
433
+ // keepalive + acks, keeping the session (and ping) alive; the transfer
434
+ // then self-clocks back up as acks resume.
435
+ st.stalls++;
436
+ st.cwnd = Math.max(CWND_MIN_CHUNKS, Math.floor(st.cwnd / 2));
414
437
  st.lastAckAdvanceMs = nowMs();
415
438
  st.lastResendMs = nowMs();
416
439
  if (st.nextSend > st.acked)
@@ -425,7 +448,7 @@ export class FileTransferManager {
425
448
  st.pumping = true;
426
449
  try {
427
450
  while (this.#map(this.#sending, friendId).has(st.fileNumber) && st.acked < st.size) {
428
- const windowEnd = Math.min(st.acked + WINDOW_CHUNKS * MAX_FILE_DATA_SIZE, st.size);
451
+ const windowEnd = Math.min(st.acked + st.cwnd * MAX_FILE_DATA_SIZE, st.size);
429
452
  if (st.nextSend >= windowEnd)
430
453
  break; // window full / all sent — wait for acks
431
454
  const off = st.nextSend;
package/dist/peer.js CHANGED
@@ -82,6 +82,18 @@ const FRIEND_PING_INTERVAL_MS = readEnvInt("DECENT_FRIEND_PING_INTERVAL_MS", 400
82
82
  // slow enough not to burn CPU on idle peers. Tunable via env.
83
83
  const FRIEND_CONNECTION_LOOP_MS = readEnvInt("DECENT_FRIEND_CONNECTION_LOOP_MS", 250);
84
84
  const FRIEND_TIMEOUT_MS = readEnvInt("DECENT_FRIEND_TIMEOUT_MS", 32000);
85
+ // A session whose keys we've PROVEN (decrypted ≥1 real packet from the peer) is
86
+ // NOT torn down at FRIEND_TIMEOUT_MS. A transient all-transport blackout — e.g.
87
+ // the GFW throttling a China<->abroad path for tens of seconds — must not trigger
88
+ // a re-handshake: an ASYMMETRIC re-handshake (one side re-keys while the other
89
+ // keeps its session) is exactly what desyncs the two sessions and produces the
90
+ // connect/disconnect CHURN that breaks the exit. Instead we keep the proven keys
91
+ // and keep probing over every transport; when the path recovers, a single packet
92
+ // resumes the SAME session with no re-handshake. Only after this much longer hard
93
+ // window do we treat the path as truly dead and re-handshake. (Unproven sessions
94
+ // — established but never carried a packet — still tear down at FRIEND_TIMEOUT_MS
95
+ // so a wedged handshake self-heals.)
96
+ const PROVEN_SESSION_HARD_TIMEOUT_MS = readEnvInt("DECENT_PROVEN_SESSION_HARD_TIMEOUT_MS", 180_000);
85
97
  // How long the direct UDP path may go without an inbound packet before we
86
98
  // release a physical-LAN lock (so a peer that left the LAN can recover onto the
87
99
  // public/relay path). 12s ≈ 3 missed 4s keepalives — long enough to never trip
@@ -2539,8 +2551,24 @@ export class Peer {
2539
2551
  // non-established branch below, which re-initiates a fresh
2540
2552
  // connection — turning a permanent wedge into a ~timeout self-heal.
2541
2553
  const lastAlive = session.lastPingRecvMs ?? session.sessionEstablishedAtMs ?? now;
2542
- if (now - lastAlive > FRIEND_TIMEOUT_MS) {
2543
- this.#debugLog(`session timeout for ${friendId} (no ping in ${now - lastAlive}ms; ` +
2554
+ const silentFor = now - lastAlive;
2555
+ // Proven = we've decrypted ≥1 real packet, so the keys are known-good and
2556
+ // a silence is a transport blackout (GFW), not a desync. Give proven
2557
+ // sessions a long grace so a blackout doesn't churn them via re-handshake;
2558
+ // unproven sessions still time out fast to self-heal a wedged handshake.
2559
+ const proven = session.lastPingRecvMs !== undefined;
2560
+ const deadline = proven ? PROVEN_SESSION_HARD_TIMEOUT_MS : FRIEND_TIMEOUT_MS;
2561
+ if (proven && silentFor > FRIEND_TIMEOUT_MS && silentFor <= deadline) {
2562
+ // Blackout grace: keep the proven session and keep probing (the
2563
+ // keepalive + UDP re-punch + relay-keepalive below all still run). Do
2564
+ // NOT delete or re-handshake — that's what desyncs and churns. The peer,
2565
+ // running the same logic, also keeps its half, so when the path recovers
2566
+ // both resume in sync. Status stays as-is; the exit's own health probe
2567
+ // (multi-exit router) handles failover meanwhile.
2568
+ this.#debugVerboseLog(`session blackout-grace ${friendId} (silent ${silentFor}ms, proven — keeping keys, not re-handshaking)`);
2569
+ }
2570
+ else if (silentFor > deadline) {
2571
+ this.#debugLog(`session timeout for ${friendId} (no ping in ${silentFor}ms, proven=${proven}; ` +
2544
2572
  `lastPingRecv=${session.lastPingRecvMs ?? "never"}) — tearing down to re-handshake`);
2545
2573
  this.#friendSessions.delete(friendId);
2546
2574
  this.#setFriendOffline(friendId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.69",
3
+ "version": "0.1.71",
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",