@torrent-tv/proxy 2.97.11 → 2.97.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/routes/encode-input/get.js +10 -2
- package/services/encode/EncodeInputs.js +40 -2
- package/services/encode/EncodeOrchestrator.js +18 -0
- package/services/encode/EncodeRuns.js +16 -3
- package/services/encode/OriginalInput.js +107 -17
- package/services/encode/quality/HostLoad.js +39 -3
- package/services/server/wire-outputs.js +3 -0
- package/services/torrent/worker/held-bytes.js +2 -1
- package/services/torrent/worker/worker.js +28 -3
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { parseRange } from "../../utils/parse-range.js";
|
|
2
2
|
|
|
3
3
|
/** FFmpeg reads only original byte ranges already owned by its live run. */
|
|
4
|
-
export function handleEncodeInputGet(req, reply, { inputOf, refused = () => {} }) {
|
|
4
|
+
export async function handleEncodeInputGet(req, reply, { inputOf, refused = () => {} }) {
|
|
5
5
|
const address = req.raw?.socket?.remoteAddress;
|
|
6
6
|
if (!["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(address)) return reply.code(403).send();
|
|
7
7
|
const token = Number(req.params.token), fileIndex = Number(req.params.fileIndex);
|
|
@@ -29,7 +29,15 @@ export function handleEncodeInputGet(req, reply, { inputOf, refused = () => {} }
|
|
|
29
29
|
// and a byte past it is refused, which from 7.1 on ends the run: every read
|
|
30
30
|
// FFmpeg makes, its stream search at open included, must be named by the
|
|
31
31
|
// container (torrent-tv/meta#165).
|
|
32
|
-
|
|
32
|
+
// A read inside the stretch of film waits for its copy, which goes on while
|
|
33
|
+
// FFmpeg opens (`OriginalInput.js`); the answer is then whole, as any other.
|
|
34
|
+
const held = await input.read(fileIndex, range.start, range.end, true);
|
|
35
|
+
if (!held && input.copyFailed?.()) {
|
|
36
|
+
// The copy of the stretch failed: a piece left the store between the
|
|
37
|
+
// check that it was there and the copy. The run ends on this refusal, and
|
|
38
|
+
// the failure is said where the copy is accounted for (`EncodeInputs.js`).
|
|
39
|
+
return reply.code(503).send({ error: "Original input bytes could not be copied." });
|
|
40
|
+
}
|
|
33
41
|
if (!held) {
|
|
34
42
|
// FFmpeg asked for a byte the run was not given: the container named too
|
|
35
43
|
// little. Said, because FFmpeg itself reports only a failed seek or read.
|
|
@@ -187,6 +187,33 @@ export class EncodeInputs {
|
|
|
187
187
|
this.#retryMemory();
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Withdraw what is being prepared or held for a start the plan no longer
|
|
192
|
+
* places: `starts` holds every start the last plan of this output placed or
|
|
193
|
+
* held back, and the number after each run it left alive. `retain` withdrew
|
|
194
|
+
* such a copy only once it was held, and only when a run was started; while
|
|
195
|
+
* it was being made it shared the torrent thread with the copy the viewer
|
|
196
|
+
* was waiting for. Field 2026-10-12: after a seek back, the next stretch of
|
|
197
|
+
* the run the seek had stopped went on being copied beside the one for the
|
|
198
|
+
* new position, 127.5 and 126.5 MB in 16 s each, and was withdrawn only once
|
|
199
|
+
* it was done (torrent-tv/meta#178).
|
|
200
|
+
*
|
|
201
|
+
* @param {object} output
|
|
202
|
+
* @param {Set<number>} starts
|
|
203
|
+
*/
|
|
204
|
+
withdrawUnplaced(output, starts) {
|
|
205
|
+
for (const [key, request] of this.#requests) {
|
|
206
|
+
if (request.output !== output || request.result?.kind === "terminal" || starts.has(request.from)) continue;
|
|
207
|
+
this.#note(request, "withdrawn: the plan places no run there");
|
|
208
|
+
this.#requests.delete(key);
|
|
209
|
+
this.#wanted.delete(key);
|
|
210
|
+
// A copy already answered may still be copying its stretch; released,
|
|
211
|
+
// the stretch is dropped when its read returns.
|
|
212
|
+
request.result?.release?.();
|
|
213
|
+
}
|
|
214
|
+
this.#retryMemory();
|
|
215
|
+
}
|
|
216
|
+
|
|
190
217
|
/**
|
|
191
218
|
* The input of a run starting at `from`, once it is prepared.
|
|
192
219
|
*
|
|
@@ -306,7 +333,9 @@ export class EncodeInputs {
|
|
|
306
333
|
if (result.kind === "result" && result.to > request.to) result.to = Math.max(request.from, request.to);
|
|
307
334
|
request.result = result;
|
|
308
335
|
this.#note(request, result.kind === "result" ? `ready #${result.from}..#${result.to}, ${result.bytes ?? "?"} bytes` +
|
|
309
|
-
(Number.isFinite(result.copyMs) ?
|
|
336
|
+
(Number.isFinite(result.copyMs) ? (result.deferredBytes > 0
|
|
337
|
+
? `, opening part copied in ${result.copyMs}ms, ${result.deferredBytes} bytes of the stretch copied while the run opens`
|
|
338
|
+
: `, copied in ${result.copyMs}ms`) : "")
|
|
310
339
|
: `${result.kind}${result.reason ? ` ${result.reason}` : ""}${Number.isFinite(result.bytes) ? ` ${result.bytes} bytes` : ""}`,
|
|
311
340
|
describeTiming(request.timing, this.#now()));
|
|
312
341
|
if (result.kind === "result" || result.kind === "terminal") this.#changed(request.output, result);
|
|
@@ -444,7 +473,16 @@ export class EncodeInputs {
|
|
|
444
473
|
if (result.kind === "needs-memory") this.#wanted.set(request.key, { wanted: minimum, required: minimum });
|
|
445
474
|
if (result.kind === "result") {
|
|
446
475
|
Object.assign(result, { from, to: end });
|
|
447
|
-
|
|
476
|
+
// The stretch is copied after the answer, while the run opens; the rate
|
|
477
|
+
// is the whole copy's, learned once it is done.
|
|
478
|
+
const copied = result.whole ?? Promise.resolve(true);
|
|
479
|
+
void copied.then(whole => {
|
|
480
|
+
if (whole && result.copyMs > 0) keep(this.#copyRates, result.bytes / result.copyMs);
|
|
481
|
+
this.#log(`encode input #${from}..#${end} of ${addressOf(output)}: ` + (whole
|
|
482
|
+
? `stretch copied, ${result.bytes} bytes in ${result.copyMs}ms of copying since it was admitted`
|
|
483
|
+
: result.copyFailed?.() ? "the stretch could not be copied: a piece it needs is no longer held"
|
|
484
|
+
: "released before its stretch was copied"));
|
|
485
|
+
});
|
|
448
486
|
}
|
|
449
487
|
return result;
|
|
450
488
|
}
|
|
@@ -228,6 +228,7 @@ export class EncodeOrchestrator {
|
|
|
228
228
|
segmentCoverage = null,
|
|
229
229
|
planSoon = null,
|
|
230
230
|
inputDemandChanged = null,
|
|
231
|
+
inputsUnplaced = null,
|
|
231
232
|
describeWaits = null,
|
|
232
233
|
admission = null,
|
|
233
234
|
logger,
|
|
@@ -264,6 +265,9 @@ export class EncodeOrchestrator {
|
|
|
264
265
|
// loop that calls it. Absent in a test, where the clock is the test's own.
|
|
265
266
|
this.planSoon = typeof planSoon === "function" ? planSoon : () => undefined;
|
|
266
267
|
this.inputDemandChanged = typeof inputDemandChanged === "function" ? inputDemandChanged : () => undefined;
|
|
268
|
+
// Told, after each plan of an output, where that plan places runs and
|
|
269
|
+
// where the runs it kept end, so inputs prepared for anywhere else go back.
|
|
270
|
+
this.inputsUnplaced = typeof inputsUnplaced === "function" ? inputsUnplaced : () => undefined;
|
|
267
271
|
}
|
|
268
272
|
|
|
269
273
|
/**
|
|
@@ -882,6 +886,20 @@ export class EncodeOrchestrator {
|
|
|
882
886
|
coverage.claim(action.run, action.from, endOfRun({ from: action.from, to: action.to }));
|
|
883
887
|
}
|
|
884
888
|
|
|
889
|
+
// AN INPUT IS PREPARED ONLY FOR A START THIS PLAN PLACES: one it starts or
|
|
890
|
+
// holds back (a copied picture's input may begin one number earlier), or
|
|
891
|
+
// the number after a run it left alive, whose next stretch is copied while
|
|
892
|
+
// it works. Any other copy shares the torrent thread with the one a viewer
|
|
893
|
+
// waits for (torrent-tv/meta#178).
|
|
894
|
+
const starts = new Set();
|
|
895
|
+
for (const action of actions) {
|
|
896
|
+
if (action.type !== "start" && action.type !== "move") continue;
|
|
897
|
+
starts.add(action.from);
|
|
898
|
+
starts.add(action.from - 1);
|
|
899
|
+
}
|
|
900
|
+
for (const run of this.runsOn(address)) if (run.isAlive) starts.add(run.to + 1);
|
|
901
|
+
this.inputsUnplaced(address, starts);
|
|
902
|
+
|
|
885
903
|
// NOBODY IS MAKING WHAT SOMEBODY IS WAITING FOR. Said here, with the numbers
|
|
886
904
|
// the decision was taken from, because it is the one state in which a viewer
|
|
887
905
|
// waits for ever and every line above it reads as a healthy proxy.
|
|
@@ -1178,7 +1178,16 @@ export class EncodeRuns {
|
|
|
1178
1178
|
// 48.9 s after run #1..#32 ended, then the start of #33..#892, and the
|
|
1179
1179
|
// viewer stood 69 s (torrent-tv/meta#166). Asked now, it is copied while
|
|
1180
1180
|
// this run works and is ready when the plan places the next run there.
|
|
1181
|
-
|
|
1181
|
+
// It waits for this run's own stretch to be copied: that copy goes on
|
|
1182
|
+
// while the run opens and FFmpeg's reads wait for it, and a second copy
|
|
1183
|
+
// beside it on the same thread would slow it (torrent-tv/meta#178).
|
|
1184
|
+
if (inputEnd < mayReach) {
|
|
1185
|
+
const askNext = () => {
|
|
1186
|
+
if (this.isLive(session) && run.isAlive) this.#host.encodeInputs?.take(session, inputEnd + 1, mayReach);
|
|
1187
|
+
};
|
|
1188
|
+
if (admittedInput.whole) void admittedInput.whole.then(whole => { if (whole) askNext(); });
|
|
1189
|
+
else askNext();
|
|
1190
|
+
}
|
|
1182
1191
|
} else if (admittedInput) {
|
|
1183
1192
|
run.admittedInputKeys = new Map([[safeIndex, inputKey]]);
|
|
1184
1193
|
let nextIndex = startIndex + 1;
|
|
@@ -1252,10 +1261,14 @@ export class EncodeRuns {
|
|
|
1252
1261
|
const pieceSeconds = Number(grid?.[run.from + 1]) - Number(grid?.[run.from]);
|
|
1253
1262
|
const speed = run.speedX > 0 ? run.speedX : this.#host.encodeOrchestrator.speedOn?.(session.outputKey) ?? 0;
|
|
1254
1263
|
if (!Number.isFinite(ended.firstNamedMs) || !(pieceSeconds > 0) || !(speed > 0)) return;
|
|
1255
|
-
|
|
1264
|
+
// FFmpeg's reads may have waited for the stretch being copied; that wait
|
|
1265
|
+
// is the copy's, and the stretch is sized from the open (torrent-tv/meta#178).
|
|
1266
|
+
const waitedMs = Number.isFinite(ended.firstNamedWallMs) ? run.originalInput?.waitedBefore?.(ended.firstNamedWallMs) ?? 0 : 0;
|
|
1267
|
+
const openSeconds = Math.max(0, (ended.firstNamedMs - waitedMs) / 1000 - pieceSeconds / speed);
|
|
1256
1268
|
this.#host.encodeOrchestrator.noteOpen?.(session.outputKey ?? "", openSeconds);
|
|
1257
1269
|
this.#host.logger.info(`encode input of run #${run.from}..#${run.to} on ${session.outputKey}: opened in ` +
|
|
1258
|
-
`${openSeconds.toFixed(2)}s (first piece named at ${ended.firstNamedMs}ms, ${pieceSeconds.toFixed(3)}s of film at ${speed.toFixed(2)}x
|
|
1270
|
+
`${openSeconds.toFixed(2)}s (first piece named at ${ended.firstNamedMs}ms, ${pieceSeconds.toFixed(3)}s of film at ${speed.toFixed(2)}x` +
|
|
1271
|
+
`${waitedMs > 0 ? `, ${waitedMs}ms of it waiting for the stretch to be copied, not counted` : ""})`);
|
|
1259
1272
|
}
|
|
1260
1273
|
|
|
1261
1274
|
/**
|
|
@@ -3,6 +3,15 @@ import { createHash } from "node:crypto";
|
|
|
3
3
|
/** Retain complete original byte ranges under the encoder's input allowance.
|
|
4
4
|
* The available-only reader acquires complete pieces atomically before copying;
|
|
5
5
|
* owned buffers then outlive torrent eviction without keeping duplicate pins.
|
|
6
|
+
*
|
|
7
|
+
* THE RUN STARTS ONCE WHAT FFMPEG OPENS WITH IS COPIED. Each source's largest
|
|
8
|
+
* range — the stretch of film — is copied after the rest, and the answer comes
|
|
9
|
+
* back before it: FFmpeg's start and its reads of the header and the index then
|
|
10
|
+
* overlap that copy, and a read inside the stretch waits for it. Field
|
|
11
|
+
* 2026-10-12, after a seek back: 126.5 MB copied in 16.3 s before the encoder
|
|
12
|
+
* was started, then 10.1 s of its own open, and the viewer stood 33 s
|
|
13
|
+
* (torrent-tv/meta#178). `whole` settles when everything is copied — true — or
|
|
14
|
+
* when the copy failed or the input was released first — false.
|
|
6
15
|
*/
|
|
7
16
|
export async function admitOriginalInput({ sources, reserve, readRanges, now = Date.now }) {
|
|
8
17
|
const bytes = originalInputBytes(sources);
|
|
@@ -15,27 +24,72 @@ export async function admitOriginalInput({ sources, reserve, readRanges, now = D
|
|
|
15
24
|
// How long the copy itself took, apart from any wait for memory or bytes
|
|
16
25
|
// before it: the figure a run's stretch is sized from, read where the copy is.
|
|
17
26
|
let copyMs = 0;
|
|
27
|
+
// Per source, the range copied after the answer: its position in the
|
|
28
|
+
// source's ranges, and the copy's promise once it has begun.
|
|
29
|
+
const later = [];
|
|
30
|
+
const read = async (source, ranges) => {
|
|
31
|
+
const startedAt = now();
|
|
32
|
+
const buffers = await readRanges(source, ranges, bytes);
|
|
33
|
+
copyMs += Math.max(0, now() - startedAt);
|
|
34
|
+
if (buffers === null) return null;
|
|
35
|
+
if (!Array.isArray(buffers) || buffers.length !== ranges.length || buffers.some((buffer, index) =>
|
|
36
|
+
!Buffer.isBuffer(buffer) || buffer.length !== ranges[index][1] - ranges[index][0] + 1)) {
|
|
37
|
+
throw new Error("Original input reader returned incomplete ranges.");
|
|
38
|
+
}
|
|
39
|
+
return buffers;
|
|
40
|
+
};
|
|
18
41
|
try {
|
|
19
42
|
for (const source of sources) {
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
43
|
+
const { ranges } = source.input;
|
|
44
|
+
const largest = ranges.reduce((best, range, index) =>
|
|
45
|
+
range[1] - range[0] > ranges[best][1] - ranges[best][0] ? index : best, 0);
|
|
46
|
+
const deferred = ranges.length > 1 ? largest : -1;
|
|
47
|
+
const first = ranges.filter((_, index) => index !== deferred);
|
|
48
|
+
const buffers = await read(source, first);
|
|
49
|
+
if (buffers === null) return { kind: "needs-bytes", sourceKey: source.sourceKey, fileIndex: source.fileIndex, ranges };
|
|
28
50
|
// THE INPUT IS NAMED BY WHAT IT IS, NOT BY ITS BYTES. Every source is a
|
|
29
51
|
// torrent whose pieces are verified before they are stored, and a read
|
|
30
52
|
// gives every byte or none, so the same source, file, ranges and
|
|
31
53
|
// selections are the same bytes. Hashing the bytes as well took 13.45 s
|
|
32
54
|
// of 180 s of the main thread on a Matroska film whose picture and sound
|
|
33
55
|
// copy the same ~30 MB a piece (torrent-tv/meta#178).
|
|
34
|
-
hash.update(JSON.stringify([source.sourceKey, source.fileIndex,
|
|
35
|
-
|
|
56
|
+
hash.update(JSON.stringify([source.sourceKey, source.fileIndex, ranges, source.input.selections]));
|
|
57
|
+
const entry = { source, buffers: ranges.map((_, index) => index === deferred ? null : buffers[index - (deferred >= 0 && index > deferred ? 1 : 0)]) };
|
|
58
|
+
held.set(source.fileIndex, entry);
|
|
59
|
+
if (deferred >= 0) later.push({ entry, position: deferred });
|
|
36
60
|
}
|
|
37
61
|
let released = false;
|
|
62
|
+
let failed = false;
|
|
38
63
|
retained = true;
|
|
64
|
+
// ONE READ FOR THE STRETCH. Copied a torrent piece per read (2.97.12), a
|
|
65
|
+
// stretch took 33.1 s for 83.9 MB on the field's AVI against 126.5 MB in
|
|
66
|
+
// 16.3 s as one read beside another copy (torrent-tv/meta#178): each read
|
|
67
|
+
// waits its turn on two busy threads, and the stretch paid that once per
|
|
68
|
+
// piece. A release therefore discards the stretch when its read returns.
|
|
69
|
+
const copyLater = job => read(job.entry.source, [job.entry.source.input.ranges[job.position]]).then(buffers => {
|
|
70
|
+
if (buffers === null) { failed = true; return false; }
|
|
71
|
+
if (released) return false;
|
|
72
|
+
job.entry.buffers[job.position] = buffers[0];
|
|
73
|
+
return true;
|
|
74
|
+
}, () => { failed = true; return false; });
|
|
75
|
+
// The stretches, one source after another: they share one reader, and the
|
|
76
|
+
// first source's stretch is the one FFmpeg reaches first.
|
|
77
|
+
const whole = (async () => {
|
|
78
|
+
for (const job of later) {
|
|
79
|
+
if (released) return false;
|
|
80
|
+
job.copy = job.copy ?? copyLater(job);
|
|
81
|
+
if (!await job.copy) return false;
|
|
82
|
+
}
|
|
83
|
+
return !released;
|
|
84
|
+
})();
|
|
85
|
+
// A read of a stretch waits for its copy, which begins at once for the
|
|
86
|
+
// stretch asked for even when another is still being copied before it.
|
|
87
|
+
const copied = (entry, position) => {
|
|
88
|
+
const job = later.find(candidate => candidate.entry === entry && candidate.position === position);
|
|
89
|
+
if (!job) return Promise.resolve(entry.buffers[position] !== null);
|
|
90
|
+
job.copy = job.copy ?? copyLater(job);
|
|
91
|
+
return job.copy;
|
|
92
|
+
};
|
|
39
93
|
// What FFmpeg asked of this input, for the run's own account of where its
|
|
40
94
|
// time went: field 2026-10-08, a run took 6-9 s for a piece FFmpeg makes in
|
|
41
95
|
// 1.6 s from a file, and nothing said whether it was waiting on its input.
|
|
@@ -45,12 +99,38 @@ export async function admitOriginalInput({ sources, reserve, readRanges, now = D
|
|
|
45
99
|
// whether all of it was (torrent-tv/meta#177).
|
|
46
100
|
/** @type {{ fileIndex: number, start: number, end: number, written: number, handedOver: boolean, at: number }[]} */
|
|
47
101
|
const answers = [];
|
|
102
|
+
// When FFmpeg's reads waited for a stretch still being copied.
|
|
103
|
+
/** @type {{ from: number, to: number | null }[]} */
|
|
104
|
+
const waits = [];
|
|
105
|
+
const answer = (entry, position, start, stop) => {
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
reads.count += 1;
|
|
108
|
+
reads.bytes += stop - start + 1;
|
|
109
|
+
reads.firstAt ||= now;
|
|
110
|
+
reads.lastAt = now;
|
|
111
|
+
const [from] = entry.source.input.ranges[position];
|
|
112
|
+
return { length: entry.source.input.fileLength, end: stop, bytes: entry.buffers[position].subarray(start - from, stop - from + 1) };
|
|
113
|
+
};
|
|
48
114
|
return {
|
|
49
|
-
kind: "result", original: true, sources, bytes,
|
|
115
|
+
kind: "result", original: true, sources, bytes, fingerprint: hash.digest("hex"),
|
|
116
|
+
// What the copy took so far; final once `whole` has settled.
|
|
117
|
+
get copyMs() { return copyMs; },
|
|
118
|
+
whole,
|
|
119
|
+
// Bytes of the stretches still to be copied when the answer was given.
|
|
120
|
+
deferredBytes: later.reduce((sum, job) => {
|
|
121
|
+
const [first, last] = job.entry.source.input.ranges[job.position];
|
|
122
|
+
return sum + last - first + 1;
|
|
123
|
+
}, 0),
|
|
124
|
+
copyFailed: () => failed,
|
|
50
125
|
// No track's own end: the container names the bytes an interval needs,
|
|
51
126
|
// not where each track stops. The final piece is judged by the input
|
|
52
127
|
// reaching the end of the file instead (`piece-completeness.js`).
|
|
53
128
|
tracks: sources.flatMap(source => (source.input.selections ?? []).map(selection => ({ track: selection.track }))),
|
|
129
|
+
/**
|
|
130
|
+
* The answer to a read from `start`, or null when it lies outside what
|
|
131
|
+
* the run holds. Inside a stretch still being copied the answer is a
|
|
132
|
+
* promise of the same, settled once the stretch is copied.
|
|
133
|
+
*/
|
|
54
134
|
read(fileIndex, start, end, partial = false) {
|
|
55
135
|
if (released) return null;
|
|
56
136
|
const entry = held.get(fileIndex);
|
|
@@ -61,12 +141,22 @@ export async function admitOriginalInput({ sources, reserve, readRanges, now = D
|
|
|
61
141
|
const position = ranges.findIndex(([from, to]) => from <= start && (partial ? start : stop) <= to);
|
|
62
142
|
if (position < 0) return null;
|
|
63
143
|
stop = Math.min(stop, ranges[position][1]);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
144
|
+
if (entry.buffers[position] !== null) return answer(entry, position, start, stop);
|
|
145
|
+
const wait = { from: Date.now(), to: null };
|
|
146
|
+
waits.push(wait);
|
|
147
|
+
return copied(entry, position).then(ok => {
|
|
148
|
+
wait.to = Date.now();
|
|
149
|
+
return ok && !released ? answer(entry, position, start, stop) : null;
|
|
150
|
+
});
|
|
151
|
+
},
|
|
152
|
+
/**
|
|
153
|
+
* Wall-clock milliseconds FFmpeg's reads spent waiting for a stretch to
|
|
154
|
+
* be copied before `until`. A run's open is measured from its first piece,
|
|
155
|
+
* and this wait is the copy's, not the open's: counted in, the stretch
|
|
156
|
+
* sized from the open would grow by its own copy time.
|
|
157
|
+
*/
|
|
158
|
+
waitedBefore(until) {
|
|
159
|
+
return waits.reduce((sum, wait) => sum + Math.max(0, Math.min(wait.to ?? until, until) - wait.from), 0);
|
|
70
160
|
},
|
|
71
161
|
lengthOf: fileIndex => held.get(fileIndex)?.source.input.fileLength ?? null,
|
|
72
162
|
reads: () => ({ ...reads, answers: describeAnswers(answers) }),
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import os from "node:os";
|
|
11
|
+
import { monitorEventLoopDelay } from "node:perf_hooks";
|
|
11
12
|
import { logger } from "../../../utils/logger.js";
|
|
12
13
|
import { availableShareFrom } from "../available-share.js";
|
|
13
14
|
import { baseDrawFrom, costPerMegabyteFrom } from "../torrent-cost.js";
|
|
@@ -15,6 +16,24 @@ import { medianOf, movedBeyondScatter, READINGS_KEPT, scatterOf } from "../learn
|
|
|
15
16
|
import { ENCODE_RUN_STATE, processCanBeSignalled } from "../encode-run-state.js";
|
|
16
17
|
import { SourceFiles } from "../../media/SourceFile.js";
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* What the torrent thread says of itself over one interval: how late its event
|
|
21
|
+
* loop ran, and how long reads for readers took inside it, from a command's
|
|
22
|
+
* arrival to its answer (torrent-tv/meta#178). Empty where it says nothing.
|
|
23
|
+
*
|
|
24
|
+
* @param {{ readerMs: number | null, readerReads: number | null, loopP99Ms: number | null, loopMaxMs: number | null }} now
|
|
25
|
+
* @param {{ readerMs: number | null, readerReads: number | null }} previous
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
function threadSays(now, previous) {
|
|
29
|
+
const loop = Number.isFinite(now.loopP99Ms) && Number.isFinite(now.loopMaxMs)
|
|
30
|
+
? `; its loop late p99=${Math.round(now.loopP99Ms)}ms max=${Math.round(now.loopMaxMs)}ms` : "";
|
|
31
|
+
const reads = Number.isFinite(now.readerReads) && Number.isFinite(previous.readerReads) && now.readerReads > previous.readerReads
|
|
32
|
+
&& Number.isFinite(now.readerMs) && Number.isFinite(previous.readerMs)
|
|
33
|
+
? `; ${now.readerReads - previous.readerReads} read(s) for readers took ${Math.round(now.readerMs - previous.readerMs)}ms in it` : "";
|
|
34
|
+
return loop + reads;
|
|
35
|
+
}
|
|
36
|
+
|
|
18
37
|
export class HostLoad {
|
|
19
38
|
/** What this reads and asks of the rest of the proxy, and nothing else. @type {object} */
|
|
20
39
|
#host;
|
|
@@ -35,6 +54,8 @@ export class HostLoad {
|
|
|
35
54
|
|
|
36
55
|
/** The previous reading of the torrent thread's processor and the bytes downloaded. */
|
|
37
56
|
#torrentSample = null;
|
|
57
|
+
/** This thread's event-loop lateness, begun at the first machine reading. */
|
|
58
|
+
#mainLoop = null;
|
|
38
59
|
|
|
39
60
|
/** A reading of the torrent thread is under way; it asks the worker, which may not answer. */
|
|
40
61
|
#readingTorrent = false;
|
|
@@ -186,6 +207,10 @@ export class HostLoad {
|
|
|
186
207
|
uploaded: totals?.uploaded ?? null,
|
|
187
208
|
served: totals?.served ?? null,
|
|
188
209
|
threadCpuSeconds: totals?.threadCpuSeconds ?? null,
|
|
210
|
+
readerMs: totals?.readerMs ?? null,
|
|
211
|
+
readerReads: totals?.readerReads ?? null,
|
|
212
|
+
loopP99Ms: totals?.loopP99Ms ?? null,
|
|
213
|
+
loopMaxMs: totals?.loopMaxMs ?? null,
|
|
189
214
|
metadata: this.#host.readMetadataActivity?.()
|
|
190
215
|
};
|
|
191
216
|
this.#learnThreadCost(now);
|
|
@@ -241,7 +266,7 @@ export class HostLoad {
|
|
|
241
266
|
`torrent: downloaded ${(megabytes / elapsedSec).toFixed(2)} MB/s, sent to peers ${rateOf("uploaded")}, ` +
|
|
242
267
|
`handed to readers ${rateOf("served")} over ${elapsedSec.toFixed(1)}s, ` +
|
|
243
268
|
`limit ${Number.isFinite(limit) ? `${(limit / 1e6).toFixed(2)} MB/s` : "none"}; ` +
|
|
244
|
-
`its thread drew ${Math.round(cpuSeconds / elapsedSec * 100)}% of the machine`
|
|
269
|
+
`its thread drew ${Math.round(cpuSeconds / elapsedSec * 100)}% of the machine` + threadSays(now, previous)
|
|
245
270
|
);
|
|
246
271
|
}
|
|
247
272
|
if (megabytes === 0) {
|
|
@@ -456,7 +481,8 @@ export class HostLoad {
|
|
|
456
481
|
try {
|
|
457
482
|
const totals = await this.#host.getTorrentTotals();
|
|
458
483
|
const read = (key) => { const value = Number(totals?.[key]); return Number.isFinite(value) ? value : null; };
|
|
459
|
-
return { downloaded: read("downloaded"), uploaded: read("uploaded"), served: read("served"), threadCpuSeconds: read("threadCpuSeconds")
|
|
484
|
+
return { downloaded: read("downloaded"), uploaded: read("uploaded"), served: read("served"), threadCpuSeconds: read("threadCpuSeconds"),
|
|
485
|
+
readerMs: read("readerMs"), readerReads: read("readerReads"), loopP99Ms: read("loopP99Ms"), loopMaxMs: read("loopMaxMs") };
|
|
460
486
|
} catch {
|
|
461
487
|
return null; // the pool is busy or gone; a reading missed is not a fault
|
|
462
488
|
}
|
|
@@ -525,6 +551,16 @@ export class HostLoad {
|
|
|
525
551
|
*/
|
|
526
552
|
async #sayMachine(share, proxyShare, running, suspended) {
|
|
527
553
|
const machine = await this.#host.readMachineState();
|
|
554
|
+
// HOW LATE THIS THREAD'S LOOP RAN over the interval: every answer to FFmpeg
|
|
555
|
+
// and every hand-over of a copy waits for it (torrent-tv/meta#178). Begun at
|
|
556
|
+
// the first reading, so a check that never reads the machine starts no timer.
|
|
557
|
+
if (!this.#mainLoop) {
|
|
558
|
+
this.#mainLoop = monitorEventLoopDelay({ resolution: 20 });
|
|
559
|
+
this.#mainLoop.enable();
|
|
560
|
+
}
|
|
561
|
+
const late = this.#mainLoop.count > 0
|
|
562
|
+
? ` loop p99=${Math.round(this.#mainLoop.percentile(99) / 1e6)}ms max=${Math.round(this.#mainLoop.max / 1e6)}ms` : "";
|
|
563
|
+
this.#mainLoop.reset();
|
|
528
564
|
const asPercent = (value) => (value === null || value === undefined ? "n/a" : `${Math.round(value * 100)}%`);
|
|
529
565
|
const free = Number.isFinite(share.systemShare) ? Math.max(0, 1 - share.systemShare) : null;
|
|
530
566
|
logger.info(
|
|
@@ -536,7 +572,7 @@ export class HostLoad {
|
|
|
536
572
|
` over=${share.elapsedSec.toFixed(1)}s` +
|
|
537
573
|
// What the offer will multiply a prediction by, in the same line as the
|
|
538
574
|
// readings it comes from.
|
|
539
|
-
` available=${asPercent(this.hostAvailability.share)} free=${asPercent(free)}`
|
|
575
|
+
` available=${asPercent(this.hostAvailability.share)} free=${asPercent(free)}` + late
|
|
540
576
|
);
|
|
541
577
|
}
|
|
542
578
|
|
|
@@ -959,6 +959,9 @@ export function wireOutputs({
|
|
|
959
959
|
inputDemandChanged: (address, windows) => {
|
|
960
960
|
for (const output of parts.outputs.outputsOn(address)) parts.encodeInputs?.retain(output, windows);
|
|
961
961
|
},
|
|
962
|
+
inputsUnplaced: (address, starts) => {
|
|
963
|
+
for (const output of parts.outputs.outputsOn(address)) parts.encodeInputs?.withdrawUnplaced(output, starts);
|
|
964
|
+
},
|
|
962
965
|
admission: parts.admission,
|
|
963
966
|
// What the viewers actually waited for, by band. The ledger is the
|
|
964
967
|
// priority layer's; the encoding is handed a way to ask it.
|
|
@@ -167,7 +167,7 @@ export async function readHeldBytes(torrent, fileIndex, start, end, logger = nul
|
|
|
167
167
|
const missingSaid = new WeakMap();
|
|
168
168
|
|
|
169
169
|
/** Acquire every input piece before copying any of the segment's ranges. */
|
|
170
|
-
export async function readHeldRanges(torrent, fileIndex, ranges, maxBytes, logger = null) {
|
|
170
|
+
export async function readHeldRanges(torrent, fileIndex, ranges, maxBytes, logger = null, onRead = null) {
|
|
171
171
|
const file = torrent?.files?.[fileIndex];
|
|
172
172
|
const pieceLength = Number(torrent?.pieceLength);
|
|
173
173
|
const store = pieceStoreOf(torrent);
|
|
@@ -223,6 +223,7 @@ export async function readHeldRanges(torrent, fileIndex, ranges, maxBytes, logge
|
|
|
223
223
|
});
|
|
224
224
|
});
|
|
225
225
|
if (!buffer || buffer.length !== length) return null;
|
|
226
|
+
onRead?.(length);
|
|
226
227
|
for (const { rangeIndex, absoluteStart, absoluteEnd } of span.ranges) {
|
|
227
228
|
const from = Math.max(absoluteStart, pieceStart);
|
|
228
229
|
const to = Math.min(absoluteEnd, pieceStart + pieceLength - 1);
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import { isUsableTorrentHandle, requireCurrentTorrent } from "./handle-state.js";
|
|
24
24
|
import "./install-webrtc-shim.js";
|
|
25
25
|
import { parentPort, workerData } from "node:worker_threads";
|
|
26
|
+
import { monitorEventLoopDelay, performance } from "node:perf_hooks";
|
|
26
27
|
import { Readable } from "node:stream";
|
|
27
28
|
import { heldFileBytes } from "./held-file-bytes.js";
|
|
28
29
|
import { createSendStream } from "./channel.js";
|
|
@@ -108,6 +109,18 @@ const openReads = new Map();
|
|
|
108
109
|
*/
|
|
109
110
|
let servedBytes = 0;
|
|
110
111
|
|
|
112
|
+
/**
|
|
113
|
+
* How long reads for readers took inside this thread, from the command's
|
|
114
|
+
* arrival to its answer, and how many there were; and how late this thread's
|
|
115
|
+
* event loop ran. A copy for an encoder took 16.3 s for 126.5 MB as one read
|
|
116
|
+
* and 33.1 s for 83.9 MB as one read per piece on the field's AVI, and nothing
|
|
117
|
+
* said whether the time was spent here or on the way (torrent-tv/meta#178).
|
|
118
|
+
*/
|
|
119
|
+
let readerMs = 0;
|
|
120
|
+
let readerReads = 0;
|
|
121
|
+
const loopDelay = monitorEventLoopDelay({ resolution: 20 });
|
|
122
|
+
loopDelay.enable();
|
|
123
|
+
|
|
111
124
|
/**
|
|
112
125
|
* Shorthand for this file. The same path as `logger.info` anywhere else in the
|
|
113
126
|
* thread — kept only because it reads better at the hundred call sites here.
|
|
@@ -432,7 +445,12 @@ async function runCommand(command, params, id) {
|
|
|
432
445
|
// bytes, so the price of a downloaded byte is taken over one interval
|
|
433
446
|
// whether or not anything encodes (torrent-tv/meta#178).
|
|
434
447
|
const usage = process.threadCpuUsage();
|
|
435
|
-
|
|
448
|
+
// The loop's lateness over the interval since the last reading, in ms.
|
|
449
|
+
const loopP99Ms = loopDelay.count > 0 ? loopDelay.percentile(99) / 1e6 : null;
|
|
450
|
+
const loopMaxMs = loopDelay.count > 0 ? loopDelay.max / 1e6 : null;
|
|
451
|
+
loopDelay.reset();
|
|
452
|
+
return { downloaded, uploaded, served: servedBytes, threadCpuSeconds: (usage.user + usage.system) / 1e6,
|
|
453
|
+
readerMs, readerReads, loopP99Ms, loopMaxMs };
|
|
436
454
|
}
|
|
437
455
|
|
|
438
456
|
|
|
@@ -528,8 +546,11 @@ async function runCommand(command, params, id) {
|
|
|
528
546
|
case Command.READ_HELD: {
|
|
529
547
|
const known = resolvedTorrents.get(params.sourceKey);
|
|
530
548
|
const torrent = isUsableTorrentHandle(known) ? known : null;
|
|
549
|
+
const startedAt = performance.now();
|
|
531
550
|
const bytes = await readWhileInUse(openReads, id, torrent, params.fileIndex,
|
|
532
551
|
() => readHeldBytes(torrent, params.fileIndex, params.start, params.end, logger));
|
|
552
|
+
readerMs += performance.now() - startedAt;
|
|
553
|
+
readerReads += 1;
|
|
533
554
|
servedBytes += bytes?.length ?? 0;
|
|
534
555
|
return { bytes };
|
|
535
556
|
}
|
|
@@ -537,9 +558,13 @@ async function runCommand(command, params, id) {
|
|
|
537
558
|
case Command.READ_HELD_RANGES: {
|
|
538
559
|
const known = resolvedTorrents.get(params.sourceKey);
|
|
539
560
|
const torrent = isUsableTorrentHandle(known) ? known : null;
|
|
561
|
+
const startedAt = performance.now();
|
|
562
|
+
// Counted as each piece is read, so a long copy lands in the intervals
|
|
563
|
+
// it was made in rather than in the one it ended in.
|
|
540
564
|
const buffers = await readWhileInUse(openReads, id, torrent, params.fileIndex,
|
|
541
|
-
() => readHeldRanges(torrent, params.fileIndex, params.ranges, params.maxBytes, logger));
|
|
542
|
-
|
|
565
|
+
() => readHeldRanges(torrent, params.fileIndex, params.ranges, params.maxBytes, logger, (length) => { servedBytes += length; }));
|
|
566
|
+
readerMs += performance.now() - startedAt;
|
|
567
|
+
readerReads += 1;
|
|
543
568
|
return { buffers };
|
|
544
569
|
}
|
|
545
570
|
|