@torrent-tv/proxy 2.96.13 → 2.97.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 +2 -0
- package/package.json +1 -1
- package/routes/encode-input/get.js +13 -0
- package/server.js +25 -3
- package/services/encode/EncodeRuns.js +81 -1
- package/services/encode/OriginalInput.js +45 -1
- package/services/storage/MachineBudget.js +19 -1
- package/services/storage/cpu-profile.js +68 -0
- package/services/storage/processor-report.js +186 -0
- package/services/torrent/worker/client.js +3 -2
- package/services/torrent/worker/pool-adapter.js +2 -2
- package/services/torrent/worker/worker.js +6 -2
- package/services/viewer/DownloadMaps.js +53 -6
package/bin/cli.js
CHANGED
|
@@ -88,6 +88,7 @@ program
|
|
|
88
88
|
.option("--no-transcode-audio", "Disable optional HLS AAC audio transcoding")
|
|
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
|
+
.option("--profile-start <seconds>", "Profile the main thread's CPU for this many seconds from the first source opened, written beside the state (off by default)")
|
|
91
92
|
.option(
|
|
92
93
|
"--send-chunk-bytes <bytes>",
|
|
93
94
|
"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."
|
|
@@ -392,6 +393,7 @@ try {
|
|
|
392
393
|
budgetPolicy,
|
|
393
394
|
logFile: options.logFile ?? "",
|
|
394
395
|
deliverySink: options.deliverySink === true,
|
|
396
|
+
profileStartSeconds: Number(options.profileStart) || 0,
|
|
395
397
|
// Late-bound the same way `webRtcManager` is below: the torrent pool is
|
|
396
398
|
// built inside `startProxyServer`, before `dataChannelHandler` — the
|
|
397
399
|
// thing that actually owns a channel to push down — exists.
|
package/package.json
CHANGED
|
@@ -36,6 +36,19 @@ export function handleEncodeInputGet(req, reply, { inputOf, refused = () => {} }
|
|
|
36
36
|
refused({ token, fileIndex, start: range.start, runTag: input.runTag ?? null });
|
|
37
37
|
return reply.code(503).send({ error: "Original input bytes are outside the admitted ranges." });
|
|
38
38
|
}
|
|
39
|
+
// What was handed to the system for this answer before its connection
|
|
40
|
+
// closed, and whether all of it was: an answer runs to the end of its
|
|
41
|
+
// admitted range and FFmpeg closes the connection once it has what it wants,
|
|
42
|
+
// so the range answered says nothing about the bytes taken. What FFmpeg read
|
|
43
|
+
// is at most what was handed over; the kernel's buffer is the difference,
|
|
44
|
+
// and it cannot be seen from here (torrent-tv/meta#177).
|
|
45
|
+
const socket = req.raw?.socket;
|
|
46
|
+
const writtenBefore = socket?.bytesWritten ?? 0;
|
|
47
|
+
reply.raw?.once?.("close", () => {
|
|
48
|
+
input.noteAnswer?.({ fileIndex, start: range.start, end: held.end,
|
|
49
|
+
written: Math.max(0, (socket?.bytesWritten ?? writtenBefore) - writtenBefore),
|
|
50
|
+
handedOver: reply.raw?.writableFinished === true });
|
|
51
|
+
});
|
|
39
52
|
return reply.code(206).header("Content-Length", String(held.bytes.length))
|
|
40
53
|
.header("Content-Range", `bytes ${range.start}-${held.end}/${length}`).send(held.bytes);
|
|
41
54
|
}
|
package/server.js
CHANGED
|
@@ -80,6 +80,8 @@ import { measureStartAndStop } from "./services/encode/start-stop-cost.js";
|
|
|
80
80
|
import { benchmarkAudio } from "./services/encode/audio-calibration.js";
|
|
81
81
|
import { logger } from "./utils/logger.js";
|
|
82
82
|
import { completedFilesRoot } from "./services/storage/files/CompletedFiles.js";
|
|
83
|
+
import { startupCpuProfile } from "./services/storage/cpu-profile.js";
|
|
84
|
+
import { startProcessorReport } from "./services/storage/processor-report.js";
|
|
83
85
|
|
|
84
86
|
const require = createRequire(import.meta.url);
|
|
85
87
|
const { version } = require("./package.json");
|
|
@@ -119,6 +121,8 @@ function buildPortCandidates(startPort, maxAttempts = 51) {
|
|
|
119
121
|
export async function startProxyServer({
|
|
120
122
|
host, port, transcodeAudio, ffmpegBin, memoryBytes, segmentFormat, stateDir, onSubtitleCues, diagnostics, budgetPolicy,
|
|
121
123
|
deliverySink = false,
|
|
124
|
+
// Seconds of main-thread CPU profile from the first source opened; 0 is off.
|
|
125
|
+
profileStartSeconds = 0,
|
|
122
126
|
// What viewers' connections carry beyond the film, per byte of film; the
|
|
123
127
|
// transport counts it and is built after this server, so it is late-bound.
|
|
124
128
|
serviceShare = () => null,
|
|
@@ -875,6 +879,22 @@ export async function startProxyServer({
|
|
|
875
879
|
await torrentPool.fillFileInBackground?.(torrent, fileIndex);
|
|
876
880
|
}
|
|
877
881
|
});
|
|
882
|
+
// Where the processor goes, and at a cold start what the main thread does
|
|
883
|
+
// with it, when the operator asks (torrent-tv/meta#177).
|
|
884
|
+
const startupProfile = startupCpuProfile({
|
|
885
|
+
seconds: profileStartSeconds,
|
|
886
|
+
dir: stateDir || "",
|
|
887
|
+
log: (line) => logger.info(line),
|
|
888
|
+
mayKeep: (bytes) => diagnostics?.mayKeep?.({ what: "a CPU profile", bytes }) ?? true
|
|
889
|
+
});
|
|
890
|
+
startProcessorReport({
|
|
891
|
+
log: (line) => logger.info(line),
|
|
892
|
+
ffmpegPids: () => [...outputParts.outputs.values()]
|
|
893
|
+
.flatMap((output) => outputParts.encodeRuns.liveRunsOf(output))
|
|
894
|
+
.map((run) => run.process?.pid)
|
|
895
|
+
.filter((pid) => Number.isInteger(pid)),
|
|
896
|
+
detailed: () => startupProfile.active()
|
|
897
|
+
});
|
|
878
898
|
// What the last life of this process left on the disk. The kernel kills this
|
|
879
899
|
// one often enough for that to be an ordinary state rather than an odd one —
|
|
880
900
|
// twice in a single viewing on 2026-09-02 — and when it does, no exit handler
|
|
@@ -1106,9 +1126,11 @@ export async function startProxyServer({
|
|
|
1106
1126
|
);
|
|
1107
1127
|
// Bytes a browser can time its link with before any film has been chosen.
|
|
1108
1128
|
app.get("/api/link-probe", async (req, reply) => handleApiLinkProbeGet(req, reply));
|
|
1109
|
-
app.post("/api/sources", async (req, reply) =>
|
|
1110
|
-
|
|
1111
|
-
|
|
1129
|
+
app.post("/api/sources", async (req, reply) => {
|
|
1130
|
+
// A cold start begins with the first source opened (torrent-tv/meta#177).
|
|
1131
|
+
startupProfile.begin();
|
|
1132
|
+
return handleApiSourcesPost(req, reply, { sourceRegistry, viewers: outputParts.viewers });
|
|
1133
|
+
});
|
|
1112
1134
|
app.get("/api/sources/:sourceKey/stats", async (req, reply) =>
|
|
1113
1135
|
handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool })
|
|
1114
1136
|
);
|
|
@@ -20,6 +20,7 @@ import { writeAdmittedInput } from "./AdmittedInput.js";
|
|
|
20
20
|
import { cutOf, cutShiftOf, judgeNeighbors, judgePiece } from "./piece-completeness.js";
|
|
21
21
|
import { presentationSegment } from "./segment-formats/presentation-segment.js";
|
|
22
22
|
import { InputFailures, failedAdmittedInput } from "./InputFailures.js";
|
|
23
|
+
import { readProcessCpuSeconds } from "./host-load.js";
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -161,6 +162,15 @@ export class EncodeRuns {
|
|
|
161
162
|
* @type {Map<number, import("./EncodeRun.js").EncodeRun>}
|
|
162
163
|
*/
|
|
163
164
|
#runsByInputToken = new Map();
|
|
165
|
+
/**
|
|
166
|
+
* Each live run's ffmpeg CPU seconds against the film it had made, as of its
|
|
167
|
+
* last progress report: what one second of film costs this machine in
|
|
168
|
+
* processor time, which the speed in x does not say once other work shares
|
|
169
|
+
* the processor (torrent-tv/meta#177). Said when the run ends.
|
|
170
|
+
*
|
|
171
|
+
* @type {WeakMap<object, { cpuSeconds: number, filmSeconds: number, reading: boolean, beside: number }>}
|
|
172
|
+
*/
|
|
173
|
+
#cpuOfRun = new WeakMap();
|
|
164
174
|
|
|
165
175
|
#lastInputToken = 0;
|
|
166
176
|
#inputFailures = new InputFailures();
|
|
@@ -1046,11 +1056,12 @@ export class EncodeRuns {
|
|
|
1046
1056
|
const since = (at) => at ? `${at - run.startedAt}ms` : "never";
|
|
1047
1057
|
this.#host.logger.info(`encode input of run #${run.from}..#${run.to} on ${session.outputKey}: ` +
|
|
1048
1058
|
`${reads.count} read(s), ${reads.bytes} bytes, first at ${since(reads.firstAt)}, last at ${since(reads.lastAt)}, ` +
|
|
1049
|
-
`ended at ${Date.now() - run.startedAt}ms`);
|
|
1059
|
+
`ended at ${Date.now() - run.startedAt}ms` + describeTaken(reads.answers, run.startedAt, ended.firstNamedMs));
|
|
1050
1060
|
}
|
|
1051
1061
|
this.#noteOpen(session, run, ended);
|
|
1052
1062
|
admittedInput.release();
|
|
1053
1063
|
}
|
|
1064
|
+
this.#sayRunCpu(session, run);
|
|
1054
1065
|
this.noteRunEnded(session, run, ended);
|
|
1055
1066
|
this.#host.encodersChanged?.();
|
|
1056
1067
|
}
|
|
@@ -1066,6 +1077,13 @@ export class EncodeRuns {
|
|
|
1066
1077
|
// cannot reach it.
|
|
1067
1078
|
run.inputThrough = inputEnd;
|
|
1068
1079
|
run.process.stdin.end();
|
|
1080
|
+
// THE NEXT STRETCH IS PREPARED WHILE THIS ONE IS BEING MADE. Asked for
|
|
1081
|
+
// only when this run had ended, its copy fell between the two runs and
|
|
1082
|
+
// nothing made the picture meanwhile: field 2026-10-11, 909 MB copied in
|
|
1083
|
+
// 48.9 s after run #1..#32 ended, then the start of #33..#892, and the
|
|
1084
|
+
// viewer stood 69 s (torrent-tv/meta#166). Asked now, it is copied while
|
|
1085
|
+
// this run works and is ready when the plan places the next run there.
|
|
1086
|
+
if (inputEnd < mayReach) this.#host.encodeInputs?.take(session, inputEnd + 1, mayReach);
|
|
1069
1087
|
} else if (admittedInput) {
|
|
1070
1088
|
run.inputFingerprint = admittedInput.fingerprint;
|
|
1071
1089
|
run.admittedInputKeys = new Map([[safeIndex, inputKey]]);
|
|
@@ -1160,6 +1178,7 @@ export class EncodeRuns {
|
|
|
1160
1178
|
if (!this.isLive(session) || !this.runsOf(session).includes(run)) {
|
|
1161
1179
|
return;
|
|
1162
1180
|
}
|
|
1181
|
+
this.#readRunCpu(run, progress);
|
|
1163
1182
|
if (run.progress.shouldLog(PROGRESS_LOG_INTERVAL_MS)) {
|
|
1164
1183
|
this.#host.logger.info(
|
|
1165
1184
|
`transcode ${session.id} "${session.file.name}" ${progress.percent.toFixed(1)}% ` +
|
|
@@ -1169,6 +1188,44 @@ export class EncodeRuns {
|
|
|
1169
1188
|
}
|
|
1170
1189
|
}
|
|
1171
1190
|
|
|
1191
|
+
/**
|
|
1192
|
+
* Read the run's ffmpeg CPU beside the film it has made, one reading at a
|
|
1193
|
+
* time; a reading that cannot be taken (no `/proc`, the process gone)
|
|
1194
|
+
* leaves the last one.
|
|
1195
|
+
*
|
|
1196
|
+
* @param {import("./EncodeRun.js").EncodeRun} run
|
|
1197
|
+
* @param {{ processedSeconds?: number, startPositionSeconds?: number }} progress
|
|
1198
|
+
*/
|
|
1199
|
+
#readRunCpu(run, progress) {
|
|
1200
|
+
const pid = run.process?.pid;
|
|
1201
|
+
const filmSeconds = Number(progress?.processedSeconds) - Number(progress?.startPositionSeconds ?? 0);
|
|
1202
|
+
const known = this.#cpuOfRun.get(run) ?? { cpuSeconds: 0, filmSeconds: 0, reading: false, beside: 0 };
|
|
1203
|
+
this.#cpuOfRun.set(run, known);
|
|
1204
|
+
if (known.reading || !Number.isInteger(pid) || !(filmSeconds > 0)) return;
|
|
1205
|
+
known.reading = true;
|
|
1206
|
+
void readProcessCpuSeconds(pid).then((cpuSeconds) => {
|
|
1207
|
+
if (Number.isFinite(cpuSeconds)) {
|
|
1208
|
+
known.cpuSeconds = cpuSeconds;
|
|
1209
|
+
known.filmSeconds = filmSeconds;
|
|
1210
|
+
known.beside = Math.max(known.beside, this.#runsByInputToken.size - 1);
|
|
1211
|
+
}
|
|
1212
|
+
}).finally(() => { known.reading = false; });
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* What the run's ffmpeg spent per second of film, said once when it ends.
|
|
1217
|
+
*
|
|
1218
|
+
* @param {object} session
|
|
1219
|
+
* @param {import("./EncodeRun.js").EncodeRun} run
|
|
1220
|
+
*/
|
|
1221
|
+
#sayRunCpu(session, run) {
|
|
1222
|
+
const known = this.#cpuOfRun.get(run);
|
|
1223
|
+
if (!known || !(known.filmSeconds > 0) || !(known.cpuSeconds > 0)) return;
|
|
1224
|
+
this.#host.logger.info(`encode-run #${run.from}..#${run.to} on ${session.outputKey}: ffmpeg used ` +
|
|
1225
|
+
`${known.cpuSeconds.toFixed(2)}s of CPU for ${known.filmSeconds.toFixed(2)}s of film, ` +
|
|
1226
|
+
`${(known.cpuSeconds / known.filmSeconds).toFixed(3)}s per second of film; at most ${known.beside} other encoder(s) alive beside it`);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1172
1229
|
/**
|
|
1173
1230
|
* What to do about a run that has ended.
|
|
1174
1231
|
*
|
|
@@ -1524,3 +1581,26 @@ export class EncodeRuns {
|
|
|
1524
1581
|
});
|
|
1525
1582
|
}
|
|
1526
1583
|
}
|
|
1584
|
+
|
|
1585
|
+
/**
|
|
1586
|
+
* What FFmpeg was handed of an original input, as a clause of the run's input
|
|
1587
|
+
* line (torrent-tv/meta#177): bytes handed to the system (at most what it
|
|
1588
|
+
* read), connections closed before an answer was all handed over,
|
|
1589
|
+
* the share of reads that went forward, the largest jump back, bytes taken
|
|
1590
|
+
* twice, and what it read before it named its first piece — its open.
|
|
1591
|
+
*
|
|
1592
|
+
* @param {{ count: number, written: number, unfinished: number, forwardShare: number | null, largestBackBytes: number, rewritten: number, list: { at: number, written: number }[] } | undefined} answers
|
|
1593
|
+
* @param {number} startedAt
|
|
1594
|
+
* @param {number | null | undefined} firstNamedMs
|
|
1595
|
+
* @returns {string}
|
|
1596
|
+
*/
|
|
1597
|
+
function describeTaken(answers, startedAt, firstNamedMs) {
|
|
1598
|
+
if (!answers || answers.count === 0) return "";
|
|
1599
|
+
const opened = Number.isFinite(firstNamedMs)
|
|
1600
|
+
? answers.list.filter((answer) => answer.at - startedAt <= firstNamedMs) : null;
|
|
1601
|
+
return `; handed over: ${answers.written} bytes in ${answers.count} answer(s), ${answers.unfinished} closed before all was handed over, ` +
|
|
1602
|
+
`${answers.forwardShare === null ? "n/a" : `${Math.round(answers.forwardShare * 100)}%`} forward, ` +
|
|
1603
|
+
`largest jump back ${answers.largestBackBytes} bytes, ${answers.rewritten} bytes taken twice` +
|
|
1604
|
+
(opened ? `; before the first piece was named ${opened.length} answer(s), ` +
|
|
1605
|
+
`${opened.reduce((sum, answer) => sum + answer.written, 0)} bytes` : "");
|
|
1606
|
+
}
|
|
@@ -35,6 +35,11 @@ export async function admitOriginalInput({ sources, reserve, readRanges, now = D
|
|
|
35
35
|
// time went: field 2026-10-08, a run took 6-9 s for a piece FFmpeg makes in
|
|
36
36
|
// 1.6 s from a file, and nothing said whether it was waiting on its input.
|
|
37
37
|
const reads = { count: 0, bytes: 0, firstAt: 0, lastAt: 0 };
|
|
38
|
+
// Each answer FFmpeg was sent, in order: where it began, where its range
|
|
39
|
+
// ended, what was handed to the system before the connection closed, and
|
|
40
|
+
// whether all of it was (torrent-tv/meta#177).
|
|
41
|
+
/** @type {{ fileIndex: number, start: number, end: number, written: number, handedOver: boolean, at: number }[]} */
|
|
42
|
+
const answers = [];
|
|
38
43
|
return {
|
|
39
44
|
kind: "result", original: true, sources, bytes, copyMs, fingerprint: hash.digest("hex"),
|
|
40
45
|
// No track's own end: the container names the bytes an interval needs,
|
|
@@ -59,7 +64,10 @@ export async function admitOriginalInput({ sources, reserve, readRanges, now = D
|
|
|
59
64
|
return { length: fileLength, end: stop, bytes: entry.buffers[position].subarray(start - ranges[position][0], stop - ranges[position][0] + 1) };
|
|
60
65
|
},
|
|
61
66
|
lengthOf: fileIndex => held.get(fileIndex)?.source.input.fileLength ?? null,
|
|
62
|
-
reads: () => ({ ...reads }),
|
|
67
|
+
reads: () => ({ ...reads, answers: describeAnswers(answers) }),
|
|
68
|
+
noteAnswer(answer) {
|
|
69
|
+
if (!released) answers.push({ ...answer, at: Date.now() });
|
|
70
|
+
},
|
|
63
71
|
release() {
|
|
64
72
|
if (released) return;
|
|
65
73
|
released = true;
|
|
@@ -77,3 +85,39 @@ export function originalInputBytes(sources) {
|
|
|
77
85
|
return sources.reduce((total, source) => total + source.input.ranges
|
|
78
86
|
.reduce((sum, [start, end]) => sum + end - start + 1, 0), 0);
|
|
79
87
|
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The shape of FFmpeg's reads of one input, from the answers it was sent:
|
|
91
|
+
* how much was handed to the system (at most what FFmpeg read), how many
|
|
92
|
+
* connections closed before all of an answer was handed over, what share began
|
|
93
|
+
* at or after the end of the previous one (a forward read), the largest jump
|
|
94
|
+
* back, and the bytes written again that an earlier answer had already
|
|
95
|
+
* written. Per file, since two files are read independently.
|
|
96
|
+
*
|
|
97
|
+
* @param {{ fileIndex: number, start: number, end: number, written: number, handedOver: boolean, at: number }[]} answers
|
|
98
|
+
* @returns {{ count: number, written: number, unfinished: number, forwardShare: number | null, largestBackBytes: number, rewritten: number, list: object[] }}
|
|
99
|
+
*/
|
|
100
|
+
export function describeAnswers(answers) {
|
|
101
|
+
let written = 0, unfinished = 0, forward = 0, largestBackBytes = 0, rewritten = 0;
|
|
102
|
+
const reachedOf = new Map();
|
|
103
|
+
const coveredOf = new Map();
|
|
104
|
+
for (const answer of answers) {
|
|
105
|
+
written += answer.written;
|
|
106
|
+
if (!answer.handedOver) unfinished += 1;
|
|
107
|
+
const reached = reachedOf.get(answer.fileIndex);
|
|
108
|
+
if (reached === undefined || answer.start >= reached) forward += 1;
|
|
109
|
+
else largestBackBytes = Math.max(largestBackBytes, reached - answer.start);
|
|
110
|
+
// Bytes actually taken by this answer, as a range from its start.
|
|
111
|
+
const takenEnd = answer.start + Math.max(0, answer.written) - 1;
|
|
112
|
+
const covered = coveredOf.get(answer.fileIndex) ?? [];
|
|
113
|
+
for (const [from, to] of covered) {
|
|
114
|
+
const overlap = Math.min(to, takenEnd) - Math.max(from, answer.start) + 1;
|
|
115
|
+
if (overlap > 0) rewritten += overlap;
|
|
116
|
+
}
|
|
117
|
+
if (takenEnd >= answer.start) covered.push([answer.start, takenEnd]);
|
|
118
|
+
coveredOf.set(answer.fileIndex, covered);
|
|
119
|
+
reachedOf.set(answer.fileIndex, Math.max(reached ?? 0, takenEnd + 1));
|
|
120
|
+
}
|
|
121
|
+
return { count: answers.length, written, unfinished,
|
|
122
|
+
forwardShare: answers.length ? forward / answers.length : null, largestBackBytes, rewritten, list: answers };
|
|
123
|
+
}
|
|
@@ -77,6 +77,13 @@ import { OtherDemand, divideAllowance } from "./allowance.js";
|
|
|
77
77
|
export class MachineBudget {
|
|
78
78
|
#revisionPromise = null;
|
|
79
79
|
#reviseAgain = false;
|
|
80
|
+
/**
|
|
81
|
+
* The revision in hand, for its own line: when it was first asked for, how
|
|
82
|
+
* many asks joined it, and which pass this is. At a cold start on the addon
|
|
83
|
+
* host a caller waited 20.2 s inside `revise()` and nothing said whether it
|
|
84
|
+
* waited for passes, for a reading, or for the thread (torrent-tv/meta#177).
|
|
85
|
+
*/
|
|
86
|
+
#revision = null;
|
|
80
87
|
/** Resource name → how to read what is free, and what others have needed. */
|
|
81
88
|
#resources = new Map();
|
|
82
89
|
|
|
@@ -151,11 +158,14 @@ export class MachineBudget {
|
|
|
151
158
|
revise() {
|
|
152
159
|
if (this.#revisionPromise) {
|
|
153
160
|
this.#reviseAgain = true;
|
|
161
|
+
this.#revision.asks += 1;
|
|
154
162
|
return this.#revisionPromise;
|
|
155
163
|
}
|
|
164
|
+
this.#revision = { askedAt: Date.now(), asks: 1, pass: 0 };
|
|
156
165
|
this.#revisionPromise = (async () => {
|
|
157
166
|
do {
|
|
158
167
|
this.#reviseAgain = false;
|
|
168
|
+
this.#revision.pass += 1;
|
|
159
169
|
await this.#readRevision();
|
|
160
170
|
} while (this.#reviseAgain);
|
|
161
171
|
return this.#last;
|
|
@@ -164,13 +174,17 @@ export class MachineBudget {
|
|
|
164
174
|
}
|
|
165
175
|
|
|
166
176
|
async #readRevision() {
|
|
177
|
+
const passFrom = Date.now();
|
|
178
|
+
let readingMs = 0;
|
|
167
179
|
for (const [name, resource] of this.#resources) {
|
|
168
180
|
const claimants = this.#claimants.filter((one) => one.resource === name);
|
|
169
181
|
if (claimants.length === 0) {
|
|
170
182
|
continue;
|
|
171
183
|
}
|
|
172
184
|
const held = claimants.reduce((sum, one) => sum + Math.max(0, one.held()), 0);
|
|
185
|
+
const readFrom = Date.now();
|
|
173
186
|
const free = await resource.readFree();
|
|
187
|
+
readingMs += Date.now() - readFrom;
|
|
174
188
|
// WITHOUT A READING, NOTHING IS ALLOWED TO GROW. A resource whose free
|
|
175
189
|
// space cannot be read is not a resource with room; answering "unbounded"
|
|
176
190
|
// there is how the spill file came to have no limit in the first place.
|
|
@@ -197,7 +211,11 @@ export class MachineBudget {
|
|
|
197
211
|
);
|
|
198
212
|
}
|
|
199
213
|
}
|
|
200
|
-
this.#
|
|
214
|
+
const revision = this.#revision;
|
|
215
|
+
this.#logger?.info?.(`${this.describe()}` + (revision
|
|
216
|
+
? ` (pass ${revision.pass} of a revision asked ${revision.asks} time(s), first ${Date.now() - revision.askedAt}ms ago; ` +
|
|
217
|
+
`this pass ${Date.now() - passFrom}ms, ${readingMs}ms of it reading what is free)`
|
|
218
|
+
: ""));
|
|
201
219
|
return this.#last;
|
|
202
220
|
}
|
|
203
221
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file A CPU profile of the main thread at a cold start, asked for by the
|
|
3
|
+
* operator and never taken otherwise (torrent-tv/meta#177).
|
|
4
|
+
*
|
|
5
|
+
* At a cold start on the addon host (#166 run 12) the picture's first input
|
|
6
|
+
* waited 29.5 s, 20.2 s of it inside a budget revision, while the main thread
|
|
7
|
+
* ran at 83-108 % of a core; what it was doing is not in any log. A profile of
|
|
8
|
+
* that thread names it.
|
|
9
|
+
*
|
|
10
|
+
* In-process, through `node:inspector`: the sampling profiler runs beside the
|
|
11
|
+
* 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). It begins at the first
|
|
13
|
+
* source opened, which is where a cold start begins, runs for the seconds the
|
|
14
|
+
* operator named, and writes `cpu-start-<time>.cpuprofile` beside the log,
|
|
15
|
+
* readable in Chrome's DevTools (Performance, "Load profile").
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { Session } from "node:inspector/promises";
|
|
19
|
+
import { writeFile } from "node:fs/promises";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} params
|
|
24
|
+
* @param {number} params.seconds - How long to profile; nothing happens unless positive.
|
|
25
|
+
* @param {string} params.dir - Where to write the profile.
|
|
26
|
+
* @param {(line: string) => void} params.log
|
|
27
|
+
* @param {(bytes: number) => boolean} [params.mayKeep] - Whether the storage budget lets it be kept.
|
|
28
|
+
* @returns {{ begin: () => void, active: () => boolean }}
|
|
29
|
+
*/
|
|
30
|
+
export function startupCpuProfile({ seconds, dir, log, mayKeep = () => true }) {
|
|
31
|
+
let begun = false;
|
|
32
|
+
let running = false;
|
|
33
|
+
const wanted = Number.isFinite(seconds) && seconds > 0 && typeof dir === "string" && dir.length > 0;
|
|
34
|
+
const take = async () => {
|
|
35
|
+
const session = new Session();
|
|
36
|
+
session.connect();
|
|
37
|
+
try {
|
|
38
|
+
await session.post("Profiler.enable");
|
|
39
|
+
await session.post("Profiler.start");
|
|
40
|
+
running = true;
|
|
41
|
+
log(`cpu profile: the main thread is profiled for ${seconds}s from the first source opened`);
|
|
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)`);
|
|
55
|
+
} finally {
|
|
56
|
+
running = false;
|
|
57
|
+
session.disconnect();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
return {
|
|
61
|
+
begin() {
|
|
62
|
+
if (!wanted || begun) return;
|
|
63
|
+
begun = true;
|
|
64
|
+
void take().catch((error) => log(`cpu profile: failed — ${error.message}`));
|
|
65
|
+
},
|
|
66
|
+
active: () => running
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Where this machine's processor goes: the proxy's own threads and every
|
|
3
|
+
* encoder it runs (torrent-tv/meta#177).
|
|
4
|
+
*
|
|
5
|
+
* The encoders are priced in seconds of work per second of film, and the
|
|
6
|
+
* torrent per megabyte moved. What neither price carries was seen only from
|
|
7
|
+
* outside: at a cold start on the addon host (#166 run 12, 2026-10-10) the main
|
|
8
|
+
* thread ran at 83-108 % of a core for thirty seconds, measured by a shell loop
|
|
9
|
+
* reading `/proc`, while the proxy's own log said nothing for 10.7 s.
|
|
10
|
+
*
|
|
11
|
+
* Read once a second, said once a minute as each part's mean and highest
|
|
12
|
+
* second, and every second while a cold start is being profiled. The Node
|
|
13
|
+
* process is counted ONCE, as `process.cpuUsage()` reports it; the split into
|
|
14
|
+
* threads only says where that time went. Each ffmpeg is its own process and
|
|
15
|
+
* is added beside it. Linux only for the split and for ffmpeg; elsewhere the
|
|
16
|
+
* process figure alone is said.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
20
|
+
|
|
21
|
+
/** Clock ticks per second of `/proc/<pid>/stat`, as in `encode/host-load.js`. */
|
|
22
|
+
const CLOCK_TICKS_PER_SECOND = 100;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The CPU seconds and the name in one `/proc/.../stat` text.
|
|
26
|
+
*
|
|
27
|
+
* The name can hold spaces and brackets, so it is everything between the first
|
|
28
|
+
* `(` and the LAST `)`, and the fields are counted from there.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} text
|
|
31
|
+
* @returns {{ name: string, seconds: number } | null}
|
|
32
|
+
*/
|
|
33
|
+
export function parseStat(text) {
|
|
34
|
+
const open = text.indexOf("(");
|
|
35
|
+
const close = text.lastIndexOf(")");
|
|
36
|
+
if (open < 0 || close < open) return null;
|
|
37
|
+
const after = text.slice(close + 2).trim().split(/\s+/);
|
|
38
|
+
// After the name and the state, utime is the 12th field and stime the 13th.
|
|
39
|
+
const utime = Number(after[11]);
|
|
40
|
+
const stime = Number(after[12]);
|
|
41
|
+
if (!Number.isFinite(utime) || !Number.isFinite(stime)) return null;
|
|
42
|
+
return { name: text.slice(open + 1, close), seconds: (utime + stime) / CLOCK_TICKS_PER_SECOND };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Which part of the proxy a thread belongs to: the main thread, the torrent
|
|
47
|
+
* thread (Node names a worker's thread `WorkerThread`), or the rest — V8's
|
|
48
|
+
* helpers, libuv's pool, the inspector — counted together.
|
|
49
|
+
*
|
|
50
|
+
* @param {number} tid
|
|
51
|
+
* @param {string} name
|
|
52
|
+
* @param {number} pid
|
|
53
|
+
* @returns {"main" | "worker" | "other"}
|
|
54
|
+
*/
|
|
55
|
+
export function partOfThread(tid, name, pid) {
|
|
56
|
+
if (tid === pid) return "main";
|
|
57
|
+
if (name === "WorkerThread") return "worker";
|
|
58
|
+
return "other";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* CPU seconds of this process's threads, by part, or null where `/proc` cannot
|
|
63
|
+
* be read.
|
|
64
|
+
*
|
|
65
|
+
* @param {number} [pid]
|
|
66
|
+
* @returns {Promise<{ main: number, worker: number, other: number } | null>}
|
|
67
|
+
*/
|
|
68
|
+
export async function readThreadSeconds(pid = process.pid) {
|
|
69
|
+
let tids;
|
|
70
|
+
try {
|
|
71
|
+
tids = await readdir(`/proc/${pid}/task`);
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const parts = { main: 0, worker: 0, other: 0 };
|
|
76
|
+
await Promise.all(tids.map(async (entry) => {
|
|
77
|
+
const tid = Number(entry);
|
|
78
|
+
const stat = await readFile(`/proc/${pid}/task/${entry}/stat`, "utf8").then(parseStat, () => null);
|
|
79
|
+
if (stat) parts[partOfThread(tid, stat.name, pid)] += stat.seconds;
|
|
80
|
+
}));
|
|
81
|
+
return parts;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* CPU seconds of each of these processes that can be read now.
|
|
86
|
+
*
|
|
87
|
+
* @param {number[]} pids
|
|
88
|
+
* @returns {Promise<Map<number, number>>}
|
|
89
|
+
*/
|
|
90
|
+
export async function readProcessesSeconds(pids) {
|
|
91
|
+
const seconds = new Map();
|
|
92
|
+
await Promise.all(pids.map(async (pid) => {
|
|
93
|
+
const stat = await readFile(`/proc/${pid}/stat`, "utf8").then(parseStat, () => null);
|
|
94
|
+
if (stat) seconds.set(pid, stat.seconds);
|
|
95
|
+
}));
|
|
96
|
+
return seconds;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Shares of one core between two readings, per part. A thread or a process
|
|
101
|
+
* present in only one of the two readings contributes nothing, so a process
|
|
102
|
+
* that came or went in between is not read as a jump.
|
|
103
|
+
*
|
|
104
|
+
* @param {{ at: number, process: number, threads: object | null, ffmpeg: Map<number, number> }} before
|
|
105
|
+
* @param {{ at: number, process: number, threads: object | null, ffmpeg: Map<number, number> }} after
|
|
106
|
+
* @returns {{ seconds: number, process: number, main: number | null, worker: number | null, other: number | null, ffmpeg: number | null } | null}
|
|
107
|
+
*/
|
|
108
|
+
export function sharesBetween(before, after) {
|
|
109
|
+
const seconds = (after.at - before.at) / 1000;
|
|
110
|
+
if (!(seconds > 0)) return null;
|
|
111
|
+
const share = (from, to) => (Number.isFinite(from) && Number.isFinite(to) ? Math.max(0, to - from) / seconds : null);
|
|
112
|
+
let ffmpeg = null;
|
|
113
|
+
for (const [pid, value] of after.ffmpeg) {
|
|
114
|
+
const earlier = before.ffmpeg.get(pid);
|
|
115
|
+
if (earlier !== undefined && value >= earlier) ffmpeg = (ffmpeg ?? 0) + (value - earlier) / seconds;
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
seconds,
|
|
119
|
+
process: share(before.process, after.process),
|
|
120
|
+
main: share(before.threads?.main, after.threads?.main),
|
|
121
|
+
worker: share(before.threads?.worker, after.threads?.worker),
|
|
122
|
+
other: share(before.threads?.other, after.threads?.other),
|
|
123
|
+
ffmpeg
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* One line: per part, the mean over the interval and its highest second, as a
|
|
129
|
+
* percentage of one core.
|
|
130
|
+
*
|
|
131
|
+
* @param {object[]} seconds - Per-second shares from `sharesBetween`.
|
|
132
|
+
* @returns {string}
|
|
133
|
+
*/
|
|
134
|
+
export function describeShares(seconds) {
|
|
135
|
+
const parts = ["process", "main", "worker", "other", "ffmpeg"];
|
|
136
|
+
const said = parts.map((part) => {
|
|
137
|
+
const values = seconds.map((one) => one[part]).filter((value) => Number.isFinite(value));
|
|
138
|
+
if (values.length === 0) return `${part} n/a`;
|
|
139
|
+
const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
140
|
+
return `${part} ${Math.round(mean * 100)}% (highest second ${Math.round(Math.max(...values) * 100)}%)`;
|
|
141
|
+
});
|
|
142
|
+
return `${said.join(", ")} of a core over ${seconds.length}s`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Read every second, say once a minute, and every second while `detailed()`
|
|
147
|
+
* is true.
|
|
148
|
+
*
|
|
149
|
+
* @param {object} params
|
|
150
|
+
* @param {(line: string) => void} params.log
|
|
151
|
+
* @param {() => number[]} params.ffmpegPids - The encoders running now.
|
|
152
|
+
* @param {() => boolean} [params.detailed]
|
|
153
|
+
* @param {number} [params.everyMs]
|
|
154
|
+
* @param {number} [params.sayEveryMs]
|
|
155
|
+
* @returns {{ stop: () => void }}
|
|
156
|
+
*/
|
|
157
|
+
export function startProcessorReport({ log, ffmpegPids, detailed = () => false, everyMs = 1000, sayEveryMs = 60_000 }) {
|
|
158
|
+
let previous = null;
|
|
159
|
+
let series = [];
|
|
160
|
+
let since = Date.now();
|
|
161
|
+
let reading = false;
|
|
162
|
+
const tick = async () => {
|
|
163
|
+
if (reading) return;
|
|
164
|
+
reading = true;
|
|
165
|
+
try {
|
|
166
|
+
const usage = process.cpuUsage();
|
|
167
|
+
const [threads, ffmpeg] = await Promise.all([readThreadSeconds(), readProcessesSeconds(ffmpegPids())]);
|
|
168
|
+
const now = { at: Date.now(), process: (usage.user + usage.system) / 1e6, threads, ffmpeg };
|
|
169
|
+
const shares = previous ? sharesBetween(previous, now) : null;
|
|
170
|
+
previous = now;
|
|
171
|
+
if (!shares) return;
|
|
172
|
+
series.push(shares);
|
|
173
|
+
if (detailed()) log(`processor: ${describeShares([shares])}`);
|
|
174
|
+
if (now.at - since >= sayEveryMs) {
|
|
175
|
+
log(`processor: ${describeShares(series)}`);
|
|
176
|
+
series = [];
|
|
177
|
+
since = now.at;
|
|
178
|
+
}
|
|
179
|
+
} finally {
|
|
180
|
+
reading = false;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
const timer = setInterval(() => { void tick(); }, everyMs);
|
|
184
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
185
|
+
return { stop: () => clearInterval(timer) };
|
|
186
|
+
}
|
|
@@ -475,12 +475,13 @@ export class TorrentWorkerClient {
|
|
|
475
475
|
* @param {{ sourceKey: string, fileIndex: number, durationSeconds: number, zones: { from: number, to: number, priority: number }[] }} params
|
|
476
476
|
* @returns {Promise<void>}
|
|
477
477
|
*/
|
|
478
|
-
async setPriorityMap({ sourceKey, fileIndex, zones, durationSeconds }) {
|
|
478
|
+
async setPriorityMap({ sourceKey, fileIndex, zones, durationSeconds, postedAt }) {
|
|
479
479
|
return this.#caller.call(Command.PRIORITY_MAP, {
|
|
480
480
|
sourceKey,
|
|
481
481
|
fileIndex,
|
|
482
482
|
zones,
|
|
483
|
-
durationSeconds
|
|
483
|
+
durationSeconds,
|
|
484
|
+
postedAt
|
|
484
485
|
});
|
|
485
486
|
}
|
|
486
487
|
|
|
@@ -164,11 +164,11 @@ export class WorkerTorrentPool {
|
|
|
164
164
|
* @param {{ sourceKey: string, fileIndex: number, durationSeconds: number, zones: object[] }} params
|
|
165
165
|
* @returns {Promise<void>}
|
|
166
166
|
*/
|
|
167
|
-
async setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones }) {
|
|
167
|
+
async setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones, postedAt }) {
|
|
168
168
|
if (!sourceKey) {
|
|
169
169
|
return;
|
|
170
170
|
}
|
|
171
|
-
return this.#client.setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones });
|
|
171
|
+
return this.#client.setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones, postedAt });
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
/**
|
|
@@ -454,13 +454,17 @@ async function runCommand(command, params, id) {
|
|
|
454
454
|
}
|
|
455
455
|
|
|
456
456
|
case Command.PRIORITY_MAP: {
|
|
457
|
+
// When this handler began, on the clock the main thread posted by: the
|
|
458
|
+
// post to here holds the transfer and this thread's queue together
|
|
459
|
+
// (torrent-tv/meta#177).
|
|
460
|
+
const receivedAt = performance.timeOrigin + performance.now();
|
|
457
461
|
// Source preparation owns adding the source. A delayed publication may
|
|
458
462
|
// only update an existing source, never revive one after its viewer left.
|
|
459
463
|
const zones = Array.isArray(params.zones) ? params.zones : [];
|
|
460
464
|
// Nor may one for a file held whole, which has nothing left to fetch.
|
|
461
465
|
const torrent = await knownTorrent(params.sourceKey);
|
|
462
466
|
if (!torrent) {
|
|
463
|
-
return { appliedMs: null };
|
|
467
|
+
return { appliedMs: null, receivedAt, repliedAt: performance.timeOrigin + performance.now() };
|
|
464
468
|
}
|
|
465
469
|
// What applying a map costs this thread, told to whoever sent it: every
|
|
466
470
|
// zone becomes a window of the register and the swarm is told again
|
|
@@ -469,7 +473,7 @@ async function runCommand(command, params, id) {
|
|
|
469
473
|
ensureArrivalsWired(params.sourceKey, torrent);
|
|
470
474
|
pool.applyPriorityMap(torrent, params.fileIndex, zones, params.durationSeconds);
|
|
471
475
|
torrent.emit("priority-map-changed", params.fileIndex);
|
|
472
|
-
return { appliedMs: performance.now() - startedAt };
|
|
476
|
+
return { appliedMs: performance.now() - startedAt, receivedAt, repliedAt: performance.timeOrigin + performance.now() };
|
|
473
477
|
}
|
|
474
478
|
|
|
475
479
|
case Command.READ_RANGE: {
|
|
@@ -12,13 +12,21 @@ export class DownloadMaps {
|
|
|
12
12
|
#nextEpoch = 0;
|
|
13
13
|
#log;
|
|
14
14
|
#now;
|
|
15
|
+
/**
|
|
16
|
+
* Milliseconds on the clock both threads read the same way, to the
|
|
17
|
+
* microsecond: what a publication's stages are measured with, the torrent
|
|
18
|
+
* thread's receipt included (torrent-tv/meta#177).
|
|
19
|
+
*/
|
|
20
|
+
#clock;
|
|
15
21
|
|
|
16
|
-
constructor({ publish, resolvePlayback = async map => map.zones, log = null, now = Date.now
|
|
22
|
+
constructor({ publish, resolvePlayback = async map => map.zones, log = null, now = Date.now,
|
|
23
|
+
clock = () => performance.timeOrigin + performance.now() }) {
|
|
17
24
|
if (typeof publish !== "function") throw new TypeError("Download map publication is required.");
|
|
18
25
|
this.#publish = publish;
|
|
19
26
|
this.#resolvePlayback = resolvePlayback;
|
|
20
27
|
this.#log = typeof log === "function" ? log : null;
|
|
21
28
|
this.#now = now;
|
|
29
|
+
this.#clock = clock;
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
async playback({ sourceKey, fileIndex, durationSeconds, zones }) {
|
|
@@ -200,6 +208,7 @@ export class DownloadMaps {
|
|
|
200
208
|
}
|
|
201
209
|
|
|
202
210
|
#emit(file) {
|
|
211
|
+
const builtFrom = this.#clock();
|
|
203
212
|
const zones = [...file.zones, ...file.nativeZones];
|
|
204
213
|
for (const { ranges, requestId, priority, urgent, deadlineAt, order } of file.metadata.values()) {
|
|
205
214
|
for (const [byteStart, byteEnd] of ranges) {
|
|
@@ -217,16 +226,32 @@ export class DownloadMaps {
|
|
|
217
226
|
// orders nothing differently, so the deadlines' order is compared and not
|
|
218
227
|
// their values.
|
|
219
228
|
const shape = shapeOf(publication);
|
|
229
|
+
// WHAT A PUBLICATION COSTS, stage by stage (torrent-tv/meta#177): building
|
|
230
|
+
// and comparing it here, every time something is stated, sent or not;
|
|
231
|
+
// waiting behind the previous one; the synchronous post, which drops the
|
|
232
|
+
// zones repeated for the torrent and copies the rest for the other thread;
|
|
233
|
+
// from the post to the torrent thread's
|
|
234
|
+
// handler, which holds the transfer and that thread's queue together; and
|
|
235
|
+
// applying it there. A cold minute of #166 run 12 built 10 408 maps and
|
|
236
|
+
// sent 439, and nothing said what that cost.
|
|
237
|
+
const builtMs = this.#clock() - builtFrom;
|
|
220
238
|
if (file.shape && sameShape(file.shape, shape)) {
|
|
221
|
-
this.#statsOf(file)
|
|
239
|
+
const stats = this.#statsOf(file);
|
|
240
|
+
stats.unchanged += 1;
|
|
241
|
+
stats.unsentBuildMs += builtMs;
|
|
222
242
|
this.#report(file);
|
|
223
243
|
return Promise.resolve();
|
|
224
244
|
}
|
|
225
245
|
file.shape = shape;
|
|
246
|
+
const askedAt = this.#clock();
|
|
226
247
|
const pending = file.pending.then(() => {
|
|
227
248
|
if (this.#files.get(`${file.sourceKey}:${file.fileIndex}`) !== file) return undefined;
|
|
228
249
|
const sentAt = this.#now();
|
|
229
|
-
|
|
250
|
+
const postedAt = this.#clock();
|
|
251
|
+
const sent = this.#publish({ ...publication, postedAt });
|
|
252
|
+
const postMs = this.#clock() - postedAt;
|
|
253
|
+
return Promise.resolve(sent).then(reply => {
|
|
254
|
+
const answeredAt = this.#clock();
|
|
230
255
|
// A torrent not yet in the thread applies nothing, so this map was not
|
|
231
256
|
// delivered and the next one, however alike, has to be.
|
|
232
257
|
if (reply?.appliedMs === null && file.shape === shape) file.shape = null;
|
|
@@ -235,6 +260,11 @@ export class DownloadMaps {
|
|
|
235
260
|
stats.sent += 1;
|
|
236
261
|
stats.zones.push(zones.length);
|
|
237
262
|
stats.deliveredMs.push(this.#now() - sentAt);
|
|
263
|
+
stats.sentBuildMs.push(builtMs);
|
|
264
|
+
stats.waitedMs.push(postedAt - askedAt);
|
|
265
|
+
stats.postMs.push(postMs);
|
|
266
|
+
if (Number.isFinite(reply?.receivedAt)) stats.toHandlerMs.push(Math.max(0, reply.receivedAt - postedAt - postMs));
|
|
267
|
+
if (Number.isFinite(reply?.repliedAt)) stats.replyMs.push(Math.max(0, answeredAt - reply.repliedAt));
|
|
238
268
|
if (Number.isFinite(reply?.appliedMs)) stats.appliedMs.push(reply.appliedMs);
|
|
239
269
|
this.#report(file);
|
|
240
270
|
return reply;
|
|
@@ -249,7 +279,7 @@ export class DownloadMaps {
|
|
|
249
279
|
}
|
|
250
280
|
|
|
251
281
|
#statsOf(file) {
|
|
252
|
-
file.stats ??=
|
|
282
|
+
file.stats ??= emptyStats(this.#now());
|
|
253
283
|
return file.stats;
|
|
254
284
|
}
|
|
255
285
|
|
|
@@ -264,11 +294,28 @@ export class DownloadMaps {
|
|
|
264
294
|
`zones median ${middle(stats.zones)} max ${largest(stats.zones)}; ` +
|
|
265
295
|
`delivered median ${middle(stats.deliveredMs)}ms max ${largest(stats.deliveredMs)}ms (${total(stats.deliveredMs)}ms in all), ` +
|
|
266
296
|
`applied in the torrent thread median ${middle(stats.appliedMs)}ms max ${largest(stats.appliedMs)}ms ` +
|
|
267
|
-
`(${total(stats.appliedMs)}ms in all)`
|
|
268
|
-
|
|
297
|
+
`(${total(stats.appliedMs)}ms in all); ` +
|
|
298
|
+
`stages of a sent map — built ${stage(stats.sentBuildMs)}, waited behind the previous ${stage(stats.waitedMs)}, ` +
|
|
299
|
+
`posted ${stage(stats.postMs)}, post to the torrent thread's handler ${stage(stats.toHandlerMs)}, ` +
|
|
300
|
+
`reply back ${stage(stats.replyMs)}; maps built and not sent took ${total([stats.unsentBuildMs])}ms here`);
|
|
301
|
+
file.stats = emptyStats(now);
|
|
269
302
|
}
|
|
270
303
|
}
|
|
271
304
|
|
|
305
|
+
/** @param {number} since */
|
|
306
|
+
function emptyStats(since) {
|
|
307
|
+
return { since, sent: 0, unchanged: 0, zones: [], deliveredMs: [], appliedMs: [],
|
|
308
|
+
sentBuildMs: [], waitedMs: [], postMs: [], toHandlerMs: [], replyMs: [], unsentBuildMs: 0 };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** One stage's median, largest and sum, in milliseconds with tenths. @param {number[]} values */
|
|
312
|
+
function stage(values) {
|
|
313
|
+
if (values.length === 0) return "n/a";
|
|
314
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
315
|
+
const sum = values.reduce((total, value) => total + value, 0);
|
|
316
|
+
return `median ${sorted[Math.floor(sorted.length / 2)].toFixed(1)}ms max ${sorted.at(-1).toFixed(1)}ms sum ${Math.round(sum)}ms`;
|
|
317
|
+
}
|
|
318
|
+
|
|
272
319
|
/** How many numbers describe one zone's part in what the torrent does. */
|
|
273
320
|
const ZONE_FIELDS = 7;
|
|
274
321
|
|