@torrent-tv/proxy 2.100.0 → 2.100.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.
package/bin/cli.js CHANGED
@@ -89,6 +89,7 @@ program
89
89
  .option("--no-port-mapping", "Disable automatic UPnP/NAT-PMP port mapping")
90
90
  .option("--delivery-sink", "Serve /api/delivery-sink, a torrent-free byte stream for delivery testing")
91
91
  .option("--pin-preset <name>", "Encode every software picture at this libx264 preset only, for runs that must be compared at one quality (off by default)")
92
+ .option("--heap-snapshots", "Write a heap snapshot of a thread whose memory reaches a new high-water; each one stops that thread while it is written (off by default)")
92
93
  .option("--profile-start <seconds>", "Profile the CPU of the main thread and the torrent thread for this many seconds from the first source opened, written beside the state (off by default)")
93
94
  .option(
94
95
  "--send-chunk-bytes <bytes>",
@@ -395,6 +396,7 @@ try {
395
396
  logFile: options.logFile ?? "",
396
397
  deliverySink: options.deliverySink === true,
397
398
  profileStartSeconds: Number(options.profileStart) || 0,
399
+ heapSnapshots: options.heapSnapshots === true,
398
400
  pinnedPreset: typeof options.pinPreset === "string" && options.pinPreset.trim() ? options.pinPreset.trim() : null,
399
401
  // Late-bound the same way `webRtcManager` is below: the torrent pool is
400
402
  // built inside `startProxyServer`, before `dataChannelHandler` — the
@@ -524,6 +526,7 @@ try {
524
526
  // 2026-09-07 and every one was gone by the time the process that wrote them
525
527
  // died of a heap it had never been possible to look at.
526
528
  snapshotDir: options.stateDir || undefined,
529
+ heapSnapshots: options.heapSnapshots === true,
527
530
  snapshotFloorBytes: 400 * 1024 * 1024,
528
531
  snapshotGrowthBytes: 400 * 1024 * 1024,
529
532
  keepSnapshots: 3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.100.0",
3
+ "version": "2.100.2",
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
@@ -127,6 +127,8 @@ function buildPortCandidates(startPort, maxAttempts = 51) {
127
127
  export async function startProxyServer({
128
128
  host, port, transcodeAudio, ffmpegBin, memoryBytes, segmentFormat, stateDir, onSubtitleCues, diagnostics, budgetPolicy,
129
129
  deliverySink = false,
130
+ // Whether a memory high-water earns a heap snapshot (`--heap-snapshots`).
131
+ heapSnapshots = false,
130
132
  // Seconds of main-thread CPU profile from the first source opened; 0 is off.
131
133
  profileStartSeconds = 0,
132
134
  // The one software preset every picture is encoded at, for runs compared at
@@ -178,6 +180,7 @@ export async function startProxyServer({
178
180
  const torrentPool = new WorkerTorrentPool({
179
181
  memoryBytes,
180
182
  stateDir,
183
+ heapSnapshots,
181
184
  onHoldingsChanged: () => onStateChanged(),
182
185
  onSourceForgotten: ({ sourceKey }) => {
183
186
  sourcePreparation?.forget(sourceKey);
@@ -889,6 +892,7 @@ export async function startProxyServer({
889
892
  const params = await containerOver({ sourceKey: output.file.sourceKey, fileIndex });
890
893
  if (!params) return { kind: "needs-source" };
891
894
  if (timing) {
895
+ params.walkAccount = timing.walk ??= { lockMs: 0, reads: 0, bytes: 0 };
892
896
  params.onTimed = (_statement, waitedMs, readMs) => {
893
897
  timing.reads += 1;
894
898
  timing.queuedMs += waitedMs;
@@ -942,6 +946,41 @@ export async function startProxyServer({
942
946
  }
943
947
  return { kind: "result", sources };
944
948
  },
949
+ // THE SOUND'S PACKETS FROM THE PICTURE'S COPY. A Matroska soundtrack is
950
+ // fed to its encoder packet by packet, and finding its packets walks the
951
+ // clusters the Cues name for the piece — picture and sound together,
952
+ // about 20 MB for 0.3 MB of sound on the field's 1080p film. The picture's
953
+ // input copies those same clusters through the torrent thread first, and
954
+ // walked a second time there each soundtrack piece took 6-11 s, slower
955
+ // than the film plays (r61, torrent-tv/meta#178). Walked from the copy
956
+ // while it is held, the sound reads only its own packets later.
957
+ stretchCopied: (output, from, to, input) => {
958
+ for (const source of input.sources ?? []) {
959
+ const container = containerOrchestrator.known(source.sourceKey, source.fileIndex);
960
+ const tracks = containerOrchestrator.tracks.get(`${source.sourceKey}:${source.fileIndex}`);
961
+ if (typeof container?.learnPackets !== "function" || !Array.isArray(tracks)) continue;
962
+ const carried = new Set((source.input.selections ?? []).map(selection => selection.track?.trackNumber));
963
+ const sounds = new Set();
964
+ for (const other of outputParts.outputs.values()) {
965
+ const sound = other.spec?.audio;
966
+ if (other.spec?.video || !sound || other.file?.sourceKey !== source.sourceKey || sound.fileIndex !== source.fileIndex) continue;
967
+ const track = tracks.filter(candidate => candidate.type === "audio")[sound.trackIndex];
968
+ if (track && !carried.has(track.trackNumber)) sounds.add(track.trackNumber);
969
+ }
970
+ if (!sounds.size || !Number.isFinite(source.input.from) || !(source.input.to > source.input.from)) continue;
971
+ const read = (start, end) => {
972
+ const answer = input.read(source.fileIndex, start, end);
973
+ return answer && typeof answer.then !== "function" && answer.end === end ? answer.bytes : null;
974
+ };
975
+ const startedAt = Date.now();
976
+ void container.learnPackets({ from: source.input.from, to: source.input.to, trackIds: [...sounds] }, read).then(learned =>
977
+ logger.info(`encode input #${from}..#${to} of ${output.outputKey}: soundtrack packets of track(s) ${[...sounds].join(",")} ` +
978
+ `over ${source.input.from.toFixed(3)}-${source.input.to.toFixed(3)}s ` +
979
+ (learned === "walked" ? "walked" : `not all walked (${learned === "memory" ? "memory refused" : "the copy does not hold the rest"})`) +
980
+ ` from its copy in ${Date.now() - startedAt}ms`),
981
+ error => logger.warn(`encode input #${from}..#${to} of ${output.outputKey}: walking the soundtrack from its copy failed: ${error?.message ?? error}`));
982
+ }
983
+ },
945
984
  readEncodeRanges: async (source, ranges, maxBytes) => {
946
985
  const torrent = torrentPool.knownTorrent(source.sourceKey);
947
986
  return torrent ? torrentPool.readHeldRangesOf(torrent, source.fileIndex, ranges, maxBytes) : null;
@@ -55,9 +55,17 @@ export class EncodeInputs {
55
55
  #rankAt;
56
56
  #log;
57
57
  #now;
58
+ /**
59
+ * Told when an original input's stretch is copied whole, while its bytes are
60
+ * held, so what else they answer can be read from them (`learnPackets`).
61
+ *
62
+ * @type {(output: object, from: number, to: number, input: object) => void}
63
+ */
64
+ #stretchCopied;
58
65
 
59
66
  constructor({ resolve, readRanges, heldRanges = null, reviseBudget, changed, failed, capacity = () => null, urgent = () => true,
60
- rankAt = () => null, openOf = () => null, log = () => {}, now = Date.now }) {
67
+ rankAt = () => null, openOf = () => null, log = () => {}, now = Date.now, stretchCopied = () => {} }) {
68
+ this.#stretchCopied = stretchCopied;
61
69
  this.#openOf = openOf;
62
70
  this.#resolve = resolve;
63
71
  this.#read = readRanges;
@@ -583,6 +591,10 @@ export class EncodeInputs {
583
591
  const copied = result.whole ?? Promise.resolve(true);
584
592
  void copied.then(whole => {
585
593
  if (whole && result.copyMs > 0) keep(this.#copyRates, result.bytes / result.copyMs);
594
+ if (whole) {
595
+ try { this.#stretchCopied(output, from, end, result); }
596
+ catch (error) { this.#log(`encode input #${from}..#${end} of ${addressOf(output)}: what was told of its copy failed: ${error?.message ?? error}`); }
597
+ }
586
598
  this.#log(`encode input #${from}..#${end} of ${addressOf(output)}: ` + (whole
587
599
  ? `stretch copied, ${result.bytes} bytes in ${result.copyMs}ms of copying since it was admitted`
588
600
  : result.copyFailed?.() ? "the stretch could not be copied: a piece it needs is no longer held"
@@ -636,6 +648,9 @@ function describeTiming(timing, now) {
636
648
  `${timing.resolutions} resolution(s) ${timing.resolveMs}ms ` +
637
649
  `(${timing.reads} file read(s): queued ${timing.queuedMs}ms, reading ${timing.readMs}ms), ` +
638
650
  `held ranges ${timing.heldMs}ms, budget ${timing.budgetMs}ms` +
651
+ // Where a packet input's walk spent its read: waiting for the same
652
+ // track's earlier walks, or reading the file (r62, torrent-tv/meta#178).
653
+ (timing.walk ? `; packet walk waited ${timing.walk.lockMs}ms for its track and read ${timing.walk.reads} time(s), ${timing.walk.bytes} bytes of the file` : "") +
639
654
  (timing.openApproximate ? "; sized by the host's startup open, this output has not opened yet" : "");
640
655
  }
641
656
 
@@ -215,7 +215,7 @@ export class ContainerOrchestrator {
215
215
  value = await container.readSourceRanges(params.packetInterval);
216
216
  if (value.kind !== "result") return { ...value, requestId };
217
217
  } else if (statement === "packets") {
218
- value = await container.readPacketIndex(params.packetInterval, { isCurrent: params.isCurrent });
218
+ value = await container.readPacketIndex(params.packetInterval, { isCurrent: params.isCurrent, account: params.walkAccount });
219
219
  if (value === null) return { kind: "terminal", reason: "request-obsolete", requestId };
220
220
  await value?.prepareAudioDependencies?.(params.packetInterval, container.readRange);
221
221
  } else {
@@ -47,7 +47,8 @@ import { firstFrameOffset, readBlockHeader, walkHeldClusters } from "./matroska-
47
47
  import { buildMatroskaIndex, matroskaIntervalCovered, walkMatroskaPackets } from "./matroska-packets.js";
48
48
  import { PacketRecordsChain } from "./PacketRecordsChain.js";
49
49
  import { RetainedReads } from "./RetainedReads.js";
50
- import { isUnavailable } from "./unavailable.js";
50
+ import { isUnavailable, strictReader } from "./unavailable.js";
51
+ import { IndexMemoryUnavailable } from "./memory-unavailable.js";
51
52
  import {
52
53
  ID_ATTACHED_FILE, ID_ATTACHMENTS, ID_CHAPTERS, ID_FILE_DATA, ID_FILE_MEDIA_TYPE, ID_FILE_NAME, ID_INFO_TITLE, ID_TAGS,
53
54
  chapterTitlesOf, coverIndexOf, trackRatesFromTags, uidKeyOf, workFromTags
@@ -784,10 +785,14 @@ export class MatroskaContainer extends Container {
784
785
 
785
786
  /**
786
787
  * @param {object} [interval]
787
- * @param {{ isCurrent?: () => boolean }} [options] - A walk whose request is
788
- * no longer current stops between clusters and answers null.
788
+ * @param {{ isCurrent?: () => boolean, readRange?: (start: number, end: number) => Promise<Buffer | null> | Buffer | null }} [options]
789
+ * A walk whose request is no longer current stops between clusters and
790
+ * answers null. `readRange`, when given, is where this one walk reads
791
+ * instead of the file's own reader; what it does not hold is unavailable.
792
+ * `account`, when given, is told how long each track's walk waited for
793
+ * that track's earlier walks and what it read (`lockMs`, `reads`, `bytes`).
789
794
  */
790
- async readPacketIndex(interval, { isCurrent } = {}) {
795
+ async readPacketIndex(interval, { isCurrent, readRange, account } = {}) {
791
796
  if (this.#packets) return this.#packets;
792
797
  const layout = await this.#segmentLayout();
793
798
  if (!layout) throw new Error("Matroska Segment is absent.");
@@ -806,7 +811,8 @@ export class MatroskaContainer extends Container {
806
811
  const complete = new Set();
807
812
  let whole = true;
808
813
  for (const track of requested) {
809
- const chain = await this.#readTrackPackets(layout, tracks, track, start, interval, isCurrent);
814
+ const chain = await this.#readTrackPackets(layout, tracks, track, start, interval, isCurrent,
815
+ readRange ? strictReader(readRange, this.fileSize) : this.readRange, account);
810
816
  if (!chain) return null;
811
817
  streams.set(track.trackNumber, chain.records);
812
818
  if (chain.reachesEnd) complete.add(track.trackNumber);
@@ -818,6 +824,34 @@ export class MatroskaContainer extends Container {
818
824
  return index;
819
825
  }
820
826
 
827
+ /**
828
+ * Record these tracks' packets over `interval` from bytes another reader
829
+ * already holds, reading nothing of the file itself.
830
+ *
831
+ * A soundtrack's piece is walked over the clusters the Cues name for its
832
+ * interval, the picture's bytes with it: about 20 MB for 0.3 MB of sound on
833
+ * the field's 1080p film, read through the torrent thread a second time
834
+ * after the picture's input had copied the same clusters. Walked from that
835
+ * copy while it is held, the sound's input later reads only its own packets
836
+ * (r61, torrent-tv/meta#178).
837
+ *
838
+ * @param {{ from: number, to: number, trackIds: number[] }} interval
839
+ * @param {(start: number, end: number) => Buffer | null} readRange - Null where it does not hold the bytes.
840
+ * @returns {Promise<"walked" | "missing" | "memory">} Whether the
841
+ * interval's packets are recorded, or what stopped the walk: bytes the
842
+ * copy does not hold, or memory refused.
843
+ */
844
+ async learnPackets(interval, readRange) {
845
+ try {
846
+ return await this.readPacketIndex(interval, { readRange }) !== null ? "walked" : "missing";
847
+ } catch (error) {
848
+ // The sound's own walk reads the rest later, from where this one stopped.
849
+ if (isUnavailable(error)) return "missing";
850
+ if (error instanceof IndexMemoryUnavailable) return "memory";
851
+ throw error;
852
+ }
853
+ }
854
+
821
855
  /**
822
856
  * One track's packets from `start` on, read as far as the interval needs.
823
857
  *
@@ -831,13 +865,21 @@ export class MatroskaContainer extends Container {
831
865
  *
832
866
  * @returns {Promise<{ records: PacketRecordsChain, startAt: number, reachesEnd: boolean }>}
833
867
  */
834
- async #readTrackPackets(layout, tracks, track, start, interval, isCurrent) {
868
+ async #readTrackPackets(layout, tracks, track, start, interval, isCurrent, reader, account = null) {
835
869
  const id = track.trackNumber;
836
870
  const previous = this.#packetWalks.get(id) ?? Promise.resolve();
837
871
  let finished;
838
872
  const walk = previous.then(() => new Promise(resolve => { finished = resolve; }));
839
873
  this.#packetWalks.set(id, walk);
874
+ const waitedFrom = Date.now();
840
875
  await previous;
876
+ const read = account ? async (start, end) => {
877
+ const bytes = await reader(start, end);
878
+ account.reads += 1;
879
+ account.bytes += bytes.length;
880
+ return bytes;
881
+ } : reader;
882
+ if (account) account.lockMs += Date.now() - waitedFrom;
841
883
  try {
842
884
  const readings = this.#packetReadings.get(id) ?? [];
843
885
  this.#packetReadings.set(id, readings);
@@ -886,7 +928,7 @@ export class MatroskaContainer extends Container {
886
928
  current = chainOf().at(-1);
887
929
  if (current.reachedEnd) break;
888
930
  const following = readings.find(reading => reading.startAt > current.startAt);
889
- const outcome = await walkMatroskaPackets({ readRange: this.readRange, fileSize: this.fileSize,
931
+ const outcome = await walkMatroskaPackets({ readRange: read, fileSize: this.fileSize,
890
932
  portionBytes: this.portionBytes, layout, tracks, trackIds: [id], interval, packetMemory: this.packetMemory,
891
933
  state: current, until: following?.startAt ?? null, isCovered: covered, readsThrough, isCurrent });
892
934
  if (outcome === "obsolete") return null;
@@ -226,6 +226,7 @@ export function wireOutputs({
226
226
  readMetadataActivity = null,
227
227
  sourceInputsFor = null,
228
228
  readEncodeRanges = null,
229
+ stretchCopied = () => {},
229
230
  readEncodeHeldRanges = null,
230
231
  encodeInputs = null,
231
232
  indexMemory = null,
@@ -1069,6 +1070,7 @@ export function wireOutputs({
1069
1070
  resolve: resolveEncodeInput,
1070
1071
  readRanges: readEncodeRanges,
1071
1072
  heldRanges: readEncodeHeldRanges,
1073
+ stretchCopied,
1072
1074
  reviseBudget: () => parts.machineBudget.revise("memory", "admitted encode inputs"),
1073
1075
  capacity: () => parts.machineBudget.capacityOf("memory"),
1074
1076
  // How long this output's input takes to open, from the one owner of start
@@ -438,6 +438,12 @@ export function unaccountedBufferBytes(arrayBuffers, stores = []) {
438
438
  * @param {number} [options.snapshotFloorBytes]
439
439
  * @param {number} [options.snapshotGrowthBytes]
440
440
  * @param {number} [options.keepSnapshots] - Newest to keep; zero keeps all.
441
+ * @param {boolean} [options.heapSnapshots] - Whether a high-water earns a heap
442
+ * snapshot at all. Off unless the operator asks: a snapshot stops the thread
443
+ * for as long as it is written, and in a healthy session on Home Assistant
444
+ * the torrent thread's array buffers reach the floor below — one snapshot
445
+ * of 524 MB stopped that thread 4.4 s, 20 s after a seek the viewer was
446
+ * waiting on (r61, torrent-tv/meta#178).
441
447
  * @param {number} [options.bufferSnapshotFloorBytes] - Array buffers above what
442
448
  * the stores hold that earn a snapshot of their own; zero never takes one.
443
449
  * @param {number} [options.bufferSnapshotGrowthBytes]
@@ -460,6 +466,7 @@ export function startMemoryReport({
460
466
  snapshotFloorBytes = 500 * 1024 * 1024,
461
467
  snapshotGrowthBytes = 100 * 1024 * 1024,
462
468
  keepSnapshots = 0,
469
+ heapSnapshots = false,
463
470
  bufferSnapshotFloorBytes = 0,
464
471
  bufferSnapshotGrowthBytes = 500 * 1024 * 1024
465
472
  }) {
@@ -601,7 +608,7 @@ export function startMemoryReport({
601
608
  // and when V8 did kill the process at 1.99 GB of old space, no snapshot
602
609
  // had ever been taken of the growth that did it.
603
610
  const heapWatched = processMemory.heapTotal;
604
- if (heapWatched > highWater + snapshotGrowthBytes && heapWatched > snapshotFloorBytes) {
611
+ if (heapSnapshots && heapWatched > highWater + snapshotGrowthBytes && heapWatched > snapshotFloorBytes) {
605
612
  highWater = heapWatched;
606
613
  await takeSnapshot(heapWatched, "a new high-water of the heap");
607
614
  }
@@ -609,6 +616,7 @@ export function startMemoryReport({
609
616
  // the heap: each one is in it with its size and its holder.
610
617
  const unaccounted = unaccountedBufferBytes(processMemory.arrayBuffers, stores);
611
618
  if (
619
+ heapSnapshots &&
612
620
  bufferSnapshotFloorBytes > 0 &&
613
621
  unaccounted > bufferSnapshotFloorBytes &&
614
622
  unaccounted > bufferHighWater + bufferSnapshotGrowthBytes
@@ -283,7 +283,23 @@ export class PieceDiskStore {
283
283
  await this.#removing.get(index);
284
284
  await this.#ensureDirectory();
285
285
  await this.#makeRoomFor(bytes.length, index);
286
- await fs.writeFile(this.#pathOf(index), bytes);
286
+ // ONE WRITE FOR THE PIECE, not `fs.writeFile`: that writes 512 KiB at a
287
+ // time, sixteen requests for an 8 MiB piece, and each waits for a turn of
288
+ // this thread's loop. On Home Assistant the torrent thread's loop ran about
289
+ // a second late while downloading, a piece took that many seconds to reach
290
+ // the disk, and pieces arriving at 20 MB/s waited for their writes: 1.1 GB
291
+ // of them in that thread's memory at once (r61, torrent-tv/meta#178).
292
+ const handle = await fs.open(this.#pathOf(index), "w");
293
+ try {
294
+ let written = 0;
295
+ while (written < bytes.length) {
296
+ const { bytesWritten } = await handle.write(bytes, written, bytes.length - written, written);
297
+ if (!(bytesWritten > 0)) throw new Error(`Piece ${index} could not be written: the disk took nothing.`);
298
+ written += bytesWritten;
299
+ }
300
+ } finally {
301
+ await handle.close();
302
+ }
287
303
  if (!this.#stored.has(index)) {
288
304
  this.#bytes += bytes.length;
289
305
  } else {
@@ -117,13 +117,13 @@ export class TorrentWorkerClient {
117
117
  /**
118
118
  * @param {{ memoryBytes?: number, stateDir?: string, onPiecesArrived?: (event: object) => void }} [options]
119
119
  */
120
- constructor({ memoryBytes, stateDir, onPiecesArrived, onSourceForgotten, onHoldingsChanged } = {}) {
120
+ constructor({ memoryBytes, stateDir, heapSnapshots = false, onPiecesArrived, onSourceForgotten, onHoldingsChanged } = {}) {
121
121
  this.#worker = new Worker(fileURLToPath(WORKER_URL), {
122
122
  // `stateDir` travels because the worker writes heap snapshots of its own
123
123
  // isolate there. It cannot choose a directory any other way: a worker may
124
124
  // not change the process's working directory, and the isolate that has
125
125
  // died three times is the one no snapshot has ever been taken of.
126
- workerData: { memoryBytes, stateDir }
126
+ workerData: { memoryBytes, stateDir, heapSnapshots }
127
127
  });
128
128
  this.#caller = createCaller(this.#worker);
129
129
  this.#onPiecesArrived = onPiecesArrived ?? (() => undefined);
@@ -692,6 +692,7 @@ startMemoryReport({
692
692
  quietMs: 60_000,
693
693
  changeBytes: 25 * 1024 * 1024,
694
694
  snapshotDir: workerData?.stateDir || undefined,
695
+ heapSnapshots: workerData?.heapSnapshots === true,
695
696
  snapshotFloorBytes: 400 * 1024 * 1024,
696
697
  snapshotGrowthBytes: 400 * 1024 * 1024,
697
698
  // CHOSEN, not derived: no healthy session has recorded this figure yet, so