@torrent-tv/proxy 2.97.12 → 2.97.14

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.14",
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();
@@ -461,6 +461,17 @@ export class EncodeInputs {
461
461
  if (this.#requests.get(request.key) !== request) return undefined;
462
462
  }
463
463
  const chosen = stretches.get(end);
464
+ // EVERY BYTE IS HERE BEFORE THE RUN IS ANSWERED. The stretch is copied
465
+ // after the answer (`OriginalInput.js`), so a stretch that is not all
466
+ // downloaded would start an encoder whose input then fails; checked here,
467
+ // it waits for the bytes as one read of everything used to. Field
468
+ // 2026-10-12: a cold MKV's first piece was answered, its stretch read
469
+ // found pieces missing, and the run was refused for good (torrent-tv/meta#178).
470
+ if (this.#heldRanges !== null && !await this.#present(chosen.sources, held, request.timing)) {
471
+ const [first] = chosen.sources;
472
+ return { kind: "needs-bytes", sourceKey: first.sourceKey, fileIndex: first.fileIndex, ranges: first.input.ranges };
473
+ }
474
+ if (this.#requests.get(request.key) !== request) return undefined;
464
475
  this.#wanted.set(request.key, { wanted: chosen.bytes, required: chosen.bytes });
465
476
  result = await admitOriginalInput({ sources: chosen.sources, readRanges: this.#read, now: this.#now,
466
477
  reserve: async bytes => this.#hold(request, bytes) });
@@ -1088,7 +1088,10 @@ export class EncodeRuns {
1088
1088
  // torrent cannot tell them apart.
1089
1089
  lastSegmentIndex: () =>
1090
1090
  run.to,
1091
- inputUnavailable: (message) => isInputUnavailable(message),
1091
+ // A stretch whose copy failed after the run started — a piece left the
1092
+ // store between the check and the copy — is an input that went away, not
1093
+ // a fault of the input: the plan asks for it again (torrent-tv/meta#178).
1094
+ inputUnavailable: (message) => isInputUnavailable(message) || admittedInput?.copyFailed?.() === true,
1092
1095
  onProgress: (report) => this.#noteRunProgress(session, run, report),
1093
1096
  // A closed piece moves this encoder forward, perhaps out of the band that
1094
1097
  // led; who has the processor is decided again at once.
@@ -1370,7 +1373,8 @@ export class EncodeRuns {
1370
1373
  // been dead code — which is also why nothing ever stopped the restart loop
1371
1374
  // recorded in the field the same day.
1372
1375
  const wasCurrent = this.runsOf(session).includes(run);
1373
- const inputFailed = run.admittedInputKeys && failedAdmittedInput(ended,
1376
+ // A stretch that could not be copied says nothing of the input itself.
1377
+ const inputFailed = run.admittedInputKeys && run.originalInput?.copyFailed?.() !== true && failedAdmittedInput(ended,
1374
1378
  session.spec.transcodesVideo && this.#host.videoEncoder.kind !== "software");
1375
1379
  if (inputFailed) {
1376
1380
  const index = run.head;
@@ -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