@torrent-tv/proxy 2.9.76 → 2.9.77

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.
@@ -1,275 +1,271 @@
1
- /**
2
- * @file Main-thread face of the torrent worker.
3
- *
4
- * Presents the same operations the routes and session manager already use, so
5
- * moving the torrent to its own thread does not ripple through calling code.
6
- * The one unavoidable change is that torrents are named by `sourceKey` instead
7
- * of passed around as objects — objects cannot cross a thread boundary, and
8
- * pretending otherwise would mean copying them on every call.
9
- *
10
- * Reads come back as an ordinary `ReadableStream`, so `/stream` and the codec
11
- * probe consume them exactly as they consume WebTorrent's own streams today.
12
- * What that hides is the part that matters: chunks arrive as transferred
13
- * buffers, never copied — 5.3 ms per 10 MB against 37 ms if cloned and 104 ms
14
- * through a transferable stream (measured 2026-08-02, see `protocol.js`).
15
- */
16
-
17
- import { Worker } from "node:worker_threads";
18
- import { Readable } from "node:stream";
19
- import { fileURLToPath } from "node:url";
20
- import { logger } from "../../utils/logger.js";
21
- import { createCaller, createReceiveStream } from "./channel.js";
22
- import { Command, Event } from "./protocol.js";
23
-
24
- const WORKER_URL = new URL("./worker.js", import.meta.url);
25
-
26
- /**
27
- * Runs the torrent client on its own thread and exposes it to the main thread.
28
- *
29
- * Why this exists at all: profiling during a live seek found the main thread
30
- * ~85% busy with WebTorrent (buffer concatenation ~15%, wire updates ~9%,
31
- * garbage collection ~5%), while three of four cores idled. Serving a segment
32
- * queued behind that work, so reading a finished 10 MB file took 12-23 s where
33
- * handing it to the channel took 125 ms.
34
- */
35
- export class TorrentWorkerClient {
36
- #worker;
37
- #caller;
38
- /** Receive-side handles for in-flight reads, keyed by request id. */
39
- #reads = new Map();
40
-
41
- /**
42
- * @param {{ maxDiskBytes?: number, memoryBytes?: number }} [options]
43
- */
44
- constructor({ maxDiskBytes, memoryBytes } = {}) {
45
- this.#worker = new Worker(fileURLToPath(WORKER_URL), {
46
- workerData: { maxDiskBytes, memoryBytes }
47
- });
48
- this.#caller = createCaller(this.#worker);
49
-
50
- this.#worker.on("message", (message) => {
51
- // A failed read must fail its stream. This is checked BEFORE the caller
52
- // sees the message: until 2.9.76 nothing here handled a read error at
53
- // all, so the worker's report was dropped as unknown, and because the
54
- // worker sent the end-of-read marker from its `finally` even when the
55
- // read had thrown, the reader saw a clean end of file instead. A read
56
- // that failed before it produced anything simply hung forever.
57
- if (message?.type === Event.ERROR && this.#reads.has(message.id)) {
58
- const read = this.#reads.get(message.id);
59
- this.#reads.delete(message.id);
60
- read.fail(new Error(message.error ?? "Torrent worker read failed."));
61
- return;
62
- }
63
- if (this.#caller.handleReply(message)) {
64
- return;
65
- }
66
- switch (message?.type) {
67
- case Event.CHUNK: {
68
- const bytes = message.bytes;
69
- this.#reads.get(message.id)?.push(
70
- new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.length)
71
- );
72
- break;
73
- }
74
- case Event.READ_END:
75
- this.#reads.get(message.id)?.close();
76
- this.#reads.delete(message.id);
77
- break;
78
- case Event.LOG:
79
- logger.info(`torrent-worker: ${message.message}`);
80
- break;
81
- default:
82
- break;
83
- }
84
- });
85
-
86
- this.#worker.on("error", (error) => {
87
- logger.error(`torrent-worker crashed: ${error?.message ?? error}`);
88
- // Fail everything outstanding rather than leaving callers hanging: a dead
89
- // worker will never answer, and a stalled request is worse than an error
90
- // the loading flow can retry.
91
- const reason = new Error("Torrent worker stopped unexpectedly.");
92
- this.#caller.rejectAll(reason);
93
- for (const [, read] of this.#reads) {
94
- read.fail(reason);
95
- }
96
- this.#reads.clear();
97
- });
98
- }
99
-
100
- /**
101
- * Add (or join) a torrent and register it under `sourceKey`.
102
- *
103
- * @param {{ sourceKey: string, sourceType: "magnet" | "torrent", source: string }} params
104
- * @returns {Promise<{ infoHash: string, name: string, files: { index: number, name: string, path: string, length: number }[] }>}
105
- */
106
- async addSource({ sourceKey, sourceType, source }) {
107
- return this.#caller.call(Command.ADD_SOURCE, { sourceKey, sourceType, source });
108
- }
109
-
110
- /**
111
- * The torrent's files, as plain data.
112
- *
113
- * @param {string} sourceKey
114
- * @returns {Promise<{ index: number, name: string, path: string, length: number }[]>}
115
- */
116
- async listFiles(sourceKey) {
117
- return this.#caller.call(Command.LIST_FILES, { sourceKey });
118
- }
119
-
120
- /**
121
- * Claim a file so it is not evicted while being read.
122
- *
123
- * @param {string} sourceKey
124
- * @param {number} fileIndex
125
- * @returns {Promise<void>}
126
- */
127
- async acquireFile(sourceKey, fileIndex) {
128
- await this.#caller.call(Command.ACQUIRE_FILE, { sourceKey, fileIndex });
129
- }
130
-
131
- /**
132
- * Drop a claim taken with {@link acquireFile}.
133
- *
134
- * @param {string} sourceKey
135
- * @param {number} fileIndex
136
- * @returns {Promise<void>}
137
- */
138
- async releaseFile(sourceKey, fileIndex) {
139
- await this.#caller.call(Command.RELEASE_FILE, { sourceKey, fileIndex });
140
- }
141
-
142
- /**
143
- * Live download figures for the progress display.
144
- *
145
- * @param {{ sourceKey: string, fileIndex: number, resumeAnchorByteStart?: number | null }} params
146
- * @returns {Promise<object>}
147
- */
148
- async getFileStats({ sourceKey, fileIndex, resumeAnchorByteStart = null }) {
149
- return this.#caller.call(Command.FILE_STATS, { sourceKey, fileIndex, resumeAnchorByteStart });
150
- }
151
-
152
- /**
153
- * Reorder piece selection around a read position (seek prioritisation).
154
- *
155
- * @param {{ sourceKey: string, fileIndex: number, byteStart: number, windowBytes?: number }} params
156
- * @returns {Promise<void>}
157
- */
158
- async prioritizeByteRange({ sourceKey, fileIndex, byteStart, windowBytes }) {
159
- await this.#caller.call(Command.PRIORITIZE, { sourceKey, fileIndex, byteStart, windowBytes });
160
- }
161
-
162
- /**
163
- * Pre-fetch the head and tail the codec probe needs.
164
- *
165
- * @param {{ sourceKey: string, fileIndex: number, headBytes?: number, tailBytes?: number, timeoutMs?: number }} params
166
- * @returns {Promise<unknown>}
167
- */
168
- async prefetchFileEdges({ sourceKey, fileIndex, headBytes, tailBytes, timeoutMs }) {
169
- return this.#caller.call(Command.PREFETCH_EDGES, {
170
- sourceKey,
171
- fileIndex,
172
- headBytes,
173
- tailBytes,
174
- timeoutMs
175
- });
176
- }
177
-
178
- /**
179
- * Read a byte range as a stream.
180
- *
181
- * Returns immediately with a stream that fills as chunks arrive; cancelling it
182
- * (viewer gone, seek superseded) stops the worker reading, so pieces are not
183
- * fetched for a stream nobody will drain.
184
- *
185
- * @param {{ sourceKey: string, fileIndex: number, start?: number | null, end?: number | null }} params
186
- * @returns {ReadableStream<Uint8Array>}
187
- */
188
- createReadStream({ sourceKey, fileIndex, start = null, end = null }) {
189
- // Same id sequence as commands — see `nextId` in `channel.js`.
190
- const readId = this.#caller.nextId();
191
- const receive = createReceiveStream({
192
- port: this.#worker,
193
- requestId: readId,
194
- onCancel: () => {
195
- void this.#caller.call(Command.CANCEL_READ, { readId }).catch(() => undefined);
196
- this.#reads.delete(readId);
197
- }
198
- });
199
- this.#reads.set(readId, receive);
200
-
201
- // The worker replies to READ_RANGE only once the body is fully sent; a
202
- // failure before that must surface on the stream, not vanish.
203
- this.#worker.postMessage({
204
- command: Command.READ_RANGE,
205
- id: readId,
206
- params: { sourceKey, fileIndex, start, end }
207
- });
208
-
209
- return receive.stream;
210
- }
211
-
212
- /**
213
- * A stand-in for the WebTorrent torrent object, backed by the worker.
214
- *
215
- * Callers already hold a torrent and reach into `torrent.files[i]` for the
216
- * length, the name, or a read stream. Handing back an object of the same
217
- * shape keeps every one of those call sites working unchanged, which matters:
218
- * they are spread across the stream route, the subtitle route, the playback
219
- * planner and the health report, and rewriting all of them to thread a
220
- * `sourceKey` through would be a large change with nothing to show for it.
221
- *
222
- * Only what is actually used is provided. Anything else would be a promise we
223
- * cannot keep the real object lives on the other thread and its methods are
224
- * not reachable from here.
225
- *
226
- * @param {{ sourceKey: string, sourceType: "magnet" | "torrent", source: string }} params
227
- * @returns {Promise<{ infoHash: string, name: string, sourceKey: string, files: object[] }>}
228
- */
229
- async getTorrent({ sourceKey, sourceType, source }) {
230
- const info = await this.addSource({ sourceKey, sourceType, source });
231
- const client = this;
232
- return {
233
- infoHash: info.infoHash,
234
- name: info.name,
235
- // Carried so helpers that receive only the torrent can still name it to
236
- // the worker.
237
- sourceKey,
238
- files: info.files.map((file) => ({
239
- ...file,
240
- /**
241
- * @param {{ start?: number, end?: number }} [options]
242
- * @returns {ReadableStream<Uint8Array>}
243
- */
244
- createReadStream(options = {}) {
245
- // Node stream, not a web one: Fastify replies and the ffmpeg pipe
246
- // both expect that shape, and every existing call site passes the
247
- // result straight to one of them. `Readable.fromWeb` adds no copy —
248
- // it wraps the same buffers.
249
- return Readable.fromWeb(
250
- client.createReadStream({
251
- sourceKey,
252
- fileIndex: file.index,
253
- start: options.start ?? null,
254
- end: options.end ?? null
255
- })
256
- );
257
- }
258
- }))
259
- };
260
- }
261
-
262
- /**
263
- * Shut the torrent client down and stop the thread.
264
- *
265
- * @returns {Promise<void>}
266
- */
267
- async destroyAll() {
268
- try {
269
- await this.#caller.call(Command.DESTROY_ALL, {});
270
- } catch {
271
- // Already gone — termination below is what matters.
272
- }
273
- await this.#worker.terminate();
274
- }
275
- }
1
+ /**
2
+ * @file Main-thread face of the torrent worker.
3
+ *
4
+ * Presents the same operations the routes and session manager already use, so
5
+ * moving the torrent to its own thread does not ripple through calling code.
6
+ * The one unavoidable change is that torrents are named by `sourceKey` instead
7
+ * of passed around as objects — objects cannot cross a thread boundary, and
8
+ * pretending otherwise would mean copying them on every call.
9
+ *
10
+ * Reads come back as an ordinary `ReadableStream`, so `/stream` and the codec
11
+ * probe consume them exactly as they consume WebTorrent's own streams today.
12
+ * What that hides is the part that matters: chunks arrive as transferred
13
+ * buffers, never copied — 5.3 ms per 10 MB against 37 ms if cloned and 104 ms
14
+ * through a transferable stream (measured 2026-08-02, see `protocol.js`).
15
+ */
16
+
17
+ import { Worker } from "node:worker_threads";
18
+ import { Readable } from "node:stream";
19
+ import { fileURLToPath } from "node:url";
20
+ import { logger } from "../../utils/logger.js";
21
+ import { createCaller, createReceiveStream } from "./channel.js";
22
+ import { Command, Event } from "./protocol.js";
23
+
24
+ const WORKER_URL = new URL("./worker.js", import.meta.url);
25
+
26
+ /**
27
+ * Runs the torrent client on its own thread and exposes it to the main thread.
28
+ *
29
+ * Why this exists at all: profiling during a live seek found the main thread
30
+ * ~85% busy with WebTorrent (buffer concatenation ~15%, wire updates ~9%,
31
+ * garbage collection ~5%), while three of four cores idled. Serving a segment
32
+ * queued behind that work, so reading a finished 10 MB file took 12-23 s where
33
+ * handing it to the channel took 125 ms.
34
+ */
35
+ export class TorrentWorkerClient {
36
+ #worker;
37
+ #caller;
38
+ /** Receive-side handles for in-flight reads, keyed by request id. */
39
+ #reads = new Map();
40
+
41
+ /**
42
+ * @param {{ maxDiskBytes?: number, memoryBytes?: number }} [options]
43
+ */
44
+ constructor({ maxDiskBytes, memoryBytes } = {}) {
45
+ this.#worker = new Worker(fileURLToPath(WORKER_URL), {
46
+ workerData: { maxDiskBytes, memoryBytes }
47
+ });
48
+ this.#caller = createCaller(this.#worker);
49
+
50
+ this.#worker.on("message", (message) => {
51
+ // A failed read must fail its stream. This is checked BEFORE the caller
52
+ // sees the message: until 2.9.76 nothing here handled a read error at
53
+ // all, so the worker's report was dropped as unknown, and because the
54
+ // worker sent the end-of-read marker from its `finally` even when the
55
+ // read had thrown, the reader saw a clean end of file instead. A read
56
+ // that failed before it produced anything simply hung forever.
57
+ if (message?.type === Event.ERROR && this.#reads.has(message.id)) {
58
+ const read = this.#reads.get(message.id);
59
+ this.#reads.delete(message.id);
60
+ read.fail(new Error(message.error ?? "Torrent worker read failed."));
61
+ return;
62
+ }
63
+ if (this.#caller.handleReply(message)) {
64
+ return;
65
+ }
66
+ switch (message?.type) {
67
+ case Event.CHUNK: {
68
+ const bytes = message.bytes;
69
+ this.#reads.get(message.id)?.push(
70
+ new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.length)
71
+ );
72
+ break;
73
+ }
74
+ case Event.READ_END:
75
+ this.#reads.get(message.id)?.close();
76
+ this.#reads.delete(message.id);
77
+ break;
78
+ case Event.LOG:
79
+ logger.info(`torrent-worker: ${message.message}`);
80
+ break;
81
+ default:
82
+ break;
83
+ }
84
+ });
85
+
86
+ this.#worker.on("error", (error) => {
87
+ logger.error(`torrent-worker crashed: ${error?.message ?? error}`);
88
+ // Fail everything outstanding rather than leaving callers hanging: a dead
89
+ // worker will never answer, and a stalled request is worse than an error
90
+ // the loading flow can retry.
91
+ const reason = new Error("Torrent worker stopped unexpectedly.");
92
+ this.#caller.rejectAll(reason);
93
+ for (const [, read] of this.#reads) {
94
+ read.fail(reason);
95
+ }
96
+ this.#reads.clear();
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Add (or join) a torrent and register it under `sourceKey`.
102
+ *
103
+ * @param {{ sourceKey: string, sourceType: "magnet" | "torrent", source: string }} params
104
+ * @returns {Promise<{ infoHash: string, name: string, files: { index: number, name: string, path: string, length: number }[] }>}
105
+ */
106
+ async addSource({ sourceKey, sourceType, source }) {
107
+ return this.#caller.call(Command.ADD_SOURCE, { sourceKey, sourceType, source });
108
+ }
109
+
110
+ /**
111
+ * The torrent's files, as plain data.
112
+ *
113
+ * @param {string} sourceKey
114
+ * @returns {Promise<{ index: number, name: string, path: string, length: number }[]>}
115
+ */
116
+ async listFiles(sourceKey) {
117
+ return this.#caller.call(Command.LIST_FILES, { sourceKey });
118
+ }
119
+
120
+ /**
121
+ * Claim a file so it is not evicted while being read.
122
+ *
123
+ * @param {string} sourceKey
124
+ * @param {number} fileIndex
125
+ * @returns {Promise<string>} The claim's identity, for {@link releaseFile}.
126
+ */
127
+ async acquireFile(sourceKey, fileIndex) {
128
+ return this.#caller.call(Command.ACQUIRE_FILE, { sourceKey, fileIndex });
129
+ }
130
+
131
+ /**
132
+ * Drop one claim taken with {@link acquireFile}.
133
+ *
134
+ * Named by claim rather than by file: several readers hold the same file at
135
+ * once, and releasing "the file" released somebody else's hold.
136
+ *
137
+ * @param {string} claimId
138
+ * @returns {Promise<void>}
139
+ */
140
+ async releaseFile(claimId) {
141
+ await this.#caller.call(Command.RELEASE_FILE, { claimId });
142
+ }
143
+
144
+ /**
145
+ * Live download figures for the progress display.
146
+ *
147
+ * @param {{ sourceKey: string, fileIndex: number, resumeAnchorByteStart?: number | null }} params
148
+ * @returns {Promise<object>}
149
+ */
150
+ async getFileStats({ sourceKey, fileIndex, resumeAnchorByteStart = null }) {
151
+ return this.#caller.call(Command.FILE_STATS, { sourceKey, fileIndex, resumeAnchorByteStart });
152
+ }
153
+
154
+ /**
155
+ * Reorder piece selection around a read position (seek prioritisation).
156
+ *
157
+ * @param {{ sourceKey: string, fileIndex: number, byteStart: number, windowBytes?: number }} params
158
+ * @returns {Promise<void>}
159
+ */
160
+ async prioritizeByteRange({ sourceKey, fileIndex, byteStart, windowBytes }) {
161
+ await this.#caller.call(Command.PRIORITIZE, { sourceKey, fileIndex, byteStart, windowBytes });
162
+ }
163
+
164
+ /**
165
+ * Pre-fetch the head and tail the codec probe needs.
166
+ *
167
+ * @param {{ sourceKey: string, fileIndex: number, options?: { headBytes?: number, tailBytes?: number, timeoutMs?: number } }} params
168
+ * @returns {Promise<unknown>}
169
+ */
170
+ async prefetchFileEdges({ sourceKey, fileIndex, options = {} }) {
171
+ return this.#caller.call(Command.PREFETCH_EDGES, { sourceKey, fileIndex, options });
172
+ }
173
+
174
+ /**
175
+ * Read a byte range as a stream.
176
+ *
177
+ * Returns immediately with a stream that fills as chunks arrive; cancelling it
178
+ * (viewer gone, seek superseded) stops the worker reading, so pieces are not
179
+ * fetched for a stream nobody will drain.
180
+ *
181
+ * @param {{ sourceKey: string, fileIndex: number, start?: number | null, end?: number | null }} params
182
+ * @returns {ReadableStream<Uint8Array>}
183
+ */
184
+ createReadStream({ sourceKey, fileIndex, start = null, end = null }) {
185
+ // Same id sequence as commands see `nextId` in `channel.js`.
186
+ const readId = this.#caller.nextId();
187
+ const receive = createReceiveStream({
188
+ port: this.#worker,
189
+ requestId: readId,
190
+ onCancel: () => {
191
+ void this.#caller.call(Command.CANCEL_READ, { readId }).catch(() => undefined);
192
+ this.#reads.delete(readId);
193
+ }
194
+ });
195
+ this.#reads.set(readId, receive);
196
+
197
+ // The worker replies to READ_RANGE only once the body is fully sent; a
198
+ // failure before that must surface on the stream, not vanish.
199
+ this.#worker.postMessage({
200
+ command: Command.READ_RANGE,
201
+ id: readId,
202
+ params: { sourceKey, fileIndex, start, end }
203
+ });
204
+
205
+ return receive.stream;
206
+ }
207
+
208
+ /**
209
+ * A stand-in for the WebTorrent torrent object, backed by the worker.
210
+ *
211
+ * Callers already hold a torrent and reach into `torrent.files[i]` — for the
212
+ * length, the name, or a read stream. Handing back an object of the same
213
+ * shape keeps every one of those call sites working unchanged, which matters:
214
+ * they are spread across the stream route, the subtitle route, the playback
215
+ * planner and the health report, and rewriting all of them to thread a
216
+ * `sourceKey` through would be a large change with nothing to show for it.
217
+ *
218
+ * Only what is actually used is provided. Anything else would be a promise we
219
+ * cannot keep the real object lives on the other thread and its methods are
220
+ * not reachable from here.
221
+ *
222
+ * @param {{ sourceKey: string, sourceType: "magnet" | "torrent", source: string }} params
223
+ * @returns {Promise<{ infoHash: string, name: string, sourceKey: string, files: object[] }>}
224
+ */
225
+ async getTorrent({ sourceKey, sourceType, source }) {
226
+ const info = await this.addSource({ sourceKey, sourceType, source });
227
+ const client = this;
228
+ return {
229
+ infoHash: info.infoHash,
230
+ name: info.name,
231
+ // Carried so helpers that receive only the torrent can still name it to
232
+ // the worker.
233
+ sourceKey,
234
+ files: info.files.map((file) => ({
235
+ ...file,
236
+ /**
237
+ * @param {{ start?: number, end?: number }} [options]
238
+ * @returns {ReadableStream<Uint8Array>}
239
+ */
240
+ createReadStream(options = {}) {
241
+ // Node stream, not a web one: Fastify replies and the ffmpeg pipe
242
+ // both expect that shape, and every existing call site passes the
243
+ // result straight to one of them. `Readable.fromWeb` adds no copy —
244
+ // it wraps the same buffers.
245
+ return Readable.fromWeb(
246
+ client.createReadStream({
247
+ sourceKey,
248
+ fileIndex: file.index,
249
+ start: options.start ?? null,
250
+ end: options.end ?? null
251
+ })
252
+ );
253
+ }
254
+ }))
255
+ };
256
+ }
257
+
258
+ /**
259
+ * Shut the torrent client down and stop the thread.
260
+ *
261
+ * @returns {Promise<void>}
262
+ */
263
+ async destroyAll() {
264
+ try {
265
+ await this.#caller.call(Command.DESTROY_ALL, {});
266
+ } catch {
267
+ // Already gone — termination below is what matters.
268
+ }
269
+ await this.#worker.terminate();
270
+ }
271
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @file Who is holding which file open.
3
+ *
4
+ * A claim keeps a file's data from being removed while something is reading it.
5
+ * Until 2.9.77 claims were keyed by `sourceKey:fileIndex`, which quietly made
6
+ * them **shared**: a second reader of the same file found the key taken and
7
+ * added nothing, so the first reader to finish released the claim out from
8
+ * under the second. The proxy reads one file from several places at once —
9
+ * ffmpeg's input, the keyframe index, the codec probe, a second viewer — so
10
+ * this was the normal case, not an edge one.
11
+ *
12
+ * The fix is not a counter. A counter restores the arithmetic but keeps the
13
+ * ambiguity: a release names a file, not a claim, so a duplicate or late
14
+ * release still decrements someone else's hold and nothing can detect it. Here
15
+ * every claim gets its own identity and a release names exactly that claim —
16
+ * so a stray release matches nothing, is reported, and harms no one.
17
+ */
18
+
19
+ /**
20
+ * @typedef {object} FileClaims
21
+ * @property {(sourceKey: string, fileIndex: number, release: () => void) => string} open
22
+ * @property {(claimId: string) => boolean} close
23
+ * @property {() => void} closeAll
24
+ * @property {number} size
25
+ */
26
+
27
+ /**
28
+ * Track file claims by identity.
29
+ *
30
+ * @returns {FileClaims}
31
+ */
32
+ export function createFileClaims() {
33
+ /** Claim id → the function that releases that one claim. */
34
+ const claims = new Map();
35
+ let counter = 0;
36
+
37
+ return {
38
+ /**
39
+ * Record a new claim and return its identity.
40
+ *
41
+ * Each call is a distinct claim even for the same file — that is the whole
42
+ * point.
43
+ *
44
+ * @param {string} sourceKey
45
+ * @param {number} fileIndex
46
+ * @param {() => void} release
47
+ * @returns {string}
48
+ */
49
+ open(sourceKey, fileIndex, release) {
50
+ counter += 1;
51
+ // The file is in the id purely so a log line reads usefully; matching is
52
+ // on the whole string.
53
+ const claimId = `${sourceKey}:${fileIndex}:${counter}`;
54
+ claims.set(claimId, release);
55
+ return claimId;
56
+ },
57
+
58
+ /**
59
+ * Release one claim. Returns false when there was no such claim, which is
60
+ * worth logging: it means a release arrived twice, or after teardown.
61
+ *
62
+ * @param {string} claimId
63
+ * @returns {boolean}
64
+ */
65
+ close(claimId) {
66
+ const release = claims.get(claimId);
67
+ if (!release) {
68
+ return false;
69
+ }
70
+ claims.delete(claimId);
71
+ release();
72
+ return true;
73
+ },
74
+
75
+ /**
76
+ * Release everything — the worker is shutting down.
77
+ *
78
+ * @returns {void}
79
+ */
80
+ closeAll() {
81
+ for (const [, release] of claims) {
82
+ release();
83
+ }
84
+ claims.clear();
85
+ },
86
+
87
+ get size() {
88
+ return claims.size;
89
+ }
90
+ };
91
+ }