@torrent-tv/proxy 2.83.2 → 2.83.4
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 +24 -0
- package/package.json +1 -1
- package/research/handover-reader-claims-removal-2026-09-12.md +166 -0
- package/research/priority-map-is-the-truth-2026-09-12.md +207 -0
- package/routes/api/sources/files/get.js +23 -9
- package/routes/api/sources/warm/post.js +26 -22
- package/routes/api/transcode-sessions/post.js +1 -49
- package/routes/stream/get.js +2 -36
- package/services/controllers/SubtitleController.js +0 -3
- package/services/hls-session-manager.js +3 -68
- package/services/playback-planner.js +12 -13
- package/services/priority/PriorityOrchestrator.js +40 -6
- package/services/torrent/Contents.js +324 -0
- package/services/torrent/files.js +8 -1
- package/services/torrent-pool.js +378 -187
- package/services/torrent-worker/client.js +0 -24
- package/services/torrent-worker/pool-adapter.js +3 -33
- package/services/torrent-worker/protocol.js +0 -4
- package/services/torrent-worker/worker.js +52 -59
- package/test/file-edges.test.js +191 -0
- package/test/priority-map-emptied.test.js +207 -0
- package/test/source-files-route.test.js +103 -0
- package/test/stream-route.test.js +4 -8
- package/test/swarm-follows-readers.test.js +107 -101
- package/test/torrent-contents.test.js +235 -0
- package/test/upload-hurry.test.js +66 -28
- package/services/torrent-worker/file-claims.js +0 -91
- package/test/file-claims.test.js +0 -64
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file WHEN NOBODY WANTS A FILE ANY MORE, THE MAP SAYS SO.
|
|
3
|
+
*
|
|
4
|
+
* The map is the only statement of what is wanted, and until 2.83.4 it could
|
|
5
|
+
* make only one of the two statements it exists for. A file whose viewers had
|
|
6
|
+
* gone was deleted from the orchestrator's own memory and published nowhere, so
|
|
7
|
+
* the bands it had stated on the swarm's behalf stood until the torrent itself
|
|
8
|
+
* was removed — read from the demand register, a film nobody had watched for an
|
|
9
|
+
* hour was indistinguishable from one being watched now.
|
|
10
|
+
*
|
|
11
|
+
* Two halves, and both are here: the side that builds the map has to say it,
|
|
12
|
+
* and the side that receives it has to act on it without needing the file's
|
|
13
|
+
* length, the torrent's list, or the torrent to exist at all.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import test from "node:test";
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { PriorityOrchestrator } from "../services/priority/PriorityOrchestrator.js";
|
|
19
|
+
import { Viewers } from "../services/viewer/Viewers.js";
|
|
20
|
+
import { viewersOf } from "../services/viewer/Viewer.js";
|
|
21
|
+
import { TorrentPool } from "../services/torrent-pool.js";
|
|
22
|
+
import { demandFor, forgetTorrent } from "../services/download/registry.js";
|
|
23
|
+
import { Urgency } from "../services/demand/index.js";
|
|
24
|
+
|
|
25
|
+
const STALE_AFTER_MS = 60_000;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A session, as much of one as the orchestrator reads.
|
|
29
|
+
*
|
|
30
|
+
* @param {{ id: string, fileIndex?: number }} params
|
|
31
|
+
* @returns {object}
|
|
32
|
+
*/
|
|
33
|
+
function outputOf({ id, fileIndex = 0 }) {
|
|
34
|
+
return {
|
|
35
|
+
id,
|
|
36
|
+
outputKey: `out:${id}`,
|
|
37
|
+
sourceKey: "source-1",
|
|
38
|
+
fileIndex,
|
|
39
|
+
file: { key: `film-${fileIndex}`, durationSeconds: 600 }
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The real orchestrator, with everything it publishes kept.
|
|
45
|
+
*
|
|
46
|
+
* @param {object[]} sessions
|
|
47
|
+
* @returns {{ priority: PriorityOrchestrator, viewers: Viewers, published: object[], publish: (sessions?: object[]) => void }}
|
|
48
|
+
*/
|
|
49
|
+
function over(sessions) {
|
|
50
|
+
const published = [];
|
|
51
|
+
const viewers = new Viewers();
|
|
52
|
+
const priority = new PriorityOrchestrator({
|
|
53
|
+
publish: (one) => published.push(one),
|
|
54
|
+
viewersOf: (session) => viewersOf(session),
|
|
55
|
+
allowanceFor: () => 10
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
priority,
|
|
59
|
+
viewers,
|
|
60
|
+
published,
|
|
61
|
+
publish: (live = sessions) =>
|
|
62
|
+
priority.publishFor({ sessionGroups: [live], staleAfterMs: STALE_AFTER_MS })
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
test("a file whose viewers have all gone is published as wanting nothing", () => {
|
|
67
|
+
const picture = outputOf({ id: "pic" });
|
|
68
|
+
const { viewers, published, publish } = over([picture]);
|
|
69
|
+
const person = viewers.of(picture, "p");
|
|
70
|
+
person.moveTo(300);
|
|
71
|
+
publish();
|
|
72
|
+
assert.ok(published.at(-1).zones.length > 0, "somebody is watching, so something is wanted");
|
|
73
|
+
|
|
74
|
+
// They stop answering. The session is still there — it outlives them by half
|
|
75
|
+
// an hour — so this is the case that used to say nothing at all.
|
|
76
|
+
person.seen(Date.now() - STALE_AFTER_MS * 2);
|
|
77
|
+
publish();
|
|
78
|
+
|
|
79
|
+
const last = published.at(-1);
|
|
80
|
+
assert.deepEqual(last.zones, []);
|
|
81
|
+
assert.equal(last.sourceKey, "source-1");
|
|
82
|
+
assert.equal(last.fileIndex, 0);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("a file whose session has gone is published as wanting nothing, once", () => {
|
|
86
|
+
const picture = outputOf({ id: "pic" });
|
|
87
|
+
const { viewers, published, publish } = over([picture]);
|
|
88
|
+
viewers.of(picture, "p").moveTo(300);
|
|
89
|
+
publish();
|
|
90
|
+
const said = published.length;
|
|
91
|
+
|
|
92
|
+
// The session is disposed: it is not among the live ones any more.
|
|
93
|
+
publish([]);
|
|
94
|
+
assert.deepEqual(published.at(-1).zones, []);
|
|
95
|
+
assert.equal(published.length, said + 1);
|
|
96
|
+
|
|
97
|
+
// And it is not repeated on every pass afterwards — the file is gone from
|
|
98
|
+
// this class's memory, and a departure is said once.
|
|
99
|
+
publish([]);
|
|
100
|
+
publish([]);
|
|
101
|
+
assert.equal(published.length, said + 1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("an unchanged map is still not republished", () => {
|
|
105
|
+
const picture = outputOf({ id: "pic" });
|
|
106
|
+
const { viewers, published, publish } = over([picture]);
|
|
107
|
+
viewers.of(picture, "p").moveTo(300);
|
|
108
|
+
publish();
|
|
109
|
+
const said = published.length;
|
|
110
|
+
publish();
|
|
111
|
+
publish();
|
|
112
|
+
assert.equal(published.length, said, "the downloading rebuilds its requests on every one");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
/** A torrent that is nothing but one file of a known length. */
|
|
116
|
+
function torrentOf({ length = 1_000_000 } = {}) {
|
|
117
|
+
return {
|
|
118
|
+
infoHash: `hash-${Math.random().toString(36).slice(2)}`,
|
|
119
|
+
pieceLength: 1024,
|
|
120
|
+
files: [{ offset: 0, length, name: "film.mkv" }],
|
|
121
|
+
_selections: { _items: [] },
|
|
122
|
+
_select() {},
|
|
123
|
+
_deselect() {},
|
|
124
|
+
critical() {}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const applyPriorityMap = TorrentPool.prototype.applyPriorityMap;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @param {object} torrent
|
|
132
|
+
* @returns {object[]}
|
|
133
|
+
*/
|
|
134
|
+
function stated(torrent) {
|
|
135
|
+
return demandFor(torrent)
|
|
136
|
+
.register.windows()
|
|
137
|
+
.filter((one) => String(one.claimant).startsWith("priority-map:"));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
test("a map with nothing in it withdraws everything that file had stated", () => {
|
|
141
|
+
const torrent = torrentOf();
|
|
142
|
+
try {
|
|
143
|
+
applyPriorityMap.call(null, torrent, 0, [
|
|
144
|
+
{ from: 0, to: 100, priority: 100 },
|
|
145
|
+
{ from: 100, to: 600, priority: 50 }
|
|
146
|
+
], 600);
|
|
147
|
+
assert.equal(stated(torrent).length, 2);
|
|
148
|
+
|
|
149
|
+
applyPriorityMap.call(null, torrent, 0, [], 600);
|
|
150
|
+
assert.equal(stated(torrent).length, 0);
|
|
151
|
+
} finally {
|
|
152
|
+
forgetTorrent(torrent);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("it withdraws that file's bands and nobody else's", () => {
|
|
157
|
+
const torrent = torrentOf();
|
|
158
|
+
try {
|
|
159
|
+
const { register } = demandFor(torrent);
|
|
160
|
+
applyPriorityMap.call(null, torrent, 0, [{ from: 0, to: 600, priority: 100 }], 600);
|
|
161
|
+
// A read stopped on a piece right now, which only a read can say, and the
|
|
162
|
+
// background fill, which the pool states for itself. Neither is the map's
|
|
163
|
+
// to withdraw.
|
|
164
|
+
register.state({
|
|
165
|
+
claimant: "read-7:blocked",
|
|
166
|
+
fileIndex: 0,
|
|
167
|
+
byteStart: 0,
|
|
168
|
+
byteEnd: 999,
|
|
169
|
+
urgency: Urgency.BLOCKED
|
|
170
|
+
});
|
|
171
|
+
register.state({
|
|
172
|
+
claimant: "background-fill:0",
|
|
173
|
+
fileIndex: 0,
|
|
174
|
+
byteStart: 1000,
|
|
175
|
+
byteEnd: 9999,
|
|
176
|
+
urgency: Urgency.TAIL
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
applyPriorityMap.call(null, torrent, 0, [], 600);
|
|
180
|
+
|
|
181
|
+
assert.equal(stated(torrent).length, 0);
|
|
182
|
+
assert.deepEqual(
|
|
183
|
+
register.windows().map((one) => one.claimant).sort(),
|
|
184
|
+
["background-fill:0", "read-7:blocked"]
|
|
185
|
+
);
|
|
186
|
+
} finally {
|
|
187
|
+
forgetTorrent(torrent);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("a departure is answered even when nothing else about the file is known", () => {
|
|
192
|
+
// The length, the duration and the torrent's own list are what the ordinary
|
|
193
|
+
// path needs to turn seconds into bytes. A departure needs none of them, and
|
|
194
|
+
// it arrives exactly when they are going away: the map that says it is
|
|
195
|
+
// published as the last viewer leaves.
|
|
196
|
+
const torrent = torrentOf();
|
|
197
|
+
try {
|
|
198
|
+
applyPriorityMap.call(null, torrent, 0, [{ from: 0, to: 600, priority: 100 }], 600);
|
|
199
|
+
assert.equal(stated(torrent).length, 1);
|
|
200
|
+
|
|
201
|
+
torrent.files = [];
|
|
202
|
+
applyPriorityMap.call(null, torrent, 0, [], 0);
|
|
203
|
+
assert.equal(stated(torrent).length, 0);
|
|
204
|
+
} finally {
|
|
205
|
+
forgetTorrent(torrent);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file WHAT THE BROWSER IS TOLD IS IN A TORRENT.
|
|
3
|
+
*
|
|
4
|
+
* This is the one place the product answers it. The browser used to answer it
|
|
5
|
+
* as well — a list of video extensions in its parser and a second, shorter pair
|
|
6
|
+
* inside its picker — and the three had already diverged: measured 2026-09-12,
|
|
7
|
+
* `.dat` was offered there as video and not counted here, which also decides
|
|
8
|
+
* whether a sidecar whose name matches nothing can belong to the only video
|
|
9
|
+
* present.
|
|
10
|
+
*
|
|
11
|
+
* So the shape of this answer is a contract with the page, and the page shows
|
|
12
|
+
* what it is given without looking at a name.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import test from "node:test";
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { handleApiSourceFilesGet } from "../routes/api/sources/files/get.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A request, a reply and the torrent behind them.
|
|
21
|
+
*
|
|
22
|
+
* @param {Array<{ path: string, length: number }>} files
|
|
23
|
+
* @returns {{ req: object, reply: object, sent: { body: object | null, code: number }, deps: object }}
|
|
24
|
+
*/
|
|
25
|
+
function harness(files) {
|
|
26
|
+
const sent = { body: null, code: 200 };
|
|
27
|
+
const reply = {
|
|
28
|
+
code(value) {
|
|
29
|
+
sent.code = value;
|
|
30
|
+
return reply;
|
|
31
|
+
},
|
|
32
|
+
send(body) {
|
|
33
|
+
sent.body = body;
|
|
34
|
+
return reply;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const deps = {
|
|
38
|
+
sourceRegistry: { get: () => ({ sourceType: "magnet", source: "magnet:?xt=urn:btih:abc" }) },
|
|
39
|
+
torrentPool: {
|
|
40
|
+
async getTorrent() {
|
|
41
|
+
return { name: "Drifters", infoHash: "abc", files };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
return { req: { params: { sourceKey: "key" }, query: {} }, reply, sent, deps };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test("the files come back in reading order, each saying what it is", () => {
|
|
49
|
+
const { req, reply, sent, deps } = harness([
|
|
50
|
+
{ path: "Drifters/ep 10.mkv", length: 9 },
|
|
51
|
+
{ path: "Drifters/ep 2.mkv", length: 9 },
|
|
52
|
+
{ path: "Drifters/Rus Sound/ep 2.mka", length: 2 },
|
|
53
|
+
{ path: "Drifters/notes.nfo", length: 1 }
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
return handleApiSourceFilesGet(req, reply, deps).then(() => {
|
|
57
|
+
assert.equal(sent.code, 200);
|
|
58
|
+
assert.deepEqual(
|
|
59
|
+
sent.body.files.map((file) => [file.relativePath, file.kind]),
|
|
60
|
+
[
|
|
61
|
+
// Runs of digits compare as numbers, so 2 comes before 10; the torrent's
|
|
62
|
+
// own order is whatever the tool that made it chose.
|
|
63
|
+
["ep 2.mkv", "video"],
|
|
64
|
+
["ep 10.mkv", "video"],
|
|
65
|
+
["notes.nfo", "other"],
|
|
66
|
+
["Rus Sound/ep 2.mka", "audio"]
|
|
67
|
+
]
|
|
68
|
+
);
|
|
69
|
+
// Relative to the torrent root: WebTorrent prefixes its own name to every
|
|
70
|
+
// path, and stripping it here means one rule in the product rather than two
|
|
71
|
+
// that can disagree.
|
|
72
|
+
assert.ok(sent.body.files.every((file) => !file.relativePath.startsWith("Drifters/")));
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("each picture is named with what belongs to it, by index", () => {
|
|
77
|
+
const { req, reply, sent, deps } = harness([
|
|
78
|
+
{ path: "Drifters/ep 1.mkv", length: 9 },
|
|
79
|
+
{ path: "Drifters/Rus Sound/ep 1.mka", length: 2 },
|
|
80
|
+
{ path: "Drifters/Sub/ep 1.ass", length: 1 },
|
|
81
|
+
{ path: "Drifters/ep 2.mkv", length: 9 }
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
return handleApiSourceFilesGet(req, reply, deps).then(() => {
|
|
85
|
+
assert.deepEqual(sent.body.items, [
|
|
86
|
+
{ fileIndex: 0, audio: [1], subtitles: [2], images: [] },
|
|
87
|
+
{ fileIndex: 3, audio: [], subtitles: [], images: [] }
|
|
88
|
+
]);
|
|
89
|
+
// The files themselves are in the list above; saying them twice is how two
|
|
90
|
+
// copies of one fact start.
|
|
91
|
+
assert.ok(sent.body.items.every((item) => item.audio.every((one) => Number.isInteger(one))));
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a torrent whose metadata has not arrived says so and lists nothing", () => {
|
|
96
|
+
const { req, reply, sent, deps } = harness([]);
|
|
97
|
+
|
|
98
|
+
return handleApiSourceFilesGet(req, reply, deps).then(() => {
|
|
99
|
+
assert.deepEqual(sent.body.files, []);
|
|
100
|
+
assert.deepEqual(sent.body.items, []);
|
|
101
|
+
assert.equal(sent.body.name, "Drifters");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -19,11 +19,11 @@ import { handleStreamGet } from "../routes/stream/get.js";
|
|
|
19
19
|
* Minimal stand-ins for the parts of Fastify and the pool this route touches.
|
|
20
20
|
*
|
|
21
21
|
* @param {{ method: string, range?: string }} request
|
|
22
|
-
* @returns {{ req: object, reply: object, sent: object, opened: string[],
|
|
22
|
+
* @returns {{ req: object, reply: object, sent: object, opened: string[], state: object }}
|
|
23
23
|
*/
|
|
24
24
|
function harness({ method, range }) {
|
|
25
25
|
const opened = [];
|
|
26
|
-
const state = {
|
|
26
|
+
const state = { prioritized: [] };
|
|
27
27
|
|
|
28
28
|
const sent = { code: 200, headers: {}, body: undefined, called: false };
|
|
29
29
|
const reply = {
|
|
@@ -71,10 +71,6 @@ function harness({ method, range }) {
|
|
|
71
71
|
async getTorrent() {
|
|
72
72
|
return { files: [file], sourceKey: "key" };
|
|
73
73
|
},
|
|
74
|
-
acquireFile() {
|
|
75
|
-
state.claims += 1;
|
|
76
|
-
return () => undefined;
|
|
77
|
-
},
|
|
78
74
|
prioritizeByteRange(_torrent, fileIndex, byteStart, _windowBytes, options) {
|
|
79
75
|
state.prioritized.push({ byteStart, wholeFileRead: options?.wholeFileRead === true });
|
|
80
76
|
}
|
|
@@ -96,7 +92,7 @@ test("HEAD reports the size without opening a read", async () => {
|
|
|
96
92
|
await handleStreamGet(req, reply, deps);
|
|
97
93
|
|
|
98
94
|
assert.deepEqual(opened, [], "HEAD started a read of the file");
|
|
99
|
-
assert.
|
|
95
|
+
assert.deepEqual(state.prioritized, [], "HEAD asked the swarm for a read it never made");
|
|
100
96
|
// The real size, not the zero Fastify substitutes for an empty payload — the
|
|
101
97
|
// keyframe index reads this header and treats 0 as "no index".
|
|
102
98
|
assert.equal(sent.headers["content-length"], "5869669065");
|
|
@@ -182,5 +178,5 @@ test("a file this proxy does not have whole still goes to the torrent", async ()
|
|
|
182
178
|
const { req, reply, state, deps } = harness({ method: "GET", range: "bytes=0-99" });
|
|
183
179
|
deps.torrentPool.wholeFiles = new Map([["other/7", { path: "/nowhere", length: 1, name: "x" }]]);
|
|
184
180
|
await handleStreamGet(req, reply, deps);
|
|
185
|
-
assert.equal(state.
|
|
181
|
+
assert.equal(state.prioritized.length, 1, "the ordinary path was not taken");
|
|
186
182
|
});
|
|
@@ -3,21 +3,38 @@
|
|
|
3
3
|
*
|
|
4
4
|
* WebTorrent enforces `maxConns` only on peers it dials (`_drain`), while
|
|
5
5
|
* `_addIncomingPeer` checks that the torrent is neither destroyed nor paused
|
|
6
|
-
* and registers the peer. A proxy with its port mapped is reachable, so
|
|
7
|
-
* connections arrive and are never turned away — field
|
|
8
|
-
* connected at the start of one viewing, 596 at the end, 15 862
|
|
9
|
-
*
|
|
10
|
-
* served by reading a 4 MB piece off the disk for every 16 KB sent.
|
|
6
|
+
* and registers the peer. A proxy with its port mapped is reachable, so on a
|
|
7
|
+
* popular torrent connections arrive and are never turned away — field
|
|
8
|
+
* 2026-09-11: 249 connected at the start of one viewing, 596 at the end, 15 862
|
|
9
|
+
* more queued, on a file complete for three quarters of an hour.
|
|
11
10
|
*
|
|
12
|
-
* The rule is not a limit on connections. While anybody is reading, every
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* The rule is not a limit on connections. While anybody is reading, every one is
|
|
12
|
+
* worth keeping: the one that has delivered nothing yet may deliver next.
|
|
13
|
+
*
|
|
14
|
+
* AND IT IS ASKED OF WHAT HAS BEEN STATED, never of a count of readers. The
|
|
15
|
+
* first version asked every five seconds whether anybody was READING, and a
|
|
16
|
+
* torrent added three seconds earlier answered no — its edges still being read,
|
|
17
|
+
* its plan still being built. It left the swarm with 741 connections let go,
|
|
18
|
+
* and nothing rejoined it: rejoining waited for a reader, and the reader was
|
|
19
|
+
* waiting for the header the swarm had been fetching. Playback did not start at
|
|
20
|
+
* all.
|
|
21
|
+
*
|
|
22
|
+
* The question now is whether anything is WANTED of it, which the priority map,
|
|
23
|
+
* the ends of an open file and a stopped read all answer — and which is true
|
|
24
|
+
* from the moment a torrent is opened.
|
|
15
25
|
*/
|
|
16
26
|
|
|
17
27
|
import test from "node:test";
|
|
18
28
|
import assert from "node:assert/strict";
|
|
19
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
isWanted,
|
|
31
|
+
leaveSwarm,
|
|
32
|
+
rejoinSwarm,
|
|
33
|
+
stateFileEdges,
|
|
34
|
+
swarmDecisionFor
|
|
35
|
+
} from "../services/torrent-pool.js";
|
|
20
36
|
import { demandFor, forgetTorrent } from "../services/download/registry.js";
|
|
37
|
+
import { Urgency } from "../services/demand/index.js";
|
|
21
38
|
|
|
22
39
|
/**
|
|
23
40
|
* A torrent that records what was done to it. Only the surface the rule
|
|
@@ -39,7 +56,7 @@ function torrentWith(peerCount) {
|
|
|
39
56
|
}
|
|
40
57
|
return {
|
|
41
58
|
infoHash: "f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0",
|
|
42
|
-
files: [{ length: 1024 }],
|
|
59
|
+
files: [{ length: 1024 }, { length: 2048 }],
|
|
43
60
|
paused: false,
|
|
44
61
|
wires: [...peers.values()].map((peer) => peer.wire),
|
|
45
62
|
_peers: peers,
|
|
@@ -52,117 +69,106 @@ function torrentWith(peerCount) {
|
|
|
52
69
|
};
|
|
53
70
|
}
|
|
54
71
|
|
|
55
|
-
|
|
56
|
-
* @param {object} torrent
|
|
57
|
-
* @returns {TorrentPool}
|
|
58
|
-
*/
|
|
59
|
-
function poolWith(torrent) {
|
|
60
|
-
const pool = Object.create(TorrentPool.prototype);
|
|
61
|
-
pool.torrents = new Map([["source", torrent]]);
|
|
62
|
-
pool.fileUsageByTorrent = new WeakMap();
|
|
63
|
-
return pool;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
test("a torrent nobody is reading is let go of, and its data is not", () => {
|
|
72
|
+
test("letting a swarm go pauses the torrent and closes what the pause does not", () => {
|
|
67
73
|
const torrent = torrentWith(6);
|
|
68
|
-
const pool = poolWith(torrent);
|
|
69
|
-
try {
|
|
70
|
-
// Read once and left, which is the state this is about: a film nobody has
|
|
71
|
-
// opened yet is being set up, and the test below covers that.
|
|
72
|
-
pool.fileUsageByTorrent.set(torrent, new Map([[0, 1]]));
|
|
73
|
-
pool.followTheReaders(torrent);
|
|
74
|
-
pool.fileUsageByTorrent.set(torrent, new Map());
|
|
75
|
-
pool.followTheReaders(torrent);
|
|
76
|
-
|
|
77
|
-
assert.equal(torrent.paused, true, "the swarm was not left");
|
|
78
|
-
assert.equal(torrent._peers.size, 0, "the connections the pause does not close were not let go");
|
|
79
|
-
} finally {
|
|
80
|
-
forgetTorrent(torrent);
|
|
81
|
-
}
|
|
82
|
-
});
|
|
83
74
|
|
|
84
|
-
|
|
85
|
-
const torrent = torrentWith(6);
|
|
86
|
-
const pool = poolWith(torrent);
|
|
87
|
-
pool.fileUsageByTorrent.set(torrent, new Map([[0, 1]]));
|
|
88
|
-
try {
|
|
89
|
-
pool.followTheReaders(torrent);
|
|
75
|
+
const closed = leaveSwarm(torrent);
|
|
90
76
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
forgetTorrent(torrent);
|
|
95
|
-
}
|
|
77
|
+
assert.equal(torrent.paused, true, "the library's own word for this was not used");
|
|
78
|
+
assert.equal(closed, 6);
|
|
79
|
+
assert.equal(torrent._peers.size, 0, "the connections the pause does not close were not let go");
|
|
96
80
|
});
|
|
97
81
|
|
|
98
|
-
test("a
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const pool = poolWith(torrent);
|
|
103
|
-
demandFor(torrent).register.state({
|
|
104
|
-
claimant: "read-1",
|
|
105
|
-
fileIndex: 0,
|
|
106
|
-
byteStart: 0,
|
|
107
|
-
byteEnd: 1023,
|
|
108
|
-
urgency: 0
|
|
109
|
-
});
|
|
110
|
-
try {
|
|
111
|
-
pool.followTheReaders(torrent);
|
|
112
|
-
assert.equal(torrent.paused, false, "a declared window did not count as somebody reading");
|
|
113
|
-
} finally {
|
|
114
|
-
forgetTorrent(torrent);
|
|
115
|
-
}
|
|
82
|
+
test("a swarm already let go is not let go twice", () => {
|
|
83
|
+
const torrent = torrentWith(4);
|
|
84
|
+
leaveSwarm(torrent);
|
|
85
|
+
assert.equal(leaveSwarm(torrent), 0);
|
|
116
86
|
});
|
|
117
87
|
|
|
118
|
-
test("the
|
|
88
|
+
test("the reader that comes back takes the swarm with it", () => {
|
|
119
89
|
const torrent = torrentWith(3);
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
pool.followTheReaders(torrent);
|
|
126
|
-
assert.equal(torrent.paused, true);
|
|
127
|
-
|
|
128
|
-
pool.fileUsageByTorrent.set(torrent, new Map([[0, 1]]));
|
|
129
|
-
pool.followTheReaders(torrent);
|
|
130
|
-
assert.equal(torrent.paused, false, "the next reader was left with a torrent that fetches nothing");
|
|
131
|
-
} finally {
|
|
132
|
-
forgetTorrent(torrent);
|
|
133
|
-
}
|
|
90
|
+
leaveSwarm(torrent);
|
|
91
|
+
|
|
92
|
+
assert.equal(rejoinSwarm(torrent), true);
|
|
93
|
+
assert.equal(torrent.paused, false, "the next reader was left with a torrent that fetches nothing");
|
|
94
|
+
assert.equal(rejoinSwarm(torrent), false, "a torrent already in its swarm was resumed again");
|
|
134
95
|
});
|
|
135
96
|
|
|
136
|
-
test("a torrent
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
// rejoined it, because rejoining waits for a reader and the reader was
|
|
141
|
-
// waiting for the header the swarm was fetching.
|
|
97
|
+
test("a torrent whose metadata has not arrived is wanted, whatever is stated", () => {
|
|
98
|
+
// THE FIELD FAILURE OF 2026-09-11, as a question rather than as a mechanism.
|
|
99
|
+
// A torrent being added has no list of files, so nothing can name a byte of
|
|
100
|
+
// it — and it is being fetched precisely because somebody asked for it.
|
|
142
101
|
const torrent = torrentWith(103);
|
|
143
|
-
|
|
102
|
+
torrent.files = [];
|
|
103
|
+
assert.equal(isWanted(torrent), true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("a torrent with files and nothing stated for them is wanted by nobody", () => {
|
|
107
|
+
const torrent = torrentWith(4);
|
|
144
108
|
try {
|
|
145
|
-
|
|
146
|
-
assert.equal(torrent.paused, false, "a torrent being opened was taken out of its swarm");
|
|
147
|
-
assert.equal(torrent._peers.size, 103, "and its connections were let go");
|
|
109
|
+
assert.equal(isWanted(torrent), false);
|
|
148
110
|
} finally {
|
|
149
111
|
forgetTorrent(torrent);
|
|
150
112
|
}
|
|
151
113
|
});
|
|
152
114
|
|
|
153
|
-
test("
|
|
115
|
+
test("anything stated makes it wanted, and the last withdrawal ends that", () => {
|
|
154
116
|
const torrent = torrentWith(4);
|
|
155
|
-
const pool = poolWith(torrent);
|
|
156
117
|
try {
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
118
|
+
// The ends of a file being opened, which is the first thing said about a
|
|
119
|
+
// torrent anybody has picked and the reason it stays in its swarm through
|
|
120
|
+
// the seconds when nothing else can say anything about it.
|
|
121
|
+
stateFileEdges(torrent, 0, Urgency.TAIL);
|
|
122
|
+
assert.equal(isWanted(torrent), true);
|
|
123
|
+
|
|
124
|
+
const { register } = demandFor(torrent);
|
|
125
|
+
register.withdraw("file-edges:0:head");
|
|
126
|
+
assert.equal(isWanted(torrent), true, "one end of it is still wanted");
|
|
127
|
+
register.withdraw("file-edges:0:tail");
|
|
128
|
+
assert.equal(isWanted(torrent), false);
|
|
165
129
|
} finally {
|
|
166
130
|
forgetTorrent(torrent);
|
|
167
131
|
}
|
|
168
132
|
});
|
|
133
|
+
|
|
134
|
+
test("a torrent that has been destroyed is wanted by nobody", () => {
|
|
135
|
+
const torrent = torrentWith(1);
|
|
136
|
+
torrent.destroyed = true;
|
|
137
|
+
assert.equal(isWanted(torrent), false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("a torrent something is wanted of keeps its swarm and is off the clock", () => {
|
|
141
|
+
assert.deepEqual(
|
|
142
|
+
swarmDecisionFor({ wanted: true, everWanted: true }),
|
|
143
|
+
{ swarm: "take", onTheClock: false }
|
|
144
|
+
);
|
|
145
|
+
assert.deepEqual(
|
|
146
|
+
swarmDecisionFor({ wanted: true, everWanted: false }),
|
|
147
|
+
{ swarm: "take", onTheClock: false }
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("a swarm is let go on a DEPARTURE, never on a beginning", () => {
|
|
152
|
+
// The whole of the 2.83.1 failure in one line: a torrent that has never been
|
|
153
|
+
// wanted is one being opened — its metadata has just landed, the file list is
|
|
154
|
+
// on its way to the person choosing, and nothing has had a chance to state
|
|
155
|
+
// anything about it yet. Taking its swarm away there destroyed 741
|
|
156
|
+
// connections it needed a minute later.
|
|
157
|
+
assert.deepEqual(
|
|
158
|
+
swarmDecisionFor({ wanted: false, everWanted: false }),
|
|
159
|
+
{ swarm: "leave alone", onTheClock: true }
|
|
160
|
+
);
|
|
161
|
+
assert.deepEqual(
|
|
162
|
+
swarmDecisionFor({ wanted: false, everWanted: true }),
|
|
163
|
+
{ swarm: "let go", onTheClock: true }
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("whatever happens to the swarm, an unwanted torrent is on the idle clock", () => {
|
|
168
|
+
// Including the one nobody has ever wanted: a file list fetched and never
|
|
169
|
+
// played used to be held for the life of the process, because the only thing
|
|
170
|
+
// that started the clock was a reader letting go.
|
|
171
|
+
for (const everWanted of [true, false]) {
|
|
172
|
+
assert.equal(swarmDecisionFor({ wanted: false, everWanted }).onTheClock, true);
|
|
173
|
+
}
|
|
174
|
+
});
|