@decentnetwork/peer 0.1.71 → 0.1.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/compat/filetransfer.d.ts +3 -0
- package/dist/compat/filetransfer.js +155 -13
- package/dist/peer.js +4 -0
- package/dist/types/peer.d.ts +7 -0
- package/package.json +1 -1
|
@@ -44,6 +44,9 @@ export declare class FileTransferManager {
|
|
|
44
44
|
private readonly send;
|
|
45
45
|
private readonly emit;
|
|
46
46
|
constructor(send: FtSend, emit: FtEmit);
|
|
47
|
+
/** Enable resumable receives: partials are persisted under `dir` and matched
|
|
48
|
+
* on re-offer by content-hash fileId. Unset = in-memory only (no resume). */
|
|
49
|
+
setResumeDir(dir: string): void;
|
|
47
50
|
sendFile(friendId: string, data: Uint8Array, opts: {
|
|
48
51
|
name: string;
|
|
49
52
|
kind?: number;
|
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
// implicit position); both ends here are the JS SDK. A native-interop mode
|
|
21
21
|
// (Phase 2) can negotiate the plain format via the send-request.
|
|
22
22
|
import { concatBytes, randomBytes } from "../utils/bytes.js";
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
24
|
+
import { existsSync, statSync, readFileSync, appendFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
23
26
|
export const PACKET_ID_FILE_SENDREQUEST = 80;
|
|
24
27
|
export const PACKET_ID_FILE_CONTROL = 81;
|
|
25
28
|
export const PACKET_ID_FILE_DATA = 82;
|
|
@@ -42,15 +45,34 @@ export const FILEKIND = { DATA: 0, AVATAR: 1 };
|
|
|
42
45
|
// the window ramps back to WINDOW_CHUNKS within a few RTTs → same speed.
|
|
43
46
|
// The 4 MB UDP socket buffer absorbs bursts; the receiver buffers by offset.
|
|
44
47
|
const WINDOW_CHUNKS = 768; // ~1 MB — the window CAP (fills a 200ms-RTT path at ~5 MB/s)
|
|
45
|
-
const CWND_INIT_CHUNKS =
|
|
46
|
-
const CWND_MIN_CHUNKS =
|
|
47
|
-
|
|
48
|
+
const CWND_INIT_CHUNKS = 16; // ~22 KB start — small so the opening burst can't bloat a thin path
|
|
49
|
+
const CWND_MIN_CHUNKS = 8; // ~11 KB floor — keeps a slow/flaky path's buffer shallow
|
|
50
|
+
// Congestion control (TCP Vegas — DELAY-based). Loss/AIMD control fails on a
|
|
51
|
+
// slow peer with a deep buffer (a user's laptop): ~1 MB in flight piles up as
|
|
52
|
+
// 12-23 s of queue latency with NO loss, so it never backs off, and the stalls
|
|
53
|
+
// trigger go-back-N resends that balloon the queue further. Vegas instead reads
|
|
54
|
+
// how many chunks are QUEUED from the RTT inflation — cwnd × (1 − minRtt/RTT) —
|
|
55
|
+
// and steers the window to keep that between ALPHA and BETA. When the queue is
|
|
56
|
+
// below ALPHA there's spare capacity → grow (no self-limiting, unlike a
|
|
57
|
+
// rate-based cap); above BETA the queue is too deep → shrink. It converges to
|
|
58
|
+
// "pipe full + a couple chunks queued" on ANY path: a fast link gets a big
|
|
59
|
+
// window, a thin flaky one a small window and a shallow queue, automatically.
|
|
60
|
+
// Target the ABSOLUTE queue delay (rtt − minRtt), not a packet count: bound the
|
|
61
|
+
// latency the transfer ADDS to the path to ≈ MAX_QUEUE_MS on ANY path. A fast
|
|
62
|
+
// link keeps a big window (it drains quickly, so a big window adds little
|
|
63
|
+
// delay); a thin flaky one keeps a small window (a big window there would add
|
|
64
|
+
// seconds). Growing whenever the added delay is low means no self-limiting.
|
|
65
|
+
const MAX_QUEUE_MS = 150; // ceiling on added latency → when exceeded, drain
|
|
66
|
+
const QUEUE_LOW_MS = 60; // below this there's spare capacity → grow
|
|
67
|
+
const VEGAS_GROW = 1.3; // window ×this per RTT when under-filled
|
|
68
|
+
const VEGAS_SHRINK = 0.85; // window ×this per RTT when the queue is too deep
|
|
48
69
|
const WATCHDOG_MS = 150; // stall poll cadence / base no-progress patience
|
|
49
|
-
const WATCHDOG_MAX_MS =
|
|
70
|
+
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)
|
|
50
71
|
const FAST_RT_MIN_MS = 40; // min gap between fast-retransmits of the same hole (≈1 RTT)
|
|
51
72
|
const ACK_THROTTLE_MS = 15; // coalesce receiver acks (fast enough to drive fast-retransmit)
|
|
52
73
|
const REACK_MS = 200; // receiver keep-alive re-ack cadence
|
|
53
74
|
const SEND_GIVEUP_MS = 60000; // abandon a send after this long with no ack progress (peer gone)
|
|
75
|
+
const PERSIST_INTERVAL_BYTES = 1024 * 1024; // flush the resume file at most once per this much progress
|
|
54
76
|
function u32be(n) {
|
|
55
77
|
const b = new Uint8Array(4);
|
|
56
78
|
b[0] = (n >>> 24) & 0xff;
|
|
@@ -119,10 +141,27 @@ export class FileTransferManager {
|
|
|
119
141
|
emit;
|
|
120
142
|
#sending = new Map();
|
|
121
143
|
#receiving = new Map();
|
|
144
|
+
// When set, incoming transfers persist their contiguous prefix here so a
|
|
145
|
+
// dropped/restarted transfer resumes instead of restarting (断点续传).
|
|
146
|
+
#resumeDir;
|
|
122
147
|
constructor(send, emit) {
|
|
123
148
|
this.send = send;
|
|
124
149
|
this.emit = emit;
|
|
125
150
|
}
|
|
151
|
+
/** Enable resumable receives: partials are persisted under `dir` and matched
|
|
152
|
+
* on re-offer by content-hash fileId. Unset = in-memory only (no resume). */
|
|
153
|
+
setResumeDir(dir) {
|
|
154
|
+
try {
|
|
155
|
+
mkdirSync(dir, { recursive: true });
|
|
156
|
+
this.#resumeDir = dir;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
this.#resumeDir = undefined;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
#partPath(fileId) {
|
|
163
|
+
return this.#resumeDir ? join(this.#resumeDir, hex(fileId) + ".part") : undefined;
|
|
164
|
+
}
|
|
126
165
|
#map(m, friendId) {
|
|
127
166
|
let inner = m.get(friendId);
|
|
128
167
|
if (!inner) {
|
|
@@ -142,13 +181,20 @@ export class FileTransferManager {
|
|
|
142
181
|
const fileNumber = this.#freeNumber(friendId);
|
|
143
182
|
if (fileNumber < 0)
|
|
144
183
|
return null;
|
|
145
|
-
|
|
184
|
+
// Content-addressed fileId (sha256): re-sending the SAME file after a
|
|
185
|
+
// restart or a dropped peer yields the SAME id, so the receiver can match
|
|
186
|
+
// it against its persisted partial and resume from where it left off — the
|
|
187
|
+
// key to 断点续传 (resumable transfer). Falls back to random for empty data.
|
|
188
|
+
const fileId = data.length > 0
|
|
189
|
+
? new Uint8Array(createHash("sha256").update(data).digest()).slice(0, FILE_ID_LENGTH)
|
|
190
|
+
: randomBytes(FILE_ID_LENGTH);
|
|
146
191
|
const req = encodeFileSendRequest(fileNumber, opts.kind ?? FILEKIND.DATA, BigInt(data.length), fileId, opts.name);
|
|
147
192
|
const st = {
|
|
148
193
|
fileNumber, fileId, name: opts.name, data, size: data.length,
|
|
149
194
|
acked: 0, nextSend: 0, pumping: false, started: false, req, startMs: nowMs(),
|
|
150
195
|
lastProgressAcked: 0, lastAckAdvanceMs: nowMs(), lastResendMs: 0,
|
|
151
196
|
cwnd: CWND_INIT_CHUNKS, stalls: 0,
|
|
197
|
+
minRttMs: Infinity, srttMs: 0, probeOffset: -1, probeSentMs: 0,
|
|
152
198
|
};
|
|
153
199
|
this.#map(this.#sending, friendId).set(fileNumber, st);
|
|
154
200
|
void this.send(friendId, PACKET_ID_FILE_SENDREQUEST, req).catch(() => undefined);
|
|
@@ -234,10 +280,42 @@ export class FileTransferManager {
|
|
|
234
280
|
const st = {
|
|
235
281
|
fileNumber: r.fileNumber, fileId: r.fileId, name: r.filename, size,
|
|
236
282
|
buf: new Uint8Array(size), got: new Uint8Array(chunkCount(size)),
|
|
237
|
-
contiguous: 0, maxByte: 0, done: false, ackDirty: false,
|
|
283
|
+
contiguous: 0, maxByte: 0, persisted: 0, partPath: this.#partPath(r.fileId), done: false, ackDirty: false,
|
|
238
284
|
};
|
|
285
|
+
// 断点续传: if we persisted a contiguous prefix of this exact file (matched
|
|
286
|
+
// by content-hash fileId) before a drop/restart, load it and resume — our
|
|
287
|
+
// ack will tell the sender to skip past what we already have.
|
|
288
|
+
if (st.partPath && existsSync(st.partPath)) {
|
|
289
|
+
try {
|
|
290
|
+
const have = Math.min(statSync(st.partPath).size, size);
|
|
291
|
+
// Only the whole-chunk prefix is trustworthy; drop any tail into a
|
|
292
|
+
// partial last chunk. contiguous must sit on a chunk boundary unless
|
|
293
|
+
// it's the final byte.
|
|
294
|
+
const chunks = Math.floor(have / MAX_FILE_DATA_SIZE);
|
|
295
|
+
const usable = Math.min(chunks * MAX_FILE_DATA_SIZE, size);
|
|
296
|
+
if (usable > 0) {
|
|
297
|
+
const partial = readFileSync(st.partPath);
|
|
298
|
+
st.buf.set(partial.subarray(0, usable), 0);
|
|
299
|
+
for (let i = 0; i < chunks; i++)
|
|
300
|
+
st.got[i] = 1;
|
|
301
|
+
st.contiguous = usable;
|
|
302
|
+
st.maxByte = usable;
|
|
303
|
+
st.persisted = usable;
|
|
304
|
+
// Truncate the file to exactly the whole-chunk prefix, so subsequent
|
|
305
|
+
// appends continue from `usable` (a leftover partial last chunk would
|
|
306
|
+
// otherwise desync the append offset from st.persisted).
|
|
307
|
+
if (have > usable)
|
|
308
|
+
writeFileSync(st.partPath, st.buf.subarray(0, usable));
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
catch { /* corrupt/unreadable partial → start fresh */ }
|
|
312
|
+
}
|
|
239
313
|
this.#map(this.#receiving, friendId).set(r.fileNumber, st);
|
|
240
314
|
this.emit("file-offer", { friendId, fileNumber: r.fileNumber, fileId: hex(r.fileId), name: r.filename, size, kind: r.fileType });
|
|
315
|
+
if (st.contiguous >= size) {
|
|
316
|
+
this.#finishRecv(friendId, st);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
241
319
|
if (size === 0)
|
|
242
320
|
this.#finishRecv(friendId, st);
|
|
243
321
|
}
|
|
@@ -309,6 +387,7 @@ export class FileTransferManager {
|
|
|
309
387
|
i++;
|
|
310
388
|
st.contiguous = Math.min(i * MAX_FILE_DATA_SIZE, st.size);
|
|
311
389
|
this.emit("file-progress", { friendId, fileId: hex(st.fileId), received: st.contiguous, total: st.size });
|
|
390
|
+
this.#persistPartial(st);
|
|
312
391
|
}
|
|
313
392
|
if (st.contiguous >= st.size) {
|
|
314
393
|
this.#finishRecv(friendId, st);
|
|
@@ -316,6 +395,22 @@ export class FileTransferManager {
|
|
|
316
395
|
}
|
|
317
396
|
this.#scheduleAck(friendId, st);
|
|
318
397
|
}
|
|
398
|
+
// Flush the newly-contiguous bytes to the resume file, throttled so we do at
|
|
399
|
+
// most one append per PERSIST_INTERVAL_BYTES of progress (not per chunk).
|
|
400
|
+
#persistPartial(st, force = false) {
|
|
401
|
+
if (!st.partPath)
|
|
402
|
+
return;
|
|
403
|
+
const pending = st.contiguous - st.persisted;
|
|
404
|
+
if (pending <= 0)
|
|
405
|
+
return;
|
|
406
|
+
if (!force && pending < PERSIST_INTERVAL_BYTES)
|
|
407
|
+
return;
|
|
408
|
+
try {
|
|
409
|
+
appendFileSync(st.partPath, st.buf.subarray(st.persisted, st.contiguous));
|
|
410
|
+
st.persisted = st.contiguous;
|
|
411
|
+
}
|
|
412
|
+
catch { /* disk issue → keep going in memory; resume just won't cover this gap */ }
|
|
413
|
+
}
|
|
319
414
|
#finishRecv(friendId, st) {
|
|
320
415
|
st.done = true;
|
|
321
416
|
if (st.ackTimer) {
|
|
@@ -328,6 +423,13 @@ export class FileTransferManager {
|
|
|
328
423
|
}
|
|
329
424
|
this.#sendAck(friendId, st); // final ack so the sender completes
|
|
330
425
|
const data = st.buf;
|
|
426
|
+
// Transfer complete → the resume file is no longer needed.
|
|
427
|
+
if (st.partPath) {
|
|
428
|
+
try {
|
|
429
|
+
unlinkSync(st.partPath);
|
|
430
|
+
}
|
|
431
|
+
catch { /* already gone */ }
|
|
432
|
+
}
|
|
331
433
|
this.emit("file-complete", { friendId, fileId: hex(st.fileId), name: st.name, size: st.size, data });
|
|
332
434
|
// Keep a lightweight done-marker (st.contiguous == size) so a LOST final
|
|
333
435
|
// ack can be recovered: any further FILE_DATA for this slot re-acks. Free
|
|
@@ -371,10 +473,42 @@ export class FileTransferManager {
|
|
|
371
473
|
if (ackedOffset > st.acked) {
|
|
372
474
|
// Progress. The path is delivering → additively re-open the window (up to
|
|
373
475
|
// the cap) and clear the stall streak (patience shrinks back to the base).
|
|
476
|
+
const nowT = nowMs();
|
|
374
477
|
st.acked = Math.min(ackedOffset, st.size);
|
|
375
|
-
st.lastAckAdvanceMs =
|
|
376
|
-
st.cwnd = Math.min(WINDOW_CHUNKS, st.cwnd + CWND_GROW_CHUNKS);
|
|
478
|
+
st.lastAckAdvanceMs = nowT;
|
|
377
479
|
st.stalls = 0;
|
|
480
|
+
// Resume: if the receiver's ack jumps ahead of where we've sent (it had a
|
|
481
|
+
// persisted partial), skip the send cursor past the bytes it already has
|
|
482
|
+
// instead of re-transmitting them.
|
|
483
|
+
if (st.nextSend < st.acked)
|
|
484
|
+
st.nextSend = st.acked;
|
|
485
|
+
// Vegas: adjust the window ONCE PER RTT, when the timed frontier byte's ack
|
|
486
|
+
// returns (per-ack would fire several times before a slow path's probe
|
|
487
|
+
// reported, overshooting). Estimate chunks queued from RTT inflation and
|
|
488
|
+
// steer to keep it in [ALPHA, BETA].
|
|
489
|
+
if (st.probeOffset >= 0 && st.acked >= st.probeOffset) {
|
|
490
|
+
const rtt = nowT - st.probeSentMs;
|
|
491
|
+
st.probeOffset = -1;
|
|
492
|
+
// Reject glitch samples: a batched/late ack can make a probe byte look
|
|
493
|
+
// acked far sooner than it was really delivered, producing an RTT far
|
|
494
|
+
// below the smoothed value — which would corrupt minRtt and wreck the
|
|
495
|
+
// queue estimate. Only trust a sample within a sane band of srtt.
|
|
496
|
+
const glitch = st.srttMs > 0 && rtt < st.srttMs * 0.4;
|
|
497
|
+
if (!glitch && rtt > 0) {
|
|
498
|
+
st.srttMs = st.srttMs === 0 ? rtt : 0.85 * st.srttMs + 0.15 * rtt;
|
|
499
|
+
if (rtt < st.minRttMs)
|
|
500
|
+
st.minRttMs = rtt;
|
|
501
|
+
// Added latency this transfer is putting on the path = how deep the
|
|
502
|
+
// queue is. Steer the window to keep it under MAX_QUEUE_MS.
|
|
503
|
+
const queueDelayMs = st.minRttMs !== Infinity ? rtt - st.minRttMs : 0;
|
|
504
|
+
if (queueDelayMs < QUEUE_LOW_MS) {
|
|
505
|
+
st.cwnd = Math.min(WINDOW_CHUNKS, Math.ceil(st.cwnd * VEGAS_GROW)); // spare capacity → grow
|
|
506
|
+
}
|
|
507
|
+
else if (queueDelayMs > MAX_QUEUE_MS) {
|
|
508
|
+
st.cwnd = Math.max(CWND_MIN_CHUNKS, Math.floor(st.cwnd * VEGAS_SHRINK)); // queue too deep → drain
|
|
509
|
+
} // else hold: pipe full, queue shallow
|
|
510
|
+
}
|
|
511
|
+
}
|
|
378
512
|
if (st.acked - st.lastProgressAcked >= 256 * 1024 || st.acked >= st.size) {
|
|
379
513
|
st.lastProgressAcked = st.acked;
|
|
380
514
|
this.emit("file-progress", { friendId, fileId: hex(st.fileId), received: st.acked, total: st.size, sending: true });
|
|
@@ -414,11 +548,13 @@ export class FileTransferManager {
|
|
|
414
548
|
}
|
|
415
549
|
if (st.acked >= st.size)
|
|
416
550
|
return;
|
|
417
|
-
// RTT-adaptive patience:
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
|
|
551
|
+
// RTT-adaptive patience: NEVER declare a stall before ~3 RTTs have
|
|
552
|
+
// passed, or a high-RTT path (acks legitimately arrive every 800ms+)
|
|
553
|
+
// false-stalls every 150ms, collapsing the window to the floor and
|
|
554
|
+
// go-back-N-re-blasting — exactly what pinned a thin path at the minimum.
|
|
555
|
+
// Take the max of the exponential backoff and 3× the smoothed RTT.
|
|
556
|
+
const rttFloor = st.srttMs > 0 ? st.srttMs * 3 : WATCHDOG_MS;
|
|
557
|
+
const patience = Math.min(WATCHDOG_MAX_MS, Math.max(WATCHDOG_MS << Math.min(st.stalls, 4), rttFloor));
|
|
422
558
|
if (nowMs() - st.lastAckAdvanceMs < patience) {
|
|
423
559
|
stalledSinceMs = nowMs();
|
|
424
560
|
return;
|
|
@@ -461,6 +597,12 @@ export class FileTransferManager {
|
|
|
461
597
|
}
|
|
462
598
|
if (st.nextSend === off)
|
|
463
599
|
st.nextSend = end; // unless a rewind moved it
|
|
600
|
+
// Arm one RTT probe per round-trip: time this fresh frontier byte until
|
|
601
|
+
// its ack lands, so #onAck can read the path's current queue delay.
|
|
602
|
+
if (st.probeOffset < 0 && end > st.acked) {
|
|
603
|
+
st.probeOffset = end;
|
|
604
|
+
st.probeSentMs = nowMs();
|
|
605
|
+
}
|
|
464
606
|
}
|
|
465
607
|
}
|
|
466
608
|
finally {
|
package/dist/peer.js
CHANGED
|
@@ -312,6 +312,10 @@ export class Peer {
|
|
|
312
312
|
};
|
|
313
313
|
this.#knownNodes = [...opts.bootstrapNodes];
|
|
314
314
|
this.#friendStoreFile = opts.friendStoreFile ?? `${opts.keyFile}.friends.json`;
|
|
315
|
+
// Enable resumable (断点续传) receives when the app gives us a directory to
|
|
316
|
+
// persist partials in.
|
|
317
|
+
if (opts.fileResumeDir)
|
|
318
|
+
this.#fileTransfer.setResumeDir(opts.fileResumeDir);
|
|
315
319
|
}
|
|
316
320
|
static async create(opts) {
|
|
317
321
|
if (opts.compatibilityMode && opts.compatibilityMode !== "legacy") {
|
package/dist/types/peer.d.ts
CHANGED
|
@@ -44,6 +44,13 @@ export type PeerOptions = {
|
|
|
44
44
|
* optimisation only applies to packet 64 and custom-id data is duplicated.
|
|
45
45
|
*/
|
|
46
46
|
bulkDataPacketId?: number;
|
|
47
|
+
/**
|
|
48
|
+
* Directory for persisting partially-received files so a dropped or
|
|
49
|
+
* restarted transfer resumes instead of restarting (断点续传). Unset =
|
|
50
|
+
* in-memory only (no resume). The fileId is content-addressed (sha256), so a
|
|
51
|
+
* re-send of the same file matches its persisted partial automatically.
|
|
52
|
+
*/
|
|
53
|
+
fileResumeDir?: string;
|
|
47
54
|
/**
|
|
48
55
|
* Display name this peer advertises to friends (Carrier nickname, packet 48 +
|
|
49
56
|
* the userinfo profile). Without it every node reports the build default
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/peer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.72",
|
|
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",
|