@torrent-tv/proxy 2.83.5 → 2.83.6

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,11 @@
1
+ ## 2.83.6
2
+ - **Fix**: A line forwarded from the torrent thread is no longer judged twice. That thread holds the same repeat rule and applies it before forwarding, and the main thread ran it again on a different history. It does not lose lines today — a re-printed line carries its held-back count, which makes the text unique — but that is an accident of the wording, against a promise this module states outright: a line cannot reach the console and miss the file. It also filled the main thread's bounded map with keys that can never repeat.
3
+ - **Fix**: The LEVEL travels with a forwarded line. It was dropped at the send, so every warning and every error the torrent thread has ever written arrived on the other side as information, and was coloured and recorded as such.
4
+ - **Fix**: The wait after a lost input is cleared only by a run that PRODUCED something, and the field documenting it said "cleared by any other ending" — the rule it replaced, two screens above the code that contradicted it.
5
+ - **New**: `torrent-pool: N piece claim(s) withdrawn` is printed. The pool counted them and nothing read the count, which is the state this project's own rule calls indistinguishable from having noticed once. The gap between it and the stores' `withdrawn=` is the reading: announcements far above withdrawals mean the stores are mostly dropping pieces that were never completed, and a withdrawal count stuck at zero while announcements climb means the mechanism is not reaching the torrent at all.
6
+ - **Chore**: What the plan remembers about an output's input is forgotten when the priority map says nobody is coming there. Three maps keyed by address would otherwise keep an entry for the life of the process, and the wait they describe would greet whoever opened that output next.
7
+ - **Chore**: The suffix saying how many repeats were held back was built in two branches with the same expression; one function now. The quiet check returned milliseconds that its only reader compared against zero; a boolean now.
8
+
1
9
  ## 2.83.5
2
10
  - **Fix**: ONE OWNER OF THE FACT "THIS PROXY HAS PIECE N", AND IT IS THE STORE. The disk tier drops a piece once every reader is past it — correctly, and that is what bounds the spill — while the library kept a second copy of the same fact in its completion bitfield and nothing reconciled them. A read then concluded the piece was had, asked for it, was told it was absent, and failed; nor was it ever fetched again, because the library does not download what it believes it owns. Field 2026-09-12: a film played 80 seconds (1920 frames, and the picture never moved again); the encoder ran on to 725 s, so some 565 spilled pieces fell behind every read head and were dropped, piece 0 among them; the encoder lost its input and restarted, which re-opens the input at byte 0; and `/stream` answered `0 of 2363497962 bytes: Piece 0 is verified but absent from the store` to every read for the next 92 minutes while the browser retried one segment against a healthy transport at 4 ms.
3
11
  - **Fix**: So the store announces when it can no longer produce a piece AT ALL — not resident, not on disk, not inside a file held whole — and the pool withdraws the library's claim. The eviction's own bargain, written in its comment as "a seek back re-downloads it", is true for the first time. The three cases are one rule rather than a list of call sites: the disk tier says only that IT has lost a piece, and the store decides whether that is a loss at all, so a piece dropped as a duplicate of an assembled file says nothing and a piece still resident says nothing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.83.5",
3
+ "version": "2.83.6",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -63,7 +63,8 @@ export class EncodeOrchestrator {
63
63
 
64
64
  /**
65
65
  * How many times in a row an output's run has died because its input was not
66
- * there. Cleared by any other ending, which is the input answering.
66
+ * there. Cleared only by a run that PRODUCED something, which is the only
67
+ * proof that the input can be read — see `#noteInputAvailability`.
67
68
  *
68
69
  * @type {Map<string, number>}
69
70
  */
@@ -307,6 +308,14 @@ export class EncodeOrchestrator {
307
308
  */
308
309
  notePriorityMap(address, zones) {
309
310
  this.demand.state(address, zones);
311
+ // NOBODY IS COMING HERE, so what was remembered about this output's input
312
+ // is about nobody. Kept, those three entries would stay for the life of the
313
+ // process, and the wait they describe would greet whoever opens this output
314
+ // next — a viewer arriving is new information, and one attempt for them is
315
+ // right whatever the last one met.
316
+ if (!Array.isArray(zones) || zones.length === 0) {
317
+ this.#forgetInputState(address);
318
+ }
310
319
  }
311
320
 
312
321
  /**
@@ -408,8 +417,7 @@ export class EncodeOrchestrator {
408
417
  // AND ONLY WHILE NOTHING IS PRODUCING THERE. A run still alive on this
409
418
  // output is proof the input can be read, whatever a run beside it met, so
410
419
  // the wait must not silence an output that is working.
411
- const quietMs = this.#quietFor(address);
412
- if (quietMs > 0 && this.runsOn(address).every((run) => !run.isAlive)) {
420
+ if (this.#isQuiet(address) && this.runsOn(address).every((run) => !run.isAlive)) {
413
421
  return;
414
422
  }
415
423
  // ONE MAP, NOT ONE WINDOW PER VIEWER PER ZONE.
@@ -812,23 +820,45 @@ export class EncodeOrchestrator {
812
820
  }
813
821
 
814
822
  /**
815
- * How long this output still has to wait before anything is placed on it, in
816
- * milliseconds; zero when it may be planned now.
823
+ * Whether this output is still waiting before anything may be placed on it.
824
+ *
825
+ * A boolean rather than the milliseconds left: the figure had one reader and
826
+ * that reader only asked whether it was above zero, so it was a number
827
+ * computed and thrown away. How long the wait is was said when it began.
817
828
  *
818
829
  * @param {string} address
819
- * @returns {number}
830
+ * @returns {boolean}
820
831
  */
821
- #quietFor(address) {
832
+ #isQuiet(address) {
822
833
  const until = this.#quietUntil.get(address);
823
834
  if (!Number.isFinite(until)) {
824
- return 0;
835
+ return false;
825
836
  }
826
- const left = until - this.now();
827
- if (left <= 0) {
837
+ if (until - this.now() <= 0) {
828
838
  this.#quietUntil.delete(address);
829
- return 0;
839
+ return false;
840
+ }
841
+ return true;
842
+ }
843
+
844
+ /**
845
+ * Forget everything remembered about an output nobody is producing for.
846
+ *
847
+ * Three maps are keyed by address, and an output that goes away leaves an
848
+ * entry in each for the life of the process. Small, and exactly the shape of
849
+ * accumulation this layer was built to remove from the session.
850
+ *
851
+ * @param {string} address
852
+ * @returns {void}
853
+ */
854
+ #forgetInputState(address) {
855
+ this.#inputLostAttempts.delete(address);
856
+ this.#quietUntil.delete(address);
857
+ const timer = this.#quietTimers.get(address);
858
+ if (timer) {
859
+ clearTimeout(timer);
860
+ this.#quietTimers.delete(address);
830
861
  }
831
- return left;
832
862
  }
833
863
 
834
864
  /**
@@ -1067,6 +1067,9 @@ export class TorrentPool {
1067
1067
  */
1068
1068
  #wholeSources = new Set();
1069
1069
 
1070
+ /** How many claims this pool has withdrawn, over its whole life. */
1071
+ #claimsWithdrawn = 0;
1072
+
1070
1073
  /**
1071
1074
  * Say that every file of a source is held whole.
1072
1075
  *
@@ -1165,9 +1168,6 @@ export class TorrentPool {
1165
1168
  }
1166
1169
  }
1167
1170
 
1168
- /** How many claims this pool has withdrawn, over its whole life. */
1169
- #claimsWithdrawn = 0;
1170
-
1171
1171
  get claimsWithdrawn() {
1172
1172
  return this.#claimsWithdrawn;
1173
1173
  }
@@ -17,7 +17,7 @@
17
17
  import { Worker } from "node:worker_threads";
18
18
  import { Readable } from "node:stream";
19
19
  import { fileURLToPath } from "node:url";
20
- import { logger } from "../../utils/logger.js";
20
+ import { logger, writeAlreadyDecided } from "../../utils/logger.js";
21
21
  import { createCaller, createReceiveStream } from "./channel.js";
22
22
  import { Command, Event } from "./protocol.js";
23
23
 
@@ -218,7 +218,10 @@ export class TorrentWorkerClient {
218
218
  this.#fragmentReaders.delete(message.id);
219
219
  break;
220
220
  case Event.LOG:
221
- logger.info(`torrent-worker: ${message.message}`);
221
+ // Written as it is: the worker holds the same repeat rule and has
222
+ // already applied it, and deciding again here would be one decision
223
+ // taken twice on two different histories.
224
+ writeAlreadyDecided(message.level ?? "info", `torrent-worker: ${message.message}`);
222
225
  break;
223
226
  case Event.FILE_COMPLETE:
224
227
  // Recorded here so the stream route can answer from the file without
@@ -56,8 +56,12 @@ import { forwardLogsTo, logger } from "../../utils/logger.js";
56
56
  * copy of the logger that had no file, and every one of their lines was lost:
57
57
  * measured over a whole 49 938-line file, not one of them was in it.
58
58
  */
59
- forwardLogsTo((_level, message) => {
60
- parentPort.postMessage({ type: Event.LOG, message });
59
+ forwardLogsTo((level, message) => {
60
+ // THE LEVEL TRAVELS TOO. It was dropped here, so every line this thread wrote
61
+ // — a warning about a spill that failed, an error about a torrent that went
62
+ // away — arrived on the other side as information and was coloured and
63
+ // recorded as such.
64
+ parentPort.postMessage({ type: Event.LOG, level, message });
61
65
  });
62
66
 
63
67
  // Imported dynamically, and that is load-bearing: static imports are RESOLVED
@@ -658,6 +662,7 @@ startMemoryReport({
658
662
  const STORE_REPORT_INTERVAL_MS = 60_000;
659
663
 
660
664
  /** Last reported reserve, so an unchanged one stays silent. */
665
+ let lastClaimsWithdrawn = 0;
661
666
  let lastReserveBytes = 0;
662
667
 
663
668
  /** Last reported figures per store, so unchanged ones stay silent. */
@@ -695,6 +700,20 @@ setInterval(() => {
695
700
  );
696
701
  }
697
702
  }
703
+ // WHAT THE ANNOUNCEMENTS ACTUALLY DID. The stores count every piece they
704
+ // stopped being able to produce; this counts the ones where the library did
705
+ // still hold a claim and it was taken back. The GAP between the two is the
706
+ // reading: announcements far above withdrawals mean the stores are mostly
707
+ // dropping pieces that were never completed, and a withdrawal count stuck at
708
+ // zero while announcements climb means the mechanism is not reaching the
709
+ // torrent at all.
710
+ if (pool.claimsWithdrawn !== lastClaimsWithdrawn) {
711
+ lastClaimsWithdrawn = pool.claimsWithdrawn;
712
+ log(
713
+ `torrent-pool: ${pool.claimsWithdrawn} piece claim(s) withdrawn — pieces this proxy ` +
714
+ "had dropped and has now told the swarm it needs again"
715
+ );
716
+ }
698
717
  const reserveNow = machineReserveBytes();
699
718
  if (reserveNow !== reserveBefore || reserveNow !== lastReserveBytes) {
700
719
  lastReserveBytes = reserveNow;
@@ -14,7 +14,7 @@
14
14
 
15
15
  import test from "node:test";
16
16
  import assert from "node:assert/strict";
17
- import { logger } from "../utils/logger.js";
17
+ import { logger, writeAlreadyDecided } from "../utils/logger.js";
18
18
 
19
19
  /** What reached the console while `body` ran. */
20
20
  function captured(body) {
@@ -109,6 +109,20 @@ test("when it is said again, it says how many were held back", async () => {
109
109
  assert.ok(Number(said[2]) > 0, `the span it covers must be stated: ${later[0]}`);
110
110
  });
111
111
 
112
+ test("a line already decided on another thread is written as it is", () => {
113
+ const message = unique("forwarded");
114
+ const lines = captured(() => {
115
+ writeAlreadyDecided("info", message);
116
+ writeAlreadyDecided("info", message);
117
+ writeAlreadyDecided("warn", message);
118
+ });
119
+ // The worker holds the same rule and applies it before forwarding. Deciding
120
+ // again here would be one decision taken twice on two different histories,
121
+ // and the file's own promise is that a line cannot reach the console and miss
122
+ // the file.
123
+ assert.equal(lines.length, 3, "a forwarded line is not judged a second time");
124
+ });
125
+
112
126
  test("every level goes through the same rule", () => {
113
127
  const message = unique("levels");
114
128
  const lines = captured(() => {
package/utils/logger.js CHANGED
@@ -166,6 +166,30 @@ export const logger = {
166
166
  error: (message) => write("error", message, chalk.red, console.error)
167
167
  };
168
168
 
169
+ /**
170
+ * Write a line that has ALREADY been through the repeat rule on another thread.
171
+ *
172
+ * The worker holds the same rule and applies it before forwarding, so running
173
+ * it again here would be one decision taken twice, on two different histories.
174
+ * It does not lose lines today — a re-printed line carries its held-back count,
175
+ * which makes the text unique — but that is an accident of the wording, and the
176
+ * file's own promise is that a line cannot reach the console and miss the file.
177
+ * It also filled this thread's bounded map with keys that can never repeat.
178
+ *
179
+ * @param {string} level
180
+ * @param {string} message - Already decided; written as it is.
181
+ * @returns {void}
182
+ */
183
+ export function writeAlreadyDecided(level, message) {
184
+ const colour = level === "error"
185
+ ? chalk.red
186
+ : level === "warn" ? chalk.yellow : level === "success" ? chalk.green : chalk.cyan;
187
+ const toConsole = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
188
+ const line = `${PREFIX} [${ts()}] ${message}`;
189
+ toConsole(colour(line));
190
+ toFile(line);
191
+ }
192
+
169
193
  /**
170
194
  * An established fact is said once, then with decreasing frequency.
171
195
  *
@@ -195,6 +219,22 @@ const REPEAT_KEYS = 512;
195
219
  /** @type {Map<string, { suppressed: number, printedAt: number, interval: number }>} */
196
220
  const recent = new Map();
197
221
 
222
+ /**
223
+ * What to append when a line is said again after repeats were held back.
224
+ *
225
+ * One function because it was written twice, in the two branches that decide to
226
+ * speak — which is how two statements of one rule drift apart.
227
+ *
228
+ * @param {number} heldBack
229
+ * @param {number} overMs
230
+ * @returns {string}
231
+ */
232
+ function heldBackSuffix(heldBack, overMs) {
233
+ return heldBack > 0
234
+ ? ` [said ${heldBack} more time(s) in the last ${(overMs / 1000).toFixed(1)}s]`
235
+ : "";
236
+ }
237
+
198
238
  /**
199
239
  * Whether this line is a repeat to hold back, and what to say if it is not.
200
240
  *
@@ -223,12 +263,7 @@ function repeatCheck(message) {
223
263
  }
224
264
  }
225
265
  recent.set(message, { suppressed: 0, printedAt: now, interval: REPEAT_FIRST_MS });
226
- return {
227
- hold: false,
228
- suffix: heldBack > 0
229
- ? ` [said ${heldBack} more time(s) in the last ${(overMs / 1000).toFixed(1)}s]`
230
- : ""
231
- };
266
+ return { hold: false, suffix: heldBackSuffix(heldBack, overMs) };
232
267
  }
233
268
  if (now - seen.printedAt < seen.interval) {
234
269
  seen.suppressed += 1;
@@ -242,14 +277,9 @@ function repeatCheck(message) {
242
277
  printedAt: now,
243
278
  interval: Math.min(REPEAT_MAX_MS, seen.interval * 2)
244
279
  });
245
- return {
246
- hold: false,
247
- // SAID, not merely hidden: the rate is the fact here, and a log that quietly
248
- // drops repeats reports a healthy proxy where a loop was spinning.
249
- suffix: heldBack > 0
250
- ? ` [said ${heldBack} more time(s) in the last ${(overMs / 1000).toFixed(1)}s]`
251
- : ""
252
- };
280
+ // SAID, not merely hidden: the rate is the fact here, and a log that quietly
281
+ // drops repeats reports a healthy proxy where a loop was spinning.
282
+ return { hold: false, suffix: heldBackSuffix(heldBack, overMs) };
253
283
  }
254
284
 
255
285
  /**