@torrent-tv/proxy 2.64.7 → 2.64.9
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 +1290 -1281
- package/bin/cli.js +533 -520
- package/package.json +1 -1
- package/services/hls-session-manager.js +87 -0
- package/services/memory-report.js +106 -1
- package/services/piece-store/shared-piece-store.js +47 -2
- package/services/torrent-worker/client.js +33 -0
- package/services/torrent-worker/worker.js +765 -750
- package/test/memory-budget.test.js +72 -1
package/package.json
CHANGED
|
@@ -468,6 +468,10 @@ const BEHIND_HEAD_REPAIR_MS = 400;
|
|
|
468
468
|
// Generous against that measurement, and far short of the hundreds of segments
|
|
469
469
|
// a scan reaches.
|
|
470
470
|
const BEHIND_HEAD_REPAIR_MAX_SEGMENTS = 60;
|
|
471
|
+
// How far the accounting of a backward restart looks for work about to be done
|
|
472
|
+
// twice. It runs on the restart path and a session an hour in has thousands of
|
|
473
|
+
// segments; the figure is for a comparison, not an inventory.
|
|
474
|
+
const BACKWARD_RESTART_SCAN_SEGMENTS = 300;
|
|
471
475
|
// Hard cap on the total settle wait, measured from the first request of a
|
|
472
476
|
// burst, so a still-moving scrubber cannot delay a genuine seek forever.
|
|
473
477
|
const SEEK_SETTLE_MAX_MS = 1_000;
|
|
@@ -4699,6 +4703,10 @@ export class HlsSessionManager {
|
|
|
4699
4703
|
// 0.54-1.47 s — does not account for it. Before rebuilding the hottest path
|
|
4700
4704
|
// in the proxy on a guess, make each stage state its own cost.
|
|
4701
4705
|
const restartEnteredAt = Date.now();
|
|
4706
|
+
// Reads where the old run began BEFORE the new one overwrites it, and does
|
|
4707
|
+
// not await: everything below is the restart path, which is measured in
|
|
4708
|
+
// milliseconds and has been worked on twice to keep it that way.
|
|
4709
|
+
this.#accountBackwardRestart(session, startIndex);
|
|
4702
4710
|
// One directory per run. Two runs writing the same segment name at once
|
|
4703
4711
|
// produce a file that is neither, which is the only reason a restart ever
|
|
4704
4712
|
// had to wait for its predecessor to die.
|
|
@@ -9330,6 +9338,85 @@ export class HlsSessionManager {
|
|
|
9330
9338
|
);
|
|
9331
9339
|
}
|
|
9332
9340
|
|
|
9341
|
+
/**
|
|
9342
|
+
* What moving the encoder BACKWARDS costs, said out loud when it happens.
|
|
9343
|
+
*
|
|
9344
|
+
* Nothing already written is lost — every run keeps its own directory and
|
|
9345
|
+
* {@link HlsSessionManager##findProducedFile} serves the union of all of them
|
|
9346
|
+
* — so the price of a restart is not the files. It is two other things, and
|
|
9347
|
+
* neither was ever counted:
|
|
9348
|
+
*
|
|
9349
|
+
* - **work done twice.** The new run begins at the target and encodes
|
|
9350
|
+
* forward through segments the old run had already finished. ffmpeg cannot
|
|
9351
|
+
* know they exist, so it makes them again.
|
|
9352
|
+
* - **the viewer in front.** While the run walks back up to where it already
|
|
9353
|
+
* was, nothing new is being made ahead of them, and their cushion drains.
|
|
9354
|
+
*
|
|
9355
|
+
* Both are what decides whether a session should be allowed a SECOND
|
|
9356
|
+
* concurrent run instead — roadmap item 64. That question cannot be answered
|
|
9357
|
+
* from taste, and this is the reading it needs: how often it happens at all,
|
|
9358
|
+
* how far back, and how much of the walk is a repeat.
|
|
9359
|
+
*
|
|
9360
|
+
* Nothing here is awaited by the caller. Everything below the call site is
|
|
9361
|
+
* the restart path, which is measured in milliseconds and has been worked on
|
|
9362
|
+
* twice to keep it that way; a reading that delays the thing it is reading
|
|
9363
|
+
* about is not a reading. The figures that MUST be taken before the new run
|
|
9364
|
+
* exists are taken synchronously, and only the file counting is left to run
|
|
9365
|
+
* on its own — against the directories that existed at this instant, so what
|
|
9366
|
+
* the new run is about to write cannot be counted as already there.
|
|
9367
|
+
*
|
|
9368
|
+
* @param {HlsSession} session
|
|
9369
|
+
* @param {number} startIndex - Where the new run will begin.
|
|
9370
|
+
* @returns {void}
|
|
9371
|
+
*/
|
|
9372
|
+
#accountBackwardRestart(session, startIndex) {
|
|
9373
|
+
const previousStart = session.encodeStartIndex;
|
|
9374
|
+
if (!Number.isInteger(previousStart) || !Number.isInteger(startIndex) || startIndex >= previousStart) {
|
|
9375
|
+
// A first run, or one moving forward. Neither costs anything here: a
|
|
9376
|
+
// forward restart skips material it never made.
|
|
9377
|
+
return;
|
|
9378
|
+
}
|
|
9379
|
+
const processed = Number(session.progress?.processedSeconds);
|
|
9380
|
+
const head = Number.isFinite(processed)
|
|
9381
|
+
? Math.max(previousStart, this.#segmentIndexForTime(session, processed))
|
|
9382
|
+
: previousStart;
|
|
9383
|
+
// Bounded: a session an hour in has thousands of segments, and the count is
|
|
9384
|
+
// for a comparison, not an inventory.
|
|
9385
|
+
const last = Math.min(head, startIndex + BACKWARD_RESTART_SCAN_SEGMENTS);
|
|
9386
|
+
const dirsBefore = this.#runDirs(session);
|
|
9387
|
+
const accounting = session.backwardRestarts ?? { count: 0, segmentsBack: 0, worstBack: 0, remade: 0 };
|
|
9388
|
+
accounting.count += 1;
|
|
9389
|
+
accounting.segmentsBack += previousStart - startIndex;
|
|
9390
|
+
accounting.worstBack = Math.max(accounting.worstBack, previousStart - startIndex);
|
|
9391
|
+
session.backwardRestarts = accounting;
|
|
9392
|
+
|
|
9393
|
+
void (async () => {
|
|
9394
|
+
let alreadyOnDisk = 0;
|
|
9395
|
+
for (let index = startIndex; index <= last; index += 1) {
|
|
9396
|
+
const fileName = session.segmentFormat.segmentFileName(index);
|
|
9397
|
+
for (const dir of dirsBefore) {
|
|
9398
|
+
try {
|
|
9399
|
+
await access(path.join(dir, fileName));
|
|
9400
|
+
alreadyOnDisk += 1;
|
|
9401
|
+
break;
|
|
9402
|
+
} catch {
|
|
9403
|
+
// Not this run's; try an older one.
|
|
9404
|
+
}
|
|
9405
|
+
}
|
|
9406
|
+
}
|
|
9407
|
+
accounting.remade += alreadyOnDisk;
|
|
9408
|
+
logger.info(
|
|
9409
|
+
`transcode ${session.id} moving the encoder BACK from #${previousStart} to #${startIndex} ` +
|
|
9410
|
+
`(head was #${head}): ${alreadyOnDisk} of the ${last - startIndex + 1} segment(s) it will walk through ` +
|
|
9411
|
+
`are already on disk and will be made again, and nothing is produced ahead of #${head} until it gets ` +
|
|
9412
|
+
`back there — ${accounting.count} backward restart(s) this session, worst ${accounting.worstBack} ` +
|
|
9413
|
+
`segment(s) back, ${accounting.remade} segment(s) remade in total (roadmap 64)`
|
|
9414
|
+
);
|
|
9415
|
+
})().catch(() => {
|
|
9416
|
+
// silent-ok: a reading that fails is not worth ending a restart over.
|
|
9417
|
+
});
|
|
9418
|
+
}
|
|
9419
|
+
|
|
9333
9420
|
/**
|
|
9334
9421
|
* The directories runs have written into, newest first.
|
|
9335
9422
|
*
|
|
@@ -101,6 +101,98 @@ export async function readAvailableMemory() {
|
|
|
101
101
|
return null;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Anonymous memory grouped by the SHAPE of the mappings holding it.
|
|
106
|
+
*
|
|
107
|
+
* The rollup says how much there is; this says what it looks like, and the
|
|
108
|
+
* three shapes it can take are three different diagnoses of the same number:
|
|
109
|
+
*
|
|
110
|
+
* - **one growing `[heap]`** — the allocator's break-managed arena. Freed
|
|
111
|
+
* blocks stay in it, and on musl there is no `malloc_trim` to ask for them
|
|
112
|
+
* back. Nothing above the allocator is holding anything.
|
|
113
|
+
* - **many large anonymous mappings** — one per big allocation, which is what
|
|
114
|
+
* a 4 MiB piece buffer is. If their count tracks the pieces the store says
|
|
115
|
+
* it holds, the memory is accounted for; if it keeps climbing while the
|
|
116
|
+
* store's count does not, the buffers are being kept alive by somebody.
|
|
117
|
+
* - **many medium ones** — the allocator's own per-thread arenas, taken and
|
|
118
|
+
* not returned.
|
|
119
|
+
*
|
|
120
|
+
* The field failure of 2026-08-31 is 700 MB that is none of the JavaScript
|
|
121
|
+
* heaps, none of the piece store, and none of ffmpeg. Which of the three
|
|
122
|
+
* shapes it has decides what to change, and no reading so far can tell them
|
|
123
|
+
* apart (roadmap item 2, step 4).
|
|
124
|
+
*
|
|
125
|
+
* @param {string} text - The contents of `/proc/self/smaps`.
|
|
126
|
+
* @returns {{ heapBytes: number, largeBytes: number, largeCount: number,
|
|
127
|
+
* largestBytes: number, smallBytes: number, smallCount: number,
|
|
128
|
+
* fileBytes: number }}
|
|
129
|
+
*/
|
|
130
|
+
export function summariseMappings(text) {
|
|
131
|
+
const summary = {
|
|
132
|
+
heapBytes: 0,
|
|
133
|
+
largeBytes: 0,
|
|
134
|
+
largeCount: 0,
|
|
135
|
+
largestBytes: 0,
|
|
136
|
+
smallBytes: 0,
|
|
137
|
+
smallCount: 0,
|
|
138
|
+
fileBytes: 0
|
|
139
|
+
};
|
|
140
|
+
// A mapping is a header line followed by its fields; only `Rss` is wanted,
|
|
141
|
+
// because a mapping that is reserved and untouched costs no memory.
|
|
142
|
+
let pathName = null;
|
|
143
|
+
for (const line of String(text ?? "").split("\n")) {
|
|
144
|
+
const header = /^[0-9a-f]+-[0-9a-f]+ \S{4} [0-9a-f]+ \S+ \d+\s*(.*)$/.exec(line);
|
|
145
|
+
if (header) {
|
|
146
|
+
pathName = header[1].trim();
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const rss = /^Rss:\s+(\d+)\s+kB$/.exec(line);
|
|
150
|
+
if (!rss || pathName === null) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const bytes = Number(rss[1]) * 1024;
|
|
154
|
+
if (bytes === 0) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (pathName === "[heap]") {
|
|
158
|
+
summary.heapBytes += bytes;
|
|
159
|
+
} else if (pathName !== "" && !pathName.startsWith("[")) {
|
|
160
|
+
// Backed by a file: the executable, the libraries, anything mapped in.
|
|
161
|
+
// Counted so the anonymous figures can be checked against `rss`.
|
|
162
|
+
summary.fileBytes += bytes;
|
|
163
|
+
} else if (bytes >= LARGE_MAPPING_BYTES) {
|
|
164
|
+
summary.largeBytes += bytes;
|
|
165
|
+
summary.largeCount += 1;
|
|
166
|
+
summary.largestBytes = Math.max(summary.largestBytes, bytes);
|
|
167
|
+
} else {
|
|
168
|
+
summary.smallBytes += bytes;
|
|
169
|
+
summary.smallCount += 1;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return summary;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Where "large" begins. Two megabytes, so a 4 MiB piece buffer is always large
|
|
177
|
+
* and an allocator's ordinary arena is not.
|
|
178
|
+
*/
|
|
179
|
+
const LARGE_MAPPING_BYTES = 2 * 1024 * 1024;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The mapping summary for this process, or null where /proc is not there.
|
|
183
|
+
*
|
|
184
|
+
* @returns {Promise<ReturnType<typeof summariseMappings> | null>}
|
|
185
|
+
*/
|
|
186
|
+
export async function readMappingSummary() {
|
|
187
|
+
try {
|
|
188
|
+
return summariseMappings(await readFile("/proc/self/smaps", "utf8"));
|
|
189
|
+
} catch {
|
|
190
|
+
// silent-ok: not Linux, or the kernel does not publish it. The line leaves
|
|
191
|
+
// the term out rather than printing a worse one.
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
104
196
|
/**
|
|
105
197
|
* Available memory, falling back to what the runtime can offer.
|
|
106
198
|
*
|
|
@@ -196,6 +288,7 @@ function megabytes(bytes) {
|
|
|
196
288
|
* @param {number} [reading.availableBytes]
|
|
197
289
|
* @param {boolean} [reading.availableMeasured]
|
|
198
290
|
* @param {number | null} [reading.anonymousBytes]
|
|
291
|
+
* @param {ReturnType<typeof summariseMappings> | null} [reading.mappings]
|
|
199
292
|
* @param {number | null} [reading.diskFreeBytes]
|
|
200
293
|
* @param {{ name: string, residentBytes: number, committedBytes: number, spilledBytes: number, budgetBytes: number }[]} [reading.stores]
|
|
201
294
|
* @returns {string}
|
|
@@ -207,6 +300,7 @@ export function describeMemory({
|
|
|
207
300
|
availableBytes,
|
|
208
301
|
availableMeasured,
|
|
209
302
|
anonymousBytes = null,
|
|
303
|
+
mappings = null,
|
|
210
304
|
diskFreeBytes = null,
|
|
211
305
|
stores = []
|
|
212
306
|
}) {
|
|
@@ -230,9 +324,16 @@ export function describeMemory({
|
|
|
230
324
|
if (scope === "thread") {
|
|
231
325
|
return `memory (${label || "thread"}): ${isolate}; ${storesPart}`;
|
|
232
326
|
}
|
|
327
|
+
const shape = mappings === null
|
|
328
|
+
? ""
|
|
329
|
+
: ` mappings=[heap ${megabytes(mappings.heapBytes)}, ` +
|
|
330
|
+
`${mappings.largeCount} anon ≥2MB = ${megabytes(mappings.largeBytes)} ` +
|
|
331
|
+
`(largest ${megabytes(mappings.largestBytes)}), ` +
|
|
332
|
+
`${mappings.smallCount} anon <2MB = ${megabytes(mappings.smallBytes)}, ` +
|
|
333
|
+
`files ${megabytes(mappings.fileBytes)}]`;
|
|
233
334
|
return (
|
|
234
335
|
`memory: rss=${megabytes(usage.rss)} ${isolate}` +
|
|
235
|
-
`${anonymousBytes === null ? "" : ` anon=${megabytes(anonymousBytes)}`}; ` +
|
|
336
|
+
`${anonymousBytes === null ? "" : ` anon=${megabytes(anonymousBytes)}`}${shape}; ` +
|
|
236
337
|
`${storesPart}; ` +
|
|
237
338
|
`machine has ${megabytes(availableBytes ?? 0)} available` +
|
|
238
339
|
`${availableMeasured ? "" : " (estimated — /proc/meminfo could not be read)"}` +
|
|
@@ -397,6 +498,10 @@ export function startMemoryReport({
|
|
|
397
498
|
availableBytes: bytes,
|
|
398
499
|
availableMeasured: measured,
|
|
399
500
|
anonymousBytes,
|
|
501
|
+
// Read only when the line is written: `smaps` is one entry per
|
|
502
|
+
// mapping and a busy process has thousands, which is a different
|
|
503
|
+
// cost from the rollup's single line.
|
|
504
|
+
mappings: await readMappingSummary(),
|
|
400
505
|
diskFreeBytes,
|
|
401
506
|
stores
|
|
402
507
|
}));
|
|
@@ -107,6 +107,38 @@ function availableMemorySync() {
|
|
|
107
107
|
return os.freemem();
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* How many piece buffers this thread has let go of, and how many the collector
|
|
112
|
+
* has actually taken back.
|
|
113
|
+
*
|
|
114
|
+
* The one reading that separates the two explanations of the 700 MB nobody can
|
|
115
|
+
* account for (roadmap item 2, field 2026-08-31): if the two numbers track each
|
|
116
|
+
* other, this code is holding nothing and whatever grows is below us, in the
|
|
117
|
+
* allocator — on musl there is no `malloc_trim` and no way to ask. If the gap
|
|
118
|
+
* widens, a reference of ours outlives the piece, and then a heap snapshot can
|
|
119
|
+
* name the holder.
|
|
120
|
+
*
|
|
121
|
+
* What it does NOT prove: a `SharedArrayBuffer`'s memory is shared between the
|
|
122
|
+
* isolates, so this thread's handle going means only that THIS thread let go.
|
|
123
|
+
* The main thread counts its own (`torrent-worker/client.js`), and the pair is
|
|
124
|
+
* what answers the question.
|
|
125
|
+
*/
|
|
126
|
+
const released = { count: 0, collected: 0 };
|
|
127
|
+
const collector = typeof FinalizationRegistry === "function"
|
|
128
|
+
? new FinalizationRegistry(() => {
|
|
129
|
+
released.collected += 1;
|
|
130
|
+
})
|
|
131
|
+
: null;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* What the collector has taken back against what was let go.
|
|
135
|
+
*
|
|
136
|
+
* @returns {{ released: number, collected: number }}
|
|
137
|
+
*/
|
|
138
|
+
export function pieceBufferCollection() {
|
|
139
|
+
return { released: released.count, collected: released.collected };
|
|
140
|
+
}
|
|
141
|
+
|
|
110
142
|
const MIN_BUDGET_BYTES = 64 * 1024 * 1024;
|
|
111
143
|
const MIN_RESIDENT_PIECES = 2;
|
|
112
144
|
/**
|
|
@@ -501,7 +533,7 @@ export class SharedPieceStore {
|
|
|
501
533
|
this.#counters.fromMemory += 1;
|
|
502
534
|
return already;
|
|
503
535
|
}
|
|
504
|
-
const target = new SharedArrayBuffer(this.#lengthOf(index));
|
|
536
|
+
const target = this.#watchForCollection(new SharedArrayBuffer(this.#lengthOf(index)));
|
|
505
537
|
await this.#disk.read(index, Buffer.from(target));
|
|
506
538
|
this.#registerPiece(index, target);
|
|
507
539
|
this.#counters.fromDisk += 1;
|
|
@@ -512,6 +544,19 @@ export class SharedPieceStore {
|
|
|
512
544
|
}
|
|
513
545
|
}
|
|
514
546
|
|
|
547
|
+
/**
|
|
548
|
+
* Count this buffer as one this thread will have to let go of, and notice
|
|
549
|
+
* when the collector takes it. See {@link pieceBufferCollection}.
|
|
550
|
+
*
|
|
551
|
+
* @param {SharedArrayBuffer} buffer
|
|
552
|
+
* @returns {SharedArrayBuffer} The same buffer.
|
|
553
|
+
*/
|
|
554
|
+
#watchForCollection(buffer) {
|
|
555
|
+
released.count += 1;
|
|
556
|
+
collector?.register(buffer, null);
|
|
557
|
+
return buffer;
|
|
558
|
+
}
|
|
559
|
+
|
|
515
560
|
/**
|
|
516
561
|
* A fresh buffer holding this piece's bytes.
|
|
517
562
|
*
|
|
@@ -521,7 +566,7 @@ export class SharedPieceStore {
|
|
|
521
566
|
*/
|
|
522
567
|
#copyIntoNewBuffer(index, bytes) {
|
|
523
568
|
const length = this.#lengthOf(index);
|
|
524
|
-
const sab = new SharedArrayBuffer(length);
|
|
569
|
+
const sab = this.#watchForCollection(new SharedArrayBuffer(length));
|
|
525
570
|
const view = Buffer.from(sab);
|
|
526
571
|
if (bytes.copy) {
|
|
527
572
|
bytes.copy(view, 0, 0, length);
|
|
@@ -41,6 +41,37 @@ const WORKER_EXIT_GRACE_MS = 5_000;
|
|
|
41
41
|
* queued behind that work, so reading a finished 10 MB file took 12-23 s where
|
|
42
42
|
* handing it to the channel took 125 ms.
|
|
43
43
|
*/
|
|
44
|
+
/**
|
|
45
|
+
* Piece buffers this thread has been handed, and how many the collector has
|
|
46
|
+
* taken back.
|
|
47
|
+
*
|
|
48
|
+
* A `SharedArrayBuffer`'s memory belongs to neither isolate: it lives until
|
|
49
|
+
* BOTH have let go. So the worker's own count answers only half the question,
|
|
50
|
+
* and this is the other half — 700 MB grew in the field on 2026-08-31 with the
|
|
51
|
+
* store's own memory falling, and the two candidates are "a reference of ours
|
|
52
|
+
* outlives the piece" and "the allocator keeps what we free". These two
|
|
53
|
+
* counters separate them (roadmap item 2).
|
|
54
|
+
*
|
|
55
|
+
* A fragment arrives as a fresh handle onto the same shared memory every time,
|
|
56
|
+
* so what is counted is handles, not pieces. That is the right quantity anyway:
|
|
57
|
+
* one handle retained keeps the whole piece alive.
|
|
58
|
+
*/
|
|
59
|
+
const fragmentBuffers = { seen: 0, collected: 0 };
|
|
60
|
+
const fragmentCollector = typeof FinalizationRegistry === "function"
|
|
61
|
+
? new FinalizationRegistry(() => {
|
|
62
|
+
fragmentBuffers.collected += 1;
|
|
63
|
+
})
|
|
64
|
+
: null;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What the collector has taken back against what this thread was handed.
|
|
68
|
+
*
|
|
69
|
+
* @returns {{ seen: number, collected: number }}
|
|
70
|
+
*/
|
|
71
|
+
export function fragmentBufferCollection() {
|
|
72
|
+
return { seen: fragmentBuffers.seen, collected: fragmentBuffers.collected };
|
|
73
|
+
}
|
|
74
|
+
|
|
44
75
|
export class TorrentWorkerClient {
|
|
45
76
|
#worker;
|
|
46
77
|
/**
|
|
@@ -138,6 +169,8 @@ export class TorrentWorkerClient {
|
|
|
138
169
|
);
|
|
139
170
|
}
|
|
140
171
|
this.#lastPieceByRead.set(message.id, message.pieceIndex);
|
|
172
|
+
fragmentBuffers.seen += 1;
|
|
173
|
+
fragmentCollector?.register(buffer, null);
|
|
141
174
|
const view = new Uint8Array(buffer, message.offset, message.length);
|
|
142
175
|
|
|
143
176
|
const reader = this.#fragmentReaders.get(message.id);
|