@torrent-tv/proxy 2.96.14 → 2.97.1

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.
@@ -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