@torrent-tv/proxy 2.101.0 → 2.101.2

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,395 @@
1
+ /**
2
+ * @file How much processor this process could ever use, and how much of it was
3
+ * free over an interval (torrent-tv/meta#180, phase 1: readings only).
4
+ *
5
+ * Two quantities, never put in each other's place:
6
+ *
7
+ * 1. **The upper bound**, in cores: what the operating system lets this process
8
+ * run on — the CPUs it may be scheduled on (`os.availableParallelism()`,
9
+ * which follows the affinity mask on Linux) and the CPU quota of every cgroup
10
+ * on its path (Docker's `--cpus`, systemd's `CPUQuota=`), the smallest of
11
+ * them. A quota may be fractional: `cpu.max` of `150000 100000` is one and a
12
+ * half cores. The bound says what can never be exceeded; it says nothing of
13
+ * what is free.
14
+ * 2. **The measured free amount**, in cores: the idle time of the machine's
15
+ * CPUs over the interval — from `/proc/stat` where the caller has it, from
16
+ * `os.cpus()` times on every other platform — and, under a bound smaller
17
+ * than the machine, no more than the bound less what this proxy and its
18
+ * encoders used. Inside a container both readings describe the HOST's CPUs,
19
+ * which is what the other containers left. Where neither reading exists, or
20
+ * our own use is unknown under such a bound, it is unknown, and said so.
21
+ *
22
+ * A cgroup's own `cpu.stat` is NOT a reading of other work: its usage is what
23
+ * this cgroup and its descendants used, and its throttling says that the quota
24
+ * held our work back. Both are reported as that, and nothing else.
25
+ *
26
+ * Memory had the same defect this file answers for the processor — the host's
27
+ * figure where a container's limit decided — and was fixed in #153/#155
28
+ * (`machine-memory.js`); the cgroup is found the same way.
29
+ */
30
+
31
+ import { readFileSync } from "node:fs";
32
+ import os from "node:os";
33
+
34
+ import { cgroupOf, levelsOf } from "./machine-memory.js";
35
+
36
+ /**
37
+ * Where each cgroup version keeps its CPU controller, and under which names.
38
+ *
39
+ * Version 1 keeps the usage in a controller of its own, `cpuacct`, mounted
40
+ * either beside `cpu` (`cpu,cpuacct`, with `cpu` and `cpuacct` as links to
41
+ * it) or on its own; `/sys/fs/cgroup/cpuacct` names it in both cases. Its
42
+ * counters are in nanoseconds; version 2's in microseconds.
43
+ */
44
+ const HIERARCHIES = {
45
+ 1: { mount: "/sys/fs/cgroup/cpu", source: "cgroup v1 cfs", usageController: "cpuacct", usageMount: "/sys/fs/cgroup/cpuacct",
46
+ usage: "cpuacct.usage", usageUnit: 1e-9, throttledName: "throttled_time", throttledUnit: 1e-9 },
47
+ 2: { mount: "/sys/fs/cgroup", source: "cgroup v2 cpu.max", usageController: "cpu", usageMount: "/sys/fs/cgroup",
48
+ usage: "cpu.stat", usageUnit: 1e-6, throttledName: "throttled_usec", throttledUnit: 1e-6 }
49
+ };
50
+
51
+ /**
52
+ * @typedef {object} ProcessorBound
53
+ * @property {number} cores - What this process can never exceed, in cores.
54
+ * @property {number} parallelism - The CPUs it may be scheduled on.
55
+ * @property {number | null} quotaCores - The smallest cgroup quota on its path, or null for none.
56
+ * @property {string} source - Which of the two decided `cores`.
57
+ */
58
+
59
+ /**
60
+ * @typedef {object} CgroupCpu
61
+ * @property {number | null} usageSeconds - CPU seconds this cgroup and its descendants used.
62
+ * @property {number | null} throttledPeriods - Periods the binding quota held the cgroup back.
63
+ * @property {number | null} throttledSeconds - Time it was held back.
64
+ */
65
+
66
+ /**
67
+ * @typedef {object} ProcessorSample
68
+ * @property {number} takenAt - Wall clock, ms.
69
+ * @property {ProcessorBound} bound
70
+ * @property {CgroupCpu | null} cgroup
71
+ * @property {number | null} idleSeconds - Idle time of every CPU since boot, from `os.cpus()`.
72
+ */
73
+
74
+ /**
75
+ * What this process can never exceed.
76
+ *
77
+ * @param {object} [options]
78
+ * @param {(path: string) => string} [options.readFile] - Throws where a file is absent; injected by the checks.
79
+ * @param {number} [options.parallelism]
80
+ * @returns {ProcessorBound}
81
+ */
82
+ export function processorBound({ readFile = readText, parallelism = os.availableParallelism() } = {}) {
83
+ const quota = quotaOnPath(readFile);
84
+ const cores = Math.max(1, parallelism);
85
+ if (quota !== null && quota.cores < cores) {
86
+ return { cores: quota.cores, parallelism: cores, quotaCores: quota.cores, source: HIERARCHIES[quota.version].source };
87
+ }
88
+ return { cores, parallelism: cores, quotaCores: quota?.cores ?? null, source: "cores" };
89
+ }
90
+
91
+ /**
92
+ * This cgroup's own CPU counters: its usage, and how often its binding quota
93
+ * held it back. Null where there is no cgroup to read (not Linux, no CPU
94
+ * controller); each counter null where its file is not there.
95
+ *
96
+ * @param {object} [options]
97
+ * @param {(path: string) => string} [options.readFile]
98
+ * @returns {CgroupCpu | null}
99
+ */
100
+ export function cgroupCpu({ readFile = readText } = {}) {
101
+ const levels = cpuLevels(readFile);
102
+ if (levels === null) {
103
+ return null;
104
+ }
105
+ const hierarchy = HIERARCHIES[levels.version];
106
+ let usageSeconds = null;
107
+ for (const directory of usageLevels(readFile, levels.version)) {
108
+ const text = tryRead(readFile, `${directory}/${hierarchy.usage}`);
109
+ if (text === null) continue;
110
+ const value = levels.version === 2 ? statField(text, "usage_usec") : wholeNumber(text);
111
+ if (value !== null) {
112
+ usageSeconds = value * hierarchy.usageUnit;
113
+ break;
114
+ }
115
+ }
116
+ let throttledPeriods = null;
117
+ let throttledSeconds = null;
118
+ const binding = bindingLevel(readFile, levels);
119
+ const stat = binding === null ? null : tryRead(readFile, `${binding}/cpu.stat`);
120
+ if (stat !== null) {
121
+ throttledPeriods = statField(stat, "nr_throttled");
122
+ const time = statField(stat, hierarchy.throttledName);
123
+ throttledSeconds = time === null ? null : time * hierarchy.throttledUnit;
124
+ }
125
+ return { usageSeconds, throttledPeriods, throttledSeconds };
126
+ }
127
+
128
+ /**
129
+ * The idle time of every CPU since boot, from `os.cpus()`, which every
130
+ * platform answers. Null where it states no times.
131
+ *
132
+ * @param {Array<{ times?: { idle?: number } }>} [cpus]
133
+ * @returns {number | null} Seconds.
134
+ */
135
+ export function cpusIdleSeconds(cpus = os.cpus()) {
136
+ if (!Array.isArray(cpus) || cpus.length === 0) {
137
+ return null;
138
+ }
139
+ let idleMs = 0;
140
+ for (const cpu of cpus) {
141
+ const idle = Number(cpu?.times?.idle);
142
+ if (!Number.isFinite(idle)) return null;
143
+ idleMs += idle;
144
+ }
145
+ return idleMs / 1000;
146
+ }
147
+
148
+ /**
149
+ * One reading of everything above, to be compared against the next.
150
+ *
151
+ * @param {object} [options]
152
+ * @param {(path: string) => string} [options.readFile]
153
+ * @param {() => Array<{ times?: { idle?: number } }>} [options.cpus]
154
+ * @param {number} [options.parallelism]
155
+ * @param {() => number} [options.now]
156
+ * @returns {ProcessorSample}
157
+ */
158
+ export function sampleProcessor({ readFile = readText, cpus = () => os.cpus(), parallelism, now = Date.now } = {}) {
159
+ return {
160
+ takenAt: now(),
161
+ bound: processorBound({ readFile, ...(parallelism === undefined ? {} : { parallelism }) }),
162
+ cgroup: cgroupCpu({ readFile }),
163
+ idleSeconds: cpusIdleSeconds(cpus())
164
+ };
165
+ }
166
+
167
+ /**
168
+ * @typedef {object} ProcessorReading
169
+ * @property {ProcessorBound} bound
170
+ * @property {number | null} freeCores - Measured free, in cores; null when unknown.
171
+ * @property {string} freeSource - Where it came from, or why it is unknown.
172
+ * @property {number | null} cgroupUsedCores - This cgroup's own use over the interval.
173
+ * @property {number | null} throttledPeriods
174
+ * @property {number | null} throttledSeconds
175
+ */
176
+
177
+ /**
178
+ * What two samples say about the interval between them.
179
+ *
180
+ * @param {ProcessorSample | null | undefined} before
181
+ * @param {ProcessorSample | null | undefined} after
182
+ * @param {object} measured - Shares of the WHOLE machine over the same interval.
183
+ * @param {number | null} measured.systemShare - Everything the machine did, from `/proc/stat`; null where unread.
184
+ * @param {number | null} measured.ourShare - The proxy and its encoders; null where either is unread.
185
+ * @param {number} measured.hostCores - The CPUs those shares are fractions of.
186
+ * @returns {ProcessorReading | null} Null without two samples.
187
+ */
188
+ export function processorBetween(before, after, { systemShare, ourShare, hostCores }) {
189
+ if (!before || !after) {
190
+ return null;
191
+ }
192
+ const elapsedSec = (after.takenAt - before.takenAt) / 1000;
193
+ if (!(elapsedSec > 0)) {
194
+ return null;
195
+ }
196
+ const cores = Math.max(1, hostCores);
197
+ let idleCores = null;
198
+ let freeSource = "unknown: no idle reading";
199
+ if (Number.isFinite(systemShare)) {
200
+ idleCores = clamp((1 - systemShare) * cores, 0, cores);
201
+ freeSource = "/proc/stat";
202
+ } else if (Number.isFinite(before.idleSeconds) && Number.isFinite(after.idleSeconds) && after.idleSeconds >= before.idleSeconds) {
203
+ idleCores = clamp((after.idleSeconds - before.idleSeconds) / elapsedSec, 0, cores);
204
+ freeSource = "os.cpus";
205
+ }
206
+ let freeCores = idleCores;
207
+ const bound = after.bound;
208
+ if (idleCores !== null && bound.cores < cores) {
209
+ // Under a bound smaller than the machine, an idle CPU is ours only as far
210
+ // as the bound leaves room beside what we already use.
211
+ if (Number.isFinite(ourShare)) {
212
+ freeCores = Math.min(idleCores, Math.max(0, bound.cores - ourShare * cores));
213
+ } else {
214
+ freeCores = null;
215
+ freeSource = "unknown: our own use is unread under a limit";
216
+ }
217
+ }
218
+ const delta = (key) => {
219
+ const from = before.cgroup?.[key];
220
+ const to = after.cgroup?.[key];
221
+ return Number.isFinite(from) && Number.isFinite(to) && to >= from ? to - from : null;
222
+ };
223
+ const used = delta("usageSeconds");
224
+ return {
225
+ bound,
226
+ freeCores,
227
+ freeSource,
228
+ cgroupUsedCores: used === null ? null : used / elapsedSec,
229
+ throttledPeriods: delta("throttledPeriods"),
230
+ throttledSeconds: delta("throttledSeconds")
231
+ };
232
+ }
233
+
234
+ /**
235
+ * The reading as it is written into the machine's line of the log.
236
+ *
237
+ * @param {ProcessorReading | null} reading
238
+ * @returns {string}
239
+ */
240
+ export function describeProcessor(reading) {
241
+ if (reading === null) {
242
+ return " bound=n/a free-cores=n/a";
243
+ }
244
+ const { bound } = reading;
245
+ const limit = bound.quotaCores === null ? "no quota" : `quota ${bound.quotaCores.toFixed(2)}`;
246
+ const free = reading.freeCores === null ? `unknown (${reading.freeSource.replace(/^unknown: /, "")})`
247
+ : `${reading.freeCores.toFixed(2)} (${reading.freeSource})`;
248
+ const used = reading.cgroupUsedCores === null ? "n/a" : `${reading.cgroupUsedCores.toFixed(2)} cores`;
249
+ const throttled = reading.throttledPeriods === null && reading.throttledSeconds === null ? "n/a"
250
+ : `${reading.throttledPeriods ?? "n/a"} periods ${reading.throttledSeconds === null ? "n/a" : `${reading.throttledSeconds.toFixed(2)}s`}`;
251
+ return ` bound=${bound.cores.toFixed(2)} cores (${bound.source}; ${bound.parallelism} cpus, ${limit})` +
252
+ ` free-cores=${free} cgroup-used=${used} throttled=${throttled}`;
253
+ }
254
+
255
+ /**
256
+ * This process's cgroup for the CPU controller and the directories to read,
257
+ * its own first, or null where there is none.
258
+ *
259
+ * @param {(path: string) => string} readFile
260
+ * @returns {{ version: 1 | 2, directories: string[] } | null}
261
+ */
262
+ function cpuLevels(readFile) {
263
+ const text = tryRead(readFile, "/proc/self/cgroup");
264
+ const membership = text === null ? null : cgroupOf(text, "cpu");
265
+ if (membership === null) {
266
+ return null;
267
+ }
268
+ const directories = levelsOf(HIERARCHIES[membership.version].mount, membership.path);
269
+ return directories.length === 0 ? null : { version: membership.version, directories };
270
+ }
271
+
272
+ /**
273
+ * The directories that hold this process's CPU usage, its own first.
274
+ *
275
+ * @param {(path: string) => string} readFile
276
+ * @param {1 | 2} version
277
+ * @returns {string[]}
278
+ */
279
+ function usageLevels(readFile, version) {
280
+ const hierarchy = HIERARCHIES[version];
281
+ const text = tryRead(readFile, "/proc/self/cgroup");
282
+ const membership = text === null ? null : cgroupOf(text, hierarchy.usageController);
283
+ return membership === null ? [] : levelsOf(hierarchy.usageMount, membership.path);
284
+ }
285
+
286
+ /**
287
+ * The quota of one cgroup directory, in cores, or null where it states none.
288
+ *
289
+ * @param {(path: string) => string} readFile
290
+ * @param {1 | 2} version
291
+ * @param {string} directory
292
+ * @returns {number | null}
293
+ */
294
+ function quotaAt(readFile, version, directory) {
295
+ if (version === 2) {
296
+ const text = tryRead(readFile, `${directory}/cpu.max`);
297
+ const [quota, period] = (text ?? "").trim().split(/\s+/);
298
+ const cores = Number(quota) / Number(period);
299
+ return /^\d+$/.test(quota ?? "") && Number.isFinite(cores) && cores > 0 ? cores : null;
300
+ }
301
+ const quota = wholeNumber(tryRead(readFile, `${directory}/cpu.cfs_quota_us`) ?? "");
302
+ const period = wholeNumber(tryRead(readFile, `${directory}/cpu.cfs_period_us`) ?? "");
303
+ // `-1` is version 1's "no quota", which `wholeNumber` does not accept.
304
+ return quota !== null && period !== null && quota > 0 && period > 0 ? quota / period : null;
305
+ }
306
+
307
+ /**
308
+ * The smallest quota on this process's cgroup path, and its version.
309
+ *
310
+ * @param {(path: string) => string} readFile
311
+ * @returns {{ version: 1 | 2, cores: number } | null}
312
+ */
313
+ function quotaOnPath(readFile) {
314
+ const levels = cpuLevels(readFile);
315
+ if (levels === null) {
316
+ return null;
317
+ }
318
+ const binding = bindingLevel(readFile, levels);
319
+ return binding === null ? null : { version: levels.version, cores: quotaAt(readFile, levels.version, binding) };
320
+ }
321
+
322
+ /**
323
+ * The directory whose quota is the smallest on the path, or null for none.
324
+ *
325
+ * @param {(path: string) => string} readFile
326
+ * @param {{ version: 1 | 2, directories: string[] }} levels
327
+ * @returns {string | null}
328
+ */
329
+ function bindingLevel(readFile, levels) {
330
+ let binding = null;
331
+ let smallest = Infinity;
332
+ for (const directory of levels.directories) {
333
+ const cores = quotaAt(readFile, levels.version, directory);
334
+ if (cores !== null && cores < smallest) {
335
+ smallest = cores;
336
+ binding = directory;
337
+ }
338
+ }
339
+ return binding;
340
+ }
341
+
342
+ /**
343
+ * @param {string} path
344
+ * @returns {string}
345
+ */
346
+ function readText(path) {
347
+ return readFileSync(path, "utf8");
348
+ }
349
+
350
+ /**
351
+ * @param {(path: string) => string} readFile
352
+ * @param {string} path
353
+ * @returns {string | null}
354
+ */
355
+ function tryRead(readFile, path) {
356
+ try {
357
+ return readFile(path);
358
+ } catch {
359
+ return null; // silent-ok: not Linux, no such level, or no CPU controller on it.
360
+ }
361
+ }
362
+
363
+ /**
364
+ * @param {string} text
365
+ * @returns {number | null}
366
+ */
367
+ function wholeNumber(text) {
368
+ const trimmed = text.trim();
369
+ return /^\d+$/.test(trimmed) ? Number(trimmed) : null;
370
+ }
371
+
372
+ /**
373
+ * One field of a `cpu.stat`.
374
+ *
375
+ * @param {string} text
376
+ * @param {string} name
377
+ * @returns {number | null}
378
+ */
379
+ function statField(text, name) {
380
+ for (const line of text.split("\n")) {
381
+ const [key, value = ""] = line.trim().split(/\s+/);
382
+ if (key === name) return wholeNumber(value);
383
+ }
384
+ return null;
385
+ }
386
+
387
+ /**
388
+ * @param {number} value
389
+ * @param {number} low
390
+ * @param {number} high
391
+ * @returns {number}
392
+ */
393
+ function clamp(value, low, high) {
394
+ return Math.min(high, Math.max(low, value));
395
+ }
@@ -14,6 +14,12 @@
14
14
  * threads only says where that time went. Each ffmpeg is its own process and
15
15
  * is added beside it. Linux only for the split and for ffmpeg; elsewhere the
16
16
  * process figure alone is said.
17
+ *
18
+ * Each run is also said on its own once a minute: the middle of its seconds'
19
+ * processor and speed, and how far those seconds sit from it
20
+ * (torrent-tv/meta#180, phase 1). That scatter is what a slowdown of the most
21
+ * urgent run would have to exceed before it could be put down to another run
22
+ * beside it; nothing decides on it yet.
17
23
  */
18
24
 
19
25
  import { readFileSync, readdirSync } from "node:fs";
@@ -212,35 +218,108 @@ export function describeShares(seconds) {
212
218
  return `${said.join(", ")} of a core over ${seconds.length}s`;
213
219
  }
214
220
 
221
+ /**
222
+ * One second of each run read at both ends of it: the cores its ffmpeg used
223
+ * and the film it made per second of wall clock. A run whose film is not
224
+ * known at either end says its processor alone.
225
+ *
226
+ * @param {{ at: number, ffmpeg: Map<number, number>, runs: Map<number, { label: string, film: number | null }> }} before
227
+ * @param {{ at: number, ffmpeg: Map<number, number>, runs: Map<number, { label: string, film: number | null }> }} after
228
+ * @returns {Map<number, { label: string, cores: number, speed: number | null }>}
229
+ */
230
+ export function runSecondsBetween(before, after) {
231
+ /** @type {Map<number, { label: string, cores: number, speed: number | null }>} */
232
+ const seconds = new Map();
233
+ const elapsed = (after.at - before.at) / 1000;
234
+ if (!(elapsed > 0)) return seconds;
235
+ for (const [pid, run] of after.runs) {
236
+ const from = before.ffmpeg.get(pid);
237
+ const to = after.ffmpeg.get(pid);
238
+ const earlier = before.runs.get(pid);
239
+ if (!earlier || !Number.isFinite(from) || !Number.isFinite(to) || to < from) continue;
240
+ const made = Number.isFinite(run.film) && Number.isFinite(earlier.film) && run.film >= earlier.film ? run.film - earlier.film : null;
241
+ seconds.set(pid, { label: run.label, cores: (to - from) / elapsed, speed: made === null ? null : made / elapsed });
242
+ }
243
+ return seconds;
244
+ }
245
+
246
+ /**
247
+ * Per run, over the interval: the median of its seconds and the median of
248
+ * their distances to it, for its processor and its speed.
249
+ *
250
+ * @param {Map<number, { label: string, cores: number[], speed: number[] }>} runs
251
+ * @returns {string} Empty when no run was read for two seconds.
252
+ */
253
+ export function describeRuns(runs) {
254
+ const said = [];
255
+ for (const run of runs.values()) {
256
+ if (run.cores.length < 2) continue;
257
+ const cores = middleAndScatter(run.cores);
258
+ const speed = middleAndScatter(run.speed);
259
+ said.push(`${run.label} cores ${cores.middle.toFixed(2)} (scatter ${cores.scatter.toFixed(2)})` +
260
+ (speed === null ? ", speed n/a" : `, speed ${speed.middle.toFixed(2)}x (scatter ${speed.scatter.toFixed(2)})`) +
261
+ ` over ${run.cores.length}s`);
262
+ }
263
+ return said.join("; ");
264
+ }
265
+
266
+ /**
267
+ * @param {number[]} values
268
+ * @returns {{ middle: number, scatter: number } | null}
269
+ */
270
+ function middleAndScatter(values) {
271
+ if (values.length === 0) return null;
272
+ const median = (list) => {
273
+ const sorted = [...list].sort((a, b) => a - b);
274
+ const half = Math.floor(sorted.length / 2);
275
+ return sorted.length % 2 === 0 ? (sorted[half - 1] + sorted[half]) / 2 : sorted[half];
276
+ };
277
+ const middle = median(values);
278
+ return { middle, scatter: median(values.map((value) => Math.abs(value - middle))) };
279
+ }
280
+
215
281
  /**
216
282
  * Read every second, say once a minute, and every second while `detailed()`
217
283
  * is true.
218
284
  *
219
285
  * @param {object} params
220
286
  * @param {(line: string) => void} params.log
221
- * @param {() => number[]} params.ffmpegPids - The encoders running now.
287
+ * @param {() => Array<{ pid: number, label: string, film: number | null }>} params.ffmpegRuns - The encoders running now, with the film each has made.
222
288
  * @param {() => boolean} [params.detailed]
223
289
  * @param {number} [params.everyMs]
224
290
  * @param {number} [params.sayEveryMs]
225
291
  * @returns {{ stop: () => void }}
226
292
  */
227
- export function startProcessorReport({ log, ffmpegPids, detailed = () => false, everyMs = 1000, sayEveryMs = 60_000 }) {
293
+ export function startProcessorReport({ log, ffmpegRuns, detailed = () => false, everyMs = 1000, sayEveryMs = 60_000 }) {
228
294
  let previous = null;
229
295
  let series = [];
296
+ /** @type {Map<number, { label: string, cores: number[], speed: number[] }>} */
297
+ let runSeries = new Map();
230
298
  let since = Date.now();
231
299
  const threads = threadReader();
232
300
  const tick = () => {
233
301
  const at = Date.now();
234
302
  const usage = process.cpuUsage();
235
- const now = { at, process: (usage.user + usage.system) / 1e6, threads: threads.read(), ffmpeg: readProcessesSeconds(ffmpegPids()) };
303
+ const runs = new Map(ffmpegRuns().map((run) => [run.pid, { label: run.label, film: run.film }]));
304
+ const now = { at, process: (usage.user + usage.system) / 1e6, threads: threads.read(), ffmpeg: readProcessesSeconds([...runs.keys()]), runs };
236
305
  const shares = previous ? sharesBetween(previous, now) : null;
306
+ const perRun = previous ? runSecondsBetween(previous, now) : new Map();
237
307
  previous = now;
238
308
  if (!shares) return;
239
309
  series.push(shares);
310
+ for (const [pid, second] of perRun) {
311
+ const kept = runSeries.get(pid) ?? { label: second.label, cores: [], speed: [] };
312
+ kept.cores.push(second.cores);
313
+ if (second.speed !== null) kept.speed.push(second.speed);
314
+ runSeries.set(pid, kept);
315
+ }
240
316
  if (detailed()) log(`processor: ${describeShares([shares])}`);
241
317
  if (now.at - since >= sayEveryMs) {
242
318
  log(`processor: ${describeShares(series)}`);
319
+ const perRunLine = describeRuns(runSeries);
320
+ if (perRunLine) log(`processor: per run, by second: ${perRunLine}`);
243
321
  series = [];
322
+ runSeries = new Map();
244
323
  since = now.at;
245
324
  // A torrent thread made again since is counted as the rest until then.
246
325
  threads.rediscover();