@torrent-tv/proxy 2.76.2 → 2.76.3

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,9 @@
1
+ ## 2.76.3
2
+
3
+ - **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.
4
+ - **Fix**: That walk can no longer do this whatever the length says. With no length known there is nothing to walk towards, and the answer — where the free stretch ends — is read off what the map already holds: the segments made and the stretches claimed, both finite however long the film is. A run then gets no end, which is what "the length is unknown" honestly means.
5
+ - **Chore**: Three test fixtures stated the moved field on the session, so the checks went on passing over code that could not work. They state the timeline now, which is where the product reads it.
6
+
1
7
  ## 2.76.2
2
8
 
3
9
  - **Fix**: How long a session waits for a film's keyframe table is bounded, and the bound is one the read already had rather than a new number. That table decides which branch a picture takes — with it the picture is passed through untouched, without it the whole picture is re-encoded — and nothing limited the wait, while the file comes off a torrent and the bytes the table lives in may still be arriving. Measured on the addon host over seventeen files from four containers, pieces from 0.25 to 16 MB (`research/keyframe-table-read-2026-09-04.md`): every table that arrived did so within 24.8 s and most within half a second, while two files answered nothing for 120.9 s and 120.5 s — which is exactly TWO of the sixty-second bound the read already has, one for the wait on the file's edges and one for the read, in series. A session now waits for one of them. The read is not cancelled: it goes on, is remembered on the file, and the next session of that file gets the copy.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.76.2",
3
+ "version": "2.76.3",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -251,7 +251,22 @@ export class CoverageMap {
251
251
  */
252
252
  freeRunFrom(index, exceptRun = null) {
253
253
  const start = Number.isInteger(index) && index > 0 ? index : 0;
254
- const last = this.#segmentCount > 0 ? this.#segmentCount - 1 : Number.MAX_SAFE_INTEGER;
254
+ if (this.#segmentCount <= 0) {
255
+ // The length is not known, so how far the free stretch reaches is not
256
+ // known either, and the honest answer is "as far as there is film" — the
257
+ // caller turns that into a run with no end.
258
+ //
259
+ // Never walked one number at a time to find that out. It used to be, up to
260
+ // MAX_SAFE_INTEGER, with a scan of every claim at each step: on the addon
261
+ // host, 2026-09-05, the main thread spun at 100% from the look-ahead timer
262
+ // and the proxy stopped answering anything at all, its own log included.
263
+ // The length was missing because the field naming it had moved and three
264
+ // readers were left on the old name — but a walk whose end depends on a
265
+ // field being present must not be able to do this even then.
266
+ const covered = this.#firstCoveredFrom(start, exceptRun);
267
+ return covered === null ? Number.POSITIVE_INFINITY : covered - start;
268
+ }
269
+ const last = this.#segmentCount - 1;
255
270
  let at = start;
256
271
  while (at <= last) {
257
272
  if (this.#ready.has(at)) {
@@ -266,6 +281,38 @@ export class CoverageMap {
266
281
  return at - start;
267
282
  }
268
283
 
284
+ /**
285
+ * The first number at or after `index` that somebody has made or is making,
286
+ * or null when nobody has touched anything from there on.
287
+ *
288
+ * Asked of what the map HOLDS rather than by walking the numbers, so it can be
289
+ * answered without a length: the map knows every ready number and every claim,
290
+ * and both are finite however long the film is.
291
+ *
292
+ * @param {number} index
293
+ * @param {object | null} exceptRun
294
+ * @returns {number | null}
295
+ */
296
+ #firstCoveredFrom(index, exceptRun) {
297
+ let lowest = null;
298
+ for (const ready of this.#ready) {
299
+ if (ready >= index && (lowest === null || ready < lowest)) {
300
+ lowest = ready;
301
+ }
302
+ }
303
+ for (const [run, span] of this.#claims) {
304
+ if (run === exceptRun) {
305
+ continue;
306
+ }
307
+ // A claim that has already begun covers `index` itself.
308
+ const covers = span.from <= index && index <= span.to ? index : span.from;
309
+ if (covers >= index && (lowest === null || covers < lowest)) {
310
+ lowest = covers;
311
+ }
312
+ }
313
+ return lowest;
314
+ }
315
+
269
316
  /**
270
317
  * What this map holds, for a log line.
271
318
  *
@@ -184,7 +184,7 @@ export function planEncoders({
184
184
  type: "move",
185
185
  run,
186
186
  from: gap,
187
- to: gap + Math.max(1, free) - 1,
187
+ to: endOfStretch(gap, free),
188
188
  because: driveSec === null
189
189
  ? `${coveredAhead} segment(s) ahead are already covered and its speed is not measured`
190
190
  : `driving through ${coveredAhead} covered segment(s) costs ${driveSec.toFixed(2)}s ` +
@@ -219,7 +219,7 @@ export function planEncoders({
219
219
  starts.push({
220
220
  type: "start",
221
221
  from: gap,
222
- to: gap + free - 1,
222
+ to: endOfStretch(gap, free),
223
223
  because: `#${gap} is wanted and nobody is making it`
224
224
  });
225
225
  }
@@ -227,6 +227,21 @@ export function planEncoders({
227
227
  return [...stops, ...moves, ...starts, ...keeps];
228
228
  }
229
229
 
230
+ /**
231
+ * The last number of a stretch that begins at `from` and is `length` long.
232
+ *
233
+ * `-1` when the length is not finite, which is this layer's word for a run with
234
+ * no end: the film's length is not known, so there is nothing to stop it at, and
235
+ * a number invented here would be an end nobody measured.
236
+ *
237
+ * @param {number} from
238
+ * @param {number} length
239
+ * @returns {number}
240
+ */
241
+ function endOfStretch(from, length) {
242
+ return Number.isFinite(length) ? from + Math.max(1, length) - 1 : -1;
243
+ }
244
+
230
245
  /**
231
246
  * The lowest number a viewer is waiting for that is not ready — what the plan
232
247
  * is judged by.
@@ -3856,7 +3856,13 @@ export class HlsSessionManager {
3856
3856
  const now = Date.now();
3857
3857
  for (const [address, sessions] of byOutput) {
3858
3858
  const coverage = this.encodeOrchestrator.coverageOf(address);
3859
- const segmentCount = Number(sessions[0].segmentCount) || 0;
3859
+ // From the TIMELINE, which is where how a file is cut has lived since
3860
+ // 2.76.0. Read off the session it left, this was `undefined` on every
3861
+ // session ever made: the map then held no length, and the walk that
3862
+ // gives a run its end ran to MAX_SAFE_INTEGER — the main thread spun at
3863
+ // 100% and the proxy answered nothing, measured on the addon host
3864
+ // 2026-09-05 with the stack read out of the live process.
3865
+ const segmentCount = Number(sessions[0].timeline?.segmentCount) || 0;
3860
3866
  if (segmentCount > 0) {
3861
3867
  coverage.setSegmentCount(segmentCount);
3862
3868
  }
@@ -5461,7 +5467,7 @@ export class HlsSessionManager {
5461
5467
 
5462
5468
  planRunInterval(session, startIndex, exceptRun = null) {
5463
5469
  const key = session.outputKey ?? "";
5464
- const lastIndex = (Number(session.segmentCount) || 0) - 1;
5470
+ const lastIndex = (Number(session.timeline?.segmentCount) || 0) - 1;
5465
5471
  if (!key || lastIndex < 0) {
5466
5472
  // Nothing to plan against: no address, or no playlist yet. The run keeps
5467
5473
  // the shape it has always had — start here, no end.
@@ -5692,7 +5698,8 @@ export class HlsSessionManager {
5692
5698
  // The film's last segment number, which is what tells "it reached the
5693
5699
  // end" from "its input dried up": ffmpeg exits zero for both and over a
5694
5700
  // torrent cannot tell them apart.
5695
- lastSegmentIndex: () => (session.segmentCount > 0 ? session.segmentCount - 1 : null),
5701
+ lastSegmentIndex: () =>
5702
+ session.timeline?.segmentCount > 0 ? session.timeline.segmentCount - 1 : null,
5696
5703
  inputUnavailable: (message) => isInputUnavailable(message),
5697
5704
  onProgress: (report) => this.#noteRunProgress(session, run, report),
5698
5705
  onEnded: (ended) => this.#onRunEnded(session, run, ended)
@@ -99,7 +99,6 @@ function fakeSession({ dirPath, transcodeVideo = true, cutGrid = transcodeVideo
99
99
  usesExplicitCuts: false,
100
100
  useSyntheticPlaylist: true,
101
101
  playlistText: "#EXTM3U\n",
102
- segmentCount: 100,
103
102
  progress: { state: "running", processedSeconds: 40, startPositionSeconds: 0, speed: "1.0x" }
104
103
  };
105
104
  }
@@ -65,7 +65,6 @@ async function managerWithRunAhead() {
65
65
  transcodeVideo: true,
66
66
  useSyntheticPlaylist: true,
67
67
  playlistText: "#EXTM3U\n",
68
- segmentCount: 1936,
69
68
  lastRestartAt: 0,
70
69
  seekFailureTarget: -1,
71
70
  seekFailureCount: 0,
@@ -151,3 +151,28 @@ test("with the length unknown, a gap search needs its own bound", () => {
151
151
  assert.equal(map.firstGapFrom(0), null, "no length and no bound answers nothing");
152
152
  assert.equal(map.firstGapFrom(0, 3), 0);
153
153
  });
154
+
155
+ test("with the length unknown, the free stretch is answered without walking to it", () => {
156
+ // What this pins: on the addon host, 2026-09-05, a map with no length walked
157
+ // one number at a time to MAX_SAFE_INTEGER, scanning every claim at each step.
158
+ // The main thread spun at 100% from the look-ahead timer and the proxy stopped
159
+ // answering anything, its own log included. The length was missing because the
160
+ // field naming it had moved to the timeline and three readers were left on the
161
+ // old name — but this walk must not be able to do that even when it is.
162
+ //
163
+ // This check cannot FAIL against the old code — a synchronous walk cannot be
164
+ // interrupted by the runner, so it would hang the whole run instead, which is
165
+ // worse than no check. What it pins is the contract that replaced the walk:
166
+ // the answer comes from what the map holds.
167
+ const map = new CoverageMap();
168
+
169
+ assert.equal(map.freeRunFrom(0), Number.POSITIVE_INFINITY, "nothing is covered, so nothing bounds it");
170
+
171
+ const run = aRun();
172
+ map.claim(run, 500, 900);
173
+ assert.equal(map.freeRunFrom(0), 500, "and a claim ahead is where the free stretch ends");
174
+ assert.equal(map.freeRunFrom(0, run), Number.POSITIVE_INFINITY, "its own claim does not bound it");
175
+
176
+ map.markReady(200);
177
+ assert.equal(map.freeRunFrom(0), 200, "so is a segment somebody has already made");
178
+ });
@@ -61,7 +61,14 @@ function sessionOn({ id, dirPath, segmentCount = 100, runState = null, encodeSta
61
61
  get inputFile() { return this.file; },
62
62
  get audioFile() { return this.file; },
63
63
  segmentFormat: fmp4Format,
64
- segmentCount,
64
+ // How the file is cut, held by the TIMELINE. A fixture that stated it on the
65
+ // session was describing a shape the product had left, and it kept a defect
66
+ // alive for a release: `session.segmentCount` is undefined on every real
67
+ // session, so runs were given no end and the coverage map had no length.
68
+ timeline: new Timeline({
69
+ boundaries: Array.from({ length: segmentCount + 1 }, (_, index) => index * 4),
70
+ cutGrid: "uniform"
71
+ }),
65
72
  runs: new Set(),
66
73
  consumers: new Set(),
67
74
  lastAccessedAt: Date.now()