@torrent-tv/proxy 2.92.3 → 2.92.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.92.3",
3
+ "version": "2.92.4",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -119,7 +119,8 @@ export async function readMachineState() {
119
119
  * @param {{ takenAt: number, processCpuSeconds: number | null, system: CpuTotals | null }} before
120
120
  * @param {{ takenAt: number, processCpuSeconds: number | null, system: CpuTotals | null }} after
121
121
  * @param {number} [cores=os.cpus().length]
122
- * @returns {{ elapsedSec: number, processShare: number | null, systemShare: number | null, iowaitShare: number | null } | null}
122
+ * @returns {{ elapsedSec: number, processShare: number | null, systemShare: number | null, iowaitShare: number | null, rejected: string[] } | null}
123
+ * `rejected` names the readings that were not a share of the machine at all.
123
124
  */
124
125
  export function shareOfMachine(before, after, cores = os.cpus().length) {
125
126
  const elapsedSec = (after.takenAt - before.takenAt) / 1000;
@@ -127,16 +128,33 @@ export function shareOfMachine(before, after, cores = os.cpus().length) {
127
128
  return null;
128
129
  }
129
130
  const machineSeconds = elapsedSec * cores;
131
+ // A share of the machine lies between 0 and 1. The counters are whole clock
132
+ // ticks, so each reading can be off by one tick and the allowance is exactly
133
+ // that: derived from the tick and the interval, not chosen. A value beyond it
134
+ // is not a measurement of anything (field 2026-10-06: `system=2422%` over
135
+ // 1.7 s, then `1832%` over 0.1 s), and it is refused here instead of being
136
+ // stored as a fact the next decision rests on.
137
+ const allowance = 1 / (CLOCK_TICKS_PER_SECOND * elapsedSec);
138
+ /** @type {string[]} */
139
+ const rejected = [];
140
+ const shareOf = (name, seconds) => {
141
+ const share = seconds / machineSeconds;
142
+ if (!(share >= -allowance && share <= 1 + allowance)) {
143
+ rejected.push(`${name}=${Math.round(share * 100)}%`);
144
+ return null;
145
+ }
146
+ return share;
147
+ };
130
148
  const processShare = before.processCpuSeconds !== null && after.processCpuSeconds !== null
131
- ? (after.processCpuSeconds - before.processCpuSeconds) / machineSeconds
149
+ ? shareOf("process", after.processCpuSeconds - before.processCpuSeconds)
132
150
  : null;
133
151
  const systemShare = before.system !== null && after.system !== null
134
- ? (after.system.busySeconds - before.system.busySeconds) / machineSeconds
152
+ ? shareOf("system", after.system.busySeconds - before.system.busySeconds)
135
153
  : null;
136
154
  const iowaitShare = before.system !== null && after.system !== null
137
- ? (after.system.iowaitSeconds - before.system.iowaitSeconds) / machineSeconds
155
+ ? shareOf("iowait", after.system.iowaitSeconds - before.system.iowaitSeconds)
138
156
  : null;
139
- return { elapsedSec, processShare, systemShare, iowaitShare };
157
+ return { elapsedSec, processShare, systemShare, iowaitShare, rejected };
140
158
  }
141
159
 
142
160
  /**
@@ -19,7 +19,12 @@ export class HostLoad {
19
19
  /** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
20
20
  #host;
21
21
 
22
- /** How much of the machine a new encoder could have, from the last reading; undefined before one. */
22
+ /**
23
+ * How much of the machine a new encoder could have, from the LAST reading;
24
+ * undefined before one, and `known: false` whenever the last reading was
25
+ * missing or was not a share of the machine. It is never older than the
26
+ * reading that made it: every pass either replaces it or says unknown.
27
+ */
23
28
  hostAvailability;
24
29
 
25
30
  /** The previous reading of the machine, to compare the next one against. */
@@ -28,6 +33,12 @@ export class HostLoad {
28
33
  /** The previous reading taken while nothing was encoding, for the torrent's own cost. */
29
34
  #idleLoadSample = null;
30
35
 
36
+ /** The previous reading of the whole machine taken while nothing was encoding. */
37
+ #idleAvailabilitySample = null;
38
+
39
+ /** A reading is under way; the next tick must not start another beside it. */
40
+ #reporting = false;
41
+
31
42
  /**
32
43
  * How long each file is in bytes, from its declared source statistics. The torrent moves the CONTAINER, so this — not the video stream's
33
44
  * bitrate — is what its work should be priced against.
@@ -335,7 +346,88 @@ export class HostLoad {
335
346
  }
336
347
  }
337
348
 
349
+ /**
350
+ * One reading of the machine, never beside another. The tick fires without
351
+ * waiting for the previous one, and two readings in flight at once each stamp
352
+ * their own time after their own await and then replace one another as the
353
+ * "previous" sample, so a delta of counters is divided by an interval that is
354
+ * not its own (field 2026-10-06: three readings inside 0.1 s).
355
+ *
356
+ * Only the reading of the MACHINE is held to one at a time: its awaits are
357
+ * files of `/proc` and `/sys`, which answer. The torrent's cost asks the
358
+ * torrent worker, which may not answer, and a reading waiting on it would
359
+ * stop every later one — the machine's availability would then stay as it
360
+ * was for as long as the worker did.
361
+ */
338
362
  async reportHostLoad() {
363
+ if (this.#reporting) {
364
+ return;
365
+ }
366
+ this.#reporting = true;
367
+ let nothingEncoding = false;
368
+ try {
369
+ nothingEncoding = await this.#reportMachineOnce();
370
+ } finally {
371
+ this.#reporting = false;
372
+ }
373
+ if (nothingEncoding) {
374
+ await this.#learnTorrentCost();
375
+ }
376
+ }
377
+
378
+ /**
379
+ * The machine's availability while nothing is encoding, taken the same way as
380
+ * while something is. Without it the last figure from the last encoder stayed
381
+ * in force for ever: it refused every new encoder, and a refused encoder is
382
+ * the only thing that would have taken the next reading.
383
+ */
384
+ async #learnIdleAvailability() {
385
+ const system = (await this.#host.readSystemCpu?.()) ?? null;
386
+ const sample = { takenAt: Date.now(), system, proxyCpuSeconds: this.#host.readProxyCpuSeconds() };
387
+ const previous = this.#idleAvailabilitySample;
388
+ this.#idleAvailabilitySample = sample;
389
+ if (previous === null) {
390
+ this.hostAvailability = availableShareFrom({});
391
+ return;
392
+ }
393
+ const share = this.#host.shareOfMachine(
394
+ { takenAt: previous.takenAt, processCpuSeconds: 0, system: previous.system },
395
+ { takenAt: sample.takenAt, processCpuSeconds: 0, system: sample.system }
396
+ );
397
+ this.#setAvailability(share, previous, sample);
398
+ }
399
+
400
+ /**
401
+ * @param {{ elapsedSec: number, processShare: number | null, systemShare: number | null, rejected?: string[] } | null} share
402
+ * @param {{ proxyCpuSeconds: number | null }} previous
403
+ * @param {{ proxyCpuSeconds: number | null }} sample
404
+ * @returns {number | null} The proxy's own share of the machine, for the log.
405
+ */
406
+ #setAvailability(share, previous, sample) {
407
+ if (share === null) {
408
+ this.hostAvailability = availableShareFrom({});
409
+ return null;
410
+ }
411
+ if (share.rejected?.length) {
412
+ logger.warn(
413
+ `host-load: ignored a reading over ${share.elapsedSec.toFixed(1)}s that is not a share of the machine ` +
414
+ `(${share.rejected.join(" ")}); availability is unknown until the next one`
415
+ );
416
+ }
417
+ const cores = Math.max(1, os.cpus().length);
418
+ const proxyShare = Number.isFinite(previous.proxyCpuSeconds)
419
+ ? (sample.proxyCpuSeconds - previous.proxyCpuSeconds) / (share.elapsedSec * cores)
420
+ : null;
421
+ this.hostAvailability = availableShareFrom({
422
+ systemBusy: share.systemShare,
423
+ encoderShare: share.processShare,
424
+ proxyShare
425
+ });
426
+ return proxyShare;
427
+ }
428
+
429
+ /** @returns {Promise<boolean>} True when no encoder is running, which is when the torrent's cost can be taken. */
430
+ async #reportMachineOnce() {
339
431
  const encoding = [...this.#host.outputs.values()].filter(
340
432
  (session) => processCanBeSignalled(this.#host.runStateOf(session))
341
433
  );
@@ -346,11 +438,12 @@ export class HostLoad {
346
438
  // in which every encoder was suspended, the torrent's price could have
347
439
  // been taken, and none was. What this process spends now is the download,
348
440
  // the verification, the piece store and the delivery. Item 7.
349
- await this.#learnTorrentCost();
441
+ await this.#learnIdleAvailability();
350
442
  this.#hostLoadSample = null;
351
- return;
443
+ return true;
352
444
  }
353
445
  this.#idleLoadSample = null;
446
+ this.#idleAvailabilitySample = null;
354
447
  // EVERY encoder, added up. One of them is meaningless on a host that runs a
355
448
  // picture and an audio track at once, and picking the first would have
356
449
  // reported whichever the map happened to hold.
@@ -387,7 +480,7 @@ export class HostLoad {
387
480
  const previous = this.#hostLoadSample;
388
481
  this.#hostLoadSample = sample;
389
482
  if (previous === null) {
390
- return; // the first reading is only something to compare against
483
+ return false; // the first reading is only something to compare against
391
484
  }
392
485
  // Summed over the pids both readings hold, so nothing is measured against a
393
486
  // process that was not there before. Unknown stays unknown: on a host with
@@ -405,7 +498,8 @@ export class HostLoad {
405
498
  { takenAt: sample.takenAt, processCpuSeconds: encoderDelta, system: sample.system }
406
499
  );
407
500
  if (share === null) {
408
- return;
501
+ this.hostAvailability = availableShareFrom({});
502
+ return false;
409
503
  }
410
504
  // How many of them are stopped by the look-ahead cap. Without this a zero
411
505
  // share reads as an encoder being starved of the machine, when it is an
@@ -417,19 +511,11 @@ export class HostLoad {
417
511
  const running = encoding.length - suspended;
418
512
  const machine = await this.#host.readMachineState();
419
513
  const asPercent = (value) => (value === null ? "n/a" : `${Math.round(value * 100)}%`);
420
- const cores = Math.max(1, os.cpus().length);
421
- const proxyShare = Number.isFinite(previous.proxyCpuSeconds)
422
- ? (sample.proxyCpuSeconds - previous.proxyCpuSeconds) / (share.elapsedSec * cores)
423
- : null;
424
514
  // Kept for the quality offer, which predicts from a benchmark taken on a
425
515
  // QUIET host: the same reading that is printed here says how much of the
426
516
  // machine a new encoder could actually have. Only what nobody has been
427
517
  // charged for is subtracted — see `encode/available-share.js`.
428
- this.hostAvailability = availableShareFrom({
429
- systemBusy: share.systemShare,
430
- encoderShare: share.processShare,
431
- proxyShare
432
- });
518
+ const proxyShare = this.#setAvailability(share, previous, sample);
433
519
  logger.info(
434
520
  `host-load: ffmpeg=${asPercent(share.processShare)} proxy=${asPercent(proxyShare)} ` +
435
521
  `system=${asPercent(share.systemShare)} ` +
@@ -441,6 +527,7 @@ export class HostLoad {
441
527
  // readings it comes from.
442
528
  ` available=${asPercent(this.hostAvailability.share)}`
443
529
  );
530
+ return false;
444
531
  }
445
532
 
446
533
  /**