@torrent-tv/proxy 2.66.0 → 2.67.0

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,784 +1,792 @@
1
- /**
2
- * @file The torrent thread: WebTorrent and nothing else.
3
- *
4
- * Everything that made the main thread unresponsive lives here now — peer
5
- * connections, buffer concatenation, piece bookkeeping, garbage collection from
6
- * all of it. The main thread keeps only what owes a viewer a prompt answer.
7
- *
8
- * This file deliberately holds no HTTP, no session logic and no knowledge of
9
- * HLS: it answers the commands in `protocol.js` and streams bytes back. That
10
- * boundary is what keeps the split honest — anything added here will compete
11
- * with the torrent for this thread, which is exactly the problem being solved.
12
- *
13
- * The existing `TorrentPool` is reused wholesale rather than reimplemented. It
14
- * already carries the parts that took field failures to get right — refcounted
15
- * file claims, idle removal, the global disk cap with LRU eviction, seek-aware
16
- * piece prioritisation, adaptive upload — and none of that changes by moving
17
- * threads.
18
- */
19
-
20
- // MUST stay first: it redirects `webrtc-polyfill` to a JavaScript WebRTC stack
21
- // before WebTorrent can reach the native one. Two isolates using
22
- // node-datachannel at once abort the process, and the torrent's wss trackers
23
- // create peer connections of their own.
24
- import { isUsableTorrentHandle } from "./handle-state.js";
25
- import "./install-webrtc-shim.js";
26
- import { parentPort, workerData } from "node:worker_threads";
27
- import { createSendStream } from "./channel.js";
28
- import { createFileClaims } from "./file-claims.js";
29
- import { readFragments, supplyFiguresFor } from "./piece-reader.js";
30
- import { cuesHeldFor, declaredSubtitleTracksOf, subtitleTracksOf, warmSubtitleCues } from "./subtitle-cues.js";
31
- import { CONTAINER_HEAD_BYTES, containerTracksOf } from "./container-tracks.js";
32
- import { Command, Event } from "./protocol.js";
33
- import { startMemoryReport, WORKER_MEMORY_SAMPLE_MS } from "../memory-report.js";
34
-
35
- // Imported dynamically, and that is load-bearing: static imports are RESOLVED
36
- // during linking, before any module body runs, so a statically imported pool
37
- // would drag in WebTorrent and with it the real `webrtc-polyfill` — before
38
- // the hook above had a chance to register. Verified the hard way: with a static
39
- // import the process still aborted, and the stack named the genuine polyfill.
40
- const { TorrentPool, resolveDhtBootstrap } = await import("../torrent-pool.js");
41
- const { collectStoreStats, pieceBufferCollection, reviseStoreBudgets } = await import("../piece-store/shared-piece-store.js");
42
-
43
- // Resolved before the client exists, because the client builds its DHT in its
44
- // own constructor and the addresses have to be in hand by then. Awaiting here
45
- // costs the few milliseconds of a DNS answer, once, on a thread that has not
46
- // been asked for anything yet.
47
- const dhtBootstrap = await resolveDhtBootstrap();
48
-
49
- const pool = new TorrentPool({
50
- maxDiskBytes: workerData?.maxDiskBytes,
51
- memoryBytes: workerData?.memoryBytes,
52
- dhtBootstrap
53
- });
54
-
55
- /** Torrents by sourceKey — the main thread names them, this thread owns them. */
56
- const torrentsByKey = new Map();
57
-
58
- /**
59
- * How each source was named when it was added, so a torrent that has since been
60
- * destroyed can be added again. Kept separately from {@link torrentsByKey}
61
- * because that map holds the promise, not the recipe.
62
- *
63
- * @type {Map<string, { sourceType: string, source: string }>}
64
- */
65
- const sourceRecipes = new Map();
66
- /** File claims, each with its own identity — see `file-claims.js`. */
67
- const fileClaims = createFileClaims();
68
- /** In-flight reads, so a cancel can stop one mid-body. */
69
- const readsById = new Map();
70
-
71
- /**
72
- * Forward a log line to the main thread, so worker output is not lost or
73
- * interleaved separately from everything else.
74
- *
75
- * @param {string} message
76
- * @returns {void}
77
- */
78
- function log(message) {
79
- parentPort.postMessage({ type: Event.LOG, message });
80
- }
81
-
82
- /**
83
- * The torrent for a sourceKey, waiting for it if it is still being added.
84
- *
85
- * The map holds a PROMISE, registered the moment the add begins rather than
86
- * when it finishes. That distinction is the whole fix: adding a magnet takes as
87
- * long as its metadata does seconds to tens of seconds and until 2.9.77
88
- * everything naming that source in the meantime was told `Unknown source`,
89
- * which is false. The source exists; it is not ready. Reproduced with a magnet
90
- * nobody seeds: stats, the file listing and a read all failed instantly while
91
- * the add was still in flight, which on the loading screen shows up as no
92
- * peers, no progress, and a plan request that fails before the torrent has had
93
- * a chance to start.
94
- *
95
- * A source that was never added still throws, which is the honest answer.
96
- *
97
- * @param {string} sourceKey
98
- * @returns {Promise<import("webtorrent").Torrent>}
99
- */
100
- async function requireTorrent(sourceKey) {
101
- const pending = torrentsByKey.get(sourceKey);
102
- if (!pending) {
103
- throw new Error(`Unknown source ${sourceKey}.`);
104
- }
105
- const torrent = await pending;
106
- if (isUsableTorrentHandle(torrent)) {
107
- return torrent;
108
- }
109
- // The pool destroys a torrent that has gone unread for a quarter of an hour,
110
- // and under disk pressure. It clears its OWN map when it does; this one it
111
- // knows nothing about, so the promise here went on resolving to a corpse: a
112
- // destroyed torrent keeps its object but loses its files. Every later session
113
- // for that source then failed the same way the plan and the codec probe
114
- // answered from cache in milliseconds, nothing waited for metadata because
115
- // everything believed the torrent was known, and ffmpeg's first read died on
116
- // `File N not found` 130 ms in, after which the session answered 500 for
117
- // ever. Measured 2026-08-06 on two sessions in a row, both from a phone,
118
- // which is what made it look like a mobile problem.
119
- const recipe = sourceRecipes.get(sourceKey);
120
- if (!recipe) {
121
- torrentsByKey.delete(sourceKey);
122
- throw new Error(`Source ${sourceKey} is gone and cannot be re-added.`);
123
- }
124
- const revived = pool.getTorrent(recipe.sourceType, recipe.source);
125
- torrentsByKey.set(sourceKey, revived);
126
- revived.catch(() => {
127
- if (torrentsByKey.get(sourceKey) === revived) {
128
- torrentsByKey.delete(sourceKey);
129
- }
130
- });
131
- return revived;
132
- }
133
-
134
-
135
- /**
136
- * Fragments waiting for the main thread to say it has finished reading them,
137
- * keyed by request id. One per read, because only one fragment is in flight.
138
- *
139
- * @type {Map<number, () => void>}
140
- */
141
- const fragmentWaiters = new Map();
142
-
143
- /**
144
- * Wake a read that is waiting for a fragment to be confirmed.
145
- *
146
- * Used both by the confirmation itself and by cancellation — a cancelled read
147
- * will never be confirmed, and without this it would wait forever holding a pin.
148
- *
149
- * @param {number} id
150
- * @returns {void}
151
- */
152
- function settleFragment(id) {
153
- const done = fragmentWaiters.get(id);
154
- if (done) {
155
- fragmentWaiters.delete(id);
156
- done();
157
- }
158
- }
159
-
160
- /**
161
- * Send one fragment's position and wait until the main thread is done with it.
162
- *
163
- * The pin is dropped only after the confirmation, because until then the other
164
- * thread may still be reading those exact bytes.
165
- *
166
- * @param {number} id
167
- * @param {import("./piece-reader.js").PieceFragment} fragment
168
- * @returns {Promise<void>}
169
- */
170
- function sendFragment(id, fragment) {
171
- return new Promise((resolve) => {
172
- fragmentWaiters.set(id, () => {
173
- fragment.release();
174
- resolve();
175
- });
176
- parentPort.postMessage({
177
- type: Event.FRAGMENT,
178
- id,
179
- pieceIndex: fragment.pieceIndex,
180
- buffer: fragment.buffer,
181
- offset: fragment.offset,
182
- length: fragment.length
183
- });
184
- });
185
- }
186
-
187
- /**
188
- * Stream a byte range back as CHUNK messages.
189
- *
190
- * Reads through WebTorrent's own read stream — which serves already-downloaded
191
- * pieces from disk and waits for the rest and forwards it in
192
- * {@link STREAM_CHUNK_BYTES} pieces, transferring ownership of each so nothing
193
- * is copied across the boundary. `createSendStream` applies the backpressure,
194
- * so a fast disk cannot outrun the main thread and rebuild the queue in memory.
195
- *
196
- * @param {object} params
197
- * @param {number} params.id - Request id; CHUNK/READ_END carry it.
198
- * @param {string} params.sourceKey
199
- * @param {number} params.fileIndex
200
- * @param {number | null} params.start - Inclusive, or null for the whole file.
201
- * @param {number | null} params.end - Inclusive.
202
- * @returns {Promise<void>}
203
- */
204
- async function streamRange({ id, sourceKey, fileIndex, start, end, windowBytes }) {
205
- const torrent = await requireTorrent(sourceKey);
206
- const file = torrent.files?.[fileIndex];
207
- if (!file) {
208
- throw new Error(`File ${fileIndex} not found in ${sourceKey}.`);
209
- }
210
-
211
- const sender = createSendStream({ port: parentPort, requestId: id });
212
- readsById.set(id, sender);
213
-
214
- // Hold the file for as long as this read runs. The caller also acquires it,
215
- // but that acquire and its release are separate messages from another thread
216
- // and can be reordered; this one cannot, because it lives entirely inside the
217
- // read. Without it the idle sweep saw a zero reader count and removed the
218
- // torrent AND its store mid-read field 2026-08-02: "removed idle torrent
219
- // ... and its store", after which every subsequent read hung and ffmpeg got
220
- // an empty input.
221
- const releaseRead = pool.acquireFile(torrent, fileIndex);
222
-
223
- const rangeStart = start ?? 0;
224
- const rangeEnd = end ?? file.length - 1;
225
-
226
- let failed = false;
227
- try {
228
- // Positions in shared memory, not bytes: the main thread maps the same pool
229
- // and reads each fragment in place, so nothing is copied and nothing is
230
- // transferred. See `piece-reader.js`.
231
- for await (const fragment of readFragments({
232
- torrent,
233
- fileIndex,
234
- start: rangeStart,
235
- end: rangeEnd,
236
- cancellation: sender,
237
- windowBytes
238
- })) {
239
- if (sender.isCancelled()) {
240
- fragment.release();
241
- break;
242
- }
243
- // One fragment in flight at a time. Each one holds a piece pinned, and
244
- // the store guarantees only two resident pieces at its smallest budget
245
- // holding two pins while asking for a third would deadlock it against
246
- // itself. The round trip costs ~100 µs against a piece worth megabytes,
247
- // so there is nothing to win by overlapping them.
248
- await sendFragment(id, fragment);
249
- }
250
- } catch (error) {
251
- // The end-of-read marker means "the body is complete". Sending it after a
252
- // failure told the reader the file simply ended a truncated segment that
253
- // ffmpeg reported as `Stream ends prematurely`, with the real cause thrown
254
- // away. Let the error propagate instead; the command handler reports it and
255
- // the main thread fails the stream.
256
- failed = true;
257
- throw error;
258
- } finally {
259
- readsById.delete(id);
260
- // Any fragment still awaiting confirmation will never get one now; settling
261
- // it here releases its pin rather than leaking a held slot.
262
- settleFragment(id);
263
- releaseRead();
264
- if (!failed) {
265
- sender.end();
266
- }
267
- // Nothing else to tear down: the reader owns no stream of its own, and a
268
- // cancelled read stops at its next fragment boundary because it polls the
269
- // same `sender` for cancellation.
270
- }
271
- }
272
-
273
- /**
274
- * Run one command and return its result.
275
- *
276
- * @param {string} command
277
- * @param {object} params
278
- * @param {number} id
279
- * @returns {Promise<unknown>}
280
- */
281
- async function runCommand(command, params, id) {
282
- switch (command) {
283
- case Command.ADD_SOURCE: {
284
- // Registered before it resolves, so anything naming this source while it
285
- // is being added waits for it instead of being told it does not exist.
286
- // Reusing the same promise for a repeated add also collapses two callers
287
- // racing to open the same torrent into one.
288
- sourceRecipes.set(params.sourceKey, {
289
- sourceType: params.sourceType,
290
- source: params.source
291
- });
292
- let pending = torrentsByKey.get(params.sourceKey);
293
- if (!pending) {
294
- pending = pool.getTorrent(params.sourceType, params.source);
295
- torrentsByKey.set(params.sourceKey, pending);
296
- // A failed add must not be remembered, or every later attempt at this
297
- // source replays the same failure. The handler also marks the rejection
298
- // as observed, so it cannot surface as an unhandled one.
299
- pending.catch(() => {
300
- if (torrentsByKey.get(params.sourceKey) === pending) {
301
- torrentsByKey.delete(params.sourceKey);
302
- }
303
- });
304
- }
305
- const torrent = await pending;
306
- return {
307
- infoHash: torrent.infoHash,
308
- name: torrent.name,
309
- // Files cross as plain data; the objects stay here.
310
- files: (torrent.files ?? []).map((file, index) => ({
311
- index,
312
- name: file.name,
313
- path: file.path,
314
- length: file.length
315
- }))
316
- };
317
- }
318
-
319
- case Command.LIST_FILES: {
320
- const torrent = await requireTorrent(params.sourceKey);
321
- return (torrent.files ?? []).map((file, index) => ({
322
- index,
323
- name: file.name,
324
- path: file.path,
325
- length: file.length
326
- }));
327
- }
328
-
329
- case Command.ACQUIRE_FILE: {
330
- const torrent = await requireTorrent(params.sourceKey);
331
- // Every acquire is its own claim. Sharing one per file meant the first
332
- // reader to finish released the hold while others were still reading.
333
- return fileClaims.open(
334
- params.sourceKey,
335
- params.fileIndex,
336
- pool.acquireFile(torrent, params.fileIndex)
337
- );
338
- }
339
-
340
- case Command.RELEASE_FILE: {
341
- const released = fileClaims.close(params.claimId);
342
- if (!released) {
343
- // Not fatal — but it means a release arrived twice or after teardown,
344
- // and silence here is what let the previous scheme look healthy.
345
- log(`release for unknown file claim ${params.claimId}`);
346
- }
347
- return released;
348
- }
349
-
350
- case Command.TORRENT_TOTALS: {
351
- // Downloaded and uploaded are counted apart: hashing every downloaded
352
- // byte is work of a different order from sending one back to the swarm,
353
- // and adding them would price both at whatever the mixture happened to
354
- // be.
355
- let downloaded = 0;
356
- let uploaded = 0;
357
- for (const torrent of pool.client?.torrents ?? []) {
358
- const gotBytes = Number(torrent?.downloaded);
359
- const sentBytes = Number(torrent?.uploaded);
360
- downloaded += Number.isFinite(gotBytes) ? gotBytes : 0;
361
- uploaded += Number.isFinite(sentBytes) ? sentBytes : 0;
362
- }
363
- return { downloaded, uploaded };
364
- }
365
-
366
- case Command.SUBTITLE_TRACKS: {
367
- const torrent = await requireTorrent(params.sourceKey);
368
- return {
369
- tracks: await subtitleTracksOf(torrent, params.fileIndex, params.sourceKey),
370
- declared: await declaredSubtitleTracksOf(torrent, params.fileIndex, params.sourceKey)
371
- };
372
- }
373
-
374
- case Command.CONTAINER_TRACKS: {
375
- const torrent = await requireTorrent(params.sourceKey);
376
- return {
377
- tracks: await containerTracksOf(torrent, params.fileIndex, params.sourceKey, {
378
- // The header of a file nobody is playing has usually not arrived at
379
- // all — a sidecar soundtrack is asked about before anyone has chosen
380
- // it. Its head is a few hundred kilobytes, and without them there is
381
- // nothing to read.
382
- prefetchEdges: () =>
383
- pool.prefetchFileEdges(torrent, params.fileIndex, {
384
- headBytes: CONTAINER_HEAD_BYTES,
385
- tailBytes: 0,
386
- timeoutMs: 60_000
387
- })
388
- })
389
- };
390
- }
391
-
392
- case Command.SUBTITLE_CUES: {
393
- const torrent = await requireTorrent(params.sourceKey);
394
- const held = await cuesHeldFor(torrent, params.fileIndex, params.sourceKey, params.trackNumber);
395
- return {
396
- cues: held.cues,
397
- coveredClusters: held.coveredClusters,
398
- indexedClusters: held.indexedClusters,
399
- codecId: held.track?.codecId ?? "",
400
- codecPrivate: held.track?.codecPrivate ?? "",
401
- language: held.track?.language ?? ""
402
- };
403
- }
404
-
405
- case Command.FILE_STATS: {
406
- const torrent = await requireTorrent(params.sourceKey);
407
- const stats = pool.getFileStats(torrent, params.fileIndex, {
408
- resumeAnchorByteStart: params.resumeAnchorByteStart ?? null
409
- });
410
- // What this file's own interruptions demand, measured by the reader in
411
- // this thread. It travels with the stats because the caller asking for
412
- // them is the one that has to decide with them — the browser's smallest
413
- // safe buffer, and the speed a quality step must sustain. Null until a
414
- // second interruption has been seen: one wait shows no interval, and an
415
- // interval invented from one point is exactly what this work removes.
416
- const file = Array.isArray(torrent?.files) ? torrent.files[params.fileIndex] : null;
417
- return {
418
- ...stats,
419
- supply: supplyFiguresFor(torrent?.infoHash, file?.name, params.segmentSeconds ?? 4)
420
- };
421
- }
422
-
423
- case Command.PRIORITIZE: {
424
- const torrent = await requireTorrent(params.sourceKey);
425
- pool.prioritizeByteRange(torrent, params.fileIndex, params.byteStart, params.windowBytes, {
426
- wholeFileRead: params.wholeFileRead === true
427
- });
428
- return true;
429
- }
430
-
431
- case Command.PREFETCH_EDGES: {
432
- const torrent = await requireTorrent(params.sourceKey);
433
- return pool.prefetchFileEdges(torrent, params.fileIndex, params.options ?? {});
434
- }
435
-
436
- case Command.READ_RANGE: {
437
- // Streams its own reply; the caller's promise resolves once the body has
438
- // been fully sent, which is what lets the client await completion.
439
- await streamRange({
440
- id,
441
- sourceKey: params.sourceKey,
442
- fileIndex: params.fileIndex,
443
- start: params.start ?? null,
444
- end: params.end ?? null,
445
- windowBytes: params.windowBytes
446
- });
447
- return true;
448
- }
449
-
450
- case Command.CANCEL_READ: {
451
- readsById.get(params.readId)?.cancel();
452
- // A cancelled read will never have its outstanding fragment confirmed, so
453
- // wake it here — otherwise it waits forever with a piece pinned.
454
- settleFragment(params.readId);
455
- return true;
456
- }
457
-
458
- case Command.DESTROY_ALL: {
459
- fileClaims.closeAll();
460
- torrentsByKey.clear();
461
- sourceRecipes.clear();
462
- await pool.destroyAll();
463
- return true;
464
- }
465
-
466
- default:
467
- throw new Error(`Unknown torrent-worker command: ${command}`);
468
- }
469
- }
470
-
471
- parentPort.on("message", async (message) => {
472
- // Chunk acknowledgements are not commands — they release backpressure on an
473
- // in-flight read.
474
- if (message?.type === Event.CHUNK_ACK) {
475
- readsById.get(message.id)?.ack();
476
- return;
477
- }
478
-
479
- // The main thread has finished reading a fragment out of shared memory, so
480
- // its piece may be unpinned and the read may continue.
481
- if (message?.type === Event.FRAGMENT_DONE) {
482
- settleFragment(message.id);
483
- return;
484
- }
485
-
486
- const { command, id, params } = message ?? {};
487
- try {
488
- const result = await runCommand(command, params ?? {}, id);
489
- parentPort.postMessage({ type: Event.RESULT, id, result });
490
- } catch (error) {
491
- parentPort.postMessage({ type: Event.ERROR, id, error: error?.message ?? String(error) });
492
- }
493
- });
494
-
495
- /**
496
- * How often the piece store reports what it has been doing.
497
- *
498
- * The store decides whether a read costs nothing or costs a disk trip, and
499
- * until 2.9.75 nothing about it reached the log — a field oddity would have had
500
- * no evidence to work from. Reported only when something changed, so an idle
501
- * proxy stays quiet.
502
- */
503
- // The torrent worker's OWN isolate, reported from inside it. The piece pool is
504
- // a `SharedArrayBuffer` allocated here, so the main thread's counters cannot
505
- // see it however carefully they are read — which is half the reason 650 MB of a
506
- // 893 MB process had no explanation on 2026-08-28 (roadmap item 2).
507
- // A second between readings, a minute between lines unless the heap moved by
508
- // 25 MB, and a heap snapshot of THIS isolate on every new high-water above
509
- // 400 MB. Three deaths — 2026-08-30 14:00 and 23:19, and
510
- // 2026-08-31 13:27 — went from a 30 MB heap to the 2240 MB ceiling inside one
511
- // sixty-second gap, and the only snapshots ever written were of the main
512
- // isolate, whose heap is 26 MB. So the isolate that dies has never once been
513
- // looked at (roadmap item 2, `research/worker-heap-oom-2026-08-31.md`).
514
- startMemoryReport({
515
- log,
516
- readStores: collectStoreStats,
517
- scope: "thread",
518
- label: "torrent worker",
519
- intervalMs: WORKER_MEMORY_SAMPLE_MS,
520
- quietMs: 60_000,
521
- changeBytes: 25 * 1024 * 1024,
522
- snapshotDir: workerData?.stateDir || undefined,
523
- snapshotFloorBytes: 400 * 1024 * 1024,
524
- snapshotGrowthBytes: 400 * 1024 * 1024,
525
- keepSnapshots: 3
526
- });
527
-
528
- const STORE_REPORT_INTERVAL_MS = 60_000;
529
-
530
- /** Last reported figures per store, so unchanged ones stay silent. */
531
- const lastReported = new Map();
532
-
533
- setInterval(() => {
534
- // What the machine can spare NOW, not what it could spare when each store was
535
- // created. With per-piece buffers a lowered ceiling is honoured immediately:
536
- // excess pieces are evicted to disk and their memory is reclaimable.
537
- for (const revised of reviseStoreBudgets()) {
538
- if (revised.evicted > 0) {
539
- log(
540
- `piece-store "${revised.name.slice(0, 40)}": allowance is now ` +
541
- `${Math.round(revised.ceilingBytes / 1048576)}MB, evicted ${revised.evicted} piece(s) to meet it — ` +
542
- `now ${Math.round(revised.committedBytes / 1048576)}MB committed`
543
- );
544
- } else if (revised.committedBytes > revised.ceilingBytes) {
545
- log(
546
- `piece-store "${revised.name.slice(0, 40)}": allowance is now ` +
547
- `${Math.round(revised.ceilingBytes / 1048576)}MB and ` +
548
- `${Math.round(revised.committedBytes / 1048576)}MB is committed ` +
549
- `all resident pieces are pinned, cannot shrink yet`
550
- );
551
- }
552
- }
553
- for (const stats of collectStoreStats()) {
554
- const signature = `${stats.fromMemory}/${stats.fromDisk}/${stats.spills}/${stats.revivals}/${stats.blockedByPins}/${stats.evictedOnRevise}/${stats.spillFailures}`;
555
- if (lastReported.get(stats.name) === signature) {
556
- continue;
557
- }
558
- lastReported.set(stats.name, signature);
559
-
560
- const reads = stats.fromMemory + stats.fromDisk;
561
- const fromMemoryShare = reads > 0 ? ((stats.fromMemory / reads) * 100).toFixed(1) : "—";
562
- log(
563
- `piece-store "${stats.name.slice(0, 40)}": resident=${stats.resident}/${stats.capacity} ` +
564
- `(${Math.round((stats.residentBytes || 0) / 1048576)}MB of ` +
565
- `${Math.round((stats.budgetBytes || 0) / 1048576)}MB allowed) ` +
566
- `committed=${Math.round((stats.committedBytes || 0) / 1048576)}MB ` +
567
- `on-disk=${Math.round((stats.spilledBytes || 0) / 1048576)}MB ` +
568
- `pinned=${stats.pinned} spilled=${stats.spilled} reads=${reads} (${fromMemoryShare}% from memory) ` +
569
- `spills=${stats.spills} revivals=${stats.revivals}` +
570
- (stats.blockedByPins > 0 ? ` blocked-by-pins=${stats.blockedByPins}` : "") +
571
- (stats.evictedOnRevise > 0 ? ` evictedOnRevise=${stats.evictedOnRevise}` : "") +
572
- (stats.spillFailures > 0 ? ` spill-failures=${stats.spillFailures}` : "") +
573
- (stats.outstanding > 0 ? ` outstanding=${stats.outstanding}` : "")
574
- );
575
- }
576
- // Not per store: the collector is per thread, and the question it answers —
577
- // is anything of ours outliving a piece — is about the thread. Printed
578
- // whenever the two disagree by more than the pieces actually held, which is
579
- // the only shape worth looking at (roadmap item 2).
580
- const collection = pieceBufferCollection();
581
- const outstandingBuffers = collection.released - collection.collected;
582
- const heldNow = collectStoreStats().reduce((sum, stats) => sum + (stats.resident || 0), 0);
583
- if (outstandingBuffers > heldNow) {
584
- log(
585
- `piece buffers: ${collection.released} let go, ${collection.collected} collected` +
586
- `${outstandingBuffers} still alive against ${heldNow} the store holds. ` +
587
- "A gap that keeps widening means a reference of ours outlives the piece; " +
588
- "a gap that does not means whatever grows is below us, in the allocator"
589
- );
590
- }
591
- }, STORE_REPORT_INTERVAL_MS).unref();
592
-
593
- /**
594
- * Walk subtitle cues for every actively-read file of one torrent, and PUSH
595
- * whatever came out new to the main thread which is what makes a browser's
596
- * copy current without it having asked.
597
- *
598
- * @param {string} sourceKey
599
- * @param {object} torrent
600
- * @returns {void}
601
- */
602
- function warmActiveFiles(sourceKey, torrent) {
603
- const usage = pool.fileUsageByTorrent.get(torrent);
604
- if (!usage) {
605
- return;
606
- }
607
- for (const fileIndex of usage.keys()) {
608
- const key = `${sourceKey}:${fileIndex}`;
609
- // A trigger that arrives while the previous pass is still walking is
610
- // dropped, not queued. `verified` fires per piece, so on a fast download
611
- // these arrive many times a second; the walk is serialized per file anyway,
612
- // and a queue of identical passes would only postpone the one that has
613
- // something new to find.
614
- if (warmupInFlight.has(key)) {
615
- continue;
616
- }
617
- warmupInFlight.add(key);
618
- warmSubtitleCues(torrent, fileIndex, sourceKey)
619
- .then((fresh) => {
620
- for (const entry of fresh) {
621
- const span = entry.spanStartSeconds === null
622
- ? "empty"
623
- : `${entry.spanStartSeconds.toFixed(1)}-${entry.spanEndSeconds.toFixed(1)}s`;
624
- log(
625
- `subtitle push ${sourceKey.slice(0, 8)}:${fileIndex} track ${entry.trackIndex}: ` +
626
- `${entry.cues.length} new cue(s) covering ${span}, ` +
627
- `clusters walked ${entry.walkedClusters}/${entry.indexedClusters}, cursor ${entry.cursor}, ` +
628
- "posting to main thread"
629
- );
630
- parentPort.postMessage({
631
- type: Event.SUBTITLE_CUES_READY,
632
- sourceKey,
633
- fileIndex,
634
- trackIndex: entry.trackIndex,
635
- cues: entry.cues,
636
- language: entry.language,
637
- cursor: entry.cursor
638
- });
639
- }
640
- })
641
- .catch((error) => {
642
- log(`subtitle warmup ${sourceKey}:${fileIndex} failed: ${error instanceof Error ? error.message : error}`);
643
- })
644
- .finally(() => {
645
- warmupInFlight.delete(key);
646
- });
647
- }
648
- }
649
-
650
- /**
651
- * Files whose warmup pass has not finished yet, by `sourceKey:fileIndex`.
652
- *
653
- * @type {Set<string>}
654
- */
655
- const warmupInFlight = new Set();
656
-
657
- /**
658
- * Torrents already wired to warm their subtitle cues the moment a piece
659
- * verifies, so the same torrent is not listened to twice.
660
- *
661
- * @type {WeakSet<object>}
662
- */
663
- const subtitleWarmupWired = new WeakSet();
664
-
665
- /**
666
- * A piece becoming readable is the actual event a cue can be pulled from —
667
- * "downloaded", not "about to be encoded or copied": what a viewer reaches is
668
- * decided by the read window ahead of the playhead, not by which of the two
669
- * paths a segment takes, and the piece exists (and is worth reading for
670
- * subtitles) whichever one that is. `verified` is WebTorrent's own signal for
671
- * exactly that instant, set at the same place the bitfield itself is (`
672
- * _markVerified`), so nothing here is guessing at readiness a different way.
673
- *
674
- * @param {string} sourceKey
675
- * @param {object} torrent
676
- * @returns {void}
677
- */
678
- function ensureSubtitleWarmupWired(sourceKey, torrent) {
679
- if (subtitleWarmupWired.has(torrent)) {
680
- return;
681
- }
682
- subtitleWarmupWired.add(torrent);
683
- torrent.on("verified", () => warmActiveFiles(sourceKey, torrent));
684
- }
685
-
686
- /**
687
- * How often an actively-read file's subtitle cues are walked ahead of being
688
- * asked for, as a fallback beside the per-piece `verified` listener above —
689
- * catches a listener attached after pieces already verified, and anything the
690
- * event path might otherwise miss. Cheap once caught up (`warmSubtitleCues`
691
- * skips clusters it has already read).
692
- */
693
- const SUBTITLE_WARMUP_INTERVAL_MS = 3_000;
694
-
695
- setInterval(() => {
696
- for (const [sourceKey, torrent] of pool.torrents) {
697
- ensureSubtitleWarmupWired(sourceKey, torrent);
698
- warmActiveFiles(sourceKey, torrent);
699
- }
700
- }, SUBTITLE_WARMUP_INTERVAL_MS).unref();
701
-
702
- /**
703
- * Keeps this thread alive ON PURPOSE — the one interval left accounted for
704
- * (no `.unref()`).
705
- *
706
- * Every other recurring handle here is unref'd, upload is disabled by
707
- * default, and idle peer connections close about half a minute after the
708
- * traffic stops — so once nothing is being read, every handle can be gone
709
- * at once, the event loop drains, and this thread ends BY ITSELF. Node then
710
- * tears the isolate down, that teardown touches memory some native module
711
- * has already freed, and the fault (SIGSEGV inside `uv_timer_stop`, reached
712
- * through `PerIsolatePlatformData::Shutdown`) kills the whole process at
713
- * once — HTTP server, tunnel, data channels — before any JS handler runs.
714
- * Field evidence: two deaths on 2026-08-22 (15:50:12 and 16:12:21 UTC),
715
- * each ~35 s after the last byte of traffic, identical core dumps;
716
- * same crash family as the utp-native faults of 2026-08-18..21
717
- * (`research/worker-thread-drain-crash-2026-08-22.md`).
718
- *
719
- * An empty repeating interval costs nothing, keeps the loop from draining
720
- * while the process lives, and thereby keeps that teardown path — and the
721
- * corrupted structure inside itunreachable, whichever module is guilty.
722
- */
723
- const WORKER_KEEPALIVE_INTERVAL_MS = 5_000;
724
-
725
- setInterval(() => {
726
- void process.uptime();
727
- }, WORKER_KEEPALIVE_INTERVAL_MS);
728
-
729
- /**
730
- * Say why this thread is ending, from inside it, before anything is torn down.
731
- *
732
- * The crash of 2026-08-27 15:40 is `node::worker::Worker::Run()` returning and
733
- * node faulting as it closes what was left on this loop. The parent DOES watch
734
- * for an unexpected exit and has a line ready for it — but that line never
735
- * printed, because the fault happens during this thread's own teardown, before
736
- * the parent's `exit` event is delivered. So the one reading that would name
737
- * the cause was being eaten by the failure it was meant to explain.
738
- *
739
- * These handlers run first, synchronously, and write through the same channel
740
- * as every other line here. `beforeExit` means the loop drained despite the
741
- * keepalive above; `exit` means the thread is going whatever the reason. The
742
- * list of what was still holding the loop open is the part that says which.
743
- */
744
- process.on("beforeExit", (code) => {
745
- log(
746
- `thread is about to end because the event loop drained (code ${code}) — ` +
747
- `still open: ${describeActiveResources()}`
748
- );
749
- });
750
-
751
- process.on("exit", (code) => {
752
- log(`thread ending with code ${code} — still open: ${describeActiveResources()}`);
753
- });
754
-
755
- process.on("uncaughtException", (error) => {
756
- log(`thread hit an uncaught error: ${error?.stack ?? error}`);
757
- });
758
-
759
- process.on("unhandledRejection", (reason) => {
760
- log(`thread hit an unhandled rejection: ${reason?.stack ?? reason}`);
761
- });
762
-
763
- /**
764
- * What is still holding this thread's event loop open, as node names it.
765
- *
766
- * @returns {string} A tally per resource kind, or why it could not be read.
767
- */
768
- function describeActiveResources() {
769
- try {
770
- const names = process.getActiveResourcesInfo?.() ?? [];
771
- if (names.length === 0) {
772
- return "nothing";
773
- }
774
- const tally = new Map();
775
- for (const name of names) {
776
- tally.set(name, (tally.get(name) ?? 0) + 1);
777
- }
778
- return [...tally].map(([name, count]) => `${name}x${count}`).join(" ");
779
- } catch (error) {
780
- return `unreadable (${error?.message ?? error})`;
781
- }
782
- }
783
-
784
- log("torrent worker started");
1
+ /**
2
+ * @file The torrent thread: WebTorrent and nothing else.
3
+ *
4
+ * Everything that made the main thread unresponsive lives here now — peer
5
+ * connections, buffer concatenation, piece bookkeeping, garbage collection from
6
+ * all of it. The main thread keeps only what owes a viewer a prompt answer.
7
+ *
8
+ * This file deliberately holds no HTTP, no session logic and no knowledge of
9
+ * HLS: it answers the commands in `protocol.js` and streams bytes back. That
10
+ * boundary is what keeps the split honest — anything added here will compete
11
+ * with the torrent for this thread, which is exactly the problem being solved.
12
+ *
13
+ * The existing `TorrentPool` is reused wholesale rather than reimplemented. It
14
+ * already carries the parts that took field failures to get right — refcounted
15
+ * file claims, idle removal, the global disk cap with LRU eviction, seek-aware
16
+ * piece prioritisation, adaptive upload — and none of that changes by moving
17
+ * threads.
18
+ */
19
+
20
+ // MUST stay first: it redirects `webrtc-polyfill` to a JavaScript WebRTC stack
21
+ // before WebTorrent can reach the native one. Two isolates using
22
+ // node-datachannel at once abort the process, and the torrent's wss trackers
23
+ // create peer connections of their own.
24
+ import { isUsableTorrentHandle } from "./handle-state.js";
25
+ import "./install-webrtc-shim.js";
26
+ import { parentPort, workerData } from "node:worker_threads";
27
+ import { createSendStream } from "./channel.js";
28
+ import { createFileClaims } from "./file-claims.js";
29
+ import { readFragments, supplyFiguresFor } from "./piece-reader.js";
30
+ import { cuesHeldFor, declaredSubtitleTracksOf, subtitleTracksOf, warmSubtitleCues } from "./subtitle-cues.js";
31
+ import { CONTAINER_HEAD_BYTES, containerTracksOf } from "./container-tracks.js";
32
+ import { fillFileInBackground } from "./background-fill.js";
33
+ import { Command, Event } from "./protocol.js";
34
+ import { startMemoryReport, WORKER_MEMORY_SAMPLE_MS } from "../memory-report.js";
35
+
36
+ // Imported dynamically, and that is load-bearing: static imports are RESOLVED
37
+ // during linking, before any module body runs, so a statically imported pool
38
+ // would drag in WebTorrent and with it the real `webrtc-polyfill` before
39
+ // the hook above had a chance to register. Verified the hard way: with a static
40
+ // import the process still aborted, and the stack named the genuine polyfill.
41
+ const { TorrentPool, resolveDhtBootstrap } = await import("../torrent-pool.js");
42
+ const { collectStoreStats, pieceBufferCollection, reviseStoreBudgets } = await import("../piece-store/shared-piece-store.js");
43
+
44
+ // Resolved before the client exists, because the client builds its DHT in its
45
+ // own constructor and the addresses have to be in hand by then. Awaiting here
46
+ // costs the few milliseconds of a DNS answer, once, on a thread that has not
47
+ // been asked for anything yet.
48
+ const dhtBootstrap = await resolveDhtBootstrap();
49
+
50
+ const pool = new TorrentPool({
51
+ maxDiskBytes: workerData?.maxDiskBytes,
52
+ memoryBytes: workerData?.memoryBytes,
53
+ dhtBootstrap
54
+ });
55
+
56
+ /** Torrents by sourceKey — the main thread names them, this thread owns them. */
57
+ const torrentsByKey = new Map();
58
+
59
+ /**
60
+ * How each source was named when it was added, so a torrent that has since been
61
+ * destroyed can be added again. Kept separately from {@link torrentsByKey}
62
+ * because that map holds the promise, not the recipe.
63
+ *
64
+ * @type {Map<string, { sourceType: string, source: string }>}
65
+ */
66
+ const sourceRecipes = new Map();
67
+ /** File claims, each with its own identity — see `file-claims.js`. */
68
+ const fileClaims = createFileClaims();
69
+ /** In-flight reads, so a cancel can stop one mid-body. */
70
+ const readsById = new Map();
71
+
72
+ /**
73
+ * Forward a log line to the main thread, so worker output is not lost or
74
+ * interleaved separately from everything else.
75
+ *
76
+ * @param {string} message
77
+ * @returns {void}
78
+ */
79
+ function log(message) {
80
+ parentPort.postMessage({ type: Event.LOG, message });
81
+ }
82
+
83
+ /**
84
+ * The torrent for a sourceKey, waiting for it if it is still being added.
85
+ *
86
+ * The map holds a PROMISE, registered the moment the add begins rather than
87
+ * when it finishes. That distinction is the whole fix: adding a magnet takes as
88
+ * long as its metadata does seconds to tens of seconds — and until 2.9.77
89
+ * everything naming that source in the meantime was told `Unknown source`,
90
+ * which is false. The source exists; it is not ready. Reproduced with a magnet
91
+ * nobody seeds: stats, the file listing and a read all failed instantly while
92
+ * the add was still in flight, which on the loading screen shows up as no
93
+ * peers, no progress, and a plan request that fails before the torrent has had
94
+ * a chance to start.
95
+ *
96
+ * A source that was never added still throws, which is the honest answer.
97
+ *
98
+ * @param {string} sourceKey
99
+ * @returns {Promise<import("webtorrent").Torrent>}
100
+ */
101
+ async function requireTorrent(sourceKey) {
102
+ const pending = torrentsByKey.get(sourceKey);
103
+ if (!pending) {
104
+ throw new Error(`Unknown source ${sourceKey}.`);
105
+ }
106
+ const torrent = await pending;
107
+ if (isUsableTorrentHandle(torrent)) {
108
+ return torrent;
109
+ }
110
+ // The pool destroys a torrent that has gone unread for a quarter of an hour,
111
+ // and under disk pressure. It clears its OWN map when it does; this one it
112
+ // knows nothing about, so the promise here went on resolving to a corpse: a
113
+ // destroyed torrent keeps its object but loses its files. Every later session
114
+ // for that source then failed the same way the plan and the codec probe
115
+ // answered from cache in milliseconds, nothing waited for metadata because
116
+ // everything believed the torrent was known, and ffmpeg's first read died on
117
+ // `File N not found` 130 ms in, after which the session answered 500 for
118
+ // ever. Measured 2026-08-06 on two sessions in a row, both from a phone,
119
+ // which is what made it look like a mobile problem.
120
+ const recipe = sourceRecipes.get(sourceKey);
121
+ if (!recipe) {
122
+ torrentsByKey.delete(sourceKey);
123
+ throw new Error(`Source ${sourceKey} is gone and cannot be re-added.`);
124
+ }
125
+ const revived = pool.getTorrent(recipe.sourceType, recipe.source);
126
+ torrentsByKey.set(sourceKey, revived);
127
+ revived.catch(() => {
128
+ if (torrentsByKey.get(sourceKey) === revived) {
129
+ torrentsByKey.delete(sourceKey);
130
+ }
131
+ });
132
+ return revived;
133
+ }
134
+
135
+
136
+ /**
137
+ * Fragments waiting for the main thread to say it has finished reading them,
138
+ * keyed by request id. One per read, because only one fragment is in flight.
139
+ *
140
+ * @type {Map<number, () => void>}
141
+ */
142
+ const fragmentWaiters = new Map();
143
+
144
+ /**
145
+ * Wake a read that is waiting for a fragment to be confirmed.
146
+ *
147
+ * Used both by the confirmation itself and by cancellation a cancelled read
148
+ * will never be confirmed, and without this it would wait forever holding a pin.
149
+ *
150
+ * @param {number} id
151
+ * @returns {void}
152
+ */
153
+ function settleFragment(id) {
154
+ const done = fragmentWaiters.get(id);
155
+ if (done) {
156
+ fragmentWaiters.delete(id);
157
+ done();
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Send one fragment's position and wait until the main thread is done with it.
163
+ *
164
+ * The pin is dropped only after the confirmation, because until then the other
165
+ * thread may still be reading those exact bytes.
166
+ *
167
+ * @param {number} id
168
+ * @param {import("./piece-reader.js").PieceFragment} fragment
169
+ * @returns {Promise<void>}
170
+ */
171
+ function sendFragment(id, fragment) {
172
+ return new Promise((resolve) => {
173
+ fragmentWaiters.set(id, () => {
174
+ fragment.release();
175
+ resolve();
176
+ });
177
+ parentPort.postMessage({
178
+ type: Event.FRAGMENT,
179
+ id,
180
+ pieceIndex: fragment.pieceIndex,
181
+ buffer: fragment.buffer,
182
+ offset: fragment.offset,
183
+ length: fragment.length
184
+ });
185
+ });
186
+ }
187
+
188
+ /**
189
+ * Stream a byte range back as CHUNK messages.
190
+ *
191
+ * Reads through WebTorrent's own read streamwhich serves already-downloaded
192
+ * pieces from disk and waits for the rest — and forwards it in
193
+ * {@link STREAM_CHUNK_BYTES} pieces, transferring ownership of each so nothing
194
+ * is copied across the boundary. `createSendStream` applies the backpressure,
195
+ * so a fast disk cannot outrun the main thread and rebuild the queue in memory.
196
+ *
197
+ * @param {object} params
198
+ * @param {number} params.id - Request id; CHUNK/READ_END carry it.
199
+ * @param {string} params.sourceKey
200
+ * @param {number} params.fileIndex
201
+ * @param {number | null} params.start - Inclusive, or null for the whole file.
202
+ * @param {number | null} params.end - Inclusive.
203
+ * @returns {Promise<void>}
204
+ */
205
+ async function streamRange({ id, sourceKey, fileIndex, start, end, windowBytes }) {
206
+ const torrent = await requireTorrent(sourceKey);
207
+ const file = torrent.files?.[fileIndex];
208
+ if (!file) {
209
+ throw new Error(`File ${fileIndex} not found in ${sourceKey}.`);
210
+ }
211
+
212
+ const sender = createSendStream({ port: parentPort, requestId: id });
213
+ readsById.set(id, sender);
214
+
215
+ // Hold the file for as long as this read runs. The caller also acquires it,
216
+ // but that acquire and its release are separate messages from another thread
217
+ // and can be reordered; this one cannot, because it lives entirely inside the
218
+ // read. Without it the idle sweep saw a zero reader count and removed the
219
+ // torrent AND its store mid-read field 2026-08-02: "removed idle torrent
220
+ // ... and its store", after which every subsequent read hung and ffmpeg got
221
+ // an empty input.
222
+ const releaseRead = pool.acquireFile(torrent, fileIndex);
223
+
224
+ const rangeStart = start ?? 0;
225
+ const rangeEnd = end ?? file.length - 1;
226
+
227
+ let failed = false;
228
+ try {
229
+ // Positions in shared memory, not bytes: the main thread maps the same pool
230
+ // and reads each fragment in place, so nothing is copied and nothing is
231
+ // transferred. See `piece-reader.js`.
232
+ for await (const fragment of readFragments({
233
+ torrent,
234
+ fileIndex,
235
+ start: rangeStart,
236
+ end: rangeEnd,
237
+ cancellation: sender,
238
+ windowBytes
239
+ })) {
240
+ if (sender.isCancelled()) {
241
+ fragment.release();
242
+ break;
243
+ }
244
+ // One fragment in flight at a time. Each one holds a piece pinned, and
245
+ // the store guarantees only two resident pieces at its smallest budget
246
+ // holding two pins while asking for a third would deadlock it against
247
+ // itself. The round trip costs ~100 µs against a piece worth megabytes,
248
+ // so there is nothing to win by overlapping them.
249
+ await sendFragment(id, fragment);
250
+ }
251
+ } catch (error) {
252
+ // The end-of-read marker means "the body is complete". Sending it after a
253
+ // failure told the reader the file simply ended a truncated segment that
254
+ // ffmpeg reported as `Stream ends prematurely`, with the real cause thrown
255
+ // away. Let the error propagate instead; the command handler reports it and
256
+ // the main thread fails the stream.
257
+ failed = true;
258
+ throw error;
259
+ } finally {
260
+ readsById.delete(id);
261
+ // Any fragment still awaiting confirmation will never get one now; settling
262
+ // it here releases its pin rather than leaking a held slot.
263
+ settleFragment(id);
264
+ releaseRead();
265
+ if (!failed) {
266
+ sender.end();
267
+ }
268
+ // Nothing else to tear down: the reader owns no stream of its own, and a
269
+ // cancelled read stops at its next fragment boundary because it polls the
270
+ // same `sender` for cancellation.
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Run one command and return its result.
276
+ *
277
+ * @param {string} command
278
+ * @param {object} params
279
+ * @param {number} id
280
+ * @returns {Promise<unknown>}
281
+ */
282
+ async function runCommand(command, params, id) {
283
+ switch (command) {
284
+ case Command.ADD_SOURCE: {
285
+ // Registered before it resolves, so anything naming this source while it
286
+ // is being added waits for it instead of being told it does not exist.
287
+ // Reusing the same promise for a repeated add also collapses two callers
288
+ // racing to open the same torrent into one.
289
+ sourceRecipes.set(params.sourceKey, {
290
+ sourceType: params.sourceType,
291
+ source: params.source
292
+ });
293
+ let pending = torrentsByKey.get(params.sourceKey);
294
+ if (!pending) {
295
+ pending = pool.getTorrent(params.sourceType, params.source);
296
+ torrentsByKey.set(params.sourceKey, pending);
297
+ // A failed add must not be remembered, or every later attempt at this
298
+ // source replays the same failure. The handler also marks the rejection
299
+ // as observed, so it cannot surface as an unhandled one.
300
+ pending.catch(() => {
301
+ if (torrentsByKey.get(params.sourceKey) === pending) {
302
+ torrentsByKey.delete(params.sourceKey);
303
+ }
304
+ });
305
+ }
306
+ const torrent = await pending;
307
+ return {
308
+ infoHash: torrent.infoHash,
309
+ name: torrent.name,
310
+ // Files cross as plain data; the objects stay here.
311
+ files: (torrent.files ?? []).map((file, index) => ({
312
+ index,
313
+ name: file.name,
314
+ path: file.path,
315
+ length: file.length
316
+ }))
317
+ };
318
+ }
319
+
320
+ case Command.LIST_FILES: {
321
+ const torrent = await requireTorrent(params.sourceKey);
322
+ return (torrent.files ?? []).map((file, index) => ({
323
+ index,
324
+ name: file.name,
325
+ path: file.path,
326
+ length: file.length
327
+ }));
328
+ }
329
+
330
+ case Command.ACQUIRE_FILE: {
331
+ const torrent = await requireTorrent(params.sourceKey);
332
+ // Every acquire is its own claim. Sharing one per file meant the first
333
+ // reader to finish released the hold while others were still reading.
334
+ return fileClaims.open(
335
+ params.sourceKey,
336
+ params.fileIndex,
337
+ pool.acquireFile(torrent, params.fileIndex)
338
+ );
339
+ }
340
+
341
+ case Command.RELEASE_FILE: {
342
+ const released = fileClaims.close(params.claimId);
343
+ if (!released) {
344
+ // Not fatal but it means a release arrived twice or after teardown,
345
+ // and silence here is what let the previous scheme look healthy.
346
+ log(`release for unknown file claim ${params.claimId}`);
347
+ }
348
+ return released;
349
+ }
350
+
351
+ case Command.TORRENT_TOTALS: {
352
+ // Downloaded and uploaded are counted apart: hashing every downloaded
353
+ // byte is work of a different order from sending one back to the swarm,
354
+ // and adding them would price both at whatever the mixture happened to
355
+ // be.
356
+ let downloaded = 0;
357
+ let uploaded = 0;
358
+ for (const torrent of pool.client?.torrents ?? []) {
359
+ const gotBytes = Number(torrent?.downloaded);
360
+ const sentBytes = Number(torrent?.uploaded);
361
+ downloaded += Number.isFinite(gotBytes) ? gotBytes : 0;
362
+ uploaded += Number.isFinite(sentBytes) ? sentBytes : 0;
363
+ }
364
+ return { downloaded, uploaded };
365
+ }
366
+
367
+ case Command.SUBTITLE_TRACKS: {
368
+ const torrent = await requireTorrent(params.sourceKey);
369
+ return {
370
+ tracks: await subtitleTracksOf(torrent, params.fileIndex, params.sourceKey),
371
+ declared: await declaredSubtitleTracksOf(torrent, params.fileIndex, params.sourceKey)
372
+ };
373
+ }
374
+
375
+ case Command.FILL_FILE: {
376
+ const torrent = await requireTorrent(params.sourceKey);
377
+ return {
378
+ started: fillFileInBackground(torrent, params.fileIndex, params.sourceKey)
379
+ };
380
+ }
381
+
382
+ case Command.CONTAINER_TRACKS: {
383
+ const torrent = await requireTorrent(params.sourceKey);
384
+ return {
385
+ tracks: await containerTracksOf(torrent, params.fileIndex, params.sourceKey, {
386
+ // The header of a file nobody is playing has usually not arrived at
387
+ // all — a sidecar soundtrack is asked about before anyone has chosen
388
+ // it. Its head is a few hundred kilobytes, and without them there is
389
+ // nothing to read.
390
+ prefetchEdges: () =>
391
+ pool.prefetchFileEdges(torrent, params.fileIndex, {
392
+ headBytes: CONTAINER_HEAD_BYTES,
393
+ tailBytes: 0,
394
+ timeoutMs: 60_000
395
+ })
396
+ })
397
+ };
398
+ }
399
+
400
+ case Command.SUBTITLE_CUES: {
401
+ const torrent = await requireTorrent(params.sourceKey);
402
+ const held = await cuesHeldFor(torrent, params.fileIndex, params.sourceKey, params.trackNumber);
403
+ return {
404
+ cues: held.cues,
405
+ coveredClusters: held.coveredClusters,
406
+ indexedClusters: held.indexedClusters,
407
+ codecId: held.track?.codecId ?? "",
408
+ codecPrivate: held.track?.codecPrivate ?? "",
409
+ language: held.track?.language ?? ""
410
+ };
411
+ }
412
+
413
+ case Command.FILE_STATS: {
414
+ const torrent = await requireTorrent(params.sourceKey);
415
+ const stats = pool.getFileStats(torrent, params.fileIndex, {
416
+ resumeAnchorByteStart: params.resumeAnchorByteStart ?? null
417
+ });
418
+ // What this file's own interruptions demand, measured by the reader in
419
+ // this thread. It travels with the stats because the caller asking for
420
+ // them is the one that has to decide with them — the browser's smallest
421
+ // safe buffer, and the speed a quality step must sustain. Null until a
422
+ // second interruption has been seen: one wait shows no interval, and an
423
+ // interval invented from one point is exactly what this work removes.
424
+ const file = Array.isArray(torrent?.files) ? torrent.files[params.fileIndex] : null;
425
+ return {
426
+ ...stats,
427
+ supply: supplyFiguresFor(torrent?.infoHash, file?.name, params.segmentSeconds ?? 4)
428
+ };
429
+ }
430
+
431
+ case Command.PRIORITIZE: {
432
+ const torrent = await requireTorrent(params.sourceKey);
433
+ pool.prioritizeByteRange(torrent, params.fileIndex, params.byteStart, params.windowBytes, {
434
+ wholeFileRead: params.wholeFileRead === true
435
+ });
436
+ return true;
437
+ }
438
+
439
+ case Command.PREFETCH_EDGES: {
440
+ const torrent = await requireTorrent(params.sourceKey);
441
+ return pool.prefetchFileEdges(torrent, params.fileIndex, params.options ?? {});
442
+ }
443
+
444
+ case Command.READ_RANGE: {
445
+ // Streams its own reply; the caller's promise resolves once the body has
446
+ // been fully sent, which is what lets the client await completion.
447
+ await streamRange({
448
+ id,
449
+ sourceKey: params.sourceKey,
450
+ fileIndex: params.fileIndex,
451
+ start: params.start ?? null,
452
+ end: params.end ?? null,
453
+ windowBytes: params.windowBytes
454
+ });
455
+ return true;
456
+ }
457
+
458
+ case Command.CANCEL_READ: {
459
+ readsById.get(params.readId)?.cancel();
460
+ // A cancelled read will never have its outstanding fragment confirmed, so
461
+ // wake it here — otherwise it waits forever with a piece pinned.
462
+ settleFragment(params.readId);
463
+ return true;
464
+ }
465
+
466
+ case Command.DESTROY_ALL: {
467
+ fileClaims.closeAll();
468
+ torrentsByKey.clear();
469
+ sourceRecipes.clear();
470
+ await pool.destroyAll();
471
+ return true;
472
+ }
473
+
474
+ default:
475
+ throw new Error(`Unknown torrent-worker command: ${command}`);
476
+ }
477
+ }
478
+
479
+ parentPort.on("message", async (message) => {
480
+ // Chunk acknowledgements are not commands they release backpressure on an
481
+ // in-flight read.
482
+ if (message?.type === Event.CHUNK_ACK) {
483
+ readsById.get(message.id)?.ack();
484
+ return;
485
+ }
486
+
487
+ // The main thread has finished reading a fragment out of shared memory, so
488
+ // its piece may be unpinned and the read may continue.
489
+ if (message?.type === Event.FRAGMENT_DONE) {
490
+ settleFragment(message.id);
491
+ return;
492
+ }
493
+
494
+ const { command, id, params } = message ?? {};
495
+ try {
496
+ const result = await runCommand(command, params ?? {}, id);
497
+ parentPort.postMessage({ type: Event.RESULT, id, result });
498
+ } catch (error) {
499
+ parentPort.postMessage({ type: Event.ERROR, id, error: error?.message ?? String(error) });
500
+ }
501
+ });
502
+
503
+ /**
504
+ * How often the piece store reports what it has been doing.
505
+ *
506
+ * The store decides whether a read costs nothing or costs a disk trip, and
507
+ * until 2.9.75 nothing about it reached the log a field oddity would have had
508
+ * no evidence to work from. Reported only when something changed, so an idle
509
+ * proxy stays quiet.
510
+ */
511
+ // The torrent worker's OWN isolate, reported from inside it. The piece pool is
512
+ // a `SharedArrayBuffer` allocated here, so the main thread's counters cannot
513
+ // see it however carefully they are read — which is half the reason 650 MB of a
514
+ // 893 MB process had no explanation on 2026-08-28 (roadmap item 2).
515
+ // A second between readings, a minute between lines unless the heap moved by
516
+ // 25 MB, and a heap snapshot of THIS isolate on every new high-water above
517
+ // 400 MB. Three deaths — 2026-08-30 14:00 and 23:19, and
518
+ // 2026-08-31 13:27 went from a 30 MB heap to the 2240 MB ceiling inside one
519
+ // sixty-second gap, and the only snapshots ever written were of the main
520
+ // isolate, whose heap is 26 MB. So the isolate that dies has never once been
521
+ // looked at (roadmap item 2, `research/worker-heap-oom-2026-08-31.md`).
522
+ startMemoryReport({
523
+ log,
524
+ readStores: collectStoreStats,
525
+ scope: "thread",
526
+ label: "torrent worker",
527
+ intervalMs: WORKER_MEMORY_SAMPLE_MS,
528
+ quietMs: 60_000,
529
+ changeBytes: 25 * 1024 * 1024,
530
+ snapshotDir: workerData?.stateDir || undefined,
531
+ snapshotFloorBytes: 400 * 1024 * 1024,
532
+ snapshotGrowthBytes: 400 * 1024 * 1024,
533
+ keepSnapshots: 3
534
+ });
535
+
536
+ const STORE_REPORT_INTERVAL_MS = 60_000;
537
+
538
+ /** Last reported figures per store, so unchanged ones stay silent. */
539
+ const lastReported = new Map();
540
+
541
+ setInterval(() => {
542
+ // What the machine can spare NOW, not what it could spare when each store was
543
+ // created. With per-piece buffers a lowered ceiling is honoured immediately:
544
+ // excess pieces are evicted to disk and their memory is reclaimable.
545
+ for (const revised of reviseStoreBudgets()) {
546
+ if (revised.evicted > 0) {
547
+ log(
548
+ `piece-store "${revised.name.slice(0, 40)}": allowance is now ` +
549
+ `${Math.round(revised.ceilingBytes / 1048576)}MB, evicted ${revised.evicted} piece(s) to meet it — ` +
550
+ `now ${Math.round(revised.committedBytes / 1048576)}MB committed`
551
+ );
552
+ } else if (revised.committedBytes > revised.ceilingBytes) {
553
+ log(
554
+ `piece-store "${revised.name.slice(0, 40)}": allowance is now ` +
555
+ `${Math.round(revised.ceilingBytes / 1048576)}MB and ` +
556
+ `${Math.round(revised.committedBytes / 1048576)}MB is committed — ` +
557
+ `all resident pieces are pinned, cannot shrink yet`
558
+ );
559
+ }
560
+ }
561
+ for (const stats of collectStoreStats()) {
562
+ const signature = `${stats.fromMemory}/${stats.fromDisk}/${stats.spills}/${stats.revivals}/${stats.blockedByPins}/${stats.evictedOnRevise}/${stats.spillFailures}`;
563
+ if (lastReported.get(stats.name) === signature) {
564
+ continue;
565
+ }
566
+ lastReported.set(stats.name, signature);
567
+
568
+ const reads = stats.fromMemory + stats.fromDisk;
569
+ const fromMemoryShare = reads > 0 ? ((stats.fromMemory / reads) * 100).toFixed(1) : "—";
570
+ log(
571
+ `piece-store "${stats.name.slice(0, 40)}": resident=${stats.resident}/${stats.capacity} ` +
572
+ `(${Math.round((stats.residentBytes || 0) / 1048576)}MB of ` +
573
+ `${Math.round((stats.budgetBytes || 0) / 1048576)}MB allowed) ` +
574
+ `committed=${Math.round((stats.committedBytes || 0) / 1048576)}MB ` +
575
+ `on-disk=${Math.round((stats.spilledBytes || 0) / 1048576)}MB ` +
576
+ `pinned=${stats.pinned} spilled=${stats.spilled} reads=${reads} (${fromMemoryShare}% from memory) ` +
577
+ `spills=${stats.spills} revivals=${stats.revivals}` +
578
+ (stats.blockedByPins > 0 ? ` blocked-by-pins=${stats.blockedByPins}` : "") +
579
+ (stats.evictedOnRevise > 0 ? ` evictedOnRevise=${stats.evictedOnRevise}` : "") +
580
+ (stats.spillFailures > 0 ? ` spill-failures=${stats.spillFailures}` : "") +
581
+ (stats.outstanding > 0 ? ` outstanding=${stats.outstanding}` : "")
582
+ );
583
+ }
584
+ // Not per store: the collector is per thread, and the question it answers —
585
+ // is anything of ours outliving a piece is about the thread. Printed
586
+ // whenever the two disagree by more than the pieces actually held, which is
587
+ // the only shape worth looking at (roadmap item 2).
588
+ const collection = pieceBufferCollection();
589
+ const outstandingBuffers = collection.released - collection.collected;
590
+ const heldNow = collectStoreStats().reduce((sum, stats) => sum + (stats.resident || 0), 0);
591
+ if (outstandingBuffers > heldNow) {
592
+ log(
593
+ `piece buffers: ${collection.released} let go, ${collection.collected} collected — ` +
594
+ `${outstandingBuffers} still alive against ${heldNow} the store holds. ` +
595
+ "A gap that keeps widening means a reference of ours outlives the piece; " +
596
+ "a gap that does not means whatever grows is below us, in the allocator"
597
+ );
598
+ }
599
+ }, STORE_REPORT_INTERVAL_MS).unref();
600
+
601
+ /**
602
+ * Walk subtitle cues for every actively-read file of one torrent, and PUSH
603
+ * whatever came out new to the main thread — which is what makes a browser's
604
+ * copy current without it having asked.
605
+ *
606
+ * @param {string} sourceKey
607
+ * @param {object} torrent
608
+ * @returns {void}
609
+ */
610
+ function warmActiveFiles(sourceKey, torrent) {
611
+ const usage = pool.fileUsageByTorrent.get(torrent);
612
+ if (!usage) {
613
+ return;
614
+ }
615
+ for (const fileIndex of usage.keys()) {
616
+ const key = `${sourceKey}:${fileIndex}`;
617
+ // A trigger that arrives while the previous pass is still walking is
618
+ // dropped, not queued. `verified` fires per piece, so on a fast download
619
+ // these arrive many times a second; the walk is serialized per file anyway,
620
+ // and a queue of identical passes would only postpone the one that has
621
+ // something new to find.
622
+ if (warmupInFlight.has(key)) {
623
+ continue;
624
+ }
625
+ warmupInFlight.add(key);
626
+ warmSubtitleCues(torrent, fileIndex, sourceKey)
627
+ .then((fresh) => {
628
+ for (const entry of fresh) {
629
+ const span = entry.spanStartSeconds === null
630
+ ? "empty"
631
+ : `${entry.spanStartSeconds.toFixed(1)}-${entry.spanEndSeconds.toFixed(1)}s`;
632
+ log(
633
+ `subtitle push ${sourceKey.slice(0, 8)}:${fileIndex} track ${entry.trackIndex}: ` +
634
+ `${entry.cues.length} new cue(s) covering ${span}, ` +
635
+ `clusters walked ${entry.walkedClusters}/${entry.indexedClusters}, cursor ${entry.cursor}, ` +
636
+ "posting to main thread"
637
+ );
638
+ parentPort.postMessage({
639
+ type: Event.SUBTITLE_CUES_READY,
640
+ sourceKey,
641
+ fileIndex,
642
+ trackIndex: entry.trackIndex,
643
+ cues: entry.cues,
644
+ language: entry.language,
645
+ cursor: entry.cursor
646
+ });
647
+ }
648
+ })
649
+ .catch((error) => {
650
+ log(`subtitle warmup ${sourceKey}:${fileIndex} failed: ${error instanceof Error ? error.message : error}`);
651
+ })
652
+ .finally(() => {
653
+ warmupInFlight.delete(key);
654
+ });
655
+ }
656
+ }
657
+
658
+ /**
659
+ * Files whose warmup pass has not finished yet, by `sourceKey:fileIndex`.
660
+ *
661
+ * @type {Set<string>}
662
+ */
663
+ const warmupInFlight = new Set();
664
+
665
+ /**
666
+ * Torrents already wired to warm their subtitle cues the moment a piece
667
+ * verifies, so the same torrent is not listened to twice.
668
+ *
669
+ * @type {WeakSet<object>}
670
+ */
671
+ const subtitleWarmupWired = new WeakSet();
672
+
673
+ /**
674
+ * A piece becoming readable is the actual event a cue can be pulled from —
675
+ * "downloaded", not "about to be encoded or copied": what a viewer reaches is
676
+ * decided by the read window ahead of the playhead, not by which of the two
677
+ * paths a segment takes, and the piece exists (and is worth reading for
678
+ * subtitles) whichever one that is. `verified` is WebTorrent's own signal for
679
+ * exactly that instant, set at the same place the bitfield itself is (`
680
+ * _markVerified`), so nothing here is guessing at readiness a different way.
681
+ *
682
+ * @param {string} sourceKey
683
+ * @param {object} torrent
684
+ * @returns {void}
685
+ */
686
+ function ensureSubtitleWarmupWired(sourceKey, torrent) {
687
+ if (subtitleWarmupWired.has(torrent)) {
688
+ return;
689
+ }
690
+ subtitleWarmupWired.add(torrent);
691
+ torrent.on("verified", () => warmActiveFiles(sourceKey, torrent));
692
+ }
693
+
694
+ /**
695
+ * How often an actively-read file's subtitle cues are walked ahead of being
696
+ * asked for, as a fallback beside the per-piece `verified` listener above —
697
+ * catches a listener attached after pieces already verified, and anything the
698
+ * event path might otherwise miss. Cheap once caught up (`warmSubtitleCues`
699
+ * skips clusters it has already read).
700
+ */
701
+ const SUBTITLE_WARMUP_INTERVAL_MS = 3_000;
702
+
703
+ setInterval(() => {
704
+ for (const [sourceKey, torrent] of pool.torrents) {
705
+ ensureSubtitleWarmupWired(sourceKey, torrent);
706
+ warmActiveFiles(sourceKey, torrent);
707
+ }
708
+ }, SUBTITLE_WARMUP_INTERVAL_MS).unref();
709
+
710
+ /**
711
+ * Keeps this thread alive ON PURPOSE — the one interval left accounted for
712
+ * (no `.unref()`).
713
+ *
714
+ * Every other recurring handle here is unref'd, upload is disabled by
715
+ * default, and idle peer connections close about half a minute after the
716
+ * traffic stops so once nothing is being read, every handle can be gone
717
+ * at once, the event loop drains, and this thread ends BY ITSELF. Node then
718
+ * tears the isolate down, that teardown touches memory some native module
719
+ * has already freed, and the fault (SIGSEGV inside `uv_timer_stop`, reached
720
+ * through `PerIsolatePlatformData::Shutdown`) kills the whole process at
721
+ * once HTTP server, tunnel, data channels before any JS handler runs.
722
+ * Field evidence: two deaths on 2026-08-22 (15:50:12 and 16:12:21 UTC),
723
+ * each ~35 s after the last byte of traffic, identical core dumps;
724
+ * same crash family as the utp-native faults of 2026-08-18..21
725
+ * (`research/worker-thread-drain-crash-2026-08-22.md`).
726
+ *
727
+ * An empty repeating interval costs nothing, keeps the loop from draining
728
+ * while the process lives, and thereby keeps that teardown path — and the
729
+ * corrupted structure inside it — unreachable, whichever module is guilty.
730
+ */
731
+ const WORKER_KEEPALIVE_INTERVAL_MS = 5_000;
732
+
733
+ setInterval(() => {
734
+ void process.uptime();
735
+ }, WORKER_KEEPALIVE_INTERVAL_MS);
736
+
737
+ /**
738
+ * Say why this thread is ending, from inside it, before anything is torn down.
739
+ *
740
+ * The crash of 2026-08-27 15:40 is `node::worker::Worker::Run()` returning and
741
+ * node faulting as it closes what was left on this loop. The parent DOES watch
742
+ * for an unexpected exit and has a line ready for it but that line never
743
+ * printed, because the fault happens during this thread's own teardown, before
744
+ * the parent's `exit` event is delivered. So the one reading that would name
745
+ * the cause was being eaten by the failure it was meant to explain.
746
+ *
747
+ * These handlers run first, synchronously, and write through the same channel
748
+ * as every other line here. `beforeExit` means the loop drained despite the
749
+ * keepalive above; `exit` means the thread is going whatever the reason. The
750
+ * list of what was still holding the loop open is the part that says which.
751
+ */
752
+ process.on("beforeExit", (code) => {
753
+ log(
754
+ `thread is about to end because the event loop drained (code ${code}) — ` +
755
+ `still open: ${describeActiveResources()}`
756
+ );
757
+ });
758
+
759
+ process.on("exit", (code) => {
760
+ log(`thread ending with code ${code} — still open: ${describeActiveResources()}`);
761
+ });
762
+
763
+ process.on("uncaughtException", (error) => {
764
+ log(`thread hit an uncaught error: ${error?.stack ?? error}`);
765
+ });
766
+
767
+ process.on("unhandledRejection", (reason) => {
768
+ log(`thread hit an unhandled rejection: ${reason?.stack ?? reason}`);
769
+ });
770
+
771
+ /**
772
+ * What is still holding this thread's event loop open, as node names it.
773
+ *
774
+ * @returns {string} A tally per resource kind, or why it could not be read.
775
+ */
776
+ function describeActiveResources() {
777
+ try {
778
+ const names = process.getActiveResourcesInfo?.() ?? [];
779
+ if (names.length === 0) {
780
+ return "nothing";
781
+ }
782
+ const tally = new Map();
783
+ for (const name of names) {
784
+ tally.set(name, (tally.get(name) ?? 0) + 1);
785
+ }
786
+ return [...tally].map(([name, count]) => `${name}x${count}`).join(" ");
787
+ } catch (error) {
788
+ return `unreadable (${error?.message ?? error})`;
789
+ }
790
+ }
791
+
792
+ log("torrent worker started");