@torrent-tv/proxy 2.61.0 → 2.63.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/CHANGELOG.md +12 -0
- package/bin/cli.js +520 -512
- package/package.json +1 -1
- package/services/delivery-probe.js +532 -480
- package/services/hls-session-manager.js +83 -9
- package/services/memory-report.js +120 -15
- package/services/piece-store/shared-piece-store.js +866 -791
- package/services/torrent-worker/worker.js +738 -706
- package/test/delivery-probe.test.js +213 -158
- package/test/memory-budget.test.js +89 -2
|
@@ -400,6 +400,10 @@ const READ_WINDOW_SECONDS = 30;
|
|
|
400
400
|
const READ_WINDOW_MIN_BYTES = 16 * 1024 * 1024;
|
|
401
401
|
const READ_WINDOW_MAX_BYTES = 96 * 1024 * 1024;
|
|
402
402
|
const LOOKAHEAD_PAUSE_SECONDS = 120;
|
|
403
|
+
// How often each session says what its cushion is. Half a minute: the link
|
|
404
|
+
// reports that feed it arrive every ten seconds, and a line per session per
|
|
405
|
+
// ten seconds would drown the log on a host serving several.
|
|
406
|
+
const CUSHION_REPORT_MS = 30_000;
|
|
403
407
|
// How old a viewer's link report may be and still describe where they are. It
|
|
404
408
|
// is sent every 10 s, and a seek in between moves them somewhere this cannot
|
|
405
409
|
// predict — so anything older is treated as no report at all.
|
|
@@ -2214,6 +2218,8 @@ export class HlsSessionManager {
|
|
|
2214
2218
|
// buffer from another viewer's read head.
|
|
2215
2219
|
/** @type {Map<string, { linkMbps: number, bufferedAheadSec: number, positionSeconds: number | null, at: number }>} */
|
|
2216
2220
|
netReports: new Map(),
|
|
2221
|
+
// When this session last said what its cushion is (see #sayCushion).
|
|
2222
|
+
cushionSaidAt: 0,
|
|
2217
2223
|
linkSlowSince: 0,
|
|
2218
2224
|
sourceWidth,
|
|
2219
2225
|
sourceHeight,
|
|
@@ -3339,6 +3345,7 @@ export class HlsSessionManager {
|
|
|
3339
3345
|
// reached. Reported on its EDGES, because it is a state and not a stream.
|
|
3340
3346
|
const claimed = Number(session.progress?.processedSeconds);
|
|
3341
3347
|
const encodedTo = this.#segmentStartTime(session, viewerSegment) + aheadSeconds;
|
|
3348
|
+
this.#sayCushion(session, encodedTo);
|
|
3342
3349
|
const disagrees =
|
|
3343
3350
|
Number.isFinite(claimed) && Math.abs(claimed - encodedTo) > LOOKAHEAD_PAUSE_SECONDS;
|
|
3344
3351
|
if (disagrees && !session.lookAheadDisagreementSince) {
|
|
@@ -3377,6 +3384,57 @@ export class HlsSessionManager {
|
|
|
3377
3384
|
}
|
|
3378
3385
|
}
|
|
3379
3386
|
|
|
3387
|
+
/**
|
|
3388
|
+
* What the cushion actually is, said once every half minute per session.
|
|
3389
|
+
*
|
|
3390
|
+
* Three quantities that were never printed together, and could not be
|
|
3391
|
+
* reconstructed afterwards from anything that was:
|
|
3392
|
+
*
|
|
3393
|
+
* - how far the produced range runs ahead of the EARLIEST viewer's picture,
|
|
3394
|
+
* which is the protection an interruption would have to exhaust before
|
|
3395
|
+
* anybody saw it;
|
|
3396
|
+
* - what that costs the person hosting this proxy, in megabytes of film
|
|
3397
|
+
* pulled off the swarm ahead of the picture — the read window sits on top
|
|
3398
|
+
* of it, so this is a floor;
|
|
3399
|
+
* - what the browsers say they are holding, so the depth asked for on that
|
|
3400
|
+
* side can be checked against the depth that arrived.
|
|
3401
|
+
*
|
|
3402
|
+
* Every term is measured: the produced range comes from the segments on disk,
|
|
3403
|
+
* the picture from the viewers' own reports, and the byte rate from the
|
|
3404
|
+
* file's length over its duration. Roadmap item 4.
|
|
3405
|
+
*
|
|
3406
|
+
* @param {HlsSession} session
|
|
3407
|
+
* @param {number} encodedTo - Seconds of film produced, contiguously, from
|
|
3408
|
+
* where the leading viewer is.
|
|
3409
|
+
* @returns {void}
|
|
3410
|
+
*/
|
|
3411
|
+
#sayCushion(session, encodedTo) {
|
|
3412
|
+
const now = Date.now();
|
|
3413
|
+
if (now - (session.cushionSaidAt ?? 0) < CUSHION_REPORT_MS) {
|
|
3414
|
+
return;
|
|
3415
|
+
}
|
|
3416
|
+
const { earliestPosition, deepestBuffer, viewers } = this.#reportedPictureOf(session, now);
|
|
3417
|
+
// Nobody has said where they are, so there is no picture to measure
|
|
3418
|
+
// against and the line would be about nothing.
|
|
3419
|
+
if (earliestPosition === null) {
|
|
3420
|
+
return;
|
|
3421
|
+
}
|
|
3422
|
+
session.cushionSaidAt = now;
|
|
3423
|
+
const aheadOfPicture = Math.max(0, encodedTo - earliestPosition);
|
|
3424
|
+
const fileLength = this.#fileLengthByKey.get(`${session.sourceKey}:${session.fileIndex}`);
|
|
3425
|
+
const duration = Number(session.totalDurationSeconds) || Number(session.durationSeconds) || 0;
|
|
3426
|
+
const megabytes =
|
|
3427
|
+
Number.isFinite(fileLength) && fileLength > 0 && duration > 0
|
|
3428
|
+
? ((aheadOfPicture * fileLength) / duration / 1e6).toFixed(0)
|
|
3429
|
+
: "?";
|
|
3430
|
+
logger.info(
|
|
3431
|
+
`transcode ${session.id.slice(0, 8)} cushion: ${Math.round(aheadOfPicture)}s of film ready ` +
|
|
3432
|
+
`ahead of the picture at ${Math.round(earliestPosition)}s (~${megabytes}MB pulled ahead), ` +
|
|
3433
|
+
`${viewers} viewer(s) holding up to ` +
|
|
3434
|
+
`${deepestBuffer === null ? "?" : deepestBuffer.toFixed(1)}s`
|
|
3435
|
+
);
|
|
3436
|
+
}
|
|
3437
|
+
|
|
3380
3438
|
/**
|
|
3381
3439
|
* Seconds of playback ready without a gap, starting at the segment the viewer
|
|
3382
3440
|
* is on.
|
|
@@ -7714,21 +7772,29 @@ export class HlsSessionManager {
|
|
|
7714
7772
|
* @param {HlsSession} base
|
|
7715
7773
|
* @returns {number}
|
|
7716
7774
|
*/
|
|
7717
|
-
|
|
7718
|
-
|
|
7719
|
-
|
|
7720
|
-
|
|
7721
|
-
|
|
7775
|
+
/**
|
|
7776
|
+
* Where the earliest viewer's picture is, and the deepest cushion any of them
|
|
7777
|
+
* reports holding — both read from the link reports, both null when nobody
|
|
7778
|
+
* has said recently.
|
|
7779
|
+
*
|
|
7780
|
+
* @param {HlsSession} session
|
|
7781
|
+
* @param {number} now
|
|
7782
|
+
* @returns {{ earliestPosition: number | null, deepestBuffer: number | null, viewers: number }}
|
|
7783
|
+
*/
|
|
7784
|
+
#reportedPictureOf(session, now) {
|
|
7785
|
+
let earliestPosition = null;
|
|
7722
7786
|
let deepestBuffer = null;
|
|
7723
|
-
|
|
7787
|
+
let viewers = 0;
|
|
7788
|
+
for (const report of session.netReports.values()) {
|
|
7724
7789
|
if (now - report.at > NET_REPORT_FRESH_MS) {
|
|
7725
7790
|
continue;
|
|
7726
7791
|
}
|
|
7792
|
+
viewers += 1;
|
|
7727
7793
|
if (Number.isFinite(report.positionSeconds)) {
|
|
7728
|
-
|
|
7729
|
-
|
|
7794
|
+
earliestPosition =
|
|
7795
|
+
earliestPosition === null
|
|
7730
7796
|
? report.positionSeconds
|
|
7731
|
-
: Math.min(
|
|
7797
|
+
: Math.min(earliestPosition, report.positionSeconds);
|
|
7732
7798
|
}
|
|
7733
7799
|
if (Number.isFinite(report.bufferedAheadSec)) {
|
|
7734
7800
|
deepestBuffer =
|
|
@@ -7737,6 +7803,14 @@ export class HlsSessionManager {
|
|
|
7737
7803
|
: Math.max(deepestBuffer, report.bufferedAheadSec);
|
|
7738
7804
|
}
|
|
7739
7805
|
}
|
|
7806
|
+
return { earliestPosition, deepestBuffer, viewers };
|
|
7807
|
+
}
|
|
7808
|
+
|
|
7809
|
+
#audioStartSecondsFor(base) {
|
|
7810
|
+
const watching = this.#activeVariant(base);
|
|
7811
|
+
const readHead = this.#viewerPositionOf(watching);
|
|
7812
|
+
const now = Date.now();
|
|
7813
|
+
const { earliestPosition: earliestStated, deepestBuffer } = this.#reportedPictureOf(watching, now);
|
|
7740
7814
|
if (earliestStated !== null) {
|
|
7741
7815
|
// Never ahead of the read head: a position claiming to be past what has
|
|
7742
7816
|
// been asked for is a report that arrived out of order, and acting on it
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* runtime already maintains.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { readFile } from "node:fs/promises";
|
|
22
|
+
import { readFile, statfs } from "node:fs/promises";
|
|
23
23
|
import os from "node:os";
|
|
24
24
|
|
|
25
25
|
/** How often the reading is taken and written. */
|
|
@@ -86,6 +86,55 @@ export async function availableMemory() {
|
|
|
86
86
|
return { bytes: os.freemem(), measured: false };
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Anonymous memory this process holds, from the kernel's own rollup.
|
|
91
|
+
*
|
|
92
|
+
* `process.memoryUsage()` sees what V8 knows about. It cannot see memory the
|
|
93
|
+
* allocator has taken and not returned, and on musl — which is what the addon
|
|
94
|
+
* runs on — a heavy churn of 8 MiB pieces leaves exactly that. The rollup's
|
|
95
|
+
* `Anonymous` counts every mapping backed by nothing but memory, which is
|
|
96
|
+
* where both a live `SharedArrayBuffer` and a freed-but-retained span sit, so
|
|
97
|
+
* the difference between it and what the isolates admit to is the size of what
|
|
98
|
+
* neither can explain (roadmap item 2, step 4).
|
|
99
|
+
*
|
|
100
|
+
* @returns {Promise<number | null>} Bytes, or null off Linux.
|
|
101
|
+
*/
|
|
102
|
+
export async function readAnonymousMemory() {
|
|
103
|
+
try {
|
|
104
|
+
const text = await readFile("/proc/self/smaps_rollup", "utf8");
|
|
105
|
+
const match = /^Anonymous:\s+(\d+)\s+kB$/m.exec(text);
|
|
106
|
+
if (match) {
|
|
107
|
+
return Number(match[1]) * 1024;
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// silent-ok: not Linux, or the kernel is too old for the rollup. The line
|
|
111
|
+
// simply leaves the term out rather than printing a worse one.
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* How much room is left where this proxy spills pieces and writes segments.
|
|
118
|
+
*
|
|
119
|
+
* A budget for memory alone is half a budget: pieces evicted from memory go to
|
|
120
|
+
* disk, so a store that is well behaved about RAM can still fill the card an
|
|
121
|
+
* addon host boots from. Both limits are the machine's, and neither was
|
|
122
|
+
* measured before (roadmap item 2).
|
|
123
|
+
*
|
|
124
|
+
* @param {string} directory
|
|
125
|
+
* @returns {Promise<number | null>} Bytes free, or null where it cannot be read.
|
|
126
|
+
*/
|
|
127
|
+
export async function readDiskFree(directory) {
|
|
128
|
+
try {
|
|
129
|
+
const stats = await statfs(directory);
|
|
130
|
+
return Number(stats.bavail) * Number(stats.bsize);
|
|
131
|
+
} catch {
|
|
132
|
+
// silent-ok: `statfs` is not everywhere, and a missing disk figure must not
|
|
133
|
+
// cost the memory reading beside it.
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
89
138
|
/**
|
|
90
139
|
* Render a size the way a person reads one.
|
|
91
140
|
*
|
|
@@ -104,26 +153,61 @@ function megabytes(bytes) {
|
|
|
104
153
|
* count is meaningless without the piece size, and the piece size differs per
|
|
105
154
|
* torrent — on the film this proxy died under, 63 pieces meant 504 MB.
|
|
106
155
|
*
|
|
156
|
+
* Two scopes, because two things are being asked and only one of them is
|
|
157
|
+
* per-thread. `rss` and the kernel's rollup belong to the PROCESS, so they are
|
|
158
|
+
* read once, on the main thread. The heap, external and arrayBuffer figures
|
|
159
|
+
* belong to an ISOLATE, and the torrent worker's are the ones that matter —
|
|
160
|
+
* the piece pool is a `SharedArrayBuffer` allocated there, which the main
|
|
161
|
+
* isolate cannot see at all. Reporting only the main thread's was half the
|
|
162
|
+
* reason 650 MB of a 893 MB process had no explanation on 2026-08-28.
|
|
163
|
+
*
|
|
107
164
|
* @param {Object} reading
|
|
165
|
+
* @param {"process" | "thread"} [reading.scope]
|
|
166
|
+
* @param {string} [reading.label] - Which thread the isolate figures are of.
|
|
108
167
|
* @param {{ rss: number, heapUsed: number, heapTotal: number, external: number, arrayBuffers: number }} reading.process
|
|
109
|
-
* @param {number} reading.availableBytes
|
|
110
|
-
* @param {boolean} reading.availableMeasured
|
|
111
|
-
* @param {
|
|
168
|
+
* @param {number} [reading.availableBytes]
|
|
169
|
+
* @param {boolean} [reading.availableMeasured]
|
|
170
|
+
* @param {number | null} [reading.anonymousBytes]
|
|
171
|
+
* @param {number | null} [reading.diskFreeBytes]
|
|
172
|
+
* @param {{ name: string, residentBytes: number, committedBytes: number, spilledBytes: number, budgetBytes: number }[]} [reading.stores]
|
|
112
173
|
* @returns {string}
|
|
113
174
|
*/
|
|
114
|
-
export function describeMemory({
|
|
115
|
-
|
|
116
|
-
|
|
175
|
+
export function describeMemory({
|
|
176
|
+
scope = "process",
|
|
177
|
+
label = "",
|
|
178
|
+
process: usage,
|
|
179
|
+
availableBytes,
|
|
180
|
+
availableMeasured,
|
|
181
|
+
anonymousBytes = null,
|
|
182
|
+
diskFreeBytes = null,
|
|
183
|
+
stores = []
|
|
184
|
+
}) {
|
|
185
|
+
const total = (field) => stores.reduce((sum, store) => sum + (store[field] || 0), 0);
|
|
186
|
+
const storeResident = total("residentBytes");
|
|
187
|
+
const storeCommitted = total("committedBytes");
|
|
188
|
+
const storeSpilled = total("spilledBytes");
|
|
189
|
+
const storeBudget = total("budgetBytes");
|
|
190
|
+
// Holding and having taken are different quantities, and the gap between
|
|
191
|
+
// them is the whole of the growth this series exists to find: the pool only
|
|
192
|
+
// grows, so a spilled piece frees a slot and no memory.
|
|
117
193
|
const storesPart = stores.length === 0
|
|
118
194
|
? "no torrent stores"
|
|
119
|
-
: `${stores.length} torrent store(s) holding ${megabytes(storeResident)} ` +
|
|
120
|
-
`of ${megabytes(storeBudget)} allowed
|
|
195
|
+
: `${stores.length} torrent store(s) holding ${megabytes(storeResident)}, ` +
|
|
196
|
+
`committed ${megabytes(storeCommitted)} of ${megabytes(storeBudget)} allowed, ` +
|
|
197
|
+
`${megabytes(storeSpilled)} spilled to disk`;
|
|
198
|
+
const isolate =
|
|
199
|
+
`heap=${megabytes(usage.heapUsed)}/${megabytes(usage.heapTotal)} ` +
|
|
200
|
+
`external=${megabytes(usage.external)} arrayBuffers=${megabytes(usage.arrayBuffers)}`;
|
|
201
|
+
if (scope === "thread") {
|
|
202
|
+
return `memory (${label || "thread"}): ${isolate}; ${storesPart}`;
|
|
203
|
+
}
|
|
121
204
|
return (
|
|
122
|
-
`memory: rss=${megabytes(usage.rss)}
|
|
123
|
-
|
|
205
|
+
`memory: rss=${megabytes(usage.rss)} ${isolate}` +
|
|
206
|
+
`${anonymousBytes === null ? "" : ` anon=${megabytes(anonymousBytes)}`}; ` +
|
|
124
207
|
`${storesPart}; ` +
|
|
125
|
-
`machine has ${megabytes(availableBytes)} available` +
|
|
126
|
-
`${availableMeasured ? "" : " (estimated — /proc/meminfo could not be read)"}`
|
|
208
|
+
`machine has ${megabytes(availableBytes ?? 0)} available` +
|
|
209
|
+
`${availableMeasured ? "" : " (estimated — /proc/meminfo could not be read)"}` +
|
|
210
|
+
`${diskFreeBytes === null ? "" : `, ${megabytes(diskFreeBytes)} free on disk`}`
|
|
127
211
|
);
|
|
128
212
|
}
|
|
129
213
|
|
|
@@ -133,13 +217,25 @@ export function describeMemory({ process: usage, availableBytes, availableMeasur
|
|
|
133
217
|
* @param {Object} options
|
|
134
218
|
* @param {(message: string) => void} options.log
|
|
135
219
|
* @param {() => { name: string, residentBytes: number, budgetBytes: number }[]} [options.readStores]
|
|
220
|
+
* @param {"process" | "thread"} [options.scope] - `thread` leaves out the
|
|
221
|
+
* process-wide figures, so the torrent worker can report its own isolate
|
|
222
|
+
* without reading /proc twice.
|
|
223
|
+
* @param {string} [options.label] - Which thread the isolate figures are of.
|
|
224
|
+
* @param {string} [options.diskPath] - Where pieces spill, for the free-space
|
|
225
|
+
* reading. Omitted, the disk term is left out rather than guessed.
|
|
136
226
|
* @param {number} [options.intervalMs]
|
|
137
227
|
* @returns {{ stop: () => void }}
|
|
138
228
|
*/
|
|
139
|
-
export function startMemoryReport({
|
|
229
|
+
export function startMemoryReport({
|
|
230
|
+
log,
|
|
231
|
+
readStores,
|
|
232
|
+
scope = "process",
|
|
233
|
+
label = "",
|
|
234
|
+
diskPath = "",
|
|
235
|
+
intervalMs = MEMORY_REPORT_INTERVAL_MS
|
|
236
|
+
}) {
|
|
140
237
|
const tick = async () => {
|
|
141
238
|
try {
|
|
142
|
-
const { bytes, measured } = await availableMemory();
|
|
143
239
|
let stores = [];
|
|
144
240
|
try {
|
|
145
241
|
stores = typeof readStores === "function" ? readStores() ?? [] : [];
|
|
@@ -147,10 +243,19 @@ export function startMemoryReport({ log, readStores, intervalMs = MEMORY_REPORT_
|
|
|
147
243
|
// silent-ok: a store list that cannot be read must not stop the reading
|
|
148
244
|
// that matters, which is the process's own.
|
|
149
245
|
}
|
|
246
|
+
if (scope === "thread") {
|
|
247
|
+
log(describeMemory({ scope, label, process: readProcessMemory(), stores }));
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const { bytes, measured } = await availableMemory();
|
|
150
251
|
log(describeMemory({
|
|
252
|
+
scope,
|
|
253
|
+
label,
|
|
151
254
|
process: readProcessMemory(),
|
|
152
255
|
availableBytes: bytes,
|
|
153
256
|
availableMeasured: measured,
|
|
257
|
+
anonymousBytes: await readAnonymousMemory(),
|
|
258
|
+
diskFreeBytes: diskPath ? await readDiskFree(diskPath) : null,
|
|
154
259
|
stores
|
|
155
260
|
}));
|
|
156
261
|
} catch {
|