@torrent-tv/proxy 2.89.16 → 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/routes/api/client-logs/post.js +9 -2
  4. package/server.js +0 -11
  5. package/.claude/commands/opsx/apply.md +0 -155
  6. package/.claude/commands/opsx/archive.md +0 -160
  7. package/.claude/commands/opsx/explore.md +0 -174
  8. package/.claude/commands/opsx/propose.md +0 -109
  9. package/.claude/commands/opsx/sync.md +0 -143
  10. package/.claude/skills/openspec-apply-change/SKILL.md +0 -159
  11. package/.claude/skills/openspec-archive-change/SKILL.md +0 -117
  12. package/.claude/skills/openspec-explore/SKILL.md +0 -289
  13. package/.claude/skills/openspec-propose/SKILL.md +0 -113
  14. package/.claude/skills/openspec-sync-specs/SKILL.md +0 -147
  15. package/.dockerignore +0 -5
  16. package/AGENTS.md +0 -363
  17. package/CHANGELOG.md +0 -2373
  18. package/Dockerfile +0 -30
  19. package/biome.json +0 -412
  20. package/corpus.tmp.json +0 -1
  21. package/docs/browser-proxy-contract.md +0 -155
  22. package/docs/container-architecture.md +0 -273
  23. package/docs/download-architecture.md +0 -261
  24. package/docs/encode-architecture.md +0 -765
  25. package/docs/encode-run-state.md +0 -96
  26. package/docs/logs.md +0 -138
  27. package/docs/playback-start-forecast.md +0 -113
  28. package/docs/socks5-egress-research.md +0 -114
  29. package/docs/storage-architecture.md +0 -273
  30. package/knip.json +0 -14
  31. package/main2.cpuprofile +0 -1
  32. package/openspec/changes/adaptive-bitrate/.openspec.yaml +0 -0
  33. package/openspec/changes/adaptive-bitrate/design.md +0 -92
  34. package/openspec/changes/adaptive-bitrate/proposal.md +0 -56
  35. package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +0 -51
  36. package/openspec/changes/adaptive-bitrate/tasks.md +0 -40
  37. package/openspec/changes/binary-distribution/.openspec.yaml +0 -2
  38. package/openspec/changes/binary-distribution/proposal.md +0 -53
  39. package/openspec/changes/chunked-request-bodies/design.md +0 -81
  40. package/openspec/changes/chunked-request-bodies/proposal.md +0 -53
  41. package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +0 -30
  42. package/openspec/changes/chunked-request-bodies/tasks.md +0 -40
  43. package/openspec/changes/cold-start/design.md +0 -98
  44. package/openspec/changes/cold-start/proposal.md +0 -62
  45. package/openspec/changes/cold-start/specs/cold-start/spec.md +0 -47
  46. package/openspec/changes/cold-start/tasks.md +0 -48
  47. package/openspec/changes/disk-cap/.openspec.yaml +0 -2
  48. package/openspec/changes/disk-cap/proposal.md +0 -37
  49. package/openspec/changes/disk-cap/specs/disk-cap/spec.md +0 -25
  50. package/openspec/changes/disk-cap/tasks.md +0 -19
  51. package/openspec/changes/proxy-observability/.openspec.yaml +0 -2
  52. package/openspec/changes/proxy-observability/design.md +0 -38
  53. package/openspec/changes/proxy-observability/proposal.md +0 -49
  54. package/openspec/changes/proxy-observability/specs/observability/spec.md +0 -33
  55. package/openspec/changes/proxy-observability/tasks.md +0 -21
  56. package/openspec/changes/seek-debounce/.openspec.yaml +0 -0
  57. package/openspec/changes/seek-debounce/design.md +0 -118
  58. package/openspec/changes/seek-debounce/proposal.md +0 -59
  59. package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +0 -41
  60. package/openspec/changes/seek-debounce/tasks.md +0 -33
  61. package/openspec/changes/subtitle-language/.openspec.yaml +0 -2
  62. package/openspec/changes/subtitle-language/proposal.md +0 -50
  63. package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +0 -34
  64. package/openspec/changes/subtitle-language/tasks.md +0 -26
  65. package/openspec/changes/track-selection/.openspec.yaml +0 -2
  66. package/openspec/changes/track-selection/design.md +0 -47
  67. package/openspec/changes/track-selection/proposal.md +0 -45
  68. package/openspec/changes/track-selection/specs/track-selection/spec.md +0 -41
  69. package/openspec/changes/track-selection/tasks.md +0 -20
  70. package/openspec/changes/transcode-quality/.openspec.yaml +0 -2
  71. package/openspec/changes/transcode-quality/proposal.md +0 -51
  72. package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +0 -139
  73. package/openspec/changes/transcode-quality/tasks.md +0 -63
  74. package/openspec/config.yaml +0 -38
  75. package/public/.well-known/appspecific/com.chrome.devtools.json +0 -1
  76. package/research/delivery-wedge-stand-ladder-2026-09-13.md +0 -329
  77. package/research/double-spawn-2026-09-10.md +0 -171
  78. package/research/handover-2026-09-13.md +0 -209
  79. package/research/handover-reader-claims-removal-2026-09-12.md +0 -166
  80. package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +0 -175
  81. package/research/preset-benchmark-size-scaling-2026-08-22.md +0 -55
  82. package/research/priority-map-is-the-truth-2026-09-12.md +0 -225
  83. package/research/valgrind-replica-2026-08-27/logs.tar.gz +0 -0
  84. package/scripts/build-audio-samples.mjs +0 -31
  85. package/scripts/render-run-graph.js +0 -173
  86. package/test/asset-paths.test.js +0 -28
  87. package/test/audio-buffer-continuity.test.js +0 -77
  88. package/test/audio-calibration.test.js +0 -23
  89. package/test/audio-inventory.test.js +0 -212
  90. package/test/audio-track-bitrate.test.js +0 -40
  91. package/test/audio-warm-recorded.test.js +0 -63
  92. package/test/auto-quality-step.test.js +0 -762
  93. package/test/available-share.test.js +0 -76
  94. package/test/background-fill.test.js +0 -190
  95. package/test/client-log-file.test.js +0 -126
  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,153 +0,0 @@
1
- /**
2
- * @file A store that is short of memory must inconvenience a read, never end a
3
- * torrent.
4
- *
5
- * The field failure of 2026-09-11, in one sentence: the store could not hand
6
- * out a block, threw, the throw travelled out through the torrent client's own
7
- * write callback, and the client destroyed the torrent. For the rest of the
8
- * process every read of that film answered `File 1 not found in
9
- * torrent:d4022ff4…`, `/stats` reported `peers=0 connected of 1186 known`, and
10
- * the viewer could not open anything until the addon was restarted.
11
- *
12
- * Three properties hold it shut, and each is checked here on its own:
13
- *
14
- * 1. an arriving piece is never refused — it has the disk;
15
- * 2. a read on the torrent client's own path takes no block at all, so an
16
- * upload can neither wait for memory nor be refused it;
17
- * 3. a store between readers keeps room for one window, because that is what
18
- * the next read asks for and the allowance is otherwise re-derived a
19
- * minute later — twelve times slower than a claim gives up.
20
- */
21
-
22
- import test from "node:test";
23
- import assert from "node:assert/strict";
24
- import fs from "node:fs/promises";
25
- import os from "node:os";
26
- import path from "node:path";
27
- import { SharedPieceStore } from "../services/storage/piece-store/shared-piece-store.js";
28
-
29
- const PIECE = 1024;
30
-
31
- /**
32
- * @param {SharedPieceStore} store
33
- * @param {number} index
34
- * @returns {Promise<void>}
35
- */
36
- const put = (store, index) =>
37
- new Promise((resolve, reject) => {
38
- store.put(index, Buffer.alloc(PIECE, index % 251), (error) => (error ? reject(error) : resolve()));
39
- });
40
-
41
- /**
42
- * @param {SharedPieceStore} store
43
- * @param {number} index
44
- * @param {{ offset: number, length: number }} range
45
- * @returns {Promise<Buffer>}
46
- */
47
- const get = (store, index, range) =>
48
- new Promise((resolve, reject) => {
49
- store.get(index, range, (error, bytes) => (error ? reject(error) : resolve(bytes)));
50
- });
51
-
52
- /**
53
- * @returns {Promise<string>}
54
- */
55
- const directory = () => fs.mkdtemp(path.join(os.tmpdir(), "never-refuses-"));
56
-
57
- test("a piece is never refused for want of memory", async () => {
58
- const root = await directory();
59
- // Every block held by a piece that may not leave. The ceiling never falls
60
- // below two, so this is how a store with nothing to give is reached: what
61
- // held the blocks in the field was two writes on their way to disk, and a pin
62
- // reaches the same state deterministically.
63
- //
64
- // This one takes the store's own patience to run — it must be seen to give
65
- // up and keep the piece anyway.
66
- const store = new SharedPieceStore(PIECE, {
67
- length: PIECE * 8,
68
- memoryBytes: PIECE * 2,
69
- path: root,
70
- name: "no-block-to-be-had"
71
- });
72
- try {
73
- await put(store, 0);
74
- await put(store, 1);
75
- store.pin(0);
76
- store.pin(1);
77
-
78
- await put(store, 3);
79
- const stats = store.stats();
80
- assert.equal(stats.spilled, 1, "the piece is on disk");
81
- assert.equal(store.locate(3), null, "and not in memory, since no block could be had for it");
82
- assert.equal(stats.resident, 2, "the pinned pieces were not taken from under their reader");
83
- assert.ok(
84
- stats.admittedWithoutSlot >= 1,
85
- "and the store says so, rather than the torrent client saying it with a destroyed torrent"
86
- );
87
- const bytes = await get(store, 3, { offset: 0, length: PIECE });
88
- assert.equal(bytes.length, PIECE, "and it reads back");
89
- assert.equal(bytes[0], 3 % 251, "with its own contents");
90
- } finally {
91
- await new Promise((resolve) => store.destroy(resolve));
92
- await fs.rm(root, { recursive: true, force: true });
93
- }
94
- });
95
-
96
- test("a read for the torrent client takes no block and only its own range", async () => {
97
- const root = await directory();
98
- const store = new SharedPieceStore(PIECE, {
99
- length: PIECE * 8,
100
- memoryBytes: PIECE,
101
- path: root,
102
- name: "ranged-read"
103
- });
104
- try {
105
- // Two fit; the third displaces the oldest, which goes to disk.
106
- await put(store, 0);
107
- await put(store, 1);
108
- await put(store, 2);
109
- const before = store.stats();
110
- assert.equal(before.spilled, 1, "the first piece is on disk");
111
-
112
- const wanted = 16;
113
- const bytes = await get(store, 0, { offset: PIECE - wanted, length: wanted });
114
- assert.equal(bytes.length, wanted, "only what was asked for comes back");
115
- assert.equal(bytes[0], 0, "and it is that piece's own bytes");
116
-
117
- const after = store.stats();
118
- assert.equal(
119
- after.blocksAllocated,
120
- before.blocksAllocated,
121
- "and answering it took no block: a peer asks for kilobytes and a piece here is megabytes"
122
- );
123
- assert.equal(after.resident, before.resident, "nothing was revived for it");
124
- assert.equal(after.fromDisk, before.fromDisk + 1, "and the read is counted as coming from disk");
125
- } finally {
126
- await new Promise((resolve) => store.destroy(resolve));
127
- await fs.rm(root, { recursive: true, force: true });
128
- }
129
- });
130
-
131
- test("a store between readers keeps room for one window", async () => {
132
- const root = await directory();
133
- const store = new SharedPieceStore(PIECE, {
134
- length: PIECE * 200,
135
- memoryBytes: PIECE * 64,
136
- path: root,
137
- name: "between-readers"
138
- });
139
- try {
140
- store.protectRange("read-1", 10, 21, 100);
141
- const asked = store.wantedBytes;
142
- assert.ok(asked >= PIECE * 12, "with a reader, the window it declared is asked for");
143
-
144
- store.releaseProtection("read-1");
145
- assert.ok(
146
- store.wantedBytes >= PIECE * 12,
147
- "and with the reader gone the floor is still one window — the next read asks for the same again"
148
- );
149
- } finally {
150
- await new Promise((resolve) => store.destroy(resolve));
151
- await fs.rm(root, { recursive: true, force: true });
152
- }
153
- });
@@ -1,403 +0,0 @@
1
- /**
2
- * @file Room for a piece is an owned reservation, and it comes back every time.
3
- *
4
- * Each case here is a defect read out of the field failure of 2026-08-31
5
- * (`research/worker-heap-oom-2026-08-31.md`, §5), where the torrent worker was
6
- * found holding reservations nobody could return. They all need the disk tier
7
- * to be slow, or to fail, at a moment the caller chooses — which is what
8
- * `options.disk` is for.
9
- */
10
-
11
- import test from "node:test";
12
- import assert from "node:assert/strict";
13
- import { SharedPieceStore } from "../services/storage/piece-store/shared-piece-store.js";
14
-
15
- const CHUNK = 1024;
16
-
17
- /**
18
- * A disk tier the test drives: writes can be held open and either side can be
19
- * made to fail.
20
- *
21
- * @param {{ failWrite?: boolean, failRead?: boolean, holdWrites?: boolean }} [behaviour]
22
- */
23
- function makeDisk({ failWrite = false, failRead = false, holdWrites = false } = {}) {
24
- const stored = new Map();
25
- const reading = new Map();
26
- /** @type {Array<() => void>} */
27
- const held = [];
28
- return {
29
- stored,
30
- /** Let every held write finish. */
31
- releaseWrites() {
32
- const waiting = held.splice(0, held.length);
33
- for (const resume of waiting) {
34
- resume();
35
- }
36
- },
37
- finishWrites() {
38
- holdWrites = false;
39
- this.releaseWrites();
40
- },
41
- get heldCount() {
42
- return held.length;
43
- },
44
- get size() {
45
- return stored.size;
46
- },
47
- get bytes() {
48
- let total = 0;
49
- for (const value of stored.values()) {
50
- total += value.length;
51
- }
52
- return total;
53
- },
54
- get path() {
55
- return ".";
56
- },
57
- get allowanceBytes() {
58
- return null;
59
- },
60
- reviseAllowance() {
61
- return null;
62
- },
63
- stats() {
64
- return { pieces: stored.size, bytes: this.bytes, allowanceBytes: null, evictions: 0 };
65
- },
66
- has(index) {
67
- return stored.has(index);
68
- },
69
- hold(index) {
70
- if (!stored.has(index)) return null;
71
- reading.set(index, (reading.get(index) ?? 0) + 1);
72
- return () => {
73
- const count = reading.get(index) - 1;
74
- if (count > 0) reading.set(index, count);
75
- else reading.delete(index);
76
- };
77
- },
78
- async write(index, bytes) {
79
- if (holdWrites) {
80
- await new Promise((resolve) => held.push(resolve));
81
- }
82
- if (failWrite) {
83
- throw new Error("the disk refused the write");
84
- }
85
- stored.set(index, Buffer.from(bytes));
86
- },
87
- async read(index, target) {
88
- if (failRead) {
89
- throw new Error("the disk refused the read");
90
- }
91
- const bytes = stored.get(index);
92
- if (!bytes) {
93
- throw new Error(`Piece ${index} is not on disk.`);
94
- }
95
- bytes.copy(target);
96
- return bytes.length;
97
- },
98
- forget(index) {
99
- if (!reading.has(index)) stored.delete(index);
100
- },
101
- async close() {},
102
- async destroy() {
103
- stored.clear();
104
- }
105
- };
106
- }
107
-
108
- /**
109
- * @param {object} [options]
110
- * @returns {{ store: SharedPieceStore, disk: ReturnType<typeof makeDisk> }}
111
- */
112
- function makeStore({ pieces = 2, totalPieces = 16, disk = makeDisk() } = {}) {
113
- const store = new SharedPieceStore(CHUNK, {
114
- length: CHUNK * totalPieces,
115
- memoryBytes: CHUNK * pieces,
116
- disk,
117
- name: "test"
118
- });
119
- return { store, disk };
120
- }
121
-
122
- /**
123
- * @param {number} index
124
- * @returns {Buffer}
125
- */
126
- function piece(index) {
127
- const bytes = Buffer.allocUnsafeSlow(CHUNK);
128
- bytes.fill(index % 256);
129
- bytes.writeUInt32BE(index, 0);
130
- return bytes;
131
- }
132
-
133
- const put = (store, index) =>
134
- new Promise((resolve, reject) => store.put(index, piece(index), (error) => (error ? reject(error) : resolve())));
135
- const get = (store, index) =>
136
- new Promise((resolve, reject) =>
137
- store.get(index, undefined, (error, bytes) => (error ? reject(error) : resolve(bytes)))
138
- );
139
-
140
- test("a revival holds disk bytes across the asynchronous memory reservation", async () => {
141
- const { store, disk } = makeStore();
142
- disk.stored.set(0, piece(0));
143
- try {
144
- const revival = store.reside(0);
145
- disk.forget(0);
146
- const located = await revival;
147
- assert.deepEqual(Buffer.from(located.buffer, located.offset, located.length), piece(0));
148
- disk.forget(0);
149
- assert.equal(disk.has(0), false);
150
- } finally {
151
- await new Promise((resolve) => store.destroy(resolve));
152
- }
153
- });
154
-
155
- test("an assembled-file revival returns shared bytes and preserves a concurrent pinned reader", async () => {
156
- const disk = makeDisk();
157
- let reads = 0;
158
- const store = new SharedPieceStore(CHUNK, {
159
- length: CHUNK * 16, memoryBytes: CHUNK * 4, disk,
160
- readPieceElsewhere: async ({ index }) => {
161
- reads += 1;
162
- return piece(index);
163
- }, name: "whole-file-read"
164
- });
165
- try {
166
- store.pin(0);
167
- const [first, second] = await Promise.all([store.reside(0), store.reside(0)]);
168
- assert.ok(first.buffer instanceof SharedArrayBuffer);
169
- assert.equal(first.buffer, second.buffer);
170
- assert.equal(reads, 1, "concurrent readers repeated the whole-file allocation and read");
171
- await put(store, 1);
172
- await put(store, 2);
173
- assert.deepEqual(Buffer.from(first.buffer, first.offset, first.length), piece(0));
174
- assert.equal(store.locate(0).buffer, first.buffer);
175
- assert.equal(store.stats().outstanding, 0);
176
- store.unpin(0);
177
- } finally {
178
- await new Promise((resolve) => store.destroy(resolve));
179
- }
180
- });
181
-
182
- test("a waiting source reader receives a released slot before queued arrivals", async () => {
183
- const disk = makeDisk({ holdWrites: true });
184
- const { store } = makeStore({ pieces: 2, disk });
185
- let arrived = false;
186
- try {
187
- await put(store, 0);
188
- await put(store, 1);
189
- store.pin(1);
190
- disk.stored.set(4, piece(4));
191
- const spilling = put(store, 2);
192
- await until(() => disk.heldCount > 0, "an arriving piece occupies the spill slot");
193
- const arrival = put(store, 3).then(() => { arrived = true; });
194
- store.pin(4);
195
- const reading = store.reside(4);
196
- await until(() => store.stats().waitedForDisk >= 2, "both claims wait behind the spill");
197
- disk.finishWrites();
198
- const located = await reading;
199
- assert.deepEqual(Buffer.from(located.buffer, located.offset, located.length), piece(4));
200
- assert.equal(arrived, false, "an arriving piece overtook an already waiting reader");
201
- store.unpin(1);
202
- store.unpin(4);
203
- await Promise.all([spilling, arrival]);
204
- assert.equal(store.stats().outstanding, 0);
205
- } finally {
206
- disk.finishWrites();
207
- await new Promise((resolve) => store.destroy(resolve));
208
- }
209
- });
210
-
211
- /**
212
- * Wait until a condition holds, rather than for a chosen interval — a test that
213
- * sleeps samples, it does not check (roadmap item 54).
214
- *
215
- * @param {() => boolean} holds
216
- * @param {string} what
217
- * @param {number} [limit] - A backstop, never the measurement: one of the
218
- * conditions here is the store's own patience, which is itself five seconds.
219
- * @returns {Promise<void>}
220
- */
221
- async function until(holds, what, limit = 5_000) {
222
- const deadline = Date.now() + limit;
223
- while (!holds()) {
224
- if (Date.now() > deadline) {
225
- throw new Error(`timed out waiting until ${what}`);
226
- }
227
- await new Promise((resolve) => setImmediate(resolve));
228
- }
229
- }
230
-
231
- test("a revival that fails on the disk gives its slot back", async () => {
232
- const disk = makeDisk({ failRead: true });
233
- const { store } = makeStore({ pieces: 2, disk });
234
- try {
235
- await put(store, 0);
236
- await put(store, 1);
237
- await put(store, 2); // piece 0 is the least recently used, so it spills
238
-
239
- await assert.rejects(() => get(store, 0), /refused the read/);
240
-
241
- assert.equal(
242
- store.stats().outstanding,
243
- 0,
244
- "the slot claimed for the revival was never returned"
245
- );
246
- } finally {
247
- await new Promise((resolve) => store.destroy(resolve));
248
- }
249
- });
250
-
251
- test("a spill that fails gives back the slot the eviction claimed", async () => {
252
- const disk = makeDisk({ failWrite: true });
253
- const { store } = makeStore({ pieces: 2, disk });
254
- try {
255
- await put(store, 0);
256
- await put(store, 1);
257
- await assert.rejects(() => put(store, 2), /refused the write/);
258
-
259
- assert.equal(store.stats().outstanding, 0, "the eviction kept its reservation");
260
- } finally {
261
- await new Promise((resolve) => store.destroy(resolve));
262
- }
263
- });
264
-
265
- // A bound, because the failure this catches is a claim that never ends: without
266
- // it the check does not fail, it HANGS, and `node --test` then cannot finish at
267
- // all — which is what happened between 2026-08-31 and 2026-09-03 (roadmap item
268
- // 54). The store gives up after PINNED_WAIT_MS, so 30 s is ample for the answer
269
- // and short enough to be a failure rather than a stoppage.
270
- test("a claim that cannot be met ends in an error, not in waiting for ever", { timeout: 30_000 }, async () => {
271
- const disk = makeDisk({ holdWrites: true });
272
- const { store } = makeStore({ pieces: 2, disk });
273
- try {
274
- await put(store, 0);
275
- await put(store, 1);
276
-
277
- // Evicts one piece; its write is held, so the store has a spill in flight
278
- // for as long as this test wants.
279
- const spilling = put(store, 2);
280
- await until(() => disk.heldCount > 0, "a write is in flight");
281
-
282
- // Nothing left that may be evicted: one piece is being written out, the
283
- // other is pinned. The old rule waited while anything was nominally in
284
- // flight, which here is for ever.
285
- store.pin(1);
286
- // The claim gives up within its own patience — that is the property. What
287
- // the caller does with the refusal is its own business, and since
288
- // 2026-09-11 an arriving piece answers it by taking the disk instead of
289
- // failing the torrent client's write.
290
- const arriving = put(store, 3);
291
- await until(
292
- () => store.stats().blockedByPins > 0,
293
- "the claim gave up rather than waiting for ever",
294
- 20_000
295
- );
296
-
297
- store.unpin(1);
298
- disk.releaseWrites();
299
- await arriving;
300
- disk.releaseWrites();
301
- await spilling;
302
- } finally {
303
- disk.releaseWrites();
304
- await new Promise((resolve) => store.destroy(resolve));
305
- }
306
- });
307
-
308
- test("closing the store fails whoever is waiting for room", { timeout: 30_000 }, async () => {
309
- const disk = makeDisk({ holdWrites: true });
310
- const { store } = makeStore({ pieces: 2, disk });
311
- try {
312
- await put(store, 0);
313
- await put(store, 1);
314
- const spilling = put(store, 2);
315
- await until(() => disk.heldCount > 0, "a write is in flight");
316
- store.pin(1);
317
-
318
- const waiting = put(store, 3);
319
- // Either counter: the claim waits for the disk first — a block is in flight
320
- // and the store is full, so evicting another piece would only raise the
321
- // memory in use — and reaches the pinned wait five seconds later. Asking for
322
- // `waitedForPins` alone named one of the two ways of waiting and timed out
323
- // while the store was demonstrably doing the other.
324
- await until(
325
- () => store.stats().waitedForPins + store.stats().waitedForDisk > 0,
326
- "the claim is waiting"
327
- );
328
-
329
- await new Promise((resolve) => store.close(resolve));
330
- await assert.rejects(() => waiting, /closed/);
331
-
332
- store.unpin(1);
333
- disk.releaseWrites();
334
- await spilling.catch(() => undefined);
335
- } finally {
336
- disk.releaseWrites();
337
- }
338
- });
339
-
340
- test("a piece written back to memory is not resurrected on disk by its own spill", { timeout: 30_000 }, async () => {
341
- const disk = makeDisk({ holdWrites: true });
342
- const { store } = makeStore({ pieces: 4, disk });
343
- try {
344
- await put(store, 0);
345
- await put(store, 1);
346
- await put(store, 2);
347
-
348
- // Piece 0 leaves memory because the machine's allowance fell; its write is
349
- // still in flight. The allowance then recovers to MORE than it was, so the
350
- // piece coming back needs no eviction and no wait for that write.
351
- //
352
- // Four blocks, not three, and the reason is what this check is about. A
353
- // block being written out is still memory in use, so with a ceiling of three
354
- // and one block in flight the store is full, and the claim correctly waits
355
- // for the disk — which the earlier setup did not allow for, so the check
356
- // timed out on an admission policy it never meant to measure. Whether a
357
- // piece arriving while its OWN spill is in flight should be admitted without
358
- // taking a second block is a real question about the accounting and is
359
- // roadmap item 9, not this check's subject: the subject is that the spill,
360
- // when it completes, must not put the stale copy back on disk.
361
- store.reviseGrowthCeiling(CHUNK * 2);
362
- await until(() => disk.heldCount > 0, "the spill of piece 0 is in flight");
363
- store.reviseGrowthCeiling(CHUNK * 4);
364
-
365
- // The swarm hands piece 0 back while that write is still going. The store
366
- // must drop the disk copy AFTER the write has recorded it, not before —
367
- // `PieceDiskStore.write` adds the index on completion, so an early forget is
368
- // undone and the next read of piece 0 comes back from before the rewrite.
369
- let settled = false;
370
- const rewritten = put(store, 0);
371
- void rewritten.then(() => {
372
- settled = true;
373
- });
374
- await until(() => store.stats().resident === 3, "piece 0 is back in memory");
375
- assert.equal(settled, false, "the write finished without waiting for the piece's own spill");
376
-
377
- disk.releaseWrites();
378
- await rewritten;
379
-
380
- assert.equal(disk.has(0), false, "the completing spill put the stale copy back");
381
- } finally {
382
- disk.releaseWrites();
383
- await new Promise((resolve) => store.destroy(resolve));
384
- }
385
- });
386
-
387
- test("a spill that fails while the allowance is lowered is counted, not thrown", async () => {
388
- const disk = makeDisk({ failWrite: true });
389
- const { store } = makeStore({ pieces: 4, disk });
390
- try {
391
- await put(store, 0);
392
- await put(store, 1);
393
- await put(store, 2);
394
- await put(store, 3);
395
-
396
- store.reviseGrowthCeiling(CHUNK * 2);
397
- await until(() => store.stats().spillFailures > 0, "the failed spills are counted");
398
-
399
- assert.equal(store.stats().outstanding, 0, "lowering the allowance held a reservation");
400
- } finally {
401
- await new Promise((resolve) => store.destroy(resolve));
402
- }
403
- });