@decentnetwork/peer 0.1.73 → 0.1.75
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 +1 -0
- package/dist/compat/filetransfer.js +232 -12
- package/dist/compat/reed-solomon.d.ts +9 -0
- package/dist/compat/reed-solomon.js +198 -0
- package/dist/peer.js +4 -3
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export declare const PACKET_ID_FILE_SENDREQUEST = 80;
|
|
2
2
|
export declare const PACKET_ID_FILE_CONTROL = 81;
|
|
3
3
|
export declare const PACKET_ID_FILE_DATA = 82;
|
|
4
|
+
export declare const PACKET_ID_FILE_FEC = 83;
|
|
4
5
|
export declare const FILE_ID_LENGTH = 32;
|
|
5
6
|
export declare const MAX_FILENAME_LENGTH = 255;
|
|
6
7
|
export declare const MAX_CONCURRENT_FILE_PIPES = 256;
|
|
@@ -23,14 +23,53 @@ import { concatBytes, randomBytes } from "../utils/bytes.js";
|
|
|
23
23
|
import { createHash } from "node:crypto";
|
|
24
24
|
import { existsSync, statSync, readFileSync, appendFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs";
|
|
25
25
|
import { join } from "node:path";
|
|
26
|
+
import { rsEncode, rsDecode } from "./reed-solomon.js";
|
|
26
27
|
export const PACKET_ID_FILE_SENDREQUEST = 80;
|
|
27
28
|
export const PACKET_ID_FILE_CONTROL = 81;
|
|
28
29
|
export const PACKET_ID_FILE_DATA = 82;
|
|
30
|
+
export const PACKET_ID_FILE_FEC = 83; // FEC parity shard (forward error correction)
|
|
29
31
|
export const FILE_ID_LENGTH = 32;
|
|
30
32
|
export const MAX_FILENAME_LENGTH = 255;
|
|
31
33
|
export const MAX_CONCURRENT_FILE_PIPES = 256;
|
|
32
34
|
// MAX_CRYPTO_DATA_SIZE (1373) − 1 (kind) − 1 (filenum) − 4 (offset).
|
|
33
35
|
export const MAX_FILE_DATA_SIZE = 1367;
|
|
36
|
+
// FEC: a block is K data chunks; the sender adds M parity chunks so the receiver
|
|
37
|
+
// can reconstruct up to M lost chunks per block locally (no retransmit) — the
|
|
38
|
+
// key to bulk transfer over a lossy GFW path (measured 15-50% loss). ~37%
|
|
39
|
+
// overhead handles bursty ~15% loss with margin; the retransmit path remains a
|
|
40
|
+
// backstop for blocks that exceed FEC capacity.
|
|
41
|
+
const FEC_K = 16;
|
|
42
|
+
// Parity is ADAPTIVE per block: the sender sizes it to the loss rate the
|
|
43
|
+
// receiver reports (piggybacked on each ack). A fixed M=6 tolerated only
|
|
44
|
+
// ~27% loss, so a 40%-loss path (measured on lili) blew past FEC capacity and
|
|
45
|
+
// fell back to go-back-N retransmit — ~1% efficient. Now the sender sends just
|
|
46
|
+
// enough parity that blocks recover LOCALLY at the current loss, so the
|
|
47
|
+
// expensive retransmit path almost never fires.
|
|
48
|
+
const FEC_M_MIN = 4; // parity the instant loss is first detected (before the report warms up)
|
|
49
|
+
const FEC_M_MAX = 16; // ceiling on parity per K=16 block (covers ~50% loss)
|
|
50
|
+
const FEC_MARGIN = 2; // extra shards over the estimate — absorbs burst variance
|
|
51
|
+
const FEC_SAFETY = 1.15; // over-provision the loss estimate by 15%
|
|
52
|
+
// Adaptive: only spend parity bandwidth while the path is actually losing
|
|
53
|
+
// packets (a dup-ack was seen within this window). A clean path sends zero
|
|
54
|
+
// parity → no overhead; a lossy path (lili) gets full protection.
|
|
55
|
+
const FEC_ACTIVE_WINDOW_MS = 4000;
|
|
56
|
+
// FEC packet payload: [filenum(1)][blockIndex u32][parityIndex(1)][parity bytes].
|
|
57
|
+
// k (data chunks in the block) and m are NOT sent — both ends share FEC_K/FEC_M
|
|
58
|
+
// constants and derive the last block's k from the file size — so the 6-byte
|
|
59
|
+
// header + a full 1367-byte parity shard fits the 1373-byte crypto limit exactly
|
|
60
|
+
// and the data chunk size (MAX_FILE_DATA_SIZE) stays unchanged (0.1.73-compatible).
|
|
61
|
+
function encodeFec(fileNumber, blockIndex, parityIndex, parity) {
|
|
62
|
+
return concatBytes([Uint8Array.of(fileNumber & 0xff), u32be(blockIndex), Uint8Array.of(parityIndex & 0xff), parity]);
|
|
63
|
+
}
|
|
64
|
+
function parseFec(p) {
|
|
65
|
+
if (p.length < 6)
|
|
66
|
+
return undefined;
|
|
67
|
+
return { fileNumber: p[0], blockIndex: readU32be(p, 1), parityIndex: p[5], parity: p.slice(6) };
|
|
68
|
+
}
|
|
69
|
+
// How many data chunks are in block b of a file with `totalChunks` chunks.
|
|
70
|
+
function fecBlockK(blockIndex, totalChunks) {
|
|
71
|
+
return Math.min(FEC_K, totalChunks - blockIndex * FEC_K);
|
|
72
|
+
}
|
|
34
73
|
export const FILECONTROL = { ACCEPT: 0, PAUSE: 1, KILL: 2, SEEK: 3, ACK: 4 };
|
|
35
74
|
export const FILEKIND = { DATA: 0, AVATAR: 1 };
|
|
36
75
|
// Reliability tuning. We RAMP an in-flight window (AIMD-lite) rather than pin it:
|
|
@@ -197,7 +236,7 @@ export class FileTransferManager {
|
|
|
197
236
|
fileNumber, fileId, name: opts.name, data, size: data.length,
|
|
198
237
|
acked: 0, nextSend: 0, pumping: false, started: false, req, startMs: nowMs(),
|
|
199
238
|
lastProgressAcked: 0, lastAckAdvanceMs: nowMs(), lastResendMs: 0,
|
|
200
|
-
cwnd: CWND_INIT_CHUNKS, stalls: 0,
|
|
239
|
+
cwnd: CWND_INIT_CHUNKS, stalls: 0, parityHighBlock: -1, lastLossMs: 0, lossEwma: 0,
|
|
201
240
|
minRttMs: Infinity, minSrttMs: Infinity, srttMs: 0, probeOffset: -1, probeSentMs: 0,
|
|
202
241
|
};
|
|
203
242
|
this.#map(this.#sending, friendId).set(fileNumber, st);
|
|
@@ -269,6 +308,10 @@ export class FileTransferManager {
|
|
|
269
308
|
this.#onData(friendId, payload);
|
|
270
309
|
return true;
|
|
271
310
|
}
|
|
311
|
+
if (packetId === PACKET_ID_FILE_FEC) {
|
|
312
|
+
this.#onFec(friendId, payload);
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
272
315
|
return false;
|
|
273
316
|
}
|
|
274
317
|
#onSendRequest(friendId, payload) {
|
|
@@ -283,8 +326,8 @@ export class FileTransferManager {
|
|
|
283
326
|
} // re-offer (our accept/ack lost) → re-ack
|
|
284
327
|
const st = {
|
|
285
328
|
fileNumber: r.fileNumber, fileId: r.fileId, name: r.filename, size,
|
|
286
|
-
buf: new Uint8Array(size), got: new Uint8Array(chunkCount(size)),
|
|
287
|
-
contiguous: 0, maxByte: 0, persisted: 0, partPath: this.#partPath(r.fileId), done: false, ackDirty: false,
|
|
329
|
+
buf: new Uint8Array(size), got: new Uint8Array(chunkCount(size)), netGot: new Uint8Array(chunkCount(size)), lossPctEwma: 0,
|
|
330
|
+
contiguous: 0, maxByte: 0, persisted: 0, partPath: this.#partPath(r.fileId), fecParity: new Map(), done: false, ackDirty: false,
|
|
288
331
|
};
|
|
289
332
|
// 断点续传: if we persisted a contiguous prefix of this exact file (matched
|
|
290
333
|
// by content-hash fileId) before a drop/restart, load it and resume — our
|
|
@@ -300,8 +343,10 @@ export class FileTransferManager {
|
|
|
300
343
|
if (usable > 0) {
|
|
301
344
|
const partial = readFileSync(st.partPath);
|
|
302
345
|
st.buf.set(partial.subarray(0, usable), 0);
|
|
303
|
-
for (let i = 0; i < chunks; i++)
|
|
346
|
+
for (let i = 0; i < chunks; i++) {
|
|
304
347
|
st.got[i] = 1;
|
|
348
|
+
st.netGot[i] = 1;
|
|
349
|
+
}
|
|
305
350
|
st.contiguous = usable;
|
|
306
351
|
st.maxByte = usable;
|
|
307
352
|
st.persisted = usable;
|
|
@@ -382,23 +427,118 @@ export class FileTransferManager {
|
|
|
382
427
|
return; // not a chunk boundary
|
|
383
428
|
if (offset + data.length > st.maxByte)
|
|
384
429
|
st.maxByte = offset + data.length;
|
|
430
|
+
st.netGot[idx] = 1; // genuine network arrival (even if a dup) → raw-loss denominator
|
|
385
431
|
if (!st.got[idx]) {
|
|
386
432
|
st.buf.set(data, offset);
|
|
387
433
|
st.got[idx] = 1;
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
434
|
+
this.#advanceContiguous(friendId, st);
|
|
435
|
+
}
|
|
436
|
+
// This chunk may complete a FEC block enough to reconstruct its other holes.
|
|
437
|
+
this.#tryRecoverBlock(friendId, st, Math.floor(idx / FEC_K));
|
|
438
|
+
this.#finishOrAck(friendId, st);
|
|
439
|
+
}
|
|
440
|
+
// Advance the contiguous prefix over received/recovered chunks, emitting
|
|
441
|
+
// progress and flushing the resume file. Does not finish or ack.
|
|
442
|
+
#advanceContiguous(friendId, st) {
|
|
443
|
+
let i = Math.floor(st.contiguous / MAX_FILE_DATA_SIZE);
|
|
444
|
+
while (i < st.got.length && st.got[i])
|
|
445
|
+
i++;
|
|
446
|
+
const nc = Math.min(i * MAX_FILE_DATA_SIZE, st.size);
|
|
447
|
+
if (nc > st.contiguous) {
|
|
448
|
+
st.contiguous = nc;
|
|
393
449
|
this.emit("file-progress", { friendId, fileId: hex(st.fileId), received: st.contiguous, total: st.size });
|
|
394
450
|
this.#persistPartial(st);
|
|
395
451
|
}
|
|
452
|
+
}
|
|
453
|
+
#finishOrAck(friendId, st) {
|
|
454
|
+
if (st.done)
|
|
455
|
+
return;
|
|
396
456
|
if (st.contiguous >= st.size) {
|
|
397
457
|
this.#finishRecv(friendId, st);
|
|
398
458
|
return;
|
|
399
459
|
}
|
|
400
460
|
this.#scheduleAck(friendId, st);
|
|
401
461
|
}
|
|
462
|
+
// A parity shard arrived → store it and try to repair its block.
|
|
463
|
+
#onFec(friendId, payload) {
|
|
464
|
+
const f = parseFec(payload);
|
|
465
|
+
if (!f)
|
|
466
|
+
return;
|
|
467
|
+
const st = this.#map(this.#receiving, friendId).get(f.fileNumber);
|
|
468
|
+
if (!st || st.done)
|
|
469
|
+
return;
|
|
470
|
+
if (f.parity.length !== MAX_FILE_DATA_SIZE || f.parityIndex >= FEC_M_MAX)
|
|
471
|
+
return;
|
|
472
|
+
let arr = st.fecParity.get(f.blockIndex);
|
|
473
|
+
if (!arr) {
|
|
474
|
+
arr = new Array(FEC_M_MAX).fill(undefined);
|
|
475
|
+
st.fecParity.set(f.blockIndex, arr);
|
|
476
|
+
}
|
|
477
|
+
if (!arr[f.parityIndex])
|
|
478
|
+
arr[f.parityIndex] = f.parity;
|
|
479
|
+
this.#tryRecoverBlock(friendId, st, f.blockIndex);
|
|
480
|
+
this.#finishOrAck(friendId, st);
|
|
481
|
+
}
|
|
482
|
+
// If FEC block `b` has at least K of its K+M shards (data + parity), rebuild
|
|
483
|
+
// the missing data chunks LOCALLY — no retransmit, so the sender's window
|
|
484
|
+
// never stalls waiting for the lost chunk.
|
|
485
|
+
#tryRecoverBlock(friendId, st, b) {
|
|
486
|
+
if (st.done || b < 0)
|
|
487
|
+
return;
|
|
488
|
+
const totalChunks = chunkCount(st.size);
|
|
489
|
+
if (b * FEC_K >= totalChunks)
|
|
490
|
+
return;
|
|
491
|
+
const k = fecBlockK(b, totalChunks);
|
|
492
|
+
const base = b * FEC_K;
|
|
493
|
+
const missing = [];
|
|
494
|
+
for (let i = 0; i < k; i++)
|
|
495
|
+
if (!st.got[base + i])
|
|
496
|
+
missing.push(i);
|
|
497
|
+
if (missing.length === 0) {
|
|
498
|
+
st.fecParity.delete(b);
|
|
499
|
+
return;
|
|
500
|
+
} // block already whole
|
|
501
|
+
const parityArr = st.fecParity.get(b);
|
|
502
|
+
const parityPresent = parityArr ? parityArr.reduce((n, s) => n + (s ? 1 : 0), 0) : 0;
|
|
503
|
+
if ((k - missing.length) + parityPresent < k)
|
|
504
|
+
return; // not enough shards yet
|
|
505
|
+
if (missing.length > parityPresent)
|
|
506
|
+
return; // can't cover this many holes
|
|
507
|
+
// Assemble the K+M shard array (present data padded to a full chunk).
|
|
508
|
+
const shards = [];
|
|
509
|
+
for (let i = 0; i < k; i++) {
|
|
510
|
+
if (st.got[base + i]) {
|
|
511
|
+
const off = (base + i) * MAX_FILE_DATA_SIZE;
|
|
512
|
+
const chunk = st.buf.subarray(off, Math.min(off + MAX_FILE_DATA_SIZE, st.size));
|
|
513
|
+
if (chunk.length === MAX_FILE_DATA_SIZE)
|
|
514
|
+
shards.push(chunk);
|
|
515
|
+
else {
|
|
516
|
+
const padded = new Uint8Array(MAX_FILE_DATA_SIZE);
|
|
517
|
+
padded.set(chunk);
|
|
518
|
+
shards.push(padded);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
else
|
|
522
|
+
shards.push(null);
|
|
523
|
+
}
|
|
524
|
+
for (let pi = 0; pi < FEC_M_MAX; pi++)
|
|
525
|
+
shards.push(parityArr?.[pi] ?? null);
|
|
526
|
+
const recovered = rsDecode(shards, k, FEC_M_MAX, MAX_FILE_DATA_SIZE);
|
|
527
|
+
if (!recovered)
|
|
528
|
+
return;
|
|
529
|
+
for (const i of missing) {
|
|
530
|
+
const off = (base + i) * MAX_FILE_DATA_SIZE;
|
|
531
|
+
const realLen = Math.min(MAX_FILE_DATA_SIZE, st.size - off);
|
|
532
|
+
if (realLen <= 0)
|
|
533
|
+
continue;
|
|
534
|
+
st.buf.set(recovered[i].subarray(0, realLen), off);
|
|
535
|
+
st.got[base + i] = 1;
|
|
536
|
+
if (off + realLen > st.maxByte)
|
|
537
|
+
st.maxByte = off + realLen;
|
|
538
|
+
}
|
|
539
|
+
st.fecParity.delete(b); // recovered — free the parity
|
|
540
|
+
this.#advanceContiguous(friendId, st);
|
|
541
|
+
}
|
|
402
542
|
// Flush the newly-contiguous bytes to the resume file, throttled so we do at
|
|
403
543
|
// most one append per PERSIST_INTERVAL_BYTES of progress (not per chunk).
|
|
404
544
|
#persistPartial(st, force = false) {
|
|
@@ -458,14 +598,37 @@ export class FileTransferManager {
|
|
|
458
598
|
this.#sendAck(friendId, st);
|
|
459
599
|
}, ACK_THROTTLE_MS);
|
|
460
600
|
}
|
|
601
|
+
// Raw network loss (0..100) behind the frontier: the fraction of chunk slots
|
|
602
|
+
// between the contiguous prefix and the highest byte seen that have NOT arrived
|
|
603
|
+
// over the network (netGot=0). FEC-recovered holes still count as losses here
|
|
604
|
+
// (netGot stays 0), so the estimate doesn't collapse to zero the moment FEC
|
|
605
|
+
// patches a block — which would starve parity and reopen the hole. Self-
|
|
606
|
+
// correcting: under-provisioned parity leaves holes → the window fills with
|
|
607
|
+
// gaps → the reported loss rises → the sender adds parity.
|
|
608
|
+
#measureLoss(st) {
|
|
609
|
+
const lo = Math.floor(st.contiguous / MAX_FILE_DATA_SIZE);
|
|
610
|
+
const hi = Math.min(st.got.length, Math.ceil(st.maxByte / MAX_FILE_DATA_SIZE));
|
|
611
|
+
let span = 0, holes = 0;
|
|
612
|
+
for (let i = lo; i < hi && span < 4096; i++) {
|
|
613
|
+
span++;
|
|
614
|
+
if (!st.netGot[i])
|
|
615
|
+
holes++;
|
|
616
|
+
}
|
|
617
|
+
const inst = span > 0 ? holes / span : 0;
|
|
618
|
+
st.lossPctEwma = st.lossPctEwma === 0 ? inst : 0.7 * st.lossPctEwma + 0.3 * inst;
|
|
619
|
+
return Math.max(0, Math.min(100, Math.round(st.lossPctEwma * 100)));
|
|
620
|
+
}
|
|
461
621
|
#sendAck(friendId, st) {
|
|
462
622
|
st.ackDirty = false;
|
|
463
623
|
// send_receive=1: the ACK refers to a file the PEER is SENDING (it lives in
|
|
464
|
-
// the peer's #sending table). Carries [contiguous][maxByte] so the
|
|
465
|
-
// sees the gap (
|
|
466
|
-
|
|
624
|
+
// the peer's #sending table). Carries [contiguous][maxByte][lossPct] so the
|
|
625
|
+
// sender sees the gap (for congestion control) and the raw loss rate (to size
|
|
626
|
+
// adaptive FEC parity). The 9th byte is additive — a pre-0.1.75 sender that
|
|
627
|
+
// reads only 8 bytes just ignores it.
|
|
628
|
+
const body = new Uint8Array(9);
|
|
467
629
|
body.set(u32be(st.contiguous), 0);
|
|
468
630
|
body.set(u32be(st.maxByte), 4);
|
|
631
|
+
body[8] = this.#measureLoss(st);
|
|
469
632
|
void this.send(friendId, PACKET_ID_FILE_CONTROL, encodeFileControl(1, st.fileNumber, FILECONTROL.ACK, body)).catch(() => undefined);
|
|
470
633
|
}
|
|
471
634
|
// ── sender ────────────────────────────────────────────────────────
|
|
@@ -474,6 +637,12 @@ export class FileTransferManager {
|
|
|
474
637
|
return;
|
|
475
638
|
const ackedOffset = readU32be(data, 0);
|
|
476
639
|
const maxRecv = data.length >= 8 ? readU32be(data, 4) : ackedOffset;
|
|
640
|
+
// Receiver-reported raw loss (0..100) → drives adaptive FEC parity. Lightly
|
|
641
|
+
// re-smoothed here so a single ack can't swing the parity budget.
|
|
642
|
+
if (data.length >= 9) {
|
|
643
|
+
const reported = data[8] / 100;
|
|
644
|
+
st.lossEwma = 0.6 * st.lossEwma + 0.4 * reported;
|
|
645
|
+
}
|
|
477
646
|
if (ackedOffset > st.acked) {
|
|
478
647
|
// Progress. The path is delivering → additively re-open the window (up to
|
|
479
648
|
// the cap) and clear the stall streak (patience shrinks back to the base).
|
|
@@ -532,6 +701,7 @@ export class FileTransferManager {
|
|
|
532
701
|
// ~1 RTT so dup-ack bursts for one loss don't storm). No window backoff:
|
|
533
702
|
// file-path loss is treated as random, not congestion.
|
|
534
703
|
st.lastResendMs = nowMs();
|
|
704
|
+
st.lastLossMs = nowMs(); // the path is lossy → turn adaptive FEC on
|
|
535
705
|
if (st.nextSend > st.acked)
|
|
536
706
|
st.nextSend = st.acked;
|
|
537
707
|
}
|
|
@@ -616,11 +786,61 @@ export class FileTransferManager {
|
|
|
616
786
|
st.probeSentMs = nowMs();
|
|
617
787
|
}
|
|
618
788
|
}
|
|
789
|
+
// After sending data, emit FEC parity for any block whose data is now all
|
|
790
|
+
// sent, so the receiver can repair losses locally instead of stalling.
|
|
791
|
+
this.#emitParity(friendId, st);
|
|
619
792
|
}
|
|
620
793
|
finally {
|
|
621
794
|
st.pumping = false;
|
|
622
795
|
}
|
|
623
796
|
}
|
|
797
|
+
// Send M parity shards for each FEC block whose K data chunks have all been
|
|
798
|
+
// sent (once per block). Parity is computed from the source file, so no state
|
|
799
|
+
// is kept beyond the high-water block index.
|
|
800
|
+
#emitParity(friendId, st) {
|
|
801
|
+
const totalChunks = chunkCount(st.size);
|
|
802
|
+
const totalBlocks = Math.ceil(totalChunks / FEC_K);
|
|
803
|
+
// Adaptive: only protect blocks while the path is actually lossy. On a clean
|
|
804
|
+
// path this sends zero parity (no overhead); the counter still advances so
|
|
805
|
+
// we never revisit a block.
|
|
806
|
+
const fecOn = nowMs() - st.lastLossMs < FEC_ACTIVE_WINDOW_MS;
|
|
807
|
+
while (st.parityHighBlock + 1 < totalBlocks) {
|
|
808
|
+
const b = st.parityHighBlock + 1;
|
|
809
|
+
const kActual = fecBlockK(b, totalChunks);
|
|
810
|
+
const blockEndByte = Math.min((b * FEC_K + kActual) * MAX_FILE_DATA_SIZE, st.size);
|
|
811
|
+
if (st.nextSend < blockEndByte)
|
|
812
|
+
break; // block's data not all sent yet
|
|
813
|
+
if (!fecOn) {
|
|
814
|
+
st.parityHighBlock = b;
|
|
815
|
+
continue;
|
|
816
|
+
} // clean path → skip parity
|
|
817
|
+
// Size parity to the reported loss p: to land k of (k+m) shards through a
|
|
818
|
+
// path dropping fraction p, need k+m ≥ k/(1−p) → m ≥ k·p/(1−p). Over-
|
|
819
|
+
// provision (SAFETY) and add a fixed MARGIN for burst variance; a fresh
|
|
820
|
+
// dup-ack (report not yet in) still gets FEC_M_MIN so we react immediately.
|
|
821
|
+
const p = Math.min(0.6, st.lossEwma); // cap so the divisor can't explode
|
|
822
|
+
const mNeed = Math.ceil((kActual * p / (1 - p)) * FEC_SAFETY) + FEC_MARGIN;
|
|
823
|
+
const mSend = Math.max(FEC_M_MIN, Math.min(FEC_M_MAX, mNeed));
|
|
824
|
+
// Build the k data shards (zero-padded to a full chunk) and encode mSend parity.
|
|
825
|
+
const shards = [];
|
|
826
|
+
for (let i = 0; i < kActual; i++) {
|
|
827
|
+
const off = (b * FEC_K + i) * MAX_FILE_DATA_SIZE;
|
|
828
|
+
const chunk = st.data.subarray(off, Math.min(off + MAX_FILE_DATA_SIZE, st.size));
|
|
829
|
+
if (chunk.length === MAX_FILE_DATA_SIZE)
|
|
830
|
+
shards.push(chunk);
|
|
831
|
+
else {
|
|
832
|
+
const padded = new Uint8Array(MAX_FILE_DATA_SIZE);
|
|
833
|
+
padded.set(chunk);
|
|
834
|
+
shards.push(padded);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
const parity = rsEncode(shards, mSend);
|
|
838
|
+
for (let pi = 0; pi < parity.length; pi++) {
|
|
839
|
+
void this.send(friendId, PACKET_ID_FILE_FEC, encodeFec(st.fileNumber, b, pi, parity[pi])).catch(() => undefined);
|
|
840
|
+
}
|
|
841
|
+
st.parityHighBlock = b;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
624
844
|
#completeSend(friendId, st) {
|
|
625
845
|
const durationMs = nowMs() - st.startMs;
|
|
626
846
|
const mbps = durationMs > 0 ? st.size / 1024 / 1024 / (durationMs / 1000) : 0;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Encode: given K equal-length data shards, return M parity shards. */
|
|
2
|
+
export declare function rsEncode(data: Uint8Array[], m: number): Uint8Array[];
|
|
3
|
+
/**
|
|
4
|
+
* Recover the K data shards from any K present shards (data and/or parity).
|
|
5
|
+
* `shards` is the full K+M array with missing entries as null; `len` is the
|
|
6
|
+
* shard length. Returns the K reconstructed data shards, or null if fewer than
|
|
7
|
+
* K shards are present (unrecoverable).
|
|
8
|
+
*/
|
|
9
|
+
export declare function rsDecode(shards: Array<Uint8Array | null>, k: number, m: number, len: number): Uint8Array[] | null;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Reed-Solomon ERASURE coding over GF(256), for FEC on lossy paths.
|
|
3
|
+
//
|
|
4
|
+
// The file transfer runs over a best-effort channel that, on a GFW-grade path,
|
|
5
|
+
// drops 15-50% of packets. Retransmit-based recovery collapses throughput
|
|
6
|
+
// (measured: ~1% of the link's real 1.3 MB/s). FEC instead sends a few PARITY
|
|
7
|
+
// shards alongside each block of K data shards; the receiver reconstructs any
|
|
8
|
+
// missing data shards LOCALLY as long as it got at least K of the K+M shards —
|
|
9
|
+
// no round-trip, so the send window never stalls on loss.
|
|
10
|
+
//
|
|
11
|
+
// This is *erasure* coding, not error correction: the receiver knows exactly
|
|
12
|
+
// which shards are missing (each chunk carries its offset), so recovery is just
|
|
13
|
+
// solving a linear system — no syndromes or error locators. We use a systematic
|
|
14
|
+
// Cauchy-Reed-Solomon matrix: the first K rows are the identity (data shards
|
|
15
|
+
// pass through untouched), the last M rows are a Cauchy matrix (the parity).
|
|
16
|
+
//
|
|
17
|
+
// All shards in a block must be the same length; the caller zero-pads.
|
|
18
|
+
//
|
|
19
|
+
// ── GF(256) with primitive polynomial 0x11d (x^8+x^4+x^3+x^2+1) ──────────────
|
|
20
|
+
const EXP = new Uint8Array(512);
|
|
21
|
+
const LOG = new Uint8Array(256);
|
|
22
|
+
(function initTables() {
|
|
23
|
+
let x = 1;
|
|
24
|
+
for (let i = 0; i < 255; i++) {
|
|
25
|
+
EXP[i] = x;
|
|
26
|
+
LOG[x] = i;
|
|
27
|
+
x <<= 1;
|
|
28
|
+
if (x & 0x100)
|
|
29
|
+
x ^= 0x11d;
|
|
30
|
+
}
|
|
31
|
+
for (let i = 255; i < 512; i++)
|
|
32
|
+
EXP[i] = EXP[i - 255];
|
|
33
|
+
})();
|
|
34
|
+
function gmul(a, b) {
|
|
35
|
+
if (a === 0 || b === 0)
|
|
36
|
+
return 0;
|
|
37
|
+
return EXP[LOG[a] + LOG[b]];
|
|
38
|
+
}
|
|
39
|
+
function gdiv(a, b) {
|
|
40
|
+
if (b === 0)
|
|
41
|
+
throw new Error("gf div by zero");
|
|
42
|
+
if (a === 0)
|
|
43
|
+
return 0;
|
|
44
|
+
return EXP[LOG[a] + 255 - LOG[b]];
|
|
45
|
+
}
|
|
46
|
+
function ginv(a) {
|
|
47
|
+
if (a === 0)
|
|
48
|
+
throw new Error("gf inv of zero");
|
|
49
|
+
return EXP[255 - LOG[a]];
|
|
50
|
+
}
|
|
51
|
+
// Build the M×K Cauchy parity matrix. Cauchy element = 1 / (x_i XOR y_j) with
|
|
52
|
+
// disjoint x (parity index space) and y (data index space). Guaranteed
|
|
53
|
+
// invertible submatrices → any K of K+M shards recover the data.
|
|
54
|
+
function cauchyParityMatrix(k, m) {
|
|
55
|
+
const rows = [];
|
|
56
|
+
for (let i = 0; i < m; i++) {
|
|
57
|
+
const row = new Uint8Array(k);
|
|
58
|
+
const xi = k + i; // parity nodes live at k..k+m-1
|
|
59
|
+
for (let j = 0; j < k; j++) {
|
|
60
|
+
row[j] = ginv(xi ^ j); // xi ^ j is never 0 since xi >= k > j
|
|
61
|
+
}
|
|
62
|
+
rows.push(row);
|
|
63
|
+
}
|
|
64
|
+
return rows;
|
|
65
|
+
}
|
|
66
|
+
/** Encode: given K equal-length data shards, return M parity shards. */
|
|
67
|
+
export function rsEncode(data, m) {
|
|
68
|
+
const k = data.length;
|
|
69
|
+
if (k === 0 || m === 0)
|
|
70
|
+
return [];
|
|
71
|
+
const len = data[0].length;
|
|
72
|
+
const parity = cauchyParityMatrix(k, m);
|
|
73
|
+
const out = [];
|
|
74
|
+
for (let i = 0; i < m; i++) {
|
|
75
|
+
const shard = new Uint8Array(len);
|
|
76
|
+
const row = parity[i];
|
|
77
|
+
for (let j = 0; j < k; j++) {
|
|
78
|
+
const coeff = row[j];
|
|
79
|
+
if (coeff === 0)
|
|
80
|
+
continue;
|
|
81
|
+
const src = data[j];
|
|
82
|
+
const lc = LOG[coeff];
|
|
83
|
+
for (let b = 0; b < len; b++) {
|
|
84
|
+
const s = src[b];
|
|
85
|
+
if (s !== 0)
|
|
86
|
+
shard[b] ^= EXP[lc + LOG[s]];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
out.push(shard);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
// Invert a K×K matrix in GF(256) via Gauss-Jordan. Returns null if singular
|
|
94
|
+
// (shouldn't happen for a Cauchy-derived submatrix, but guard anyway).
|
|
95
|
+
function invert(matrix, k) {
|
|
96
|
+
// Augment [M | I].
|
|
97
|
+
const a = matrix.map((r) => Uint8Array.from(r));
|
|
98
|
+
const inv = Array.from({ length: k }, (_, i) => {
|
|
99
|
+
const r = new Uint8Array(k);
|
|
100
|
+
r[i] = 1;
|
|
101
|
+
return r;
|
|
102
|
+
});
|
|
103
|
+
for (let col = 0; col < k; col++) {
|
|
104
|
+
// Find a pivot.
|
|
105
|
+
let piv = col;
|
|
106
|
+
while (piv < k && a[piv][col] === 0)
|
|
107
|
+
piv++;
|
|
108
|
+
if (piv === k)
|
|
109
|
+
return null;
|
|
110
|
+
if (piv !== col) {
|
|
111
|
+
[a[piv], a[col]] = [a[col], a[piv]];
|
|
112
|
+
[inv[piv], inv[col]] = [inv[col], inv[piv]];
|
|
113
|
+
}
|
|
114
|
+
// Normalize the pivot row.
|
|
115
|
+
const pv = a[col][col];
|
|
116
|
+
const pvInv = ginv(pv);
|
|
117
|
+
for (let j = 0; j < k; j++) {
|
|
118
|
+
a[col][j] = gmul(a[col][j], pvInv);
|
|
119
|
+
inv[col][j] = gmul(inv[col][j], pvInv);
|
|
120
|
+
}
|
|
121
|
+
// Eliminate the column from all other rows.
|
|
122
|
+
for (let row = 0; row < k; row++) {
|
|
123
|
+
if (row === col)
|
|
124
|
+
continue;
|
|
125
|
+
const f = a[row][col];
|
|
126
|
+
if (f === 0)
|
|
127
|
+
continue;
|
|
128
|
+
const lf = LOG[f];
|
|
129
|
+
for (let j = 0; j < k; j++) {
|
|
130
|
+
if (a[col][j] !== 0)
|
|
131
|
+
a[row][j] ^= EXP[lf + LOG[a[col][j]]];
|
|
132
|
+
if (inv[col][j] !== 0)
|
|
133
|
+
inv[row][j] ^= EXP[lf + LOG[inv[col][j]]];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return inv;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Recover the K data shards from any K present shards (data and/or parity).
|
|
141
|
+
* `shards` is the full K+M array with missing entries as null; `len` is the
|
|
142
|
+
* shard length. Returns the K reconstructed data shards, or null if fewer than
|
|
143
|
+
* K shards are present (unrecoverable).
|
|
144
|
+
*/
|
|
145
|
+
export function rsDecode(shards, k, m, len) {
|
|
146
|
+
// Fast path: all K data shards present.
|
|
147
|
+
if (shards.slice(0, k).every((s) => s !== null))
|
|
148
|
+
return shards.slice(0, k);
|
|
149
|
+
// Collect the first K present shards and the rows of the full encoding matrix
|
|
150
|
+
// (identity for data rows, Cauchy for parity rows) that produced them.
|
|
151
|
+
const parity = cauchyParityMatrix(k, m);
|
|
152
|
+
const encRow = (idx) => {
|
|
153
|
+
if (idx < k) {
|
|
154
|
+
const r = new Uint8Array(k);
|
|
155
|
+
r[idx] = 1;
|
|
156
|
+
return r;
|
|
157
|
+
} // identity row
|
|
158
|
+
return parity[idx - k];
|
|
159
|
+
};
|
|
160
|
+
const subMatrix = [];
|
|
161
|
+
const present = [];
|
|
162
|
+
for (let idx = 0; idx < k + m && subMatrix.length < k; idx++) {
|
|
163
|
+
const s = shards[idx];
|
|
164
|
+
if (s) {
|
|
165
|
+
subMatrix.push(encRow(idx));
|
|
166
|
+
present.push(s);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (subMatrix.length < k)
|
|
170
|
+
return null; // not enough shards
|
|
171
|
+
const invMatrix = invert(subMatrix, k);
|
|
172
|
+
if (!invMatrix)
|
|
173
|
+
return null;
|
|
174
|
+
// data = inv(subMatrix) × present.
|
|
175
|
+
const data = [];
|
|
176
|
+
for (let i = 0; i < k; i++) {
|
|
177
|
+
if (shards[i]) {
|
|
178
|
+
data.push(shards[i]);
|
|
179
|
+
continue;
|
|
180
|
+
} // already have it
|
|
181
|
+
const shard = new Uint8Array(len);
|
|
182
|
+
const row = invMatrix[i];
|
|
183
|
+
for (let j = 0; j < k; j++) {
|
|
184
|
+
const coeff = row[j];
|
|
185
|
+
if (coeff === 0)
|
|
186
|
+
continue;
|
|
187
|
+
const src = present[j];
|
|
188
|
+
const lc = LOG[coeff];
|
|
189
|
+
for (let b = 0; b < len; b++) {
|
|
190
|
+
const sv = src[b];
|
|
191
|
+
if (sv !== 0)
|
|
192
|
+
shard[b] ^= EXP[lc + LOG[sv]];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
data.push(shard);
|
|
196
|
+
}
|
|
197
|
+
return data;
|
|
198
|
+
}
|
package/dist/peer.js
CHANGED
|
@@ -9,7 +9,7 @@ import { PACKET_TYPE_MESSAGE, PACKET_TYPE_FRIEND_REQUEST, PACKET_TYPE_USERINFO,
|
|
|
9
9
|
import { NET_PACKET_ONION_ANNOUNCE_RESPONSE, NET_PACKET_ONION_DATA_RESPONSE, ONION_FRIEND_REQUEST_ID, createOnionAnnounceRequest, createOnionDataPacket, createOnionDataRequest, createOnionRequest0, openOnionAnnounceResponse, openOnionDataPacket, openOnionDataResponse } from "./compat/tox-onion.js";
|
|
10
10
|
import { CRYPTO_PACKET_DHTPK, CRYPTO_PACKET_FRIEND_REQ, NET_PACKET_CRYPTO, createToxDhtCryptoRequest, openToxDhtCryptoRequest } from "./compat/tox-dht-crypto.js";
|
|
11
11
|
import { NET_PACKET_PING_REQUEST, NET_PACKET_PING_RESPONSE, NET_PACKET_GET_NODES, NET_PACKET_SEND_NODES, encodeDhtRpc, decodeDhtRpc, buildPingPlain, parsePingPlain, buildGetNodesPlain, parseGetNodesPlain, buildSendNodesPlain, parseSendNodesNodeBytes, packUdpNodeV4 } from "./compat/dht-rpc.js";
|
|
12
|
-
import { FileTransferManager, PACKET_ID_FILE_SENDREQUEST, PACKET_ID_FILE_CONTROL, PACKET_ID_FILE_DATA } from "./compat/filetransfer.js";
|
|
12
|
+
import { FileTransferManager, PACKET_ID_FILE_SENDREQUEST, PACKET_ID_FILE_CONTROL, PACKET_ID_FILE_DATA, PACKET_ID_FILE_FEC } from "./compat/filetransfer.js";
|
|
13
13
|
import { NET_PACKET_CRYPTO_DATA, NET_PACKET_CRYPTO_HS, NET_PACKET_COOKIE_REQUEST, NET_PACKET_COOKIE_RESPONSE, createCookieRequest, createCookieResponse, createCryptoDataPacket, createCryptoHandshake, openCookieRequest, openCookieResponse, openCryptoDataPacket, openCryptoHandshake, incrementNonce } from "./compat/net-crypto.js";
|
|
14
14
|
import { LegacyExpressClient } from "./compat/express.js";
|
|
15
15
|
import { TcpRelayPool } from "./compat/tcp-relay-pool.js";
|
|
@@ -1649,7 +1649,8 @@ export class Peer {
|
|
|
1649
1649
|
const isSingleTransportKind = (this.#opts.bulkDataPacketId !== undefined && innerKind === this.#opts.bulkDataPacketId) ||
|
|
1650
1650
|
innerKind === PACKET_ID_FILE_SENDREQUEST ||
|
|
1651
1651
|
innerKind === PACKET_ID_FILE_CONTROL ||
|
|
1652
|
-
innerKind === PACKET_ID_FILE_DATA
|
|
1652
|
+
innerKind === PACKET_ID_FILE_DATA ||
|
|
1653
|
+
innerKind === PACKET_ID_FILE_FEC;
|
|
1653
1654
|
// Confirm the transport path + liveness for EVERY decrypted packet —
|
|
1654
1655
|
// INCLUDING a dual-send duplicate. Receiving over this transport proves
|
|
1655
1656
|
// it works, so this MUST run BEFORE the dedup drop below. Otherwise the
|
|
@@ -2922,7 +2923,7 @@ export class Peer {
|
|
|
2922
2923
|
// happens to finish before reordering bites. Pinning file transfer to a
|
|
2923
2924
|
// single transport keeps the chunks in send order. (Reliability across a
|
|
2924
2925
|
// lossy path still wants a reorder buffer + retransmit — tracked separately.)
|
|
2925
|
-
const isFileTransfer = kind === PACKET_ID_FILE_SENDREQUEST || kind === PACKET_ID_FILE_CONTROL || kind === PACKET_ID_FILE_DATA;
|
|
2926
|
+
const isFileTransfer = kind === PACKET_ID_FILE_SENDREQUEST || kind === PACKET_ID_FILE_CONTROL || kind === PACKET_ID_FILE_DATA || kind === PACKET_ID_FILE_FEC;
|
|
2926
2927
|
const isBulkData = (this.#opts.bulkDataPacketId !== undefined && kind === this.#opts.bulkDataPacketId) || isFileTransfer;
|
|
2927
2928
|
// File transfer rides the relay RELIABLY (never dropped under backpressure):
|
|
2928
2929
|
// a dropped IP/CCTV packet is fine (the stream moves on), but a dropped file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/peer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.75",
|
|
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",
|