@torrent-tv/proxy 2.99.21 → 2.100.1
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 +4 -1
- package/package.json +1 -1
- package/server.js +38 -1
- package/services/encode/EncodeInputs.js +13 -1
- package/services/media/container/MatroskaContainer.js +37 -7
- package/services/server/wire-outputs.js +2 -0
- package/services/storage/cpu-profile.js +59 -31
- package/services/storage/memory-report.js +9 -1
- package/services/storage/piece-store/piece-disk-store.js +17 -1
- package/services/torrent/worker/client.js +13 -2
- package/services/torrent/worker/pool-adapter.js +5 -0
- package/services/torrent/worker/protocol.js +2 -0
- package/services/torrent/worker/worker.js +8 -0
package/bin/cli.js
CHANGED
|
@@ -89,7 +89,8 @@ 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("--
|
|
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)")
|
|
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>",
|
|
95
96
|
"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."
|
|
@@ -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
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);
|
|
@@ -942,6 +945,39 @@ export async function startProxyServer({
|
|
|
942
945
|
}
|
|
943
946
|
return { kind: "result", sources };
|
|
944
947
|
},
|
|
948
|
+
// THE SOUND'S PACKETS FROM THE PICTURE'S COPY. A Matroska soundtrack is
|
|
949
|
+
// fed to its encoder packet by packet, and finding its packets walks the
|
|
950
|
+
// clusters the Cues name for the piece — picture and sound together,
|
|
951
|
+
// about 20 MB for 0.3 MB of sound on the field's 1080p film. The picture's
|
|
952
|
+
// input copies those same clusters through the torrent thread first, and
|
|
953
|
+
// walked a second time there each soundtrack piece took 6-11 s, slower
|
|
954
|
+
// than the film plays (r61, torrent-tv/meta#178). Walked from the copy
|
|
955
|
+
// while it is held, the sound reads only its own packets later.
|
|
956
|
+
stretchCopied: (output, from, to, input) => {
|
|
957
|
+
for (const source of input.sources ?? []) {
|
|
958
|
+
const container = containerOrchestrator.known(source.sourceKey, source.fileIndex);
|
|
959
|
+
const tracks = containerOrchestrator.tracks.get(`${source.sourceKey}:${source.fileIndex}`);
|
|
960
|
+
if (typeof container?.learnPackets !== "function" || !Array.isArray(tracks)) continue;
|
|
961
|
+
const carried = new Set((source.input.selections ?? []).map(selection => selection.track?.trackNumber));
|
|
962
|
+
const sounds = new Set();
|
|
963
|
+
for (const other of outputParts.outputs.values()) {
|
|
964
|
+
const sound = other.spec?.audio;
|
|
965
|
+
if (other.spec?.video || !sound || other.file?.sourceKey !== source.sourceKey || sound.fileIndex !== source.fileIndex) continue;
|
|
966
|
+
const track = tracks.filter(candidate => candidate.type === "audio")[sound.trackIndex];
|
|
967
|
+
if (track && !carried.has(track.trackNumber)) sounds.add(track.trackNumber);
|
|
968
|
+
}
|
|
969
|
+
if (!sounds.size || !Number.isFinite(source.input.from) || !(source.input.to > source.input.from)) continue;
|
|
970
|
+
const read = (start, end) => {
|
|
971
|
+
const answer = input.read(source.fileIndex, start, end);
|
|
972
|
+
return answer && typeof answer.then !== "function" && answer.end === end ? answer.bytes : null;
|
|
973
|
+
};
|
|
974
|
+
const startedAt = Date.now();
|
|
975
|
+
void container.learnPackets({ from: source.input.from, to: source.input.to, trackIds: [...sounds] }, read).then(learned =>
|
|
976
|
+
logger.info(`encode input #${from}..#${to} of ${output.outputKey}: soundtrack packets of track(s) ${[...sounds].join(",")} ` +
|
|
977
|
+
`over ${source.input.from.toFixed(3)}-${source.input.to.toFixed(3)}s ${learned ? "walked" : "not all walked"} from its copy in ${Date.now() - startedAt}ms`),
|
|
978
|
+
error => logger.warn(`encode input #${from}..#${to} of ${output.outputKey}: walking the soundtrack from its copy failed: ${error?.message ?? error}`));
|
|
979
|
+
}
|
|
980
|
+
},
|
|
945
981
|
readEncodeRanges: async (source, ranges, maxBytes) => {
|
|
946
982
|
const torrent = torrentPool.knownTorrent(source.sourceKey);
|
|
947
983
|
return torrent ? torrentPool.readHeldRangesOf(torrent, source.fileIndex, ranges, maxBytes) : null;
|
|
@@ -1099,7 +1135,8 @@ export async function startProxyServer({
|
|
|
1099
1135
|
seconds: profileStartSeconds,
|
|
1100
1136
|
dir: stateDir || "",
|
|
1101
1137
|
log: (line) => logger.info(line),
|
|
1102
|
-
mayKeep: (bytes) => diagnostics?.mayKeep?.({ what: "a CPU profile", bytes }) ?? true
|
|
1138
|
+
mayKeep: (bytes) => diagnostics?.mayKeep?.({ what: "a CPU profile", bytes }) ?? true,
|
|
1139
|
+
torrentThread: (seconds) => torrentPool.cpuProfile?.(seconds) ?? Promise.resolve(null)
|
|
1103
1140
|
});
|
|
1104
1141
|
startProcessorReport({
|
|
1105
1142
|
log: (line) => logger.info(line),
|
|
@@ -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"
|
|
@@ -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,12 @@ export class MatroskaContainer extends Container {
|
|
|
784
785
|
|
|
785
786
|
/**
|
|
786
787
|
* @param {object} [interval]
|
|
787
|
-
* @param {{ isCurrent?: () => boolean
|
|
788
|
-
* no longer current stops between clusters and
|
|
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.
|
|
789
792
|
*/
|
|
790
|
-
async readPacketIndex(interval, { isCurrent } = {}) {
|
|
793
|
+
async readPacketIndex(interval, { isCurrent, readRange } = {}) {
|
|
791
794
|
if (this.#packets) return this.#packets;
|
|
792
795
|
const layout = await this.#segmentLayout();
|
|
793
796
|
if (!layout) throw new Error("Matroska Segment is absent.");
|
|
@@ -806,7 +809,8 @@ export class MatroskaContainer extends Container {
|
|
|
806
809
|
const complete = new Set();
|
|
807
810
|
let whole = true;
|
|
808
811
|
for (const track of requested) {
|
|
809
|
-
const chain = await this.#readTrackPackets(layout, tracks, track, start, interval, isCurrent
|
|
812
|
+
const chain = await this.#readTrackPackets(layout, tracks, track, start, interval, isCurrent,
|
|
813
|
+
readRange ? strictReader(readRange, this.fileSize) : this.readRange);
|
|
810
814
|
if (!chain) return null;
|
|
811
815
|
streams.set(track.trackNumber, chain.records);
|
|
812
816
|
if (chain.reachesEnd) complete.add(track.trackNumber);
|
|
@@ -818,6 +822,32 @@ export class MatroskaContainer extends Container {
|
|
|
818
822
|
return index;
|
|
819
823
|
}
|
|
820
824
|
|
|
825
|
+
/**
|
|
826
|
+
* Record these tracks' packets over `interval` from bytes another reader
|
|
827
|
+
* already holds, reading nothing of the file itself.
|
|
828
|
+
*
|
|
829
|
+
* A soundtrack's piece is walked over the clusters the Cues name for its
|
|
830
|
+
* interval, the picture's bytes with it: about 20 MB for 0.3 MB of sound on
|
|
831
|
+
* the field's 1080p film, read through the torrent thread a second time
|
|
832
|
+
* after the picture's input had copied the same clusters. Walked from that
|
|
833
|
+
* copy while it is held, the sound's input later reads only its own packets
|
|
834
|
+
* (r61, torrent-tv/meta#178).
|
|
835
|
+
*
|
|
836
|
+
* @param {{ from: number, to: number, trackIds: number[] }} interval
|
|
837
|
+
* @param {(start: number, end: number) => Buffer | null} readRange - Null where it does not hold the bytes.
|
|
838
|
+
* @returns {Promise<boolean>} Whether the interval's packets are recorded.
|
|
839
|
+
*/
|
|
840
|
+
async learnPackets(interval, readRange) {
|
|
841
|
+
try {
|
|
842
|
+
return await this.readPacketIndex(interval, { readRange }) !== null;
|
|
843
|
+
} catch (error) {
|
|
844
|
+
// What the copy does not hold, or memory refused: the sound's own walk
|
|
845
|
+
// reads it later, from where this one stopped.
|
|
846
|
+
if (isUnavailable(error) || error instanceof IndexMemoryUnavailable) return false;
|
|
847
|
+
throw error;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
821
851
|
/**
|
|
822
852
|
* One track's packets from `start` on, read as far as the interval needs.
|
|
823
853
|
*
|
|
@@ -831,7 +861,7 @@ export class MatroskaContainer extends Container {
|
|
|
831
861
|
*
|
|
832
862
|
* @returns {Promise<{ records: PacketRecordsChain, startAt: number, reachesEnd: boolean }>}
|
|
833
863
|
*/
|
|
834
|
-
async #readTrackPackets(layout, tracks, track, start, interval, isCurrent) {
|
|
864
|
+
async #readTrackPackets(layout, tracks, track, start, interval, isCurrent, read) {
|
|
835
865
|
const id = track.trackNumber;
|
|
836
866
|
const previous = this.#packetWalks.get(id) ?? Promise.resolve();
|
|
837
867
|
let finished;
|
|
@@ -886,7 +916,7 @@ export class MatroskaContainer extends Container {
|
|
|
886
916
|
current = chainOf().at(-1);
|
|
887
917
|
if (current.reachedEnd) break;
|
|
888
918
|
const following = readings.find(reading => reading.startAt > current.startAt);
|
|
889
|
-
const outcome = await walkMatroskaPackets({ readRange:
|
|
919
|
+
const outcome = await walkMatroskaPackets({ readRange: read, fileSize: this.fileSize,
|
|
890
920
|
portionBytes: this.portionBytes, layout, tracks, trackIds: [id], interval, packetMemory: this.packetMemory,
|
|
891
921
|
state: current, until: following?.startAt ?? null, isCovered: covered, readsThrough, isCurrent });
|
|
892
922
|
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
|
|
@@ -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
|
}
|
|
@@ -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
|
-
|
|
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);
|
|
@@ -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();
|
|
@@ -685,6 +692,7 @@ startMemoryReport({
|
|
|
685
692
|
quietMs: 60_000,
|
|
686
693
|
changeBytes: 25 * 1024 * 1024,
|
|
687
694
|
snapshotDir: workerData?.stateDir || undefined,
|
|
695
|
+
heapSnapshots: workerData?.heapSnapshots === true,
|
|
688
696
|
snapshotFloorBytes: 400 * 1024 * 1024,
|
|
689
697
|
snapshotGrowthBytes: 400 * 1024 * 1024,
|
|
690
698
|
// CHOSEN, not derived: no healthy session has recorded this figure yet, so
|