@torrent-tv/proxy 2.101.20 → 2.101.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.101.20",
3
+ "version": "2.101.22",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { open } from "node:fs/promises";
10
+ import { Readable } from "node:stream";
10
11
  import { parseRange } from "../../utils/parse-range.js";
11
12
  import { logger } from "../../utils/logger.js";
12
13
 
@@ -109,49 +110,66 @@ function wholeFileFor(torrentPool, sourceKey, fileIndex) {
109
110
  }
110
111
 
111
112
  /**
112
- * Open the whole file a response under way can continue from, if there is one.
113
+ * Acquire the whole file a response can continue from, if there is one.
113
114
  *
114
- * Any failure to open it leaves the response on the torrent, which can still
115
+ * Any failure to acquire it leaves the response on the torrent, which can still
115
116
  * answer; a file the disk owner removed is also forgotten here, as the route
116
117
  * does before headers.
117
118
  *
118
- * @param {{ wholeFiles?: Map<string, { path: string, length: number, name: string }> }} torrentPool
119
+ * @param {{ wholeFiles?: Map<string, { path: string, length: number, name: string }>, acquireWholeFile: (source: { sourceKey: string, fileIndex: number }) => Promise<{ length: number, stream: (start: number, end: number, onRead: (length: number) => void) => import("node:stream").Readable | null, release: () => Promise<unknown> } | null> }} torrentPool
119
120
  * @param {string} sourceKey
120
121
  * @param {number} fileIndex
121
122
  * @param {number} length - The length the response was promised for.
122
- * @returns {Promise<import("node:fs/promises").FileHandle | null>}
123
+ * @returns {Promise<{ length: number, stream: (start: number, end: number, onRead: (length: number) => void) => import("node:stream").Readable | null, release: () => Promise<unknown> } | null>}
123
124
  */
124
- async function openWholeFileToContinue(torrentPool, sourceKey, fileIndex, length) {
125
+ async function acquireWholeFileRead(torrentPool, sourceKey, fileIndex, length) {
125
126
  const whole = wholeFileFor(torrentPool, sourceKey, fileIndex);
126
127
  if (!whole || whole.length !== length) {
127
128
  return null;
128
129
  }
129
130
  try {
130
- return await open(whole.path, "r");
131
+ const read = await torrentPool.acquireWholeFile({ sourceKey, fileIndex });
132
+ if (!read) {
133
+ forgetWholeFile(torrentPool, sourceKey, fileIndex, whole);
134
+ return null;
135
+ }
136
+ if (read.length !== length) {
137
+ await read.release();
138
+ forgetWholeFile(torrentPool, sourceKey, fileIndex, whole);
139
+ return null;
140
+ }
141
+ return read;
131
142
  } catch (error) {
132
143
  if (error.code === "ENOENT") {
133
- const key = `${sourceKey.slice("torrent:".length).toLowerCase()}/${fileIndex}`;
134
- if (torrentPool.wholeFiles.get(key) === whole) torrentPool.wholeFiles.delete(key);
144
+ forgetWholeFile(torrentPool, sourceKey, fileIndex, whole);
135
145
  }
136
146
  return null;
137
147
  }
138
148
  }
139
149
 
150
+ function forgetWholeFile(torrentPool, sourceKey, fileIndex, whole) {
151
+ const key = `${sourceKey.slice("torrent:".length).toLowerCase()}/${fileIndex}`;
152
+ if (torrentPool.wholeFiles?.get(key) === whole) torrentPool.wholeFiles.delete(key);
153
+ }
154
+
140
155
  /**
141
156
  * Write bytes `from`..`end` of an open file to a response, each write waited
142
157
  * for, and close the file.
143
158
  *
144
159
  * @param {import("node:http").ServerResponse} raw
145
- * @param {import("node:fs/promises").FileHandle} handle
160
+ * @param {{ stream: (start: number, end: number, onRead: (length: number) => void) => import("node:stream").Readable | null, release: () => Promise<unknown> }} read
146
161
  * @param {number} from - Inclusive.
147
162
  * @param {number} end - Inclusive.
148
163
  * @param {(length: number) => void} onWritten
149
164
  * @returns {Promise<number>} Bytes written.
150
165
  */
151
- async function writeFromWholeFile(raw, handle, from, end, onWritten) {
166
+ async function writeFromWholeFile(raw, read, from, end, onWritten) {
152
167
  let written = 0;
168
+ let stream = null;
153
169
  try {
154
- for await (const chunk of handle.createReadStream({ start: from, end, autoClose: false })) {
170
+ stream = read.stream(from, end, () => {});
171
+ if (!stream) throw new Error("Completed file did not provide the admitted range.");
172
+ for await (const chunk of stream) {
155
173
  if (raw.writableEnded || raw.destroyed) {
156
174
  break;
157
175
  }
@@ -162,7 +180,8 @@ async function writeFromWholeFile(raw, handle, from, end, onWritten) {
162
180
  onWritten(chunk.length);
163
181
  }
164
182
  } finally {
165
- await handle.close().catch(() => undefined);
183
+ stream?.destroy();
184
+ await read.release();
166
185
  }
167
186
  return written;
168
187
  }
@@ -173,35 +192,41 @@ async function writeFromWholeFile(raw, handle, from, end, onWritten) {
173
192
  * @param {import("fastify").FastifyRequest} req
174
193
  * @param {import("fastify").FastifyReply} reply
175
194
  * @param {{ path: string, length: number, name: string }} file
176
- * @param {import("node:fs/promises").FileHandle} handle - Already open; closed for HEAD.
195
+ * @param {{ stream: (start: number, end: number, onRead: (length: number) => void) => import("node:stream").Readable | null, release: () => Promise<unknown> }} read
177
196
  * @returns {Promise<void> | void}
178
197
  */
179
- function serveWholeFile(req, reply, file, handle) {
198
+ function serveWholeFile(req, reply, file, read) {
180
199
  const disposition = `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`;
181
- if (req.method === "HEAD") {
182
- reply.hijack();
183
- reply.raw.writeHead(200, {
184
- "Accept-Ranges": "bytes",
185
- "Content-Type": "application/octet-stream",
186
- "Content-Length": String(file.length),
187
- "Content-Disposition": disposition
188
- });
189
- reply.raw.end();
190
- return;
191
- }
192
200
  reply.header("Accept-Ranges", "bytes");
193
201
  reply.header("Content-Type", "application/octet-stream");
194
202
  reply.header("Content-Disposition", disposition);
195
203
  const range = parseRange(req.headers.range, file.length);
204
+ if (file.length === 0) {
205
+ reply.header("Content-Length", "0");
206
+ void read.release().catch(() => undefined);
207
+ return reply.send(Readable.from([]));
208
+ }
209
+ const start = range?.start ?? 0;
210
+ const end = range?.end ?? file.length - 1;
211
+ const stream = read.stream(start, end, () => {});
212
+ if (!stream) {
213
+ void read.release().catch(() => undefined);
214
+ return null;
215
+ }
196
216
  if (!range) {
197
217
  reply.header("Content-Length", String(file.length));
198
- return reply.send(handle.createReadStream());
218
+ } else {
219
+ reply.code(206);
220
+ reply.header("Content-Length", String(end - start + 1));
221
+ reply.header("Content-Range", `bytes ${start}-${end}/${file.length}`);
222
+ }
223
+ void read.release().catch(() => undefined);
224
+ try {
225
+ return reply.send(stream);
226
+ } catch (error) {
227
+ stream.destroy();
228
+ throw error;
199
229
  }
200
- const { start, end } = range;
201
- reply.code(206);
202
- reply.header("Content-Length", String(end - start + 1));
203
- reply.header("Content-Range", `bytes ${start}-${end}/${file.length}`);
204
- return reply.send(handle.createReadStream({ start, end }));
205
230
  }
206
231
 
207
232
  export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool, noteInputBytes = null, noteInputWaiting = null }) {
@@ -224,23 +249,36 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
224
249
  // nothing the torrent would have told us.
225
250
  const whole = wholeFileFor(torrentPool, sourceKey, fileIndex);
226
251
  if (whole) {
227
- let handle;
228
- try {
229
- // Open before committing headers: the disk owner may have removed an
230
- // announced file. An open descriptor also preserves a live POSIX read.
231
- handle = await open(whole.path, "r");
232
- } catch (error) {
233
- if (error.code !== "ENOENT") throw error;
234
- const key = `${sourceKey.slice("torrent:".length).toLowerCase()}/${fileIndex}`;
235
- if (torrentPool.wholeFiles.get(key) === whole) torrentPool.wholeFiles.delete(key);
236
- }
237
- if (handle) {
238
- if (req.method === "HEAD") await handle.close();
252
+ if (req.method === "HEAD") {
253
+ let handle;
239
254
  try {
240
- return serveWholeFile(req, reply, whole, handle);
255
+ handle = await open(whole.path, "r");
241
256
  } catch (error) {
242
- await handle.close().catch(() => undefined);
243
- throw error;
257
+ if (error.code !== "ENOENT") throw error;
258
+ forgetWholeFile(torrentPool, sourceKey, fileIndex, whole);
259
+ }
260
+ if (handle) {
261
+ await handle.close();
262
+ reply.hijack();
263
+ reply.raw.writeHead(200, {
264
+ "Accept-Ranges": "bytes",
265
+ "Content-Type": "application/octet-stream",
266
+ "Content-Length": String(whole.length),
267
+ "Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(whole.name)}`
268
+ });
269
+ reply.raw.end();
270
+ return;
271
+ }
272
+ } else {
273
+ const read = await acquireWholeFileRead(torrentPool, sourceKey, fileIndex, whole.length);
274
+ if (read) {
275
+ try {
276
+ const sent = serveWholeFile(req, reply, whole, read);
277
+ if (sent) return sent;
278
+ } catch (error) {
279
+ await read.release().catch(() => undefined);
280
+ throw error;
281
+ }
244
282
  }
245
283
  }
246
284
  }
@@ -338,7 +376,7 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
338
376
  fragment.release();
339
377
  break;
340
378
  }
341
- continueFrom = await openWholeFileToContinue(torrentPool, sourceKey, fileIndex, file.length);
379
+ continueFrom = await acquireWholeFileRead(torrentPool, sourceKey, fileIndex, file.length);
342
380
  if (continueFrom) {
343
381
  fragment.release();
344
382
  break;
@@ -368,7 +406,7 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
368
406
  }
369
407
  } catch (error) {
370
408
  if (sent >= contentLength || req.raw.aborted || reply.raw.destroyed) throw error;
371
- continueFrom = await openWholeFileToContinue(torrentPool, sourceKey, fileIndex, file.length);
409
+ continueFrom = await acquireWholeFileRead(torrentPool, sourceKey, fileIndex, file.length);
372
410
  if (!continueFrom) throw error;
373
411
  }
374
412
  if (continueFrom) {
@@ -54,8 +54,13 @@ export class CompletedFiles {
54
54
 
55
55
  /** Assemblies in flight, so two passes cannot write one file at once. @type {Set<string>} */
56
56
  #writing = new Set();
57
+ /** Bytes promised to assemblies that have not yet become held files. @type {Map<string, number>} */
58
+ #reservations = new Map();
59
+ /** Serialize allowance changes and admission decisions. @type {Promise<unknown>} */
60
+ #admission = Promise.resolve();
57
61
  #readers = new Map();
58
62
  #removeAfterRead = new Set();
63
+ #removing = new Set();
59
64
 
60
65
  /**
61
66
  * What this store may hold, handed to it by the owner of the disk.
@@ -76,6 +81,8 @@ export class CompletedFiles {
76
81
 
77
82
  /** When each held file was last asked for. @type {Map<string, number>} */
78
83
  #lastRead = new Map();
84
+ /** Monotonic access order, so reads in the same clock tick remain ordered. */
85
+ #accessOrder = 0;
79
86
 
80
87
  /**
81
88
  * @param {object} params
@@ -142,17 +149,22 @@ export class CompletedFiles {
142
149
  */
143
150
  find(infoHash, fileIndex) {
144
151
  const key = this.#keyOf(infoHash, fileIndex);
145
- if (this.#removeAfterRead.has(key)) return null;
152
+ if (this.#removeAfterRead.has(key) || this.#removing.has(key)) return null;
146
153
  const file = this.#held.get(key) ?? null;
147
154
  if (file) {
148
155
  // WHEN IT WAS LAST WANTED, which is the order it leaves in. A whole file
149
156
  // nobody has asked for in the longest is the one whose loss costs least:
150
157
  // it can be downloaded again, and a read of it falls back to the pieces.
151
- this.#lastRead.set(key, Date.now());
158
+ this.#touch(key);
152
159
  }
153
160
  return file;
154
161
  }
155
162
 
163
+ /** Mark a file as more recently wanted than every earlier access. */
164
+ #touch(key) {
165
+ this.#lastRead.set(key, ++this.#accessOrder);
166
+ }
167
+
156
168
  /** Pin a completed file against eviction until all its offset reads finish. */
157
169
  acquire(infoHash, fileIndex) {
158
170
  const key = this.#keyOf(infoHash, fileIndex);
@@ -186,20 +198,38 @@ export class CompletedFiles {
186
198
  * many files went.
187
199
  */
188
200
  async allow(bytes) {
189
- this.#allowanceBytes = Number.isFinite(bytes) && bytes >= 0 ? bytes : null;
190
- let removed = 0;
191
- if (this.#allowanceBytes === null) {
201
+ return this.#withAdmission(async () => {
202
+ this.#allowanceBytes = Number.isFinite(bytes) && bytes >= 0 ? bytes : null;
203
+ const removed = await this.#trimToAllowance();
192
204
  return { bytes: this.bytes, removed };
193
- }
194
- while (this.bytes > this.#allowanceBytes) {
205
+ });
206
+ }
207
+
208
+ /** Run one storage decision at a time. @param {() => Promise<unknown> | unknown} action */
209
+ #withAdmission(action) {
210
+ const result = this.#admission.then(action, action);
211
+ this.#admission = result.catch(() => undefined);
212
+ return result;
213
+ }
214
+
215
+ /** Include bytes currently being assembled when enforcing the allowance. */
216
+ #reservedBytes() {
217
+ let total = 0;
218
+ for (const length of this.#reservations.values()) total += length;
219
+ return total;
220
+ }
221
+
222
+ /** Remove eligible unused files until held and reserved bytes fit. */
223
+ async #trimToAllowance() {
224
+ let removed = 0;
225
+ if (this.#allowanceBytes === null) return removed;
226
+ while (this.bytes + this.#reservedBytes() > this.#allowanceBytes) {
195
227
  const victim = this.#longestUnread();
196
- if (!victim) {
197
- break;
198
- }
199
- await this.#remove(victim);
228
+ if (!victim) break;
229
+ if (!await this.#remove(victim)) break;
200
230
  removed += 1;
201
231
  }
202
- return { bytes: this.bytes, removed };
232
+ return removed;
203
233
  }
204
234
 
205
235
  /**
@@ -215,7 +245,7 @@ export class CompletedFiles {
215
245
  if (this.#allowanceBytes === null) {
216
246
  return true;
217
247
  }
218
- return this.bytes + Math.max(0, Number(length) || 0) <= this.#allowanceBytes;
248
+ return this.bytes + this.#reservedBytes() + Math.max(0, Number(length) || 0) <= this.#allowanceBytes;
219
249
  }
220
250
 
221
251
  /**
@@ -227,7 +257,7 @@ export class CompletedFiles {
227
257
  let oldest = null;
228
258
  let oldestAt = Number.POSITIVE_INFINITY;
229
259
  for (const key of this.#held.keys()) {
230
- if (this.#writing.has(key) || this.#readers.has(key)) {
260
+ if (this.#writing.has(key) || this.#readers.has(key) || this.#removing.has(key)) {
231
261
  continue;
232
262
  }
233
263
  const at = this.#lastRead.get(key) ?? 0;
@@ -243,17 +273,24 @@ export class CompletedFiles {
243
273
  * Take one whole file off the disk.
244
274
  *
245
275
  * @param {string} key
246
- * @returns {Promise<void>}
276
+ * @returns {Promise<boolean>} Whether the file was removed.
247
277
  */
248
278
  async #remove(key) {
249
- if (this.#readers.has(key)) { this.#removeAfterRead.add(key); return; }
279
+ if (this.#removing.has(key)) return false;
280
+ if (this.#readers.has(key)) { this.#removeAfterRead.add(key); return false; }
250
281
  const file = this.#held.get(key);
282
+ if (!file) return true;
283
+ this.#removing.add(key);
284
+ try {
285
+ await fs.rm(file.path, REMOVAL);
286
+ } catch {
287
+ this.#removing.delete(key);
288
+ return false;
289
+ }
251
290
  this.#held.delete(key);
252
291
  this.#lastRead.delete(key);
253
- if (!file) {
254
- return;
255
- }
256
- await fs.rm(file.path, REMOVAL).catch(() => undefined);
292
+ this.#removing.delete(key);
293
+ return true;
257
294
  }
258
295
 
259
296
  /**
@@ -344,18 +381,30 @@ export class CompletedFiles {
344
381
  async keep({ infoHash, fileIndex, length, name, open }) {
345
382
  const key = this.#keyOf(infoHash, fileIndex);
346
383
  const held = this.#held.get(key);
347
- if (held) {
384
+ if (held && !this.#removing.has(key)) {
348
385
  return held;
349
386
  }
387
+ if (this.#removing.has(key)) return null;
350
388
  if (this.#writing.has(key)) {
351
389
  return null;
352
390
  }
353
- if (!this.hasRoomFor(length)) {
354
- // Assembling it and then removing it is the same bytes written for
355
- // nothing. The pieces still serve the read; this file simply is not kept.
391
+ this.#writing.add(key);
392
+ const admitted = await this.#withAdmission(async () => {
393
+ if (this.#held.has(key)) return false;
394
+ if (this.#allowanceBytes !== null && length > this.#allowanceBytes) return false;
395
+ while (this.#allowanceBytes !== null &&
396
+ this.bytes + this.#reservedBytes() + length > this.#allowanceBytes) {
397
+ const victim = this.#longestUnread();
398
+ if (!victim) return false;
399
+ if (!await this.#remove(victim)) return false;
400
+ }
401
+ this.#reservations.set(key, length);
402
+ return true;
403
+ });
404
+ if (!admitted) {
405
+ await this.#withAdmission(() => { this.#writing.delete(key); });
356
406
  return null;
357
407
  }
358
- this.#writing.add(key);
359
408
  const directory = path.join(this.#root, String(infoHash));
360
409
  const where = path.join(directory, String(fileIndex));
361
410
  const partial = `${where}.partial`;
@@ -367,6 +416,9 @@ export class CompletedFiles {
367
416
  let written = 0;
368
417
  try {
369
418
  for await (const chunk of source) {
419
+ if (written + chunk.length > length) {
420
+ throw new Error("Whole-file read exceeded its declared length");
421
+ }
370
422
  await handle.write(chunk);
371
423
  written += chunk.length;
372
424
  }
@@ -382,14 +434,24 @@ export class CompletedFiles {
382
434
  }
383
435
  await fs.rename(partial, where);
384
436
  const file = { path: where, length, name: String(name ?? fileIndex) };
385
- this.#held.set(key, file);
437
+ await this.#withAdmission(async () => {
438
+ this.#reservations.delete(key);
439
+ this.#held.set(key, file);
440
+ this.#touch(key);
441
+ this.#writing.delete(key);
442
+ await this.#trimToAllowance();
443
+ });
386
444
  await this.#writeManifest(infoHash);
387
- return file;
445
+ return this.#held.get(key) ?? null;
388
446
  } catch {
389
447
  await fs.rm(partial, REMOVAL).catch(() => undefined);
390
448
  return null;
391
449
  } finally {
392
- this.#writing.delete(key);
450
+ await this.#withAdmission(async () => {
451
+ this.#reservations.delete(key);
452
+ this.#writing.delete(key);
453
+ await this.#trimToAllowance();
454
+ });
393
455
  }
394
456
  }
395
457