@torrent-tv/proxy 2.89.17 → 2.89.18

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 (299) hide show
  1. package/README.md +14 -3
  2. package/package.json +10 -2
  3. package/server.js +0 -11
  4. package/.claude/commands/opsx/apply.md +0 -155
  5. package/.claude/commands/opsx/archive.md +0 -160
  6. package/.claude/commands/opsx/explore.md +0 -174
  7. package/.claude/commands/opsx/propose.md +0 -109
  8. package/.claude/commands/opsx/sync.md +0 -143
  9. package/.claude/skills/openspec-apply-change/SKILL.md +0 -159
  10. package/.claude/skills/openspec-archive-change/SKILL.md +0 -117
  11. package/.claude/skills/openspec-explore/SKILL.md +0 -289
  12. package/.claude/skills/openspec-propose/SKILL.md +0 -113
  13. package/.claude/skills/openspec-sync-specs/SKILL.md +0 -147
  14. package/.dockerignore +0 -5
  15. package/AGENTS.md +0 -363
  16. package/CHANGELOG.md +0 -2377
  17. package/Dockerfile +0 -30
  18. package/biome.json +0 -412
  19. package/corpus.tmp.json +0 -1
  20. package/docs/browser-proxy-contract.md +0 -155
  21. package/docs/container-architecture.md +0 -273
  22. package/docs/download-architecture.md +0 -261
  23. package/docs/encode-architecture.md +0 -765
  24. package/docs/encode-run-state.md +0 -96
  25. package/docs/logs.md +0 -151
  26. package/docs/playback-start-forecast.md +0 -113
  27. package/docs/socks5-egress-research.md +0 -114
  28. package/docs/storage-architecture.md +0 -273
  29. package/knip.json +0 -14
  30. package/main2.cpuprofile +0 -1
  31. package/openspec/changes/adaptive-bitrate/.openspec.yaml +0 -0
  32. package/openspec/changes/adaptive-bitrate/design.md +0 -92
  33. package/openspec/changes/adaptive-bitrate/proposal.md +0 -56
  34. package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +0 -51
  35. package/openspec/changes/adaptive-bitrate/tasks.md +0 -40
  36. package/openspec/changes/binary-distribution/.openspec.yaml +0 -2
  37. package/openspec/changes/binary-distribution/proposal.md +0 -53
  38. package/openspec/changes/chunked-request-bodies/design.md +0 -81
  39. package/openspec/changes/chunked-request-bodies/proposal.md +0 -53
  40. package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +0 -30
  41. package/openspec/changes/chunked-request-bodies/tasks.md +0 -40
  42. package/openspec/changes/cold-start/design.md +0 -98
  43. package/openspec/changes/cold-start/proposal.md +0 -62
  44. package/openspec/changes/cold-start/specs/cold-start/spec.md +0 -47
  45. package/openspec/changes/cold-start/tasks.md +0 -48
  46. package/openspec/changes/disk-cap/.openspec.yaml +0 -2
  47. package/openspec/changes/disk-cap/proposal.md +0 -37
  48. package/openspec/changes/disk-cap/specs/disk-cap/spec.md +0 -25
  49. package/openspec/changes/disk-cap/tasks.md +0 -19
  50. package/openspec/changes/proxy-observability/.openspec.yaml +0 -2
  51. package/openspec/changes/proxy-observability/design.md +0 -38
  52. package/openspec/changes/proxy-observability/proposal.md +0 -49
  53. package/openspec/changes/proxy-observability/specs/observability/spec.md +0 -33
  54. package/openspec/changes/proxy-observability/tasks.md +0 -21
  55. package/openspec/changes/seek-debounce/.openspec.yaml +0 -0
  56. package/openspec/changes/seek-debounce/design.md +0 -118
  57. package/openspec/changes/seek-debounce/proposal.md +0 -59
  58. package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +0 -41
  59. package/openspec/changes/seek-debounce/tasks.md +0 -33
  60. package/openspec/changes/subtitle-language/.openspec.yaml +0 -2
  61. package/openspec/changes/subtitle-language/proposal.md +0 -50
  62. package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +0 -34
  63. package/openspec/changes/subtitle-language/tasks.md +0 -26
  64. package/openspec/changes/track-selection/.openspec.yaml +0 -2
  65. package/openspec/changes/track-selection/design.md +0 -47
  66. package/openspec/changes/track-selection/proposal.md +0 -45
  67. package/openspec/changes/track-selection/specs/track-selection/spec.md +0 -41
  68. package/openspec/changes/track-selection/tasks.md +0 -20
  69. package/openspec/changes/transcode-quality/.openspec.yaml +0 -2
  70. package/openspec/changes/transcode-quality/proposal.md +0 -51
  71. package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +0 -139
  72. package/openspec/changes/transcode-quality/tasks.md +0 -63
  73. package/openspec/config.yaml +0 -38
  74. package/public/.well-known/appspecific/com.chrome.devtools.json +0 -1
  75. package/research/delivery-wedge-stand-ladder-2026-09-13.md +0 -329
  76. package/research/double-spawn-2026-09-10.md +0 -171
  77. package/research/handover-2026-09-13.md +0 -209
  78. package/research/handover-reader-claims-removal-2026-09-12.md +0 -166
  79. package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +0 -175
  80. package/research/preset-benchmark-size-scaling-2026-08-22.md +0 -55
  81. package/research/priority-map-is-the-truth-2026-09-12.md +0 -225
  82. package/research/valgrind-replica-2026-08-27/logs.tar.gz +0 -0
  83. package/scripts/build-audio-samples.mjs +0 -31
  84. package/scripts/render-run-graph.js +0 -173
  85. package/test/asset-paths.test.js +0 -28
  86. package/test/audio-buffer-continuity.test.js +0 -77
  87. package/test/audio-calibration.test.js +0 -23
  88. package/test/audio-inventory.test.js +0 -212
  89. package/test/audio-track-bitrate.test.js +0 -40
  90. package/test/audio-warm-recorded.test.js +0 -63
  91. package/test/auto-quality-step.test.js +0 -762
  92. package/test/available-share.test.js +0 -76
  93. package/test/background-fill.test.js +0 -190
  94. package/test/client-log-file.test.js +0 -126
  95. package/test/client-logs-route.test.js +0 -34
  96. package/test/coalesce.test.js +0 -39
  97. package/test/completed-files.test.js +0 -115
  98. package/test/concurrent-cost.test.js +0 -148
  99. package/test/container-media-info.test.js +0 -228
  100. package/test/contention.test.js +0 -76
  101. package/test/core-dumps.test.js +0 -57
  102. package/test/coverage-follows-the-disk.test.js +0 -233
  103. package/test/coverage-map.test.js +0 -195
  104. package/test/cue-push-carries-what-the-walk-found.test.js +0 -58
  105. package/test/cut-times-timeline.test.js +0 -65
  106. package/test/cuts-follow-published-grid.test.js +0 -109
  107. package/test/data-channel-frame.test.js +0 -63
  108. package/test/data-channel-viewer-lifecycle.test.js +0 -193
  109. package/test/dead-torrent-handle.test.js +0 -52
  110. package/test/declared-rates.test.js +0 -116
  111. package/test/declared-tracks.test.js +0 -43
  112. package/test/decode-cost-fit.test.js +0 -202
  113. package/test/decode-cost.test.js +0 -475
  114. package/test/delivery-probe-field.test.js +0 -173
  115. package/test/delivery-probe.test.js +0 -382
  116. package/test/demand-register.test.js +0 -206
  117. package/test/dht-bootstrap.test.js +0 -72
  118. package/test/drain-threat.test.js +0 -65
  119. package/test/duplicate-add.test.js +0 -48
  120. package/test/encode-admission.test.js +0 -170
  121. package/test/encode-cost.test.js +0 -275
  122. package/test/encode-exit.test.js +0 -111
  123. package/test/encode-orchestrator.test.js +0 -426
  124. package/test/encode-plan-viewers.test.js +0 -725
  125. package/test/encode-plan.test.js +0 -545
  126. package/test/encode-run-state.test.js +0 -314
  127. package/test/encode-run.test.js +0 -248
  128. package/test/encode-slope.test.js +0 -46
  129. package/test/encoded-output.test.js +0 -39
  130. package/test/encoder-kinds.test.js +0 -122
  131. package/test/encoder-readings.test.js +0 -68
  132. package/test/episode-naming.test.js +0 -131
  133. package/test/every-claimant-is-told-its-share.test.js +0 -152
  134. package/test/failed-encoder-still-serves-what-exists.test.js +0 -354
  135. package/test/failed-start-breaker.test.js +0 -202
  136. package/test/fastest-wires.test.js +0 -143
  137. package/test/ffmpeg-stream-counts.test.js +0 -101
  138. package/test/file-edges.test.js +0 -191
  139. package/test/files-in-use.test.js +0 -34
  140. package/test/fixtures/audio-frame-boundary.json +0 -1
  141. package/test/fixtures/refused-audio-20261002.json +0 -10
  142. package/test/fmp4-presentation-clock.test.js +0 -148
  143. package/test/fmp4-self-contained.test.js +0 -80
  144. package/test/fmp4-timestamps.test.js +0 -140
  145. package/test/fmp4-track-completeness.test.js +0 -41
  146. package/test/format-keyed-outputs.test.js +0 -78
  147. package/test/health-metrics.test.js +0 -36
  148. package/test/held-read-ownership.test.js +0 -77
  149. package/test/held-request-width.test.js +0 -157
  150. package/test/helpers/encode-run.js +0 -248
  151. package/test/helpers/held-file.js +0 -77
  152. package/test/helpers/manager.js +0 -62
  153. package/test/helpers/matroska-file.js +0 -296
  154. package/test/helpers/output-spec.js +0 -63
  155. package/test/host-load.test.js +0 -68
  156. package/test/index-accuracy-is-about-the-file.test.js +0 -128
  157. package/test/init-compat.test.js +0 -219
  158. package/test/input-lost-quiets-the-plan.test.js +0 -261
  159. package/test/input-unavailable.test.js +0 -48
  160. package/test/installation-id.test.js +0 -39
  161. package/test/keeping-period.test.js +0 -83
  162. package/test/keyframe-index-accuracy.test.js +0 -188
  163. package/test/keyframe-table-budget.test.js +0 -123
  164. package/test/keyframes-belong-to-the-file.test.js +0 -249
  165. package/test/learned-median.test.js +0 -37
  166. package/test/letting-go-asks-who-is-watching.test.js +0 -191
  167. package/test/limit-row-by-area.test.js +0 -60
  168. package/test/link-budget.test.js +0 -111
  169. package/test/link-probe.test.js +0 -56
  170. package/test/local-encoding-data.test.js +0 -273
  171. package/test/logger-repeats.test.js +0 -134
  172. package/test/look-ahead-coverage.test.js +0 -56
  173. package/test/manager-surface.test.js +0 -291
  174. package/test/matroska-blocks.test.js +0 -0
  175. package/test/matroska-cluster-discovery.test.js +0 -323
  176. package/test/matroska-cues-track.test.js +0 -192
  177. package/test/media-time.test.js +0 -62
  178. package/test/memory-budget.test.js +0 -390
  179. package/test/move-cost.test.js +0 -216
  180. package/test/mp4-composition-times.test.js +0 -0
  181. package/test/mp4-subtitles.test.js +0 -173
  182. package/test/one-authority.test.js +0 -285
  183. package/test/one-budget.test.js +0 -149
  184. package/test/one-output-one-session.test.js +0 -273
  185. package/test/one-resource-divided.test.js +0 -185
  186. package/test/one-serving-output-per-height.test.js +0 -458
  187. package/test/orchestrator-wired.test.js +0 -207
  188. package/test/output-catalog.test.js +0 -39
  189. package/test/output-shape.test.js +0 -21
  190. package/test/output-spec.test.js +0 -251
  191. package/test/output-speed.test.js +0 -92
  192. package/test/outputs-being-prepared.test.js +0 -40
  193. package/test/packet-witness-ring.test.js +0 -236
  194. package/test/packet-witness.test.js +0 -148
  195. package/test/piece-completeness.test.js +0 -78
  196. package/test/piece-disk-store.test.js +0 -401
  197. package/test/piece-from-whole-file.test.js +0 -129
  198. package/test/piece-lru.test.js +0 -319
  199. package/test/piece-reader.test.js +0 -162
  200. package/test/piece-selection-offset.test.js +0 -85
  201. package/test/piece-store-eviction.test.js +0 -595
  202. package/test/piece-store-never-refuses.test.js +0 -153
  203. package/test/piece-store-reservations.test.js +0 -403
  204. package/test/piece-store-slow-disk.test.js +0 -159
  205. package/test/piece-tail.test.js +0 -97
  206. package/test/plan-host-timings.test.js +0 -68
  207. package/test/plan-is-steady.test.js +0 -225
  208. package/test/playback-readiness.test.js +0 -518
  209. package/test/playback-start-regressions.test.js +0 -101
  210. package/test/priority-map-download.test.js +0 -174
  211. package/test/priority-map-emptied.test.js +0 -204
  212. package/test/priority-map-per-output.test.js +0 -215
  213. package/test/priority-map.test.js +0 -163
  214. package/test/probe-wedge-certainty.test.js +0 -60
  215. package/test/produced-copy-choice.test.js +0 -213
  216. package/test/published-timeline.test.js +0 -83
  217. package/test/quality-offer.test.js +0 -160
  218. package/test/quality-variants.test.js +0 -1274
  219. package/test/rate-control-in-the-output.test.js +0 -181
  220. package/test/read-survives-withdrawal.test.js +0 -164
  221. package/test/read-window.test.js +0 -365
  222. package/test/reads-not-yet-arrived.test.js +0 -218
  223. package/test/resume-warm.test.js +0 -39
  224. package/test/run-graph-drift.test.js +0 -37
  225. package/test/run-inputs.test.js +0 -80
  226. package/test/run-intervals.test.js +0 -100
  227. package/test/run-position-follows-published-grid.test.js +0 -89
  228. package/test/run-progress.test.js +0 -64
  229. package/test/same-height-switch.test.js +0 -663
  230. package/test/seek-frees-the-viewers-requests.test.js +0 -217
  231. package/test/seek-landing.test.js +0 -109
  232. package/test/seek-target-not-superseded.test.js +0 -195
  233. package/test/segment-closed-on-both-branches.test.js +0 -114
  234. package/test/segment-cut-times.test.js +0 -63
  235. package/test/segment-demand.test.js +0 -80
  236. package/test/segment-hold-supersede.test.js +0 -91
  237. package/test/segment-judged-by-its-own-header.test.js +0 -222
  238. package/test/segment-serve-wiring.test.js +0 -481
  239. package/test/segment-store-eviction.test.js +0 -247
  240. package/test/segment-store.test.js +0 -352
  241. package/test/segment-true-start.test.js +0 -86
  242. package/test/segments-are-shared.test.js +0 -176
  243. package/test/send-chunk-bytes.test.js +0 -86
  244. package/test/serving-existing-output.test.js +0 -111
  245. package/test/serving-output.test.js +0 -102
  246. package/test/shared-piece-store.test.js +0 -310
  247. package/test/sidecar-naming.test.js +0 -142
  248. package/test/soundtrack-rate-bound.test.js +0 -289
  249. package/test/source-file.test.js +0 -133
  250. package/test/source-files-route.test.js +0 -113
  251. package/test/source-registry.test.js +0 -18
  252. package/test/split-a-stretch.test.js +0 -139
  253. package/test/stale-request-after-seek.test.js +0 -158
  254. package/test/startup-readings.test.js +0 -124
  255. package/test/stream-route.test.js +0 -237
  256. package/test/subtitle-cue-framing.test.js +0 -200
  257. package/test/subtitle-cue-source.test.js +0 -102
  258. package/test/subtitle-cue-walk.test.js +0 -385
  259. package/test/subtitle-cursor.test.js +0 -63
  260. package/test/subtitle-defaults.test.js +0 -118
  261. package/test/subtitle-language.test.js +0 -252
  262. package/test/subtitle-metadata.test.js +0 -15
  263. package/test/subtitle-plan-waits.test.js +0 -125
  264. package/test/subtitle-track-numbering.test.js +0 -391
  265. package/test/supply-margin.test.js +0 -190
  266. package/test/swarm-follows-readers.test.js +0 -174
  267. package/test/swarm-reach.test.js +0 -130
  268. package/test/swarm-selection.test.js +0 -220
  269. package/test/tail-duplication.test.js +0 -167
  270. package/test/the-channel-has-both-ends.test.js +0 -91
  271. package/test/the-torrent-thread-serves-bytes.test.js +0 -107
  272. package/test/thread-pool.test.js +0 -53
  273. package/test/timeline.test.js +0 -84
  274. package/test/torrent-contents.test.js +0 -235
  275. package/test/torrent-cost.test.js +0 -71
  276. package/test/torrent-files.test.js +0 -221
  277. package/test/torrent-naming.test.js +0 -255
  278. package/test/tracks-begin-together.test.js +0 -209
  279. package/test/tunnel-renewal.test.js +0 -167
  280. package/test/two-viewers-one-picture.test.js +0 -508
  281. package/test/upload-hurry.test.js +0 -186
  282. package/test/usrsctp-state.test.js +0 -131
  283. package/test/video-facts.test.js +0 -102
  284. package/test/viewer-assignments.test.js +0 -143
  285. package/test/viewer-outputs.test.js +0 -285
  286. package/test/viewer-presence.test.js +0 -116
  287. package/test/viewer-requests-admission.test.js +0 -92
  288. package/test/viewer-starts-on-map.test.js +0 -27
  289. package/test/viewer-states.test.js +0 -192
  290. package/test/viewer-subtitle-subscription.test.js +0 -96
  291. package/test/viewer.test.js +0 -139
  292. package/test/viewing-generations.test.js +0 -390
  293. package/test/visible-rung.test.js +0 -37
  294. package/test/wait-ledger.test.js +0 -100
  295. package/test/webrtc-shim.test.js +0 -149
  296. package/test/wedge-certainty.test.js +0 -131
  297. package/test/whole-file-held-read.test.js +0 -30
  298. package/test/withdraw-piece-claim.test.js +0 -212
  299. package/test/worker-channel.test.js +0 -158
@@ -1,129 +0,0 @@
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/storage/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
- });
@@ -1,319 +0,0 @@
1
- /**
2
- * @file Recency and pinning for resident pieces.
3
- *
4
- * The pinning cases matter more than the ordering ones: proxy 2.9.71 removed a
5
- * torrent's data while a reader was mid-read, and every later read hung. Here
6
- * that is meant to be impossible by construction, so it is worth stating in
7
- * tests rather than trusting to eviction order.
8
- */
9
-
10
- import test from "node:test";
11
- import assert from "node:assert/strict";
12
- import { PieceLru } from "../services/storage/piece-store/piece-lru.js";
13
-
14
- test("evicts the least recently used piece", () => {
15
- const lru = new PieceLru(3);
16
- lru.touch(1);
17
- lru.touch(2);
18
- lru.touch(3);
19
-
20
- assert.equal(lru.evictionCandidate(), 1);
21
- });
22
-
23
- test("using a piece again moves it out of the firing line", () => {
24
- const lru = new PieceLru(3);
25
- lru.touch(1);
26
- lru.touch(2);
27
- lru.touch(3);
28
- lru.touch(1);
29
-
30
- assert.equal(lru.evictionCandidate(), 2);
31
- });
32
-
33
- test("a pinned piece is never offered for eviction", () => {
34
- const lru = new PieceLru(3);
35
- lru.touch(1);
36
- lru.touch(2);
37
- lru.touch(3);
38
- lru.pin(1);
39
-
40
- assert.equal(lru.evictionCandidate(), 2, "the pinned piece was offered up");
41
- });
42
-
43
- test("pins nest, so one reader leaving does not expose the piece", () => {
44
- const lru = new PieceLru(2);
45
- lru.touch(1);
46
- lru.touch(2);
47
-
48
- // Two sessions reading the same piece — the union-window case.
49
- lru.pin(1);
50
- lru.pin(1);
51
- lru.unpin(1);
52
-
53
- assert.ok(lru.isPinned(1), "the piece stopped being held while a reader still had it");
54
- assert.equal(lru.evictionCandidate(), 2);
55
-
56
- lru.unpin(1);
57
- assert.equal(lru.isPinned(1), false);
58
- });
59
-
60
- test("reports no candidate rather than evicting a piece in use", () => {
61
- const lru = new PieceLru(2);
62
- lru.touch(1);
63
- lru.touch(2);
64
- lru.pin(1);
65
- lru.pin(2);
66
-
67
- assert.equal(
68
- lru.evictionCandidate(),
69
- null,
70
- "with every piece held, the caller must wait — not have memory taken from under it"
71
- );
72
- });
73
-
74
- test("unpinning something that was never pinned is harmless", () => {
75
- const lru = new PieceLru(2);
76
- lru.touch(1);
77
- lru.unpin(1);
78
- lru.unpin(1);
79
-
80
- assert.equal(lru.isPinned(1), false);
81
- assert.equal(lru.evictionCandidate(), 1);
82
- });
83
-
84
- test("removal takes a piece out of the ordering", () => {
85
- const lru = new PieceLru(3);
86
- lru.touch(1);
87
- lru.touch(2);
88
- lru.remove(1);
89
-
90
- assert.equal(lru.has(1), false);
91
- assert.equal(lru.evictionCandidate(), 2);
92
- assert.equal(lru.size, 1);
93
- });
94
-
95
- test("fullness follows capacity", () => {
96
- const lru = new PieceLru(2);
97
- assert.equal(lru.isFull(), false);
98
- lru.touch(1);
99
- lru.touch(2);
100
- assert.equal(lru.isFull(), true);
101
- lru.remove(1);
102
- assert.equal(lru.isFull(), false);
103
- });
104
-
105
- test("a nonsensical capacity is refused at construction", () => {
106
- assert.throws(() => new PieceLru(0), /positive integer/);
107
- assert.throws(() => new PieceLru(-1), /positive integer/);
108
- assert.throws(() => new PieceLru(1.5), /positive integer/);
109
- });
110
-
111
- test("a declared window is the last thing evicted, not the first", () => {
112
- const lru = new PieceLru(4);
113
- // Oldest first: 10 was touched longest ago, so plain recency would take it.
114
- for (const index of [10, 11, 12, 13]) {
115
- lru.touch(index);
116
- }
117
- lru.protect("reader-a", 10, 11);
118
-
119
- assert.equal(
120
- lru.evictionCandidate(),
121
- 12,
122
- "recency alone would have taken 10; the reader said it is about to read it"
123
- );
124
- });
125
-
126
- test("protection yields when everything resident is protected", () => {
127
- // The case the store cannot survive if protection were absolute: at its
128
- // smallest budget only two pieces are resident, and both belong to a window.
129
- const lru = new PieceLru(2);
130
- lru.touch(4);
131
- lru.touch(5);
132
- lru.protect("reader-a", 4, 5);
133
-
134
- assert.equal(lru.evictionCandidate(), 4, "a full store must still be able to admit a piece");
135
- });
136
-
137
- test("a pin is never overruled, protected or not", () => {
138
- const lru = new PieceLru(2);
139
- lru.touch(7);
140
- lru.touch(8);
141
- lru.pin(7);
142
- lru.protect("reader-a", 7, 8);
143
-
144
- assert.equal(lru.evictionCandidate(), 8, "the piece being read now must stay");
145
- lru.pin(8);
146
- assert.equal(lru.evictionCandidate(), null, "nothing may be taken from under a reader");
147
- });
148
-
149
- test("windows of several readers add up, and leaving takes only your own", () => {
150
- const lru = new PieceLru(6);
151
- for (const index of [20, 21, 22, 23, 24, 25]) {
152
- lru.touch(index);
153
- }
154
- lru.protect("reader-a", 20, 21);
155
- lru.protect("reader-b", 22, 23);
156
- assert.equal(lru.evictionCandidate(), 24);
157
-
158
- lru.unprotect("reader-b");
159
- assert.equal(lru.evictionCandidate(), 22, "b's window is free once b has gone");
160
- assert.equal(lru.protectedCount, 1);
161
- });
162
-
163
- test("a reader moving its window replaces it instead of accumulating", () => {
164
- const lru = new PieceLru(4);
165
- for (const index of [30, 31, 32, 33]) {
166
- lru.touch(index);
167
- }
168
- lru.protect("reader-a", 30, 31);
169
- lru.protect("reader-a", 32, 33);
170
-
171
- assert.equal(lru.evictionCandidate(), 30, "the pieces already read are free again");
172
- assert.equal(lru.protectedCount, 1);
173
- });
174
-
175
- test("the capacity follows the store's live allowance", () => {
176
- const lru = new PieceLru(4);
177
- for (const index of [40, 41]) {
178
- lru.touch(index);
179
- }
180
- assert.equal(lru.isFull(), false, "two of four is not full");
181
-
182
- // The store's allowance moves with the machine's free memory, and the LRU is
183
- // told. Before this it kept the capacity it was built with for ever, so
184
- // `isFull` answered against a number that had stopped being the limit.
185
- lru.setCapacity(2);
186
- assert.equal(lru.capacity, 2);
187
- assert.equal(lru.isFull(), true, "two of two is full");
188
-
189
- lru.setCapacity(0);
190
- assert.equal(lru.capacity, 2, "a capacity below one is refused, not obeyed");
191
- });
192
-
193
- test("the demand is the union of the readers' windows, not their sum", () => {
194
- const lru = new PieceLru(88);
195
- // Two readers of one file — picture and sound — overlap by construction.
196
- // Summing them would say the store is short when it is not.
197
- lru.protect("video", 100, 149);
198
- lru.protect("audio", 130, 179);
199
-
200
- const demand = lru.demand();
201
- assert.equal(demand.readers, 2);
202
- // NAMED, because the count read as five encoders on a session that had two:
203
- // four of the five were zones of the priority map, and only the names could
204
- // say so.
205
- assert.deepEqual(demand.names, ["audio", "video"]);
206
- assert.equal(demand.unionPieces, 80, "100..179 is eighty pieces, not a hundred");
207
- assert.equal(demand.widestPieces, 50);
208
- assert.equal(demand.capacity, 88);
209
-
210
- lru.protect("second-viewer", 900, 979);
211
- assert.equal(lru.demand().unionPieces, 160, "windows that do not touch add up");
212
-
213
- lru.unprotect("second-viewer");
214
- lru.unprotect("audio");
215
- lru.unprotect("video");
216
- assert.deepEqual(
217
- lru.demand(),
218
- { readers: 0, names: [], unionPieces: 0, widestPieces: 0, capacity: 88 },
219
- "no reader asking for anything is not the same as asking for one piece"
220
- );
221
- });
222
-
223
- test("an eviction says whether it had to take a piece a reader declared", () => {
224
- const lru = new PieceLru(3);
225
- lru.touch(10);
226
- lru.touch(11);
227
- lru.touch(12);
228
- lru.protect("video", 11, 12);
229
-
230
- const spare = lru.evictionChoice();
231
- assert.equal(spare.index, 10, "the piece outside every window goes first");
232
- assert.equal(spare.protectionYielded, false);
233
- assert.equal(spare.distance, 1, "one piece away from the window at 11");
234
-
235
- // Nothing spare left: both survivors are inside the declared window.
236
- lru.remove(10);
237
- const forced = lru.evictionChoice();
238
- assert.equal(forced.index, 11);
239
- assert.equal(forced.protectionYielded, true, "the store is holding less than it is asked to");
240
- assert.equal(forced.distance, 0, "inside a window");
241
-
242
- assert.equal(lru.evictionCandidate(), 11, "the older answer is the same choice");
243
- });
244
-
245
- test("with nothing declared there is no distance to report", () => {
246
- const lru = new PieceLru(2);
247
- lru.touch(7);
248
- const choice = lru.evictionChoice();
249
- assert.equal(choice.index, 7);
250
- assert.equal(choice.distance, -1, "-1 is absence, and 0 would read as inside a window");
251
-
252
- lru.pin(7);
253
- assert.deepEqual(
254
- lru.evictionChoice(),
255
- { index: null, protectionYielded: false, distance: -1 },
256
- "a pinned piece is never a candidate, and says so without a distance"
257
- );
258
- });
259
-
260
- test("the least wanted piece goes, however recently it was touched", () => {
261
- // Field 2026-09-05: fifteen reads declared fifteen windows on a store holding
262
- // sixteen pieces, and with everything resident declared, eviction fell back
263
- // to recency — 392 of 780 evictions took a piece a reader had said it wanted.
264
- // Recency cannot separate them; the priority map's number can.
265
- const lru = new PieceLru(3);
266
- for (const index of [10, 20, 30]) {
267
- lru.touch(index);
268
- }
269
- // 10 is the stalest, and the map wants it most: it is where a viewer is.
270
- lru.protect("map:blocked", 10, 10, 0);
271
- lru.protect("map:ahead", 20, 20, 2);
272
- lru.protect("map:tail", 30, 30, 3);
273
-
274
- const choice = lru.evictionChoice();
275
- assert.equal(choice.index, 30, "eviction took the piece the map wants most");
276
- assert.equal(choice.protectionYielded, true, "every resident piece was wanted, and it said so");
277
- });
278
-
279
- test("recency decides only between pieces the map wants equally", () => {
280
- const lru = new PieceLru(3);
281
- for (const index of [10, 20, 30]) {
282
- lru.touch(index);
283
- }
284
- lru.protect("map:blocked", 10, 10, 0);
285
- lru.protect("map:ahead", 20, 30, 2);
286
-
287
- // 20 and 30 sit in one zone, so the stalest of the two goes.
288
- assert.equal(lru.evictionCandidate(), 20);
289
- lru.touch(20);
290
- assert.equal(lru.evictionCandidate(), 30);
291
- });
292
-
293
- test("a piece no zone covers goes before any piece a zone covers", () => {
294
- const lru = new PieceLru(4);
295
- for (const index of [10, 20, 30, 40]) {
296
- lru.touch(index);
297
- }
298
- // Only the last-touched piece is outside every zone — and it still goes
299
- // first, because nobody has said they will read it.
300
- lru.protect("map:tail", 10, 30, 4);
301
-
302
- const choice = lru.evictionChoice();
303
- assert.equal(choice.index, 40);
304
- assert.equal(choice.protectionYielded, false, "nothing the map wanted was taken");
305
- });
306
-
307
- test("a claimant that states no number cannot displace one that did", () => {
308
- const lru = new PieceLru(2);
309
- lru.touch(10);
310
- lru.touch(20);
311
- lru.protect("map:tail", 10, 10, 4);
312
- lru.protect("nameless", 20, 20);
313
-
314
- assert.equal(
315
- lru.evictionCandidate(),
316
- 20,
317
- "a range with no stated number was treated as more wanted than the map's own"
318
- );
319
- });
@@ -1,162 +0,0 @@
1
- /**
2
- * @file Turning a byte range into positions in shared memory.
3
- *
4
- * This arithmetic fails silently when it is wrong: the response comes back the
5
- * right length and full of the wrong bytes. Piece numbers are torrent-wide
6
- * while a read is expressed in file coordinates, and the first and last pieces
7
- * of a range are almost always partial — so every case here is a boundary.
8
- */
9
-
10
- import test from "node:test";
11
- import assert from "node:assert/strict";
12
- import { EventEmitter } from "node:events";
13
- import { readFragments } from "../services/torrent/worker/piece-reader.js";
14
- import { SharedPieceStore } from "../services/storage/piece-store/shared-piece-store.js";
15
- import os from "node:os";
16
- import path from "node:path";
17
- import fs from "node:fs/promises";
18
-
19
- const PIECE = 1024;
20
-
21
- /**
22
- * A torrent whose pieces are all present, backed by a real store so that
23
- * `locate`/`reside`/`pin` behave as they do in production.
24
- *
25
- * @param {{ fileOffset: number, fileLength: number, totalLength: number }} shape
26
- */
27
- async function fakeTorrent({ fileOffset, fileLength, totalLength }) {
28
- const directory = await fs.mkdtemp(path.join(os.tmpdir(), "piece-reader-test-"));
29
- const store = new SharedPieceStore(PIECE, {
30
- length: totalLength,
31
- memoryBytes: 64 * PIECE,
32
- path: directory,
33
- name: "test"
34
- });
35
-
36
- const pieceCount = Math.ceil(totalLength / PIECE);
37
- for (let index = 0; index < pieceCount; index += 1) {
38
- const length = index === pieceCount - 1 ? totalLength - index * PIECE : PIECE;
39
- const piece = Buffer.alloc(length);
40
- // Each byte encodes its own absolute position, so a misplaced offset is
41
- // visible in the value itself rather than only in the length.
42
- for (let at = 0; at < length; at += 1) {
43
- piece[at] = (index * PIECE + at) % 251;
44
- }
45
- await new Promise((resolve, reject) => {
46
- store.put(index, piece, (error) => (error ? reject(error) : resolve()));
47
- });
48
- }
49
-
50
- const torrent = Object.assign(new EventEmitter(), {
51
- pieceLength: PIECE,
52
- store,
53
- bitfield: { get: () => true },
54
- files: [{ offset: fileOffset, length: fileLength, name: "file.bin" }],
55
- select() {},
56
- critical() {}
57
- });
58
-
59
- return { torrent, store, directory };
60
- }
61
-
62
- /** Collect a range through the reader, as the worker does. */
63
- async function readRange(torrent, start, end) {
64
- const collected = [];
65
- const positions = [];
66
- for await (const fragment of readFragments({
67
- torrent,
68
- fileIndex: 0,
69
- start,
70
- end,
71
- cancellation: { isCancelled: () => false }
72
- })) {
73
- const source = fragment.buffer ? Buffer.from(fragment.buffer, fragment.offset, fragment.length) : Buffer.alloc(0);
74
- collected.push(Buffer.from(source));
75
- positions.push({ piece: fragment.pieceIndex, length: fragment.length });
76
- fragment.release();
77
- }
78
- return { bytes: Buffer.concat(collected), positions };
79
- }
80
-
81
- /** What the bytes at an absolute torrent offset should be. */
82
- function expectedBytes(absoluteStart, length) {
83
- const expected = Buffer.alloc(length);
84
- for (let at = 0; at < length; at += 1) {
85
- expected[at] = (absoluteStart + at) % 251;
86
- }
87
- return expected;
88
- }
89
-
90
- test("a range inside one piece is read from that piece only", async () => {
91
- const { torrent, store, directory } = await fakeTorrent({
92
- fileOffset: 0,
93
- fileLength: 4 * PIECE,
94
- totalLength: 4 * PIECE
95
- });
96
- try {
97
- const { bytes, positions } = await readRange(torrent, 100, 199);
98
- assert.equal(positions.length, 1);
99
- assert.deepEqual(bytes, expectedBytes(100, 100));
100
- } finally {
101
- store.destroy(() => undefined);
102
- await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
103
- }
104
- });
105
-
106
- test("a range spanning pieces reassembles in order", async () => {
107
- const { torrent, store, directory } = await fakeTorrent({
108
- fileOffset: 0,
109
- fileLength: 4 * PIECE,
110
- totalLength: 4 * PIECE
111
- });
112
- try {
113
- const start = PIECE - 10;
114
- const end = 2 * PIECE + 9;
115
- const { bytes, positions } = await readRange(torrent, start, end);
116
- assert.deepEqual(positions.map((entry) => entry.piece), [0, 1, 2]);
117
- assert.deepEqual(bytes, expectedBytes(start, end - start + 1));
118
- } finally {
119
- store.destroy(() => undefined);
120
- await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
121
- }
122
- });
123
-
124
- test("a file that does not start at a piece boundary is still read correctly", async () => {
125
- // The usual case in a multi-file torrent, and the one where using file
126
- // offsets as if they were torrent offsets returns the wrong bytes at the
127
- // right length.
128
- const fileOffset = PIECE + 300;
129
- const { torrent, store, directory } = await fakeTorrent({
130
- fileOffset,
131
- fileLength: 2 * PIECE,
132
- totalLength: 5 * PIECE
133
- });
134
- try {
135
- const { bytes } = await readRange(torrent, 0, 1499);
136
- assert.deepEqual(bytes, expectedBytes(fileOffset, 1500));
137
- } finally {
138
- store.destroy(() => undefined);
139
- await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
140
- }
141
- });
142
-
143
- test("every fragment releases its pin, so nothing stays held", async () => {
144
- const { torrent, store, directory } = await fakeTorrent({
145
- fileOffset: 0,
146
- fileLength: 4 * PIECE,
147
- totalLength: 4 * PIECE
148
- });
149
- try {
150
- await readRange(torrent, 0, 4 * PIECE - 1);
151
- // With every piece unpinned the store can still make room; if a pin leaked
152
- // it would eventually refuse.
153
- const before = store.stats().blockedByPins;
154
- for (let index = 0; index < 200; index += 1) {
155
- await store.reside(index % 4);
156
- }
157
- assert.equal(store.stats().blockedByPins, before, "a pin was left behind");
158
- } finally {
159
- store.destroy(() => undefined);
160
- await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
161
- }
162
- });
@@ -1,85 +0,0 @@
1
- /**
2
- * @file What WebTorrent's own selection bookkeeping does when a seek demotes
3
- * the pieces behind the playhead.
4
- *
5
- * This is the library's behaviour, not ours, and `prioritizeByteRange` depends
6
- * on it: a selection carries an `offset` — how many pieces from its start are
7
- * already downloaded — and the picker scans from `from + offset`
8
- * (`torrent.js`, `for (piece = next.from + next.offset; piece <= next.to; …)`).
9
- * `deselect` subtracts an interval and copies that offset into what survives,
10
- * so the remaining selection can end up scanning past its own end and yield
11
- * nothing at all.
12
- *
13
- * Measured consequence before the fix: a seek to 89.1% of a 4.7 GB film left
14
- * the seek target wanted by nobody, a later range-less read re-selected the
15
- * whole file, and the swarm walked it from the first missing piece — 2.47 GB
16
- * over 93 s before the segment could be served.
17
- *
18
- * If a WebTorrent upgrade changes any of this, these tests fail rather than the
19
- * behaviour silently regressing.
20
- */
21
-
22
- import test from "node:test";
23
- import assert from "node:assert/strict";
24
- import { Selections } from "webtorrent/lib/selections.js";
25
-
26
- // The numbers are the measured session: 588 pieces of 8 MiB, sequential
27
- // download had reached 38.4% (piece 226), the viewer seeked to 89.1% (piece
28
- // 523).
29
- const LAST_PIECE = 587;
30
- const DOWNLOADED_TO = 226;
31
- const PLAYHEAD = 523;
32
-
33
- /** Where the picker would start scanning this selection. */
34
- const scanStart = (selection) => selection.from + selection.offset;
35
-
36
- test("deselecting the gap behind the playhead leaves a selection that yields nothing", () => {
37
- const selections = new Selections();
38
- selections.insert({ from: 0, to: LAST_PIECE, offset: 0, priority: 1 });
39
- // What `_gcSelections` does as pieces arrive.
40
- selections.get(0).offset = DOWNLOADED_TO;
41
-
42
- selections.remove({ from: 0, to: PLAYHEAD - 1, isStreamSelection: false });
43
-
44
- assert.equal(selections.length, 1);
45
- const survivor = selections.get(0);
46
- assert.equal(survivor.from, PLAYHEAD, "the surviving selection starts at the playhead");
47
- assert.equal(survivor.offset, DOWNLOADED_TO, "and it kept the offset of the range it came from");
48
- assert.ok(
49
- scanStart(survivor) > survivor.to,
50
- `scan would start at piece ${scanStart(survivor)} of ${survivor.to} — nothing is downloadable`
51
- );
52
- });
53
-
54
- test("re-selecting the same range restores a scan that starts at the playhead", () => {
55
- const selections = new Selections();
56
- selections.insert({ from: 0, to: LAST_PIECE, offset: 0, priority: 1 });
57
- selections.get(0).offset = DOWNLOADED_TO;
58
-
59
- // Exactly what prioritizeByteRange does on a forward seek.
60
- selections.remove({ from: 0, to: PLAYHEAD - 1, isStreamSelection: false });
61
- selections.insert({ from: PLAYHEAD, to: LAST_PIECE, offset: 0, priority: 1 });
62
-
63
- assert.equal(selections.length, 1, "the dead selection was replaced, not added to");
64
- const selection = selections.get(0);
65
- assert.equal(scanStart(selection), PLAYHEAD, "the picker now starts at the seek target");
66
- assert.equal(selection.to, LAST_PIECE);
67
- });
68
-
69
- test("selecting the whole file again undoes the demotion", () => {
70
- const selections = new Selections();
71
- selections.insert({ from: 0, to: LAST_PIECE, offset: 0, priority: 1 });
72
- selections.get(0).offset = DOWNLOADED_TO;
73
- selections.remove({ from: 0, to: PLAYHEAD - 1, isStreamSelection: false });
74
- selections.insert({ from: PLAYHEAD, to: LAST_PIECE, offset: 0, priority: 1 });
75
-
76
- // A range-less read reporting position 0 used to land here.
77
- selections.insert({ from: 0, to: LAST_PIECE, offset: 0, priority: 1 });
78
-
79
- assert.equal(selections.length, 1);
80
- assert.equal(
81
- scanStart(selections.get(0)),
82
- 0,
83
- "the whole file is selected again, so the picker falls back to the first missing piece"
84
- );
85
- });