@torrent-tv/proxy 2.9.73 → 2.9.75
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 +374 -362
- package/bin/cli.js +8 -0
- package/package.json +5 -2
- package/server.js +229 -228
- package/services/piece-store/disk-tier.js +151 -0
- package/services/piece-store/piece-lru.js +150 -0
- package/services/piece-store/shared-piece-store.js +416 -0
- package/services/torrent-pool.js +18 -2
- package/services/torrent-worker/channel.js +17 -13
- package/services/torrent-worker/client.js +264 -264
- package/services/torrent-worker/install-webrtc-shim.js +32 -0
- package/services/torrent-worker/pool-adapter.js +179 -179
- package/services/torrent-worker/webrtc-shim.js +219 -0
- package/services/torrent-worker/worker.js +49 -2
- package/test/piece-lru.test.js +109 -0
- package/test/shared-piece-store.test.js +302 -0
- package/test/webrtc-shim.test.js +150 -0
- package/test/worker-channel.test.js +120 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The second tier: pieces that no longer fit in memory.
|
|
3
|
+
*
|
|
4
|
+
* One sparse file per torrent, a piece at `index * chunkLength`. Not the
|
|
5
|
+
* torrent's real files — nobody reads those directly; playback goes through
|
|
6
|
+
* `/stream`, and the data is discarded when the torrent goes idle. A single
|
|
7
|
+
* file by piece index keeps the mapping arithmetic instead of bookkeeping, and
|
|
8
|
+
* sparseness means the untouched gaps cost nothing.
|
|
9
|
+
*
|
|
10
|
+
* Reads take a destination buffer rather than returning a fresh one. That is
|
|
11
|
+
* not a style preference — measured on the field host, an 8 MB piece costs
|
|
12
|
+
* 22.08 ms via `readFile`, which allocates, and **7.63 ms** read into a buffer
|
|
13
|
+
* we already hold. Two thirds of the apparent "disk" cost was allocation.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from "node:fs/promises";
|
|
17
|
+
import { constants } from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
|
|
20
|
+
export class DiskTier {
|
|
21
|
+
#filePath;
|
|
22
|
+
#chunkLength;
|
|
23
|
+
/** @type {import("node:fs/promises").FileHandle | null} */
|
|
24
|
+
#handle = null;
|
|
25
|
+
/** Piece indices known to be on disk. */
|
|
26
|
+
#stored = new Set();
|
|
27
|
+
/** @type {Promise<void> | null} */
|
|
28
|
+
#opening = null;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {object} params
|
|
32
|
+
* @param {string} params.directory - Where the backing file lives.
|
|
33
|
+
* @param {string} params.name - File name, unique per torrent.
|
|
34
|
+
* @param {number} params.chunkLength - Piece size; fixes the stride on disk.
|
|
35
|
+
*/
|
|
36
|
+
constructor({ directory, name, chunkLength }) {
|
|
37
|
+
this.#filePath = path.join(directory, name);
|
|
38
|
+
this.#chunkLength = chunkLength;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Where the backing file lives, for logging and cleanup. */
|
|
42
|
+
get filePath() {
|
|
43
|
+
return this.#filePath;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** How many pieces are currently held on disk. */
|
|
47
|
+
get size() {
|
|
48
|
+
return this.#stored.size;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Open the backing file, creating it and its directory if needed.
|
|
53
|
+
*
|
|
54
|
+
* Concurrent callers share one open — several evictions can start at once.
|
|
55
|
+
*
|
|
56
|
+
* @returns {Promise<import("node:fs/promises").FileHandle>}
|
|
57
|
+
*/
|
|
58
|
+
async #open() {
|
|
59
|
+
if (this.#handle) {
|
|
60
|
+
return this.#handle;
|
|
61
|
+
}
|
|
62
|
+
if (!this.#opening) {
|
|
63
|
+
this.#opening = (async () => {
|
|
64
|
+
await fs.mkdir(path.dirname(this.#filePath), { recursive: true });
|
|
65
|
+
// Read/write, created if absent — NOT append mode. Under `a+` POSIX
|
|
66
|
+
// ignores the position on every write and puts the bytes at the end of
|
|
67
|
+
// the file, so pieces would pile up in arrival order and each read
|
|
68
|
+
// would return whichever piece happened to land at that offset.
|
|
69
|
+
this.#handle = await fs.open(this.#filePath, constants.O_RDWR | constants.O_CREAT);
|
|
70
|
+
})();
|
|
71
|
+
}
|
|
72
|
+
await this.#opening;
|
|
73
|
+
if (!this.#handle) {
|
|
74
|
+
throw new Error(`Could not open the piece file at ${this.#filePath}.`);
|
|
75
|
+
}
|
|
76
|
+
return this.#handle;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {number} index
|
|
81
|
+
* @returns {boolean}
|
|
82
|
+
*/
|
|
83
|
+
has(index) {
|
|
84
|
+
return this.#stored.has(index);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Write a piece out.
|
|
89
|
+
*
|
|
90
|
+
* @param {number} index
|
|
91
|
+
* @param {Uint8Array} bytes
|
|
92
|
+
* @returns {Promise<void>}
|
|
93
|
+
*/
|
|
94
|
+
async write(index, bytes) {
|
|
95
|
+
const handle = await this.#open();
|
|
96
|
+
await handle.write(bytes, 0, bytes.length, index * this.#chunkLength);
|
|
97
|
+
this.#stored.add(index);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Read a piece back into a buffer the caller already owns.
|
|
102
|
+
*
|
|
103
|
+
* @param {number} index
|
|
104
|
+
* @param {Uint8Array} target - Destination; its length is what gets read.
|
|
105
|
+
* @returns {Promise<number>} Bytes read.
|
|
106
|
+
*/
|
|
107
|
+
async read(index, target) {
|
|
108
|
+
if (!this.#stored.has(index)) {
|
|
109
|
+
throw new Error(`Piece ${index} is not on disk.`);
|
|
110
|
+
}
|
|
111
|
+
const handle = await this.#open();
|
|
112
|
+
const { bytesRead } = await handle.read(target, 0, target.length, index * this.#chunkLength);
|
|
113
|
+
return bytesRead;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Forget a piece. The bytes stay on disk until the file is removed — there is
|
|
118
|
+
* nothing to gain from punching them out, since the file is discarded whole.
|
|
119
|
+
*
|
|
120
|
+
* @param {number} index
|
|
121
|
+
* @returns {void}
|
|
122
|
+
*/
|
|
123
|
+
forget(index) {
|
|
124
|
+
this.#stored.delete(index);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Close the file, leaving its contents in place.
|
|
129
|
+
*
|
|
130
|
+
* @returns {Promise<void>}
|
|
131
|
+
*/
|
|
132
|
+
async close() {
|
|
133
|
+
const handle = this.#handle;
|
|
134
|
+
this.#handle = null;
|
|
135
|
+
this.#opening = null;
|
|
136
|
+
if (handle) {
|
|
137
|
+
await handle.close();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Close and delete the backing file.
|
|
143
|
+
*
|
|
144
|
+
* @returns {Promise<void>}
|
|
145
|
+
*/
|
|
146
|
+
async destroy() {
|
|
147
|
+
await this.close();
|
|
148
|
+
this.#stored.clear();
|
|
149
|
+
await fs.rm(this.#filePath, { force: true });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Which piece leaves memory next — and which one may not.
|
|
3
|
+
*
|
|
4
|
+
* Two responsibilities, deliberately kept apart from any storage:
|
|
5
|
+
*
|
|
6
|
+
* - **recency**, so the piece evicted is the one least likely to be wanted;
|
|
7
|
+
* - **pinning**, so a piece being read cannot be evicted at all.
|
|
8
|
+
*
|
|
9
|
+
* The second is not a refinement of the first. webtor's seeder relies on recency
|
|
10
|
+
* alone and guards the actual eviction with a read/write lock per piece
|
|
11
|
+
* (`mmap.go`, 1024 shards) — because ordering says a piece is *unlikely* to be
|
|
12
|
+
* in use, never that it is not. We have already paid for that difference once:
|
|
13
|
+
* proxy 2.9.71 removed a torrent, and its data, out from under an active reader,
|
|
14
|
+
* after which every read hung and ffmpeg saw an empty input. Here the guarantee
|
|
15
|
+
* is explicit: a pinned piece is never returned as an eviction candidate, and
|
|
16
|
+
* pins nest, because a piece can be read by several sessions at once.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Recency and pin bookkeeping for one torrent's pieces.
|
|
21
|
+
*
|
|
22
|
+
* Holds no data — it answers "what may go" and nothing else, which is what
|
|
23
|
+
* makes it testable without a torrent, a disk or a thread.
|
|
24
|
+
*/
|
|
25
|
+
export class PieceLru {
|
|
26
|
+
/** Insertion-ordered: the first key is the least recently used. */
|
|
27
|
+
#order = new Set();
|
|
28
|
+
/** Piece index → number of readers currently holding it. */
|
|
29
|
+
#pins = new Map();
|
|
30
|
+
#capacity;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {number} capacity - How many pieces may be resident at once.
|
|
34
|
+
*/
|
|
35
|
+
constructor(capacity) {
|
|
36
|
+
if (!Number.isInteger(capacity) || capacity < 1) {
|
|
37
|
+
throw new Error(`Piece capacity must be a positive integer, got ${capacity}.`);
|
|
38
|
+
}
|
|
39
|
+
this.#capacity = capacity;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** How many pieces are resident. */
|
|
43
|
+
get size() {
|
|
44
|
+
return this.#order.size;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** How many pieces may be resident at once. */
|
|
48
|
+
get capacity() {
|
|
49
|
+
return this.#capacity;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Mark a piece as resident, or as used again if it already was.
|
|
54
|
+
*
|
|
55
|
+
* A `Set` preserves insertion order, so deleting and re-adding is what moves
|
|
56
|
+
* a piece to the most-recent end.
|
|
57
|
+
*
|
|
58
|
+
* @param {number} index
|
|
59
|
+
* @returns {void}
|
|
60
|
+
*/
|
|
61
|
+
touch(index) {
|
|
62
|
+
this.#order.delete(index);
|
|
63
|
+
this.#order.add(index);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {number} index
|
|
68
|
+
* @returns {boolean}
|
|
69
|
+
*/
|
|
70
|
+
has(index) {
|
|
71
|
+
return this.#order.has(index);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Stop tracking a piece — it is no longer resident.
|
|
76
|
+
*
|
|
77
|
+
* @param {number} index
|
|
78
|
+
* @returns {void}
|
|
79
|
+
*/
|
|
80
|
+
remove(index) {
|
|
81
|
+
this.#order.delete(index);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Hold a piece in memory for the duration of a read.
|
|
86
|
+
*
|
|
87
|
+
* Nested: two readers of the same piece take two pins, and the piece stays
|
|
88
|
+
* held until both let go.
|
|
89
|
+
*
|
|
90
|
+
* @param {number} index
|
|
91
|
+
* @returns {void}
|
|
92
|
+
*/
|
|
93
|
+
pin(index) {
|
|
94
|
+
this.#pins.set(index, (this.#pins.get(index) ?? 0) + 1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Release one pin taken with {@link pin}.
|
|
99
|
+
*
|
|
100
|
+
* @param {number} index
|
|
101
|
+
* @returns {void}
|
|
102
|
+
*/
|
|
103
|
+
unpin(index) {
|
|
104
|
+
const held = this.#pins.get(index);
|
|
105
|
+
if (held === undefined) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (held <= 1) {
|
|
109
|
+
this.#pins.delete(index);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
this.#pins.set(index, held - 1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @param {number} index
|
|
117
|
+
* @returns {boolean}
|
|
118
|
+
*/
|
|
119
|
+
isPinned(index) {
|
|
120
|
+
return this.#pins.has(index);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The least recently used piece that is free to go, or `null` when every
|
|
125
|
+
* resident piece is pinned.
|
|
126
|
+
*
|
|
127
|
+
* Returning `null` rather than evicting a pinned piece is the whole point:
|
|
128
|
+
* the caller must then wait or fail, never take memory out from under a
|
|
129
|
+
* reader.
|
|
130
|
+
*
|
|
131
|
+
* @returns {number | null}
|
|
132
|
+
*/
|
|
133
|
+
evictionCandidate() {
|
|
134
|
+
for (const index of this.#order) {
|
|
135
|
+
if (!this.#pins.has(index)) {
|
|
136
|
+
return index;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Whether admitting one more piece would exceed the capacity.
|
|
144
|
+
*
|
|
145
|
+
* @returns {boolean}
|
|
146
|
+
*/
|
|
147
|
+
isFull() {
|
|
148
|
+
return this.#order.size >= this.#capacity;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Torrent pieces in shared memory, with disk as the second tier.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the chunk store WebTorrent would otherwise build for itself. Two
|
|
5
|
+
* reasons, one forced and one chosen.
|
|
6
|
+
*
|
|
7
|
+
* **Forced.** WebTorrent's own piece cache hands out the buffer it keeps using
|
|
8
|
+
* and slices again on the next read. The torrent client now runs on its own
|
|
9
|
+
* thread, and moving a piece to the main thread by transferring ownership
|
|
10
|
+
* detached the cache's memory: proxy 2.9.71-2.9.73 answered every read with an
|
|
11
|
+
* empty body (`Stream ends prematurely at 0`) and the failure was invisible,
|
|
12
|
+
* because the error never reached the reader. Owning the memory ourselves
|
|
13
|
+
* removes the question of whose it was.
|
|
14
|
+
*
|
|
15
|
+
* **Chosen.** Memory this side of the thread boundary can be *shared* memory,
|
|
16
|
+
* which the main thread reads by offset instead of receiving as bytes — see
|
|
17
|
+
* {@link SharedPieceStore#locate}. Measured on the field host: a piece copy
|
|
18
|
+
* costs 3.64 ms, a read from the page cache into a buffer we own 7.63 ms, and
|
|
19
|
+
* re-downloading a piece from the swarm ~1430 ms. So memory first, disk under
|
|
20
|
+
* it, and never the swarm twice.
|
|
21
|
+
*
|
|
22
|
+
* What this deliberately does NOT do is manage the disk as a cache of its own.
|
|
23
|
+
* Pieces evicted from memory are written once and read back on demand; the file
|
|
24
|
+
* is discarded whole when the torrent goes away. libtorrent 2.0 and webtor's
|
|
25
|
+
* seeder both concluded that a hand-rolled disk cache earns less than it costs,
|
|
26
|
+
* and nothing here disagrees.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import os from "node:os";
|
|
30
|
+
import { PieceLru } from "./piece-lru.js";
|
|
31
|
+
import { DiskTier } from "./disk-tier.js";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Live stores, so the worker can report on them.
|
|
35
|
+
*
|
|
36
|
+
* WebTorrent constructs the store itself, deep inside its own wrappers, so
|
|
37
|
+
* there is no handle to reach for from outside. Registering here is what makes
|
|
38
|
+
* the store's behaviour visible in the field at all — without it the first
|
|
39
|
+
* strange case has nothing to go on.
|
|
40
|
+
*
|
|
41
|
+
* @type {Set<SharedPieceStore>}
|
|
42
|
+
*/
|
|
43
|
+
const liveStores = new Set();
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A snapshot of every live store, for logging.
|
|
47
|
+
*
|
|
48
|
+
* @returns {{ name: string, resident: number, capacity: number, spilled: number, fromMemory: number, fromDisk: number, spills: number, revivals: number, blockedByPins: number }[]}
|
|
49
|
+
*/
|
|
50
|
+
export function collectStoreStats() {
|
|
51
|
+
return [...liveStores].map((store) => store.stats());
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Ceiling for the automatic budget, and the share of free memory it will take.
|
|
56
|
+
*
|
|
57
|
+
* A flat default would be a guess dressed as a decision: the proxy runs on
|
|
58
|
+
* whatever the owner has, from a Pi to a rented box, and the budget is **per
|
|
59
|
+
* torrent** — several viewers mean several of these. Measured on the field host
|
|
60
|
+
* after one session: the proxy container sat at 796 MB with 4.1 GB free and 1.3
|
|
61
|
+
* GB already in swap, so a fixed half-gigabyte per torrent is not something to
|
|
62
|
+
* hand out blindly. Hence: a quarter of what is free, capped.
|
|
63
|
+
*/
|
|
64
|
+
const MEMORY_BUDGET_CEILING_BYTES = 512 * 1024 * 1024;
|
|
65
|
+
const FREE_MEMORY_SHARE = 0.25;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Budget for one torrent's resident pieces when the caller names none.
|
|
69
|
+
*
|
|
70
|
+
* @returns {number}
|
|
71
|
+
*/
|
|
72
|
+
function defaultMemoryBytes() {
|
|
73
|
+
const share = Math.floor(os.freemem() * FREE_MEMORY_SHARE);
|
|
74
|
+
return Math.max(MIN_BUDGET_BYTES, Math.min(MEMORY_BUDGET_CEILING_BYTES, share));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Floor for the automatic budget — below this the store thrashes to disk. */
|
|
78
|
+
const MIN_BUDGET_BYTES = 64 * 1024 * 1024;
|
|
79
|
+
/**
|
|
80
|
+
* Never keep fewer than this many pieces resident, whatever the budget says.
|
|
81
|
+
*
|
|
82
|
+
* Two is the smallest workable number rather than a round one: a piece being
|
|
83
|
+
* read holds its slot, so a second slot must exist for the next piece to land
|
|
84
|
+
* in. With one, a single reader would deadlock the store against itself.
|
|
85
|
+
*/
|
|
86
|
+
const MIN_RESIDENT_PIECES = 2;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A chunk store holding pieces in a `SharedArrayBuffer`, spilling to disk.
|
|
90
|
+
*
|
|
91
|
+
* Implements the `abstract-chunk-store` shape WebTorrent expects — `put`,
|
|
92
|
+
* `get`, `close`, `destroy` — plus {@link locate}, {@link pin} and
|
|
93
|
+
* {@link unpin}, which is how the main thread reads a piece without it being
|
|
94
|
+
* copied or moved.
|
|
95
|
+
*/
|
|
96
|
+
export class SharedPieceStore {
|
|
97
|
+
#chunkLength;
|
|
98
|
+
#lastChunkLength;
|
|
99
|
+
#lastChunkIndex;
|
|
100
|
+
#capacity;
|
|
101
|
+
/** @type {SharedArrayBuffer} */
|
|
102
|
+
#shared;
|
|
103
|
+
/** @type {Buffer} A view over the whole pool, for slot arithmetic. */
|
|
104
|
+
#pool;
|
|
105
|
+
/** Piece index → slot number. */
|
|
106
|
+
#slotOf = new Map();
|
|
107
|
+
/** Slot numbers not currently holding a piece. */
|
|
108
|
+
#freeSlots = [];
|
|
109
|
+
#lru;
|
|
110
|
+
#disk;
|
|
111
|
+
/** Slots backed by memory right now; grows towards {@link capacity}. */
|
|
112
|
+
#allocatedSlots = 0;
|
|
113
|
+
#closed = false;
|
|
114
|
+
#name;
|
|
115
|
+
/**
|
|
116
|
+
* What the store has actually been doing. Reported, not just kept: the
|
|
117
|
+
* balance between memory and disk reads is the number that says whether the
|
|
118
|
+
* budget is right, and it cannot be guessed from outside.
|
|
119
|
+
*/
|
|
120
|
+
#counters = {
|
|
121
|
+
fromMemory: 0,
|
|
122
|
+
fromDisk: 0,
|
|
123
|
+
spills: 0,
|
|
124
|
+
revivals: 0,
|
|
125
|
+
blockedByPins: 0
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {number} chunkLength - Piece length, and therefore the slot size.
|
|
130
|
+
* @param {object} [options]
|
|
131
|
+
* @param {number} [options.length] - Total torrent length, so the short last piece is sized correctly.
|
|
132
|
+
* @param {number} [options.memoryBytes] - Budget for resident pieces.
|
|
133
|
+
* @param {string} [options.path] - Directory for the spill file.
|
|
134
|
+
* @param {string} [options.name] - Spill file name; must be unique per torrent.
|
|
135
|
+
*/
|
|
136
|
+
constructor(chunkLength, options = {}) {
|
|
137
|
+
if (!Number.isInteger(chunkLength) || chunkLength < 1) {
|
|
138
|
+
throw new Error(`Chunk length must be a positive integer, got ${chunkLength}.`);
|
|
139
|
+
}
|
|
140
|
+
this.#chunkLength = chunkLength;
|
|
141
|
+
|
|
142
|
+
const totalLength = Number.isFinite(options.length) ? options.length : 0;
|
|
143
|
+
this.#lastChunkIndex = totalLength > 0 ? Math.ceil(totalLength / chunkLength) - 1 : -1;
|
|
144
|
+
const remainder = totalLength % chunkLength;
|
|
145
|
+
this.#lastChunkLength = remainder === 0 ? chunkLength : remainder;
|
|
146
|
+
|
|
147
|
+
const memoryBytes = Number.isFinite(options.memoryBytes) && options.memoryBytes > 0
|
|
148
|
+
? options.memoryBytes
|
|
149
|
+
: defaultMemoryBytes();
|
|
150
|
+
this.#capacity = Math.max(MIN_RESIDENT_PIECES, Math.floor(memoryBytes / chunkLength));
|
|
151
|
+
|
|
152
|
+
// Grows into the budget instead of taking it up front. The budget is per
|
|
153
|
+
// torrent, so claiming all of it on `add` would charge a host for pieces
|
|
154
|
+
// nobody has asked for — and a torrent that is merely open, or one being
|
|
155
|
+
// probed for its codecs, needs a handful of slots, not the ceiling.
|
|
156
|
+
this.#shared = new SharedArrayBuffer(MIN_RESIDENT_PIECES * chunkLength, {
|
|
157
|
+
maxByteLength: this.#capacity * chunkLength
|
|
158
|
+
});
|
|
159
|
+
this.#pool = Buffer.from(this.#shared);
|
|
160
|
+
for (let slot = 0; slot < MIN_RESIDENT_PIECES; slot += 1) {
|
|
161
|
+
this.#freeSlots.push(slot);
|
|
162
|
+
}
|
|
163
|
+
this.#allocatedSlots = MIN_RESIDENT_PIECES;
|
|
164
|
+
this.#lru = new PieceLru(this.#capacity);
|
|
165
|
+
this.#name = options.name ?? "pieces";
|
|
166
|
+
this.#disk = new DiskTier({
|
|
167
|
+
directory: options.path ?? ".",
|
|
168
|
+
name: `${this.#name}.pieces`,
|
|
169
|
+
chunkLength
|
|
170
|
+
});
|
|
171
|
+
liveStores.add(this);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* What this store has been doing, for the periodic report.
|
|
176
|
+
*
|
|
177
|
+
* @returns {{ name: string, resident: number, capacity: number, spilled: number, fromMemory: number, fromDisk: number, spills: number, revivals: number, blockedByPins: number }}
|
|
178
|
+
*/
|
|
179
|
+
stats() {
|
|
180
|
+
return {
|
|
181
|
+
name: this.#name,
|
|
182
|
+
resident: this.#slotOf.size,
|
|
183
|
+
capacity: this.#capacity,
|
|
184
|
+
spilled: this.#disk.size,
|
|
185
|
+
...this.#counters
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** `abstract-chunk-store` exposes the piece size under this name. */
|
|
190
|
+
get chunkLength() {
|
|
191
|
+
return this.#chunkLength;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The pool itself, so another thread can map the same memory and read a piece
|
|
196
|
+
* by the offset {@link locate} reports.
|
|
197
|
+
*
|
|
198
|
+
* @returns {SharedArrayBuffer}
|
|
199
|
+
*/
|
|
200
|
+
get sharedBuffer() {
|
|
201
|
+
return this.#shared;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** How many pieces fit in memory at once. */
|
|
205
|
+
get capacity() {
|
|
206
|
+
return this.#capacity;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** How many pieces are resident right now. */
|
|
210
|
+
get residentCount() {
|
|
211
|
+
return this.#slotOf.size;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** How many pieces have been spilled to disk. */
|
|
215
|
+
get spilledCount() {
|
|
216
|
+
return this.#disk.size;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Length of a given piece — the last one is usually short.
|
|
221
|
+
*
|
|
222
|
+
* @param {number} index
|
|
223
|
+
* @returns {number}
|
|
224
|
+
*/
|
|
225
|
+
#lengthOf(index) {
|
|
226
|
+
return index === this.#lastChunkIndex ? this.#lastChunkLength : this.#chunkLength;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Where a resident piece sits in the shared pool, or `null` if it is not
|
|
231
|
+
* resident.
|
|
232
|
+
*
|
|
233
|
+
* The main thread reads straight from those bytes, so callers MUST hold a pin
|
|
234
|
+
* across the read — see {@link pin}.
|
|
235
|
+
*
|
|
236
|
+
* @param {number} index
|
|
237
|
+
* @returns {{ offset: number, length: number } | null}
|
|
238
|
+
*/
|
|
239
|
+
locate(index) {
|
|
240
|
+
const slot = this.#slotOf.get(index);
|
|
241
|
+
if (slot === undefined) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
return { offset: slot * this.#chunkLength, length: this.#lengthOf(index) };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Hold a piece in memory across a read. Nested; release with {@link unpin}.
|
|
249
|
+
*
|
|
250
|
+
* @param {number} index
|
|
251
|
+
* @returns {void}
|
|
252
|
+
*/
|
|
253
|
+
pin(index) {
|
|
254
|
+
this.#lru.pin(index);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* @param {number} index
|
|
259
|
+
* @returns {void}
|
|
260
|
+
*/
|
|
261
|
+
unpin(index) {
|
|
262
|
+
this.#lru.unpin(index);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Make a slot available, spilling the least recently used piece if need be.
|
|
267
|
+
*
|
|
268
|
+
* @returns {Promise<number>} Slot number.
|
|
269
|
+
*/
|
|
270
|
+
async #claimSlot() {
|
|
271
|
+
const free = this.#freeSlots.pop();
|
|
272
|
+
if (free !== undefined) {
|
|
273
|
+
return free;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Room left in the budget: take more memory rather than evicting. Growing
|
|
277
|
+
// replaces the view over the pool, so every slot offset stays valid — the
|
|
278
|
+
// bytes do not move.
|
|
279
|
+
if (this.#allocatedSlots < this.#capacity) {
|
|
280
|
+
this.#allocatedSlots += 1;
|
|
281
|
+
this.#shared.grow(this.#allocatedSlots * this.#chunkLength);
|
|
282
|
+
this.#pool = Buffer.from(this.#shared);
|
|
283
|
+
return this.#allocatedSlots - 1;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const victim = this.#lru.evictionCandidate();
|
|
287
|
+
if (victim === null) {
|
|
288
|
+
// Every resident piece is being read. Taking one anyway is precisely the
|
|
289
|
+
// failure this store exists to make impossible.
|
|
290
|
+
this.#counters.blockedByPins += 1;
|
|
291
|
+
throw new Error("Every resident piece is pinned; no slot can be freed.");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const slot = this.#slotOf.get(victim);
|
|
295
|
+
const bytes = this.#pool.subarray(slot * this.#chunkLength, slot * this.#chunkLength + this.#lengthOf(victim));
|
|
296
|
+
await this.#disk.write(victim, bytes);
|
|
297
|
+
this.#counters.spills += 1;
|
|
298
|
+
|
|
299
|
+
this.#slotOf.delete(victim);
|
|
300
|
+
this.#lru.remove(victim);
|
|
301
|
+
return slot;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Store a piece.
|
|
306
|
+
*
|
|
307
|
+
* @param {number} index
|
|
308
|
+
* @param {Uint8Array} bytes
|
|
309
|
+
* @param {(error?: Error | null) => void} [callback]
|
|
310
|
+
* @returns {void}
|
|
311
|
+
*/
|
|
312
|
+
put(index, bytes, callback = () => undefined) {
|
|
313
|
+
if (this.#closed) {
|
|
314
|
+
queueMicrotask(() => callback(new Error("Piece store is closed.")));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const existing = this.#slotOf.get(index);
|
|
319
|
+
const write = async () => {
|
|
320
|
+
const slot = existing ?? (await this.#claimSlot());
|
|
321
|
+
bytes.copy
|
|
322
|
+
? bytes.copy(this.#pool, slot * this.#chunkLength)
|
|
323
|
+
: this.#pool.set(bytes, slot * this.#chunkLength);
|
|
324
|
+
this.#slotOf.set(index, slot);
|
|
325
|
+
this.#lru.touch(index);
|
|
326
|
+
// A newer copy is in memory; whatever is on disk is stale.
|
|
327
|
+
this.#disk.forget(index);
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
write().then(() => callback(null), (error) => callback(error));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Fetch a piece, or a range within it.
|
|
335
|
+
*
|
|
336
|
+
* Returns a buffer of its own rather than a view into the pool: WebTorrent
|
|
337
|
+
* keeps what it is given — to verify a hash, to serve a peer — and the slot
|
|
338
|
+
* underneath may be reused meanwhile. The thread-crossing path avoids this
|
|
339
|
+
* copy entirely by going through {@link locate}.
|
|
340
|
+
*
|
|
341
|
+
* @param {number} index
|
|
342
|
+
* @param {{ offset?: number, length?: number } | ((error: Error | null, bytes?: Buffer) => void)} [options]
|
|
343
|
+
* @param {(error: Error | null, bytes?: Buffer) => void} [callback]
|
|
344
|
+
* @returns {void}
|
|
345
|
+
*/
|
|
346
|
+
get(index, options, callback) {
|
|
347
|
+
if (typeof options === "function") {
|
|
348
|
+
return this.get(index, undefined, options);
|
|
349
|
+
}
|
|
350
|
+
const done = callback ?? (() => undefined);
|
|
351
|
+
if (this.#closed) {
|
|
352
|
+
queueMicrotask(() => done(new Error("Piece store is closed.")));
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const pieceLength = this.#lengthOf(index);
|
|
357
|
+
const offset = options?.offset ?? 0;
|
|
358
|
+
const length = options?.length ?? pieceLength - offset;
|
|
359
|
+
|
|
360
|
+
const fetch = async () => {
|
|
361
|
+
const slot = this.#slotOf.get(index);
|
|
362
|
+
if (slot !== undefined) {
|
|
363
|
+
this.#lru.touch(index);
|
|
364
|
+
this.#counters.fromMemory += 1;
|
|
365
|
+
const start = slot * this.#chunkLength + offset;
|
|
366
|
+
return Buffer.from(this.#pool.subarray(start, start + length));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (!this.#disk.has(index)) {
|
|
370
|
+
throw new Error(`Piece ${index} is not in the store.`);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Bring it back into memory: it was just asked for, so it is likely to be
|
|
374
|
+
// asked for again, and the caller may follow up with `locate`.
|
|
375
|
+
const revived = await this.#claimSlot();
|
|
376
|
+
const target = this.#pool.subarray(
|
|
377
|
+
revived * this.#chunkLength,
|
|
378
|
+
revived * this.#chunkLength + pieceLength
|
|
379
|
+
);
|
|
380
|
+
await this.#disk.read(index, target);
|
|
381
|
+
this.#slotOf.set(index, revived);
|
|
382
|
+
this.#lru.touch(index);
|
|
383
|
+
this.#counters.fromDisk += 1;
|
|
384
|
+
this.#counters.revivals += 1;
|
|
385
|
+
const start = revived * this.#chunkLength + offset;
|
|
386
|
+
return Buffer.from(this.#pool.subarray(start, start + length));
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
fetch().then((bytes) => done(null, bytes), (error) => done(error));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Close the store, keeping the spill file.
|
|
394
|
+
*
|
|
395
|
+
* @param {(error?: Error | null) => void} [callback]
|
|
396
|
+
* @returns {void}
|
|
397
|
+
*/
|
|
398
|
+
close(callback = () => undefined) {
|
|
399
|
+
this.#closed = true;
|
|
400
|
+
liveStores.delete(this);
|
|
401
|
+
this.#disk.close().then(() => callback(null), (error) => callback(error));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Close the store and delete everything it wrote.
|
|
406
|
+
*
|
|
407
|
+
* @param {(error?: Error | null) => void} [callback]
|
|
408
|
+
* @returns {void}
|
|
409
|
+
*/
|
|
410
|
+
destroy(callback = () => undefined) {
|
|
411
|
+
this.#closed = true;
|
|
412
|
+
liveStores.delete(this);
|
|
413
|
+
this.#slotOf.clear();
|
|
414
|
+
this.#disk.destroy().then(() => callback(null), (error) => callback(error));
|
|
415
|
+
}
|
|
416
|
+
}
|