@torrent-tv/proxy 2.9.72 → 2.9.74

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 CHANGED
@@ -1,3 +1,15 @@
1
+ ## 2.9.74
2
+
3
+ - **Fix**: Playback works again. Since 2.9.71 every read answered with headers and an empty body — ffmpeg reported `Stream ends prematurely at 0, should be <size>` and the loading screen sat on "Preparing HLS transcode" until it gave up. Root cause, reproduced locally on two different torrents once the right conditions were used (a large, **partially downloaded** file — a complete one never shows it): the worker transferred ownership of a buffer belonging to WebTorrent's piece cache, the cache's memory was detached, and from that moment every read failed with `Cannot perform %TypedArray%.prototype.slice on a detached ArrayBuffer`. Nobody saw that error, because the worker sent the end-of-read marker from its `finally` before posting the failure and the main thread had no handler for a read error at all — so a broken read was indistinguishable from an empty file. Fixed at the root by owning the memory (the new piece store) and at the boundary by having the transport copy into memory it allocated itself rather than trying to guess whether the caller's buffer was safe to take. Verified end to end on both an almost-complete and a freshly-added torrent: playback plan, byte range, transcode session, init segment and first media segment all produced.
4
+ - **New**: A piece store of our own (`services/piece-store/`): pieces live in a `SharedArrayBuffer`, spill to a single sparse file when the memory budget is full, and come back from it on demand. Owning the memory is what makes the thread split safe — WebTorrent's own cache hands out buffers it keeps using, which is why transferring one detached the cache and killed every subsequent read. Two properties are enforced rather than hoped for: a piece being read is **pinned** and cannot be evicted (with every piece pinned the store refuses to make room instead of taking memory from under a reader — the exact failure of 2.9.71), and a buffer handed to a caller is never invalidated by later writes. Sized by measurement on the field host: a piece copy costs 3.64 ms, reading one back from disk into a buffer we already own 7.63 ms, and re-downloading it from the swarm ~1430 ms — so memory first, disk under it, the swarm never twice. Found while testing: opening the spill file in append mode makes POSIX ignore the write position entirely, so pieces piled up in arrival order and reads returned whichever piece happened to sit at that offset.
5
+ - **Fix**: A torrent carrying a `wss://` tracker took the **whole proxy process** down from 2.9.71 — including the demo magnet on the site's own button. node-datachannel is native and cannot be used from two V8 isolates at once (`HandleScope: Entering the V8 API without proper locking in place`); measured identically on win32/x64 and linux/arm64, with one isolate fine either way, two fatal, and `preload()` in both no help. Before the thread split both users lived in one isolate; afterwards the browser's video channel sat on the main thread while the torrent's tracker announces created peer connections on the worker. Fixed by leaving the native stack where it carries video and giving the torrent thread a JavaScript one (`werift`) through a module-resolution hook scoped to that worker — no dependency is patched, which matters because the addon installs with `--ignore-scripts`. The shim supplies the three things werift's data channel lacks and `simple-peer` depends on: `binaryType` (without it every payload goes through a text decoder and arrives corrupted), the buffered-amount-low event (its backpressure never resumes without it), and a session description built from one object rather than two positional arguments (werift's own signature is `(sdp, type)`, so `{ type, sdp }` was rejected as `invalid sessionDescription`). Verified end to end: a magnet with **only** wss trackers now connects to browser peers and downloads the file completely.
6
+ - **Chore**: The proxy has tests, and publishing runs them. There were none before, and nothing stood between writing code and `npm publish` — which is how the 2.9.71 thread split reached the field with a defect that stopped every read. `npm test` (Node's own runner, no new dependencies) plus `prepublishOnly`, so an unproven package cannot be published. The first cases cover the transport's memory contract and are written to FAIL on the current code: sending a chunk must leave the source buffer usable by its owner, and a second read of the same piece must still return its bytes. Both fail today, which is the point — they describe the shipped defect.
7
+
8
+ ## 2.9.73
9
+
10
+ - **Fix**: File stats came back as `{}` after the torrent moved to its own thread (2.9.71), which left the loading screen with no peers, no speed and no progress. Two call sites — the stats route and the health report — invoked `getFileStats` **without awaiting**: it used to answer locally and immediately, and now crosses a thread boundary, so the reply was the pending promise itself, serialised to an empty object. Both now await it.
11
+ - **Chore**: Chunk transfer no longer hands over memory the chunk does not own outright. Node allocates small buffers from a shared 8 KB pool — several unrelated buffers occupy one region, each viewing its own slice (verified: a 1 KB buffer reports an 8192-byte region at offset 8) — so transferring that region would detach it from its neighbours. Chunks sourced from the network are small enough to be pooled while local disk reads are not, which is exactly the difference between the field host and the local test. Measured afterwards: pooled chunks cross intact, so this is a correctness guard rather than the cause of the field failure.
12
+
1
13
  ## 2.9.72
2
14
 
3
15
  - **Fix**: Playback broke entirely after the torrent moved to its own thread (2.9.71): the torrent was deleted **with its downloaded data** while still being read, after which every read hung and ffmpeg saw an empty input (`Stream ends prematurely at 0, should be 3303133078`), and the container-index read took 73 s to return nothing. Cause: `acquireFile` was dispatched without awaiting while its release was sent normally, so a release could overtake the acquire it belonged to; the reader count then hit zero mid-read and the idle sweep fired (`removed idle torrent ... and its store`). Two fixes, each sufficient alone: the release is now chained onto the acquire so it can never arrive first, and the worker additionally holds the file for the whole duration of the read — a claim that lives inside the read and so cannot be reordered against it. Not reproducible locally, where the test torrent was fully downloaded and never went idle.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.72",
3
+ "version": "2.9.74",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -15,7 +15,9 @@
15
15
  "minor": "npm whoami && npm version minor && npm publish && git push --follow-tags",
16
16
  "major": "npm whoami && npm version major && npm publish && git push --follow-tags",
17
17
  "start": "node ./bin/cli.js",
18
- "dev": "node --inspect=0 --experimental-network-inspection ./bin/cli.js"
18
+ "dev": "node --inspect=0 --experimental-network-inspection ./bin/cli.js",
19
+ "test": "node --test",
20
+ "prepublishOnly": "npm test"
19
21
  },
20
22
  "dependencies": {
21
23
  "@fastify/cors": "^11.2.0",
@@ -31,6 +33,7 @@
31
33
  "node-datachannel": "^0.32.0",
32
34
  "uint8-util": "2.2.6",
33
35
  "webtorrent": "2.8.5",
36
+ "werift": "^0.24.2",
34
37
  "ws": "^8.18.2"
35
38
  }
36
39
  }
@@ -1,66 +1,69 @@
1
- import { logger } from "../../../../utils/logger.js";
2
-
3
- /**
4
- * Return download statistics for a registered torrent source.
5
- *
6
- * Provides peer count, transfer speeds, and per-file download progress so
7
- * that the browser client can display meaningful feedback while the proxy is
8
- * pre-fetching file metadata (MOOV atom / EBML headers) before codec probing.
9
- *
10
- * GET /api/sources/:sourceKey/stats?fileIndex=N
11
- *
12
- * @param {import("fastify").FastifyRequest} req
13
- * @param {import("fastify").FastifyReply} reply
14
- * @param {{
15
- * sourceRegistry: ReturnType<import("../../../../store/source-registry.js").createSourceRegistry>,
16
- * torrentPool: import("../../../../services/torrent-pool.js").TorrentPool
17
- * }} deps
18
- * @returns {Promise<void>}
19
- */
20
- export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool }) {
21
- const sourceKey = typeof req.params.sourceKey === "string" ? req.params.sourceKey.trim() : "";
22
- if (!sourceKey) {
23
- return reply.code(400).send({ error: "sourceKey is required." });
24
- }
25
-
26
- const sourceRecord = sourceRegistry.get(sourceKey);
27
- if (!sourceRecord) {
28
- return reply.code(404).send({ error: "Source key was not found." });
29
- }
30
-
31
- let torrent;
32
- try {
33
- // getTorrent resolves immediately when the torrent is already loaded.
34
- torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
35
- } catch (error) {
36
- const message = error instanceof Error ? error.message : String(error);
37
- return reply.code(500).send({ error: `Failed to load torrent: ${message}` });
38
- }
39
-
40
- const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
41
- const fileIndex = fileIndexRaw !== "" && /^\d+$/.test(fileIndexRaw) ? Number(fileIndexRaw) : null;
42
-
43
- // Optional: pin the resume window to a FIXED byte offset for the duration of
44
- // one buffering episode (see getFileStats JSDoc) instead of the live, moving
45
- // read position — otherwise "bytes needed" can jump up mid-poll as the window
46
- // slides forward with playback/encoding progress.
47
- const resumeAnchorRaw = typeof req.query.resumeAnchorByteStart === "string" ? req.query.resumeAnchorByteStart : "";
48
- const resumeAnchorByteStart = resumeAnchorRaw !== "" && /^\d+$/.test(resumeAnchorRaw) ? Number(resumeAnchorRaw) : null;
49
-
50
- const stats = torrentPool.getFileStats(torrent, fileIndex, { resumeAnchorByteStart });
51
-
52
- // Diagnostic: surface the real swarm state per poll so a cold-start download
53
- // stall (0 peers / header not advancing → playback-plan blocks on the codec
54
- // probe → browser timeout) is visible in the proxy log.
55
- const downKbps = (stats.downloadSpeed / 1024).toFixed(0);
56
- const filePct = stats.fileProgress != null ? `${(stats.fileProgress * 100).toFixed(1)}%` : "n/a";
57
- const header =
58
- stats.headerBytes != null
59
- ? `${stats.headerDownloadedBytes}/${stats.headerBytes}B`
60
- : "n/a";
61
- logger.info(
62
- `[stats] ${sourceKey.slice(0, 8)} peers=${stats.numPeers} down=${downKbps}KB/s file=${filePct} header=${header}`
63
- );
64
-
65
- return reply.send(stats);
66
- }
1
+ import { logger } from "../../../../utils/logger.js";
2
+
3
+ /**
4
+ * Return download statistics for a registered torrent source.
5
+ *
6
+ * Provides peer count, transfer speeds, and per-file download progress so
7
+ * that the browser client can display meaningful feedback while the proxy is
8
+ * pre-fetching file metadata (MOOV atom / EBML headers) before codec probing.
9
+ *
10
+ * GET /api/sources/:sourceKey/stats?fileIndex=N
11
+ *
12
+ * @param {import("fastify").FastifyRequest} req
13
+ * @param {import("fastify").FastifyReply} reply
14
+ * @param {{
15
+ * sourceRegistry: ReturnType<import("../../../../store/source-registry.js").createSourceRegistry>,
16
+ * torrentPool: import("../../../../services/torrent-pool.js").TorrentPool
17
+ * }} deps
18
+ * @returns {Promise<void>}
19
+ */
20
+ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool }) {
21
+ const sourceKey = typeof req.params.sourceKey === "string" ? req.params.sourceKey.trim() : "";
22
+ if (!sourceKey) {
23
+ return reply.code(400).send({ error: "sourceKey is required." });
24
+ }
25
+
26
+ const sourceRecord = sourceRegistry.get(sourceKey);
27
+ if (!sourceRecord) {
28
+ return reply.code(404).send({ error: "Source key was not found." });
29
+ }
30
+
31
+ let torrent;
32
+ try {
33
+ // getTorrent resolves immediately when the torrent is already loaded.
34
+ torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
35
+ } catch (error) {
36
+ const message = error instanceof Error ? error.message : String(error);
37
+ return reply.code(500).send({ error: `Failed to load torrent: ${message}` });
38
+ }
39
+
40
+ const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
41
+ const fileIndex = fileIndexRaw !== "" && /^\d+$/.test(fileIndexRaw) ? Number(fileIndexRaw) : null;
42
+
43
+ // Optional: pin the resume window to a FIXED byte offset for the duration of
44
+ // one buffering episode (see getFileStats JSDoc) instead of the live, moving
45
+ // read position — otherwise "bytes needed" can jump up mid-poll as the window
46
+ // slides forward with playback/encoding progress.
47
+ const resumeAnchorRaw = typeof req.query.resumeAnchorByteStart === "string" ? req.query.resumeAnchorByteStart : "";
48
+ const resumeAnchorByteStart = resumeAnchorRaw !== "" && /^\d+$/.test(resumeAnchorRaw) ? Number(resumeAnchorRaw) : null;
49
+
50
+ // Awaited: with the torrent on its own thread this is a round trip, not a
51
+ // local lookup. Without the await the reply was the pending promise itself,
52
+ // which serialises to `{}` — the empty stats seen in the field 2026-08-02.
53
+ const stats = await torrentPool.getFileStats(torrent, fileIndex, { resumeAnchorByteStart });
54
+
55
+ // Diagnostic: surface the real swarm state per poll so a cold-start download
56
+ // stall (0 peers / header not advancing playback-plan blocks on the codec
57
+ // probe → browser timeout) is visible in the proxy log.
58
+ const downKbps = (stats.downloadSpeed / 1024).toFixed(0);
59
+ const filePct = stats.fileProgress != null ? `${(stats.fileProgress * 100).toFixed(1)}%` : "n/a";
60
+ const header =
61
+ stats.headerBytes != null
62
+ ? `${stats.headerDownloadedBytes}/${stats.headerBytes}B`
63
+ : "n/a";
64
+ logger.info(
65
+ `[stats] ${sourceKey.slice(0, 8)} peers=${stats.numPeers} down=${downKbps}KB/s file=${filePct} header=${header}`
66
+ );
67
+
68
+ return reply.send(stats);
69
+ }
package/server.js CHANGED
@@ -144,7 +144,9 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
144
144
  }
145
145
  try {
146
146
  const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
147
- return torrentPool.getFileStats(torrent, Number.isInteger(fileIndex) ? fileIndex : null);
147
+ // Awaited for the same reason as the stats route: this now crosses a
148
+ // thread boundary and returns a promise.
149
+ return await torrentPool.getFileStats(torrent, Number.isInteger(fileIndex) ? fileIndex : null);
148
150
  } catch {
149
151
  return null;
150
152
  }
@@ -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
+ }