@torrent-tv/proxy 2.64.3 → 2.64.5

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.
@@ -59,11 +59,7 @@ export class TorrentWorkerClient {
59
59
  *
60
60
  * @type {Map<number, number>}
61
61
  */
62
- #lastPieceByRead = new Map();
63
- /** Each torrent's piece pool, so a fragment can be read where it lies. */
64
- #poolBySource = new Map();
65
- /** Which pool an in-flight read belongs to, keyed by request id. */
66
- #poolByRead = new Map();
62
+ #lastPieceByRead = new Map();
67
63
  /** Reads consuming fragments in place, keyed by request id. */
68
64
  #fragmentReaders = new Map();
69
65
 
@@ -96,7 +92,6 @@ export class TorrentWorkerClient {
96
92
  if (message?.type === Event.ERROR && this.#fragmentReaders.has(message.id)) {
97
93
  const reader = this.#fragmentReaders.get(message.id);
98
94
  this.#fragmentReaders.delete(message.id);
99
- this.#poolByRead.delete(message.id);
100
95
  reader.fail(new Error(message.error ?? "Torrent worker read failed."));
101
96
  return;
102
97
  }
@@ -105,28 +100,17 @@ export class TorrentWorkerClient {
105
100
  }
106
101
  switch (message?.type) {
107
102
  case Event.FRAGMENT: {
108
- const pool = this.#poolByRead.get(message.id);
109
- if (!pool) {
110
- // No pool means no way to read the fragment; confirm it so the
103
+ const buffer = message.buffer;
104
+ if (!buffer) {
105
+ // No buffer means no way to read the fragment; confirm it so the
111
106
  // worker is not left waiting, and let the read end short.
112
107
  this.#worker.postMessage({ type: Event.FRAGMENT_DONE, id: message.id });
113
108
  break;
114
109
  }
115
- // The pool is a growable SharedArrayBuffer shared with the worker
116
- // thread, and an offset is only meaningful against the buffer of the
117
- // store that produced it. When the two disagree — a second store
118
- // opened for the same torrent, a slot handed out before the buffer
119
- // grew — this threw `RangeError: Invalid typed array length`, which
120
- // the process-wide handler swallowed. Reads then stopped answering
121
- // for good: field 2026-08-09, one segment was held for a minute
122
- // eight times running while the audio decoder was fed cut-up frames
123
- // and reported them as a broken AC-3 stream. A read that cannot be
124
- // satisfied must end short and say so, not take the whole source
125
- // down silently.
126
- if (message.offset + message.length > pool.byteLength) {
110
+ if (message.offset + message.length > buffer.byteLength) {
127
111
  logger.warn(
128
112
  `torrent-worker: fragment ${message.offset}+${message.length} lies outside ` +
129
- `its pool of ${pool.byteLength}B (read ${message.id}) — ending the read short`
113
+ `its buffer of ${buffer.byteLength}B (read ${message.id}) — ending the read short`
130
114
  );
131
115
  this.#worker.postMessage({ type: Event.FRAGMENT_DONE, id: message.id });
132
116
  break;
@@ -150,7 +134,7 @@ export class TorrentWorkerClient {
150
134
  );
151
135
  }
152
136
  this.#lastPieceByRead.set(message.id, message.pieceIndex);
153
- const view = new Uint8Array(pool, message.offset, message.length);
137
+ const view = new Uint8Array(buffer, message.offset, message.length);
154
138
 
155
139
  const reader = this.#fragmentReaders.get(message.id);
156
140
  if (reader) {
@@ -183,7 +167,6 @@ export class TorrentWorkerClient {
183
167
  this.#reads.delete(message.id);
184
168
  this.#fragmentReaders.get(message.id)?.close();
185
169
  this.#fragmentReaders.delete(message.id);
186
- this.#poolByRead.delete(message.id);
187
170
  break;
188
171
  case Event.LOG:
189
172
  logger.info(`torrent-worker: ${message.message}`);
@@ -373,17 +356,10 @@ export class TorrentWorkerClient {
373
356
  onCancel: () => {
374
357
  void this.#caller.call(Command.CANCEL_READ, { readId }).catch(() => undefined);
375
358
  this.#reads.delete(readId);
376
- this.#poolByRead.delete(readId);
377
359
  this.#lastPieceByRead.delete(readId);
378
360
  }
379
361
  });
380
362
  this.#reads.set(readId, receive);
381
- // Which pool this read's fragments will point into. Recorded before the
382
- // command is sent, because the first fragment can arrive immediately.
383
- const pool = this.#poolBySource.get(sourceKey);
384
- if (pool) {
385
- this.#poolByRead.set(readId, pool);
386
- }
387
363
 
388
364
  // The worker replies to READ_RANGE only once the body is fully sent; a
389
365
  // failure before that must surface on the stream, not vanish.
@@ -414,11 +390,6 @@ export class TorrentWorkerClient {
414
390
  * @returns {{ [Symbol.asyncIterator]: () => AsyncGenerator<{ bytes: Uint8Array, release: () => void }>, cancel: () => void } | null}
415
391
  */
416
392
  createFragmentReader({ sourceKey, fileIndex, start = null, end = null, windowBytes }) {
417
- const pool = this.#poolBySource.get(sourceKey);
418
- if (!pool) {
419
- return null;
420
- }
421
-
422
393
  const readId = this.#caller.nextId();
423
394
  /** @type {{ bytes: Uint8Array, release: () => void }[]} */
424
395
  const queue = [];
@@ -447,11 +418,9 @@ export class TorrentWorkerClient {
447
418
  notify();
448
419
  }
449
420
  });
450
- this.#poolByRead.set(readId, pool);
451
421
 
452
422
  const cancel = () => {
453
423
  if (this.#fragmentReaders.delete(readId)) {
454
- this.#poolByRead.delete(readId);
455
424
  void this.#caller.call(Command.CANCEL_READ, { readId }).catch(() => undefined);
456
425
  }
457
426
  finished = true;
@@ -513,11 +482,6 @@ export class TorrentWorkerClient {
513
482
  */
514
483
  async getTorrent({ sourceKey, sourceType, source }) {
515
484
  const info = await this.addSource({ sourceKey, sourceType, source });
516
- // The torrent's piece pool. Both threads now hold the same memory, so a
517
- // read can be answered with an offset instead of with bytes.
518
- if (info.sharedBuffer) {
519
- this.#poolBySource.set(sourceKey, info.sharedBuffer);
520
- }
521
485
  const client = this;
522
486
  return {
523
487
  infoHash: info.infoHash,
@@ -1082,6 +1082,7 @@ export async function* readFragments({
1082
1082
  deliveredBytes += toWithinPiece - fromWithinPiece + 1;
1083
1083
  yield {
1084
1084
  pieceIndex,
1085
+ buffer: located.buffer,
1085
1086
  offset: located.offset + fromWithinPiece,
1086
1087
  length: toWithinPiece - fromWithinPiece + 1,
1087
1088
  release() {
@@ -37,7 +37,7 @@ import { startMemoryReport } from "../memory-report.js";
37
37
  // the hook above had a chance to register. Verified the hard way: with a static
38
38
  // import the process still aborted, and the stack named the genuine polyfill.
39
39
  const { TorrentPool, resolveDhtBootstrap } = await import("../torrent-pool.js");
40
- const { collectStoreStats, findSharedStore, reviseStoreBudgets } = await import("../piece-store/shared-piece-store.js");
40
+ const { collectStoreStats, reviseStoreBudgets } = await import("../piece-store/shared-piece-store.js");
41
41
 
42
42
  // Resolved before the client exists, because the client builds its DHT in its
43
43
  // own constructor and the addresses have to be in hand by then. Awaiting here
@@ -176,6 +176,7 @@ function sendFragment(id, fragment) {
176
176
  type: Event.FRAGMENT,
177
177
  id,
178
178
  pieceIndex: fragment.pieceIndex,
179
+ buffer: fragment.buffer,
179
180
  offset: fragment.offset,
180
181
  length: fragment.length
181
182
  });
@@ -304,10 +305,6 @@ async function runCommand(command, params, id) {
304
305
  return {
305
306
  infoHash: torrent.infoHash,
306
307
  name: torrent.name,
307
- // The piece pool itself. A `SharedArrayBuffer` crosses as a reference to
308
- // the same memory rather than a copy, which is what lets the main thread
309
- // read a piece where it already lies instead of being sent its bytes.
310
- sharedBuffer: findSharedStore(torrent)?.sharedBuffer ?? null,
311
308
  // Files cross as plain data; the objects stay here.
312
309
  files: (torrent.files ?? []).map((file, index) => ({
313
310
  index,
@@ -502,21 +499,26 @@ const lastReported = new Map();
502
499
 
503
500
  setInterval(() => {
504
501
  // What the machine can spare NOW, not what it could spare when each store was
505
- // created. Lowering a ceiling frees nothing already committed the pool
506
- // cannot shrink but it stops growth into memory the host no longer has, and
507
- // those pieces go to disk instead (roadmap item 2).
502
+ // created. With per-piece buffers a lowered ceiling is honoured immediately:
503
+ // excess pieces are evicted to disk and their memory is reclaimable.
508
504
  for (const revised of reviseStoreBudgets()) {
509
- if (revised.committedBytes > revised.ceilingBytes) {
505
+ if (revised.evicted > 0) {
506
+ log(
507
+ `piece-store "${revised.name.slice(0, 40)}": allowance is now ` +
508
+ `${Math.round(revised.ceilingBytes / 1048576)}MB, evicted ${revised.evicted} piece(s) to meet it — ` +
509
+ `now ${Math.round(revised.committedBytes / 1048576)}MB committed`
510
+ );
511
+ } else if (revised.committedBytes > revised.ceilingBytes) {
510
512
  log(
511
513
  `piece-store "${revised.name.slice(0, 40)}": allowance is now ` +
512
514
  `${Math.round(revised.ceilingBytes / 1048576)}MB and ` +
513
- `${Math.round(revised.committedBytes / 1048576)}MB is already committed — ` +
514
- `it will not grow further, and cannot give back what it holds`
515
+ `${Math.round(revised.committedBytes / 1048576)}MB is committed — ` +
516
+ `all resident pieces are pinned, cannot shrink yet`
515
517
  );
516
518
  }
517
519
  }
518
520
  for (const stats of collectStoreStats()) {
519
- const signature = `${stats.fromMemory}/${stats.fromDisk}/${stats.spills}/${stats.revivals}/${stats.blockedByPins}`;
521
+ const signature = `${stats.fromMemory}/${stats.fromDisk}/${stats.spills}/${stats.revivals}/${stats.blockedByPins}/${stats.evictedOnRevise}`;
520
522
  if (lastReported.get(stats.name) === signature) {
521
523
  continue;
522
524
  }
@@ -525,21 +527,15 @@ setInterval(() => {
525
527
  const reads = stats.fromMemory + stats.fromDisk;
526
528
  const fromMemoryShare = reads > 0 ? ((stats.fromMemory / reads) * 100).toFixed(1) : "—";
527
529
  log(
528
- // In BYTES as well as in pieces. The count alone says nothing without the
529
- // piece size, and the piece size differs per torrent: on the film the
530
- // proxy was killed under, 2026-08-28, "63" meant 504 MB.
531
530
  `piece-store "${stats.name.slice(0, 40)}": resident=${stats.resident}/${stats.capacity} ` +
532
531
  `(${Math.round((stats.residentBytes || 0) / 1048576)}MB of ` +
533
532
  `${Math.round((stats.budgetBytes || 0) / 1048576)}MB allowed) ` +
534
- // What the machine has actually parted with, which is not what is held:
535
- // the pool only grows, so a spilled piece frees its slot and no memory.
536
- // Reporting the first without the second is what hid 650 MB on
537
- // 2026-08-28 (roadmap item 2).
538
533
  `committed=${Math.round((stats.committedBytes || 0) / 1048576)}MB ` +
539
534
  `on-disk=${Math.round((stats.spilledBytes || 0) / 1048576)}MB ` +
540
535
  `pinned=${stats.pinned} spilled=${stats.spilled} reads=${reads} (${fromMemoryShare}% from memory) ` +
541
536
  `spills=${stats.spills} revivals=${stats.revivals}` +
542
- (stats.blockedByPins > 0 ? ` blocked-by-pins=${stats.blockedByPins}` : "")
537
+ (stats.blockedByPins > 0 ? ` blocked-by-pins=${stats.blockedByPins}` : "") +
538
+ (stats.evictedOnRevise > 0 ? ` evictedOnRevise=${stats.evictedOnRevise}` : "")
543
539
  );
544
540
  }
545
541
  }, STORE_REPORT_INTERVAL_MS).unref();
@@ -63,7 +63,6 @@ async function fakeTorrent({ fileOffset, fileLength, totalLength }) {
63
63
  async function readRange(torrent, start, end) {
64
64
  const collected = [];
65
65
  const positions = [];
66
- const pool = Buffer.from(torrent.store.sharedBuffer);
67
66
  for await (const fragment of readFragments({
68
67
  torrent,
69
68
  fileIndex: 0,
@@ -71,7 +70,8 @@ async function readRange(torrent, start, end) {
71
70
  end,
72
71
  cancellation: { isCancelled: () => false }
73
72
  })) {
74
- collected.push(Buffer.from(pool.subarray(fragment.offset, fragment.offset + fragment.length)));
73
+ const source = fragment.buffer ? Buffer.from(fragment.buffer, fragment.offset, fragment.length) : Buffer.alloc(0);
74
+ collected.push(Buffer.from(source));
75
75
  positions.push({ piece: fragment.pieceIndex, length: fragment.length });
76
76
  fragment.release();
77
77
  }
@@ -133,7 +133,7 @@ test("a pinned piece is not evicted to make room", async () => {
133
133
 
134
134
  const located = store.locate(0);
135
135
  assert.ok(located, "the pinned piece was evicted while held");
136
- const view = Buffer.from(store.sharedBuffer, located.offset, located.length);
136
+ const view = Buffer.from(located.buffer, located.offset, located.length);
137
137
  assert.deepEqual(view, piece(0), "the pinned piece was overwritten in place");
138
138
 
139
139
  store.unpin(0);
@@ -175,7 +175,7 @@ test("a piece revived from disk is readable by offset again", async () => {
175
175
  await get(store, 0); // brings it back
176
176
  const located = store.locate(0);
177
177
  assert.ok(located, "piece 0 was not brought back into memory");
178
- const view = Buffer.from(store.sharedBuffer, located.offset, located.length);
178
+ const view = Buffer.from(located.buffer, located.offset, located.length);
179
179
  assert.deepEqual(view, piece(0));
180
180
  } finally {
181
181
  await new Promise((resolve) => store.destroy(resolve));
@@ -215,25 +215,19 @@ test("takes memory as it needs it, not the whole budget up front", async () => {
215
215
  const { store, directory } = await makeStore({ pieces: 16, totalPieces: 64 });
216
216
  try {
217
217
  assert.equal(store.capacity, 16);
218
- const initial = store.sharedBuffer.byteLength;
219
- assert.ok(initial <= CHUNK * 2, `claimed ${initial} bytes before holding anything`);
218
+ const initial = store.stats().committedBytes;
219
+ assert.ok(initial === 0, `claimed ${initial} bytes before holding anything`);
220
220
 
221
221
  for (let index = 0; index < 5; index += 1) {
222
222
  await put(store, index, piece(index));
223
223
  }
224
224
 
225
225
  assert.equal(store.residentCount, 5);
226
- assert.ok(
227
- store.sharedBuffer.byteLength >= CHUNK * 5,
228
- "the pool did not grow to hold what was put in it"
229
- );
230
- assert.ok(
231
- store.sharedBuffer.byteLength <= CHUNK * 6,
232
- "the pool grew past what was actually needed"
233
- );
226
+ assert.equal(store.stats().committedBytes, CHUNK * 5, "committed should equal resident");
227
+ assert.equal(store.stats().residentBytes, CHUNK * 5);
234
228
 
235
- // Growing must not move bytes already written.
236
- assert.deepEqual(await get(store, 0), piece(0), "an early piece was disturbed by growth");
229
+ // Per-piece buffers must not disturb early pieces.
230
+ assert.deepEqual(await get(store, 0), piece(0), "an early piece was disturbed");
237
231
  } finally {
238
232
  await new Promise((resolve) => store.destroy(resolve));
239
233
  await fs.rm(directory, { recursive: true, force: true });