@decentnetwork/peer 0.1.53 → 0.1.54
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.js +105 -70
- package/package.json +1 -1
|
@@ -30,18 +30,18 @@ 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
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const
|
|
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
|
|
42
|
+
const FAST_RT_MIN_MS = 40; // min gap between fast-retransmits of the same hole (≈1 RTT)
|
|
43
|
+
const ACK_THROTTLE_MS = 15; // coalesce receiver acks (fast enough to drive fast-retransmit)
|
|
44
|
+
const REACK_MS = 200; // receiver keep-alive re-ack cadence
|
|
45
45
|
const SEND_GIVEUP_MS = 60000; // abandon a send after this long with no ack progress (peer gone)
|
|
46
46
|
function u32be(n) {
|
|
47
47
|
const b = new Uint8Array(4);
|
|
@@ -135,14 +135,34 @@ export class FileTransferManager {
|
|
|
135
135
|
if (fileNumber < 0)
|
|
136
136
|
return null;
|
|
137
137
|
const fileId = randomBytes(FILE_ID_LENGTH);
|
|
138
|
+
const req = encodeFileSendRequest(fileNumber, opts.kind ?? FILEKIND.DATA, BigInt(data.length), fileId, opts.name);
|
|
138
139
|
const st = {
|
|
139
140
|
fileNumber, fileId, name: opts.name, data, size: data.length,
|
|
140
|
-
acked: 0,
|
|
141
|
-
|
|
141
|
+
acked: 0, highestSent: 0, started: false, req, startMs: nowMs(),
|
|
142
|
+
lastProgressAcked: 0, lastAckAdvanceMs: nowMs(), lastResendMs: 0,
|
|
142
143
|
};
|
|
143
144
|
this.#map(this.#sending, friendId).set(fileNumber, st);
|
|
144
|
-
const req = encodeFileSendRequest(fileNumber, opts.kind ?? FILEKIND.DATA, BigInt(data.length), fileId, opts.name);
|
|
145
145
|
void this.send(friendId, PACKET_ID_FILE_SENDREQUEST, req).catch(() => undefined);
|
|
146
|
+
// Retransmit the offer until the receiver responds — on a lossy path the
|
|
147
|
+
// offer itself can drop and nothing else would start the transfer.
|
|
148
|
+
let offerSinceMs = nowMs();
|
|
149
|
+
st.offerTimer = setInterval(() => {
|
|
150
|
+
const cur = this.#map(this.#sending, friendId).get(fileNumber);
|
|
151
|
+
if (!cur || cur.started) {
|
|
152
|
+
if (st.offerTimer)
|
|
153
|
+
clearInterval(st.offerTimer);
|
|
154
|
+
st.offerTimer = undefined;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (nowMs() - offerSinceMs >= SEND_GIVEUP_MS) {
|
|
158
|
+
this.#endSend(friendId, fileNumber);
|
|
159
|
+
this.emit("file-cancel", { friendId, fileId: hex(fileId), sending: true, reason: "no-response" });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
void this.send(friendId, PACKET_ID_FILE_SENDREQUEST, cur.req).catch(() => undefined);
|
|
163
|
+
}, WATCHDOG_MS * 3);
|
|
164
|
+
if (typeof st.offerTimer.unref === "function")
|
|
165
|
+
st.offerTimer.unref();
|
|
146
166
|
return hex(fileId);
|
|
147
167
|
}
|
|
148
168
|
accept(friendId, fileNumber) {
|
|
@@ -164,7 +184,7 @@ export class FileTransferManager {
|
|
|
164
184
|
return;
|
|
165
185
|
}
|
|
166
186
|
this.#sendAck(friendId, cur);
|
|
167
|
-
},
|
|
187
|
+
}, REACK_MS);
|
|
168
188
|
if (typeof st.reackTimer.unref === "function")
|
|
169
189
|
st.reackTimer.unref();
|
|
170
190
|
}
|
|
@@ -198,8 +218,10 @@ export class FileTransferManager {
|
|
|
198
218
|
return;
|
|
199
219
|
const size = Number(r.fileSize);
|
|
200
220
|
const existing = this.#map(this.#receiving, friendId).get(r.fileNumber);
|
|
201
|
-
if (existing && !existing.done)
|
|
202
|
-
|
|
221
|
+
if (existing && !existing.done) {
|
|
222
|
+
this.#sendAck(friendId, existing);
|
|
223
|
+
return;
|
|
224
|
+
} // re-offer (our accept/ack lost) → re-ack
|
|
203
225
|
const st = {
|
|
204
226
|
fileNumber: r.fileNumber, fileId: r.fileId, name: r.filename, size,
|
|
205
227
|
buf: new Uint8Array(size), got: new Uint8Array(chunkCount(size)),
|
|
@@ -219,8 +241,17 @@ export class FileTransferManager {
|
|
|
219
241
|
const st = this.#map(this.#sending, friendId).get(c.fileNumber);
|
|
220
242
|
if (!st)
|
|
221
243
|
return;
|
|
244
|
+
if (c.controlType === FILECONTROL.ACCEPT || c.controlType === FILECONTROL.ACK) {
|
|
245
|
+
if (!st.started) {
|
|
246
|
+
st.started = true;
|
|
247
|
+
if (st.offerTimer) {
|
|
248
|
+
clearInterval(st.offerTimer);
|
|
249
|
+
st.offerTimer = undefined;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
222
253
|
if (c.controlType === FILECONTROL.ACCEPT) {
|
|
223
|
-
|
|
254
|
+
this.#pump(friendId, st);
|
|
224
255
|
}
|
|
225
256
|
else if (c.controlType === FILECONTROL.ACK) {
|
|
226
257
|
this.#onAck(friendId, st, c.data);
|
|
@@ -328,41 +359,33 @@ export class FileTransferManager {
|
|
|
328
359
|
return;
|
|
329
360
|
const ackedOffset = readU32be(data, 0);
|
|
330
361
|
const maxRecv = data.length >= 8 ? readU32be(data, 4) : ackedOffset;
|
|
331
|
-
// Gap = bytes the receiver has seen BEYOND the contiguous point that are
|
|
332
|
-
// still missing below maxRecv → loss (or reorder). A gap bigger than half
|
|
333
|
-
// the window means we're overrunning the path → back off (multiplicative
|
|
334
|
-
// decrease). Otherwise the path is keeping up → grow (slow-start / linear).
|
|
335
|
-
const gapChunks = Math.max(0, maxRecv - ackedOffset) / MAX_FILE_DATA_SIZE;
|
|
336
362
|
if (ackedOffset > st.acked) {
|
|
363
|
+
// Progress.
|
|
337
364
|
st.acked = Math.min(ackedOffset, st.size);
|
|
338
365
|
st.lastAckAdvanceMs = nowMs();
|
|
339
|
-
if (
|
|
340
|
-
st.ssthresh = Math.max(MIN_CWND, st.cwnd >> 1);
|
|
341
|
-
st.cwnd = st.ssthresh;
|
|
342
|
-
}
|
|
343
|
-
else {
|
|
344
|
-
st.cwnd = st.cwnd < st.ssthresh
|
|
345
|
-
? Math.min(st.cwnd * 2, st.ssthresh)
|
|
346
|
-
: Math.min(st.cwnd + 1, MAX_CWND);
|
|
347
|
-
}
|
|
348
|
-
if (st.acked - st.lastProgressAcked >= 64 * 1024 || st.acked >= st.size) {
|
|
366
|
+
if (st.acked - st.lastProgressAcked >= 256 * 1024 || st.acked >= st.size) {
|
|
349
367
|
st.lastProgressAcked = st.acked;
|
|
350
368
|
this.emit("file-progress", { friendId, fileId: hex(st.fileId), received: st.acked, total: st.size, sending: true });
|
|
351
369
|
}
|
|
352
370
|
}
|
|
371
|
+
else if (maxRecv > st.acked + MAX_FILE_DATA_SIZE && nowMs() - st.lastResendMs >= FAST_RT_MIN_MS) {
|
|
372
|
+
// Duplicate ack WITH a gap → the chunk at `acked` was lost. Fast-retransmit
|
|
373
|
+
// the hole region immediately (rate-limited to ~1 RTT so a burst of
|
|
374
|
+
// dup-acks for one loss doesn't storm). No window backoff: file-path loss
|
|
375
|
+
// is treated as random, not congestion.
|
|
376
|
+
st.lastResendMs = nowMs();
|
|
377
|
+
this.#resendRange(friendId, st, st.acked, maxRecv);
|
|
378
|
+
}
|
|
353
379
|
if (st.acked >= st.size) {
|
|
354
380
|
this.#completeSend(friendId, st);
|
|
355
381
|
return;
|
|
356
382
|
}
|
|
357
|
-
|
|
383
|
+
this.#pump(friendId, st); // send new data
|
|
358
384
|
}
|
|
359
|
-
|
|
360
|
-
if (
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
// Retransmit watchdog: if the ack hasn't advanced within RETRANSMIT_MS,
|
|
364
|
-
// rewind the send cursor to the ack point (go-back-N) so the lost chunk and
|
|
365
|
-
// anything after it are re-sent. Cumulative acks make this self-correcting.
|
|
385
|
+
#pump(friendId, st) {
|
|
386
|
+
// Watchdog: if the ack hasn't advanced within WATCHDOG_MS, a chunk (or the
|
|
387
|
+
// fast-retransmit) was lost → go-back-N resend from the ack point. Backstop
|
|
388
|
+
// to the dup-ack fast-retransmit above.
|
|
366
389
|
if (!st.timer) {
|
|
367
390
|
let stalledSinceMs = nowMs();
|
|
368
391
|
st.timer = setInterval(() => {
|
|
@@ -375,57 +398,62 @@ export class FileTransferManager {
|
|
|
375
398
|
}
|
|
376
399
|
if (st.acked >= st.size)
|
|
377
400
|
return;
|
|
378
|
-
|
|
379
|
-
if (sinceAck < RETRANSMIT_MS) {
|
|
401
|
+
if (nowMs() - st.lastAckAdvanceMs < WATCHDOG_MS) {
|
|
380
402
|
stalledSinceMs = nowMs();
|
|
381
403
|
return;
|
|
382
404
|
}
|
|
383
|
-
// Give up if the receiver has gone silent for too long (disconnected /
|
|
384
|
-
// closed the app) rather than retransmitting forever.
|
|
385
405
|
if (nowMs() - stalledSinceMs >= SEND_GIVEUP_MS) {
|
|
386
406
|
this.#endSend(friendId, st.fileNumber);
|
|
387
407
|
this.emit("file-cancel", { friendId, fileId: hex(st.fileId), sending: true, reason: "timeout" });
|
|
388
408
|
return;
|
|
389
409
|
}
|
|
390
|
-
// Loss/stall detected → multiplicative decrease (back off), then resend
|
|
391
|
-
// from the ack point. This is the AIMD that paces us to the path.
|
|
392
|
-
st.ssthresh = Math.max(MIN_CWND, st.cwnd >> 1);
|
|
393
|
-
st.cwnd = st.ssthresh;
|
|
394
410
|
st.lastAckAdvanceMs = nowMs();
|
|
395
|
-
|
|
396
|
-
|
|
411
|
+
st.highestSent = st.acked; // go-back-N: re-send from the ack point
|
|
412
|
+
st.lastResendMs = nowMs();
|
|
413
|
+
this.#pumpFrom(friendId, st, st.acked);
|
|
414
|
+
}, WATCHDOG_MS);
|
|
397
415
|
if (typeof st.timer.unref === "function")
|
|
398
416
|
st.timer.unref();
|
|
399
417
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
}
|
|
403
|
-
finally {
|
|
404
|
-
st.pumping = false;
|
|
405
|
-
}
|
|
418
|
+
// Send only NEW data, from the high-water mark up to the fixed window edge.
|
|
419
|
+
this.#pumpFrom(friendId, st, Math.max(st.acked, st.highestSent));
|
|
406
420
|
}
|
|
407
|
-
//
|
|
408
|
-
|
|
409
|
-
|
|
421
|
+
// Fire chunks from `from` up to the window edge. Fire-and-forget: the fixed
|
|
422
|
+
// window bounds in-flight bytes and the 4 MB socket buffer absorbs the burst,
|
|
423
|
+
// so we don't serialize on a per-chunk await (which capped throughput at one
|
|
424
|
+
// chunk per event-loop turn → minutes for a big file).
|
|
425
|
+
#pumpFrom(friendId, st, from) {
|
|
426
|
+
const windowEnd = Math.min(st.acked + WINDOW_CHUNKS * MAX_FILE_DATA_SIZE, st.size);
|
|
410
427
|
let off = from;
|
|
411
428
|
while (off < windowEnd) {
|
|
412
429
|
if (!this.#map(this.#sending, friendId).has(st.fileNumber))
|
|
413
430
|
return; // cancelled
|
|
414
431
|
const end = Math.min(off + MAX_FILE_DATA_SIZE, st.size);
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
432
|
+
void this.send(friendId, PACKET_ID_FILE_DATA, encodeFileData(st.fileNumber, off, st.data.subarray(off, end))).catch(() => undefined);
|
|
433
|
+
off = end;
|
|
434
|
+
}
|
|
435
|
+
if (off > st.highestSent)
|
|
436
|
+
st.highestSent = off;
|
|
437
|
+
}
|
|
438
|
+
// Re-send the chunks in [from, to) — the gap region the receiver is missing.
|
|
439
|
+
// Already-received chunks are idempotent (placed by offset on the receiver).
|
|
440
|
+
#resendRange(friendId, st, from, to) {
|
|
441
|
+
let off = from;
|
|
442
|
+
const end0 = Math.min(to, st.size);
|
|
443
|
+
while (off < end0) {
|
|
444
|
+
if (!this.#map(this.#sending, friendId).has(st.fileNumber))
|
|
445
|
+
return;
|
|
446
|
+
const end = Math.min(off + MAX_FILE_DATA_SIZE, st.size);
|
|
447
|
+
void this.send(friendId, PACKET_ID_FILE_DATA, encodeFileData(st.fileNumber, off, st.data.subarray(off, end))).catch(() => undefined);
|
|
422
448
|
off = end;
|
|
423
449
|
}
|
|
424
450
|
}
|
|
425
451
|
#completeSend(friendId, st) {
|
|
452
|
+
const durationMs = nowMs() - st.startMs;
|
|
453
|
+
const mbps = durationMs > 0 ? st.size / 1024 / 1024 / (durationMs / 1000) : 0;
|
|
426
454
|
this.#endSend(friendId, st.fileNumber);
|
|
427
455
|
this.emit("file-progress", { friendId, fileId: hex(st.fileId), received: st.size, total: st.size, sending: true });
|
|
428
|
-
this.emit("file-complete", { friendId, fileId: hex(st.fileId), name: st.name, size: st.size, sending: true });
|
|
456
|
+
this.emit("file-complete", { friendId, fileId: hex(st.fileId), name: st.name, size: st.size, sending: true, durationMs, mbps });
|
|
429
457
|
}
|
|
430
458
|
#endSend(friendId, fileNumber) {
|
|
431
459
|
const st = this.#map(this.#sending, friendId).get(fileNumber);
|
|
@@ -433,6 +461,10 @@ export class FileTransferManager {
|
|
|
433
461
|
clearInterval(st.timer);
|
|
434
462
|
st.timer = undefined;
|
|
435
463
|
}
|
|
464
|
+
if (st?.offerTimer) {
|
|
465
|
+
clearInterval(st.offerTimer);
|
|
466
|
+
st.offerTimer = undefined;
|
|
467
|
+
}
|
|
436
468
|
this.#map(this.#sending, friendId).delete(fileNumber);
|
|
437
469
|
}
|
|
438
470
|
#endRecv(friendId, fileNumber) {
|
|
@@ -448,9 +480,12 @@ export class FileTransferManager {
|
|
|
448
480
|
this.#map(this.#receiving, friendId).delete(fileNumber);
|
|
449
481
|
}
|
|
450
482
|
clearFriend(friendId) {
|
|
451
|
-
for (const st of this.#map(this.#sending, friendId).values())
|
|
483
|
+
for (const st of this.#map(this.#sending, friendId).values()) {
|
|
452
484
|
if (st.timer)
|
|
453
485
|
clearInterval(st.timer);
|
|
486
|
+
if (st.offerTimer)
|
|
487
|
+
clearInterval(st.offerTimer);
|
|
488
|
+
}
|
|
454
489
|
for (const st of this.#map(this.#receiving, friendId).values()) {
|
|
455
490
|
if (st.ackTimer)
|
|
456
491
|
clearTimeout(st.ackTimer);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/peer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.54",
|
|
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",
|