@torrent-tv/proxy 2.9.74 → 2.9.75

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.
@@ -26,11 +26,56 @@
26
26
  * and nothing here disagrees.
27
27
  */
28
28
 
29
+ import os from "node:os";
29
30
  import { PieceLru } from "./piece-lru.js";
30
31
  import { DiskTier } from "./disk-tier.js";
31
32
 
32
- /** Fallback budget when the caller names none: enough for a comfortable window. */
33
- const DEFAULT_MEMORY_BYTES = 512 * 1024 * 1024;
33
+ /**
34
+ * Live stores, so the worker can report on them.
35
+ *
36
+ * WebTorrent constructs the store itself, deep inside its own wrappers, so
37
+ * there is no handle to reach for from outside. Registering here is what makes
38
+ * the store's behaviour visible in the field at all — without it the first
39
+ * strange case has nothing to go on.
40
+ *
41
+ * @type {Set<SharedPieceStore>}
42
+ */
43
+ const liveStores = new Set();
44
+
45
+ /**
46
+ * A snapshot of every live store, for logging.
47
+ *
48
+ * @returns {{ name: string, resident: number, capacity: number, spilled: number, fromMemory: number, fromDisk: number, spills: number, revivals: number, blockedByPins: number }[]}
49
+ */
50
+ export function collectStoreStats() {
51
+ return [...liveStores].map((store) => store.stats());
52
+ }
53
+
54
+ /**
55
+ * Ceiling for the automatic budget, and the share of free memory it will take.
56
+ *
57
+ * A flat default would be a guess dressed as a decision: the proxy runs on
58
+ * whatever the owner has, from a Pi to a rented box, and the budget is **per
59
+ * torrent** — several viewers mean several of these. Measured on the field host
60
+ * after one session: the proxy container sat at 796 MB with 4.1 GB free and 1.3
61
+ * GB already in swap, so a fixed half-gigabyte per torrent is not something to
62
+ * hand out blindly. Hence: a quarter of what is free, capped.
63
+ */
64
+ const MEMORY_BUDGET_CEILING_BYTES = 512 * 1024 * 1024;
65
+ const FREE_MEMORY_SHARE = 0.25;
66
+
67
+ /**
68
+ * Budget for one torrent's resident pieces when the caller names none.
69
+ *
70
+ * @returns {number}
71
+ */
72
+ function defaultMemoryBytes() {
73
+ const share = Math.floor(os.freemem() * FREE_MEMORY_SHARE);
74
+ return Math.max(MIN_BUDGET_BYTES, Math.min(MEMORY_BUDGET_CEILING_BYTES, share));
75
+ }
76
+
77
+ /** Floor for the automatic budget — below this the store thrashes to disk. */
78
+ const MIN_BUDGET_BYTES = 64 * 1024 * 1024;
34
79
  /**
35
80
  * Never keep fewer than this many pieces resident, whatever the budget says.
36
81
  *
@@ -63,7 +108,22 @@ export class SharedPieceStore {
63
108
  #freeSlots = [];
64
109
  #lru;
65
110
  #disk;
111
+ /** Slots backed by memory right now; grows towards {@link capacity}. */
112
+ #allocatedSlots = 0;
66
113
  #closed = false;
114
+ #name;
115
+ /**
116
+ * What the store has actually been doing. Reported, not just kept: the
117
+ * balance between memory and disk reads is the number that says whether the
118
+ * budget is right, and it cannot be guessed from outside.
119
+ */
120
+ #counters = {
121
+ fromMemory: 0,
122
+ fromDisk: 0,
123
+ spills: 0,
124
+ revivals: 0,
125
+ blockedByPins: 0
126
+ };
67
127
 
68
128
  /**
69
129
  * @param {number} chunkLength - Piece length, and therefore the slot size.
@@ -86,20 +146,44 @@ export class SharedPieceStore {
86
146
 
87
147
  const memoryBytes = Number.isFinite(options.memoryBytes) && options.memoryBytes > 0
88
148
  ? options.memoryBytes
89
- : DEFAULT_MEMORY_BYTES;
149
+ : defaultMemoryBytes();
90
150
  this.#capacity = Math.max(MIN_RESIDENT_PIECES, Math.floor(memoryBytes / chunkLength));
91
151
 
92
- this.#shared = new SharedArrayBuffer(this.#capacity * chunkLength);
152
+ // Grows into the budget instead of taking it up front. The budget is per
153
+ // torrent, so claiming all of it on `add` would charge a host for pieces
154
+ // nobody has asked for — and a torrent that is merely open, or one being
155
+ // probed for its codecs, needs a handful of slots, not the ceiling.
156
+ this.#shared = new SharedArrayBuffer(MIN_RESIDENT_PIECES * chunkLength, {
157
+ maxByteLength: this.#capacity * chunkLength
158
+ });
93
159
  this.#pool = Buffer.from(this.#shared);
94
- for (let slot = 0; slot < this.#capacity; slot += 1) {
160
+ for (let slot = 0; slot < MIN_RESIDENT_PIECES; slot += 1) {
95
161
  this.#freeSlots.push(slot);
96
162
  }
163
+ this.#allocatedSlots = MIN_RESIDENT_PIECES;
97
164
  this.#lru = new PieceLru(this.#capacity);
165
+ this.#name = options.name ?? "pieces";
98
166
  this.#disk = new DiskTier({
99
167
  directory: options.path ?? ".",
100
- name: `${options.name ?? "pieces"}.pieces`,
168
+ name: `${this.#name}.pieces`,
101
169
  chunkLength
102
170
  });
171
+ liveStores.add(this);
172
+ }
173
+
174
+ /**
175
+ * What this store has been doing, for the periodic report.
176
+ *
177
+ * @returns {{ name: string, resident: number, capacity: number, spilled: number, fromMemory: number, fromDisk: number, spills: number, revivals: number, blockedByPins: number }}
178
+ */
179
+ stats() {
180
+ return {
181
+ name: this.#name,
182
+ resident: this.#slotOf.size,
183
+ capacity: this.#capacity,
184
+ spilled: this.#disk.size,
185
+ ...this.#counters
186
+ };
103
187
  }
104
188
 
105
189
  /** `abstract-chunk-store` exposes the piece size under this name. */
@@ -189,16 +273,28 @@ export class SharedPieceStore {
189
273
  return free;
190
274
  }
191
275
 
276
+ // Room left in the budget: take more memory rather than evicting. Growing
277
+ // replaces the view over the pool, so every slot offset stays valid — the
278
+ // bytes do not move.
279
+ if (this.#allocatedSlots < this.#capacity) {
280
+ this.#allocatedSlots += 1;
281
+ this.#shared.grow(this.#allocatedSlots * this.#chunkLength);
282
+ this.#pool = Buffer.from(this.#shared);
283
+ return this.#allocatedSlots - 1;
284
+ }
285
+
192
286
  const victim = this.#lru.evictionCandidate();
193
287
  if (victim === null) {
194
288
  // Every resident piece is being read. Taking one anyway is precisely the
195
289
  // failure this store exists to make impossible.
290
+ this.#counters.blockedByPins += 1;
196
291
  throw new Error("Every resident piece is pinned; no slot can be freed.");
197
292
  }
198
293
 
199
294
  const slot = this.#slotOf.get(victim);
200
295
  const bytes = this.#pool.subarray(slot * this.#chunkLength, slot * this.#chunkLength + this.#lengthOf(victim));
201
296
  await this.#disk.write(victim, bytes);
297
+ this.#counters.spills += 1;
202
298
 
203
299
  this.#slotOf.delete(victim);
204
300
  this.#lru.remove(victim);
@@ -265,6 +361,7 @@ export class SharedPieceStore {
265
361
  const slot = this.#slotOf.get(index);
266
362
  if (slot !== undefined) {
267
363
  this.#lru.touch(index);
364
+ this.#counters.fromMemory += 1;
268
365
  const start = slot * this.#chunkLength + offset;
269
366
  return Buffer.from(this.#pool.subarray(start, start + length));
270
367
  }
@@ -283,6 +380,8 @@ export class SharedPieceStore {
283
380
  await this.#disk.read(index, target);
284
381
  this.#slotOf.set(index, revived);
285
382
  this.#lru.touch(index);
383
+ this.#counters.fromDisk += 1;
384
+ this.#counters.revivals += 1;
286
385
  const start = revived * this.#chunkLength + offset;
287
386
  return Buffer.from(this.#pool.subarray(start, start + length));
288
387
  };
@@ -298,6 +397,7 @@ export class SharedPieceStore {
298
397
  */
299
398
  close(callback = () => undefined) {
300
399
  this.#closed = true;
400
+ liveStores.delete(this);
301
401
  this.#disk.close().then(() => callback(null), (error) => callback(error));
302
402
  }
303
403
 
@@ -309,6 +409,7 @@ export class SharedPieceStore {
309
409
  */
310
410
  destroy(callback = () => undefined) {
311
411
  this.#closed = true;
412
+ liveStores.delete(this);
312
413
  this.#slotOf.clear();
313
414
  this.#disk.destroy().then(() => callback(null), (error) => callback(error));
314
415
  }