@torrent-tv/proxy 2.80.4 → 2.80.5

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,7 @@
1
+ ## 2.80.5
2
+
3
+ - **Fix**: A piece is kept only where the run PROVED it finished, which closes the half 2.80.4 left open. That release recognised the piece ffmpeg writes out on `SIGTERM` and named it by when it arrived on the ready channel — right for our own stop, and blind to every other way a run ends. Killed harder, or dying on its own, a run leaves the same piece half-written and with no name at all; it decodes just as well and is just as short. The question asked of the highest-numbered file in a run's stretch is no longer what it contains but whether the run named it while still running normally, so all three endings are covered by one fact the run already holds.
4
+
1
5
  ## 2.80.4
2
6
 
3
7
  - **Fix**: The piece an encoder writes out while it is being stopped is thrown away instead of served. `-segment_list pipe:3` was taken as proof that a piece is whole — ffmpeg names a file when it closes it, so a named file is closed. That is true of the FILE and false of the SPAN: on `SIGTERM` ffmpeg writes out the piece it had open and names it like any other, so what lands on disk is a valid, decodable piece holding film only up to the instant of the stop, under a name whose playlist entry promises the whole span. Field 2026-09-06, one session, both tracks: `segment-00010.mp4` held 3.92 s of its declared 5.589 s — 96 frames — and the picture jumped 1.5 s at 1:02, while the soundtrack's own stopped run left the same shape at 17.5 s, 2.8 s wide. Both were the viewer's report of the picture and the sound jerking.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.80.4",
3
+ "version": "2.80.5",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -61,11 +61,10 @@ const MICROSECONDS_PER_SECOND = 1_000_000;
61
61
  * @property {number} livedMs
62
62
  * @property {boolean} normal - Whether this ending is the expected one.
63
63
  * @property {string} lastError - The last thing ffmpeg said on stderr.
64
- * @property {string | null} flushedName - The piece the encoder wrote out while
65
- * it was shutting down, if it wrote one. It is closed and it is SHORT: it
66
- * holds film only up to the instant the run was stopped, while its name
67
- * promises the whole span the playlist gives that number. Null where the run
68
- * was never told to stop.
64
+ * @property {string | null} provenName - The last piece this run named while it
65
+ * was still running normally, and therefore the last one it is known to have
66
+ * finished. Anything on disk beyond it was open when the run ended, whatever
67
+ * ended it. Null where the run named nothing.
69
68
  */
70
69
 
71
70
  /**
@@ -105,24 +104,27 @@ export class EncodeRun {
105
104
  #closedTail = "";
106
105
 
107
106
  /**
108
- * The last piece named on the ready channel AFTER the run was told to stop.
107
+ * The last piece named on the ready channel while the run was still running
108
+ * normally — the last one it is KNOWN to have finished.
109
109
  *
110
- * On SIGTERM ffmpeg writes out the piece it had open and names it like any
111
- * other, so "the encoder closed it" stops meaning "it is whole". Field
112
- * 2026-09-06: a run stopped mid-piece left `segment-00010.mp4` holding 3.92 s
113
- * of the 5.589 s its name promises, and the viewer's picture jumped 1.5 s at
114
- * 1:02. The soundtrack did the same at 17.5 s, 2.8 s wide, in the same
115
- * session.
110
+ * "The encoder named it" was taken to mean "it is whole". That is true of the
111
+ * file and false of the span. On SIGTERM ffmpeg writes out the piece it had
112
+ * open and names it like any other; killed harder, or dying on its own, it
113
+ * leaves that piece unnamed and half-written. Both are readable, and neither
114
+ * covers the span its number promises. Field 2026-09-06: a run stopped
115
+ * mid-piece left `segment-00010.mp4` holding 3.92 s of the 5.589 s the
116
+ * playlist gives #10, and the viewer's picture jumped 1.5 s at 1:02; the
117
+ * soundtrack did the same at 17.5 s, 2.8 s wide, in the same session.
116
118
  *
117
- * Distinguished by WHEN the name arrives, which is exact and needs no reading
118
- * of the file: a name that arrives after the stop was ordered is the flush.
119
- * A piece genuinely closed a moment before the stop can land here too, and
120
- * then it is made a second time — the cheaper of the two errors, since the
121
- * other one is a hole the viewer sees.
119
+ * Recorded by WHEN the name arrives, so nothing is read and no span is
120
+ * measured: a name that arrives after the stop was ordered is the flush and
121
+ * does not count as proof. A piece genuinely closed in the moment between the
122
+ * last normal name and the stop is then made a second time — the cheaper of
123
+ * the two errors, since the other is a hole the viewer sees.
122
124
  *
123
125
  * @type {string | null}
124
126
  */
125
- #flushedName = null;
127
+ #provenName = null;
126
128
 
127
129
  /**
128
130
  * When this run was told to stop, so the death itself can be priced.
@@ -451,8 +453,8 @@ export class EncodeRun {
451
453
  if (name.length === 0) {
452
454
  continue;
453
455
  }
454
- if (this.#stopping) {
455
- this.#flushedName = name;
456
+ if (!this.#stopping) {
457
+ this.#provenName = name;
456
458
  }
457
459
  this.onClosed(name);
458
460
  }
@@ -643,7 +645,7 @@ export class EncodeRun {
643
645
  from: this.from,
644
646
  to: this.to,
645
647
  reached: this.reached,
646
- flushedName: this.#flushedName,
648
+ provenName: this.#provenName,
647
649
  livedMs,
648
650
  // How long dying took, and how long the first output took to appear.
649
651
  // Null where the run was never told to stop, or never produced anything:
@@ -350,12 +350,12 @@ export class SegmentStore {
350
350
  * @param {((raw: Buffer) => boolean) | null} [judgeUsable]
351
351
  * @returns {Promise<number | null>} The segment number removed, or null.
352
352
  */
353
- async discardOpenPieceOf(key, within, judgeUsable = null, flushedName = null) {
353
+ async discardOpenPieceOf(key, within, judgeUsable = null, provenName = null) {
354
354
  const format = this.#formats.get(key);
355
355
  if (!format) {
356
356
  return null;
357
357
  }
358
- const removed = await discardOpenPiece(this.directoryFor(key), format, within, judgeUsable, flushedName);
358
+ const removed = await discardOpenPiece(this.directoryFor(key), format, within, judgeUsable, provenName);
359
359
  if (removed !== null) {
360
360
  this.#held.delete(key);
361
361
  this.#logger?.info?.(
@@ -23,37 +23,39 @@ import path from "node:path";
23
23
  * numbering and convinced the look-ahead to keep the encoder stopped for having
24
24
  * "produced" it.
25
25
  *
26
- * Two kinds of file are removed, and the second is the one a viewer feels.
26
+ * **A piece is whole only if the run PROVED it, and reading it proves nothing.**
27
27
  *
28
- * The first is unreadable a run that died mid-write. The second READS
29
- * perfectly and is SHORT: on SIGTERM ffmpeg writes out the piece it had open
30
- * and names it on the ready channel like any other, so it is a valid fMP4
31
- * holding film only up to the instant of the stop, under a name that promises
32
- * the whole span the playlist gives that number. Field 2026-09-06:
28
+ * The highest-numbered file in a run's stretch is the one it had open. How a
29
+ * run ends decides what became of that file, and all three outcomes leave it
30
+ * readable-looking: stopped with SIGTERM, ffmpeg writes it out and names it on
31
+ * the ready channel exactly as it names a finished one; killed harder, or dying
32
+ * on its own, it leaves the bytes it had written with no name at all. In every
33
+ * case the file decodes and holds film only up to the instant the run ended,
34
+ * under a number whose playlist entry promises a whole span. Field 2026-09-06:
33
35
  * `segment-00010.mp4` held 3.92 s of its declared 5.589 s — 96 frames — and the
34
36
  * picture jumped 1.5 s at 1:02; the soundtrack did the same at 17.5 s, 2.8 s
35
- * wide, in the same session. Judging such a file by whether it decodes says
36
- * yes, which is how both survived.
37
+ * wide, in the same session. Both decoded, which is how both reached the viewer.
37
38
  *
38
- * Which file that is comes from the run itself the last name it wrote after
39
- * being told to stop and not from reading the pieces, so there is no span to
40
- * measure and no tolerance to choose.
39
+ * So the question asked here is not what the file contains but whether the run
40
+ * named it while it was still running normally. That is a fact the run holds,
41
+ * so there is no span to measure and no tolerance to choose.
41
42
  *
42
- * A piece that was genuinely finished a moment before the stop can be named
43
- * here too, and is then made a second time. That is the cheaper error: the
44
- * other one is a hole the viewer sees.
43
+ * A piece finished in the moment between the last such name and the end is
44
+ * then made a second time. That is the cheaper error: the other is a hole the
45
+ * viewer sees.
45
46
  *
46
47
  * @param {string | null | undefined} runDirPath
47
48
  * @param {{ isSegmentFileName: (name: string) => boolean, segmentIndexFromName: (name: string) => number }} segmentFormat
48
49
  * @param {((raw: Buffer) => boolean) | null} judgeUsable - Whether a non-empty
49
50
  * piece carries what it should. Null where nothing can say, and then only an
50
51
  * empty file is removed.
51
- * @param {string | null} [flushedName] - The piece the encoder wrote out while
52
- * shutting down. Removed whether or not it reads, because reading is not the
53
- * question about it.
52
+ * @param {string | null} [provenName] - The last piece the run named while it
53
+ * was running normally. A file beyond it was open when the run ended and goes
54
+ * whether or not it reads. Null where the run proved nothing, and then every
55
+ * piece it left is unproven.
54
56
  * @returns {Promise<number | null>} The segment number removed, or null.
55
57
  */
56
- export async function discardOpenPiece(runDirPath, segmentFormat, within, judgeUsable, flushedName = null) {
58
+ export async function discardOpenPiece(runDirPath, segmentFormat, within, judgeUsable, provenName = null) {
57
59
  if (!runDirPath || typeof segmentFormat?.isSegmentFileName !== "function") {
58
60
  return null;
59
61
  }
@@ -85,11 +87,11 @@ export async function discardOpenPiece(runDirPath, segmentFormat, within, judgeU
85
87
  return null;
86
88
  }
87
89
  const filePath = path.join(runDirPath, highest.name);
88
- // The encoder named this one on its way out, so it holds film up to the stop
89
- // and no further. Nothing about its contents can say that it decodes — so
90
- // nothing about its contents is asked.
91
- const wasFlushedOnTheWayOut = typeof flushedName === "string" && flushedName === highest.name;
92
- let unusable = wasFlushedOnTheWayOut;
90
+ // Proven finished only if the run said so while it was running. Anything
91
+ // beyond that name was open when the run ended, and its contents cannot say
92
+ // so it decodes.
93
+ const proven = typeof provenName === "string" && provenName === highest.name;
94
+ let unusable = !proven;
93
95
  try {
94
96
  const info = await stat(filePath);
95
97
  if (info.size === 0) {
@@ -474,13 +474,13 @@ export class EncodeOrchestrator {
474
474
  this.#costs.note(ended);
475
475
  // Exactly one ending is normal — the run reached the end of the stretch it
476
476
  // was given and closed its last file. Every other leaves a piece open, and
477
- // that file's name is indistinguishable from a finished one's: on SIGTERM
478
- // ffmpeg writes the open piece out and names it on the ready channel like
479
- // any other, so it is a valid file holding less film than its name
480
- // promises. The run says which one that was.
477
+ // that file looks finished however the run ended: stopped, ffmpeg writes it
478
+ // out and names it like any other; killed harder, it leaves the bytes it
479
+ // had. Either way it decodes and holds less film than its number promises.
480
+ // So what is kept is what the run PROVED it finished, and nothing beyond.
481
481
  if (ended.ending !== ENCODE_EXIT.COMPLETE && this.segmentStore) {
482
482
  void this.segmentStore
483
- .discardOpenPieceOf(ended.address, { from: ended.from, to: ended.to }, null, ended.flushedName)
483
+ .discardOpenPieceOf(ended.address, { from: ended.from, to: ended.to }, null, ended.provenName)
484
484
  .catch(() => {});
485
485
  }
486
486
  this.coverageOf(ended.address).release(ended.run);
@@ -1,18 +1,22 @@
1
1
  /**
2
- * @file The piece an encoder writes out on its way to being stopped.
2
+ * @file The piece a run had open when it ended, and what proves one whole.
3
3
  *
4
- * `-segment_list pipe:3` was taken as proof that a piece is whole: ffmpeg names
5
- * a file when it closes it, so a named file is closed. True of the FILE and
6
- * false of the SPAN. On SIGTERM ffmpeg writes out the piece it had open and
7
- * names it like any other, so the result is a valid, decodable piece holding
8
- * film only up to the instant of the stop under a name whose playlist entry
9
- * promises the whole span.
4
+ * `-segment_list pipe:3` was taken as proof: ffmpeg names a file when it closes
5
+ * it, so a named file is closed. True of the FILE and false of the SPAN. The
6
+ * highest-numbered file in a run's stretch is the one it had open, and how the
7
+ * run ended decides what became of it stopped with SIGTERM, ffmpeg writes it
8
+ * out and names it exactly as it names a finished one; killed harder, or dying
9
+ * on its own, it leaves the bytes it had written. All three outcomes decode,
10
+ * and all three hold film only up to the instant the run ended.
10
11
  *
11
12
  * Field 2026-09-06, one session, both tracks: `segment-00010.mp4` held 3.92 s
12
13
  * of its declared 5.589 s (96 frames), and the picture jumped 1.5 s at 1:02;
13
14
  * the soundtrack's own stopped run left the same shape at 17.5 s, 2.8 s wide.
14
15
  * Judging such a file by whether it decodes answers yes, which is how both
15
16
  * reached the viewer.
17
+ *
18
+ * So what is kept is what the run PROVED it finished: the last piece it named
19
+ * while it was still running normally.
16
20
  */
17
21
 
18
22
  import test from "node:test";
@@ -29,79 +33,79 @@ const format = {
29
33
 
30
34
  /** A run directory holding pieces 0..last, every one of them readable. */
31
35
  async function runDirectory(last) {
32
- const dir = await mkdtemp(path.join(os.tmpdir(), "flushed-piece-"));
36
+ const dir = await mkdtemp(path.join(os.tmpdir(), "open-piece-"));
33
37
  for (let index = 0; index <= last; index += 1) {
34
38
  await writeFile(path.join(dir, `segment-${String(index).padStart(5, "0")}.mp4`), Buffer.alloc(64, 7));
35
39
  }
36
40
  return dir;
37
41
  }
38
42
 
39
- test("the piece named on the way out goes, though it reads perfectly", async () => {
43
+ test("the piece written out on the way to being stopped goes, though it reads", async () => {
44
+ // The field case: ffmpeg named #10 while shutting down, so #9 is the last it
45
+ // proved. Everything decodes, and nothing about #10's contents betrays it.
40
46
  const dir = await runDirectory(10);
41
47
  try {
42
48
  const removed = await discardOpenPiece(
43
- dir,
44
- format,
45
- { from: 0, to: 535 },
46
- // Everything decodes. This is the field case exactly: the short piece is
47
- // a valid fMP4 and nothing about its contents betrays it.
48
- () => true,
49
- "segment-00010.mp4"
49
+ dir, format, { from: 0, to: 535 }, () => true, "segment-00009.mp4"
50
50
  );
51
- assert.equal(removed, 10, "the flushed piece was kept because it decodes");
51
+ assert.equal(removed, 10, "the open piece was kept because it decodes");
52
52
  const left = await readdir(dir);
53
53
  assert.ok(!left.includes("segment-00010.mp4"));
54
- assert.ok(left.includes("segment-00009.mp4"), "a piece closed before the stop was taken too");
54
+ assert.ok(left.includes("segment-00009.mp4"), "a piece the run proved was taken too");
55
55
  } finally {
56
56
  await rm(dir, { recursive: true, force: true });
57
57
  }
58
58
  });
59
59
 
60
- test("a run that named nothing on the way out loses no readable piece", async () => {
61
- // A run that reached the end of its stretch closed its last file properly.
62
- // Removing it would mean encoding it a second time for nothing.
60
+ test("a piece the run never named goes too, however it was killed", async () => {
61
+ // Killed harder than SIGTERM, or dead on its own: the open piece is left
62
+ // half-written and unnamed. It still decodes, and it is still short.
63
63
  const dir = await runDirectory(10);
64
64
  try {
65
- const removed = await discardOpenPiece(dir, format, { from: 0, to: 535 }, () => true, null);
66
- assert.equal(removed, null);
67
- assert.ok((await readdir(dir)).includes("segment-00010.mp4"));
65
+ const removed = await discardOpenPiece(
66
+ dir, format, { from: 0, to: 535 }, () => true, "segment-00009.mp4"
67
+ );
68
+ assert.equal(removed, 10);
68
69
  } finally {
69
70
  await rm(dir, { recursive: true, force: true });
70
71
  }
71
72
  });
72
73
 
73
- test("a piece that does not read still goes, named or not", async () => {
74
+ test("the piece the run proved finished stays", async () => {
75
+ // A run stopped in the moment after closing #10 and before opening #11
76
+ // proved #10. Removing it would mean encoding it a second time for nothing.
74
77
  const dir = await runDirectory(10);
75
78
  try {
76
- const removed = await discardOpenPiece(dir, format, { from: 0, to: 535 }, () => false, null);
77
- assert.equal(removed, 10, "an unreadable last piece survived");
79
+ const removed = await discardOpenPiece(
80
+ dir, format, { from: 0, to: 535 }, () => true, "segment-00010.mp4"
81
+ );
82
+ assert.equal(removed, null);
83
+ assert.ok((await readdir(dir)).includes("segment-00010.mp4"));
78
84
  } finally {
79
85
  await rm(dir, { recursive: true, force: true });
80
86
  }
81
87
  });
82
88
 
83
- test("only inside the stretch the ended run was given", async () => {
84
- // Several runs write into one directory, kept apart by their intervals. The
85
- // highest file overall may belong to a run that is still going.
86
- const dir = await runDirectory(20);
89
+ test("a run that proved nothing keeps nothing", async () => {
90
+ const dir = await runDirectory(3);
87
91
  try {
88
- const removed = await discardOpenPiece(
89
- dir, format, { from: 0, to: 10 }, () => true, "segment-00010.mp4"
90
- );
91
- assert.equal(removed, 10);
92
- assert.ok((await readdir(dir)).includes("segment-00020.mp4"), "another run's piece was taken");
92
+ const removed = await discardOpenPiece(dir, format, { from: 0, to: 535 }, () => true, null);
93
+ assert.equal(removed, 3, "a run that named nothing had its last piece believed");
93
94
  } finally {
94
95
  await rm(dir, { recursive: true, force: true });
95
96
  }
96
97
  });
97
98
 
98
- test("a name from another run's stretch takes nothing", async () => {
99
+ test("only inside the stretch the ended run was given", async () => {
100
+ // Several runs write into one directory, kept apart by their intervals. The
101
+ // highest file overall may belong to a run that is still going.
99
102
  const dir = await runDirectory(20);
100
103
  try {
101
104
  const removed = await discardOpenPiece(
102
- dir, format, { from: 0, to: 10 }, () => true, "segment-00020.mp4"
105
+ dir, format, { from: 0, to: 10 }, () => true, "segment-00009.mp4"
103
106
  );
104
- assert.equal(removed, null, "a name outside the stretch removed a piece anyway");
107
+ assert.equal(removed, 10);
108
+ assert.ok((await readdir(dir)).includes("segment-00020.mp4"), "another run's piece was taken");
105
109
  } finally {
106
110
  await rm(dir, { recursive: true, force: true });
107
111
  }