@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.
- package/CHANGELOG.md +11 -0
- package/package.json +1 -1
- package/services/hls-session-manager.js +28 -21
- package/services/piece-store/shared-piece-store.js +167 -484
- package/services/torrent-worker/client.js +7 -43
- package/services/torrent-worker/piece-reader.js +1 -0
- package/services/torrent-worker/worker.js +16 -20
- package/test/piece-reader.test.js +2 -2
- package/test/shared-piece-store.test.js +8 -14
|
@@ -59,11 +59,7 @@ export class TorrentWorkerClient {
|
|
|
59
59
|
*
|
|
60
60
|
* @type {Map<number, number>}
|
|
61
61
|
*/
|
|
62
|
-
|
|
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
|
|
109
|
-
if (!
|
|
110
|
-
// No
|
|
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
|
-
|
|
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
|
|
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(
|
|
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,
|
|
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.
|
|
506
|
-
//
|
|
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.
|
|
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
|
|
514
|
-
`
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
219
|
-
assert.ok(initial
|
|
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.
|
|
227
|
-
|
|
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
|
-
//
|
|
236
|
-
assert.deepEqual(await get(store, 0), piece(0), "an early piece was disturbed
|
|
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 });
|