@decentnetwork/peer 0.1.52 → 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 +114 -45
- package/package.json +1 -1
|
@@ -30,10 +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 tuning.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
37
45
|
const SEND_GIVEUP_MS = 60000; // abandon a send after this long with no ack progress (peer gone)
|
|
38
46
|
function u32be(n) {
|
|
39
47
|
const b = new Uint8Array(4);
|
|
@@ -127,13 +135,34 @@ export class FileTransferManager {
|
|
|
127
135
|
if (fileNumber < 0)
|
|
128
136
|
return null;
|
|
129
137
|
const fileId = randomBytes(FILE_ID_LENGTH);
|
|
138
|
+
const req = encodeFileSendRequest(fileNumber, opts.kind ?? FILEKIND.DATA, BigInt(data.length), fileId, opts.name);
|
|
130
139
|
const st = {
|
|
131
140
|
fileNumber, fileId, name: opts.name, data, size: data.length,
|
|
132
|
-
acked: 0,
|
|
141
|
+
acked: 0, highestSent: 0, started: false, req, startMs: nowMs(),
|
|
142
|
+
lastProgressAcked: 0, lastAckAdvanceMs: nowMs(), lastResendMs: 0,
|
|
133
143
|
};
|
|
134
144
|
this.#map(this.#sending, friendId).set(fileNumber, st);
|
|
135
|
-
const req = encodeFileSendRequest(fileNumber, opts.kind ?? FILEKIND.DATA, BigInt(data.length), fileId, opts.name);
|
|
136
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();
|
|
137
166
|
return hex(fileId);
|
|
138
167
|
}
|
|
139
168
|
accept(friendId, fileNumber) {
|
|
@@ -155,7 +184,7 @@ export class FileTransferManager {
|
|
|
155
184
|
return;
|
|
156
185
|
}
|
|
157
186
|
this.#sendAck(friendId, cur);
|
|
158
|
-
},
|
|
187
|
+
}, REACK_MS);
|
|
159
188
|
if (typeof st.reackTimer.unref === "function")
|
|
160
189
|
st.reackTimer.unref();
|
|
161
190
|
}
|
|
@@ -189,12 +218,14 @@ export class FileTransferManager {
|
|
|
189
218
|
return;
|
|
190
219
|
const size = Number(r.fileSize);
|
|
191
220
|
const existing = this.#map(this.#receiving, friendId).get(r.fileNumber);
|
|
192
|
-
if (existing && !existing.done)
|
|
193
|
-
|
|
221
|
+
if (existing && !existing.done) {
|
|
222
|
+
this.#sendAck(friendId, existing);
|
|
223
|
+
return;
|
|
224
|
+
} // re-offer (our accept/ack lost) → re-ack
|
|
194
225
|
const st = {
|
|
195
226
|
fileNumber: r.fileNumber, fileId: r.fileId, name: r.filename, size,
|
|
196
227
|
buf: new Uint8Array(size), got: new Uint8Array(chunkCount(size)),
|
|
197
|
-
contiguous: 0, done: false, ackDirty: false,
|
|
228
|
+
contiguous: 0, maxByte: 0, done: false, ackDirty: false,
|
|
198
229
|
};
|
|
199
230
|
this.#map(this.#receiving, friendId).set(r.fileNumber, st);
|
|
200
231
|
this.emit("file-offer", { friendId, fileNumber: r.fileNumber, fileId: hex(r.fileId), name: r.filename, size, kind: r.fileType });
|
|
@@ -210,8 +241,17 @@ export class FileTransferManager {
|
|
|
210
241
|
const st = this.#map(this.#sending, friendId).get(c.fileNumber);
|
|
211
242
|
if (!st)
|
|
212
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
|
+
}
|
|
213
253
|
if (c.controlType === FILECONTROL.ACCEPT) {
|
|
214
|
-
|
|
254
|
+
this.#pump(friendId, st);
|
|
215
255
|
}
|
|
216
256
|
else if (c.controlType === FILECONTROL.ACK) {
|
|
217
257
|
this.#onAck(friendId, st, c.data);
|
|
@@ -249,6 +289,8 @@ export class FileTransferManager {
|
|
|
249
289
|
const idx = Math.floor(offset / MAX_FILE_DATA_SIZE);
|
|
250
290
|
if (offset % MAX_FILE_DATA_SIZE !== 0 || idx >= st.got.length)
|
|
251
291
|
return; // not a chunk boundary
|
|
292
|
+
if (offset + data.length > st.maxByte)
|
|
293
|
+
st.maxByte = offset + data.length;
|
|
252
294
|
if (!st.got[idx]) {
|
|
253
295
|
st.buf.set(data, offset);
|
|
254
296
|
st.got[idx] = 1;
|
|
@@ -304,35 +346,46 @@ export class FileTransferManager {
|
|
|
304
346
|
#sendAck(friendId, st) {
|
|
305
347
|
st.ackDirty = false;
|
|
306
348
|
// send_receive=1: the ACK refers to a file the PEER is SENDING (it lives in
|
|
307
|
-
// the peer's #sending table)
|
|
308
|
-
|
|
349
|
+
// the peer's #sending table). Carries [contiguous][maxByte] so the sender
|
|
350
|
+
// sees the gap (maxByte − contiguous) = loss/reorder, for congestion control.
|
|
351
|
+
const body = new Uint8Array(8);
|
|
352
|
+
body.set(u32be(st.contiguous), 0);
|
|
353
|
+
body.set(u32be(st.maxByte), 4);
|
|
354
|
+
void this.send(friendId, PACKET_ID_FILE_CONTROL, encodeFileControl(1, st.fileNumber, FILECONTROL.ACK, body)).catch(() => undefined);
|
|
309
355
|
}
|
|
310
356
|
// ── sender ────────────────────────────────────────────────────────
|
|
311
357
|
#onAck(friendId, st, data) {
|
|
312
358
|
if (data.length < 4)
|
|
313
359
|
return;
|
|
314
360
|
const ackedOffset = readU32be(data, 0);
|
|
361
|
+
const maxRecv = data.length >= 8 ? readU32be(data, 4) : ackedOffset;
|
|
315
362
|
if (ackedOffset > st.acked) {
|
|
363
|
+
// Progress.
|
|
316
364
|
st.acked = Math.min(ackedOffset, st.size);
|
|
317
365
|
st.lastAckAdvanceMs = nowMs();
|
|
318
|
-
if (st.acked - st.lastProgressAcked >=
|
|
366
|
+
if (st.acked - st.lastProgressAcked >= 256 * 1024 || st.acked >= st.size) {
|
|
319
367
|
st.lastProgressAcked = st.acked;
|
|
320
368
|
this.emit("file-progress", { friendId, fileId: hex(st.fileId), received: st.acked, total: st.size, sending: true });
|
|
321
369
|
}
|
|
322
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
|
+
}
|
|
323
379
|
if (st.acked >= st.size) {
|
|
324
380
|
this.#completeSend(friendId, st);
|
|
325
381
|
return;
|
|
326
382
|
}
|
|
327
|
-
|
|
383
|
+
this.#pump(friendId, st); // send new data
|
|
328
384
|
}
|
|
329
|
-
|
|
330
|
-
if (
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
// Retransmit watchdog: if the ack hasn't advanced within RETRANSMIT_MS,
|
|
334
|
-
// rewind the send cursor to the ack point (go-back-N) so the lost chunk and
|
|
335
|
-
// 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.
|
|
336
389
|
if (!st.timer) {
|
|
337
390
|
let stalledSinceMs = nowMs();
|
|
338
391
|
st.timer = setInterval(() => {
|
|
@@ -345,53 +398,62 @@ export class FileTransferManager {
|
|
|
345
398
|
}
|
|
346
399
|
if (st.acked >= st.size)
|
|
347
400
|
return;
|
|
348
|
-
|
|
349
|
-
if (sinceAck < RETRANSMIT_MS) {
|
|
401
|
+
if (nowMs() - st.lastAckAdvanceMs < WATCHDOG_MS) {
|
|
350
402
|
stalledSinceMs = nowMs();
|
|
351
403
|
return;
|
|
352
404
|
}
|
|
353
|
-
// Give up if the receiver has gone silent for too long (disconnected /
|
|
354
|
-
// closed the app) rather than retransmitting forever.
|
|
355
405
|
if (nowMs() - stalledSinceMs >= SEND_GIVEUP_MS) {
|
|
356
406
|
this.#endSend(friendId, st.fileNumber);
|
|
357
407
|
this.emit("file-cancel", { friendId, fileId: hex(st.fileId), sending: true, reason: "timeout" });
|
|
358
408
|
return;
|
|
359
409
|
}
|
|
360
410
|
st.lastAckAdvanceMs = nowMs();
|
|
361
|
-
|
|
362
|
-
|
|
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);
|
|
363
415
|
if (typeof st.timer.unref === "function")
|
|
364
416
|
st.timer.unref();
|
|
365
417
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
}
|
|
369
|
-
finally {
|
|
370
|
-
st.pumping = false;
|
|
371
|
-
}
|
|
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));
|
|
372
420
|
}
|
|
373
|
-
//
|
|
374
|
-
|
|
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) {
|
|
375
426
|
const windowEnd = Math.min(st.acked + WINDOW_CHUNKS * MAX_FILE_DATA_SIZE, st.size);
|
|
376
427
|
let off = from;
|
|
377
428
|
while (off < windowEnd) {
|
|
378
429
|
if (!this.#map(this.#sending, friendId).has(st.fileNumber))
|
|
379
430
|
return; // cancelled
|
|
380
431
|
const end = Math.min(off + MAX_FILE_DATA_SIZE, st.size);
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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);
|
|
388
448
|
off = end;
|
|
389
449
|
}
|
|
390
450
|
}
|
|
391
451
|
#completeSend(friendId, st) {
|
|
452
|
+
const durationMs = nowMs() - st.startMs;
|
|
453
|
+
const mbps = durationMs > 0 ? st.size / 1024 / 1024 / (durationMs / 1000) : 0;
|
|
392
454
|
this.#endSend(friendId, st.fileNumber);
|
|
393
455
|
this.emit("file-progress", { friendId, fileId: hex(st.fileId), received: st.size, total: st.size, sending: true });
|
|
394
|
-
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 });
|
|
395
457
|
}
|
|
396
458
|
#endSend(friendId, fileNumber) {
|
|
397
459
|
const st = this.#map(this.#sending, friendId).get(fileNumber);
|
|
@@ -399,6 +461,10 @@ export class FileTransferManager {
|
|
|
399
461
|
clearInterval(st.timer);
|
|
400
462
|
st.timer = undefined;
|
|
401
463
|
}
|
|
464
|
+
if (st?.offerTimer) {
|
|
465
|
+
clearInterval(st.offerTimer);
|
|
466
|
+
st.offerTimer = undefined;
|
|
467
|
+
}
|
|
402
468
|
this.#map(this.#sending, friendId).delete(fileNumber);
|
|
403
469
|
}
|
|
404
470
|
#endRecv(friendId, fileNumber) {
|
|
@@ -414,9 +480,12 @@ export class FileTransferManager {
|
|
|
414
480
|
this.#map(this.#receiving, friendId).delete(fileNumber);
|
|
415
481
|
}
|
|
416
482
|
clearFriend(friendId) {
|
|
417
|
-
for (const st of this.#map(this.#sending, friendId).values())
|
|
483
|
+
for (const st of this.#map(this.#sending, friendId).values()) {
|
|
418
484
|
if (st.timer)
|
|
419
485
|
clearInterval(st.timer);
|
|
486
|
+
if (st.offerTimer)
|
|
487
|
+
clearInterval(st.offerTimer);
|
|
488
|
+
}
|
|
420
489
|
for (const st of this.#map(this.#receiving, friendId).values()) {
|
|
421
490
|
if (st.ackTimer)
|
|
422
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",
|