@torrent-tv/proxy 2.96.14 → 2.97.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/bin/cli.js CHANGED
@@ -88,6 +88,7 @@ program
88
88
  .option("--no-transcode-audio", "Disable optional HLS AAC audio transcoding")
89
89
  .option("--no-port-mapping", "Disable automatic UPnP/NAT-PMP port mapping")
90
90
  .option("--delivery-sink", "Serve /api/delivery-sink, a torrent-free byte stream for delivery testing")
91
+ .option("--profile-start <seconds>", "Profile the main thread's CPU for this many seconds from the first source opened, written beside the state (off by default)")
91
92
  .option(
92
93
  "--send-chunk-bytes <bytes>",
93
94
  "Size of one data-channel message when sending a response body (0 = whatever the body read hands over, ~64KB). For measuring whether the delivery wedge depends on that size."
@@ -392,6 +393,7 @@ try {
392
393
  budgetPolicy,
393
394
  logFile: options.logFile ?? "",
394
395
  deliverySink: options.deliverySink === true,
396
+ profileStartSeconds: Number(options.profileStart) || 0,
395
397
  // Late-bound the same way `webRtcManager` is below: the torrent pool is
396
398
  // built inside `startProxyServer`, before `dataChannelHandler` — the
397
399
  // thing that actually owns a channel to push down — exists.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.96.14",
3
+ "version": "2.97.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -36,6 +36,19 @@ export function handleEncodeInputGet(req, reply, { inputOf, refused = () => {} }
36
36
  refused({ token, fileIndex, start: range.start, runTag: input.runTag ?? null });
37
37
  return reply.code(503).send({ error: "Original input bytes are outside the admitted ranges." });
38
38
  }
39
+ // What was handed to the system for this answer before its connection
40
+ // closed, and whether all of it was: an answer runs to the end of its
41
+ // admitted range and FFmpeg closes the connection once it has what it wants,
42
+ // so the range answered says nothing about the bytes taken. What FFmpeg read
43
+ // is at most what was handed over; the kernel's buffer is the difference,
44
+ // and it cannot be seen from here (torrent-tv/meta#177).
45
+ const socket = req.raw?.socket;
46
+ const writtenBefore = socket?.bytesWritten ?? 0;
47
+ reply.raw?.once?.("close", () => {
48
+ input.noteAnswer?.({ fileIndex, start: range.start, end: held.end,
49
+ written: Math.max(0, (socket?.bytesWritten ?? writtenBefore) - writtenBefore),
50
+ handedOver: reply.raw?.writableFinished === true });
51
+ });
39
52
  return reply.code(206).header("Content-Length", String(held.bytes.length))
40
53
  .header("Content-Range", `bytes ${range.start}-${held.end}/${length}`).send(held.bytes);
41
54
  }
package/server.js CHANGED
@@ -80,6 +80,8 @@ import { measureStartAndStop } from "./services/encode/start-stop-cost.js";
80
80
  import { benchmarkAudio } from "./services/encode/audio-calibration.js";
81
81
  import { logger } from "./utils/logger.js";
82
82
  import { completedFilesRoot } from "./services/storage/files/CompletedFiles.js";
83
+ import { startupCpuProfile } from "./services/storage/cpu-profile.js";
84
+ import { startProcessorReport } from "./services/storage/processor-report.js";
83
85
 
84
86
  const require = createRequire(import.meta.url);
85
87
  const { version } = require("./package.json");
@@ -119,6 +121,8 @@ function buildPortCandidates(startPort, maxAttempts = 51) {
119
121
  export async function startProxyServer({
120
122
  host, port, transcodeAudio, ffmpegBin, memoryBytes, segmentFormat, stateDir, onSubtitleCues, diagnostics, budgetPolicy,
121
123
  deliverySink = false,
124
+ // Seconds of main-thread CPU profile from the first source opened; 0 is off.
125
+ profileStartSeconds = 0,
122
126
  // What viewers' connections carry beyond the film, per byte of film; the
123
127
  // transport counts it and is built after this server, so it is late-bound.
124
128
  serviceShare = () => null,
@@ -875,6 +879,22 @@ export async function startProxyServer({
875
879
  await torrentPool.fillFileInBackground?.(torrent, fileIndex);
876
880
  }
877
881
  });
882
+ // Where the processor goes, and at a cold start what the main thread does
883
+ // with it, when the operator asks (torrent-tv/meta#177).
884
+ const startupProfile = startupCpuProfile({
885
+ seconds: profileStartSeconds,
886
+ dir: stateDir || "",
887
+ log: (line) => logger.info(line),
888
+ mayKeep: (bytes) => diagnostics?.mayKeep?.({ what: "a CPU profile", bytes }) ?? true
889
+ });
890
+ startProcessorReport({
891
+ log: (line) => logger.info(line),
892
+ ffmpegPids: () => [...outputParts.outputs.values()]
893
+ .flatMap((output) => outputParts.encodeRuns.liveRunsOf(output))
894
+ .map((run) => run.process?.pid)
895
+ .filter((pid) => Number.isInteger(pid)),
896
+ detailed: () => startupProfile.active()
897
+ });
878
898
  // What the last life of this process left on the disk. The kernel kills this
879
899
  // one often enough for that to be an ordinary state rather than an odd one —
880
900
  // twice in a single viewing on 2026-09-02 — and when it does, no exit handler
@@ -1106,9 +1126,11 @@ export async function startProxyServer({
1106
1126
  );
1107
1127
  // Bytes a browser can time its link with before any film has been chosen.
1108
1128
  app.get("/api/link-probe", async (req, reply) => handleApiLinkProbeGet(req, reply));
1109
- app.post("/api/sources", async (req, reply) =>
1110
- handleApiSourcesPost(req, reply, { sourceRegistry, viewers: outputParts.viewers })
1111
- );
1129
+ app.post("/api/sources", async (req, reply) => {
1130
+ // A cold start begins with the first source opened (torrent-tv/meta#177).
1131
+ startupProfile.begin();
1132
+ return handleApiSourcesPost(req, reply, { sourceRegistry, viewers: outputParts.viewers });
1133
+ });
1112
1134
  app.get("/api/sources/:sourceKey/stats", async (req, reply) =>
1113
1135
  handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool })
1114
1136
  );
@@ -20,6 +20,7 @@ import { writeAdmittedInput } from "./AdmittedInput.js";
20
20
  import { cutOf, cutShiftOf, judgeNeighbors, judgePiece } from "./piece-completeness.js";
21
21
  import { presentationSegment } from "./segment-formats/presentation-segment.js";
22
22
  import { InputFailures, failedAdmittedInput } from "./InputFailures.js";
23
+ import { readProcessCpuSeconds } from "./host-load.js";
23
24
 
24
25
 
25
26
  /**
@@ -161,6 +162,15 @@ export class EncodeRuns {
161
162
  * @type {Map<number, import("./EncodeRun.js").EncodeRun>}
162
163
  */
163
164
  #runsByInputToken = new Map();
165
+ /**
166
+ * Each live run's ffmpeg CPU seconds against the film it had made, as of its
167
+ * last progress report: what one second of film costs this machine in
168
+ * processor time, which the speed in x does not say once other work shares
169
+ * the processor (torrent-tv/meta#177). Said when the run ends.
170
+ *
171
+ * @type {WeakMap<object, { cpuSeconds: number, filmSeconds: number, reading: boolean, beside: number }>}
172
+ */
173
+ #cpuOfRun = new WeakMap();
164
174
 
165
175
  #lastInputToken = 0;
166
176
  #inputFailures = new InputFailures();
@@ -1046,11 +1056,12 @@ export class EncodeRuns {
1046
1056
  const since = (at) => at ? `${at - run.startedAt}ms` : "never";
1047
1057
  this.#host.logger.info(`encode input of run #${run.from}..#${run.to} on ${session.outputKey}: ` +
1048
1058
  `${reads.count} read(s), ${reads.bytes} bytes, first at ${since(reads.firstAt)}, last at ${since(reads.lastAt)}, ` +
1049
- `ended at ${Date.now() - run.startedAt}ms`);
1059
+ `ended at ${Date.now() - run.startedAt}ms` + describeTaken(reads.answers, run.startedAt, ended.firstNamedMs));
1050
1060
  }
1051
1061
  this.#noteOpen(session, run, ended);
1052
1062
  admittedInput.release();
1053
1063
  }
1064
+ this.#sayRunCpu(session, run);
1054
1065
  this.noteRunEnded(session, run, ended);
1055
1066
  this.#host.encodersChanged?.();
1056
1067
  }
@@ -1167,6 +1178,7 @@ export class EncodeRuns {
1167
1178
  if (!this.isLive(session) || !this.runsOf(session).includes(run)) {
1168
1179
  return;
1169
1180
  }
1181
+ this.#readRunCpu(run, progress);
1170
1182
  if (run.progress.shouldLog(PROGRESS_LOG_INTERVAL_MS)) {
1171
1183
  this.#host.logger.info(
1172
1184
  `transcode ${session.id} "${session.file.name}" ${progress.percent.toFixed(1)}% ` +
@@ -1176,6 +1188,44 @@ export class EncodeRuns {
1176
1188
  }
1177
1189
  }
1178
1190
 
1191
+ /**
1192
+ * Read the run's ffmpeg CPU beside the film it has made, one reading at a
1193
+ * time; a reading that cannot be taken (no `/proc`, the process gone)
1194
+ * leaves the last one.
1195
+ *
1196
+ * @param {import("./EncodeRun.js").EncodeRun} run
1197
+ * @param {{ processedSeconds?: number, startPositionSeconds?: number }} progress
1198
+ */
1199
+ #readRunCpu(run, progress) {
1200
+ const pid = run.process?.pid;
1201
+ const filmSeconds = Number(progress?.processedSeconds) - Number(progress?.startPositionSeconds ?? 0);
1202
+ const known = this.#cpuOfRun.get(run) ?? { cpuSeconds: 0, filmSeconds: 0, reading: false, beside: 0 };
1203
+ this.#cpuOfRun.set(run, known);
1204
+ if (known.reading || !Number.isInteger(pid) || !(filmSeconds > 0)) return;
1205
+ known.reading = true;
1206
+ void readProcessCpuSeconds(pid).then((cpuSeconds) => {
1207
+ if (Number.isFinite(cpuSeconds)) {
1208
+ known.cpuSeconds = cpuSeconds;
1209
+ known.filmSeconds = filmSeconds;
1210
+ known.beside = Math.max(known.beside, this.#runsByInputToken.size - 1);
1211
+ }
1212
+ }).finally(() => { known.reading = false; });
1213
+ }
1214
+
1215
+ /**
1216
+ * What the run's ffmpeg spent per second of film, said once when it ends.
1217
+ *
1218
+ * @param {object} session
1219
+ * @param {import("./EncodeRun.js").EncodeRun} run
1220
+ */
1221
+ #sayRunCpu(session, run) {
1222
+ const known = this.#cpuOfRun.get(run);
1223
+ if (!known || !(known.filmSeconds > 0) || !(known.cpuSeconds > 0)) return;
1224
+ this.#host.logger.info(`encode-run #${run.from}..#${run.to} on ${session.outputKey}: ffmpeg used ` +
1225
+ `${known.cpuSeconds.toFixed(2)}s of CPU for ${known.filmSeconds.toFixed(2)}s of film, ` +
1226
+ `${(known.cpuSeconds / known.filmSeconds).toFixed(3)}s per second of film; at most ${known.beside} other encoder(s) alive beside it`);
1227
+ }
1228
+
1179
1229
  /**
1180
1230
  * What to do about a run that has ended.
1181
1231
  *
@@ -1531,3 +1581,26 @@ export class EncodeRuns {
1531
1581
  });
1532
1582
  }
1533
1583
  }
1584
+
1585
+ /**
1586
+ * What FFmpeg was handed of an original input, as a clause of the run's input
1587
+ * line (torrent-tv/meta#177): bytes handed to the system (at most what it
1588
+ * read), connections closed before an answer was all handed over,
1589
+ * the share of reads that went forward, the largest jump back, bytes taken
1590
+ * twice, and what it read before it named its first piece — its open.
1591
+ *
1592
+ * @param {{ count: number, written: number, unfinished: number, forwardShare: number | null, largestBackBytes: number, rewritten: number, list: { at: number, written: number }[] } | undefined} answers
1593
+ * @param {number} startedAt
1594
+ * @param {number | null | undefined} firstNamedMs
1595
+ * @returns {string}
1596
+ */
1597
+ function describeTaken(answers, startedAt, firstNamedMs) {
1598
+ if (!answers || answers.count === 0) return "";
1599
+ const opened = Number.isFinite(firstNamedMs)
1600
+ ? answers.list.filter((answer) => answer.at - startedAt <= firstNamedMs) : null;
1601
+ return `; handed over: ${answers.written} bytes in ${answers.count} answer(s), ${answers.unfinished} closed before all was handed over, ` +
1602
+ `${answers.forwardShare === null ? "n/a" : `${Math.round(answers.forwardShare * 100)}%`} forward, ` +
1603
+ `largest jump back ${answers.largestBackBytes} bytes, ${answers.rewritten} bytes taken twice` +
1604
+ (opened ? `; before the first piece was named ${opened.length} answer(s), ` +
1605
+ `${opened.reduce((sum, answer) => sum + answer.written, 0)} bytes` : "");
1606
+ }
@@ -35,6 +35,11 @@ export async function admitOriginalInput({ sources, reserve, readRanges, now = D
35
35
  // time went: field 2026-10-08, a run took 6-9 s for a piece FFmpeg makes in
36
36
  // 1.6 s from a file, and nothing said whether it was waiting on its input.
37
37
  const reads = { count: 0, bytes: 0, firstAt: 0, lastAt: 0 };
38
+ // Each answer FFmpeg was sent, in order: where it began, where its range
39
+ // ended, what was handed to the system before the connection closed, and
40
+ // whether all of it was (torrent-tv/meta#177).
41
+ /** @type {{ fileIndex: number, start: number, end: number, written: number, handedOver: boolean, at: number }[]} */
42
+ const answers = [];
38
43
  return {
39
44
  kind: "result", original: true, sources, bytes, copyMs, fingerprint: hash.digest("hex"),
40
45
  // No track's own end: the container names the bytes an interval needs,
@@ -59,7 +64,10 @@ export async function admitOriginalInput({ sources, reserve, readRanges, now = D
59
64
  return { length: fileLength, end: stop, bytes: entry.buffers[position].subarray(start - ranges[position][0], stop - ranges[position][0] + 1) };
60
65
  },
61
66
  lengthOf: fileIndex => held.get(fileIndex)?.source.input.fileLength ?? null,
62
- reads: () => ({ ...reads }),
67
+ reads: () => ({ ...reads, answers: describeAnswers(answers) }),
68
+ noteAnswer(answer) {
69
+ if (!released) answers.push({ ...answer, at: Date.now() });
70
+ },
63
71
  release() {
64
72
  if (released) return;
65
73
  released = true;
@@ -77,3 +85,39 @@ export function originalInputBytes(sources) {
77
85
  return sources.reduce((total, source) => total + source.input.ranges
78
86
  .reduce((sum, [start, end]) => sum + end - start + 1, 0), 0);
79
87
  }
88
+
89
+ /**
90
+ * The shape of FFmpeg's reads of one input, from the answers it was sent:
91
+ * how much was handed to the system (at most what FFmpeg read), how many
92
+ * connections closed before all of an answer was handed over, what share began
93
+ * at or after the end of the previous one (a forward read), the largest jump
94
+ * back, and the bytes written again that an earlier answer had already
95
+ * written. Per file, since two files are read independently.
96
+ *
97
+ * @param {{ fileIndex: number, start: number, end: number, written: number, handedOver: boolean, at: number }[]} answers
98
+ * @returns {{ count: number, written: number, unfinished: number, forwardShare: number | null, largestBackBytes: number, rewritten: number, list: object[] }}
99
+ */
100
+ export function describeAnswers(answers) {
101
+ let written = 0, unfinished = 0, forward = 0, largestBackBytes = 0, rewritten = 0;
102
+ const reachedOf = new Map();
103
+ const coveredOf = new Map();
104
+ for (const answer of answers) {
105
+ written += answer.written;
106
+ if (!answer.handedOver) unfinished += 1;
107
+ const reached = reachedOf.get(answer.fileIndex);
108
+ if (reached === undefined || answer.start >= reached) forward += 1;
109
+ else largestBackBytes = Math.max(largestBackBytes, reached - answer.start);
110
+ // Bytes actually taken by this answer, as a range from its start.
111
+ const takenEnd = answer.start + Math.max(0, answer.written) - 1;
112
+ const covered = coveredOf.get(answer.fileIndex) ?? [];
113
+ for (const [from, to] of covered) {
114
+ const overlap = Math.min(to, takenEnd) - Math.max(from, answer.start) + 1;
115
+ if (overlap > 0) rewritten += overlap;
116
+ }
117
+ if (takenEnd >= answer.start) covered.push([answer.start, takenEnd]);
118
+ coveredOf.set(answer.fileIndex, covered);
119
+ reachedOf.set(answer.fileIndex, Math.max(reached ?? 0, takenEnd + 1));
120
+ }
121
+ return { count: answers.length, written, unfinished,
122
+ forwardShare: answers.length ? forward / answers.length : null, largestBackBytes, rewritten, list: answers };
123
+ }
@@ -77,6 +77,13 @@ import { OtherDemand, divideAllowance } from "./allowance.js";
77
77
  export class MachineBudget {
78
78
  #revisionPromise = null;
79
79
  #reviseAgain = false;
80
+ /**
81
+ * The revision in hand, for its own line: when it was first asked for, how
82
+ * many asks joined it, and which pass this is. At a cold start on the addon
83
+ * host a caller waited 20.2 s inside `revise()` and nothing said whether it
84
+ * waited for passes, for a reading, or for the thread (torrent-tv/meta#177).
85
+ */
86
+ #revision = null;
80
87
  /** Resource name → how to read what is free, and what others have needed. */
81
88
  #resources = new Map();
82
89
 
@@ -151,11 +158,14 @@ export class MachineBudget {
151
158
  revise() {
152
159
  if (this.#revisionPromise) {
153
160
  this.#reviseAgain = true;
161
+ this.#revision.asks += 1;
154
162
  return this.#revisionPromise;
155
163
  }
164
+ this.#revision = { askedAt: Date.now(), asks: 1, pass: 0 };
156
165
  this.#revisionPromise = (async () => {
157
166
  do {
158
167
  this.#reviseAgain = false;
168
+ this.#revision.pass += 1;
159
169
  await this.#readRevision();
160
170
  } while (this.#reviseAgain);
161
171
  return this.#last;
@@ -164,13 +174,17 @@ export class MachineBudget {
164
174
  }
165
175
 
166
176
  async #readRevision() {
177
+ const passFrom = Date.now();
178
+ let readingMs = 0;
167
179
  for (const [name, resource] of this.#resources) {
168
180
  const claimants = this.#claimants.filter((one) => one.resource === name);
169
181
  if (claimants.length === 0) {
170
182
  continue;
171
183
  }
172
184
  const held = claimants.reduce((sum, one) => sum + Math.max(0, one.held()), 0);
185
+ const readFrom = Date.now();
173
186
  const free = await resource.readFree();
187
+ readingMs += Date.now() - readFrom;
174
188
  // WITHOUT A READING, NOTHING IS ALLOWED TO GROW. A resource whose free
175
189
  // space cannot be read is not a resource with room; answering "unbounded"
176
190
  // there is how the spill file came to have no limit in the first place.
@@ -197,7 +211,11 @@ export class MachineBudget {
197
211
  );
198
212
  }
199
213
  }
200
- this.#logger?.info?.(this.describe());
214
+ const revision = this.#revision;
215
+ this.#logger?.info?.(`${this.describe()}` + (revision
216
+ ? ` (pass ${revision.pass} of a revision asked ${revision.asks} time(s), first ${Date.now() - revision.askedAt}ms ago; ` +
217
+ `this pass ${Date.now() - passFrom}ms, ${readingMs}ms of it reading what is free)`
218
+ : ""));
201
219
  return this.#last;
202
220
  }
203
221
 
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @file A CPU profile of the main thread at a cold start, asked for by the
3
+ * operator and never taken otherwise (torrent-tv/meta#177).
4
+ *
5
+ * At a cold start on the addon host (#166 run 12) the picture's first input
6
+ * waited 29.5 s, 20.2 s of it inside a budget revision, while the main thread
7
+ * ran at 83-108 % of a core; what it was doing is not in any log. A profile of
8
+ * that thread names it.
9
+ *
10
+ * In-process, through `node:inspector`: the sampling profiler runs beside the
11
+ * thread and does not stop it — unlike the debugger that stopped every thread
12
+ * of this process on 2026-09-05 (torrent-tv/meta#77). It begins at the first
13
+ * source opened, which is where a cold start begins, runs for the seconds the
14
+ * operator named, and writes `cpu-start-<time>.cpuprofile` beside the log,
15
+ * readable in Chrome's DevTools (Performance, "Load profile").
16
+ */
17
+
18
+ import { Session } from "node:inspector/promises";
19
+ import { writeFile } from "node:fs/promises";
20
+ import path from "node:path";
21
+
22
+ /**
23
+ * @param {object} params
24
+ * @param {number} params.seconds - How long to profile; nothing happens unless positive.
25
+ * @param {string} params.dir - Where to write the profile.
26
+ * @param {(line: string) => void} params.log
27
+ * @param {(bytes: number) => boolean} [params.mayKeep] - Whether the storage budget lets it be kept.
28
+ * @returns {{ begin: () => void, active: () => boolean }}
29
+ */
30
+ export function startupCpuProfile({ seconds, dir, log, mayKeep = () => true }) {
31
+ let begun = false;
32
+ let running = false;
33
+ const wanted = Number.isFinite(seconds) && seconds > 0 && typeof dir === "string" && dir.length > 0;
34
+ const take = async () => {
35
+ const session = new Session();
36
+ session.connect();
37
+ try {
38
+ await session.post("Profiler.enable");
39
+ await session.post("Profiler.start");
40
+ running = true;
41
+ log(`cpu profile: the main thread is profiled for ${seconds}s from the first source opened`);
42
+ await new Promise((resolve) => {
43
+ const timer = setTimeout(resolve, seconds * 1000);
44
+ if (typeof timer.unref === "function") timer.unref();
45
+ });
46
+ const { profile } = await session.post("Profiler.stop");
47
+ const text = JSON.stringify(profile);
48
+ if (!mayKeep(Buffer.byteLength(text))) {
49
+ log(`cpu profile: not kept, ${Buffer.byteLength(text)} bytes refused by the storage budget`);
50
+ return;
51
+ }
52
+ const file = path.join(dir, `cpu-start-${new Date().toISOString().replace(/[:.]/g, "-")}.cpuprofile`);
53
+ await writeFile(file, text);
54
+ log(`cpu profile: written to ${file} (${Buffer.byteLength(text)} bytes)`);
55
+ } finally {
56
+ running = false;
57
+ session.disconnect();
58
+ }
59
+ };
60
+ return {
61
+ begin() {
62
+ if (!wanted || begun) return;
63
+ begun = true;
64
+ void take().catch((error) => log(`cpu profile: failed — ${error.message}`));
65
+ },
66
+ active: () => running
67
+ };
68
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * @file Where this machine's processor goes: the proxy's own threads and every
3
+ * encoder it runs (torrent-tv/meta#177).
4
+ *
5
+ * The encoders are priced in seconds of work per second of film, and the
6
+ * torrent per megabyte moved. What neither price carries was seen only from
7
+ * outside: at a cold start on the addon host (#166 run 12, 2026-10-10) the main
8
+ * thread ran at 83-108 % of a core for thirty seconds, measured by a shell loop
9
+ * reading `/proc`, while the proxy's own log said nothing for 10.7 s.
10
+ *
11
+ * Read once a second, said once a minute as each part's mean and highest
12
+ * second, and every second while a cold start is being profiled. The Node
13
+ * process is counted ONCE, as `process.cpuUsage()` reports it; the split into
14
+ * threads only says where that time went. Each ffmpeg is its own process and
15
+ * is added beside it. Linux only for the split and for ffmpeg; elsewhere the
16
+ * process figure alone is said.
17
+ */
18
+
19
+ import { readFile, readdir } from "node:fs/promises";
20
+
21
+ /** Clock ticks per second of `/proc/<pid>/stat`, as in `encode/host-load.js`. */
22
+ const CLOCK_TICKS_PER_SECOND = 100;
23
+
24
+ /**
25
+ * The CPU seconds and the name in one `/proc/.../stat` text.
26
+ *
27
+ * The name can hold spaces and brackets, so it is everything between the first
28
+ * `(` and the LAST `)`, and the fields are counted from there.
29
+ *
30
+ * @param {string} text
31
+ * @returns {{ name: string, seconds: number } | null}
32
+ */
33
+ export function parseStat(text) {
34
+ const open = text.indexOf("(");
35
+ const close = text.lastIndexOf(")");
36
+ if (open < 0 || close < open) return null;
37
+ const after = text.slice(close + 2).trim().split(/\s+/);
38
+ // After the name and the state, utime is the 12th field and stime the 13th.
39
+ const utime = Number(after[11]);
40
+ const stime = Number(after[12]);
41
+ if (!Number.isFinite(utime) || !Number.isFinite(stime)) return null;
42
+ return { name: text.slice(open + 1, close), seconds: (utime + stime) / CLOCK_TICKS_PER_SECOND };
43
+ }
44
+
45
+ /**
46
+ * Which part of the proxy a thread belongs to: the main thread, the torrent
47
+ * thread (Node names a worker's thread `WorkerThread`), or the rest — V8's
48
+ * helpers, libuv's pool, the inspector — counted together.
49
+ *
50
+ * @param {number} tid
51
+ * @param {string} name
52
+ * @param {number} pid
53
+ * @returns {"main" | "worker" | "other"}
54
+ */
55
+ export function partOfThread(tid, name, pid) {
56
+ if (tid === pid) return "main";
57
+ if (name === "WorkerThread") return "worker";
58
+ return "other";
59
+ }
60
+
61
+ /**
62
+ * CPU seconds of this process's threads, by part, or null where `/proc` cannot
63
+ * be read.
64
+ *
65
+ * @param {number} [pid]
66
+ * @returns {Promise<{ main: number, worker: number, other: number } | null>}
67
+ */
68
+ export async function readThreadSeconds(pid = process.pid) {
69
+ let tids;
70
+ try {
71
+ tids = await readdir(`/proc/${pid}/task`);
72
+ } catch {
73
+ return null;
74
+ }
75
+ const parts = { main: 0, worker: 0, other: 0 };
76
+ await Promise.all(tids.map(async (entry) => {
77
+ const tid = Number(entry);
78
+ const stat = await readFile(`/proc/${pid}/task/${entry}/stat`, "utf8").then(parseStat, () => null);
79
+ if (stat) parts[partOfThread(tid, stat.name, pid)] += stat.seconds;
80
+ }));
81
+ return parts;
82
+ }
83
+
84
+ /**
85
+ * CPU seconds of each of these processes that can be read now.
86
+ *
87
+ * @param {number[]} pids
88
+ * @returns {Promise<Map<number, number>>}
89
+ */
90
+ export async function readProcessesSeconds(pids) {
91
+ const seconds = new Map();
92
+ await Promise.all(pids.map(async (pid) => {
93
+ const stat = await readFile(`/proc/${pid}/stat`, "utf8").then(parseStat, () => null);
94
+ if (stat) seconds.set(pid, stat.seconds);
95
+ }));
96
+ return seconds;
97
+ }
98
+
99
+ /**
100
+ * Shares of one core between two readings, per part. A thread or a process
101
+ * present in only one of the two readings contributes nothing, so a process
102
+ * that came or went in between is not read as a jump.
103
+ *
104
+ * @param {{ at: number, process: number, threads: object | null, ffmpeg: Map<number, number> }} before
105
+ * @param {{ at: number, process: number, threads: object | null, ffmpeg: Map<number, number> }} after
106
+ * @returns {{ seconds: number, process: number, main: number | null, worker: number | null, other: number | null, ffmpeg: number | null } | null}
107
+ */
108
+ export function sharesBetween(before, after) {
109
+ const seconds = (after.at - before.at) / 1000;
110
+ if (!(seconds > 0)) return null;
111
+ const share = (from, to) => (Number.isFinite(from) && Number.isFinite(to) ? Math.max(0, to - from) / seconds : null);
112
+ let ffmpeg = null;
113
+ for (const [pid, value] of after.ffmpeg) {
114
+ const earlier = before.ffmpeg.get(pid);
115
+ if (earlier !== undefined && value >= earlier) ffmpeg = (ffmpeg ?? 0) + (value - earlier) / seconds;
116
+ }
117
+ return {
118
+ seconds,
119
+ process: share(before.process, after.process),
120
+ main: share(before.threads?.main, after.threads?.main),
121
+ worker: share(before.threads?.worker, after.threads?.worker),
122
+ other: share(before.threads?.other, after.threads?.other),
123
+ ffmpeg
124
+ };
125
+ }
126
+
127
+ /**
128
+ * One line: per part, the mean over the interval and its highest second, as a
129
+ * percentage of one core.
130
+ *
131
+ * @param {object[]} seconds - Per-second shares from `sharesBetween`.
132
+ * @returns {string}
133
+ */
134
+ export function describeShares(seconds) {
135
+ const parts = ["process", "main", "worker", "other", "ffmpeg"];
136
+ const said = parts.map((part) => {
137
+ const values = seconds.map((one) => one[part]).filter((value) => Number.isFinite(value));
138
+ if (values.length === 0) return `${part} n/a`;
139
+ const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
140
+ return `${part} ${Math.round(mean * 100)}% (highest second ${Math.round(Math.max(...values) * 100)}%)`;
141
+ });
142
+ return `${said.join(", ")} of a core over ${seconds.length}s`;
143
+ }
144
+
145
+ /**
146
+ * Read every second, say once a minute, and every second while `detailed()`
147
+ * is true.
148
+ *
149
+ * @param {object} params
150
+ * @param {(line: string) => void} params.log
151
+ * @param {() => number[]} params.ffmpegPids - The encoders running now.
152
+ * @param {() => boolean} [params.detailed]
153
+ * @param {number} [params.everyMs]
154
+ * @param {number} [params.sayEveryMs]
155
+ * @returns {{ stop: () => void }}
156
+ */
157
+ export function startProcessorReport({ log, ffmpegPids, detailed = () => false, everyMs = 1000, sayEveryMs = 60_000 }) {
158
+ let previous = null;
159
+ let series = [];
160
+ let since = Date.now();
161
+ let reading = false;
162
+ const tick = async () => {
163
+ if (reading) return;
164
+ reading = true;
165
+ try {
166
+ const usage = process.cpuUsage();
167
+ const [threads, ffmpeg] = await Promise.all([readThreadSeconds(), readProcessesSeconds(ffmpegPids())]);
168
+ const now = { at: Date.now(), process: (usage.user + usage.system) / 1e6, threads, ffmpeg };
169
+ const shares = previous ? sharesBetween(previous, now) : null;
170
+ previous = now;
171
+ if (!shares) return;
172
+ series.push(shares);
173
+ if (detailed()) log(`processor: ${describeShares([shares])}`);
174
+ if (now.at - since >= sayEveryMs) {
175
+ log(`processor: ${describeShares(series)}`);
176
+ series = [];
177
+ since = now.at;
178
+ }
179
+ } finally {
180
+ reading = false;
181
+ }
182
+ };
183
+ const timer = setInterval(() => { void tick(); }, everyMs);
184
+ if (typeof timer.unref === "function") timer.unref();
185
+ return { stop: () => clearInterval(timer) };
186
+ }
@@ -475,12 +475,13 @@ export class TorrentWorkerClient {
475
475
  * @param {{ sourceKey: string, fileIndex: number, durationSeconds: number, zones: { from: number, to: number, priority: number }[] }} params
476
476
  * @returns {Promise<void>}
477
477
  */
478
- async setPriorityMap({ sourceKey, fileIndex, zones, durationSeconds }) {
478
+ async setPriorityMap({ sourceKey, fileIndex, zones, durationSeconds, postedAt }) {
479
479
  return this.#caller.call(Command.PRIORITY_MAP, {
480
480
  sourceKey,
481
481
  fileIndex,
482
482
  zones,
483
- durationSeconds
483
+ durationSeconds,
484
+ postedAt
484
485
  });
485
486
  }
486
487
 
@@ -164,11 +164,11 @@ export class WorkerTorrentPool {
164
164
  * @param {{ sourceKey: string, fileIndex: number, durationSeconds: number, zones: object[] }} params
165
165
  * @returns {Promise<void>}
166
166
  */
167
- async setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones }) {
167
+ async setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones, postedAt }) {
168
168
  if (!sourceKey) {
169
169
  return;
170
170
  }
171
- return this.#client.setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones });
171
+ return this.#client.setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones, postedAt });
172
172
  }
173
173
 
174
174
  /**
@@ -454,13 +454,17 @@ async function runCommand(command, params, id) {
454
454
  }
455
455
 
456
456
  case Command.PRIORITY_MAP: {
457
+ // When this handler began, on the clock the main thread posted by: the
458
+ // post to here holds the transfer and this thread's queue together
459
+ // (torrent-tv/meta#177).
460
+ const receivedAt = performance.timeOrigin + performance.now();
457
461
  // Source preparation owns adding the source. A delayed publication may
458
462
  // only update an existing source, never revive one after its viewer left.
459
463
  const zones = Array.isArray(params.zones) ? params.zones : [];
460
464
  // Nor may one for a file held whole, which has nothing left to fetch.
461
465
  const torrent = await knownTorrent(params.sourceKey);
462
466
  if (!torrent) {
463
- return { appliedMs: null };
467
+ return { appliedMs: null, receivedAt, repliedAt: performance.timeOrigin + performance.now() };
464
468
  }
465
469
  // What applying a map costs this thread, told to whoever sent it: every
466
470
  // zone becomes a window of the register and the swarm is told again
@@ -469,7 +473,7 @@ async function runCommand(command, params, id) {
469
473
  ensureArrivalsWired(params.sourceKey, torrent);
470
474
  pool.applyPriorityMap(torrent, params.fileIndex, zones, params.durationSeconds);
471
475
  torrent.emit("priority-map-changed", params.fileIndex);
472
- return { appliedMs: performance.now() - startedAt };
476
+ return { appliedMs: performance.now() - startedAt, receivedAt, repliedAt: performance.timeOrigin + performance.now() };
473
477
  }
474
478
 
475
479
  case Command.READ_RANGE: {
@@ -12,13 +12,21 @@ export class DownloadMaps {
12
12
  #nextEpoch = 0;
13
13
  #log;
14
14
  #now;
15
+ /**
16
+ * Milliseconds on the clock both threads read the same way, to the
17
+ * microsecond: what a publication's stages are measured with, the torrent
18
+ * thread's receipt included (torrent-tv/meta#177).
19
+ */
20
+ #clock;
15
21
 
16
- constructor({ publish, resolvePlayback = async map => map.zones, log = null, now = Date.now }) {
22
+ constructor({ publish, resolvePlayback = async map => map.zones, log = null, now = Date.now,
23
+ clock = () => performance.timeOrigin + performance.now() }) {
17
24
  if (typeof publish !== "function") throw new TypeError("Download map publication is required.");
18
25
  this.#publish = publish;
19
26
  this.#resolvePlayback = resolvePlayback;
20
27
  this.#log = typeof log === "function" ? log : null;
21
28
  this.#now = now;
29
+ this.#clock = clock;
22
30
  }
23
31
 
24
32
  async playback({ sourceKey, fileIndex, durationSeconds, zones }) {
@@ -200,6 +208,7 @@ export class DownloadMaps {
200
208
  }
201
209
 
202
210
  #emit(file) {
211
+ const builtFrom = this.#clock();
203
212
  const zones = [...file.zones, ...file.nativeZones];
204
213
  for (const { ranges, requestId, priority, urgent, deadlineAt, order } of file.metadata.values()) {
205
214
  for (const [byteStart, byteEnd] of ranges) {
@@ -217,16 +226,32 @@ export class DownloadMaps {
217
226
  // orders nothing differently, so the deadlines' order is compared and not
218
227
  // their values.
219
228
  const shape = shapeOf(publication);
229
+ // WHAT A PUBLICATION COSTS, stage by stage (torrent-tv/meta#177): building
230
+ // and comparing it here, every time something is stated, sent or not;
231
+ // waiting behind the previous one; the synchronous post, which drops the
232
+ // zones repeated for the torrent and copies the rest for the other thread;
233
+ // from the post to the torrent thread's
234
+ // handler, which holds the transfer and that thread's queue together; and
235
+ // applying it there. A cold minute of #166 run 12 built 10 408 maps and
236
+ // sent 439, and nothing said what that cost.
237
+ const builtMs = this.#clock() - builtFrom;
220
238
  if (file.shape && sameShape(file.shape, shape)) {
221
- this.#statsOf(file).unchanged += 1;
239
+ const stats = this.#statsOf(file);
240
+ stats.unchanged += 1;
241
+ stats.unsentBuildMs += builtMs;
222
242
  this.#report(file);
223
243
  return Promise.resolve();
224
244
  }
225
245
  file.shape = shape;
246
+ const askedAt = this.#clock();
226
247
  const pending = file.pending.then(() => {
227
248
  if (this.#files.get(`${file.sourceKey}:${file.fileIndex}`) !== file) return undefined;
228
249
  const sentAt = this.#now();
229
- return Promise.resolve(this.#publish(publication)).then(reply => {
250
+ const postedAt = this.#clock();
251
+ const sent = this.#publish({ ...publication, postedAt });
252
+ const postMs = this.#clock() - postedAt;
253
+ return Promise.resolve(sent).then(reply => {
254
+ const answeredAt = this.#clock();
230
255
  // A torrent not yet in the thread applies nothing, so this map was not
231
256
  // delivered and the next one, however alike, has to be.
232
257
  if (reply?.appliedMs === null && file.shape === shape) file.shape = null;
@@ -235,6 +260,11 @@ export class DownloadMaps {
235
260
  stats.sent += 1;
236
261
  stats.zones.push(zones.length);
237
262
  stats.deliveredMs.push(this.#now() - sentAt);
263
+ stats.sentBuildMs.push(builtMs);
264
+ stats.waitedMs.push(postedAt - askedAt);
265
+ stats.postMs.push(postMs);
266
+ if (Number.isFinite(reply?.receivedAt)) stats.toHandlerMs.push(Math.max(0, reply.receivedAt - postedAt - postMs));
267
+ if (Number.isFinite(reply?.repliedAt)) stats.replyMs.push(Math.max(0, answeredAt - reply.repliedAt));
238
268
  if (Number.isFinite(reply?.appliedMs)) stats.appliedMs.push(reply.appliedMs);
239
269
  this.#report(file);
240
270
  return reply;
@@ -249,7 +279,7 @@ export class DownloadMaps {
249
279
  }
250
280
 
251
281
  #statsOf(file) {
252
- file.stats ??= { since: this.#now(), sent: 0, unchanged: 0, zones: [], deliveredMs: [], appliedMs: [] };
282
+ file.stats ??= emptyStats(this.#now());
253
283
  return file.stats;
254
284
  }
255
285
 
@@ -264,11 +294,28 @@ export class DownloadMaps {
264
294
  `zones median ${middle(stats.zones)} max ${largest(stats.zones)}; ` +
265
295
  `delivered median ${middle(stats.deliveredMs)}ms max ${largest(stats.deliveredMs)}ms (${total(stats.deliveredMs)}ms in all), ` +
266
296
  `applied in the torrent thread median ${middle(stats.appliedMs)}ms max ${largest(stats.appliedMs)}ms ` +
267
- `(${total(stats.appliedMs)}ms in all)`);
268
- file.stats = { since: now, sent: 0, unchanged: 0, zones: [], deliveredMs: [], appliedMs: [] };
297
+ `(${total(stats.appliedMs)}ms in all); ` +
298
+ `stages of a sent map — built ${stage(stats.sentBuildMs)}, waited behind the previous ${stage(stats.waitedMs)}, ` +
299
+ `posted ${stage(stats.postMs)}, post to the torrent thread's handler ${stage(stats.toHandlerMs)}, ` +
300
+ `reply back ${stage(stats.replyMs)}; maps built and not sent took ${total([stats.unsentBuildMs])}ms here`);
301
+ file.stats = emptyStats(now);
269
302
  }
270
303
  }
271
304
 
305
+ /** @param {number} since */
306
+ function emptyStats(since) {
307
+ return { since, sent: 0, unchanged: 0, zones: [], deliveredMs: [], appliedMs: [],
308
+ sentBuildMs: [], waitedMs: [], postMs: [], toHandlerMs: [], replyMs: [], unsentBuildMs: 0 };
309
+ }
310
+
311
+ /** One stage's median, largest and sum, in milliseconds with tenths. @param {number[]} values */
312
+ function stage(values) {
313
+ if (values.length === 0) return "n/a";
314
+ const sorted = [...values].sort((left, right) => left - right);
315
+ const sum = values.reduce((total, value) => total + value, 0);
316
+ return `median ${sorted[Math.floor(sorted.length / 2)].toFixed(1)}ms max ${sorted.at(-1).toFixed(1)}ms sum ${Math.round(sum)}ms`;
317
+ }
318
+
272
319
  /** How many numbers describe one zone's part in what the torrent does. */
273
320
  const ZONE_FIELDS = 7;
274
321