@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.64.8",
3
+ "version": "2.64.9",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -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);