@torrent-tv/proxy 2.69.1 → 2.70.0

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 CHANGED
@@ -1,3 +1,24 @@
1
+ ## 2.70.0
2
+
3
+ - **New**: The piece store keeps a pool of memory blocks instead of allocating one per piece. A block is one piece's worth of memory; it is taken from the free list, and put back there when its piece is written out. Field 2026-09-02: 7575 allocations of 4 MiB in 44 minutes, each released only when the collector reached it, which is why the process held 1.86 GB while the store's own accounting said 352 MB. A block goes back for re-use only AFTER the spill write has finished, because that write reads out of it; a block whose piece is re-put while a reader holds it is given up rather than recycled, and a counter says if one ever is not.
4
+ - **Fix**: The store's allowance could only ever fall. `#capacity` was computed once in the constructor and used as an upper bound on every revision, so a torrent opened while the machine was full kept a small allowance for its whole life however much memory was freed afterwards. The field is gone; the opening figure is now only where the store starts.
5
+ - **Fix**: `AVAILABLE_MEMORY_SHARE` (a quarter), `MIN_BUDGET_BYTES` (64 MB) and `MEMORY_BUDGET_CEILING_BYTES` (512 MB) are removed. All three trace to one observation of one host on 2026-08-03 and none was derived. The budget is now the smaller of what the readers have declared — the union of their windows, since picture and sound overlap — and what the machine allows, which is `MemAvailable` plus what the stores already hold, less what other processes have recently been seen to need. That last figure starts at zero and grows only on evidence: the fall in available memory between two readings, beyond what the stores themselves took.
6
+ - **Fix**: A piece no reader has declared, arriving at a store with no room, goes straight to disk instead of pushing out a piece a reader is about to read. It costs the same one write it would have cost when the next arrival evicted it. Before the first read nothing is declared and nothing is refused memory on a guess.
7
+ - **Fix**: Evicting a piece the disk already holds costs no second write. `#revive` reads a piece back and leaves the copy on disk, and only `put` removes it, so a piece revived and not re-put is identical to what is already written. There were 7575 revivals in that one session (roadmap item 66: 14.4 GB written in a single viewing).
8
+ - **Fix**: A store whose readers have GONE asks for nothing, where before it kept asking for what it held. Its torrent sits until the pool's idle timer removes it, and that timer needs a refcount of zero and can be a quarter of an hour away. A store that has never had a reader is a different case and keeps its opening share.
9
+ - **Fix**: A store is never cut below one reader's whole window, whatever the machine's share says. Obeying a smaller share would leave it unable to finish the read it is serving: every resident piece pinned, zero bytes returned, ffmpeg taking that for the end of the file — which killed every encoder on that file on 2026-08-15. The line says when the share was smaller than the window.
10
+ - **Chore**: What other processes need is a window of the last sixty observations, not a high-water. A single spike would otherwise squeeze the stores for the life of the process, which is the same mistake an all-time maximum makes of the block re-use gap.
11
+ - **New**: A spare block is given up once it has sat unused longer than the store's own working rhythm — the longest wait, over recent work, between a block falling free and being wanted again. Measured, not chosen: while a film is being watched a block is taken again within milliseconds, because one is taken for every piece that arrives.
12
+ - **New**: The store line says how many blocks of memory exist and how many are spare, how long a block waits before it is wanted again, how many were given back, how many pieces were admitted that were in nobody's window, and how many evictions needed no write. `committed` now means what the process holds — the blocks — rather than the pieces in them.
13
+ - **Chore**: Thirteen checks across `test/piece-lru.test.js`, `test/piece-store-eviction.test.js` and `test/memory-budget.test.js`, including that a re-used block never carries the previous piece's bytes into the next one.
14
+
15
+ ## 2.69.2
16
+
17
+ - **New**: The piece store says WHY it spills, which no reading has ever answered. A session on 2026-09-02 did 6565 spills and 7575 revivals in 44 minutes with only 53.6 % of reads served from memory, and nothing recorded whether that was an eviction order fighting the read order or a working set that simply does not fit. Three figures now settle it, on one line per store: what the live readers between them are asking to keep against what the store may hold; how many evictions had to take a piece a reader had declared it wants; and how long a revived piece had been on disk before it was wanted back.
18
+ - **Chore**: The demand is the UNION of the readers' windows, not their sum. Two readers of one file — picture and sound — overlap by construction, and summing them would report the store as short when it is not. A union wider than the capacity cannot be held however the eviction is ordered, which is the difference between a policy to fix and arithmetic to accept.
19
+ - **Chore**: `PieceLru.evictionChoice()` returns the victim with the two facts about it — whether protection had to yield, and how many pieces the victim lies from the nearest declared window, zero inside one and -1 when nothing is declared. `evictionCandidate()` is kept and delegates, so nothing else moved.
20
+ - **Chore**: A revived piece's age is kept as a bounded window of the last 200, because the figure wanted is a median rather than a history. The line also says how many of those came back within five seconds — a piece wanted again that soon should not have left. Seven checks in `test/piece-lru.test.js` and `test/piece-store-eviction.test.js`.
21
+
1
22
  ## 2.69.1
2
23
 
3
24
  - **Fix**: The change trigger on the torrent worker's memory line watched the one figure that does not move. A thread's watched quantity was `heapTotal`, and through the session of 2026-09-02 that stood at 31-173 MB while the same isolate's `arrayBuffers` swung between 130 and 950 MB — so nothing ever earned a line and every reading of the quantity that grew came out on the quiet minute. Each of `heapTotal`, `external` and `arrayBuffers` is now compared against its own last written value and any one of them moving writes the line. Nothing is summed: `arrayBuffers` is documented as part of `external` and reads larger than it here, and this code has no business resolving that.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.69.1",
3
+ "version": "2.70.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -174,11 +174,31 @@ export class PieceLru {
174
174
  * @returns {number | null}
175
175
  */
176
176
  evictionCandidate() {
177
+ return this.evictionChoice().index;
178
+ }
179
+
180
+ /**
181
+ * The same choice, with the two facts that say whether the store is working
182
+ * or thrashing: whether protection had to yield, and how far the victim was
183
+ * from the nearest piece a reader declared it wants.
184
+ *
185
+ * Evicting a stale piece nobody asked for is the store doing its job.
186
+ * Evicting a piece inside a reader's own declared window is the store being
187
+ * asked to hold more than it has room for, and it comes back from disk
188
+ * moments later — 6565 spills and 7575 revivals in 44 minutes on
189
+ * 2026-09-02, with only 53.6% of reads served from memory. Nothing recorded
190
+ * which of the two was happening (roadmap item 9).
191
+ *
192
+ * @returns {{ index: number | null, protectionYielded: boolean, distance: number }}
193
+ * `distance` is in pieces from the nearest declared window, zero when the
194
+ * victim is inside one, and -1 when no reader has declared anything.
195
+ */
196
+ evictionChoice() {
177
197
  // First choice: the least recently used piece nobody is reading and nobody
178
198
  // is about to read.
179
199
  for (const index of this.#order) {
180
200
  if (!this.#pins.has(index) && !this.#isProtected(index)) {
181
- return index;
201
+ return { index, protectionYielded: false, distance: this.#distanceToWindow(index) };
182
202
  }
183
203
  }
184
204
  // Nothing spare left. Protection yields — it is a preference, and refusing
@@ -186,10 +206,96 @@ export class PieceLru {
186
206
  // yield: a piece being read now cannot have its memory taken away.
187
207
  for (const index of this.#order) {
188
208
  if (!this.#pins.has(index)) {
189
- return index;
209
+ return { index, protectionYielded: true, distance: this.#distanceToWindow(index) };
210
+ }
211
+ }
212
+ return { index: null, protectionYielded: false, distance: -1 };
213
+ }
214
+
215
+ /**
216
+ * How many pieces the live readers between them are asking to keep, against
217
+ * how many this store may hold.
218
+ *
219
+ * The union, not the sum: two readers of one file overlap, and counting the
220
+ * overlap twice would say the store is short when it is not. This is the
221
+ * comparison that decides whether thrashing is a policy fault or arithmetic —
222
+ * a union wider than the capacity cannot be held however the eviction is
223
+ * ordered.
224
+ *
225
+ * @returns {{ readers: number, unionPieces: number, widestPieces: number, capacity: number }}
226
+ */
227
+ demand() {
228
+ const ranges = [...this.#protected.values()]
229
+ .map((range) => ({ from: range.from, to: range.to }))
230
+ .sort((left, right) => left.from - right.from);
231
+ let unionPieces = 0;
232
+ let widestPieces = 0;
233
+ let coveredTo = -Infinity;
234
+ for (const range of ranges) {
235
+ const width = range.to - range.from + 1;
236
+ widestPieces = Math.max(widestPieces, width);
237
+ const from = Math.max(range.from, coveredTo + 1);
238
+ if (range.to >= from) {
239
+ unionPieces += range.to - from + 1;
240
+ coveredTo = range.to;
241
+ }
242
+ }
243
+ return {
244
+ readers: ranges.length,
245
+ unionPieces,
246
+ widestPieces,
247
+ capacity: this.#capacity
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Whether a reader is holding this piece right now.
253
+ *
254
+ * Asked before a block is put back for re-use: a pinned piece has a view onto
255
+ * its memory somewhere, and handing that memory to another piece would let
256
+ * the holder read bytes that are not its own.
257
+ *
258
+ * @param {number} index
259
+ * @returns {boolean}
260
+ */
261
+ isPinned(index) {
262
+ return this.#pins.has(index);
263
+ }
264
+
265
+ /**
266
+ * Whether any live reader has declared it will want this piece.
267
+ *
268
+ * Asked on admission, not only on eviction: a piece nobody has declared is
269
+ * being downloaded ahead of every reader, and putting it in memory means
270
+ * pushing out one that IS declared — which is then read back from disk
271
+ * moments later. Measured 2026-09-02: 6565 spills and 7575 revivals in 44
272
+ * minutes with 53.6 % of reads served from memory (roadmap item 9).
273
+ *
274
+ * @param {number} index
275
+ * @returns {boolean}
276
+ */
277
+ wants(index) {
278
+ return this.#isProtected(index);
279
+ }
280
+
281
+ /**
282
+ * Pieces from `index` to the nearest declared window, zero inside one and -1
283
+ * when nothing is declared.
284
+ *
285
+ * @param {number} index
286
+ * @returns {number}
287
+ */
288
+ #distanceToWindow(index) {
289
+ let nearest = -1;
290
+ for (const range of this.#protected.values()) {
291
+ const gap = index < range.from
292
+ ? range.from - index
293
+ : index > range.to ? index - range.to : 0;
294
+ if (nearest === -1 || gap < nearest) {
295
+ nearest = gap;
190
296
  }
191
297
  }
192
- return null;
298
+ return nearest;
193
299
  }
194
300
 
195
301
  /**