@decentnetwork/peer 0.1.53 → 0.1.55
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 +103 -72
- 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, nextSend: 0, pumping: false, 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,37 @@ 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. Rewind the send
|
|
373
|
+
// cursor to the ack point so the loop re-sends the hole (rate-limited to
|
|
374
|
+
// ~1 RTT so dup-ack bursts for one loss don't storm). No window backoff:
|
|
375
|
+
// file-path loss is treated as random, not congestion.
|
|
376
|
+
st.lastResendMs = nowMs();
|
|
377
|
+
if (st.nextSend > st.acked)
|
|
378
|
+
st.nextSend = st.acked;
|
|
379
|
+
}
|
|
353
380
|
if (st.acked >= st.size) {
|
|
354
381
|
this.#completeSend(friendId, st);
|
|
355
382
|
return;
|
|
356
383
|
}
|
|
357
|
-
void this.#pump(friendId, st);
|
|
384
|
+
void this.#pump(friendId, st);
|
|
358
385
|
}
|
|
386
|
+
// Single send loop. Awaits each send so net_crypto / the UDP socket applies
|
|
387
|
+
// backpressure (fire-and-forget blasted the whole window into the macOS UDP
|
|
388
|
+
// buffer at once → systematic overflow/drops the retransmit couldn't dig out
|
|
389
|
+
// of → a big file "failed"). Sends from `nextSend`, which the ack handler and
|
|
390
|
+
// watchdog rewind to `acked` to retransmit a hole (go-back-N). The fixed
|
|
391
|
+
// window bounds in-flight bytes; throughput self-clocks to the path.
|
|
359
392
|
async #pump(friendId, st) {
|
|
360
|
-
if (st.pumping)
|
|
361
|
-
return;
|
|
362
|
-
st.pumping = true;
|
|
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.
|
|
366
393
|
if (!st.timer) {
|
|
367
394
|
let stalledSinceMs = nowMs();
|
|
368
395
|
st.timer = setInterval(() => {
|
|
@@ -375,57 +402,54 @@ export class FileTransferManager {
|
|
|
375
402
|
}
|
|
376
403
|
if (st.acked >= st.size)
|
|
377
404
|
return;
|
|
378
|
-
|
|
379
|
-
if (sinceAck < RETRANSMIT_MS) {
|
|
405
|
+
if (nowMs() - st.lastAckAdvanceMs < WATCHDOG_MS) {
|
|
380
406
|
stalledSinceMs = nowMs();
|
|
381
407
|
return;
|
|
382
408
|
}
|
|
383
|
-
// Give up if the receiver has gone silent for too long (disconnected /
|
|
384
|
-
// closed the app) rather than retransmitting forever.
|
|
385
409
|
if (nowMs() - stalledSinceMs >= SEND_GIVEUP_MS) {
|
|
386
410
|
this.#endSend(friendId, st.fileNumber);
|
|
387
411
|
this.emit("file-cancel", { friendId, fileId: hex(st.fileId), sending: true, reason: "timeout" });
|
|
388
412
|
return;
|
|
389
413
|
}
|
|
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
414
|
st.lastAckAdvanceMs = nowMs();
|
|
395
|
-
|
|
396
|
-
|
|
415
|
+
st.lastResendMs = nowMs();
|
|
416
|
+
if (st.nextSend > st.acked)
|
|
417
|
+
st.nextSend = st.acked; // go-back-N
|
|
418
|
+
void this.#pump(friendId, st);
|
|
419
|
+
}, WATCHDOG_MS);
|
|
397
420
|
if (typeof st.timer.unref === "function")
|
|
398
421
|
st.timer.unref();
|
|
399
422
|
}
|
|
423
|
+
if (st.pumping)
|
|
424
|
+
return;
|
|
425
|
+
st.pumping = true;
|
|
400
426
|
try {
|
|
401
|
-
|
|
427
|
+
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);
|
|
429
|
+
if (st.nextSend >= windowEnd)
|
|
430
|
+
break; // window full / all sent — wait for acks
|
|
431
|
+
const off = st.nextSend;
|
|
432
|
+
const end = Math.min(off + MAX_FILE_DATA_SIZE, st.size);
|
|
433
|
+
try {
|
|
434
|
+
await this.send(friendId, PACKET_ID_FILE_DATA, encodeFileData(st.fileNumber, off, st.data.subarray(off, end)));
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
break; // transport down; the watchdog re-kicks
|
|
438
|
+
}
|
|
439
|
+
if (st.nextSend === off)
|
|
440
|
+
st.nextSend = end; // unless a rewind moved it
|
|
441
|
+
}
|
|
402
442
|
}
|
|
403
443
|
finally {
|
|
404
444
|
st.pumping = false;
|
|
405
445
|
}
|
|
406
446
|
}
|
|
407
|
-
// Send chunks from `from` up to the congestion-window edge (acked + cwnd).
|
|
408
|
-
async #pumpFrom(friendId, st, from) {
|
|
409
|
-
const windowEnd = Math.min(st.acked + Math.max(MIN_CWND, st.cwnd | 0) * MAX_FILE_DATA_SIZE, st.size);
|
|
410
|
-
let off = from;
|
|
411
|
-
while (off < windowEnd) {
|
|
412
|
-
if (!this.#map(this.#sending, friendId).has(st.fileNumber))
|
|
413
|
-
return; // cancelled
|
|
414
|
-
const end = Math.min(off + MAX_FILE_DATA_SIZE, st.size);
|
|
415
|
-
const chunk = st.data.subarray(off, end);
|
|
416
|
-
try {
|
|
417
|
-
await this.send(friendId, PACKET_ID_FILE_DATA, encodeFileData(st.fileNumber, off, chunk));
|
|
418
|
-
}
|
|
419
|
-
catch {
|
|
420
|
-
return; // transport down; the watchdog will retry from the ack point
|
|
421
|
-
}
|
|
422
|
-
off = end;
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
447
|
#completeSend(friendId, st) {
|
|
448
|
+
const durationMs = nowMs() - st.startMs;
|
|
449
|
+
const mbps = durationMs > 0 ? st.size / 1024 / 1024 / (durationMs / 1000) : 0;
|
|
426
450
|
this.#endSend(friendId, st.fileNumber);
|
|
427
451
|
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 });
|
|
452
|
+
this.emit("file-complete", { friendId, fileId: hex(st.fileId), name: st.name, size: st.size, sending: true, durationMs, mbps });
|
|
429
453
|
}
|
|
430
454
|
#endSend(friendId, fileNumber) {
|
|
431
455
|
const st = this.#map(this.#sending, friendId).get(fileNumber);
|
|
@@ -433,6 +457,10 @@ export class FileTransferManager {
|
|
|
433
457
|
clearInterval(st.timer);
|
|
434
458
|
st.timer = undefined;
|
|
435
459
|
}
|
|
460
|
+
if (st?.offerTimer) {
|
|
461
|
+
clearInterval(st.offerTimer);
|
|
462
|
+
st.offerTimer = undefined;
|
|
463
|
+
}
|
|
436
464
|
this.#map(this.#sending, friendId).delete(fileNumber);
|
|
437
465
|
}
|
|
438
466
|
#endRecv(friendId, fileNumber) {
|
|
@@ -448,9 +476,12 @@ export class FileTransferManager {
|
|
|
448
476
|
this.#map(this.#receiving, friendId).delete(fileNumber);
|
|
449
477
|
}
|
|
450
478
|
clearFriend(friendId) {
|
|
451
|
-
for (const st of this.#map(this.#sending, friendId).values())
|
|
479
|
+
for (const st of this.#map(this.#sending, friendId).values()) {
|
|
452
480
|
if (st.timer)
|
|
453
481
|
clearInterval(st.timer);
|
|
482
|
+
if (st.offerTimer)
|
|
483
|
+
clearInterval(st.offerTimer);
|
|
484
|
+
}
|
|
454
485
|
for (const st of this.#map(this.#receiving, friendId).values()) {
|
|
455
486
|
if (st.ackTimer)
|
|
456
487
|
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.55",
|
|
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",
|