@torrent-tv/proxy 2.81.2 → 2.83.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.
@@ -48,6 +48,40 @@ export class RunCosts {
48
48
  /** How long the first output took to appear, in milliseconds. @type {number[]} */
49
49
  #firstOutput = [];
50
50
 
51
+ /**
52
+ * What this host was measured to do at startup, before any viewer existed.
53
+ *
54
+ * WITHOUT IT BOTH FIGURES ARE ZERO AT A COLD OPEN, and zero does not read as
55
+ * "not measured" — it reads as "free". The whole comparison the plan makes is
56
+ * between leaving an encoder where it stands, which costs the remainder of its
57
+ * warm-up, and moving it, which costs the killing plus a warm-up from the
58
+ * beginning. Subtract one from the other and what is left is the killing plus
59
+ * the time the run has already lived — the warm-up a move throws away. Set the
60
+ * warm-up to zero and that difference collapses to zero as well: keeping and
61
+ * moving cost exactly the same, the tie falls to position, and any advantage
62
+ * however small wins. Field 2026-09-08: an encoder moved between two adjacent
63
+ * numbers every half second, produced nothing, and was killed each time.
64
+ *
65
+ * Readings from real runs replace it as they arrive; this is where the plan
66
+ * starts from, not where it stays.
67
+ *
68
+ * @type {{ firstByteWaitSec: number, killCostSec: number } | null}
69
+ */
70
+ #atStartup = null;
71
+
72
+ /**
73
+ * Take what the startup measurement found on this host.
74
+ *
75
+ * @param {{ firstByteWaitSec: number, killCostSec: number } | null} measured
76
+ * @returns {void}
77
+ */
78
+ noteStartup(measured) {
79
+ this.#atStartup =
80
+ Number.isFinite(measured?.firstByteWaitSec) && measured.firstByteWaitSec > 0
81
+ ? { firstByteWaitSec: measured.firstByteWaitSec, killCostSec: Math.max(0, measured.killCostSec ?? 0) }
82
+ : null;
83
+ }
84
+
51
85
  /**
52
86
  * Take the two readings a finished run carries. Either may be absent — a run
53
87
  * that was never told to stop did not die on command, and one that produced
@@ -112,8 +146,9 @@ export class RunCosts {
112
146
  // piece's encoding. Whoever uses it separates the two, because the piece
113
147
  // costs more when encoders share the machine and the spawn does not.
114
148
  return {
115
- killCostSec: (middleOf(this.#dying) ?? 0) / 1000,
116
- firstByteWaitSec: (middleOf(this.#firstOutput) ?? 0) / 1000,
149
+ killCostSec: (middleOf(this.#dying) ?? 0) / 1000 || (this.#atStartup?.killCostSec ?? 0),
150
+ firstByteWaitSec:
151
+ (middleOf(this.#firstOutput) ?? 0) / 1000 || (this.#atStartup?.firstByteWaitSec ?? 0),
117
152
  samples: Math.min(this.#dying.length, this.#firstOutput.length)
118
153
  };
119
154
  }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * @file What starting and stopping an encoder costs on THIS host, measured
3
+ * before any viewer exists.
4
+ *
5
+ * WHY IT HAS TO BE MEASURED AT STARTUP. Both figures decide one thing: whether
6
+ * to leave an encoder where it stands or kill it and start another elsewhere.
7
+ * The plan compares when the wanted pieces appear under each arrangement, and
8
+ * for that it needs how long a fresh encoder takes to produce anything and how
9
+ * long killing one takes.
10
+ *
11
+ * Until now both were learned only from runs that had ENDED, so at a cold open
12
+ * they were zero — and zero does not read as "not measured", it reads as
13
+ * "free". A warming encoder then owed one piece and a moved one owed
14
+ * `0 + 0 + one piece`: the same figure to the millisecond. The tie fell to
15
+ * position, so any advantage however small won, and the plan moved the encoder
16
+ * on every pass. Field 2026-09-08, the first fifteen seconds of a session:
17
+ * start at #68, a second later kill and start at #69, half a second later kill
18
+ * and start at #68 again, each dying having produced nothing. Over two days,
19
+ * 153 runs stopped that way and 68 of them made no segment at all.
20
+ *
21
+ * ONE RUN GIVES BOTH READINGS. An encoder is started on a generated picture
22
+ * through the same pipeline a session uses, timed until it says it has closed
23
+ * its first piece, then killed and timed until it exits. Nothing about the
24
+ * measurement is chosen: it is the same encoder, the same muxer, the same
25
+ * channel the encoder announces finished pieces on.
26
+ */
27
+
28
+ import { spawn } from "node:child_process";
29
+ import { mkdtempSync, rmSync } from "node:fs";
30
+ import os from "node:os";
31
+ import path from "node:path";
32
+
33
+ /**
34
+ * How long the measurement may take before it is abandoned.
35
+ *
36
+ * Not a property of the host and not a figure anything is derived from: it
37
+ * bounds a startup step so a machine that cannot produce a piece at all does not
38
+ * hold the proxy closed. A host that hits it has said something useful — that
39
+ * its first piece takes longer than this — and the plan is told the bound rather
40
+ * than a zero.
41
+ */
42
+ const GIVE_UP_AFTER_MS = 30_000;
43
+
44
+ /**
45
+ * Measure a start and a stop on this host.
46
+ *
47
+ * @param {object} params
48
+ * @param {string} params.ffmpegBin
49
+ * @param {import("./Encoder.js").Encoder} params.encoder - The encoder this
50
+ * proxy has chosen, so the reading is of the thing that will actually run.
51
+ * @param {number} [params.segmentDurationSec]
52
+ * @param {{ info: Function, warn: Function }} [params.logger]
53
+ * @param {() => number} [params.now]
54
+ * @returns {Promise<{ firstByteWaitSec: number, killCostSec: number } | null>}
55
+ * Null where nothing could be measured, which is said rather than passed off
56
+ * as a zero.
57
+ */
58
+ export async function measureStartAndStop({
59
+ ffmpegBin,
60
+ encoder,
61
+ segmentDurationSec = 4,
62
+ logger = null,
63
+ now = Date.now
64
+ }) {
65
+ const log = logger ?? { info: () => {}, warn: () => {} };
66
+ const dir = mkdtempSync(path.join(os.tmpdir(), "tt-startstop-"));
67
+ try {
68
+ const args = [
69
+ "-hide_banner",
70
+ "-nostats",
71
+ "-loglevel",
72
+ "error",
73
+ // The device, where the kind takes one.
74
+ ...(typeof encoder?.benchmarkInputArgs === "function" ? encoder.benchmarkInputArgs() : []),
75
+ // A generated picture: the reading is of this host's encoder and muxer,
76
+ // and a file would add its own reading and its own download.
77
+ "-f",
78
+ "lavfi",
79
+ "-i",
80
+ `testsrc2=size=640x360:rate=25`,
81
+ "-t",
82
+ String(segmentDurationSec * 4),
83
+ // The encoder this proxy has chosen, asked for its own arguments: the
84
+ // reading must be of the thing that will actually run, since what a start
85
+ // costs is mostly the encoder opening.
86
+ // The same arguments the throughput benchmark uses, for the same reason:
87
+ // a start is timed on the encoder itself, not on a scaler in front of it.
88
+ ...(typeof encoder?.benchmarkArgs === "function"
89
+ ? encoder.benchmarkArgs(null)
90
+ : ["-c:v", "libx264", "-preset", "ultrafast"]),
91
+ "-an",
92
+ "-f",
93
+ "segment",
94
+ "-segment_time",
95
+ String(segmentDurationSec),
96
+ // The channel the encoder names its finished pieces on — the same one a
97
+ // session reads, so "the first piece exists" means here what it means
98
+ // there.
99
+ "-segment_list",
100
+ "pipe:3",
101
+ "-segment_list_flags",
102
+ "+live",
103
+ "-segment_format",
104
+ "mp4",
105
+ path.join(dir, "seg-%05d.mp4")
106
+ ];
107
+
108
+ const spawnedAt = now();
109
+ const child = spawn(ffmpegBin, args, {
110
+ stdio: ["ignore", "ignore", "pipe", "pipe"],
111
+ windowsHide: true
112
+ });
113
+
114
+ const firstPiece = await new Promise((resolve) => {
115
+ const timer = setTimeout(() => resolve(null), GIVE_UP_AFTER_MS);
116
+ timer.unref?.();
117
+ child.stdio?.[3]?.on("data", () => {
118
+ clearTimeout(timer);
119
+ resolve(now() - spawnedAt);
120
+ });
121
+ child.on("error", () => {
122
+ clearTimeout(timer);
123
+ resolve(null);
124
+ });
125
+ child.on("exit", () => {
126
+ clearTimeout(timer);
127
+ resolve(null);
128
+ });
129
+ });
130
+
131
+ if (firstPiece === null) {
132
+ try {
133
+ child.kill("SIGKILL");
134
+ } catch {
135
+ // Already gone.
136
+ }
137
+ log.warn("hwaccel: a start could not be measured; the plan is told so rather than told zero");
138
+ return null;
139
+ }
140
+
141
+ const killedAt = now();
142
+ const died = await new Promise((resolve) => {
143
+ const timer = setTimeout(() => resolve(null), GIVE_UP_AFTER_MS);
144
+ timer.unref?.();
145
+ child.on("exit", () => {
146
+ clearTimeout(timer);
147
+ resolve(now() - killedAt);
148
+ });
149
+ try {
150
+ child.kill("SIGTERM");
151
+ } catch {
152
+ clearTimeout(timer);
153
+ resolve(null);
154
+ }
155
+ });
156
+
157
+ const firstByteWaitSec = firstPiece / 1000;
158
+ const killCostSec = died === null ? 0 : died / 1000;
159
+ log.info(
160
+ `hwaccel: a start costs ${firstByteWaitSec.toFixed(2)}s to a first piece and ` +
161
+ `a stop ${killCostSec.toFixed(2)}s on this host — measured before any viewer, ` +
162
+ "because a plan told zero reads it as free and moves an encoder for nothing"
163
+ );
164
+ return { firstByteWaitSec, killCostSec };
165
+ } catch (error) {
166
+ log.warn(
167
+ `hwaccel: a start and a stop could not be measured: ` +
168
+ `${error instanceof Error ? error.message : String(error)}`
169
+ );
170
+ return null;
171
+ } finally {
172
+ try {
173
+ rmSync(dir, { recursive: true, force: true });
174
+ } catch {
175
+ // best effort
176
+ }
177
+ }
178
+ }
@@ -94,6 +94,8 @@ import { LiveOutputs } from "./output/LiveOutputs.js";
94
94
  import { variantHeightsFor } from "./output/ladder.js";
95
95
  import { EncodeOrchestrator } from "./orchestrators/EncodeOrchestrator.js";
96
96
  import { wireDiskSpace } from "./disk/wire.js";
97
+ import { IDLE_KEEP_MS } from "./disk/keep.js";
98
+ import { Returns } from "./disk/returns.js";
97
99
  import { freeBytesFor } from "./disk/free.js";
98
100
 
99
101
  /**
@@ -474,7 +476,7 @@ const DEFAULT_SESSION_TTL_MS = 30 * 60 * 1000;
474
476
  * not this; this only stops something nobody has touched all day from sitting
475
477
  * there for the life of the process.
476
478
  */
477
- const SEGMENT_STORE_IDLE_MS = 6 * 60 * 60 * 1000;
479
+ const SEGMENT_STORE_IDLE_MS = IDLE_KEEP_MS;
478
480
  const DEFAULT_STARTUP_WAIT_MS = 5_000;
479
481
  // Realtime budget — runtime downswitch (software encoder only). Periodically
480
482
  // check each active software-transcode session's ffmpeg `speed`; when it stays
@@ -1399,6 +1401,7 @@ export class HlsSessionManager {
1399
1401
  stateDir = "",
1400
1402
  segmentStore = null,
1401
1403
  getTorrentTotals,
1404
+ startStopCost = null,
1402
1405
  spillDisk = null}) {
1403
1406
  this.enabled = Boolean(enabled);
1404
1407
  this.ffmpegBin = ffmpegBin;
@@ -1576,6 +1579,11 @@ export class HlsSessionManager {
1576
1579
  segmentStore: this.segmentStore,
1577
1580
  logger
1578
1581
  });
1582
+ // What a start and a stop were measured to cost here, before any viewer
1583
+ // existed. Without it both read zero at a cold open, and zero is not
1584
+ // "unmeasured" — it is "free", which is what moved an encoder between two
1585
+ // adjacent numbers every half second in the field.
1586
+ this.encodeOrchestrator.noteStartupCosts(startStopCost);
1579
1587
  // Where each file is cut, held once per file and grid rather than once per
1580
1588
  // session. Two sessions of one film MUST agree about this to the
1581
1589
  // millisecond — a segment made by either has to be appendable where the
@@ -1599,6 +1607,10 @@ export class HlsSessionManager {
1599
1607
  void this.cleanupExpired();
1600
1608
  }, CLEANUP_INTERVAL_MS);
1601
1609
  this.cleanupTimer.unref();
1610
+ // How long after material stops being read somebody asks for it again — the
1611
+ // one term of the keeping period that is guessed rather than measured, and
1612
+ // the only place it can be measured from.
1613
+ this.returns = new Returns();
1602
1614
  // One owner of the disk, and the list of what takes it lives with the owner.
1603
1615
  this.diskSpace = wireDiskSpace({
1604
1616
  segmentStore: this.segmentStore,
@@ -2251,6 +2263,10 @@ export class HlsSessionManager {
2251
2263
  // session was never registered, and no sweep looks for one. Proxy
2252
2264
  // 2.9.101-2.9.102 failed here on every single request and the leftovers
2253
2265
  // were the only trace of it on disk.
2266
+ // A RETURN, if this output was held before — and its age, which is the one
2267
+ // term of the keeping period that nothing measures. Read BEFORE the
2268
+ // directory is claimed, since claiming it is what marks it read.
2269
+ this.returns.note({ lastReadAt: this.segmentStore.lastReadAt(spec.toKey()), now: Date.now() });
2254
2270
  this.segmentStore.directoryFor(spec.toKey());
2255
2271
  this.segmentStore.useFormat(spec.toKey(), segmentFormat);
2256
2272
 
@@ -9672,6 +9688,12 @@ export class HlsSessionManager {
9672
9688
  // last read, and how much room the disk has for the lot.
9673
9689
  // The room is the disk owner's to divide; this asks what the share is now.
9674
9690
  await this.diskSpace.revise();
9691
+ // What viewers actually do, beside the period that stands in for it. Said
9692
+ // where it can be read against the disk figures rather than on its own.
9693
+ const returns = this.returns.describe(IDLE_KEEP_MS);
9694
+ if (returns !== null) {
9695
+ logger.info(returns);
9696
+ }
9675
9697
  this.segmentStore.enforce({
9676
9698
  idleMs: SEGMENT_STORE_IDLE_MS,
9677
9699
  maxBytes: this.diskSpace.segmentBytes(),