@torrent-tv/proxy 2.76.3 → 2.76.4

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,8 @@
1
+ ## 2.76.4
2
+
3
+ - **Fix**: An encoder was started and killed every five seconds, each one producing 0-2 segments, for as long as anybody watched. Measured on the addon host: a run given no end carries a `to` below its `from` — which is how "no end" is written everywhere here — and two places read that as a number instead. The plan's test for "is anybody waiting for what this run was given" said no, so it was stopped as unwanted; and the stretch it claimed in the coverage map collapsed to a single segment, so the plan saw the rest of the film as free and started another encoder one number along. The two together are the loop. The rule is stated once now (`endOfRun`) and read from that one place, including where a run's own ending is judged.
4
+ - **Fix**: A run with no end holds the look-ahead in front of it, not the rest of the film. It is what actually bounds one — a run is suspended once it is that far in front of the segment its viewer asked for and produces nothing until somebody asks — and claiming further would leave a viewer who opens the same film further in with no encoder at all, waiting for that run to encode its way there. The rule was already applied where a session plans its own interval and not on the path the plan uses.
5
+
1
6
  ## 2.76.3
2
7
 
3
8
  - **Fix**: The proxy stopped answering anything — playback, health, its own log — a few seconds after a viewer opened a film, and burned a whole processor doing it. Measured on the addon host with the stack read out of the live process: the look-ahead timer asked the plan where a new encoder could start, and the walk that answers that walked one segment number at a time towards nine quadrillion, scanning every claim at each step. It did that because the map had no length, and the map had no length because the field naming it moved onto the timeline in 2.76.0 while three readers were left on the old name, where every session answers `undefined`. Those three are the whole defect: with them wrong, no run was ever given an end either, so the feature that lets two encoders share one output was inert as well.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.76.3",
3
+ "version": "2.76.4",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -38,6 +38,8 @@
38
38
  * own answer — the look-ahead, which suspends a run rather than bounding it.
39
39
  */
40
40
 
41
+ import { endOfRun } from "./EncodeRun.js";
42
+
41
43
  /**
42
44
  * One encoder that is running now.
43
45
  *
@@ -138,7 +140,7 @@ export function planEncoders({
138
140
  for (const run of live) {
139
141
  // 1. Is anybody waiting for what this run was given? A run whose stretch
140
142
  // touches no window is making material nobody has asked for.
141
- const stillWanted = wanted.some((span) => overlaps(run.from, run.to, span.from, span.to));
143
+ const stillWanted = wanted.some((span) => overlaps(run.from, endOfRun(run), span.from, span.to));
142
144
  if (!stillWanted) {
143
145
  stops.push({ type: "stop", run, because: "nothing it was given is wanted" });
144
146
  continue;
@@ -63,6 +63,26 @@ const MICROSECONDS_PER_SECOND = 1_000_000;
63
63
  * @property {string} lastError - The last thing ffmpeg said on stderr.
64
64
  */
65
65
 
66
+ /**
67
+ * The last number this run was given, or Infinity when it was given no end.
68
+ *
69
+ * ONE reading of one convention. "No end" is written as a `to` below `from` —
70
+ * the session layer returns -1 for it and the log prints `#-1` — and every place
71
+ * that has to compare against it read that for itself. One place did not: the
72
+ * coverage map was handed the raw -1, `Math.max(from, -1)` made the claim one
73
+ * segment long, and the plan then saw the rest of the film as free. Field,
74
+ * 2026-09-05: an encoder was started and killed every five seconds, each one
75
+ * producing 0-2 segments, for as long as anybody watched.
76
+ *
77
+ * @param {{ from: number, to: number }} run
78
+ * @returns {number}
79
+ */
80
+ export function endOfRun(run) {
81
+ const from = Number(run?.from);
82
+ const to = Number(run?.to);
83
+ return Number.isInteger(to) && to >= from ? to : Number.POSITIVE_INFINITY;
84
+ }
85
+
66
86
  export class EncodeRun {
67
87
  /** @type {import("node:child_process").ChildProcess | null} */
68
88
  #process = null;
@@ -466,7 +486,7 @@ export class EncodeRun {
466
486
  // one, and the end of the film where it was not. A run told to make #10..#14
467
487
  // that exits cleanly at #11 has not finished, whatever the film's length;
468
488
  // and a run with no end has nothing but the film to be measured against.
469
- const endOfWork = this.to >= this.from ? this.to : this.lastSegmentIndex();
489
+ const endOfWork = Number.isFinite(endOfRun(this)) ? this.to : this.lastSegmentIndex();
470
490
  const outcome = classifyEncodeExit({
471
491
  code,
472
492
  producedThrough: this.#produced.size > 0 ? this.reached : null,
@@ -68,6 +68,7 @@ import { masterPlaylistText, mediaPlaylistText, segmentIndexForTime } from "./ou
68
68
  import { SourceFiles, sourceDecodeCharacteristics } from "./source/SourceFile.js";
69
69
  import { ProducedIndex } from "./produced-index.js";
70
70
  import { SegmentStore } from "./encode/SegmentStore.js";
71
+ import { endOfRun } from "./encode/EncodeRun.js";
71
72
  import {
72
73
  buildRunCommand,
73
74
  ffmpegSeconds,
@@ -1807,6 +1808,7 @@ export class HlsSessionManager {
1807
1808
  makeRun: ({ address, from }) => this.#makeRunAt(address, from),
1808
1809
  segmentSeconds: this.segmentDurationSec,
1809
1810
  restartCostSec: RUN_RESTART_COST_SEC,
1811
+ lookaheadSegments: Math.ceil(this.lookaheadSeconds / this.segmentDurationSec),
1810
1812
  logger
1811
1813
  });
1812
1814
  // Where each file is cut, held once per file and grid rather than once per
@@ -5495,7 +5497,7 @@ export class HlsSessionManager {
5495
5497
  // the honest extent of its claim, and it is a measured figure rather than
5496
5498
  // a chosen one — the same allowance the browser sizes its cushion from.
5497
5499
  const willReach = run.head + lookaheadSegments;
5498
- const allowed = Number.isInteger(run.to) && run.to >= run.from ? run.to : lastIndex;
5500
+ const allowed = Number.isFinite(endOfRun(run)) ? run.to : lastIndex;
5499
5501
  claims.push({ from: run.from, to: Math.min(allowed, willReach) });
5500
5502
  }
5501
5503
  const takenAt = (index) =>
@@ -25,6 +25,7 @@
25
25
 
26
26
  import { CoverageMap } from "../encode/CoverageMap.js";
27
27
  import { firstUnmetWant, planEncoders } from "../encode/EncodePlan.js";
28
+ import { endOfRun } from "../encode/EncodeRun.js";
28
29
  import { ENCODE_EXIT } from "../encode/encode-exit.js";
29
30
  import { SegmentDemand } from "../encode/SegmentDemand.js";
30
31
 
@@ -52,12 +53,17 @@ export class EncodeOrchestrator {
52
53
  * @param {{ info: (line: string) => void, warn: (line: string) => void }} params.logger
53
54
  * @param {() => number} [params.now]
54
55
  */
55
- constructor({ maxRunsFor, makeRun, segmentSeconds, restartCostSec, logger, now }) {
56
+ constructor({ maxRunsFor, makeRun, segmentSeconds, restartCostSec, lookaheadSegments = 0, logger, now }) {
56
57
  this.demand = new SegmentDemand();
57
58
  this.maxRunsFor = maxRunsFor;
58
59
  this.makeRun = makeRun;
59
60
  this.segmentSeconds = segmentSeconds;
60
61
  this.restartCostSec = restartCostSec;
62
+ // How far in front of its viewer a run is allowed to get. It is what bounds
63
+ // the claim of a run that was given no end — see #claimFor.
64
+ this.lookaheadSegments = Number.isFinite(lookaheadSegments) && lookaheadSegments > 0
65
+ ? Math.ceil(lookaheadSegments)
66
+ : 0;
61
67
  this.logger = logger;
62
68
  this.now = typeof now === "function" ? now : Date.now;
63
69
  }
@@ -227,7 +233,7 @@ export class EncodeOrchestrator {
227
233
  }
228
234
  // A run that stays keeps its claim current: the free stretch ahead of it
229
235
  // may have shrunk since it was given one.
230
- coverage.claim(action.run, action.from, action.to);
236
+ this.#claimFor(coverage, action.run, action.from, action.to);
231
237
  }
232
238
  }
233
239
 
@@ -278,7 +284,40 @@ export class EncodeOrchestrator {
278
284
  }
279
285
  onThisOutput.push(run);
280
286
  this.#runs.set(address, onThisOutput);
281
- this.coverageOf(address).claim(run, run.from, run.to);
287
+ this.#claimFor(this.coverageOf(address), run, run.from, run.to);
288
+ }
289
+
290
+ /**
291
+ * What a run holds, as far as the map is concerned.
292
+ *
293
+ * A run given an end holds exactly that stretch. A run given NO end — `to`
294
+ * below `from`, which is how this is written everywhere here — would hold the
295
+ * rest of the film, and that is what must not be claimed: a second viewer
296
+ * opening the same film further in would find every number taken and get no
297
+ * encoder at all, waiting instead for the first run to encode its way there,
298
+ * which on a long film is an hour.
299
+ *
300
+ * What bounds it in practice is the look-ahead: a run is suspended once it is
301
+ * that far in front of the segment its viewer last asked for, and past that it
302
+ * produces nothing until somebody asks. So that is the honest extent of the
303
+ * claim, and it is a measured figure rather than a chosen one — the same
304
+ * allowance the browser sizes its cushion from. `planRunInterval` has applied
305
+ * this rule since runs got intervals; this path did not, which is how an
306
+ * encoder came to be started and killed every five seconds in the field.
307
+ *
308
+ * @param {CoverageMap} coverage
309
+ * @param {object} run
310
+ * @param {number} from
311
+ * @param {number} to
312
+ */
313
+ #claimFor(coverage, run, from, to) {
314
+ const end = endOfRun({ from, to });
315
+ if (Number.isFinite(end)) {
316
+ coverage.claim(run, from, end);
317
+ return;
318
+ }
319
+ const head = Number.isFinite(run?.head) ? run.head : from;
320
+ coverage.claim(run, from, Math.max(from, head + this.lookaheadSegments));
282
321
  }
283
322
 
284
323
  /**
@@ -194,3 +194,34 @@ test("nothing wanted anywhere is said plainly", () => {
194
194
  const { made } = orchestrator();
195
195
  assert.match(made.describe(), /nothing wanted/);
196
196
  });
197
+
198
+ test("a run adopted with no end holds the look-ahead, not the rest of the film", () => {
199
+ // A session's own encoder is handed to the plan rather than built by it, and
200
+ // it carries `to = -1` — no end. Claiming the film from there would leave a
201
+ // viewer further in with no encoder at all: they would wait for this run to
202
+ // encode its way to them, which on a long film is an hour. What bounds it is
203
+ // the look-ahead, because a run is suspended once it is that far in front of
204
+ // its viewer and produces nothing until somebody asks.
205
+ const { made } = orchestrator({ maxRuns: 2 });
206
+ made.lookaheadSegments = 30;
207
+ const adopted = {
208
+ from: 0,
209
+ to: -1,
210
+ head: 3,
211
+ isAlive: true,
212
+ isStopping: false,
213
+ speedX: 8,
214
+ stop() {
215
+ this.isAlive = false;
216
+ }
217
+ };
218
+ made.adopt(PICTURE, adopted);
219
+
220
+ made.want({ claimant: "far", address: PICTURE, from: 200, to: 230 });
221
+ made.reconcile();
222
+
223
+ const runs = made.runsOn(PICTURE);
224
+ assert.equal(runs.length, 2, "the viewer further in got an encoder of their own");
225
+ assert.ok(adopted.isAlive, "and the adopted run was not stopped to make room");
226
+ assert.equal(runs.some((run) => run.from === 200), true, "started where that viewer is waiting");
227
+ });
@@ -10,6 +10,7 @@
10
10
  import test from "node:test";
11
11
  import assert from "node:assert/strict";
12
12
  import { CoverageMap } from "../services/encode/CoverageMap.js";
13
+ import { endOfRun } from "../services/encode/EncodeRun.js";
13
14
  import { firstUnmetWant, planEncoders } from "../services/encode/EncodePlan.js";
14
15
 
15
16
  /** A host that can afford two encoders, four-second segments, a cheap restart. */
@@ -243,3 +244,35 @@ test("the lowest thing a viewer is waiting for is reported, so a stalled plan is
243
244
  coverage.markReadyAll([42, 43, 44]);
244
245
  assert.equal(firstUnmetWant(coverage, [{ from: 40, to: 44 }]), null);
245
246
  });
247
+
248
+ test("a run with no end is making what the viewers ahead of it are waiting for", () => {
249
+ // Field, 2026-09-05: an encoder was started and killed every five seconds,
250
+ // each producing 0-2 segments, for as long as anybody watched. A run given no
251
+ // end carries `to = -1`, and two places read that as a number instead of as
252
+ // "no end": the overlap test called its work unwanted, and the claim it made
253
+ // in the coverage map was one segment long, so the plan saw the rest of the
254
+ // film as free and started another encoder there.
255
+ const coverage = new CoverageMap({ segmentCount: 570 });
256
+ const run = { from: 0, to: -1, head: 3, speedX: 8, isAlive: true };
257
+ coverage.claim(run, run.from, endOfRun(run));
258
+
259
+ const actions = planEncoders({
260
+ coverage,
261
+ live: [run],
262
+ wanted: [{ from: 0, to: 30 }],
263
+ maxRuns: 2,
264
+ segmentSeconds: 4,
265
+ restartCostSec: 0.12
266
+ });
267
+
268
+ assert.deepEqual(
269
+ actions.filter((action) => action.type === "stop"),
270
+ [],
271
+ "nothing is stopped: it is making exactly what is wanted"
272
+ );
273
+ assert.deepEqual(
274
+ actions.filter((action) => action.type === "start"),
275
+ [],
276
+ "and nothing new is started over ground it already holds"
277
+ );
278
+ });