@torrent-tv/proxy 2.101.21 → 2.101.23

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.21",
3
+ "version": "2.101.23",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -219,7 +219,7 @@ export class OutputLifecycle {
219
219
  const maxBytes = this.#host.machineBudget.segmentBytes();
220
220
  const inventory = this.#host.segmentStore.inventory();
221
221
  let held = inventory.reduce((bytes, output) => bytes + output.bytes, 0);
222
- if (!Number.isFinite(maxBytes) || maxBytes <= 0 || held <= maxBytes) return;
222
+ if (!Number.isFinite(maxBytes) || maxBytes < 0 || held <= maxBytes) return;
223
223
  const candidates = leastNeededSegments(inventory.map(output => ({
224
224
  ...output,
225
225
  positions: this.#host.viewerSegmentsOn(output.key),
@@ -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
 
@@ -56,9 +56,9 @@ import { availableMemoryBytes } from "./machine-memory.js";
56
56
  * @param {import("./MachineBudget.js").BudgetPolicy} [params.policy] - What the
57
57
  * operator has said this proxy may take. Absent, the measured default.
58
58
  * @param {{ info: Function, warn?: Function }} [params.logger]
59
- * @returns {{ revise: () => Promise<unknown>, segmentBytes: () => number, diagnostics: object | null, describe: () => string }}
60
- * What the segments may hold is asked for rather than pushed: zero until the
61
- * first revision, and zero stops growth rather than licensing it.
59
+ * @returns {{ revise: () => Promise<unknown>, segmentBytes: () => number | null, diagnostics: object | null, describe: () => string }}
60
+ * What the segments may hold is asked for rather than pushed: null until the
61
+ * first revision, then the measured allowance, including a measured zero.
62
62
  */
63
63
  export function wireMachineBudget({
64
64
  segmentStore,
@@ -101,7 +101,7 @@ export function wireMachineBudget({
101
101
  const segmentsOn = deviceOf(segmentStore.root);
102
102
  defineDisk(segmentsOn, segmentStore.root);
103
103
 
104
- let segmentBytes = 0;
104
+ let segmentBytes = null;
105
105
  budget.register({
106
106
  name: "segments",
107
107
  resource: segmentsOn,