@torrent-tv/proxy 2.70.0 → 2.71.1
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/CHANGELOG.md +20 -0
- package/CLAUDE.md +12 -0
- package/docs/download-architecture.md +175 -0
- package/docs/logs.md +8 -0
- package/package.json +1 -1
- package/services/demand/DemandRegister.js +182 -0
- package/services/demand/Urgency.js +137 -0
- package/services/demand/Window.js +118 -0
- package/services/demand/index.js +11 -0
- package/services/demand/pieces.js +140 -0
- package/services/download/SwarmSelection.js +367 -0
- package/services/download/index.js +8 -0
- package/services/download/registry.js +96 -0
- package/services/piece-store/shared-piece-store.js +16 -0
- package/services/playback-planner.js +15 -1
- package/services/torrent-pool.js +64 -197
- package/services/torrent-worker/fastest-wires.js +319 -279
- package/services/torrent-worker/piece-reader.js +149 -183
- package/services/torrent-worker/worker.js +23 -3
- package/test/demand-register.test.js +195 -0
- package/test/fastest-wires.test.js +23 -1
- package/test/read-bands.test.js +17 -10
- package/test/read-window.test.js +20 -8
- package/test/swarm-selection.test.js +220 -0
- package/utils/logger.js +62 -20
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What is wanted, by whom, and how urgently.
|
|
3
|
+
*
|
|
4
|
+
* These are the rules the download layer and the memory store both read, so a
|
|
5
|
+
* defect here is a defect in two places at once. Everything is numbers: no
|
|
6
|
+
* torrent, no library, no piece store.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
|
|
12
|
+
import { DemandRegister } from "../services/demand/DemandRegister.js";
|
|
13
|
+
import { unionOf, Window } from "../services/demand/Window.js";
|
|
14
|
+
import {
|
|
15
|
+
isConditional,
|
|
16
|
+
mayDisplaceSlowPeer,
|
|
17
|
+
selectionPriority,
|
|
18
|
+
Urgency
|
|
19
|
+
} from "../services/demand/Urgency.js";
|
|
20
|
+
import { nearestFirst, piecesNeededFor, piecesOf, piecesWithin } from "../services/demand/pieces.js";
|
|
21
|
+
|
|
22
|
+
const MEGABYTE = 1024 * 1024;
|
|
23
|
+
|
|
24
|
+
test("a window states bytes and refuses what it cannot mean", () => {
|
|
25
|
+
const window = new Window({
|
|
26
|
+
claimant: "video", fileIndex: 0, byteStart: 100, byteEnd: 199, urgency: Urgency.NEAR
|
|
27
|
+
});
|
|
28
|
+
assert.equal(window.byteLength, 100, "inclusive at both ends");
|
|
29
|
+
|
|
30
|
+
assert.throws(() => new Window({
|
|
31
|
+
claimant: "", fileIndex: 0, byteStart: 0, byteEnd: 1, urgency: 0
|
|
32
|
+
}), /claimant/, "a window nobody can release is a leak with a name missing");
|
|
33
|
+
assert.throws(() => new Window({
|
|
34
|
+
claimant: "video", fileIndex: 0, byteStart: 200, byteEnd: 100, urgency: 0
|
|
35
|
+
}), /Byte end/);
|
|
36
|
+
assert.throws(() => new Window({
|
|
37
|
+
claimant: "video", fileIndex: -1, byteStart: 0, byteEnd: 1, urgency: 0
|
|
38
|
+
}), /File index/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("windows of different files are never merged", () => {
|
|
42
|
+
const register = new DemandRegister();
|
|
43
|
+
register.state({ claimant: "a", fileIndex: 0, byteStart: 0, byteEnd: 99, urgency: Urgency.NEAR });
|
|
44
|
+
register.state({ claimant: "b", fileIndex: 1, byteStart: 0, byteEnd: 99, urgency: Urgency.NEAR });
|
|
45
|
+
|
|
46
|
+
// Byte 50 of file 0 and byte 50 of file 1 are different bytes. Two viewers on
|
|
47
|
+
// two episodes of one release is the ordinary case, not an edge one.
|
|
48
|
+
assert.deepEqual(register.union(), [
|
|
49
|
+
{ byteStart: 0, byteEnd: 99 },
|
|
50
|
+
{ byteStart: 0, byteEnd: 99 }
|
|
51
|
+
]);
|
|
52
|
+
assert.deepEqual(register.files(), [0, 1]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("the union of overlapping windows is their union, not their sum", () => {
|
|
56
|
+
// Picture and sound are two readers of one file and their windows overlap by
|
|
57
|
+
// construction. Adding them would report a demand that is not there.
|
|
58
|
+
assert.deepEqual(
|
|
59
|
+
unionOf([
|
|
60
|
+
{ byteStart: 0, byteEnd: 99 },
|
|
61
|
+
{ byteStart: 50, byteEnd: 149 }
|
|
62
|
+
]),
|
|
63
|
+
[{ byteStart: 0, byteEnd: 149 }]
|
|
64
|
+
);
|
|
65
|
+
// Touching ranges are one run: 0-99 and 100-199 have nothing between them.
|
|
66
|
+
assert.deepEqual(
|
|
67
|
+
unionOf([
|
|
68
|
+
{ byteStart: 100, byteEnd: 199 },
|
|
69
|
+
{ byteStart: 0, byteEnd: 99 }
|
|
70
|
+
]),
|
|
71
|
+
[{ byteStart: 0, byteEnd: 199 }]
|
|
72
|
+
);
|
|
73
|
+
assert.deepEqual(
|
|
74
|
+
unionOf([
|
|
75
|
+
{ byteStart: 0, byteEnd: 99 },
|
|
76
|
+
{ byteStart: 200, byteEnd: 299 }
|
|
77
|
+
]),
|
|
78
|
+
[{ byteStart: 0, byteEnd: 99 }, { byteStart: 200, byteEnd: 299 }],
|
|
79
|
+
"a gap between them is a gap"
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a claimant states one window, and restating replaces it", () => {
|
|
84
|
+
const register = new DemandRegister();
|
|
85
|
+
register.state({ claimant: "video", fileIndex: 0, byteStart: 0, byteEnd: 99, urgency: Urgency.NEAR });
|
|
86
|
+
register.state({ claimant: "video", fileIndex: 0, byteStart: 100, byteEnd: 199, urgency: Urgency.NEAR });
|
|
87
|
+
|
|
88
|
+
// A reader walking a film restates its window many times a second. If those
|
|
89
|
+
// accumulated, the download set would be the whole file within a minute.
|
|
90
|
+
assert.equal(register.size, 1);
|
|
91
|
+
assert.deepEqual(register.union(0), [{ byteStart: 100, byteEnd: 199 }]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("a withdrawal names its own claimant and nothing else", () => {
|
|
95
|
+
const register = new DemandRegister();
|
|
96
|
+
register.state({ claimant: "video", fileIndex: 0, byteStart: 0, byteEnd: 99, urgency: Urgency.NEAR });
|
|
97
|
+
register.state({ claimant: "audio", fileIndex: 0, byteStart: 0, byteEnd: 99, urgency: Urgency.NEAR });
|
|
98
|
+
|
|
99
|
+
assert.equal(register.withdraw("audio"), true);
|
|
100
|
+
assert.equal(register.withdraw("audio"), false, "a second release withdraws nothing");
|
|
101
|
+
assert.equal(register.withdraw("nobody"), false, "a stray release matches nothing");
|
|
102
|
+
assert.equal(register.size, 1, "and video still wants what it wanted");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("the speculative levels are stated only while nothing urgent is missing", () => {
|
|
106
|
+
const register = new DemandRegister();
|
|
107
|
+
register.state({ claimant: "video", fileIndex: 0, byteStart: 0, byteEnd: 99, urgency: Urgency.BLOCKED });
|
|
108
|
+
register.state({ claimant: "fill", fileIndex: 0, byteStart: 1000, byteEnd: 9999, urgency: Urgency.TAIL });
|
|
109
|
+
register.state({ claimant: "back", fileIndex: 0, byteStart: 0, byteEnd: 999, urgency: Urgency.BEHIND });
|
|
110
|
+
|
|
111
|
+
// Something urgent is missing: the speculative levels are not stated at all.
|
|
112
|
+
// Withdrawn, not lowered — a withdrawn window is not in the download set, so
|
|
113
|
+
// a peer with nothing urgent to give cannot fall through to it and spend the
|
|
114
|
+
// shared link on a piece nobody is waiting for.
|
|
115
|
+
assert.deepEqual(
|
|
116
|
+
register.levelsToState((window) => window.urgency !== Urgency.BLOCKED),
|
|
117
|
+
[Urgency.BLOCKED, Urgency.NEAR, Urgency.AHEAD]
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Everything urgent has arrived: they are stated, in order.
|
|
121
|
+
assert.deepEqual(
|
|
122
|
+
register.levelsToState(() => true),
|
|
123
|
+
[Urgency.BLOCKED, Urgency.NEAR, Urgency.AHEAD, Urgency.TAIL, Urgency.BEHIND]
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// The tail is complete but the gap behind is not: the gap stays stated and
|
|
127
|
+
// nothing below it exists to withhold.
|
|
128
|
+
assert.deepEqual(
|
|
129
|
+
register.levelsToState((window) => window.urgency !== Urgency.BEHIND),
|
|
130
|
+
[Urgency.BLOCKED, Urgency.NEAR, Urgency.AHEAD, Urgency.TAIL, Urgency.BEHIND]
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("only the level being waited on may take a block from a slow peer", () => {
|
|
135
|
+
assert.equal(mayDisplaceSlowPeer(Urgency.BLOCKED), true);
|
|
136
|
+
for (const urgency of [Urgency.NEAR, Urgency.AHEAD, Urgency.TAIL, Urgency.BEHIND]) {
|
|
137
|
+
assert.equal(mayDisplaceSlowPeer(urgency), false);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("the library is given the only distinction it keeps", () => {
|
|
142
|
+
// Measured against the vendored 2.8.5: distinct non-zero priorities order the
|
|
143
|
+
// selection list once and are round-robined afterwards by `shufflePriority`.
|
|
144
|
+
// Zero is the only value that stays put, always last.
|
|
145
|
+
assert.equal(selectionPriority(Urgency.BLOCKED), 1);
|
|
146
|
+
assert.equal(selectionPriority(Urgency.NEAR), 1);
|
|
147
|
+
assert.equal(selectionPriority(Urgency.AHEAD), 1);
|
|
148
|
+
assert.equal(selectionPriority(Urgency.TAIL), 0);
|
|
149
|
+
assert.equal(selectionPriority(Urgency.BEHIND), 0);
|
|
150
|
+
|
|
151
|
+
assert.equal(isConditional(Urgency.AHEAD), false);
|
|
152
|
+
assert.equal(isConditional(Urgency.TAIL), true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("bytes become pieces in one place, and a partial piece is a whole piece", () => {
|
|
156
|
+
const pieceLength = 4 * MEGABYTE;
|
|
157
|
+
// A file starting one piece into the torrent, wanting its first byte.
|
|
158
|
+
assert.deepEqual(
|
|
159
|
+
piecesOf({ fileOffset: pieceLength, byteStart: 0, byteEnd: 0, pieceLength }),
|
|
160
|
+
{ from: 1, to: 1 }
|
|
161
|
+
);
|
|
162
|
+
// A range ending one byte into the next piece still needs that whole piece:
|
|
163
|
+
// the protocol delivers and verifies nothing smaller.
|
|
164
|
+
assert.deepEqual(
|
|
165
|
+
piecesOf({ fileOffset: 0, byteStart: 0, byteEnd: pieceLength, pieceLength }),
|
|
166
|
+
{ from: 0, to: 1 }
|
|
167
|
+
);
|
|
168
|
+
assert.equal(piecesOf({ fileOffset: 0, byteStart: 10, byteEnd: 5, pieceLength }), null);
|
|
169
|
+
assert.equal(piecesOf({ fileOffset: 0, byteStart: 0, byteEnd: 1, pieceLength: 0 }), null);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("a budget buys whole places, and a window needs one more than it measures", () => {
|
|
173
|
+
const pieceLength = 16 * MEGABYTE;
|
|
174
|
+
// The failure this rounding hid: 64 MB of allowance is four places, and two
|
|
175
|
+
// readers asking for 96 MB each want six. Counted in bytes the shortage is
|
|
176
|
+
// plain; counted in floored pieces it is not.
|
|
177
|
+
assert.equal(piecesWithin(64 * MEGABYTE, pieceLength), 4);
|
|
178
|
+
assert.equal(piecesNeededFor(96 * MEGABYTE, pieceLength), 7, "six, plus the one it can straddle");
|
|
179
|
+
assert.equal(piecesWithin(0, pieceLength), 0);
|
|
180
|
+
assert.equal(piecesNeededFor(0, pieceLength), 0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("the gap behind the playhead is stated nearest first", () => {
|
|
184
|
+
// WebTorrent walks a selection from its start upwards, so one claim over
|
|
185
|
+
// everything behind the viewer would be fetched from the beginning of the
|
|
186
|
+
// file — the end furthest from where a backward seek lands.
|
|
187
|
+
const ranges = nearestFirst({ byteStart: 0, byteEnd: 999, parts: 4 });
|
|
188
|
+
assert.equal(ranges.length, 4);
|
|
189
|
+
assert.equal(ranges[0].byteEnd, 999, "the part nearest the playhead comes first");
|
|
190
|
+
assert.equal(ranges[ranges.length - 1].byteStart, 0, "and the furthest comes last");
|
|
191
|
+
for (const range of ranges) {
|
|
192
|
+
assert.ok(range.byteStart <= range.byteEnd);
|
|
193
|
+
}
|
|
194
|
+
assert.deepEqual(nearestFirst({ byteStart: 10, byteEnd: 5, parts: 2 }), []);
|
|
195
|
+
});
|
|
@@ -91,13 +91,35 @@ test("a refusal is counted as a refusal", () => {
|
|
|
91
91
|
const result = askFastestWiresFor(torrent, 11);
|
|
92
92
|
assert.equal(result.asked, 0);
|
|
93
93
|
assert.equal(result.considered, 2);
|
|
94
|
+
// Refused, but not because every block was spoken for — this stub has no
|
|
95
|
+
// reservations at all. The two reasons are different and only the second is
|
|
96
|
+
// the one WebTorrent's displacement thresholds decide.
|
|
97
|
+
assert.equal(result.refusedWhileReserved, 0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("a refusal with every block already reserved is counted separately", () => {
|
|
101
|
+
// The interesting refusal: a fast peer we picked was turned away because
|
|
102
|
+
// every block belongs to somebody else and displacement did not happen. The
|
|
103
|
+
// thresholds that decide it are constants in the library — the asker above
|
|
104
|
+
// 16 KB/s, the holder below 48 KB/s and twice as slow — so a holder at
|
|
105
|
+
// 50 KB/s is never displaced however long the piece has waited. This number
|
|
106
|
+
// is what would justify replacing that rule.
|
|
107
|
+
const { torrent } = torrentWith([wire({ speed: 500 })], { refuse: true });
|
|
108
|
+
torrent.pieces = [];
|
|
109
|
+
torrent.pieces[11] = { reserve: () => -1, cancel: () => undefined };
|
|
110
|
+
const result = askFastestWiresFor(torrent, 11);
|
|
111
|
+
assert.equal(result.asked, 0);
|
|
112
|
+
assert.equal(result.refusedWhileReserved, 1);
|
|
94
113
|
});
|
|
95
114
|
|
|
96
115
|
test("a build without the request entry is reported, not silently skipped", () => {
|
|
97
116
|
assert.equal(canPlaceRequests({ wires: [] }), false);
|
|
98
117
|
assert.equal(canPlaceRequests({ wires: [], _request: () => true }), true);
|
|
99
118
|
const result = askFastestWiresFor({ wires: [wire({ speed: 1 })] }, 1);
|
|
100
|
-
assert.deepEqual(
|
|
119
|
+
assert.deepEqual(
|
|
120
|
+
result,
|
|
121
|
+
{ asked: 0, refusedWhileReserved: 0, attempted: 0, considered: 0, fastestBytesPerSecond: 0 }
|
|
122
|
+
);
|
|
101
123
|
});
|
|
102
124
|
|
|
103
125
|
test("a wire that cannot say how fast it is ranks last rather than throwing", () => {
|
package/test/read-bands.test.js
CHANGED
|
@@ -11,10 +11,11 @@
|
|
|
11
11
|
import test from "node:test";
|
|
12
12
|
import assert from "node:assert/strict";
|
|
13
13
|
import { bandWidthsFrom, bandsFrom, sameBands } from "../services/torrent-worker/piece-reader.js";
|
|
14
|
+
import { Urgency } from "../services/demand/Urgency.js";
|
|
14
15
|
|
|
15
16
|
const PIECE = 8 * 1024 * 1024;
|
|
16
17
|
|
|
17
|
-
test("the urgent band
|
|
18
|
+
test("the urgent band is the one being read, and the lead bands come behind it in order", () => {
|
|
18
19
|
const bands = bandsFrom({
|
|
19
20
|
urgent: { from: 100, to: 103 },
|
|
20
21
|
pieceIndex: 100,
|
|
@@ -26,15 +27,15 @@ test("the urgent band keeps the highest priority, and the lead bands come behind
|
|
|
26
27
|
assert.deepEqual(
|
|
27
28
|
bands,
|
|
28
29
|
[
|
|
29
|
-
{ from: 100, to: 103,
|
|
30
|
-
{ from: 104, to: 108,
|
|
31
|
-
{ from: 109, to: 128,
|
|
30
|
+
{ from: 100, to: 103, urgency: Urgency.NEAR },
|
|
31
|
+
{ from: 104, to: 108, urgency: Urgency.AHEAD },
|
|
32
|
+
{ from: 109, to: 128, urgency: Urgency.AHEAD }
|
|
32
33
|
],
|
|
33
34
|
"no gap, no overlap, and each band strictly less urgent than the one in front"
|
|
34
35
|
);
|
|
35
36
|
});
|
|
36
37
|
|
|
37
|
-
test("no
|
|
38
|
+
test("no band is speculative while the reader is still walking the file", () => {
|
|
38
39
|
const bands = bandsFrom({
|
|
39
40
|
urgent: { from: 0, to: 1 },
|
|
40
41
|
pieceIndex: 0,
|
|
@@ -44,7 +45,7 @@ test("no priority is zero, because the library's own whole-file fill sits there"
|
|
|
44
45
|
});
|
|
45
46
|
|
|
46
47
|
assert.ok(
|
|
47
|
-
bands.every((band) => band.
|
|
48
|
+
bands.every((band) => band.urgency <= Urgency.AHEAD),
|
|
48
49
|
"a band at 0 would be indistinguishable from the background fill of the whole torrent"
|
|
49
50
|
);
|
|
50
51
|
});
|
|
@@ -72,7 +73,7 @@ test("what was never downloaded behind the viewer is not asked for until the lea
|
|
|
72
73
|
});
|
|
73
74
|
assert.deepEqual(
|
|
74
75
|
covered[covered.length - 1],
|
|
75
|
-
{ from: 0, to: 49,
|
|
76
|
+
{ from: 0, to: 49, urgency: Urgency.BEHIND },
|
|
76
77
|
"once there is nothing left ahead, the gap behind the viewer is worth filling"
|
|
77
78
|
);
|
|
78
79
|
});
|
|
@@ -121,12 +122,18 @@ test("with nothing measured yet both lead bands fall back to the reader's own wi
|
|
|
121
122
|
});
|
|
122
123
|
|
|
123
124
|
test("an unchanged claim is recognised, so it is not released and re-made on every read", () => {
|
|
124
|
-
const bands = [
|
|
125
|
+
const bands = [
|
|
126
|
+
{ from: 1, to: 2, urgency: Urgency.NEAR },
|
|
127
|
+
{ from: 3, to: 9, urgency: Urgency.AHEAD }
|
|
128
|
+
];
|
|
125
129
|
|
|
126
130
|
assert.equal(sameBands(bands, [...bands.map((band) => ({ ...band }))]), true);
|
|
127
|
-
assert.equal(sameBands(bands, [{ from: 1, to: 2,
|
|
131
|
+
assert.equal(sameBands(bands, [{ from: 1, to: 2, urgency: Urgency.NEAR }]), false);
|
|
128
132
|
assert.equal(
|
|
129
|
-
sameBands(bands, [
|
|
133
|
+
sameBands(bands, [
|
|
134
|
+
{ from: 1, to: 2, urgency: Urgency.NEAR },
|
|
135
|
+
{ from: 3, to: 9, urgency: Urgency.BEHIND }
|
|
136
|
+
]),
|
|
130
137
|
false,
|
|
131
138
|
"the same range at another urgency is a different claim"
|
|
132
139
|
);
|
package/test/read-window.test.js
CHANGED
|
@@ -62,11 +62,13 @@ async function recordingTorrent({ pieceCount, present = () => true }) {
|
|
|
62
62
|
bitfield: { get: (index) => present(index) },
|
|
63
63
|
files: [{ offset: 0, length: totalLength, name: "file.bin" }],
|
|
64
64
|
_critical: [],
|
|
65
|
+
_selections: { _items: [] },
|
|
65
66
|
calls,
|
|
66
67
|
held,
|
|
67
68
|
_select(from, to, _priority, _notify, isStreamSelection) {
|
|
68
69
|
calls.push({ call: "select", from, to, stream: isStreamSelection === true });
|
|
69
70
|
held.push(`${from}-${to}`);
|
|
71
|
+
this._selections._items.push({ from, to });
|
|
70
72
|
},
|
|
71
73
|
_deselect(from, to, isStreamSelection) {
|
|
72
74
|
calls.push({ call: "deselect", from, to, stream: isStreamSelection === true });
|
|
@@ -74,6 +76,10 @@ async function recordingTorrent({ pieceCount, present = () => true }) {
|
|
|
74
76
|
if (at >= 0) {
|
|
75
77
|
held.splice(at, 1);
|
|
76
78
|
}
|
|
79
|
+
const item = this._selections._items.findIndex((one) => one.from === from && one.to === to);
|
|
80
|
+
if (item >= 0) {
|
|
81
|
+
this._selections._items.splice(item, 1);
|
|
82
|
+
}
|
|
77
83
|
},
|
|
78
84
|
critical(from, to) {
|
|
79
85
|
calls.push({ call: "critical", from, to });
|
|
@@ -181,7 +187,7 @@ test("an abandoned read leaves nothing selected", async () => {
|
|
|
181
187
|
}
|
|
182
188
|
});
|
|
183
189
|
|
|
184
|
-
test("two readers add up, and one leaving takes only its own
|
|
190
|
+
test("two readers add up, and one leaving takes only its own", async () => {
|
|
185
191
|
const { torrent, store, directory } = await recordingTorrent({ pieceCount: 8000 });
|
|
186
192
|
try {
|
|
187
193
|
const head = readFragments({
|
|
@@ -195,18 +201,24 @@ test("two readers add up, and one leaving takes only its own window", async () =
|
|
|
195
201
|
(await head.next()).value.release();
|
|
196
202
|
(await tail.next()).value.release();
|
|
197
203
|
|
|
198
|
-
|
|
199
|
-
|
|
204
|
+
// Not a count: each reader states several bands by level, and two readers
|
|
205
|
+
// wanting the same pieces are one instruction. What matters is that both
|
|
206
|
+
// are represented and that leaving removes only what leaving should.
|
|
207
|
+
const pieceOf = (range) => Number(range.split("-")[0]);
|
|
208
|
+
const held = [...torrent.held];
|
|
209
|
+
assert.ok(held.some((range) => pieceOf(range) < 4000), "the head reader holds nothing");
|
|
210
|
+
assert.ok(held.some((range) => pieceOf(range) >= 4000), "the tail reader holds nothing");
|
|
200
211
|
|
|
201
212
|
await tail.return();
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
213
|
+
const after = [...torrent.held];
|
|
214
|
+
assert.ok(
|
|
215
|
+
after.some((range) => pieceOf(range) < 4000),
|
|
216
|
+
"the head reader's window went with the tail reader"
|
|
206
217
|
);
|
|
218
|
+
assert.ok(after.length < held.length, "the tail reader took nothing away when it left");
|
|
207
219
|
|
|
208
220
|
await head.return();
|
|
209
|
-
assert.deepEqual(torrent.held, []);
|
|
221
|
+
assert.deepEqual(torrent.held, [], "the last reader left something behind");
|
|
210
222
|
} finally {
|
|
211
223
|
store.destroy(() => undefined);
|
|
212
224
|
await fs.rm(directory, { recursive: true, force: true });
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The one place that speaks to WebTorrent.
|
|
3
|
+
*
|
|
4
|
+
* Driven against a stub torrent shaped like the vendored 2.8.5: a selection
|
|
5
|
+
* list it can be asked about, a bitfield saying what has arrived, and the
|
|
6
|
+
* private `_select`/`_deselect` the real one exposes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
|
|
12
|
+
import { DemandRegister } from "../services/demand/DemandRegister.js";
|
|
13
|
+
import { Urgency } from "../services/demand/Urgency.js";
|
|
14
|
+
import { SwarmSelection } from "../services/download/SwarmSelection.js";
|
|
15
|
+
|
|
16
|
+
const PIECE = 1024;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {object} [params]
|
|
20
|
+
* @param {number[]} [params.have] - Pieces that have arrived.
|
|
21
|
+
* @param {number} [params.files] - How many files, each ten pieces long.
|
|
22
|
+
* @returns {object}
|
|
23
|
+
*/
|
|
24
|
+
function stubTorrent({ have = [], files = 1 } = {}) {
|
|
25
|
+
const arrived = new Set(have);
|
|
26
|
+
const items = [];
|
|
27
|
+
return {
|
|
28
|
+
pieceLength: PIECE,
|
|
29
|
+
store: null,
|
|
30
|
+
files: Array.from({ length: files }, (unused, index) => ({
|
|
31
|
+
offset: index * 10 * PIECE,
|
|
32
|
+
length: 10 * PIECE
|
|
33
|
+
})),
|
|
34
|
+
bitfield: { get: (index) => arrived.has(index) },
|
|
35
|
+
_critical: [],
|
|
36
|
+
_selections: { _items: items },
|
|
37
|
+
calls: { select: [], deselect: [], critical: [] },
|
|
38
|
+
_select(from, to, priority, notify, isStream) {
|
|
39
|
+
this.calls.select.push({ from, to, priority, isStream });
|
|
40
|
+
items.push({ from, to, priority });
|
|
41
|
+
},
|
|
42
|
+
_deselect(from, to) {
|
|
43
|
+
this.calls.deselect.push({ from, to });
|
|
44
|
+
const at = items.findIndex((item) => item.from === from && item.to === to);
|
|
45
|
+
if (at >= 0) {
|
|
46
|
+
items.splice(at, 1);
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
critical(from, to) {
|
|
50
|
+
this.calls.critical.push({ from, to });
|
|
51
|
+
for (let index = from; index <= to; index += 1) {
|
|
52
|
+
this._critical[index] = true;
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
/** Pretend the library dropped a satisfied selection, which it does. */
|
|
56
|
+
forget(from, to) {
|
|
57
|
+
const at = items.findIndex((item) => item.from === from && item.to === to);
|
|
58
|
+
if (at >= 0) {
|
|
59
|
+
items.splice(at, 1);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
test("nothing is asked of the swarm until somebody states a need", () => {
|
|
66
|
+
const torrent = stubTorrent();
|
|
67
|
+
const selection = new SwarmSelection({ torrent, register: new DemandRegister() });
|
|
68
|
+
|
|
69
|
+
assert.deepEqual(selection.reconcile(), { stated: 0, withdrawn: 0 });
|
|
70
|
+
assert.equal(torrent.calls.select.length, 0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("two viewers of one film are two instructions, and both are urgent", () => {
|
|
74
|
+
const torrent = stubTorrent();
|
|
75
|
+
const register = new DemandRegister();
|
|
76
|
+
const selection = new SwarmSelection({ torrent, register });
|
|
77
|
+
|
|
78
|
+
// One stopped at the start, one stopped further in. Both pictures are still.
|
|
79
|
+
register.state({ claimant: "v1", fileIndex: 0, byteStart: 0, byteEnd: PIECE - 1, urgency: Urgency.BLOCKED });
|
|
80
|
+
register.state({ claimant: "v2", fileIndex: 0, byteStart: 5 * PIECE, byteEnd: 6 * PIECE - 1, urgency: Urgency.BLOCKED });
|
|
81
|
+
selection.reconcile();
|
|
82
|
+
|
|
83
|
+
assert.deepEqual(torrent.calls.select, [
|
|
84
|
+
{ from: 0, to: 0, priority: 1, isStream: true },
|
|
85
|
+
{ from: 5, to: 5, priority: 1, isStream: true }
|
|
86
|
+
]);
|
|
87
|
+
// Both may take a block from a slow peer, and the mark spans both.
|
|
88
|
+
assert.deepEqual(torrent.calls.critical, [{ from: 0, to: 5 }]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("the same pieces wanted by two claimants are one instruction", () => {
|
|
92
|
+
const torrent = stubTorrent();
|
|
93
|
+
const register = new DemandRegister();
|
|
94
|
+
const selection = new SwarmSelection({ torrent, register });
|
|
95
|
+
|
|
96
|
+
// Picture and sound of one viewer read the same file and overlap.
|
|
97
|
+
register.state({ claimant: "video", fileIndex: 0, byteStart: 0, byteEnd: PIECE - 1, urgency: Urgency.NEAR });
|
|
98
|
+
register.state({ claimant: "audio", fileIndex: 0, byteStart: 0, byteEnd: PIECE - 1, urgency: Urgency.NEAR });
|
|
99
|
+
const first = selection.reconcile();
|
|
100
|
+
|
|
101
|
+
assert.equal(first.stated, 1, "one range, told once");
|
|
102
|
+
assert.equal(torrent.calls.select.length, 1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("the speculative levels are withdrawn whole the moment something urgent is missing", () => {
|
|
106
|
+
const torrent = stubTorrent({ have: [0] });
|
|
107
|
+
const register = new DemandRegister();
|
|
108
|
+
const selection = new SwarmSelection({ torrent, register });
|
|
109
|
+
|
|
110
|
+
register.state({ claimant: "video", fileIndex: 0, byteStart: 0, byteEnd: PIECE - 1, urgency: Urgency.NEAR });
|
|
111
|
+
register.state({ claimant: "fill", fileIndex: 0, byteStart: 5 * PIECE, byteEnd: 9 * PIECE - 1, urgency: Urgency.TAIL });
|
|
112
|
+
selection.reconcile();
|
|
113
|
+
|
|
114
|
+
assert.equal(selection.statedRanges().length, 2, "nothing urgent is missing, so the tail is stated");
|
|
115
|
+
assert.ok(torrent.calls.select.some((call) => call.priority === 0), "and it is stated as zero");
|
|
116
|
+
|
|
117
|
+
// The viewer moves on to a piece that has not arrived.
|
|
118
|
+
register.state({ claimant: "video", fileIndex: 0, byteStart: PIECE, byteEnd: 2 * PIECE - 1, urgency: Urgency.NEAR });
|
|
119
|
+
selection.reconcile();
|
|
120
|
+
|
|
121
|
+
assert.deepEqual(
|
|
122
|
+
selection.statedRanges().map((range) => range.priority),
|
|
123
|
+
[1],
|
|
124
|
+
"the tail is out of the download set entirely, not lowered — a peer with nothing urgent to give must not be able to fall through to it"
|
|
125
|
+
);
|
|
126
|
+
assert.ok(torrent.calls.deselect.length > 0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a selection the library drops once satisfied is stated again", () => {
|
|
130
|
+
const torrent = stubTorrent();
|
|
131
|
+
const register = new DemandRegister();
|
|
132
|
+
const selection = new SwarmSelection({ torrent, register });
|
|
133
|
+
|
|
134
|
+
register.state({ claimant: "video", fileIndex: 0, byteStart: 0, byteEnd: PIECE - 1, urgency: Urgency.NEAR });
|
|
135
|
+
selection.reconcile();
|
|
136
|
+
assert.equal(torrent.calls.select.length, 1);
|
|
137
|
+
|
|
138
|
+
// Nothing changed: no second instruction.
|
|
139
|
+
selection.reconcile();
|
|
140
|
+
assert.equal(torrent.calls.select.length, 1);
|
|
141
|
+
|
|
142
|
+
// WebTorrent removes a selection once every piece in it has arrived, and says
|
|
143
|
+
// nothing about having done so. The window is still wanted.
|
|
144
|
+
torrent.forget(0, 0);
|
|
145
|
+
selection.reconcile();
|
|
146
|
+
assert.equal(torrent.calls.select.length, 2, "stated again, because the library had let it go");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("a claimant that withdraws takes its instruction with it", () => {
|
|
150
|
+
const torrent = stubTorrent();
|
|
151
|
+
const register = new DemandRegister();
|
|
152
|
+
const selection = new SwarmSelection({ torrent, register });
|
|
153
|
+
|
|
154
|
+
register.state({ claimant: "video", fileIndex: 0, byteStart: 0, byteEnd: PIECE - 1, urgency: Urgency.BLOCKED });
|
|
155
|
+
selection.reconcile();
|
|
156
|
+
register.withdraw("video");
|
|
157
|
+
const after = selection.reconcile();
|
|
158
|
+
|
|
159
|
+
assert.equal(after.withdrawn, 1);
|
|
160
|
+
assert.equal(selection.statedRanges().length, 0);
|
|
161
|
+
// And the displacement mark goes with it: WebTorrent never clears it itself,
|
|
162
|
+
// so a reader walking a film would leave every piece of it marked.
|
|
163
|
+
assert.equal(torrent._critical.some((marked) => marked === true), false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("a window is bounded by its own file, so it cannot claim the next one", () => {
|
|
167
|
+
const torrent = stubTorrent({ files: 3 });
|
|
168
|
+
const register = new DemandRegister();
|
|
169
|
+
const selection = new SwarmSelection({ torrent, register });
|
|
170
|
+
|
|
171
|
+
// Asking past the end of file 1. File 1 occupies pieces 10-19.
|
|
172
|
+
register.state({
|
|
173
|
+
claimant: "video", fileIndex: 1, byteStart: 0, byteEnd: 100 * PIECE, urgency: Urgency.NEAR
|
|
174
|
+
});
|
|
175
|
+
selection.reconcile();
|
|
176
|
+
|
|
177
|
+
assert.deepEqual(torrent.calls.select, [{ from: 10, to: 19, priority: 1, isStream: true }]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("releasing everything leaves the library holding nothing of ours", () => {
|
|
181
|
+
const torrent = stubTorrent();
|
|
182
|
+
const register = new DemandRegister();
|
|
183
|
+
const selection = new SwarmSelection({ torrent, register });
|
|
184
|
+
|
|
185
|
+
register.state({ claimant: "video", fileIndex: 0, byteStart: 0, byteEnd: 3 * PIECE, urgency: Urgency.BLOCKED });
|
|
186
|
+
selection.reconcile();
|
|
187
|
+
selection.releaseAll();
|
|
188
|
+
|
|
189
|
+
assert.equal(torrent._selections._items.length, 0);
|
|
190
|
+
assert.equal(selection.statedRanges().length, 0);
|
|
191
|
+
assert.equal(torrent._critical.some((marked) => marked === true), false);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("the store is told what will be read soon, from the same stated needs", () => {
|
|
195
|
+
const torrent = stubTorrent();
|
|
196
|
+
const protectedBy = new Map();
|
|
197
|
+
torrent.store = {
|
|
198
|
+
protectRange: (claimant, from, to) => protectedBy.set(claimant, `${from}-${to}`),
|
|
199
|
+
releaseProtection: (claimant) => protectedBy.delete(claimant)
|
|
200
|
+
};
|
|
201
|
+
const register = new DemandRegister();
|
|
202
|
+
const selection = new SwarmSelection({ torrent, register, findStore: () => torrent.store });
|
|
203
|
+
|
|
204
|
+
register.state({ claimant: "video", fileIndex: 0, byteStart: 0, byteEnd: PIECE - 1, urgency: Urgency.NEAR });
|
|
205
|
+
register.state({ claimant: "fill", fileIndex: 0, byteStart: 5 * PIECE, byteEnd: 9 * PIECE - 1, urgency: Urgency.TAIL });
|
|
206
|
+
selection.reconcile();
|
|
207
|
+
|
|
208
|
+
// One statement, two views of it. Until 2026-09-02 a reader said the same
|
|
209
|
+
// thing twice — once to the store for memory, once to the torrent for
|
|
210
|
+
// download — and a third piece of code read the first to rebuild the second.
|
|
211
|
+
assert.equal(protectedBy.get("video"), "0-0");
|
|
212
|
+
// But only the urgent levels: memory holds what will be READ soon, and the
|
|
213
|
+
// tail is fetched speculatively. Protecting it would push out a piece the
|
|
214
|
+
// decoder is about to want.
|
|
215
|
+
assert.equal(protectedBy.has("fill"), false);
|
|
216
|
+
|
|
217
|
+
register.withdraw("video");
|
|
218
|
+
selection.reconcile();
|
|
219
|
+
assert.equal(protectedBy.size, 0, "a reader that withdrew still held memory");
|
|
220
|
+
});
|