@torrent-tv/proxy 2.99.20 → 2.100.0
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 +1 -1
- package/package.json +1 -1
- package/server.js +4 -2
- package/services/encode/EncodeInputs.js +7 -3
- package/services/media/ContainerOrchestrator.js +2 -1
- package/services/media/container/MatroskaContainer.js +11 -4
- package/services/media/container/matroska-packets.js +6 -2
- package/services/storage/cpu-profile.js +59 -31
- package/services/torrent/worker/client.js +11 -0
- package/services/torrent/worker/pool-adapter.js +5 -0
- package/services/torrent/worker/protocol.js +2 -0
- package/services/torrent/worker/worker.js +7 -0
package/bin/cli.js
CHANGED
|
@@ -89,7 +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("--profile-start <seconds>", "Profile the main thread
|
|
92
|
+
.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
93
|
.option(
|
|
94
94
|
"--send-chunk-bytes <bytes>",
|
|
95
95
|
"Size of one data-channel message when sending a response body (0 = whatever the body read hands over, ~64KB). For measuring whether the delivery wedge depends on that size."
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -868,7 +868,7 @@ export async function startProxyServer({
|
|
|
868
868
|
},
|
|
869
869
|
// `timing`, when given, is told how long each read of a file waited behind
|
|
870
870
|
// the reads asked before it and how long it took (torrent-tv/meta#166).
|
|
871
|
-
resolveEncodeInput: async (output, fromIndex, toIndex, timing = null) => {
|
|
871
|
+
resolveEncodeInput: async (output, fromIndex, toIndex, timing = null, isCurrent = null) => {
|
|
872
872
|
const grid = output.timeline?.published ?? output.timeline?.boundaries;
|
|
873
873
|
const from = grid?.[fromIndex], to = grid?.[toIndex + 1];
|
|
874
874
|
if (!Number.isFinite(from) || !(to > from)) return { kind: "terminal", reason: "output-interval-not-declared" };
|
|
@@ -914,6 +914,7 @@ export async function startProxyServer({
|
|
|
914
914
|
if (media.kind !== "result") return media;
|
|
915
915
|
const timeShiftSeconds = Number(media.value?.startTimeSeconds) || 0;
|
|
916
916
|
const sourceInterval = output.timeline.sourceInterval(fromIndex, toIndex, timeShiftSeconds);
|
|
917
|
+
if (isCurrent) params.isCurrent = isCurrent;
|
|
917
918
|
params.packetInterval = { ...sourceInterval,
|
|
918
919
|
trackIds: wanted.map(track => track.trackNumber),
|
|
919
920
|
modes: Object.fromEntries(wanted.map(track => [track.trackNumber, modes.get(track)])) };
|
|
@@ -1098,7 +1099,8 @@ export async function startProxyServer({
|
|
|
1098
1099
|
seconds: profileStartSeconds,
|
|
1099
1100
|
dir: stateDir || "",
|
|
1100
1101
|
log: (line) => logger.info(line),
|
|
1101
|
-
mayKeep: (bytes) => diagnostics?.mayKeep?.({ what: "a CPU profile", bytes }) ?? true
|
|
1102
|
+
mayKeep: (bytes) => diagnostics?.mayKeep?.({ what: "a CPU profile", bytes }) ?? true,
|
|
1103
|
+
torrentThread: (seconds) => torrentPool.cpuProfile?.(seconds) ?? Promise.resolve(null)
|
|
1102
1104
|
});
|
|
1103
1105
|
startProcessorReport({
|
|
1104
1106
|
log: (line) => logger.info(line),
|
|
@@ -92,10 +92,10 @@ export class EncodeInputs {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
/** The container's statement of an interval, its time added to the request's account. */
|
|
95
|
-
async #resolveTimed(timing, output, from, to) {
|
|
95
|
+
async #resolveTimed(timing, output, from, to, isCurrent = null) {
|
|
96
96
|
const startedAt = this.#now();
|
|
97
97
|
try {
|
|
98
|
-
return await this.#resolve(output, from, to, timing);
|
|
98
|
+
return await this.#resolve(output, from, to, timing, isCurrent);
|
|
99
99
|
} finally {
|
|
100
100
|
timing.resolutions += 1;
|
|
101
101
|
timing.resolveMs += this.#now() - startedAt;
|
|
@@ -384,7 +384,11 @@ export class EncodeInputs {
|
|
|
384
384
|
reads: 0, queuedMs: 0, readMs: 0, heldMs: 0, budgetMs: 0 };
|
|
385
385
|
request.timing.attempts += 1;
|
|
386
386
|
request.promise = (async () => {
|
|
387
|
-
|
|
387
|
+
// A request the plan withdraws stops its reads: a soundtrack piece's
|
|
388
|
+
// packet walk, left to run, held the file's reads and the piece the
|
|
389
|
+
// viewer had seeked to waited 16.9 s behind it (r59, torrent-tv/meta#178).
|
|
390
|
+
const resolved = await this.#resolveTimed(request.timing, request.output, request.from, request.from,
|
|
391
|
+
() => this.#requests.get(request.key) === request);
|
|
388
392
|
if (resolved.kind === "result") this.#original.set(request.output, resolved.sources?.every(source => source.input.original === true) === true);
|
|
389
393
|
if (this.#requests.get(request.key) !== request) return;
|
|
390
394
|
let result;
|
|
@@ -215,7 +215,8 @@ 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);
|
|
218
|
+
value = await container.readPacketIndex(params.packetInterval, { isCurrent: params.isCurrent });
|
|
219
|
+
if (value === null) return { kind: "terminal", reason: "request-obsolete", requestId };
|
|
219
220
|
await value?.prepareAudioDependencies?.(params.packetInterval, container.readRange);
|
|
220
221
|
} else {
|
|
221
222
|
throw new TypeError(`Unknown media statement: ${statement}`);
|
|
@@ -782,7 +782,12 @@ export class MatroskaContainer extends Container {
|
|
|
782
782
|
streamSearchSeconds: FFMPEG_STREAM_SEARCH_SECONDS };
|
|
783
783
|
}
|
|
784
784
|
|
|
785
|
-
|
|
785
|
+
/**
|
|
786
|
+
* @param {object} [interval]
|
|
787
|
+
* @param {{ isCurrent?: () => boolean }} [options] - A walk whose request is
|
|
788
|
+
* no longer current stops between clusters and answers null.
|
|
789
|
+
*/
|
|
790
|
+
async readPacketIndex(interval, { isCurrent } = {}) {
|
|
786
791
|
if (this.#packets) return this.#packets;
|
|
787
792
|
const layout = await this.#segmentLayout();
|
|
788
793
|
if (!layout) throw new Error("Matroska Segment is absent.");
|
|
@@ -801,7 +806,8 @@ export class MatroskaContainer extends Container {
|
|
|
801
806
|
const complete = new Set();
|
|
802
807
|
let whole = true;
|
|
803
808
|
for (const track of requested) {
|
|
804
|
-
const chain = await this.#readTrackPackets(layout, tracks, track, start, interval);
|
|
809
|
+
const chain = await this.#readTrackPackets(layout, tracks, track, start, interval, isCurrent);
|
|
810
|
+
if (!chain) return null;
|
|
805
811
|
streams.set(track.trackNumber, chain.records);
|
|
806
812
|
if (chain.reachesEnd) complete.add(track.trackNumber);
|
|
807
813
|
if (!chain.reachesEnd || chain.startAt !== layout.firstClusterAt) whole = false;
|
|
@@ -825,7 +831,7 @@ export class MatroskaContainer extends Container {
|
|
|
825
831
|
*
|
|
826
832
|
* @returns {Promise<{ records: PacketRecordsChain, startAt: number, reachesEnd: boolean }>}
|
|
827
833
|
*/
|
|
828
|
-
async #readTrackPackets(layout, tracks, track, start, interval) {
|
|
834
|
+
async #readTrackPackets(layout, tracks, track, start, interval, isCurrent) {
|
|
829
835
|
const id = track.trackNumber;
|
|
830
836
|
const previous = this.#packetWalks.get(id) ?? Promise.resolve();
|
|
831
837
|
let finished;
|
|
@@ -882,7 +888,8 @@ export class MatroskaContainer extends Container {
|
|
|
882
888
|
const following = readings.find(reading => reading.startAt > current.startAt);
|
|
883
889
|
const outcome = await walkMatroskaPackets({ readRange: this.readRange, fileSize: this.fileSize,
|
|
884
890
|
portionBytes: this.portionBytes, layout, tracks, trackIds: [id], interval, packetMemory: this.packetMemory,
|
|
885
|
-
state: current, until: following?.startAt ?? null, isCovered: covered, readsThrough });
|
|
891
|
+
state: current, until: following?.startAt ?? null, isCovered: covered, readsThrough, isCurrent });
|
|
892
|
+
if (outcome === "obsolete") return null;
|
|
886
893
|
if (outcome !== "until") break;
|
|
887
894
|
current.next = following;
|
|
888
895
|
}
|
|
@@ -44,9 +44,12 @@ function recordedTracks(tracks, trackIds) {
|
|
|
44
44
|
* `start` on, where the caller knows it: those are read a portion at a time
|
|
45
45
|
* across clusters too.
|
|
46
46
|
*
|
|
47
|
-
*
|
|
47
|
+
* A walk whose `isCurrent` turns false stops at the next cluster, where its
|
|
48
|
+
* state is whole, and answers "obsolete".
|
|
49
|
+
*
|
|
50
|
+
* @returns {Promise<"covered" | "until" | "end" | "obsolete">}
|
|
48
51
|
*/
|
|
49
|
-
export async function walkMatroskaPackets({ readRange, fileSize, portionBytes, layout, tracks, trackIds, interval, packetMemory, state, until = null, isCovered = null, readsThrough = null }) {
|
|
52
|
+
export async function walkMatroskaPackets({ readRange, fileSize, portionBytes, layout, tracks, trackIds, interval, packetMemory, state, until = null, isCovered = null, readsThrough = null, isCurrent = null }) {
|
|
50
53
|
// INSIDE A CLUSTER EVERY BLOCK HEADER IS READ, so its bytes are read a
|
|
51
54
|
// portion at a time up to the cluster's end, and never past it. Read a
|
|
52
55
|
// header at a time, each a read of a few bytes from the torrent thread, the
|
|
@@ -71,6 +74,7 @@ export async function walkMatroskaPackets({ readRange, fileSize, portionBytes, l
|
|
|
71
74
|
if (covered()) return "covered";
|
|
72
75
|
let at = state.at;
|
|
73
76
|
while (at < limit) {
|
|
77
|
+
if (!state.cluster && isCurrent?.() === false) return "obsolete";
|
|
74
78
|
const element = state.cluster?.element ?? await validHeader(reader, at, layout.segmentEnd);
|
|
75
79
|
if (element.id !== CLUSTER) {
|
|
76
80
|
if (element.end === null) throw new Error("An unknown-sized Matroska element is not a Cluster.");
|
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @file
|
|
3
|
-
* operator and never taken otherwise (torrent-tv/meta#177).
|
|
2
|
+
* @file CPU profiles of the main thread and of the torrent thread, asked for
|
|
3
|
+
* by the operator and never taken otherwise (torrent-tv/meta#177, #178).
|
|
4
4
|
*
|
|
5
5
|
* At a cold start on the addon host (#166 run 12) the picture's first input
|
|
6
6
|
* waited 29.5 s, 20.2 s of it inside a budget revision, while the main thread
|
|
7
7
|
* ran at 83-108 % of a core; what it was doing is not in any log. A profile of
|
|
8
8
|
* that thread names it.
|
|
9
9
|
*
|
|
10
|
+
* The torrent thread is profiled beside it. After a seek on Home Assistant
|
|
11
|
+
* (r60, #178) a soundtrack's input waited 23.3 s, every read of held bytes took
|
|
12
|
+
* 1.5-6 s in that thread, its loop ran up to 2.7 s late and it drew three
|
|
13
|
+
* quarters of a core; what it spent them on is in no log either.
|
|
14
|
+
*
|
|
10
15
|
* In-process, through `node:inspector`: the sampling profiler runs beside the
|
|
11
16
|
* thread and does not stop it — unlike the debugger that stopped every thread
|
|
12
|
-
* of this process on 2026-09-05 (torrent-tv/meta#77).
|
|
13
|
-
* source opened, which is where a cold start begins,
|
|
14
|
-
* operator named, and
|
|
17
|
+
* of this process on 2026-09-05 (torrent-tv/meta#77). Both begin at the first
|
|
18
|
+
* source opened, which is where a cold start begins, run for the seconds the
|
|
19
|
+
* operator named, and are written by this thread under the one storage budget
|
|
20
|
+
* as `cpu-start-<time>.cpuprofile` (main) and `cpu-torrent-<time>.cpuprofile`,
|
|
15
21
|
* readable in Chrome's DevTools (Performance, "Load profile").
|
|
16
22
|
*/
|
|
17
23
|
|
|
@@ -19,50 +25,72 @@ import { Session } from "node:inspector/promises";
|
|
|
19
25
|
import { writeFile } from "node:fs/promises";
|
|
20
26
|
import path from "node:path";
|
|
21
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Profile the thread this runs on for `seconds`.
|
|
30
|
+
*
|
|
31
|
+
* @param {number} seconds
|
|
32
|
+
* @returns {Promise<object>} The profile, as `Profiler.stop` gives it.
|
|
33
|
+
*/
|
|
34
|
+
export async function profileThisThread(seconds) {
|
|
35
|
+
const session = new Session();
|
|
36
|
+
session.connect();
|
|
37
|
+
try {
|
|
38
|
+
await session.post("Profiler.enable");
|
|
39
|
+
await session.post("Profiler.start");
|
|
40
|
+
await new Promise((resolve) => {
|
|
41
|
+
const timer = setTimeout(resolve, seconds * 1000);
|
|
42
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
43
|
+
});
|
|
44
|
+
const { profile } = await session.post("Profiler.stop");
|
|
45
|
+
return profile;
|
|
46
|
+
} finally {
|
|
47
|
+
session.disconnect();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
22
51
|
/**
|
|
23
52
|
* @param {object} params
|
|
24
53
|
* @param {number} params.seconds - How long to profile; nothing happens unless positive.
|
|
25
54
|
* @param {string} params.dir - Where to write the profile.
|
|
26
55
|
* @param {(line: string) => void} params.log
|
|
27
56
|
* @param {(bytes: number) => boolean} [params.mayKeep] - Whether the storage budget lets it be kept.
|
|
57
|
+
* @param {(seconds: number) => Promise<string | null>} [params.torrentThread] - The torrent thread's
|
|
58
|
+
* profile over the same seconds, as JSON text, or null when it could not be taken.
|
|
28
59
|
* @returns {{ begin: () => void, active: () => boolean }}
|
|
29
60
|
*/
|
|
30
|
-
export function startupCpuProfile({ seconds, dir, log, mayKeep = () => true }) {
|
|
61
|
+
export function startupCpuProfile({ seconds, dir, log, mayKeep = () => true, torrentThread = null }) {
|
|
31
62
|
let begun = false;
|
|
32
|
-
let running =
|
|
63
|
+
let running = 0;
|
|
33
64
|
const wanted = Number.isFinite(seconds) && seconds > 0 && typeof dir === "string" && dir.length > 0;
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
65
|
+
const keep = async (name, text) => {
|
|
66
|
+
const bytes = Buffer.byteLength(text);
|
|
67
|
+
if (!mayKeep(bytes)) {
|
|
68
|
+
log(`cpu profile: ${name} not kept, ${bytes} bytes refused by the storage budget`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const file = path.join(dir, `cpu-${name}-${new Date().toISOString().replace(/[:.]/g, "-")}.cpuprofile`);
|
|
72
|
+
await writeFile(file, text);
|
|
73
|
+
log(`cpu profile: written to ${file} (${bytes} bytes)`);
|
|
74
|
+
};
|
|
75
|
+
const take = async (name, profile) => {
|
|
76
|
+
running += 1;
|
|
37
77
|
try {
|
|
38
|
-
await
|
|
39
|
-
await
|
|
40
|
-
|
|
41
|
-
log(`cpu profile:
|
|
42
|
-
await new Promise((resolve) => {
|
|
43
|
-
const timer = setTimeout(resolve, seconds * 1000);
|
|
44
|
-
if (typeof timer.unref === "function") timer.unref();
|
|
45
|
-
});
|
|
46
|
-
const { profile } = await session.post("Profiler.stop");
|
|
47
|
-
const text = JSON.stringify(profile);
|
|
48
|
-
if (!mayKeep(Buffer.byteLength(text))) {
|
|
49
|
-
log(`cpu profile: not kept, ${Buffer.byteLength(text)} bytes refused by the storage budget`);
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
const file = path.join(dir, `cpu-start-${new Date().toISOString().replace(/[:.]/g, "-")}.cpuprofile`);
|
|
53
|
-
await writeFile(file, text);
|
|
54
|
-
log(`cpu profile: written to ${file} (${Buffer.byteLength(text)} bytes)`);
|
|
78
|
+
const text = await profile();
|
|
79
|
+
if (text) await keep(name, text);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
log(`cpu profile: ${name} failed — ${error.message}`);
|
|
55
82
|
} finally {
|
|
56
|
-
running
|
|
57
|
-
session.disconnect();
|
|
83
|
+
running -= 1;
|
|
58
84
|
}
|
|
59
85
|
};
|
|
60
86
|
return {
|
|
61
87
|
begin() {
|
|
62
88
|
if (!wanted || begun) return;
|
|
63
89
|
begun = true;
|
|
64
|
-
|
|
90
|
+
log(`cpu profile: the main thread${torrentThread ? " and the torrent thread are" : " is"} profiled for ${seconds}s from the first source opened`);
|
|
91
|
+
void take("start", async () => JSON.stringify(await profileThisThread(seconds)));
|
|
92
|
+
if (torrentThread) void take("torrent", () => torrentThread(seconds));
|
|
65
93
|
},
|
|
66
|
-
active: () => running
|
|
94
|
+
active: () => running > 0
|
|
67
95
|
};
|
|
68
96
|
}
|
|
@@ -744,6 +744,17 @@ export class TorrentWorkerClient {
|
|
|
744
744
|
return this.wholeFileBytes ?? 0;
|
|
745
745
|
}
|
|
746
746
|
|
|
747
|
+
/**
|
|
748
|
+
* A processor profile of the torrent thread over `seconds`, as JSON text.
|
|
749
|
+
*
|
|
750
|
+
* @param {number} seconds
|
|
751
|
+
* @returns {Promise<string | null>}
|
|
752
|
+
*/
|
|
753
|
+
async cpuProfile(seconds) {
|
|
754
|
+
const text = await this.#caller.call(Command.CPU_PROFILE, { seconds });
|
|
755
|
+
return typeof text === "string" ? text : null;
|
|
756
|
+
}
|
|
757
|
+
|
|
747
758
|
/**
|
|
748
759
|
* Say how much disk the spilled pieces on this thread may take between them.
|
|
749
760
|
*
|
|
@@ -278,6 +278,11 @@ export class WorkerTorrentPool {
|
|
|
278
278
|
*
|
|
279
279
|
* @returns {Promise<{ infoHash: string, progress: number, bytes: number }[]>}
|
|
280
280
|
*/
|
|
281
|
+
/** A processor profile of the torrent thread, as JSON text (`TorrentWorkerClient.cpuProfile`). */
|
|
282
|
+
async cpuProfile(seconds) {
|
|
283
|
+
return this.#client.cpuProfile(seconds);
|
|
284
|
+
}
|
|
285
|
+
|
|
281
286
|
async heldTorrents() {
|
|
282
287
|
const answer = await this.#client.heldTorrents();
|
|
283
288
|
return Array.isArray(answer?.held) ? answer.held : [];
|
|
@@ -107,6 +107,8 @@ export const Command = {
|
|
|
107
107
|
SPILL_ALLOWANCE: "spill-allowance",
|
|
108
108
|
WHOLE_FILES_ALLOWANCE: "whole-files-allowance",
|
|
109
109
|
MEMORY_ALLOWANCE: "memory-allowance",
|
|
110
|
+
/** Profile this thread's processor for some seconds and answer with the profile as JSON text (opt-in, torrent-tv/meta#178). */
|
|
111
|
+
CPU_PROFILE: "cpu-profile",
|
|
110
112
|
DESTROY_ALL: "destroy-all"
|
|
111
113
|
};
|
|
112
114
|
|
|
@@ -39,6 +39,7 @@ import { wholeFileStats } from "./whole-file-stats.js";
|
|
|
39
39
|
import { describeHeldObjects, readUtpOpenCount } from "./held-objects.js";
|
|
40
40
|
import { destroyedTorrents } from "../destroyed-torrents.js";
|
|
41
41
|
import { startMemoryReport, WORKER_MEMORY_SAMPLE_MS } from "../../storage/memory-report.js";
|
|
42
|
+
import { profileThisThread } from "../../storage/cpu-profile.js";
|
|
42
43
|
import { forwardLogsTo, logger } from "../../../utils/logger.js";
|
|
43
44
|
|
|
44
45
|
/**
|
|
@@ -603,6 +604,12 @@ async function runCommand(command, params, id) {
|
|
|
603
604
|
return after.bytes;
|
|
604
605
|
}
|
|
605
606
|
|
|
607
|
+
case Command.CPU_PROFILE: {
|
|
608
|
+
// The operator's profile of this thread, written by the main thread under
|
|
609
|
+
// the storage budget there (`storage/cpu-profile.js`).
|
|
610
|
+
return JSON.stringify(await profileThisThread(Number(params.seconds)));
|
|
611
|
+
}
|
|
612
|
+
|
|
606
613
|
case Command.DESTROY_ALL: {
|
|
607
614
|
torrentsByKey.clear();
|
|
608
615
|
resolvedTorrents.clear();
|