@torrent-tv/proxy 2.9.74 → 2.9.76
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 +379 -369
- package/bin/cli.js +8 -0
- package/package.json +1 -1
- package/server.js +229 -228
- package/services/piece-store/shared-piece-store.js +107 -6
- package/services/torrent-worker/channel.js +12 -0
- package/services/torrent-worker/client.js +275 -264
- package/services/torrent-worker/pool-adapter.js +179 -179
- package/services/torrent-worker/worker.js +49 -2
- package/test/shared-piece-store.test.js +76 -0
- package/test/worker-channel.test.js +56 -1
|
@@ -1,179 +1,179 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file `TorrentPool`'s interface, served from the worker thread.
|
|
3
|
-
*
|
|
4
|
-
* The routes, the planner, the health report and the session manager all reach
|
|
5
|
-
* for a torrent pool and use it the same handful of ways. Rather than rewrite
|
|
6
|
-
* every one of them to thread a `sourceKey` through and await what used to be
|
|
7
|
-
* immediate, this presents the shape they already expect and does the thread
|
|
8
|
-
* hop behind it. Swapping the implementation is then a one-line change at
|
|
9
|
-
* construction, and the call sites are untouched — which is what keeps a change
|
|
10
|
-
* of this size reviewable.
|
|
11
|
-
*
|
|
12
|
-
* Two accommodations are needed, and both are deliberate:
|
|
13
|
-
*
|
|
14
|
-
* - **`acquireFile` and `prioritizeByteRange` stay synchronous.** They return
|
|
15
|
-
* nothing the caller inspects, so the command is dispatched and not awaited.
|
|
16
|
-
* `acquireFile` hands back a release function exactly as before, which sends
|
|
17
|
-
* its own command when called. Awaiting them would mean touching every call
|
|
18
|
-
* site for no observable gain.
|
|
19
|
-
* - **`getTorrent` needs a `sourceKey`.** Torrent objects cannot cross a
|
|
20
|
-
* thread, so the worker keys them. Callers that have one pass it; the rest
|
|
21
|
-
* get one derived from the source itself, so the identity stays stable
|
|
22
|
-
* across calls for the same torrent.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import crypto from "node:crypto";
|
|
26
|
-
import { TorrentWorkerClient } from "./client.js";
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Stable key for a source, matching how the worker keys its torrents.
|
|
30
|
-
*
|
|
31
|
-
* Derived from the source itself rather than handed out per request, so two
|
|
32
|
-
* routes asking for the same torrent name the same thing on the worker side.
|
|
33
|
-
*
|
|
34
|
-
* @param {"magnet" | "torrent"} sourceType
|
|
35
|
-
* @param {string} source
|
|
36
|
-
* @returns {string}
|
|
37
|
-
*/
|
|
38
|
-
function deriveSourceKey(sourceType, source) {
|
|
39
|
-
return `${sourceType}:${crypto.createHash("sha1").update(source).digest("hex")}`;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* A torrent pool whose work happens on another thread.
|
|
44
|
-
*
|
|
45
|
-
* See `protocol.js` for why: the torrent was taking ~85% of the main thread and
|
|
46
|
-
* everything owed to a viewer queued behind it.
|
|
47
|
-
*/
|
|
48
|
-
export class WorkerTorrentPool {
|
|
49
|
-
#client;
|
|
50
|
-
/** Stand-ins by source key, so repeat calls return the same object. */
|
|
51
|
-
#torrents = new Map();
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* @param {{ maxDiskBytes?: number }} [options]
|
|
55
|
-
*/
|
|
56
|
-
constructor(options = {}) {
|
|
57
|
-
this.#client = new TorrentWorkerClient(options);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Load (or join) a torrent and return a stand-in for it.
|
|
62
|
-
*
|
|
63
|
-
* @param {"magnet" | "torrent"} sourceType
|
|
64
|
-
* @param {string} source
|
|
65
|
-
* @returns {Promise<object>}
|
|
66
|
-
*/
|
|
67
|
-
async getTorrent(sourceType, source) {
|
|
68
|
-
const sourceKey = deriveSourceKey(sourceType, source);
|
|
69
|
-
const existing = this.#torrents.get(sourceKey);
|
|
70
|
-
if (existing) {
|
|
71
|
-
return existing;
|
|
72
|
-
}
|
|
73
|
-
const torrent = await this.#client.getTorrent({ sourceKey, sourceType, source });
|
|
74
|
-
this.#torrents.set(sourceKey, torrent);
|
|
75
|
-
return torrent;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Claim a file for reading; the returned function releases it.
|
|
80
|
-
*
|
|
81
|
-
* Synchronous by design — see the file header.
|
|
82
|
-
*
|
|
83
|
-
* @param {object} torrent - A stand-in from {@link getTorrent}.
|
|
84
|
-
* @param {number} fileIndex
|
|
85
|
-
* @returns {() => void}
|
|
86
|
-
*/
|
|
87
|
-
acquireFile(torrent, fileIndex) {
|
|
88
|
-
const sourceKey = torrent?.sourceKey;
|
|
89
|
-
if (!sourceKey) {
|
|
90
|
-
return () => undefined;
|
|
91
|
-
}
|
|
92
|
-
// Dispatched, not awaited — callers use the result immediately and inspect
|
|
93
|
-
// nothing. But the release MUST NOT overtake it: both are ordinary messages
|
|
94
|
-
// to the worker, and if release arrives first the reader count drops to zero
|
|
95
|
-
// while a read is still running. The idle sweep then removes the torrent AND
|
|
96
|
-
// its downloaded data out from under the encoder — field 2026-08-02:
|
|
97
|
-
// "removed idle torrent ... and its store" mid-playback, after which every
|
|
98
|
-
// read hung and ffmpeg saw an empty input ("Stream ends prematurely at 0").
|
|
99
|
-
// Chaining the release onto the acquire keeps them in order.
|
|
100
|
-
const acquired = this.#client.acquireFile(sourceKey, fileIndex).catch(() => undefined);
|
|
101
|
-
let released = false;
|
|
102
|
-
return () => {
|
|
103
|
-
if (released) {
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
released = true;
|
|
107
|
-
void acquired.then(() => this.#client.releaseFile(sourceKey, fileIndex)).catch(() => undefined);
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Live download figures for the progress display.
|
|
113
|
-
*
|
|
114
|
-
* @param {object} torrent
|
|
115
|
-
* @param {number | null} [fileIndex]
|
|
116
|
-
* @param {{ resumeAnchorByteStart?: number | null }} [options]
|
|
117
|
-
* @returns {Promise<object | null>}
|
|
118
|
-
*/
|
|
119
|
-
async getFileStats(torrent, fileIndex = null, options = {}) {
|
|
120
|
-
const sourceKey = torrent?.sourceKey;
|
|
121
|
-
if (!sourceKey) {
|
|
122
|
-
return null;
|
|
123
|
-
}
|
|
124
|
-
return this.#client.getFileStats({
|
|
125
|
-
sourceKey,
|
|
126
|
-
fileIndex,
|
|
127
|
-
resumeAnchorByteStart: options?.resumeAnchorByteStart ?? null
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Reorder piece selection around a read position.
|
|
133
|
-
*
|
|
134
|
-
* Synchronous by design — see the file header.
|
|
135
|
-
*
|
|
136
|
-
* @param {object} torrent
|
|
137
|
-
* @param {number} fileIndex
|
|
138
|
-
* @param {number} byteStart
|
|
139
|
-
* @param {number} [windowBytes]
|
|
140
|
-
* @returns {void}
|
|
141
|
-
*/
|
|
142
|
-
prioritizeByteRange(torrent, fileIndex, byteStart, windowBytes) {
|
|
143
|
-
const sourceKey = torrent?.sourceKey;
|
|
144
|
-
if (!sourceKey) {
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
void this.#client
|
|
148
|
-
.prioritizeByteRange({ sourceKey, fileIndex, byteStart, windowBytes })
|
|
149
|
-
.catch(() => undefined);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Pre-fetch the head and tail the codec probe needs.
|
|
154
|
-
*
|
|
155
|
-
* @param {object} torrent
|
|
156
|
-
* @param {number} fileIndex
|
|
157
|
-
* @param {number} [headBytes]
|
|
158
|
-
* @param {number} [tailBytes]
|
|
159
|
-
* @param {number} [timeoutMs]
|
|
160
|
-
* @returns {Promise<unknown>}
|
|
161
|
-
*/
|
|
162
|
-
async prefetchFileEdges(torrent, fileIndex, headBytes, tailBytes, timeoutMs) {
|
|
163
|
-
const sourceKey = torrent?.sourceKey;
|
|
164
|
-
if (!sourceKey) {
|
|
165
|
-
return null;
|
|
166
|
-
}
|
|
167
|
-
return this.#client.prefetchFileEdges({ sourceKey, fileIndex, headBytes, tailBytes, timeoutMs });
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Shut the torrent client down and stop the thread.
|
|
172
|
-
*
|
|
173
|
-
* @returns {Promise<void>}
|
|
174
|
-
*/
|
|
175
|
-
async destroyAll() {
|
|
176
|
-
this.#torrents.clear();
|
|
177
|
-
await this.#client.destroyAll();
|
|
178
|
-
}
|
|
179
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @file `TorrentPool`'s interface, served from the worker thread.
|
|
3
|
+
*
|
|
4
|
+
* The routes, the planner, the health report and the session manager all reach
|
|
5
|
+
* for a torrent pool and use it the same handful of ways. Rather than rewrite
|
|
6
|
+
* every one of them to thread a `sourceKey` through and await what used to be
|
|
7
|
+
* immediate, this presents the shape they already expect and does the thread
|
|
8
|
+
* hop behind it. Swapping the implementation is then a one-line change at
|
|
9
|
+
* construction, and the call sites are untouched — which is what keeps a change
|
|
10
|
+
* of this size reviewable.
|
|
11
|
+
*
|
|
12
|
+
* Two accommodations are needed, and both are deliberate:
|
|
13
|
+
*
|
|
14
|
+
* - **`acquireFile` and `prioritizeByteRange` stay synchronous.** They return
|
|
15
|
+
* nothing the caller inspects, so the command is dispatched and not awaited.
|
|
16
|
+
* `acquireFile` hands back a release function exactly as before, which sends
|
|
17
|
+
* its own command when called. Awaiting them would mean touching every call
|
|
18
|
+
* site for no observable gain.
|
|
19
|
+
* - **`getTorrent` needs a `sourceKey`.** Torrent objects cannot cross a
|
|
20
|
+
* thread, so the worker keys them. Callers that have one pass it; the rest
|
|
21
|
+
* get one derived from the source itself, so the identity stays stable
|
|
22
|
+
* across calls for the same torrent.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import crypto from "node:crypto";
|
|
26
|
+
import { TorrentWorkerClient } from "./client.js";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Stable key for a source, matching how the worker keys its torrents.
|
|
30
|
+
*
|
|
31
|
+
* Derived from the source itself rather than handed out per request, so two
|
|
32
|
+
* routes asking for the same torrent name the same thing on the worker side.
|
|
33
|
+
*
|
|
34
|
+
* @param {"magnet" | "torrent"} sourceType
|
|
35
|
+
* @param {string} source
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
function deriveSourceKey(sourceType, source) {
|
|
39
|
+
return `${sourceType}:${crypto.createHash("sha1").update(source).digest("hex")}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A torrent pool whose work happens on another thread.
|
|
44
|
+
*
|
|
45
|
+
* See `protocol.js` for why: the torrent was taking ~85% of the main thread and
|
|
46
|
+
* everything owed to a viewer queued behind it.
|
|
47
|
+
*/
|
|
48
|
+
export class WorkerTorrentPool {
|
|
49
|
+
#client;
|
|
50
|
+
/** Stand-ins by source key, so repeat calls return the same object. */
|
|
51
|
+
#torrents = new Map();
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {{ maxDiskBytes?: number, memoryBytes?: number }} [options]
|
|
55
|
+
*/
|
|
56
|
+
constructor(options = {}) {
|
|
57
|
+
this.#client = new TorrentWorkerClient(options);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Load (or join) a torrent and return a stand-in for it.
|
|
62
|
+
*
|
|
63
|
+
* @param {"magnet" | "torrent"} sourceType
|
|
64
|
+
* @param {string} source
|
|
65
|
+
* @returns {Promise<object>}
|
|
66
|
+
*/
|
|
67
|
+
async getTorrent(sourceType, source) {
|
|
68
|
+
const sourceKey = deriveSourceKey(sourceType, source);
|
|
69
|
+
const existing = this.#torrents.get(sourceKey);
|
|
70
|
+
if (existing) {
|
|
71
|
+
return existing;
|
|
72
|
+
}
|
|
73
|
+
const torrent = await this.#client.getTorrent({ sourceKey, sourceType, source });
|
|
74
|
+
this.#torrents.set(sourceKey, torrent);
|
|
75
|
+
return torrent;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Claim a file for reading; the returned function releases it.
|
|
80
|
+
*
|
|
81
|
+
* Synchronous by design — see the file header.
|
|
82
|
+
*
|
|
83
|
+
* @param {object} torrent - A stand-in from {@link getTorrent}.
|
|
84
|
+
* @param {number} fileIndex
|
|
85
|
+
* @returns {() => void}
|
|
86
|
+
*/
|
|
87
|
+
acquireFile(torrent, fileIndex) {
|
|
88
|
+
const sourceKey = torrent?.sourceKey;
|
|
89
|
+
if (!sourceKey) {
|
|
90
|
+
return () => undefined;
|
|
91
|
+
}
|
|
92
|
+
// Dispatched, not awaited — callers use the result immediately and inspect
|
|
93
|
+
// nothing. But the release MUST NOT overtake it: both are ordinary messages
|
|
94
|
+
// to the worker, and if release arrives first the reader count drops to zero
|
|
95
|
+
// while a read is still running. The idle sweep then removes the torrent AND
|
|
96
|
+
// its downloaded data out from under the encoder — field 2026-08-02:
|
|
97
|
+
// "removed idle torrent ... and its store" mid-playback, after which every
|
|
98
|
+
// read hung and ffmpeg saw an empty input ("Stream ends prematurely at 0").
|
|
99
|
+
// Chaining the release onto the acquire keeps them in order.
|
|
100
|
+
const acquired = this.#client.acquireFile(sourceKey, fileIndex).catch(() => undefined);
|
|
101
|
+
let released = false;
|
|
102
|
+
return () => {
|
|
103
|
+
if (released) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
released = true;
|
|
107
|
+
void acquired.then(() => this.#client.releaseFile(sourceKey, fileIndex)).catch(() => undefined);
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Live download figures for the progress display.
|
|
113
|
+
*
|
|
114
|
+
* @param {object} torrent
|
|
115
|
+
* @param {number | null} [fileIndex]
|
|
116
|
+
* @param {{ resumeAnchorByteStart?: number | null }} [options]
|
|
117
|
+
* @returns {Promise<object | null>}
|
|
118
|
+
*/
|
|
119
|
+
async getFileStats(torrent, fileIndex = null, options = {}) {
|
|
120
|
+
const sourceKey = torrent?.sourceKey;
|
|
121
|
+
if (!sourceKey) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
return this.#client.getFileStats({
|
|
125
|
+
sourceKey,
|
|
126
|
+
fileIndex,
|
|
127
|
+
resumeAnchorByteStart: options?.resumeAnchorByteStart ?? null
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Reorder piece selection around a read position.
|
|
133
|
+
*
|
|
134
|
+
* Synchronous by design — see the file header.
|
|
135
|
+
*
|
|
136
|
+
* @param {object} torrent
|
|
137
|
+
* @param {number} fileIndex
|
|
138
|
+
* @param {number} byteStart
|
|
139
|
+
* @param {number} [windowBytes]
|
|
140
|
+
* @returns {void}
|
|
141
|
+
*/
|
|
142
|
+
prioritizeByteRange(torrent, fileIndex, byteStart, windowBytes) {
|
|
143
|
+
const sourceKey = torrent?.sourceKey;
|
|
144
|
+
if (!sourceKey) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
void this.#client
|
|
148
|
+
.prioritizeByteRange({ sourceKey, fileIndex, byteStart, windowBytes })
|
|
149
|
+
.catch(() => undefined);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Pre-fetch the head and tail the codec probe needs.
|
|
154
|
+
*
|
|
155
|
+
* @param {object} torrent
|
|
156
|
+
* @param {number} fileIndex
|
|
157
|
+
* @param {number} [headBytes]
|
|
158
|
+
* @param {number} [tailBytes]
|
|
159
|
+
* @param {number} [timeoutMs]
|
|
160
|
+
* @returns {Promise<unknown>}
|
|
161
|
+
*/
|
|
162
|
+
async prefetchFileEdges(torrent, fileIndex, headBytes, tailBytes, timeoutMs) {
|
|
163
|
+
const sourceKey = torrent?.sourceKey;
|
|
164
|
+
if (!sourceKey) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
return this.#client.prefetchFileEdges({ sourceKey, fileIndex, headBytes, tailBytes, timeoutMs });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Shut the torrent client down and stop the thread.
|
|
172
|
+
*
|
|
173
|
+
* @returns {Promise<void>}
|
|
174
|
+
*/
|
|
175
|
+
async destroyAll() {
|
|
176
|
+
this.#torrents.clear();
|
|
177
|
+
await this.#client.destroyAll();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -32,8 +32,12 @@ import { Command, Event, STREAM_CHUNK_BYTES } from "./protocol.js";
|
|
|
32
32
|
// the hook above had a chance to register. Verified the hard way: with a static
|
|
33
33
|
// import the process still aborted, and the stack named the genuine polyfill.
|
|
34
34
|
const { TorrentPool } = await import("../torrent-pool.js");
|
|
35
|
+
const { collectStoreStats } = await import("../piece-store/shared-piece-store.js");
|
|
35
36
|
|
|
36
|
-
const pool = new TorrentPool({
|
|
37
|
+
const pool = new TorrentPool({
|
|
38
|
+
maxDiskBytes: workerData?.maxDiskBytes,
|
|
39
|
+
memoryBytes: workerData?.memoryBytes
|
|
40
|
+
});
|
|
37
41
|
|
|
38
42
|
/** Torrents by sourceKey — the main thread names them, this thread owns them. */
|
|
39
43
|
const torrentsByKey = new Map();
|
|
@@ -122,6 +126,7 @@ async function streamRange({ id, sourceKey, fileIndex, start, end }) {
|
|
|
122
126
|
await sender.send(merged);
|
|
123
127
|
};
|
|
124
128
|
|
|
129
|
+
let failed = false;
|
|
125
130
|
try {
|
|
126
131
|
for await (const part of source) {
|
|
127
132
|
if (sender.isCancelled()) {
|
|
@@ -136,10 +141,20 @@ async function streamRange({ id, sourceKey, fileIndex, start, end }) {
|
|
|
136
141
|
if (!sender.isCancelled()) {
|
|
137
142
|
await flush();
|
|
138
143
|
}
|
|
144
|
+
} catch (error) {
|
|
145
|
+
// The end-of-read marker means "the body is complete". Sending it after a
|
|
146
|
+
// failure told the reader the file simply ended — a truncated segment that
|
|
147
|
+
// ffmpeg reported as `Stream ends prematurely`, with the real cause thrown
|
|
148
|
+
// away. Let the error propagate instead; the command handler reports it and
|
|
149
|
+
// the main thread fails the stream.
|
|
150
|
+
failed = true;
|
|
151
|
+
throw error;
|
|
139
152
|
} finally {
|
|
140
153
|
readsById.delete(id);
|
|
141
154
|
releaseRead();
|
|
142
|
-
|
|
155
|
+
if (!failed) {
|
|
156
|
+
sender.end();
|
|
157
|
+
}
|
|
143
158
|
// A cancelled read must stop the underlying torrent stream too, or the
|
|
144
159
|
// pieces keep being fetched for a viewer who has gone.
|
|
145
160
|
if (typeof source.destroy === "function") {
|
|
@@ -273,4 +288,36 @@ parentPort.on("message", async (message) => {
|
|
|
273
288
|
}
|
|
274
289
|
});
|
|
275
290
|
|
|
291
|
+
/**
|
|
292
|
+
* How often the piece store reports what it has been doing.
|
|
293
|
+
*
|
|
294
|
+
* The store decides whether a read costs nothing or costs a disk trip, and
|
|
295
|
+
* until 2.9.75 nothing about it reached the log — a field oddity would have had
|
|
296
|
+
* no evidence to work from. Reported only when something changed, so an idle
|
|
297
|
+
* proxy stays quiet.
|
|
298
|
+
*/
|
|
299
|
+
const STORE_REPORT_INTERVAL_MS = 60_000;
|
|
300
|
+
|
|
301
|
+
/** Last reported figures per store, so unchanged ones stay silent. */
|
|
302
|
+
const lastReported = new Map();
|
|
303
|
+
|
|
304
|
+
setInterval(() => {
|
|
305
|
+
for (const stats of collectStoreStats()) {
|
|
306
|
+
const signature = `${stats.fromMemory}/${stats.fromDisk}/${stats.spills}/${stats.revivals}/${stats.blockedByPins}`;
|
|
307
|
+
if (lastReported.get(stats.name) === signature) {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
lastReported.set(stats.name, signature);
|
|
311
|
+
|
|
312
|
+
const reads = stats.fromMemory + stats.fromDisk;
|
|
313
|
+
const fromMemoryShare = reads > 0 ? ((stats.fromMemory / reads) * 100).toFixed(1) : "—";
|
|
314
|
+
log(
|
|
315
|
+
`piece-store "${stats.name.slice(0, 40)}": resident=${stats.resident}/${stats.capacity} ` +
|
|
316
|
+
`spilled=${stats.spilled} reads=${reads} (${fromMemoryShare}% from memory) ` +
|
|
317
|
+
`spills=${stats.spills} revivals=${stats.revivals}` +
|
|
318
|
+
(stats.blockedByPins > 0 ? ` blocked-by-pins=${stats.blockedByPins}` : "")
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
}, STORE_REPORT_INTERVAL_MS).unref();
|
|
322
|
+
|
|
276
323
|
log("torrent worker started");
|
|
@@ -209,6 +209,82 @@ test("a range within a piece is served correctly from memory and from disk", asy
|
|
|
209
209
|
}
|
|
210
210
|
});
|
|
211
211
|
|
|
212
|
+
test("takes memory as it needs it, not the whole budget up front", async () => {
|
|
213
|
+
// The budget is per torrent, so a torrent that is merely open — or one being
|
|
214
|
+
// probed for its codecs — must not charge the host for the ceiling.
|
|
215
|
+
const { store, directory } = await makeStore({ pieces: 16, totalPieces: 64 });
|
|
216
|
+
try {
|
|
217
|
+
assert.equal(store.capacity, 16);
|
|
218
|
+
const initial = store.sharedBuffer.byteLength;
|
|
219
|
+
assert.ok(initial <= CHUNK * 2, `claimed ${initial} bytes before holding anything`);
|
|
220
|
+
|
|
221
|
+
for (let index = 0; index < 5; index += 1) {
|
|
222
|
+
await put(store, index, piece(index));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
assert.equal(store.residentCount, 5);
|
|
226
|
+
assert.ok(
|
|
227
|
+
store.sharedBuffer.byteLength >= CHUNK * 5,
|
|
228
|
+
"the pool did not grow to hold what was put in it"
|
|
229
|
+
);
|
|
230
|
+
assert.ok(
|
|
231
|
+
store.sharedBuffer.byteLength <= CHUNK * 6,
|
|
232
|
+
"the pool grew past what was actually needed"
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// Growing must not move bytes already written.
|
|
236
|
+
assert.deepEqual(await get(store, 0), piece(0), "an early piece was disturbed by growth");
|
|
237
|
+
} finally {
|
|
238
|
+
await new Promise((resolve) => store.destroy(resolve));
|
|
239
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("counts where reads were served from, so the budget can be judged", async () => {
|
|
244
|
+
const { store, directory } = await makeStore({ pieces: 2, totalPieces: 8 });
|
|
245
|
+
try {
|
|
246
|
+
await put(store, 0, piece(0));
|
|
247
|
+
await get(store, 0); // memory
|
|
248
|
+
await get(store, 0); // memory
|
|
249
|
+
|
|
250
|
+
await put(store, 1, piece(1));
|
|
251
|
+
await put(store, 2, piece(2)); // piece 0 spills
|
|
252
|
+
await get(store, 0); // disk
|
|
253
|
+
|
|
254
|
+
const stats = store.stats();
|
|
255
|
+
assert.equal(stats.fromMemory, 2, "memory reads miscounted");
|
|
256
|
+
assert.equal(stats.fromDisk, 1, "disk reads miscounted");
|
|
257
|
+
// Two: piece 0 goes out to make room for piece 2, then piece 1 goes out to
|
|
258
|
+
// make room for piece 0 coming back. Reviving costs a spill of its own, and
|
|
259
|
+
// that is worth seeing in the figures rather than hiding.
|
|
260
|
+
assert.equal(stats.spills, 2, "spills miscounted");
|
|
261
|
+
assert.equal(stats.revivals, 1, "revivals miscounted");
|
|
262
|
+
assert.equal(stats.blockedByPins, 0);
|
|
263
|
+
assert.equal(stats.capacity, 2);
|
|
264
|
+
} finally {
|
|
265
|
+
await new Promise((resolve) => store.destroy(resolve));
|
|
266
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("counts a refusal caused by pinned pieces", async () => {
|
|
271
|
+
const { store, directory } = await makeStore({ pieces: 2, totalPieces: 8 });
|
|
272
|
+
try {
|
|
273
|
+
await put(store, 0, piece(0));
|
|
274
|
+
await put(store, 1, piece(1));
|
|
275
|
+
store.pin(0);
|
|
276
|
+
store.pin(1);
|
|
277
|
+
await assert.rejects(() => put(store, 2, piece(2)));
|
|
278
|
+
|
|
279
|
+
assert.equal(store.stats().blockedByPins, 1, "a refusal went unrecorded");
|
|
280
|
+
} finally {
|
|
281
|
+
store.unpin(0);
|
|
282
|
+
store.unpin(1);
|
|
283
|
+
await new Promise((resolve) => store.destroy(resolve));
|
|
284
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
212
288
|
test("destroy removes the spill file", async () => {
|
|
213
289
|
const { store, directory } = await makeStore({ pieces: 2, totalPieces: 8 });
|
|
214
290
|
await put(store, 0, piece(0));
|
|
@@ -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
|
+
});
|