@torrent-tv/proxy 2.9.75 → 2.9.77
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 +12 -0
- package/package.json +1 -1
- package/routes/stream/get.js +141 -116
- package/services/piece-store/shared-piece-store.js +416 -416
- package/services/torrent-worker/channel.js +12 -0
- package/services/torrent-worker/client.js +271 -264
- package/services/torrent-worker/file-claims.js +91 -0
- package/services/torrent-worker/pool-adapter.js +188 -179
- package/services/torrent-worker/worker.js +72 -35
- package/test/file-claims.test.js +64 -0
- package/test/stream-route.test.js +121 -0
- package/test/worker-channel.test.js +56 -1
- package/test/worker-source-race.test.js +76 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What `/stream` does with a HEAD request.
|
|
3
|
+
*
|
|
4
|
+
* Fastify answers HEAD from the GET handler, so without an explicit branch a
|
|
5
|
+
* HEAD started a read of the entire file. Node discards the body, but the read
|
|
6
|
+
* itself runs on and the response never completes, which blocks the next
|
|
7
|
+
* request on that keep-alive connection. The keyframe index asks for the file
|
|
8
|
+
* size with exactly such a HEAD before every transcode session.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import test from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { handleStreamGet } from "../routes/stream/get.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Minimal stand-ins for the parts of Fastify and the pool this route touches.
|
|
17
|
+
*
|
|
18
|
+
* @param {{ method: string, range?: string }} request
|
|
19
|
+
* @returns {{ req: object, reply: object, sent: object, opened: string[], claims: number }}
|
|
20
|
+
*/
|
|
21
|
+
function harness({ method, range }) {
|
|
22
|
+
const opened = [];
|
|
23
|
+
const state = { claims: 0 };
|
|
24
|
+
|
|
25
|
+
const sent = { code: 200, headers: {}, body: undefined, called: false };
|
|
26
|
+
const reply = {
|
|
27
|
+
code(value) {
|
|
28
|
+
sent.code = value;
|
|
29
|
+
return reply;
|
|
30
|
+
},
|
|
31
|
+
header(name, value) {
|
|
32
|
+
sent.headers[name.toLowerCase()] = value;
|
|
33
|
+
return reply;
|
|
34
|
+
},
|
|
35
|
+
send(body) {
|
|
36
|
+
sent.called = true;
|
|
37
|
+
sent.body = body;
|
|
38
|
+
return reply;
|
|
39
|
+
},
|
|
40
|
+
hijack() {
|
|
41
|
+
sent.hijacked = true;
|
|
42
|
+
},
|
|
43
|
+
raw: {
|
|
44
|
+
// `bindRelease` listens here; HEAD writes its response here.
|
|
45
|
+
once() {},
|
|
46
|
+
writeHead(code, headers) {
|
|
47
|
+
sent.code = code;
|
|
48
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
49
|
+
sent.headers[name.toLowerCase()] = value;
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
end() {
|
|
53
|
+
sent.called = true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const file = {
|
|
59
|
+
name: "movie.mkv",
|
|
60
|
+
length: 5_869_669_065,
|
|
61
|
+
createReadStream(options = {}) {
|
|
62
|
+
opened.push(`${options.start ?? 0}-${options.end ?? "end"}`);
|
|
63
|
+
return { on() {} };
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const torrentPool = {
|
|
68
|
+
async getTorrent() {
|
|
69
|
+
return { files: [file], sourceKey: "key" };
|
|
70
|
+
},
|
|
71
|
+
acquireFile() {
|
|
72
|
+
state.claims += 1;
|
|
73
|
+
return () => undefined;
|
|
74
|
+
},
|
|
75
|
+
prioritizeByteRange() {}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const req = {
|
|
79
|
+
method,
|
|
80
|
+
query: { sourceType: "magnet", source: "magnet:?xt=urn:btih:abc", fileIndex: "0" },
|
|
81
|
+
headers: range ? { range } : {},
|
|
82
|
+
raw: { once() {} }
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return { req, reply, sent, opened, state, deps: { sourceRegistry: { get: () => null }, torrentPool } };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
test("HEAD reports the size without opening a read", async () => {
|
|
89
|
+
const { req, reply, sent, opened, state, deps } = harness({ method: "HEAD" });
|
|
90
|
+
|
|
91
|
+
await handleStreamGet(req, reply, deps);
|
|
92
|
+
|
|
93
|
+
assert.deepEqual(opened, [], "HEAD started a read of the file");
|
|
94
|
+
assert.equal(state.claims, 0, "HEAD claimed the file it never read");
|
|
95
|
+
// The real size, not the zero Fastify substitutes for an empty payload — the
|
|
96
|
+
// keyframe index reads this header and treats 0 as "no index".
|
|
97
|
+
assert.equal(sent.headers["content-length"], "5869669065");
|
|
98
|
+
assert.equal(sent.headers["accept-ranges"], "bytes");
|
|
99
|
+
assert.equal(sent.called, true, "HEAD never completed its response");
|
|
100
|
+
assert.equal(sent.body, undefined, "HEAD answered with a body");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("GET still streams the bytes", async () => {
|
|
104
|
+
const { req, reply, sent, opened, deps } = harness({ method: "GET" });
|
|
105
|
+
|
|
106
|
+
await handleStreamGet(req, reply, deps);
|
|
107
|
+
|
|
108
|
+
assert.equal(opened.length, 1, "GET did not open a read");
|
|
109
|
+
assert.equal(sent.headers["content-length"], "5869669065");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("GET with a range streams only that range", async () => {
|
|
113
|
+
const { req, reply, sent, opened, deps } = harness({ method: "GET", range: "bytes=100-199" });
|
|
114
|
+
|
|
115
|
+
await handleStreamGet(req, reply, deps);
|
|
116
|
+
|
|
117
|
+
assert.deepEqual(opened, ["100-199"]);
|
|
118
|
+
assert.equal(sent.code, 206);
|
|
119
|
+
assert.equal(sent.headers["content-range"], "bytes 100-199/5869669065");
|
|
120
|
+
assert.equal(sent.headers["content-length"], "100");
|
|
121
|
+
});
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
import test from "node:test";
|
|
17
17
|
import assert from "node:assert/strict";
|
|
18
18
|
import { MessageChannel } from "node:worker_threads";
|
|
19
|
-
import { createSendStream, createReceiveStream } from "../services/torrent-worker/channel.js";
|
|
19
|
+
import { createSendStream, createReceiveStream, createCaller } from "../services/torrent-worker/channel.js";
|
|
20
|
+
import { TorrentWorkerClient } from "../services/torrent-worker/client.js";
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* A buffer standing in for one owned by WebTorrent's piece cache: allocated
|
|
@@ -96,6 +97,36 @@ test("chunks arrive with their contents intact", async () => {
|
|
|
96
97
|
}
|
|
97
98
|
});
|
|
98
99
|
|
|
100
|
+
test("reads and commands never share a request id", () => {
|
|
101
|
+
const { port1, port2 } = new MessageChannel();
|
|
102
|
+
try {
|
|
103
|
+
const commandIds = [];
|
|
104
|
+
port2.on("message", (message) => commandIds.push(message.id));
|
|
105
|
+
|
|
106
|
+
const caller = createCaller(port1);
|
|
107
|
+
const readIds = [];
|
|
108
|
+
|
|
109
|
+
// Interleaved exactly as the real client does it: commands through `call`,
|
|
110
|
+
// reads taking an id directly, both over the same channel.
|
|
111
|
+
for (let round = 0; round < 50; round += 1) {
|
|
112
|
+
void caller.call("noop", {});
|
|
113
|
+
readIds.push(caller.nextId());
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// A repeat between the two sequences is the collision that let a read's
|
|
117
|
+
// reply resolve a command — with its result, silently.
|
|
118
|
+
const everyId = [...commandIds, ...readIds];
|
|
119
|
+
assert.equal(
|
|
120
|
+
new Set(everyId).size,
|
|
121
|
+
everyId.length,
|
|
122
|
+
"an id was handed out to both a command and a read"
|
|
123
|
+
);
|
|
124
|
+
} finally {
|
|
125
|
+
port1.close();
|
|
126
|
+
port2.close();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
99
130
|
test("a failed read surfaces on the reader instead of ending quietly", async () => {
|
|
100
131
|
const { port1, port2 } = new MessageChannel();
|
|
101
132
|
try {
|
|
@@ -118,3 +149,27 @@ test("a failed read surfaces on the reader instead of ending quietly", async ()
|
|
|
118
149
|
port2.close();
|
|
119
150
|
}
|
|
120
151
|
});
|
|
152
|
+
|
|
153
|
+
test("a read of an unknown source fails the stream rather than hanging", async () => {
|
|
154
|
+
// End to end through a real worker, because this is where the defect lived:
|
|
155
|
+
// the worker reported the failure, nothing on the main thread listened, and
|
|
156
|
+
// the reader waited forever. A unit test on either half alone passes happily.
|
|
157
|
+
const client = new TorrentWorkerClient({ memoryBytes: 8 * 1024 * 1024 });
|
|
158
|
+
try {
|
|
159
|
+
const stream = client.createReadStream({ sourceKey: "no-such-source", fileIndex: 0 });
|
|
160
|
+
const reader = stream.getReader();
|
|
161
|
+
|
|
162
|
+
const outcome = await Promise.race([
|
|
163
|
+
reader.read().then(() => "resolved", (error) => `rejected: ${error?.message}`),
|
|
164
|
+
new Promise((resolve) => setTimeout(() => resolve("hung"), 10_000))
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
assert.match(
|
|
168
|
+
outcome,
|
|
169
|
+
/^rejected: .*no-such-source/,
|
|
170
|
+
`expected the read to fail, got "${outcome}"`
|
|
171
|
+
);
|
|
172
|
+
} finally {
|
|
173
|
+
await client.destroyAll();
|
|
174
|
+
}
|
|
175
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Naming a source that is still being added.
|
|
3
|
+
*
|
|
4
|
+
* Adding a magnet takes as long as its metadata does — seconds to tens of
|
|
5
|
+
* seconds — while the browser is already polling stats and the planner is
|
|
6
|
+
* already asking for a plan. Until 2.9.77 the worker registered the torrent
|
|
7
|
+
* only once the add had finished, so everything arriving in that window was
|
|
8
|
+
* told `Unknown source`, which is false: the source exists, it is not ready.
|
|
9
|
+
* Reproduced with a magnet nobody seeds — stats, the file listing and a read
|
|
10
|
+
* all failed instantly while the add was in flight.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import test from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import crypto from "node:crypto";
|
|
16
|
+
import { TorrentWorkerClient } from "../services/torrent-worker/client.js";
|
|
17
|
+
|
|
18
|
+
/** A well-formed infohash nobody is seeding, so the add stays in flight. */
|
|
19
|
+
function pendingMagnet() {
|
|
20
|
+
return `magnet:?xt=urn:btih:${crypto.randomBytes(20).toString("hex")}&dn=nobody-has-this`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What a promise did within `ms` — settled how, or still waiting.
|
|
25
|
+
*
|
|
26
|
+
* @param {Promise<unknown>} promise
|
|
27
|
+
* @param {number} ms
|
|
28
|
+
* @returns {Promise<string>}
|
|
29
|
+
*/
|
|
30
|
+
function outcomeWithin(promise, ms) {
|
|
31
|
+
return Promise.race([
|
|
32
|
+
promise.then(() => "resolved", (error) => `rejected: ${error?.message}`),
|
|
33
|
+
new Promise((resolve) => setTimeout(() => resolve("waiting"), ms))
|
|
34
|
+
]);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test("commands wait for a source that is still being added", async () => {
|
|
38
|
+
const client = new TorrentWorkerClient({ memoryBytes: 16 * 1024 * 1024 });
|
|
39
|
+
try {
|
|
40
|
+
// Without this the worker's own startup would keep the commands waiting and
|
|
41
|
+
// the test would pass for the wrong reason.
|
|
42
|
+
await client.listFiles("warm-up").catch(() => undefined);
|
|
43
|
+
|
|
44
|
+
const sourceKey = "pending-source";
|
|
45
|
+
// Deliberately not awaited: this is the window under test.
|
|
46
|
+
const adding = client.addSource({ sourceKey, sourceType: "magnet", source: pendingMagnet() });
|
|
47
|
+
adding.catch(() => undefined);
|
|
48
|
+
|
|
49
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
50
|
+
|
|
51
|
+
const stats = await outcomeWithin(client.getFileStats({ sourceKey, fileIndex: 0 }), 3000);
|
|
52
|
+
const files = await outcomeWithin(client.listFiles(sourceKey), 3000);
|
|
53
|
+
|
|
54
|
+
assert.equal(stats, "waiting", `stats did not wait for the add: ${stats}`);
|
|
55
|
+
assert.equal(files, "waiting", `the file listing did not wait for the add: ${files}`);
|
|
56
|
+
} finally {
|
|
57
|
+
await client.destroyAll();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("a source that was never added is still reported as unknown", async () => {
|
|
62
|
+
const client = new TorrentWorkerClient({ memoryBytes: 16 * 1024 * 1024 });
|
|
63
|
+
try {
|
|
64
|
+
// Starting the worker takes several seconds — it builds a torrent client,
|
|
65
|
+
// a DHT and the rest — so the first command measures startup, not the
|
|
66
|
+
// behaviour under test. Wait for one to come back before timing anything.
|
|
67
|
+
await client.listFiles("warm-up").catch(() => undefined);
|
|
68
|
+
|
|
69
|
+
// Waiting forever for something nobody ever asked for would be worse than
|
|
70
|
+
// an error — this case must stay an error.
|
|
71
|
+
const outcome = await outcomeWithin(client.listFiles("never-added"), 5000);
|
|
72
|
+
assert.match(outcome, /^rejected: .*never-added/, `expected an error, got "${outcome}"`);
|
|
73
|
+
} finally {
|
|
74
|
+
await client.destroyAll();
|
|
75
|
+
}
|
|
76
|
+
});
|