@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.64.7",
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": {
@@ -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);