@torrent-tv/proxy 2.83.0 → 2.83.2

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 (33) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/package.json +1 -1
  3. package/routes/api/sources/stats/get.js +11 -2
  4. package/routes/stream/get.js +74 -3
  5. package/services/delivery-probe.js +248 -43
  6. package/services/download/SwarmSelection.js +5 -5
  7. package/services/download/registry.js +20 -0
  8. package/services/encode/EncodeRun.js +1 -0
  9. package/services/encode/encode-exit.js +17 -0
  10. package/services/files/CompletedFiles.js +276 -0
  11. package/services/files/piece-from-whole-file.js +118 -0
  12. package/services/output/cut-grid.js +13 -3
  13. package/services/piece-store/piece-disk-store.js +72 -2
  14. package/services/piece-store/shared-piece-store.js +274 -27
  15. package/services/torrent-pool.js +259 -19
  16. package/services/torrent-worker/client.js +21 -0
  17. package/services/torrent-worker/protocol.js +9 -1
  18. package/services/torrent-worker/worker.js +183 -2
  19. package/test/completed-files.test.js +115 -0
  20. package/test/cuts-follow-published-grid.test.js +35 -0
  21. package/test/delivery-probe.test.js +114 -1
  22. package/test/encode-exit.test.js +18 -0
  23. package/test/piece-disk-store.test.js +26 -0
  24. package/test/piece-from-whole-file.test.js +129 -0
  25. package/test/piece-store-eviction.test.js +28 -15
  26. package/test/piece-store-never-refuses.test.js +153 -0
  27. package/test/piece-store-reservations.test.js +16 -3
  28. package/test/probe-wedge-certainty.test.js +3 -3
  29. package/test/shared-piece-store.test.js +27 -13
  30. package/test/stream-route.test.js +41 -0
  31. package/test/swarm-follows-readers.test.js +168 -0
  32. package/test/swarm-reach.test.js +5 -0
  33. package/test/upload-hurry.test.js +27 -0
@@ -36,6 +36,8 @@ import {
36
36
  warmResumePosition
37
37
  } from "./container-tracks.js";
38
38
  import { fillFileInBackground } from "./background-fill.js";
39
+ import { CompletedFiles, completedFilesRoot } from "../files/CompletedFiles.js";
40
+ import { pieceFromWholeFiles, pieceIsInWholeFiles } from "../files/piece-from-whole-file.js";
39
41
  import { Command, Event } from "./protocol.js";
40
42
  import { startMemoryReport, WORKER_MEMORY_SAMPLE_MS } from "../memory-report.js";
41
43
  import { forwardLogsTo, logger } from "../../utils/logger.js";
@@ -65,7 +67,7 @@ forwardLogsTo((_level, message) => {
65
67
  // the hook above had a chance to register. Verified the hard way: with a static
66
68
  // import the process still aborted, and the stack named the genuine polyfill.
67
69
  const { TorrentPool, resolveDhtBootstrap } = await import("../torrent-pool.js");
68
- const { collectStoreStats, machineReserveBytes, pieceBufferCollection, reviseSpillBudgets, reviseStoreBudgets } =
70
+ const { collectStoreStats, findSharedStore, machineReserveBytes, pieceBufferCollection, reviseSpillBudgets, reviseStoreBudgets } =
69
71
  await import("../piece-store/shared-piece-store.js");
70
72
 
71
73
  // Resolved before the client exists, because the client builds its DHT in its
@@ -733,7 +735,9 @@ setInterval(() => {
733
735
  `on-disk=${Math.round((stats.spilledBytes || 0) / 1048576)}MB ` +
734
736
  `pinned=${stats.pinned} spilled=${stats.spilled} reads=${reads} (${fromMemoryShare}% from memory) ` +
735
737
  `spills=${stats.spills} revivals=${stats.revivals}` +
736
- (stats.blockedByPins > 0 ? ` blocked-by-pins=${stats.blockedByPins}` : "") +
738
+ (stats.blockedByPins > 0 ? ` no-block-for=${stats.blockedByPins}` : "") +
739
+ (stats.admittedWithoutSlot > 0 ? ` to-disk-for-want-of-memory=${stats.admittedWithoutSlot}` : "") +
740
+ (stats.stillMs > 1000 ? ` nothing-moved-for=${Math.round(stats.stillMs / 1000)}s` : "") +
737
741
  (stats.evictedOnRevise > 0 ? ` evictedOnRevise=${stats.evictedOnRevise}` : "") +
738
742
  (stats.spillFailures > 0 ? ` spill-failures=${stats.spillFailures}` : "") +
739
743
  (stats.outstanding > 0 ? ` outstanding=${stats.outstanding}` : "")
@@ -922,6 +926,183 @@ setInterval(() => {
922
926
  }
923
927
  }, SUBTITLE_WARMUP_INTERVAL_MS).unref();
924
928
 
929
+ /**
930
+ * Files this proxy has downloaded whole. One directory, two readers of it: this
931
+ * thread writes them, the main thread serves them without asking anybody.
932
+ */
933
+ const completedFiles = new CompletedFiles({ root: completedFilesRoot() });
934
+
935
+ /**
936
+ * Which torrent a set of files belongs to.
937
+ *
938
+ * The piece store hands its own files back without knowing what they are; this
939
+ * thread does know, and a file carries its torrent.
940
+ *
941
+ * @param {object[]} files
942
+ * @returns {string}
943
+ */
944
+ const infoHashOf = (files) => String(files?.[0]?._torrent?.infoHash ?? "").toLowerCase();
945
+
946
+ // WHERE A PIECE COMES FROM WHEN NEITHER TIER HAS IT. Handed to every store this
947
+ // pool builds, so a film already assembled into a file is read from that file:
948
+ // which is what lets its spilled copy be dropped as the duplicate it has become,
949
+ // and what lets a torrent be destroyed and added again without fetching a byte.
950
+ pool.buildStoresWith({
951
+ readPieceElsewhere: ({ index, pieceLength, length, files }) =>
952
+ pieceFromWholeFiles({
953
+ index,
954
+ pieceLength,
955
+ length,
956
+ files,
957
+ wholeFileAt: (fileIndex) => completedFiles.find(infoHashOf(files), fileIndex)
958
+ }),
959
+ isPieceElsewhere: ({ index, pieceLength, length, files }) =>
960
+ pieceIsInWholeFiles({
961
+ index,
962
+ pieceLength,
963
+ length,
964
+ files,
965
+ wholeFileAt: (fileIndex) => completedFiles.find(infoHashOf(files), fileIndex)
966
+ })
967
+ });
968
+ void completedFiles.adopt(() => null).then((adopted) => {
969
+ if (adopted > 0) {
970
+ logger.info(
971
+ `whole files: took up ${adopted} file(s) a previous life left in ${completedFiles.root}`
972
+ );
973
+ }
974
+ });
975
+
976
+ /**
977
+ * How often whole files are looked for.
978
+ *
979
+ * The work itself is one pass over the files of each torrent asking a boolean
980
+ * the library already keeps; writing one out happens at most once per file,
981
+ * ever.
982
+ */
983
+ const WHOLE_FILE_SWEEP_MS = 10_000;
984
+
985
+ /** Files being written out right now, so a sweep does not start a second one. */
986
+ const beingKept = new Set();
987
+
988
+ /**
989
+ * Keep every file that is now whole, and let go of a torrent that has nothing
990
+ * left to fetch.
991
+ *
992
+ * The instruction this serves, 2026-09-11: as soon as a torrent is fully
993
+ * downloaded, downloading stops, the torrent is deleted, and what was
994
+ * downloaded stays for as long as it is wanted.
995
+ *
996
+ * `file.done` is the library's own answer to "is every piece of this file
997
+ * here", and `torrent.done` to "is that true of every file". The second is a
998
+ * strong condition and will not fire for a season pack of which one episode is
999
+ * watched — nothing fetches the other four — and that is right: the instruction
1000
+ * is about a torrent downloaded WHOLE.
1001
+ *
1002
+ * @returns {Promise<void>}
1003
+ */
1004
+ async function keepWholeFiles() {
1005
+ for (const [sourceKey, torrent] of [...pool.torrents]) {
1006
+ // Lower case, because that is what the source key carries and the main
1007
+ // thread looks these up by the key alone.
1008
+ const infoHash = String(torrent?.infoHash ?? "").toLowerCase();
1009
+ if (!infoHash || !Array.isArray(torrent.files)) {
1010
+ continue;
1011
+ }
1012
+ const usage = pool.fileUsageByTorrent?.get?.(torrent);
1013
+ for (const [fileIndex, file] of torrent.files.entries()) {
1014
+ const key = `${infoHash}/${fileIndex}`;
1015
+ if (file?.done !== true || completedFiles.find(infoHash, fileIndex) || beingKept.has(key)) {
1016
+ continue;
1017
+ }
1018
+ // NOT WHILE SOMEBODY IS READING IT. Writing a film out is a read of the
1019
+ // whole of it and a write of the whole of it — a gigabyte and a half on
1020
+ // the file this was measured against — and doing that beside a viewer
1021
+ // takes the disk and the piece store from them for nothing they asked
1022
+ // for. The file is complete; it will still be complete when they leave.
1023
+ if (usage?.has?.(fileIndex)) {
1024
+ continue;
1025
+ }
1026
+ beingKept.add(key);
1027
+ try {
1028
+ const kept = await completedFiles.keep({
1029
+ infoHash,
1030
+ fileIndex,
1031
+ length: file.length,
1032
+ name: file.name,
1033
+ open: () => file.createReadStream()
1034
+ });
1035
+ if (kept) {
1036
+ logger.info(
1037
+ `whole files: kept "${file.name}" (${Math.round(kept.length / 1048576)}MB) — ` +
1038
+ "it is a file now, and reading it needs no torrent"
1039
+ );
1040
+ // The pieces it was built from are a second copy of the same bytes.
1041
+ // Nothing is lost by dropping them: a read that wants one of them is
1042
+ // answered from the file.
1043
+ const store = findSharedStore(torrent);
1044
+ const dropped = store?.dropDuplicatesHeldElsewhere?.() ?? 0;
1045
+ if (dropped > 0) {
1046
+ logger.info(
1047
+ `whole files: dropped ${dropped} spilled piece(s) of "${file.name}" — ` +
1048
+ "the film was on this disk twice and is not any more"
1049
+ );
1050
+ }
1051
+ parentPort.postMessage({
1052
+ type: Event.FILE_COMPLETE,
1053
+ infoHash,
1054
+ fileIndex,
1055
+ path: kept.path,
1056
+ length: kept.length,
1057
+ name: kept.name
1058
+ });
1059
+ }
1060
+ } catch (error) {
1061
+ logger.warn(`whole files: could not keep "${file?.name}": ${error?.message ?? error}`);
1062
+ } finally {
1063
+ beingKept.delete(key);
1064
+ }
1065
+ }
1066
+ // EVERYTHING, WITHOUT EXCEPTION, AND EVERY BYTE OF IT A FILE ON DISK. The
1067
+ // torrent has no job left: there is nothing to fetch, and this proxy does
1068
+ // not seed what nobody is watching.
1069
+ //
1070
+ // Safe to destroy only because a piece can now be read out of those files:
1071
+ // a path that asks for this source again adds the torrent back, and what it
1072
+ // verifies it reads from the files rather than from the swarm. Not while
1073
+ // anybody is reading it, for the same reason the writing above waits.
1074
+ const isWhole =
1075
+ torrent.done === true &&
1076
+ torrent.files.every((unused, fileIndex) => completedFiles.find(infoHash, fileIndex) !== null);
1077
+ if (isWhole && !(usage?.size > 0)) {
1078
+ logger.info(
1079
+ `whole files: "${torrent.name}" is downloaded whole and saved — removing the torrent, keeping the files`
1080
+ );
1081
+ // THE RECIPE STAYS, and so does the entry that leads to it. Everything
1082
+ // that asks this thread about a source — the track table, the media
1083
+ // info, the keyframe table, the stats the browser polls — comes through
1084
+ // `requireTorrent`, which adds a torrent back when the one it holds is
1085
+ // no longer usable. Deleting the entry here would turn a viewer
1086
+ // returning to this film into `Unknown source`, which is a worse failure
1087
+ // than the one this removal is for.
1088
+ //
1089
+ // What the torrent finds when it comes back is the whole files: its
1090
+ // store reads pieces from them, so it fetches nothing. It is added with
1091
+ // verification skipped, and that is not a shortcut — the file was
1092
+ // written out of pieces this client had already hashed, and its size was
1093
+ // checked against what the torrent says. Re-hashing a gigabyte and a
1094
+ // half to learn what we wrote down is a minute of a viewer's time for
1095
+ // nothing.
1096
+ pool.remove(torrent, "downloaded-whole");
1097
+ pool.addWholeSource(sourceKey);
1098
+ }
1099
+ }
1100
+ }
1101
+
1102
+ setInterval(() => {
1103
+ void keepWholeFiles();
1104
+ }, WHOLE_FILE_SWEEP_MS).unref();
1105
+
925
1106
  /**
926
1107
  * Keeps this thread alive ON PURPOSE — the one interval left accounted for
927
1108
  * (no `.unref()`).
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @file Files downloaded whole are files, and outlive the torrent that fetched
3
+ * them.
4
+ *
5
+ * Asked for in these words on 2026-09-11: as soon as a torrent is fully
6
+ * downloaded, downloading stops, the torrent is deleted, and the artefacts —
7
+ * what was downloaded — stay for as long as they are wanted.
8
+ */
9
+
10
+ import test from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import fs from "node:fs/promises";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import { Readable } from "node:stream";
16
+ import { CompletedFiles } from "../services/files/CompletedFiles.js";
17
+
18
+ const INFO_HASH = "abcdef0123456789abcdef0123456789abcdef01";
19
+
20
+ /** @returns {Promise<string>} */
21
+ const directory = () => fs.mkdtemp(path.join(os.tmpdir(), "whole-files-"));
22
+
23
+ /**
24
+ * @param {Buffer} bytes
25
+ * @param {number} [chunk]
26
+ * @returns {() => NodeJS.ReadableStream}
27
+ */
28
+ const opens = (bytes, chunk = 7) => () => {
29
+ const parts = [];
30
+ for (let at = 0; at < bytes.length; at += chunk) {
31
+ parts.push(bytes.subarray(at, Math.min(at + chunk, bytes.length)));
32
+ }
33
+ return Readable.from(parts);
34
+ };
35
+
36
+ test("a file read whole is kept whole, and reads back byte for byte", async () => {
37
+ const root = await directory();
38
+ const files = new CompletedFiles({ root });
39
+ const bytes = Buffer.from("the whole of a small film", "utf8");
40
+ try {
41
+ const kept = await files.keep({ infoHash: INFO_HASH, fileIndex: 3, length: bytes.length, name: "film.mkv", open: opens(bytes) });
42
+ assert.ok(kept, "the file was not kept");
43
+ assert.equal(kept.length, bytes.length);
44
+
45
+ const found = files.find(INFO_HASH, 3);
46
+ assert.deepEqual(found, kept, "what was kept is not what is found");
47
+ assert.deepEqual(await fs.readFile(found.path), bytes, "the bytes came back changed");
48
+ } finally {
49
+ await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
50
+ }
51
+ });
52
+
53
+ test("a read that ends early leaves nothing that looks whole", async () => {
54
+ const root = await directory();
55
+ const files = new CompletedFiles({ root });
56
+ const bytes = Buffer.from("half a film", "utf8");
57
+ try {
58
+ // The torrent says the file is longer than what its read produced — the
59
+ // data went away mid-write, which over a torrent is ordinary.
60
+ const kept = await files.keep({
61
+ infoHash: INFO_HASH,
62
+ fileIndex: 0,
63
+ length: bytes.length + 100,
64
+ open: opens(bytes)
65
+ });
66
+ assert.equal(kept, null, "a short file was kept as whole");
67
+ assert.equal(files.find(INFO_HASH, 0), null);
68
+ assert.deepEqual(
69
+ (await fs.readdir(path.join(root, INFO_HASH))).filter((entry) => entry !== "manifest.json"),
70
+ [],
71
+ "a partial file was left behind"
72
+ );
73
+ } finally {
74
+ await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
75
+ }
76
+ });
77
+
78
+ test("what a previous life left is taken up, and what is the wrong size is not", async () => {
79
+ const root = await directory();
80
+ const bytes = Buffer.from("a film from the last time this ran", "utf8");
81
+ try {
82
+ const first = new CompletedFiles({ root });
83
+ await first.keep({ infoHash: INFO_HASH, fileIndex: 2, length: bytes.length, name: "film.mkv", open: opens(bytes) });
84
+ // And one that was being written when the process died, under a name that
85
+ // says it is whole — the only way to tell is its size.
86
+ await fs.writeFile(path.join(root, INFO_HASH, "5"), Buffer.alloc(3));
87
+
88
+ const second = new CompletedFiles({ root });
89
+ const adopted = await second.adopt((infoHash, fileIndex) =>
90
+ infoHash === INFO_HASH && fileIndex === 2 ? bytes.length : 999
91
+ );
92
+ assert.equal(adopted, 1, "the whole file was not taken up, or the short one was");
93
+ assert.equal(second.find(INFO_HASH, 2)?.name, "film.mkv", "the name the torrent gave it was lost");
94
+ assert.equal(second.find(INFO_HASH, 5), null, "a file of the wrong size was taken up");
95
+ } finally {
96
+ await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
97
+ }
98
+ });
99
+
100
+ test("forgetting a torrent removes its files and nobody else's", async () => {
101
+ const root = await directory();
102
+ const files = new CompletedFiles({ root });
103
+ const other = "0123456789abcdef0123456789abcdef01234567";
104
+ const bytes = Buffer.from("kept", "utf8");
105
+ try {
106
+ await files.keep({ infoHash: INFO_HASH, fileIndex: 1, length: bytes.length, name: "one.mkv", open: opens(bytes) });
107
+ await files.keep({ infoHash: other, fileIndex: 1, length: bytes.length, name: "two.mkv", open: opens(bytes) });
108
+
109
+ await files.forget(INFO_HASH);
110
+ assert.equal(files.find(INFO_HASH, 1), null);
111
+ assert.ok(files.find(other, 1), "another torrent's file went with it");
112
+ } finally {
113
+ await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
114
+ }
115
+ });
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import assert from "node:assert/strict";
18
+ import { computeCutGrid } from "../services/output/cut-grid.js";
18
19
  import { Timeline } from "../services/output/Timeline.js";
19
20
  import test from "node:test";
20
21
 
@@ -71,3 +72,37 @@ test("a session that published no grid falls back to the live one", () => {
71
72
  const session = { id: "no-playlist", timeline: new Timeline({ boundaries: [...CORRECTED], published: [], cutGrid: "uniform" }) };
72
73
  assert.deepEqual(manager.publishedGridFor(session), CORRECTED);
73
74
  });
75
+
76
+ test("no segment is left shorter than a segment at the end of the film", () => {
77
+ // The field case of 2026-09-11, to the millisecond: a 54-minute film whose
78
+ // last keyframe sits 160 ms before the end. Taking it as a cut leaves a
79
+ // segment of 0.16 s — one the sound has no data in at all, which is how one
80
+ // output came to have 542 segments and the other 541.
81
+ const total = 3246.12;
82
+ const keyframes = [];
83
+ for (let at = 0; at < total - 1; at += 2) {
84
+ keyframes.push(Number(at.toFixed(3)));
85
+ }
86
+ keyframes.push(3245.96);
87
+
88
+ const grid = computeCutGrid({
89
+ useKeyframeGrid: true,
90
+ durationSeconds: total,
91
+ segDur: 6,
92
+ keyframeTimes: keyframes,
93
+ startTime: 0
94
+ });
95
+
96
+ const last = grid.boundaries[grid.boundaries.length - 1];
97
+ const before = grid.boundaries[grid.boundaries.length - 2];
98
+ assert.equal(last, total, "the film still ends where it ends");
99
+ assert.ok(
100
+ last - before >= 6,
101
+ `the last segment is ${(last - before).toFixed(3)}s, shorter than the 6s every other cut is held to`
102
+ );
103
+ // And the rule is the one every cut obeys, so no segment anywhere is short.
104
+ for (let index = 1; index < grid.boundaries.length; index += 1) {
105
+ const span = grid.boundaries[index] - grid.boundaries[index - 1];
106
+ assert.ok(span >= 6 - 0.05, `segment ${index - 1} lasts ${span.toFixed(3)}s`);
107
+ }
108
+ });
@@ -1,7 +1,13 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
 
4
- import { allowedGap, readProbeState, PROBE_INTERVAL_MS, UNRELIABLE_LABEL } from "../services/delivery-probe.js";
4
+ import {
5
+ allowedGap,
6
+ probeWedgeIsCertain,
7
+ readProbeState,
8
+ PROBE_INTERVAL_MS,
9
+ UNRELIABLE_LABEL
10
+ } from "../services/delivery-probe.js";
5
11
 
6
12
  const ORDERED = ["proxy", "proxy-control"];
7
13
  const ALL = [...ORDERED, UNRELIABLE_LABEL];
@@ -278,3 +284,110 @@ test("a peer that reports no loop delay is judged exactly as before", () => {
278
284
  assert.doesNotMatch(reading.detail, /peerLoopLag=/);
279
285
  assert.doesNotMatch(reading.detail, /peerTab=/);
280
286
  });
287
+
288
+ test("a quiet stretch shorter than a legitimate report is not a wedge", () => {
289
+ // Field 2026-09-11: two captures of 180 s each, triggered at `wedged 1s` and
290
+ // `wedged 2s`, on a connection with `rtt=5ms`, every queue at 0 B and the
291
+ // viewer watching. In its first minutes a connection has shown no healthy gap
292
+ // at all, and the floor under "longer than anything healthy" was a single
293
+ // probe interval — 500 ms — so any quiet moment beat it.
294
+ // Both cadences plus the crossing: a probe sent just after the peer composed
295
+ // a report shows up only in the next one.
296
+ const legitimateReportMs = 500 + 500 + 12 + 0;
297
+ assert.equal(
298
+ probeWedgeIsCertain({ stuckForMs: 1000, longestHealthySeenGapMs: 0, legitimateReportMs }).isCertain,
299
+ false,
300
+ "a second of quiet is shorter than one legitimate report and says nothing"
301
+ );
302
+ // And what the detector exists for is untouched: a counter frozen for
303
+ // minutes is far past any report this connection could legitimately owe.
304
+ assert.equal(
305
+ probeWedgeIsCertain({ stuckForMs: 60_000, longestHealthySeenGapMs: 0, legitimateReportMs }).isCertain,
306
+ true
307
+ );
308
+ // A peer whose event loop is late is owed that time as well.
309
+ assert.equal(
310
+ probeWedgeIsCertain({
311
+ stuckForMs: 3000,
312
+ longestHealthySeenGapMs: 0,
313
+ legitimateReportMs: 500 + 12 + 5000
314
+ }).isCertain,
315
+ false,
316
+ "a peer frozen for five seconds cannot answer sooner than that"
317
+ );
318
+ });
319
+
320
+ test("what is behind is judged in time, not in probes", () => {
321
+ // The same probe goes down every channel including the one carrying the film,
322
+ // and SCTP schedules per association — so a probe waits behind queued video
323
+ // exactly as a segment does. Counting outstanding probes therefore measures
324
+ // the queue, not the association, which is why the count is only printed now.
325
+ const behind = { proxy: 800, "proxy-control": 800, "proxy-fast": 800 };
326
+ const mayWait = { proxy: 2000, "proxy-control": 2000, "proxy-fast": 2000 };
327
+
328
+ // Far behind in probes, well within the time its own queue is allowed.
329
+ const healthy = readProbeState(
330
+ state(
331
+ { proxy: 40, "proxy-control": 41, "proxy-fast": 42 },
332
+ { behindMs: behind, allowedWaitMs: mayWait }
333
+ )
334
+ );
335
+ assert.equal(healthy.verdict, "flowing");
336
+ assert.match(healthy.detail, /800ms of 2000ms/, "both readings belong in the line");
337
+
338
+ // The same gaps, the same allowance, and the probe is older than the queue
339
+ // could account for: that is the association and not the burst.
340
+ const wedged = readProbeState(
341
+ state(
342
+ { proxy: 40, "proxy-control": 41, "proxy-fast": 42 },
343
+ {
344
+ behindMs: { proxy: 9000, "proxy-control": 9000, "proxy-fast": 9000 },
345
+ allowedWaitMs: mayWait
346
+ }
347
+ )
348
+ );
349
+ assert.equal(wedged.verdict, "association-stopped");
350
+ });
351
+
352
+ test("with no send time recorded the count still decides", () => {
353
+ // A probe older than the history kept, or a connection that has just begun:
354
+ // the reading is absent rather than wrong, and the old comparison stands.
355
+ const { verdict } = readProbeState(
356
+ state({ proxy: 40, "proxy-control": 41, "proxy-fast": 42 }, { behindMs: {}, allowedWaitMs: {} })
357
+ );
358
+ assert.equal(verdict, "association-stopped");
359
+ });
360
+
361
+ test("the measured one-way time is preferred over the age of the report", () => {
362
+ // With the clocks reconciled, the proxy knows how long the probe itself took
363
+ // to reach the peer. The age of the newest reported probe is the same thing
364
+ // plus the peer's reporting cadence and the way back — so where both are
365
+ // known, the measurement wins and its allowance carries neither.
366
+ const { verdict } = readProbeState(
367
+ state(
368
+ { proxy: 40, "proxy-control": 41, "proxy-fast": 42 },
369
+ {
370
+ // The age says far behind against its allowance...
371
+ behindMs: { proxy: 9000, "proxy-control": 9000, "proxy-fast": 9000 },
372
+ allowedWaitMs: { proxy: 2000, "proxy-control": 2000, "proxy-fast": 2000 },
373
+ // ...while the probe itself took 300 ms of the 900 its queue may take.
374
+ oneWayMs: { proxy: 300, "proxy-control": 300, "proxy-fast": 300 },
375
+ allowedOneWayMs: { proxy: 900, "proxy-control": 900, "proxy-fast": 900 }
376
+ }
377
+ )
378
+ );
379
+ assert.equal(verdict, "flowing");
380
+ });
381
+
382
+ test("a one-way time past what the queue can account for is the association", () => {
383
+ const { verdict } = readProbeState(
384
+ state(
385
+ { proxy: 40, "proxy-control": 41, "proxy-fast": 42 },
386
+ {
387
+ oneWayMs: { proxy: 12_000, "proxy-control": 12_000, "proxy-fast": 12_000 },
388
+ allowedOneWayMs: { proxy: 900, "proxy-control": 900, "proxy-fast": 900 }
389
+ }
390
+ )
391
+ );
392
+ assert.equal(verdict, "association-stopped");
393
+ });
@@ -91,3 +91,21 @@ test("every combination answers, and no input produces undefined", () => {
91
91
  }
92
92
  assert.ok(Object.values(ENCODE_EXIT).includes(classifyEncodeExit()));
93
93
  });
94
+
95
+ test("a run that made nothing has not finished, whatever its exit code", () => {
96
+ // Field 2026-09-11: a run given #541..#541 was handed a start later than its
97
+ // own end, wrote 190 bytes that are not a fragment, and exited zero. Making
98
+ // no segment and being unable to read the directory were the same `null`, and
99
+ // the second reading — "cannot be contradicted, so it stands" — was applied
100
+ // to the first.
101
+ assert.equal(
102
+ classifyEncodeExit({ code: 0, producedThrough: null, producedCount: 0, lastSegmentIndex: 541 }),
103
+ ENCODE_EXIT.SHORT
104
+ );
105
+ // And the case that reading was written for still stands: nothing readable on
106
+ // disk is not a claim that nothing was made.
107
+ assert.equal(
108
+ classifyEncodeExit({ code: 0, producedThrough: null, producedCount: null, lastSegmentIndex: 623 }),
109
+ ENCODE_EXIT.COMPLETE
110
+ );
111
+ });
@@ -353,3 +353,29 @@ test("when there is no room, what is behind the readers goes before what is ahea
353
353
  await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
354
354
  }
355
355
  });
356
+
357
+ test("a store takes up the pieces a previous life left in its directory", async () => {
358
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "adopt-"));
359
+ try {
360
+ const first = new PieceDiskStore({ directory: root, name: "film.pieces", chunkLength: 1024 });
361
+ await first.write(7, Buffer.alloc(1024, 7));
362
+ await first.write(9, Buffer.alloc(1024, 9));
363
+ await first.close();
364
+
365
+ // A torrent torn down and added again gets a NEW store over the same
366
+ // directory. Before 2026-09-11 its index started empty, so every piece it
367
+ // in fact had read as missing and the film was downloaded a second time
368
+ // while the first copy sat beside it.
369
+ const second = new PieceDiskStore({ directory: root, name: "film.pieces", chunkLength: 1024 });
370
+ assert.equal(second.size, 2, "the pieces already on disk were not taken up");
371
+ assert.equal(second.bytes, 2048, "nor were their bytes counted");
372
+ assert.ok(second.has(7) && second.has(9));
373
+
374
+ const target = Buffer.alloc(1024);
375
+ await second.read(7, target);
376
+ assert.ok(target.equals(Buffer.alloc(1024, 7)), "and they read back as themselves");
377
+ await second.destroy();
378
+ } finally {
379
+ await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
380
+ }
381
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * @file A piece read out of the files it was assembled into.
3
+ *
4
+ * Without this a whole file is a second copy of bytes the piece store also
5
+ * holds, and neither copy can be dropped. With it the spilled copy is a
6
+ * duplicate and can go — one episode on the field host of 2026-09-11 was
7
+ * 1417 MB of segments beside 1424 MB of spilled pieces — and a torrent can be
8
+ * destroyed and added again without fetching a byte.
9
+ */
10
+
11
+ import test from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import fs from "node:fs/promises";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { pieceFromWholeFiles, pieceIsInWholeFiles } from "../services/files/piece-from-whole-file.js";
17
+
18
+ const PIECE = 16;
19
+
20
+ /**
21
+ * Two files laid end to end, as a torrent lays them out, with a piece straddling
22
+ * the boundary between them.
23
+ *
24
+ * @returns {Promise<{ root: string, files: object[], length: number, bytes: Buffer }>}
25
+ */
26
+ async function twoFiles() {
27
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "piece-of-whole-"));
28
+ const first = Buffer.alloc(20, 1);
29
+ const second = Buffer.alloc(24, 2);
30
+ await fs.writeFile(path.join(root, "0"), first);
31
+ await fs.writeFile(path.join(root, "1"), second);
32
+ return {
33
+ root,
34
+ files: [
35
+ { offset: 0, length: first.length },
36
+ { offset: first.length, length: second.length }
37
+ ],
38
+ length: first.length + second.length,
39
+ bytes: Buffer.concat([first, second])
40
+ };
41
+ }
42
+
43
+ /**
44
+ * @param {string} root
45
+ * @param {number[]} whole - Which file indexes this proxy holds whole.
46
+ * @returns {(fileIndex: number) => { path: string, length: number } | null}
47
+ */
48
+ const holds = (root, whole) => (fileIndex) =>
49
+ whole.includes(fileIndex) ? { path: path.join(root, String(fileIndex)), length: 0 } : null;
50
+
51
+ test("a piece inside one file comes back byte for byte", async () => {
52
+ const { root, files, length, bytes } = await twoFiles();
53
+ try {
54
+ const piece = await pieceFromWholeFiles({
55
+ index: 0,
56
+ pieceLength: PIECE,
57
+ length,
58
+ files,
59
+ wholeFileAt: holds(root, [0, 1])
60
+ });
61
+ assert.deepEqual(piece, bytes.subarray(0, PIECE));
62
+ } finally {
63
+ await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
64
+ }
65
+ });
66
+
67
+ test("a piece straddling two files is stitched from both", async () => {
68
+ const { root, files, length, bytes } = await twoFiles();
69
+ try {
70
+ // Piece 1 covers bytes 16..31: four from the first file, twelve from the
71
+ // second.
72
+ const piece = await pieceFromWholeFiles({
73
+ index: 1,
74
+ pieceLength: PIECE,
75
+ length,
76
+ files,
77
+ wholeFileAt: holds(root, [0, 1])
78
+ });
79
+ assert.deepEqual(piece, bytes.subarray(PIECE, PIECE * 2));
80
+ } finally {
81
+ await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
82
+ }
83
+ });
84
+
85
+ test("the last piece is read to the end of the torrent and no further", async () => {
86
+ const { root, files, length, bytes } = await twoFiles();
87
+ try {
88
+ // 44 bytes in pieces of 16: the last piece is 12 long.
89
+ const piece = await pieceFromWholeFiles({
90
+ index: 2,
91
+ pieceLength: PIECE,
92
+ length,
93
+ files,
94
+ wholeFileAt: holds(root, [0, 1])
95
+ });
96
+ assert.equal(piece.length, 12);
97
+ assert.deepEqual(piece, bytes.subarray(32));
98
+ } finally {
99
+ await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
100
+ }
101
+ });
102
+
103
+ test("a piece any part of which is not held whole is refused, not half read", async () => {
104
+ const { root, files, length } = await twoFiles();
105
+ try {
106
+ // Only the first file is here; piece 1 straddles both. Half a piece is
107
+ // worse than none: the layer above would hash it and mark it bad.
108
+ const piece = await pieceFromWholeFiles({
109
+ index: 1,
110
+ pieceLength: PIECE,
111
+ length,
112
+ files,
113
+ wholeFileAt: holds(root, [0])
114
+ });
115
+ assert.equal(piece, null);
116
+ assert.equal(
117
+ pieceIsInWholeFiles({ index: 1, pieceLength: PIECE, length, files, wholeFileAt: holds(root, [0]) }),
118
+ false
119
+ );
120
+ // And one wholly inside the file that IS held is both readable and known to
121
+ // be a duplicate of what is on the spill.
122
+ assert.equal(
123
+ pieceIsInWholeFiles({ index: 0, pieceLength: PIECE, length, files, wholeFileAt: holds(root, [0]) }),
124
+ true
125
+ );
126
+ } finally {
127
+ await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
128
+ }
129
+ });