@torrent-tv/proxy 2.87.19 → 2.87.21
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 +8 -0
- package/package.json +1 -1
- package/routes/stream/get.js +24 -5
- package/services/storage/piece-store/shared-piece-store.js +48 -25
- package/services/torrent/worker/pool-adapter.js +8 -2
- package/test/piece-store-reservations.test.js +39 -1
- package/test/stream-route.test.js +32 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 2.87.21
|
|
2
|
+
|
|
3
|
+
- **Fix**: Give waiting source reads ordered memory reservations ahead of new piece arrivals, share concurrent revival work for one piece, and reserve memory before allocating assembled-file bytes.
|
|
4
|
+
|
|
5
|
+
## 2.87.20
|
|
6
|
+
|
|
7
|
+
- **Fix**: Expose completed files through the worker pool adapter so source HTTP reads use the assembled file; open it before sending headers and return to torrent reads if it has already been removed.
|
|
8
|
+
|
|
1
9
|
## 2.87.19
|
|
2
10
|
|
|
3
11
|
- **Fix**: Return shared source bytes after whole-file assembly and retain the buffer held by concurrent readers instead of replacing it; fail malformed fragments explicitly rather than skipping source bytes.
|
package/package.json
CHANGED
package/routes/stream/get.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* and HTTP 200 for full-file requests.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { open } from "node:fs/promises";
|
|
10
10
|
import { parseRange } from "../../utils/parse-range.js";
|
|
11
11
|
import { logger } from "../../utils/logger.js";
|
|
12
12
|
|
|
@@ -107,9 +107,10 @@ function wholeFileFor(torrentPool, sourceKey, fileIndex) {
|
|
|
107
107
|
* @param {import("fastify").FastifyRequest} req
|
|
108
108
|
* @param {import("fastify").FastifyReply} reply
|
|
109
109
|
* @param {{ path: string, length: number, name: string }} file
|
|
110
|
+
* @param {import("node:fs/promises").FileHandle} handle - Already open; closed for HEAD.
|
|
110
111
|
* @returns {Promise<void> | void}
|
|
111
112
|
*/
|
|
112
|
-
function serveWholeFile(req, reply, file) {
|
|
113
|
+
function serveWholeFile(req, reply, file, handle) {
|
|
113
114
|
const disposition = `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`;
|
|
114
115
|
if (req.method === "HEAD") {
|
|
115
116
|
reply.hijack();
|
|
@@ -128,13 +129,13 @@ function serveWholeFile(req, reply, file) {
|
|
|
128
129
|
const range = parseRange(req.headers.range, file.length);
|
|
129
130
|
if (!range) {
|
|
130
131
|
reply.header("Content-Length", String(file.length));
|
|
131
|
-
return reply.send(createReadStream(
|
|
132
|
+
return reply.send(handle.createReadStream());
|
|
132
133
|
}
|
|
133
134
|
const { start, end } = range;
|
|
134
135
|
reply.code(206);
|
|
135
136
|
reply.header("Content-Length", String(end - start + 1));
|
|
136
137
|
reply.header("Content-Range", `bytes ${start}-${end}/${file.length}`);
|
|
137
|
-
return reply.send(createReadStream(
|
|
138
|
+
return reply.send(handle.createReadStream({ start, end }));
|
|
138
139
|
}
|
|
139
140
|
|
|
140
141
|
export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool, noteInputBytes = null }) {
|
|
@@ -157,7 +158,25 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
|
|
|
157
158
|
// nothing the torrent would have told us.
|
|
158
159
|
const whole = wholeFileFor(torrentPool, sourceKey, fileIndex);
|
|
159
160
|
if (whole) {
|
|
160
|
-
|
|
161
|
+
let handle;
|
|
162
|
+
try {
|
|
163
|
+
// Open before committing headers: the disk owner may have removed an
|
|
164
|
+
// announced file. An open descriptor also preserves a live POSIX read.
|
|
165
|
+
handle = await open(whole.path, "r");
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (error.code !== "ENOENT") throw error;
|
|
168
|
+
const key = `${sourceKey.slice("torrent:".length).toLowerCase()}/${fileIndex}`;
|
|
169
|
+
if (torrentPool.wholeFiles.get(key) === whole) torrentPool.wholeFiles.delete(key);
|
|
170
|
+
}
|
|
171
|
+
if (handle) {
|
|
172
|
+
if (req.method === "HEAD") await handle.close();
|
|
173
|
+
try {
|
|
174
|
+
return serveWholeFile(req, reply, whole, handle);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
await handle.close().catch(() => undefined);
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
161
180
|
}
|
|
162
181
|
|
|
163
182
|
let torrent;
|
|
@@ -405,6 +405,12 @@ export class SharedPieceStore {
|
|
|
405
405
|
*/
|
|
406
406
|
#readElsewhere = null;
|
|
407
407
|
|
|
408
|
+
/** Reads waiting for a reservation, in arrival order. */
|
|
409
|
+
#readerClaims = [];
|
|
410
|
+
|
|
411
|
+
/** One revival per piece, shared by concurrent readers. */
|
|
412
|
+
#revivals = new Map();
|
|
413
|
+
|
|
408
414
|
/**
|
|
409
415
|
* Whether a piece can be had elsewhere, asked without reading it — so a
|
|
410
416
|
* spilled copy that has become a duplicate can be dropped.
|
|
@@ -858,7 +864,7 @@ export class SharedPieceStore {
|
|
|
858
864
|
}
|
|
859
865
|
}
|
|
860
866
|
|
|
861
|
-
async #claimSlot() {
|
|
867
|
+
async #claimSlot(forReader = false) {
|
|
862
868
|
// How long THIS claim has been trying, kept here and not on the store.
|
|
863
869
|
// It was a field, `#pinnedWaitStartedAt`, shared by the two waits inside
|
|
864
870
|
// `#claimSlotOnce` — the one for the disk and the one for a piece that may
|
|
@@ -870,23 +876,36 @@ export class SharedPieceStore {
|
|
|
870
876
|
// claimants a shared field is wrong anyway, since one caller giving up
|
|
871
877
|
// would reset the wait of every other.
|
|
872
878
|
const waitingSince = Date.now();
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
879
|
+
const claimant = forReader ? {} : null;
|
|
880
|
+
if (claimant) this.#readerClaims.push(claimant);
|
|
881
|
+
try {
|
|
882
|
+
for (;;) {
|
|
883
|
+
if (this.#closed) {
|
|
884
|
+
throw new Error("Piece store is closed.");
|
|
885
|
+
}
|
|
886
|
+
// New arrivals must not take every released block ahead of an encoder
|
|
887
|
+
// already waiting to read. Readers share one ordered reservation queue.
|
|
888
|
+
const hasTurn = this.#readerClaims.length === 0 || this.#readerClaims[0] === claimant;
|
|
889
|
+
const isReserved = hasTurn && await this.#claimSlotOnce(waitingSince);
|
|
890
|
+
if (isReserved) {
|
|
891
|
+
let released = false;
|
|
892
|
+
return () => {
|
|
893
|
+
if (released) {
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
released = true;
|
|
897
|
+
this.#outstandingPieces -= 1;
|
|
898
|
+
this.#noteProgress();
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
await this.#waitForSlot();
|
|
876
902
|
}
|
|
877
|
-
|
|
878
|
-
if (
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
return;
|
|
883
|
-
}
|
|
884
|
-
released = true;
|
|
885
|
-
this.#outstandingPieces -= 1;
|
|
886
|
-
this.#noteProgress();
|
|
887
|
-
};
|
|
903
|
+
} finally {
|
|
904
|
+
if (claimant) {
|
|
905
|
+
const at = this.#readerClaims.indexOf(claimant);
|
|
906
|
+
if (at >= 0) this.#readerClaims.splice(at, 1);
|
|
907
|
+
this.#wake();
|
|
888
908
|
}
|
|
889
|
-
await this.#waitForSlot();
|
|
890
909
|
}
|
|
891
910
|
}
|
|
892
911
|
|
|
@@ -1395,15 +1414,14 @@ export class SharedPieceStore {
|
|
|
1395
1414
|
// Not spilled, but the file it belongs to may be here whole — which is
|
|
1396
1415
|
// the ordinary case once a film has been assembled and its spilled copy
|
|
1397
1416
|
// dropped as the duplicate it had become.
|
|
1398
|
-
|
|
1399
|
-
if (!whole) {
|
|
1400
|
-
return null;
|
|
1401
|
-
}
|
|
1402
|
-
this.#counters.fromWholeFile += 1;
|
|
1417
|
+
if (this.#isElsewhere && !this.isInWholeFiles(index)) return null;
|
|
1403
1418
|
// Fragment readers require shared memory, including when the bytes now
|
|
1404
|
-
// live in an assembled file.
|
|
1405
|
-
const release = await this.#claimSlot();
|
|
1419
|
+
// live in an assembled file. Reserve before allocating ordinary bytes.
|
|
1420
|
+
const release = await this.#claimSlot(true);
|
|
1406
1421
|
try {
|
|
1422
|
+
const whole = await this.#fromWholeFiles(index);
|
|
1423
|
+
if (!whole) return null;
|
|
1424
|
+
this.#counters.fromWholeFile += 1;
|
|
1407
1425
|
return this.#registerPiece(index, this.#copyIntoNewBuffer(index, whole));
|
|
1408
1426
|
} finally {
|
|
1409
1427
|
release();
|
|
@@ -1415,7 +1433,7 @@ export class SharedPieceStore {
|
|
|
1415
1433
|
const releaseDisk = this.#disk.hold?.(index) ?? (() => {});
|
|
1416
1434
|
let release = null;
|
|
1417
1435
|
try {
|
|
1418
|
-
release = await this.#claimSlot();
|
|
1436
|
+
release = await this.#claimSlot(true);
|
|
1419
1437
|
// Another caller may have brought it back while this one waited for a
|
|
1420
1438
|
// slot. Registering a second buffer for the same piece would leave
|
|
1421
1439
|
// whoever holds the first reading memory nothing evicts.
|
|
@@ -1860,7 +1878,12 @@ export class SharedPieceStore {
|
|
|
1860
1878
|
return this.locate(index);
|
|
1861
1879
|
}
|
|
1862
1880
|
|
|
1863
|
-
|
|
1881
|
+
let revival = this.#revivals.get(index);
|
|
1882
|
+
if (!revival) {
|
|
1883
|
+
revival = this.#revive(index).finally(() => this.#revivals.delete(index));
|
|
1884
|
+
this.#revivals.set(index, revival);
|
|
1885
|
+
}
|
|
1886
|
+
const revived = await revival;
|
|
1864
1887
|
if (revived === null) {
|
|
1865
1888
|
return null;
|
|
1866
1889
|
}
|
|
@@ -36,9 +36,10 @@ export class WorkerTorrentPool {
|
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
38
|
* @param {{ memoryBytes?: number, stateDir?: string }} [options]
|
|
39
|
+
* @param {object | null} [client] - The worker interface; tests supply a fake.
|
|
39
40
|
*/
|
|
40
|
-
constructor(options = {}) {
|
|
41
|
-
this.#client = new TorrentWorkerClient(options);
|
|
41
|
+
constructor(options = {}, client = null) {
|
|
42
|
+
this.#client = client ?? new TorrentWorkerClient(options);
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
/**
|
|
@@ -95,6 +96,11 @@ export class WorkerTorrentPool {
|
|
|
95
96
|
return this.#client.wholeFileBytes ?? 0;
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
/** Completed files announced by the worker, shared with the HTTP reader. */
|
|
100
|
+
get wholeFiles() {
|
|
101
|
+
return this.#client.wholeFiles;
|
|
102
|
+
}
|
|
103
|
+
|
|
98
104
|
allowWholeFileBytes(bytes) {
|
|
99
105
|
return this.#client.allowWholeFileBytes(bytes);
|
|
100
106
|
}
|
|
@@ -34,6 +34,10 @@ function makeDisk({ failWrite = false, failRead = false, holdWrites = false } =
|
|
|
34
34
|
resume();
|
|
35
35
|
}
|
|
36
36
|
},
|
|
37
|
+
finishWrites() {
|
|
38
|
+
holdWrites = false;
|
|
39
|
+
this.releaseWrites();
|
|
40
|
+
},
|
|
37
41
|
get heldCount() {
|
|
38
42
|
return held.length;
|
|
39
43
|
},
|
|
@@ -150,15 +154,20 @@ test("a revival holds disk bytes across the asynchronous memory reservation", as
|
|
|
150
154
|
|
|
151
155
|
test("an assembled-file revival returns shared bytes and preserves a concurrent pinned reader", async () => {
|
|
152
156
|
const disk = makeDisk();
|
|
157
|
+
let reads = 0;
|
|
153
158
|
const store = new SharedPieceStore(CHUNK, {
|
|
154
159
|
length: CHUNK * 16, memoryBytes: CHUNK * 4, disk,
|
|
155
|
-
readPieceElsewhere: async ({ index }) =>
|
|
160
|
+
readPieceElsewhere: async ({ index }) => {
|
|
161
|
+
reads += 1;
|
|
162
|
+
return piece(index);
|
|
163
|
+
}, name: "whole-file-read"
|
|
156
164
|
});
|
|
157
165
|
try {
|
|
158
166
|
store.pin(0);
|
|
159
167
|
const [first, second] = await Promise.all([store.reside(0), store.reside(0)]);
|
|
160
168
|
assert.ok(first.buffer instanceof SharedArrayBuffer);
|
|
161
169
|
assert.equal(first.buffer, second.buffer);
|
|
170
|
+
assert.equal(reads, 1, "concurrent readers repeated the whole-file allocation and read");
|
|
162
171
|
await put(store, 1);
|
|
163
172
|
await put(store, 2);
|
|
164
173
|
assert.deepEqual(Buffer.from(first.buffer, first.offset, first.length), piece(0));
|
|
@@ -170,6 +179,35 @@ test("an assembled-file revival returns shared bytes and preserves a concurrent
|
|
|
170
179
|
}
|
|
171
180
|
});
|
|
172
181
|
|
|
182
|
+
test("a waiting source reader receives a released slot before queued arrivals", async () => {
|
|
183
|
+
const disk = makeDisk({ holdWrites: true });
|
|
184
|
+
const { store } = makeStore({ pieces: 2, disk });
|
|
185
|
+
let arrived = false;
|
|
186
|
+
try {
|
|
187
|
+
await put(store, 0);
|
|
188
|
+
await put(store, 1);
|
|
189
|
+
store.pin(1);
|
|
190
|
+
disk.stored.set(4, piece(4));
|
|
191
|
+
const spilling = put(store, 2);
|
|
192
|
+
await until(() => disk.heldCount > 0, "an arriving piece occupies the spill slot");
|
|
193
|
+
const arrival = put(store, 3).then(() => { arrived = true; });
|
|
194
|
+
store.pin(4);
|
|
195
|
+
const reading = store.reside(4);
|
|
196
|
+
await until(() => store.stats().waitedForDisk >= 2, "both claims wait behind the spill");
|
|
197
|
+
disk.finishWrites();
|
|
198
|
+
const located = await reading;
|
|
199
|
+
assert.deepEqual(Buffer.from(located.buffer, located.offset, located.length), piece(4));
|
|
200
|
+
assert.equal(arrived, false, "an arriving piece overtook an already waiting reader");
|
|
201
|
+
store.unpin(1);
|
|
202
|
+
store.unpin(4);
|
|
203
|
+
await Promise.all([spilling, arrival]);
|
|
204
|
+
assert.equal(store.stats().outstanding, 0);
|
|
205
|
+
} finally {
|
|
206
|
+
disk.finishWrites();
|
|
207
|
+
await new Promise((resolve) => store.destroy(resolve));
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
173
211
|
/**
|
|
174
212
|
* Wait until a condition holds, rather than for a chosen interval — a test that
|
|
175
213
|
* sleeps samples, it does not check (roadmap item 54).
|
|
@@ -14,6 +14,7 @@ import fs from "node:fs/promises";
|
|
|
14
14
|
import os from "node:os";
|
|
15
15
|
import path from "node:path";
|
|
16
16
|
import { handleStreamGet } from "../routes/stream/get.js";
|
|
17
|
+
import { WorkerTorrentPool } from "../services/torrent/worker/pool-adapter.js";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Minimal stand-ins for the parts of Fastify and the pool this route touches.
|
|
@@ -151,13 +152,18 @@ test("a file downloaded whole is served from disk without touching the torrent",
|
|
|
151
152
|
try {
|
|
152
153
|
const { req, reply, sent, deps } = harness({ method: "GET", range: "bytes=2-6" });
|
|
153
154
|
let asked = false;
|
|
154
|
-
|
|
155
|
-
asked = true;
|
|
156
|
-
throw new Error("the torrent was asked for, which is what this avoids");
|
|
157
|
-
};
|
|
158
|
-
deps.torrentPool.wholeFiles = new Map([
|
|
155
|
+
const wholeFiles = new Map([
|
|
159
156
|
["abc/0", { path: where, length: bytes.length, name: "film.mkv" }]
|
|
160
157
|
]);
|
|
158
|
+
// Supply the worker boundary explicitly: no Worker or torrent is started.
|
|
159
|
+
deps.torrentPool = new WorkerTorrentPool({}, {
|
|
160
|
+
wholeFiles,
|
|
161
|
+
async getTorrent() {
|
|
162
|
+
asked = true;
|
|
163
|
+
throw new Error("the worker must not be asked for completed bytes");
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
assert.equal(deps.torrentPool.wholeFiles, wholeFiles);
|
|
161
167
|
// The source key IS the identity — `torrent:<infohash>` — and it is all the
|
|
162
168
|
// route needs to find the file.
|
|
163
169
|
deps.sourceRegistry = { get: () => ({ sourceType: "torrent", source: "magnet:?xt=urn:btih:abc" }) };
|
|
@@ -169,11 +175,32 @@ test("a file downloaded whole is served from disk without touching the torrent",
|
|
|
169
175
|
assert.equal(sent.code, 206);
|
|
170
176
|
assert.equal(sent.headers["content-range"], `bytes 2-6/${bytes.length}`);
|
|
171
177
|
assert.equal(sent.headers["content-length"], "5");
|
|
178
|
+
const chunks = [];
|
|
179
|
+
for await (const chunk of sent.body) chunks.push(chunk);
|
|
180
|
+
assert.deepEqual(Buffer.concat(chunks), bytes.subarray(2, 7));
|
|
172
181
|
} finally {
|
|
173
182
|
await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
174
183
|
}
|
|
175
184
|
});
|
|
176
185
|
|
|
186
|
+
test("an announced file removed before opening falls back to the torrent before headers", async () => {
|
|
187
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "stream-removed-"));
|
|
188
|
+
try {
|
|
189
|
+
const { req, reply, sent, opened, deps } = harness({ method: "GET", range: "bytes=2-6" });
|
|
190
|
+
deps.torrentPool.wholeFiles = new Map([
|
|
191
|
+
["abc/0", { path: path.join(root, "missing"), length: 10, name: "gone.mkv" }]
|
|
192
|
+
]);
|
|
193
|
+
deps.sourceRegistry = { get: () => ({ sourceType: "magnet", source: "magnet:?xt=urn:btih:abc" }) };
|
|
194
|
+
req.query = { sourceKey: "torrent:abc", fileIndex: "0" };
|
|
195
|
+
await handleStreamGet(req, reply, deps);
|
|
196
|
+
assert.deepEqual(opened, ["2-6"]);
|
|
197
|
+
assert.equal(deps.torrentPool.wholeFiles.size, 0);
|
|
198
|
+
assert.equal(sent.headers["content-range"], "bytes 2-6/5869669065");
|
|
199
|
+
} finally {
|
|
200
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
177
204
|
test("a file this proxy does not have whole still goes to the torrent", async () => {
|
|
178
205
|
const { req, reply, state, deps } = harness({ method: "GET", range: "bytes=0-99" });
|
|
179
206
|
deps.torrentPool.wholeFiles = new Map([["other/7", { path: "/nowhere", length: 1, name: "x" }]]);
|