@torrent-tv/proxy 2.81.1 → 2.82.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ ## 2.82.0
2
+
3
+ - **Fix**: THE ENCODER PING-PONGED BECAUSE NOTHING MEASURED WHAT A START COSTS. The plan compares when the wanted pieces appear under each arrangement, and for that it needs how long a fresh encoder takes to produce anything and how long killing one takes. Both were learned only from runs that had ENDED, so at a cold open both were zero — and zero does not read as “not measured”, it reads as “free”. Keeping a warming encoder then cost one piece and moving it cost `0 + 0 + one piece`: the same figure to the millisecond, so the tie fell to position and any advantage however small won. Field 2026-09-08: start at #68, a second later kill and start at #69, half a second later kill and start at #68 again, each dying having produced nothing. Over two days 153 runs were stopped that way and 68 of them made no segment at all.
4
+ **Both are measured at startup now, from one ffmpeg run**: spawn to the first piece the encoder itself announces closed, then SIGTERM to exit. 0.68 s and 0.02 s on the developer’s desktop. Readings from real runs replace them as they arrive. Worth stating because it is what the arithmetic already assumed: subtract keeping from moving and what is left is the killing plus the time the run has already lived — the warm-up a move throws away. That term exists in the formula and vanishes with the measurement, which is why the plan behaved as though a move were free.
5
+ - **Fix**: The decode cost and the contention penalty were asked only where the chosen encoder was SOFTWARE, and neither is about the encoder. A host with a GPU decodes in software just the same — no hardware decoder is asked for anywhere — and what a second job costs is a property of the machine. So a GPU host had no decode model, no contention penalty and no encoder throughput at all, and the quality offer, which is arithmetic over those three, had nothing to compute from.
6
+ - **New**: The throughput benchmark walks the CHOSEN encoder’s own speed ladder — libx264’s presets, NVENC’s `p1`…`p7`, QSV’s `veryfast`…`veryslow`, VAAPI’s quality levels. Each kind says how to measure itself (`Encoder.benchmarkArgs`), which is what the ladders declared and nothing ever ran. A kind with no ladder is measured once, which is still a reading where there was none.
7
+ - **Chore**: 1000 checks pass, biome clean. NOT yet seen in the field. What the next session must show: `a start costs …s to a first piece and a stop …s on this host` once at startup, `firstByte=` and `kill=` non-zero in the very first `encode-plan` line, and no run stopped with “scores worse than standing at” an adjacent number.
8
+
9
+ ## 2.81.2
10
+
11
+ - **Fix**: `disk: 0MB free` on a host with 103 GB. The free space was read from the segments’ own directory, which is made when the first session starts and removed when the proxy stops — so at every start, and after every clean exit, the reading failed and answered zero, which means “no room” to everything downstream. It reads the nearest ancestor that exists; the disk is the same disk either way.
12
+ - **Fix**: The spilled pieces were never registered with the owner of the disk, so it divided nothing and they kept the ceiling they had. The session manager holds no torrent pool — it is handed closures over the thread boundary — and 2.81.0 asked it for a field that does not exist. They arrive the same way everything else from that thread does.
13
+
1
14
  ## 2.81.1
2
15
 
3
16
  - **Fix**: The `disk:` line is actually said. 2.81.0 built the reading and called it from nowhere, so the one thing that can answer "why is there no room" was absent from the log. The owner says it itself, once a pass, in the series beside the memory reading.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.81.1",
3
+ "version": "2.82.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
package/server.js CHANGED
@@ -40,6 +40,7 @@ import { WorkerTorrentPool } from "./services/torrent-worker/pool-adapter.js";
40
40
  import { HlsSessionManager } from "./services/hls-session-manager.js";
41
41
  import { createPlaybackPlanner } from "./services/playback-planner.js";
42
42
  import { detectVideoEncoder, benchmarkSoftwarePresets, benchmarkDecodeCost, benchmarkContention, benchmarkCopySpeed, detectTonemapSupport } from "./services/hwaccel.js";
43
+ import { measureStartAndStop } from "./services/encode/start-stop-cost.js";
43
44
  import { logger } from "./utils/logger.js";
44
45
 
45
46
  const __filename = fileURLToPath(import.meta.url);
@@ -126,23 +127,31 @@ export async function startProxyServer({
126
127
  const videoEncoder = transcodeAudio
127
128
  ? await detectVideoEncoder({ ffmpegBin, logger })
128
129
  : null;
129
- // For software libx264, benchmark preset throughput once at startup so the
130
- // session manager can pick the highest-quality preset that still encodes each
131
- // stream faster than realtime. Hardware encoders use their own fixed preset.
132
- // The decode model first, and the preset benchmark secondthey are
133
- // independent now (the presets are timed on raw frames), but the order costs
134
- // nothing and keeps the two figures side by side in the log.
135
- const decodeCostModel = videoEncoder?.kind === "software"
130
+ // WHAT THIS HOST DOES, measured before any viewer exists. Every one of these
131
+ // was gated on the chosen encoder being SOFTWARE, and two of the three are
132
+ // not about the encoder at all: a host with a GPU decodes in software just
133
+ // the same no hardware decoder is asked for anywhere and what a second
134
+ // job costs is a property of the machine. So a GPU host had no decode cost,
135
+ // no contention penalty and no encoder throughput, and the quality offer,
136
+ // which is arithmetic over those three, had nothing to compute from.
137
+ //
138
+ // The decode model first and the throughput second — they are independent
139
+ // (the rungs are timed on raw frames), but the order keeps the two figures
140
+ // side by side in the log.
141
+ const decodeCostModel = transcodeAudio
136
142
  ? await benchmarkDecodeCost({ ffmpegBin, logger })
137
143
  : null;
138
144
  // What a second job costs on this host. Measured because the budget adds
139
145
  // independent prices and this host says two jobs that each fit alone do not
140
146
  // fit together — 2.6× on the addon box (2026-08-18).
141
- const contentionPenalties = videoEncoder?.kind === "software"
147
+ const contentionPenalties = transcodeAudio
142
148
  ? await benchmarkContention({ ffmpegBin, logger })
143
149
  : null;
144
- const softwarePresetBenchmark = videoEncoder?.kind === "software"
145
- ? await benchmarkSoftwarePresets({ ffmpegBin, logger })
150
+ // The chosen encoder walked over its OWN speed ladder: libx264's presets,
151
+ // NVENC's p1…p7, QSV's veryfast…veryslow, VAAPI's quality levels. A kind with
152
+ // no ladder is measured once, which is still a reading where there was none.
153
+ const softwarePresetBenchmark = videoEncoder
154
+ ? await benchmarkSoftwarePresets({ ffmpegBin, logger, encoder: videoEncoder })
146
155
  : null;
147
156
  // What this host does with a picture it does NOT re-encode. Every other
148
157
  // startup measurement prices encoding or decoding, and a copied picture does
@@ -155,6 +164,15 @@ export async function startProxyServer({
155
164
  const copySpeedX = transcodeAudio
156
165
  ? await benchmarkCopySpeed({ ffmpegBin, logger })
157
166
  : null;
167
+ // WHAT A START AND A STOP COST HERE, before any viewer exists. Both decide one
168
+ // thing — leave an encoder where it stands, or kill it and start another —
169
+ // and both used to be learned only from runs that had ENDED, so at a cold
170
+ // open they were zero. Zero does not read as "not measured": it reads as
171
+ // "free", and a free move is always taken. Field 2026-09-08: an encoder moved
172
+ // between two adjacent numbers every half second and produced nothing.
173
+ const startStopCost = transcodeAudio
174
+ ? await measureStartAndStop({ ffmpegBin, encoder: videoEncoder, logger })
175
+ : null;
158
176
  // Whether this ffmpeg build can tone-map HDR→SDR (zscale + tonemap filters).
159
177
  // Detected once; the session manager applies the tonemap chain only for HDR
160
178
  // sources on the software path when available.
@@ -171,6 +189,7 @@ export async function startProxyServer({
171
189
  decodeCostModel,
172
190
  contentionPenalties,
173
191
  copySpeedX,
192
+ startStopCost,
174
193
  tonemapSupported,
175
194
  segmentFormatId: segmentFormat,
176
195
  stateDir,
@@ -178,6 +197,15 @@ export async function startProxyServer({
178
197
  // CPU-bound transcode from a download-starved input before downscaling.
179
198
  // What every torrent here has moved, so the proxy can price its own
180
199
  // downloading, hashing and delivery against the machine (roadmap item 7).
200
+ // What the spilled pieces weigh on the torrent thread, and how to tell them
201
+ // their share of the disk. The owner of the disk is on this side, where the
202
+ // segments are; the pieces are on the other.
203
+ spillDisk: typeof torrentPool.allowSpillBytes === "function"
204
+ ? {
205
+ held: () => torrentPool.spilledBytes ?? 0,
206
+ allow: (bytes) => torrentPool.allowSpillBytes(bytes)
207
+ }
208
+ : null,
181
209
  getTorrentTotals: async () => {
182
210
  if (typeof torrentPool.getTorrentTotals !== "function") {
183
211
  return null;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @file How much room the disk holding a directory has.
3
+ *
4
+ * Separate from the plain `statfs` reading because of one case that is the
5
+ * normal one, not an edge: the directory may not exist yet. The segments live
6
+ * under `os.tmpdir()/torrent-tv-hls`, which is made when the first session is
7
+ * created and removed when the proxy stops — so at every start, and after every
8
+ * clean exit, `statfs` on it fails. Field 2026-09-10: the first reading said
9
+ * `disk: 0MB free` on a host with 103 GB, and zero means "no room" to everything
10
+ * that reads it.
11
+ *
12
+ * The disk is the same disk whether or not that directory has been made yet, so
13
+ * the answer is the nearest ancestor that exists.
14
+ */
15
+
16
+ import { statfs } from "node:fs/promises";
17
+ import path from "node:path";
18
+
19
+ /**
20
+ * @param {string} directory
21
+ * @returns {Promise<number | null>} Bytes free, or null where nothing answered.
22
+ */
23
+ export async function freeBytesFor(directory) {
24
+ let at = path.resolve(directory);
25
+ for (let depth = 0; depth < 16; depth += 1) {
26
+ try {
27
+ const stats = await statfs(at);
28
+ return Number(stats.bavail) * Number(stats.bsize);
29
+ } catch {
30
+ const up = path.dirname(at);
31
+ if (up === at) {
32
+ return null;
33
+ }
34
+ at = up;
35
+ }
36
+ }
37
+ return null;
38
+ }
@@ -19,14 +19,16 @@ import { DiskSpace } from "./DiskSpace.js";
19
19
  *
20
20
  * @param {object} params
21
21
  * @param {{ root: string, stats: () => { bytes: number } }} params.segmentStore
22
- * @param {{ spilledBytes?: number, allowSpillBytes?: (bytes: number) => unknown }} [params.torrentPool]
22
+ * @param {{ held: () => number, allow: (bytes: number) => unknown }} [params.spill] -
23
+ * The pieces the memory store spills. They live on the torrent thread, so
24
+ * this is a pair of closures over the channel rather than the pool itself.
23
25
  * @param {(directory: string) => Promise<number | null>} params.readFree
24
26
  * @param {{ info: Function, warn?: Function }} [params.logger]
25
27
  * @returns {{ revise: () => Promise<unknown>, segmentBytes: () => number, describe: () => string }}
26
28
  * What the segments may hold is asked for rather than pushed: zero until the
27
29
  * first revision, and zero stops growth rather than licensing it.
28
30
  */
29
- export function wireDiskSpace({ segmentStore, torrentPool, readFree, logger }) {
31
+ export function wireDiskSpace({ segmentStore, spill, readFree, logger }) {
30
32
  const space = new DiskSpace({ readFree: () => readFree(segmentStore.root), logger });
31
33
  let segmentBytes = 0;
32
34
  space.register({
@@ -39,16 +41,15 @@ export function wireDiskSpace({ segmentStore, torrentPool, readFree, logger }) {
39
41
  segmentBytes = bytes;
40
42
  }
41
43
  });
42
- if (typeof torrentPool?.allowSpillBytes === "function") {
43
- // The pieces the memory store spills. They live on the torrent thread, so
44
- // the share travels the channel that already carries everything else, and
44
+ if (typeof spill?.allow === "function") {
45
+ // The share travels the channel that already carries everything else, and
45
46
  // the reply says what they hold — one exchange, both directions.
46
47
  space.register({
47
48
  name: "spilled pieces",
48
- held: () => torrentPool.spilledBytes ?? 0,
49
+ held: () => spill.held?.() ?? 0,
49
50
  wanted: () => Number.MAX_SAFE_INTEGER,
50
51
  allow: (bytes) => {
51
- void torrentPool.allowSpillBytes?.(bytes);
52
+ void spill.allow(bytes);
52
53
  }
53
54
  });
54
55
  }
@@ -81,4 +81,31 @@ export class Encoder {
81
81
  buildVideoArgs(_options) {
82
82
  throw new Error(`${this.name} does not say how to build its video arguments.`);
83
83
  }
84
+
85
+ /**
86
+ * How to encode raw frames at one rung of this kind's speed ladder, for the
87
+ * startup benchmark and nothing else.
88
+ *
89
+ * SEPARATE FROM `buildVideoArgs` on purpose. That one produces a picture from
90
+ * a film: it scales, it tone-maps, it forces keyframes onto a grid, it caps a
91
+ * bitrate. The benchmark is fed raw frames of a known size and wants the
92
+ * encoder and the rung with nothing else in the way — otherwise the reading
93
+ * prices a scaler as though it were the encoder.
94
+ *
95
+ * WHY EVERY KIND MUST ANSWER IT. Until now the throughput benchmark existed
96
+ * only for libx264 and was gated on the chosen encoder being software, so a
97
+ * host with NVENC, QSV, VAAPI or V4L2M2M measured its encoder not at all —
98
+ * and the quality offer, which is arithmetic over pixels per second, had no
99
+ * pixels per second to work with. NVENC's own ladder is `p1`…`p7` and QSV's
100
+ * is `veryfast`…`veryslow`; both are declared here and neither was ever run.
101
+ *
102
+ * @param {string | null} rung - One value of {@link speedLadder}, or null
103
+ * where the kind has no ladder and there is one thing to measure.
104
+ * @returns {string[]}
105
+ */
106
+ benchmarkArgs(rung = null) {
107
+ const ladder = this.speedLadder;
108
+ const setting = ladder?.flag && rung ? [ladder.flag, rung] : [];
109
+ return ["-c:v", this.name, ...setting];
110
+ }
84
111
  }
@@ -35,6 +35,12 @@ export class QsvEncoder extends Encoder {
35
35
  };
36
36
  }
37
37
 
38
+ /** @param {string | null} rung @returns {string[]} */
39
+ benchmarkArgs(rung = null) {
40
+ const preset = rung ? ["-preset", rung] : [];
41
+ return ["-c:v", "h264_qsv", "-global_quality", "24", ...preset];
42
+ }
43
+
38
44
  buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, forcedKeyframeTimes }) {
39
45
  const { w, h } = safeDimensions(targetWidth, targetHeight);
40
46
  return [
@@ -42,6 +42,11 @@ export class SoftwareEncoder extends Encoder {
42
42
  };
43
43
  }
44
44
 
45
+ /** @param {string | null} rung @returns {string[]} */
46
+ benchmarkArgs(rung = null) {
47
+ return ["-c:v", "libx264", "-preset", rung ?? SOFTWARE_PRESET, "-crf", SOFTWARE_CRF, "-pix_fmt", "yuv420p"];
48
+ }
49
+
45
50
  buildVideoArgs({
46
51
  targetWidth,
47
52
  targetHeight,
@@ -40,6 +40,14 @@ export class VaapiEncoder extends Encoder {
40
40
 
41
41
  // No fps filter: VAAPI inherits the source rate and keeps keyframes on the
42
42
  // grid via time-based -force_key_frames, so it already honours source fps.
43
+ /** @param {string | null} rung @returns {string[]} */
44
+ benchmarkArgs(rung = null) {
45
+ // Raw frames live in this process; VAAPI encodes what is in the device, so
46
+ // the upload is part of what this kind costs and belongs in the reading.
47
+ const quality = rung ? ["-quality", rung] : [];
48
+ return ["-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi", "-qp", "24", ...quality];
49
+ }
50
+
43
51
  buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, forcedKeyframeTimes }) {
44
52
  const { w, h } = safeDimensions(targetWidth, targetHeight);
45
53
  return [
@@ -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,174 @@
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
+ // A generated picture: the reading is of this host's encoder and muxer,
74
+ // and a file would add its own reading and its own download.
75
+ "-f",
76
+ "lavfi",
77
+ "-i",
78
+ `testsrc2=size=640x360:rate=25`,
79
+ "-t",
80
+ String(segmentDurationSec * 4),
81
+ // The encoder this proxy has chosen, asked for its own arguments: the
82
+ // reading must be of the thing that will actually run, since what a start
83
+ // costs is mostly the encoder opening.
84
+ ...(typeof encoder?.buildVideoArgs === "function"
85
+ ? encoder.buildVideoArgs({ targetWidth: 640, targetHeight: 360, segmentDurationSec, fps: 25 })
86
+ : ["-c:v", "libx264", "-preset", "ultrafast"]),
87
+ "-an",
88
+ "-f",
89
+ "segment",
90
+ "-segment_time",
91
+ String(segmentDurationSec),
92
+ // The channel the encoder names its finished pieces on — the same one a
93
+ // session reads, so "the first piece exists" means here what it means
94
+ // there.
95
+ "-segment_list",
96
+ "pipe:3",
97
+ "-segment_list_flags",
98
+ "+live",
99
+ "-segment_format",
100
+ "mp4",
101
+ path.join(dir, "seg-%05d.mp4")
102
+ ];
103
+
104
+ const spawnedAt = now();
105
+ const child = spawn(ffmpegBin, args, {
106
+ stdio: ["ignore", "ignore", "pipe", "pipe"],
107
+ windowsHide: true
108
+ });
109
+
110
+ const firstPiece = await new Promise((resolve) => {
111
+ const timer = setTimeout(() => resolve(null), GIVE_UP_AFTER_MS);
112
+ timer.unref?.();
113
+ child.stdio?.[3]?.on("data", () => {
114
+ clearTimeout(timer);
115
+ resolve(now() - spawnedAt);
116
+ });
117
+ child.on("error", () => {
118
+ clearTimeout(timer);
119
+ resolve(null);
120
+ });
121
+ child.on("exit", () => {
122
+ clearTimeout(timer);
123
+ resolve(null);
124
+ });
125
+ });
126
+
127
+ if (firstPiece === null) {
128
+ try {
129
+ child.kill("SIGKILL");
130
+ } catch {
131
+ // Already gone.
132
+ }
133
+ log.warn("hwaccel: a start could not be measured; the plan is told so rather than told zero");
134
+ return null;
135
+ }
136
+
137
+ const killedAt = now();
138
+ const died = await new Promise((resolve) => {
139
+ const timer = setTimeout(() => resolve(null), GIVE_UP_AFTER_MS);
140
+ timer.unref?.();
141
+ child.on("exit", () => {
142
+ clearTimeout(timer);
143
+ resolve(now() - killedAt);
144
+ });
145
+ try {
146
+ child.kill("SIGTERM");
147
+ } catch {
148
+ clearTimeout(timer);
149
+ resolve(null);
150
+ }
151
+ });
152
+
153
+ const firstByteWaitSec = firstPiece / 1000;
154
+ const killCostSec = died === null ? 0 : died / 1000;
155
+ log.info(
156
+ `hwaccel: a start costs ${firstByteWaitSec.toFixed(2)}s to a first piece and ` +
157
+ `a stop ${killCostSec.toFixed(2)}s on this host — measured before any viewer, ` +
158
+ "because a plan told zero reads it as free and moves an encoder for nothing"
159
+ );
160
+ return { firstByteWaitSec, killCostSec };
161
+ } catch (error) {
162
+ log.warn(
163
+ `hwaccel: a start and a stop could not be measured: ` +
164
+ `${error instanceof Error ? error.message : String(error)}`
165
+ );
166
+ return null;
167
+ } finally {
168
+ try {
169
+ rmSync(dir, { recursive: true, force: true });
170
+ } catch {
171
+ // best effort
172
+ }
173
+ }
174
+ }
@@ -93,8 +93,8 @@ import { Viewers } from "./viewer/Viewers.js";
93
93
  import { LiveOutputs } from "./output/LiveOutputs.js";
94
94
  import { variantHeightsFor } from "./output/ladder.js";
95
95
  import { EncodeOrchestrator } from "./orchestrators/EncodeOrchestrator.js";
96
- import { readDiskFree } from "./memory-report.js";
97
96
  import { wireDiskSpace } from "./disk/wire.js";
97
+ import { freeBytesFor } from "./disk/free.js";
98
98
 
99
99
  /**
100
100
  * Whether an encoder run died because its INPUT went away, rather than because
@@ -1398,7 +1398,9 @@ export class HlsSessionManager {
1398
1398
  segmentFormatId = undefined,
1399
1399
  stateDir = "",
1400
1400
  segmentStore = null,
1401
- getTorrentTotals}) {
1401
+ getTorrentTotals,
1402
+ startStopCost = null,
1403
+ spillDisk = null}) {
1402
1404
  this.enabled = Boolean(enabled);
1403
1405
  this.ffmpegBin = ffmpegBin;
1404
1406
  this.keyframeTableBudgetMs = Number.isFinite(keyframeTableBudgetMs) && keyframeTableBudgetMs > 0
@@ -1451,6 +1453,10 @@ export class HlsSessionManager {
1451
1453
  // torrent itself costs the machine (item 7). Optional: a proxy wired
1452
1454
  // without it simply never learns that figure.
1453
1455
  this.getTorrentTotals = typeof getTorrentTotals === "function" ? getTorrentTotals : null;
1456
+ // The spilled pieces, as a pair of closures over the torrent thread: what
1457
+ // they weigh and how to tell them their share. Not the pool — the pool is
1458
+ // on the other side of the thread boundary and this side holds none of it.
1459
+ this.spillDisk = spillDisk;
1454
1460
  // Detected H.264 encoder descriptor (hardware or software). Defaults to
1455
1461
  // software libx264 when no detection result is supplied. May be downgraded
1456
1462
  // to software at runtime if a hardware encode fails.
@@ -1571,6 +1577,11 @@ export class HlsSessionManager {
1571
1577
  segmentStore: this.segmentStore,
1572
1578
  logger
1573
1579
  });
1580
+ // What a start and a stop were measured to cost here, before any viewer
1581
+ // existed. Without it both read zero at a cold open, and zero is not
1582
+ // "unmeasured" — it is "free", which is what moved an encoder between two
1583
+ // adjacent numbers every half second in the field.
1584
+ this.encodeOrchestrator.noteStartupCosts(startStopCost);
1574
1585
  // Where each file is cut, held once per file and grid rather than once per
1575
1586
  // session. Two sessions of one film MUST agree about this to the
1576
1587
  // millisecond — a segment made by either has to be appendable where the
@@ -1597,8 +1608,8 @@ export class HlsSessionManager {
1597
1608
  // One owner of the disk, and the list of what takes it lives with the owner.
1598
1609
  this.diskSpace = wireDiskSpace({
1599
1610
  segmentStore: this.segmentStore,
1600
- torrentPool: this.torrentPool,
1601
- readFree: readDiskFree,
1611
+ spill: this.spillDisk,
1612
+ readFree: freeBytesFor,
1602
1613
  logger
1603
1614
  });
1604
1615
  // Realtime-budget monitor: only meaningful for the software encoder with a