@torrent-tv/proxy 2.83.3 → 2.83.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/package.json +1 -1
  3. package/research/handover-reader-claims-removal-2026-09-12.md +166 -0
  4. package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -0
  5. package/research/priority-map-is-the-truth-2026-09-12.md +225 -0
  6. package/routes/api/sources/files/get.js +23 -9
  7. package/routes/api/sources/warm/post.js +26 -22
  8. package/routes/api/transcode-sessions/post.js +1 -49
  9. package/routes/stream/get.js +2 -36
  10. package/services/controllers/SubtitleController.js +0 -3
  11. package/services/download/withdraw-claim.js +80 -0
  12. package/services/hls-session-manager.js +17 -110
  13. package/services/orchestrators/EncodeOrchestrator.js +133 -0
  14. package/services/piece-store/piece-disk-store.js +24 -1
  15. package/services/piece-store/shared-piece-store.js +71 -1
  16. package/services/playback-planner.js +12 -13
  17. package/services/priority/PriorityOrchestrator.js +40 -6
  18. package/services/torrent/Contents.js +324 -0
  19. package/services/torrent/files.js +8 -1
  20. package/services/torrent-pool.js +378 -102
  21. package/services/torrent-worker/client.js +0 -24
  22. package/services/torrent-worker/piece-reader.js +30 -1
  23. package/services/torrent-worker/pool-adapter.js +3 -33
  24. package/services/torrent-worker/protocol.js +0 -4
  25. package/services/torrent-worker/worker.js +60 -60
  26. package/test/file-edges.test.js +191 -0
  27. package/test/input-lost-quiets-the-plan.test.js +261 -0
  28. package/test/logger-repeats.test.js +120 -0
  29. package/test/priority-map-emptied.test.js +207 -0
  30. package/test/read-survives-withdrawal.test.js +164 -0
  31. package/test/source-files-route.test.js +103 -0
  32. package/test/stream-route.test.js +4 -8
  33. package/test/swarm-follows-readers.test.js +96 -14
  34. package/test/torrent-contents.test.js +235 -0
  35. package/test/upload-hurry.test.js +66 -28
  36. package/test/withdraw-piece-claim.test.js +212 -0
  37. package/utils/logger.js +105 -7
  38. package/services/torrent-worker/file-claims.js +0 -91
  39. package/test/file-claims.test.js +0 -64
@@ -1,3 +1,5 @@
1
+ import { contentsOf } from "../../../../services/torrent/Contents.js";
2
+
1
3
  /**
2
4
  * List the files of a registered source (torrent file OR magnet).
3
5
  *
@@ -70,17 +72,29 @@ export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torr
70
72
  }
71
73
 
72
74
  const torrent = result;
73
- const files = (torrent.files ?? []).map((file, index) => ({
74
- index,
75
- name: file?.name ?? "",
76
- // Path relative to the torrent root (matches the browser's own parser).
77
- relativePath: file?.path ?? file?.name ?? "",
78
- length: Number.isFinite(file?.length) ? file.length : 0
79
- }));
80
-
75
+ // WHAT IS IN THIS TORRENT, decided here and nowhere else. The browser used to
76
+ // decide it again — a list of video extensions in its parser and a second,
77
+ // shorter pair inside its picker — and the three answers had already diverged
78
+ // (measured 2026-09-12: `.dat` was offered there and not counted here, which
79
+ // also decides whether a sidecar whose name matches nothing can belong to the
80
+ // only video present). It ships the paths already relative to the torrent
81
+ // root, so there is no stripping rule on the other side either.
82
+ const contents = contentsOf(torrent);
81
83
  return reply.send({
82
84
  name: torrent.name ?? "",
83
85
  infoHash: torrent.infoHash ?? "",
84
- files
86
+ // In the order a person reads them — by folder, then by name, with runs of
87
+ // digits compared as numbers. A torrent's own order is whatever the tool
88
+ // that made it chose, and it is routinely by size.
89
+ files: contents.files(),
90
+ // The pictures, each with what belongs to it. By index, because the files
91
+ // themselves are in the list above and saying them twice is how two copies
92
+ // of one fact start.
93
+ items: contents.items.map((item) => ({
94
+ fileIndex: item.fileIndex,
95
+ audio: item.audio.map((part) => part.fileIndex),
96
+ subtitles: item.subtitles.map((part) => part.fileIndex),
97
+ images: item.images.map((part) => part.fileIndex)
98
+ }))
85
99
  });
86
100
  }
@@ -1,9 +1,6 @@
1
1
  import { logger } from "../../../../utils/logger.js";
2
- import {
3
- countVideoFiles,
4
- matchSidecarFiles,
5
- TEXT_SUBTITLE_SIDECAR_EXTENSIONS
6
- } from "../../../../services/torrent/files.js";
2
+ import { TEXT_SUBTITLE_SIDECAR_EXTENSIONS } from "../../../../services/torrent/files.js";
3
+ import { contentsOf } from "../../../../services/torrent/Contents.js";
7
4
 
8
5
  /**
9
6
  * Start fetching a source before anyone asks to play it.
@@ -70,17 +67,26 @@ export async function handleApiSourceWarmPost(req, reply, { sourceRegistry, torr
70
67
  return reply.send({ started: false, swarm: false, edges: false });
71
68
  }
72
69
 
73
- // The edges are only worth fetching once it is known WHICH file will be
74
- // played: on a season pack, warming twenty episodes' worth would spend the
75
- // pool owner's bandwidth on nineteen files nobody opened. The caller passes
76
- // an index when the torrent holds a single video, and again later if it
77
- // wants to.
70
+ // WHICH FILE THIS IS ABOUT. The caller names one when it knows a torrent
71
+ // with a single video, or an episode the viewer has settled on — and on a
72
+ // season pack it names none, because nobody has chosen yet.
73
+ //
74
+ // Then the first item of the torrent stands in for the choice. One item, not
75
+ // twenty: warming a whole pack's worth of edges would spend the pool owner's
76
+ // bandwidth on nineteen files nobody opened, while one item is two pieces and
77
+ // is also the likeliest pick. It is stated as something nobody is waiting
78
+ // for, so on a proxy serving somebody else it costs nothing at all until
79
+ // their own film has everything it needs.
80
+ const contents = contentsOf(torrent);
81
+ const named = fileIndex !== null && torrent.files?.[fileIndex] ? fileIndex : null;
82
+ const candidate = named ?? contents.items[0]?.fileIndex ?? null;
83
+
78
84
  let edges = false;
79
- if (fileIndex !== null && torrent.files?.[fileIndex]) {
85
+ if (candidate !== null) {
80
86
  edges = true;
81
87
  // Deliberately not awaited: this is the multi-second part, and the point of
82
88
  // the whole route is that the viewer goes on choosing while it happens.
83
- Promise.resolve(torrentPool.prefetchFileEdges(torrent, fileIndex)).catch((error) => {
89
+ Promise.resolve(torrentPool.prefetchFileEdges(torrent, candidate)).catch((error) => {
84
90
  const message = error instanceof Error ? error.message : String(error);
85
91
  logger.warn(`warm ${sourceKey.slice(0, 8)}: file edges failed: ${message}`);
86
92
  });
@@ -88,8 +94,8 @@ export async function handleApiSourceWarmPost(req, reply, { sourceRegistry, torr
88
94
  // the playback plan, so they must not queue behind a region nobody is
89
95
  // reading yet. This one only has to arrive before the encoder does, and the
90
96
  // encoder is a plan and a session away.
91
- if (positionSeconds > 0 && typeof torrentPool.warmResumePosition === "function") {
92
- Promise.resolve(torrentPool.warmResumePosition(torrent, fileIndex, positionSeconds)).catch(
97
+ if (named !== null && positionSeconds > 0 && typeof torrentPool.warmResumePosition === "function") {
98
+ Promise.resolve(torrentPool.warmResumePosition(torrent, named, positionSeconds)).catch(
93
99
  (error) => {
94
100
  const message = error instanceof Error ? error.message : String(error);
95
101
  logger.warn(`warm ${sourceKey.slice(0, 8)}: the viewer's position failed: ${message}`);
@@ -116,13 +122,8 @@ export async function handleApiSourceWarmPost(req, reply, { sourceRegistry, torr
116
122
  // does. The rest of it is fetched when it is played, and nothing here spends
117
123
  // the pool owner's bandwidth on a track nobody chose.
118
124
  let sidecars = 0;
119
- if (fileIndex !== null && Array.isArray(torrent.files)) {
120
- const matched = matchSidecarFiles({
121
- files: torrent.files,
122
- videoIndex: fileIndex,
123
- torrentName: typeof torrent.name === "string" ? torrent.name : "",
124
- videoCount: countVideoFiles(torrent.files)
125
- });
125
+ if (candidate !== null && Array.isArray(torrent.files)) {
126
+ const matched = contents.sidecarsOf(candidate);
126
127
  const warmOne = (file, options) => {
127
128
  sidecars += 1;
128
129
  // Not awaited, like the picture's own edges above: the point of this route
@@ -162,7 +163,10 @@ export async function handleApiSourceWarmPost(req, reply, { sourceRegistry, torr
162
163
 
163
164
  logger.info(
164
165
  `warm ${sourceKey.slice(0, 8)}: swarm started for "${torrent.name}"` +
165
- (edges ? `, fetching the edges of file ${fileIndex}` : ", file not chosen yet") +
166
+ (edges
167
+ ? `, fetching the edges of file ${candidate}` +
168
+ (named === null ? " — the first item, since nothing is chosen yet" : "")
169
+ : ", and it holds nothing to fetch the edges of") +
166
170
  (sidecars > 0 ? ` and of ${sidecars} file(s) beside it` : "")
167
171
  );
168
172
 
@@ -25,39 +25,6 @@ function getPayload(body) {
25
25
  return {};
26
26
  }
27
27
 
28
- /**
29
- * Claim the source's file so the pool cannot clean it up under a live session.
30
- *
31
- * Asynchronous underneath — the torrent lives on another thread — but the
32
- * caller needs a release function immediately, so the claim is chased and the
33
- * release waits for it.
34
- *
35
- * @param {{ sourceRegistry: object, torrentPool: object, sourceKey: string, fileIndex: number }} params
36
- * @returns {() => void}
37
- */
38
- function holdSource({ sourceRegistry, torrentPool, sourceKey, fileIndex }) {
39
- let release = null;
40
- let releasedEarly = false;
41
- const record = sourceRegistry?.get?.(sourceKey);
42
- if (!record) {
43
- return () => {};
44
- }
45
- void Promise.resolve(torrentPool.getTorrent(record.sourceType, record.source))
46
- .then((torrent) => {
47
- release = torrentPool.acquireFile(torrent, fileIndex);
48
- if (releasedEarly) {
49
- release();
50
- }
51
- })
52
- .catch(() => {});
53
- return () => {
54
- releasedEarly = true;
55
- if (typeof release === "function") {
56
- release();
57
- release = null;
58
- }
59
- };
60
- }
61
28
 
62
29
  export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionManager, sourceRegistry, torrentPool }) {
63
30
  const payload = getPayload(req.body);
@@ -108,22 +75,7 @@ export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionMan
108
75
  : 0,
109
76
  audioTrackIndex:
110
77
  Number.isInteger(audioTrackIndex) && audioTrackIndex > 0 ? audioTrackIndex : 0,
111
- segmentFormatId,
112
- // Hold the torrent for as long as this session lives. Reads take a claim
113
- // only while they run, and a seek leaves a gap with no read at all — the
114
- // disk sweep caught that gap on 2026-08-06 and deleted the film being
115
- // watched.
116
- // Takes the file to hold, because a session does not always read the file
117
- // it was created for: a release whose dub ships as its own file gives that
118
- // soundtrack a session of its own, reading a different index of the same
119
- // torrent. Defaults to the picture, which is every other case.
120
- acquireSource: (heldFileIndex = fileIndex) =>
121
- holdSource({
122
- sourceRegistry,
123
- torrentPool,
124
- sourceKey,
125
- fileIndex: Number.isInteger(heldFileIndex) ? heldFileIndex : fileIndex
126
- })
78
+ segmentFormatId
127
79
  });
128
80
  // The index of quality variants, when this session has more than one to
129
81
  // offer. Its presence is what tells the browser it can change quality
@@ -208,8 +208,6 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
208
208
  return;
209
209
  }
210
210
 
211
- const releaseFile = torrentPool.acquireFile(torrent, fileIndex);
212
-
213
211
  const range = parseRange(req.headers.range, file.length);
214
212
  // Prioritize the pieces at this read position so a seek (a request at a new
215
213
  // byte offset) downloads first instead of waiting behind the sequential
@@ -343,8 +341,6 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
343
341
  logger.warn(line);
344
342
  }
345
343
  reply.raw.destroy();
346
- } finally {
347
- releaseFile();
348
344
  }
349
345
  return;
350
346
  }
@@ -355,41 +351,11 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
355
351
 
356
352
  if (!range) {
357
353
  reply.header("Content-Length", String(file.length));
358
- const stream = file.createReadStream();
359
- bindRelease(stream, reply, releaseFile);
360
- return reply.send(stream);
354
+ return reply.send(file.createReadStream());
361
355
  }
362
356
 
363
357
  reply.code(206);
364
358
  reply.header("Content-Length", String(contentLength));
365
359
  reply.header("Content-Range", `bytes ${start}-${end}/${file.length}`);
366
- const stream = file.createReadStream({ start, end });
367
- bindRelease(stream, reply, releaseFile);
368
- return reply.send(stream);
369
- }
370
-
371
- /**
372
- * Attach event listeners that release the file reference exactly once when
373
- * the stream or the underlying HTTP connection closes.
374
- *
375
- * @param {import("node:stream").Readable} stream
376
- * @param {import("fastify").FastifyReply} reply
377
- * @param {() => void} release
378
- * @returns {void}
379
- */
380
- function bindRelease(stream, reply, release) {
381
- let released = false;
382
- const releaseOnce = () => {
383
- if (released) {
384
- return;
385
- }
386
- released = true;
387
- release();
388
- };
389
-
390
- stream.on("close", releaseOnce);
391
- stream.on("end", releaseOnce);
392
- stream.on("error", releaseOnce);
393
- reply.raw.once("close", releaseOnce);
394
- reply.raw.once("finish", releaseOnce);
360
+ return reply.send(file.createReadStream({ start, end }));
395
361
  }
@@ -51,7 +51,6 @@ export class SubtitleController {
51
51
  if (!hasTrack) {
52
52
  const name = file.name ?? "";
53
53
  const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
54
- const release = this.torrentPool.acquireFile(torrent, fileIndex);
55
54
  try {
56
55
  const bytes = await readFileFully(file, EXTERNAL_MAX_BYTES);
57
56
  const text = SubtitleFileContainer.decodeBytes(bytes);
@@ -65,8 +64,6 @@ export class SubtitleController {
65
64
  return { vtt, language: TextSubtitleTrack.detectLanguageFromVtt(vtt), headers: {} };
66
65
  } catch (e) {
67
66
  return { error: `Could not read subtitle file: ${e?.message ?? e}`, status: 502 };
68
- } finally {
69
- release();
70
67
  }
71
68
  }
72
69
 
@@ -0,0 +1,80 @@
1
+ /**
2
+ * @file Withdrawing the claim that this proxy has a piece.
3
+ *
4
+ * **The fact has one owner and it is the store.** The store holds the bytes, so
5
+ * it says what this proxy has. The library keeps a second copy of that fact in
6
+ * its completion bitfield, and until 2026-09-12 nothing reconciled the two: the
7
+ * disk tier drops a piece once every reader is past it — correctly, and that is
8
+ * what bounds the spill — while the bitfield went on saying the piece was
9
+ * verified. A read then concluded the piece was had, asked for it, was told it
10
+ * was absent, and failed. Nor was it ever fetched again, because the library
11
+ * does not download what it believes it already owns.
12
+ *
13
+ * Field 2026-09-12: a film played 80 seconds; the encoder ran on to 725 s, so
14
+ * some 565 spilled pieces fell behind every read head and were dropped,
15
+ * including piece 0; the encoder then lost its input and restarted, which
16
+ * re-opens the input at byte 0; and `/stream` answered `0 of 2363497962 bytes:
17
+ * Piece 0 is verified but absent from the store` to every read for the next 92
18
+ * minutes while the browser retried one segment and the picture stood still.
19
+ *
20
+ * So the eviction's own bargain — "a seek back re-downloads it" — is made true
21
+ * here, and the bitfield becomes a projection of what the store holds.
22
+ *
23
+ * **Why it is a plain function.** It takes the values it needs and holds
24
+ * nothing: no pool, no store, no client. That is what lets it be exercised with
25
+ * an object literal, and it is the rule the layers are checked against — a layer
26
+ * must be usable with plain values alone, with no process, no disk and no clock.
27
+ */
28
+
29
+ /**
30
+ * Tell the library it no longer has a piece, so the next read of it waits for a
31
+ * download instead of failing.
32
+ *
33
+ * **What it deliberately does not do.** The library's `_markUnverified` would
34
+ * also re-select the piece; it does not here, because every torrent is added
35
+ * with `deselect: true`, which sets the library's own `_startAsDeselected` and
36
+ * makes it skip that call. The download set has exactly one owner —
37
+ * `SwarmSelection`, from the priority map — and a withdrawn piece is fetched
38
+ * again when a read states it, which is the same statement every other piece
39
+ * waits on.
40
+ *
41
+ * @param {object} what
42
+ * @param {number} what.index - The piece the store can no longer produce.
43
+ * @param {object[]} [what.files] - The store's own files; the torrent that owns
44
+ * them is found through one of them, because the store has no idea what a
45
+ * torrent is.
46
+ * @param {object} [what.torrent] - Given directly instead of through `files`.
47
+ * @param {(line: string) => void} [what.warn] - Said when the library refuses.
48
+ * @returns {"withdrawn" | "nothing-to-withdraw" | "no-torrent" | "refused"}
49
+ */
50
+ export function withdrawClaim({ index, files, torrent, warn = () => undefined }) {
51
+ if (!Number.isInteger(index) || index < 0) {
52
+ return "nothing-to-withdraw";
53
+ }
54
+ const owner = torrent ?? files?.[0]?._torrent ?? null;
55
+ // A torrent being destroyed is the ordinary case at the end of a session, and
56
+ // it has no claim left to withdraw: the store is going with it.
57
+ if (!owner || owner.destroyed || typeof owner._markUnverified !== "function") {
58
+ return "no-torrent";
59
+ }
60
+ // NOTHING TO WITHDRAW, which is most of the time: the store also drops pieces
61
+ // it never completed, and marking one unverified that the library already
62
+ // knows is missing would re-create the piece and discard whatever blocks are
63
+ // in flight for it.
64
+ if (!owner.bitfield?.get?.(index)) {
65
+ return "nothing-to-withdraw";
66
+ }
67
+ try {
68
+ owner._markUnverified(index);
69
+ } catch (error) {
70
+ // This reaches into the library's own bookkeeping. If a later version
71
+ // changes it, the honest result is a line saying so rather than an eviction
72
+ // that fails.
73
+ warn(
74
+ `could not withdraw the claim on piece ${index} of ${owner.name ?? "?"}: ` +
75
+ `${error?.message ?? error}`
76
+ );
77
+ return "refused";
78
+ }
79
+ return "withdrawn";
80
+ }
@@ -441,13 +441,6 @@ const START_FAST_FAIL_MS = 2_000;
441
441
  // for whatever residual case still fails — not a second competing "fix" that
442
442
  // blindly retries the identical command hoping for a different result.
443
443
  const MAX_FAILED_STARTS = 3;
444
- // A run that lost its INPUT is retried rather than condemned: the torrent can
445
- // be added again and the pieces downloaded again, so the data being gone is a
446
- // wait, not a verdict. Backed off so a source that is truly unavailable costs a
447
- // process every few seconds rather than continuously, and never given up on —
448
- // the session's own idle TTL is what ends it if the viewer leaves.
449
- const INPUT_RETRY_BASE_MS = 2_000;
450
- const INPUT_RETRY_MAX_MS = 15_000;
451
444
  // Idle TTL: a session is disposed this long after the last segment/playlist
452
445
  // access. Long enough that a viewer who pauses, backgrounds the tab, or briefly
453
446
  // turns the phone off can resume WITHOUT a cold ffmpeg restart (the warm session
@@ -1577,6 +1570,11 @@ export class HlsSessionManager {
1577
1570
  contentionPenalties: this.contentionPenalties,
1578
1571
  startingSpeedFor: (address) => this.encodeCost.speedForOutput(address),
1579
1572
  segmentStore: this.segmentStore,
1573
+ // HOW IT ASKS TO DECIDE AGAIN. A plan that refuses to place anything
1574
+ // because an output's input is away needs something to bring it back:
1575
+ // nothing about the state changes while the data is missing, so no event
1576
+ // arrives on its own.
1577
+ planSoon: () => this.planEncodersSoon(),
1580
1578
  logger
1581
1579
  });
1582
1580
  // What a start and a stop were measured to cost here, before any viewer
@@ -1680,12 +1678,7 @@ export class HlsSessionManager {
1680
1678
  // keyframe times and which container they were read from. Present only for
1681
1679
  // a variant of a session cut at the source's keyframes, and it is what
1682
1680
  // makes the two interchangeable.
1683
- inheritedGrid = null,
1684
- // Called once for a session that is actually created, and expected to
1685
- // return a function that lets the source go. It is what keeps the torrent's
1686
- // data alive for as long as a viewer has a session on it — see
1687
- // disposeSession.
1688
- acquireSource = null
1681
+ inheritedGrid = null
1689
1682
  }) {
1690
1683
  if (!this.enabled) {
1691
1684
  const error = new Error("Audio transcoding is disabled on this proxy.");
@@ -2540,40 +2533,6 @@ export class HlsSessionManager {
2540
2533
  lastLoggedAt: 0
2541
2534
  }
2542
2535
  };
2543
- // Kept so a variant of this session can take its own hold on the same
2544
- // source: a variant is another encode of the same file and must keep the
2545
- // torrent's data alive exactly as this one does.
2546
- session.acquireSource = typeof acquireSource === "function" ? acquireSource : null;
2547
- if (typeof acquireSource === "function") {
2548
- // One claim per file this session READS. Almost always that is one file;
2549
- // a muxed session whose soundtrack ships beside the picture reads two, and
2550
- // holding only the picture would leave the sound to be swept off the disk
2551
- // from under a running encoder.
2552
- const claim = (heldFileIndex) => {
2553
- try {
2554
- const release = acquireSource(heldFileIndex);
2555
- return typeof release === "function" ? release : null;
2556
- } catch {
2557
- return null;
2558
- }
2559
- };
2560
- const releases = [claim(undefined)];
2561
- if (audioInputUrl) {
2562
- releases.push(claim(audioSource.fileIndex));
2563
- }
2564
- const held = releases.filter((release) => typeof release === "function");
2565
- session.releaseSource = held.length > 0
2566
- ? () => {
2567
- for (const release of held) {
2568
- try {
2569
- release();
2570
- } catch {
2571
- // Best effort — a session must always finish being disposed.
2572
- }
2573
- }
2574
- }
2575
- : null;
2576
- }
2577
2536
  // The viewer who asked for this session, so a browser that names itself
2578
2537
  // never has to have requested a segment first for its own soundtrack choice
2579
2538
  // to be known — nor for its own POSITION to be known, which is the same
@@ -5748,33 +5707,19 @@ export class HlsSessionManager {
5748
5707
  // condemn a session whose data merely went away.
5749
5708
  if (ended.ending === ENCODE_EXIT.INPUT_LOST) {
5750
5709
  session.inputRetryCount = (session.inputRetryCount ?? 0) + 1;
5751
- const delayMs = Math.min(
5752
- INPUT_RETRY_MAX_MS,
5753
- INPUT_RETRY_BASE_MS * 2 ** Math.min(session.inputRetryCount - 1, 6)
5754
- );
5755
5710
  logger.warn(
5756
5711
  `transcode ${session.id} encode-run #${ended.from}..#${ended.to} lost its input ` +
5757
- `(${session.lastError}); retrying in ${Math.round(delayMs / 1000)}s ` +
5758
- `(attempt ${session.inputRetryCount})`
5712
+ `(${session.lastError}) (attempt ${session.inputRetryCount})`
5759
5713
  );
5760
- session.inputRetryTimer = setTimeout(() => {
5761
- session.inputRetryTimer = null;
5762
- if (run.state !== ENCODE_RUN_STATE.RETRY_WAIT) {
5763
- return;
5764
- }
5714
+ // HOW LONG TO WAIT IS THE PLAN'S, and this says only what happened. The
5715
+ // delay used to be timed here, against the dead run, which the plan never
5716
+ // consults — so it placed a fresh run at the same spot as fast as ffmpeg
5717
+ // could fail there: 2432 starts in 23 minutes in the field 2026-09-12,
5718
+ // against a delay that had reached its 15 s ceiling long before. The
5719
+ // orchestrator holds it now, beside the decision it governs.
5720
+ if (run.state === ENCODE_RUN_STATE.RETRY_WAIT) {
5765
5721
  run.retryDue();
5766
- // The data may be back, which is a reason to DECIDE again and not a
5767
- // decision. Where to start is the plan's, from where the viewers are;
5768
- // this used to start one at the segment last requested, which is the
5769
- // player's read head rather than anybody's position, and is a number
5770
- // requests are explicitly not allowed to steer an encoder by.
5771
- //
5772
- // What the delay is for stays: the plan is a function of the state, and
5773
- // nothing about the state changes while the torrent is away, so it would
5774
- // command the same start as fast as ffmpeg could fail.
5775
- this.planEncodersSoon();
5776
- }, delayMs);
5777
- session.inputRetryTimer.unref?.();
5722
+ }
5778
5723
  return;
5779
5724
  }
5780
5725
  // A run that exits THIS fast never did real work: it failed at the start
@@ -7686,14 +7631,6 @@ export class HlsSessionManager {
7686
7631
  * @returns {void}
7687
7632
  */
7688
7633
  #stopEncodeRun(session, reason) {
7689
- // Everything armed to re-decide on this session's behalf. A stop that
7690
- // leaves the input-retry timer running is not a stop: it fires seconds
7691
- // later and has the plan asked again for a session nobody is watching, and
7692
- // torrent starvation, which is what arms it, is routine here.
7693
- if (session.inputRetryTimer) {
7694
- clearTimeout(session.inputRetryTimer);
7695
- session.inputRetryTimer = null;
7696
- }
7697
7634
  const running = liveRunsOf(session);
7698
7635
  if (running.length === 0) {
7699
7636
  return;
@@ -7839,8 +7776,7 @@ export class HlsSessionManager {
7839
7776
  // creations (field 2026-08-17, corrections of 0.6-2.9 s).
7840
7777
  published: base.timeline.published
7841
7778
  }
7842
- : null,
7843
- acquireSource: base.acquireSource
7779
+ : null
7844
7780
  })
7845
7781
  .then(async (variant) => {
7846
7782
  // Making a session takes seconds — a probe and a keyframe index — and
@@ -8628,15 +8564,7 @@ export class HlsSessionManager {
8628
8564
  boundaries: base.timeline.boundaries,
8629
8565
  published: base.timeline.published
8630
8566
  }
8631
- : null,
8632
- // Hold the file this rendition will READ. For a soundtrack shipped beside
8633
- // the picture that is a different file of the same torrent, and nothing
8634
- // else claims it: the base holds the picture, and the disk sweep deletes
8635
- // what nobody is holding — which is how a film being watched was deleted
8636
- // on 2026-08-06.
8637
- acquireSource: () => base.acquireSource?.(
8638
- this.#resolveAudioSource(base.file.sourceKey, base.file.fileIndex, trackIndex).fileIndex
8639
- )
8567
+ : null
8640
8568
  });
8641
8569
  return rendition ?? null;
8642
8570
  }
@@ -10017,27 +9945,6 @@ export class HlsSessionManager {
10017
9945
  }
10018
9946
  }
10019
9947
 
10020
- // Let go of the source. While a session exists its torrent must survive
10021
- // both cleanups the pool runs, and until now neither knew about it: the
10022
- // only claim on a file is taken by a READ, and during a seek there is no
10023
- // read at all — the old encoder is dead and the new one has not started.
10024
- // Field 2026-08-06, that window met the thirty-second disk sweep and the
10025
- // film being watched was evicted mid-seek, six gigabytes deleted, after
10026
- // which the new encoder had nothing to read. The session's own thirty
10027
- // minutes governed the session, never the data under it.
10028
- if (typeof session.releaseSource === "function") {
10029
- try {
10030
- session.releaseSource();
10031
- } catch {
10032
- // Best effort — a session must always finish being disposed.
10033
- }
10034
- session.releaseSource = null;
10035
- }
10036
-
10037
- if (session.inputRetryTimer) {
10038
- clearTimeout(session.inputRetryTimer);
10039
- session.inputRetryTimer = null;
10040
- }
10041
9948
 
10042
9949
  // Whether the process is still RUNNING, not whether anyone has called kill
10043
9950
  // on it: `.killed` means only that a signal was sent, and a run that ended