@torrent-tv/proxy 2.64.8 → 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 -1285
- package/bin/cli.js +533 -520
- package/package.json +1 -1
- 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
|
@@ -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);
|