@torrent-tv/proxy 2.97.12 → 2.97.13

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.97.12",
3
+ "version": "2.97.13",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
package/server.js CHANGED
@@ -716,10 +716,7 @@ export async function startProxyServer({
716
716
  if (original) {
717
717
  const ranges = await containerOrchestrator.inspect(params, "source-ranges");
718
718
  if (ranges.kind !== "result") return ranges;
719
- // The torrent's piece is the unit its stretch is copied in, so a copy
720
- // nobody needs any more stops at the next piece (`OriginalInput.js`).
721
719
  sources.push({ sourceKey: params.sourceKey, fileIndex, timeShiftSeconds,
722
- pieceLength: torrentPool.knownTorrent(params.sourceKey)?.pieceLength ?? null,
723
720
  input: { ...ranges.value, original: true, selections: wanted.map(track => ({ track,
724
721
  index: track.declaredIndex ?? choices.find(choice => choice.type === track.type)?.index ?? 0 })) } });
725
722
  continue;
@@ -208,7 +208,7 @@ export class EncodeInputs {
208
208
  this.#requests.delete(key);
209
209
  this.#wanted.delete(key);
210
210
  // A copy already answered may still be copying its stretch; released,
211
- // it stops at the next piece.
211
+ // the stretch is dropped when its read returns.
212
212
  request.result?.release?.();
213
213
  }
214
214
  this.#retryMemory();
@@ -61,29 +61,17 @@ export async function admitOriginalInput({ sources, reserve, readRanges, now = D
61
61
  let released = false;
62
62
  let failed = false;
63
63
  retained = true;
64
- // A stretch is copied one piece of the torrent at a time, so a release
65
- // stops it at the next piece: the thread it is copied on serves every other
66
- // read too, and a copy nobody will use went on to its end (torrent-tv/meta#178).
67
- const copyLater = async job => {
68
- const { source } = job.entry;
69
- const [first, last] = source.input.ranges[job.position];
70
- const unit = Number(source.pieceLength) > 0 ? Number(source.pieceLength) : last - first + 1;
71
- const target = Buffer.allocUnsafeSlow(last - first + 1);
72
- try {
73
- for (let at = first; at <= last; at += unit) {
74
- if (released) return false;
75
- const buffers = await read(source, [[at, Math.min(last, at + unit - 1)]]);
76
- if (buffers === null) { failed = true; return false; }
77
- buffers[0].copy(target, at - first);
78
- }
79
- } catch {
80
- failed = true;
81
- return false;
82
- }
64
+ // ONE READ FOR THE STRETCH. Copied a torrent piece per read (2.97.12), a
65
+ // stretch took 33.1 s for 83.9 MB on the field's AVI against 126.5 MB in
66
+ // 16.3 s as one read beside another copy (torrent-tv/meta#178): each read
67
+ // waits its turn on two busy threads, and the stretch paid that once per
68
+ // piece. A release therefore discards the stretch when its read returns.
69
+ const copyLater = job => read(job.entry.source, [job.entry.source.input.ranges[job.position]]).then(buffers => {
70
+ if (buffers === null) { failed = true; return false; }
83
71
  if (released) return false;
84
- job.entry.buffers[job.position] = target;
72
+ job.entry.buffers[job.position] = buffers[0];
85
73
  return true;
86
- };
74
+ }, () => { failed = true; return false; });
87
75
  // The stretches, one source after another: they share one reader, and the
88
76
  // first source's stretch is the one FFmpeg reaches first.
89
77
  const whole = (async () => {
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import os from "node:os";
11
+ import { monitorEventLoopDelay } from "node:perf_hooks";
11
12
  import { logger } from "../../../utils/logger.js";
12
13
  import { availableShareFrom } from "../available-share.js";
13
14
  import { baseDrawFrom, costPerMegabyteFrom } from "../torrent-cost.js";
@@ -15,6 +16,24 @@ import { medianOf, movedBeyondScatter, READINGS_KEPT, scatterOf } from "../learn
15
16
  import { ENCODE_RUN_STATE, processCanBeSignalled } from "../encode-run-state.js";
16
17
  import { SourceFiles } from "../../media/SourceFile.js";
17
18
 
19
+ /**
20
+ * What the torrent thread says of itself over one interval: how late its event
21
+ * loop ran, and how long reads for readers took inside it, from a command's
22
+ * arrival to its answer (torrent-tv/meta#178). Empty where it says nothing.
23
+ *
24
+ * @param {{ readerMs: number | null, readerReads: number | null, loopP99Ms: number | null, loopMaxMs: number | null }} now
25
+ * @param {{ readerMs: number | null, readerReads: number | null }} previous
26
+ * @returns {string}
27
+ */
28
+ function threadSays(now, previous) {
29
+ const loop = Number.isFinite(now.loopP99Ms) && Number.isFinite(now.loopMaxMs)
30
+ ? `; its loop late p99=${Math.round(now.loopP99Ms)}ms max=${Math.round(now.loopMaxMs)}ms` : "";
31
+ const reads = Number.isFinite(now.readerReads) && Number.isFinite(previous.readerReads) && now.readerReads > previous.readerReads
32
+ && Number.isFinite(now.readerMs) && Number.isFinite(previous.readerMs)
33
+ ? `; ${now.readerReads - previous.readerReads} read(s) for readers took ${Math.round(now.readerMs - previous.readerMs)}ms in it` : "";
34
+ return loop + reads;
35
+ }
36
+
18
37
  export class HostLoad {
19
38
  /** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
20
39
  #host;
@@ -35,6 +54,8 @@ export class HostLoad {
35
54
 
36
55
  /** The previous reading of the torrent thread's processor and the bytes downloaded. */
37
56
  #torrentSample = null;
57
+ /** This thread's event-loop lateness, begun at the first machine reading. */
58
+ #mainLoop = null;
38
59
 
39
60
  /** A reading of the torrent thread is under way; it asks the worker, which may not answer. */
40
61
  #readingTorrent = false;
@@ -186,6 +207,10 @@ export class HostLoad {
186
207
  uploaded: totals?.uploaded ?? null,
187
208
  served: totals?.served ?? null,
188
209
  threadCpuSeconds: totals?.threadCpuSeconds ?? null,
210
+ readerMs: totals?.readerMs ?? null,
211
+ readerReads: totals?.readerReads ?? null,
212
+ loopP99Ms: totals?.loopP99Ms ?? null,
213
+ loopMaxMs: totals?.loopMaxMs ?? null,
189
214
  metadata: this.#host.readMetadataActivity?.()
190
215
  };
191
216
  this.#learnThreadCost(now);
@@ -241,7 +266,7 @@ export class HostLoad {
241
266
  `torrent: downloaded ${(megabytes / elapsedSec).toFixed(2)} MB/s, sent to peers ${rateOf("uploaded")}, ` +
242
267
  `handed to readers ${rateOf("served")} over ${elapsedSec.toFixed(1)}s, ` +
243
268
  `limit ${Number.isFinite(limit) ? `${(limit / 1e6).toFixed(2)} MB/s` : "none"}; ` +
244
- `its thread drew ${Math.round(cpuSeconds / elapsedSec * 100)}% of the machine`
269
+ `its thread drew ${Math.round(cpuSeconds / elapsedSec * 100)}% of the machine` + threadSays(now, previous)
245
270
  );
246
271
  }
247
272
  if (megabytes === 0) {
@@ -456,7 +481,8 @@ export class HostLoad {
456
481
  try {
457
482
  const totals = await this.#host.getTorrentTotals();
458
483
  const read = (key) => { const value = Number(totals?.[key]); return Number.isFinite(value) ? value : null; };
459
- return { downloaded: read("downloaded"), uploaded: read("uploaded"), served: read("served"), threadCpuSeconds: read("threadCpuSeconds") };
484
+ return { downloaded: read("downloaded"), uploaded: read("uploaded"), served: read("served"), threadCpuSeconds: read("threadCpuSeconds"),
485
+ readerMs: read("readerMs"), readerReads: read("readerReads"), loopP99Ms: read("loopP99Ms"), loopMaxMs: read("loopMaxMs") };
460
486
  } catch {
461
487
  return null; // the pool is busy or gone; a reading missed is not a fault
462
488
  }
@@ -525,6 +551,16 @@ export class HostLoad {
525
551
  */
526
552
  async #sayMachine(share, proxyShare, running, suspended) {
527
553
  const machine = await this.#host.readMachineState();
554
+ // HOW LATE THIS THREAD'S LOOP RAN over the interval: every answer to FFmpeg
555
+ // and every hand-over of a copy waits for it (torrent-tv/meta#178). Begun at
556
+ // the first reading, so a check that never reads the machine starts no timer.
557
+ if (!this.#mainLoop) {
558
+ this.#mainLoop = monitorEventLoopDelay({ resolution: 20 });
559
+ this.#mainLoop.enable();
560
+ }
561
+ const late = this.#mainLoop.count > 0
562
+ ? ` loop p99=${Math.round(this.#mainLoop.percentile(99) / 1e6)}ms max=${Math.round(this.#mainLoop.max / 1e6)}ms` : "";
563
+ this.#mainLoop.reset();
528
564
  const asPercent = (value) => (value === null || value === undefined ? "n/a" : `${Math.round(value * 100)}%`);
529
565
  const free = Number.isFinite(share.systemShare) ? Math.max(0, 1 - share.systemShare) : null;
530
566
  logger.info(
@@ -536,7 +572,7 @@ export class HostLoad {
536
572
  ` over=${share.elapsedSec.toFixed(1)}s` +
537
573
  // What the offer will multiply a prediction by, in the same line as the
538
574
  // readings it comes from.
539
- ` available=${asPercent(this.hostAvailability.share)} free=${asPercent(free)}`
575
+ ` available=${asPercent(this.hostAvailability.share)} free=${asPercent(free)}` + late
540
576
  );
541
577
  }
542
578
 
@@ -167,7 +167,7 @@ export async function readHeldBytes(torrent, fileIndex, start, end, logger = nul
167
167
  const missingSaid = new WeakMap();
168
168
 
169
169
  /** Acquire every input piece before copying any of the segment's ranges. */
170
- export async function readHeldRanges(torrent, fileIndex, ranges, maxBytes, logger = null) {
170
+ export async function readHeldRanges(torrent, fileIndex, ranges, maxBytes, logger = null, onRead = null) {
171
171
  const file = torrent?.files?.[fileIndex];
172
172
  const pieceLength = Number(torrent?.pieceLength);
173
173
  const store = pieceStoreOf(torrent);
@@ -223,6 +223,7 @@ export async function readHeldRanges(torrent, fileIndex, ranges, maxBytes, logge
223
223
  });
224
224
  });
225
225
  if (!buffer || buffer.length !== length) return null;
226
+ onRead?.(length);
226
227
  for (const { rangeIndex, absoluteStart, absoluteEnd } of span.ranges) {
227
228
  const from = Math.max(absoluteStart, pieceStart);
228
229
  const to = Math.min(absoluteEnd, pieceStart + pieceLength - 1);
@@ -23,6 +23,7 @@
23
23
  import { isUsableTorrentHandle, requireCurrentTorrent } from "./handle-state.js";
24
24
  import "./install-webrtc-shim.js";
25
25
  import { parentPort, workerData } from "node:worker_threads";
26
+ import { monitorEventLoopDelay, performance } from "node:perf_hooks";
26
27
  import { Readable } from "node:stream";
27
28
  import { heldFileBytes } from "./held-file-bytes.js";
28
29
  import { createSendStream } from "./channel.js";
@@ -108,6 +109,18 @@ const openReads = new Map();
108
109
  */
109
110
  let servedBytes = 0;
110
111
 
112
+ /**
113
+ * How long reads for readers took inside this thread, from the command's
114
+ * arrival to its answer, and how many there were; and how late this thread's
115
+ * event loop ran. A copy for an encoder took 16.3 s for 126.5 MB as one read
116
+ * and 33.1 s for 83.9 MB as one read per piece on the field's AVI, and nothing
117
+ * said whether the time was spent here or on the way (torrent-tv/meta#178).
118
+ */
119
+ let readerMs = 0;
120
+ let readerReads = 0;
121
+ const loopDelay = monitorEventLoopDelay({ resolution: 20 });
122
+ loopDelay.enable();
123
+
111
124
  /**
112
125
  * Shorthand for this file. The same path as `logger.info` anywhere else in the
113
126
  * thread — kept only because it reads better at the hundred call sites here.
@@ -432,7 +445,12 @@ async function runCommand(command, params, id) {
432
445
  // bytes, so the price of a downloaded byte is taken over one interval
433
446
  // whether or not anything encodes (torrent-tv/meta#178).
434
447
  const usage = process.threadCpuUsage();
435
- return { downloaded, uploaded, served: servedBytes, threadCpuSeconds: (usage.user + usage.system) / 1e6 };
448
+ // The loop's lateness over the interval since the last reading, in ms.
449
+ const loopP99Ms = loopDelay.count > 0 ? loopDelay.percentile(99) / 1e6 : null;
450
+ const loopMaxMs = loopDelay.count > 0 ? loopDelay.max / 1e6 : null;
451
+ loopDelay.reset();
452
+ return { downloaded, uploaded, served: servedBytes, threadCpuSeconds: (usage.user + usage.system) / 1e6,
453
+ readerMs, readerReads, loopP99Ms, loopMaxMs };
436
454
  }
437
455
 
438
456
 
@@ -528,8 +546,11 @@ async function runCommand(command, params, id) {
528
546
  case Command.READ_HELD: {
529
547
  const known = resolvedTorrents.get(params.sourceKey);
530
548
  const torrent = isUsableTorrentHandle(known) ? known : null;
549
+ const startedAt = performance.now();
531
550
  const bytes = await readWhileInUse(openReads, id, torrent, params.fileIndex,
532
551
  () => readHeldBytes(torrent, params.fileIndex, params.start, params.end, logger));
552
+ readerMs += performance.now() - startedAt;
553
+ readerReads += 1;
533
554
  servedBytes += bytes?.length ?? 0;
534
555
  return { bytes };
535
556
  }
@@ -537,9 +558,13 @@ async function runCommand(command, params, id) {
537
558
  case Command.READ_HELD_RANGES: {
538
559
  const known = resolvedTorrents.get(params.sourceKey);
539
560
  const torrent = isUsableTorrentHandle(known) ? known : null;
561
+ const startedAt = performance.now();
562
+ // Counted as each piece is read, so a long copy lands in the intervals
563
+ // it was made in rather than in the one it ended in.
540
564
  const buffers = await readWhileInUse(openReads, id, torrent, params.fileIndex,
541
- () => readHeldRanges(torrent, params.fileIndex, params.ranges, params.maxBytes, logger));
542
- for (const buffer of buffers ?? []) servedBytes += buffer?.length ?? 0;
565
+ () => readHeldRanges(torrent, params.fileIndex, params.ranges, params.maxBytes, logger, (length) => { servedBytes += length; }));
566
+ readerMs += performance.now() - startedAt;
567
+ readerReads += 1;
543
568
  return { buffers };
544
569
  }
545
570