@torrent-tv/proxy 2.88.4 → 2.88.6

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 (489) hide show
  1. package/.claude/commands/opsx/apply.md +155 -155
  2. package/.claude/commands/opsx/archive.md +160 -160
  3. package/.claude/commands/opsx/explore.md +174 -174
  4. package/.claude/commands/opsx/propose.md +109 -109
  5. package/.claude/commands/opsx/sync.md +143 -143
  6. package/.claude/skills/openspec-apply-change/SKILL.md +159 -159
  7. package/.claude/skills/openspec-archive-change/SKILL.md +117 -117
  8. package/.claude/skills/openspec-explore/SKILL.md +289 -289
  9. package/.claude/skills/openspec-propose/SKILL.md +113 -113
  10. package/.claude/skills/openspec-sync-specs/SKILL.md +147 -147
  11. package/.dockerignore +5 -5
  12. package/AGENTS.md +322 -322
  13. package/CHANGELOG.md +2269 -2258
  14. package/Dockerfile +30 -30
  15. package/LICENSE +16 -16
  16. package/README.md +369 -369
  17. package/assets/calibration/NOTICE.md +100 -100
  18. package/assets/calibration/audio/NOTICE.md +19 -0
  19. package/assets/calibration/audio/aac.m4a +0 -0
  20. package/assets/calibration/audio/ac3.ac3 +0 -0
  21. package/assets/calibration/audio/alac.m4a +0 -0
  22. package/assets/calibration/audio/dts.dts +0 -0
  23. package/assets/calibration/audio/eac3.eac3 +0 -0
  24. package/assets/calibration/audio/flac.flac +0 -0
  25. package/assets/calibration/audio/manifest.json +98 -0
  26. package/assets/calibration/audio/mp2.mp2 +0 -0
  27. package/assets/calibration/audio/mp3.mp3 +0 -0
  28. package/assets/calibration/audio/opus.opus +0 -0
  29. package/assets/calibration/audio/pcm.wav +0 -0
  30. package/assets/calibration/audio/truehd.thd +0 -0
  31. package/assets/calibration/audio/vorbis.ogg +0 -0
  32. package/assets/diagnostics/sctpstate.gdb +31 -31
  33. package/bin/cli.js +684 -684
  34. package/bin/thread-pool.js +30 -30
  35. package/biome.json +412 -411
  36. package/docs/browser-proxy-contract.md +155 -152
  37. package/docs/container-architecture.md +218 -218
  38. package/docs/download-architecture.md +261 -261
  39. package/docs/encode-architecture.md +729 -729
  40. package/docs/encode-run-state.md +96 -96
  41. package/docs/logs.md +97 -97
  42. package/docs/playback-start-forecast.md +56 -0
  43. package/docs/socks5-egress-research.md +114 -114
  44. package/docs/storage-architecture.md +267 -267
  45. package/knip.json +14 -14
  46. package/openspec/changes/adaptive-bitrate/design.md +92 -92
  47. package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
  48. package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
  49. package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
  50. package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
  51. package/openspec/changes/binary-distribution/proposal.md +53 -53
  52. package/openspec/changes/chunked-request-bodies/design.md +81 -81
  53. package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
  54. package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
  55. package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
  56. package/openspec/changes/cold-start/design.md +98 -98
  57. package/openspec/changes/cold-start/proposal.md +62 -62
  58. package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
  59. package/openspec/changes/cold-start/tasks.md +48 -48
  60. package/openspec/changes/disk-cap/.openspec.yaml +2 -2
  61. package/openspec/changes/disk-cap/proposal.md +37 -37
  62. package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
  63. package/openspec/changes/disk-cap/tasks.md +19 -19
  64. package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
  65. package/openspec/changes/proxy-observability/design.md +38 -38
  66. package/openspec/changes/proxy-observability/proposal.md +49 -49
  67. package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
  68. package/openspec/changes/proxy-observability/tasks.md +21 -21
  69. package/openspec/changes/seek-debounce/design.md +118 -118
  70. package/openspec/changes/seek-debounce/proposal.md +59 -59
  71. package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
  72. package/openspec/changes/seek-debounce/tasks.md +33 -33
  73. package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
  74. package/openspec/changes/subtitle-language/proposal.md +50 -50
  75. package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
  76. package/openspec/changes/subtitle-language/tasks.md +26 -26
  77. package/openspec/changes/track-selection/.openspec.yaml +2 -2
  78. package/openspec/changes/track-selection/design.md +47 -47
  79. package/openspec/changes/track-selection/proposal.md +45 -45
  80. package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
  81. package/openspec/changes/track-selection/tasks.md +20 -20
  82. package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
  83. package/openspec/changes/transcode-quality/proposal.md +51 -51
  84. package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
  85. package/openspec/changes/transcode-quality/tasks.md +63 -63
  86. package/openspec/config.yaml +38 -38
  87. package/package.json +1 -1
  88. package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
  89. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  90. package/research/double-spawn-2026-09-10.md +171 -171
  91. package/research/handover-2026-09-13.md +209 -209
  92. package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
  93. package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
  94. package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
  95. package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
  96. package/routes/api/client-logs/post.js +86 -86
  97. package/routes/api/delivery-sink/get.js +72 -72
  98. package/routes/api/link-probe/get.js +51 -51
  99. package/routes/api/playback-plan/audio-tracks/post.js +29 -29
  100. package/routes/api/playback-plan/post.js +53 -53
  101. package/routes/api/sources/post.js +36 -36
  102. package/routes/api/sources/stats/get.js +132 -132
  103. package/routes/api/sources/warm/post.js +217 -217
  104. package/routes/api/subtitles/get.js +230 -230
  105. package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
  106. package/routes/api/transcode-sessions/net-report/post.js +138 -138
  107. package/routes/api/transcode-sessions/post.js +178 -178
  108. package/routes/api/transcode-sessions/progress/get.js +27 -27
  109. package/routes/api/transcode-sessions/release/post.js +41 -41
  110. package/routes/api/transcode-sessions/seek/post.js +63 -63
  111. package/routes/health/get.js +13 -13
  112. package/routes/healthz/get.js +13 -13
  113. package/routes/stream/get.js +380 -380
  114. package/routes/transcode/audio-file/get.js +64 -64
  115. package/routes/transcode/audio-warm/get.js +95 -95
  116. package/routes/transcode/session-file/get.js +52 -52
  117. package/routes/transcode/variant-file/get.js +95 -95
  118. package/routes/transcode/variant-warm/get.js +84 -84
  119. package/scripts/build-audio-samples.mjs +31 -0
  120. package/scripts/render-run-graph.js +173 -173
  121. package/server.js +730 -725
  122. package/services/encode/CoverageMap.js +428 -428
  123. package/services/encode/CushionReport.js +345 -345
  124. package/services/encode/EncodeAdmission.js +376 -376
  125. package/services/encode/EncodeOrchestrator.js +1217 -1217
  126. package/services/encode/EncodePlan.js +1366 -1366
  127. package/services/encode/EncodeRun.js +813 -813
  128. package/services/encode/EncodeRuns.js +1239 -1235
  129. package/services/encode/Encoder.js +142 -142
  130. package/services/encode/EncoderSelection.js +38 -38
  131. package/services/encode/LocalObservations.js +359 -359
  132. package/services/encode/NvencEncoder.js +50 -50
  133. package/services/encode/OutputOpening.js +783 -783
  134. package/services/encode/OutputTimes.js +579 -579
  135. package/services/encode/QsvEncoder.js +58 -58
  136. package/services/encode/Renditions.js +2282 -2282
  137. package/services/encode/RunProgress.js +84 -84
  138. package/services/encode/SegmentDemand.js +95 -95
  139. package/services/encode/SoftwareEncoder.js +121 -121
  140. package/services/encode/V4l2m2mEncoder.js +53 -53
  141. package/services/encode/VaapiEncoder.js +66 -66
  142. package/services/encode/args.js +418 -418
  143. package/services/encode/audio-calibration.js +67 -0
  144. package/services/encode/audio-work-rate.js +15 -0
  145. package/services/encode/available-share.js +89 -89
  146. package/services/encode/calibration.js +361 -361
  147. package/services/encode/contention.js +128 -128
  148. package/services/encode/decode-cost-fit.js +363 -363
  149. package/services/encode/encode-exit.js +114 -114
  150. package/services/encode/encode-run-state.js +551 -551
  151. package/services/encode/encoder-readings.js +45 -45
  152. package/services/encode/fingerprint.js +322 -322
  153. package/services/encode/host-load.js +174 -174
  154. package/services/encode/hwaccel.js +1894 -1894
  155. package/services/encode/index.js +9 -9
  156. package/services/encode/learned-median.js +90 -90
  157. package/services/encode/output/EncodedOutput.js +57 -57
  158. package/services/encode/output/Output.js +131 -131
  159. package/services/encode/output/OutputCatalog.js +414 -414
  160. package/services/encode/output/OutputSpec.js +390 -390
  161. package/services/encode/output/Timeline.js +308 -308
  162. package/services/encode/output/cut-grid.js +107 -107
  163. package/services/encode/output/index.js +24 -20
  164. package/services/encode/output/ladder.js +32 -32
  165. package/services/encode/output/playlists.js +284 -284
  166. package/services/encode/output/rates.js +125 -125
  167. package/services/encode/output-key-format.js +21 -21
  168. package/services/encode/quality/EncodeCost.js +1167 -1150
  169. package/services/encode/quality/HostLoad.js +525 -525
  170. package/services/encode/quality/HostTimings.js +249 -249
  171. package/services/encode/quality/OutputQualityState.js +28 -28
  172. package/services/encode/quality/QualityController.js +935 -935
  173. package/services/encode/quality/QualityOffer.js +416 -416
  174. package/services/encode/quality/drain-threat.js +47 -47
  175. package/services/encode/quality/link-budget.js +349 -349
  176. package/services/encode/quality/output-format.js +319 -319
  177. package/services/encode/quality/serving-output.js +216 -216
  178. package/services/encode/quality/visible-rung.js +57 -57
  179. package/services/encode/run-budget.js +97 -97
  180. package/services/encode/run-command.js +755 -754
  181. package/services/encode/run-costs.js +155 -155
  182. package/services/encode/run-inputs.js +42 -42
  183. package/services/encode/segment-formats/fmp4.js +458 -456
  184. package/services/encode/segment-formats/index.js +85 -85
  185. package/services/encode/segment-formats/init-compat.js +324 -324
  186. package/services/encode/segment-formats/mp4-boxes.js +12 -1
  187. package/services/encode/segment-formats/mpegts.js +134 -134
  188. package/services/encode/start-stop-cost.js +178 -178
  189. package/services/encode/throughput.js +97 -97
  190. package/services/encode/torrent-cost.js +88 -88
  191. package/services/identity/installation-id.js +38 -38
  192. package/services/media/ContainerOrchestrator.js +149 -149
  193. package/services/media/KeyframeTables.js +316 -316
  194. package/services/media/SourceFile.js +298 -298
  195. package/services/media/SubtitleCues.js +520 -520
  196. package/services/media/SubtitleOrchestrator.js +167 -167
  197. package/services/media/audio-inventory.js +299 -298
  198. package/services/media/container/AviContainer.js +266 -266
  199. package/services/media/container/Container.js +609 -609
  200. package/services/media/container/ContainerFactory.js +56 -56
  201. package/services/media/container/KeyframeTable.js +272 -272
  202. package/services/media/container/MatroskaContainer.js +1494 -1494
  203. package/services/media/container/Mp4Container.js +1344 -1344
  204. package/services/media/container/ebml-reader.js +159 -159
  205. package/services/media/ffmpeg-banner.js +260 -260
  206. package/services/media/keyframe-probe.js +157 -157
  207. package/services/media/media-info-probe.js +93 -93
  208. package/services/media/playback-planner.js +931 -931
  209. package/services/media/tracks/AudioTrack.js +143 -143
  210. package/services/media/tracks/ContainerTrack.js +75 -75
  211. package/services/media/tracks/ImageSubtitleTrack.js +19 -19
  212. package/services/media/tracks/SubtitleTrack.js +38 -38
  213. package/services/media/tracks/TextSubtitleTrack.js +287 -287
  214. package/services/media/tracks/VideoTrack.js +33 -33
  215. package/services/media/tracks/audio-bound.js +233 -233
  216. package/services/media/tracks/index.js +15 -15
  217. package/services/media/tracks/language-detect.js +174 -174
  218. package/services/server/OutputLifecycle.js +418 -418
  219. package/services/server/SegmentServing.js +1150 -1150
  220. package/services/server/ViewerRequests.js +786 -760
  221. package/services/server/WaitLedger.js +142 -142
  222. package/services/server/controllers/PlaybackController.js +40 -40
  223. package/services/server/controllers/SubtitleController.js +109 -109
  224. package/services/server/http-responses.js +60 -60
  225. package/services/server/playback-readiness.js +516 -708
  226. package/services/server/transcode-session-files.js +325 -325
  227. package/services/server/wire-outputs.js +1020 -995
  228. package/services/storage/Diagnostics.js +202 -202
  229. package/services/storage/MachineBudget.js +235 -235
  230. package/services/storage/allowance.js +107 -107
  231. package/services/storage/core-dumps.js +106 -106
  232. package/services/storage/files/CompletedFiles.js +391 -391
  233. package/services/storage/files/piece-from-whole-file.js +118 -118
  234. package/services/storage/free.js +71 -71
  235. package/services/storage/keep.js +48 -48
  236. package/services/storage/machine-memory.js +56 -56
  237. package/services/storage/memory-report.js +575 -575
  238. package/services/storage/piece-store/piece-disk-store.js +539 -539
  239. package/services/storage/piece-store/piece-lru.js +495 -495
  240. package/services/storage/piece-store/shared-piece-store.js +1937 -1931
  241. package/services/storage/returns.js +103 -103
  242. package/services/storage/segment-store/SegmentStore.js +1255 -1240
  243. package/services/storage/wire.js +175 -175
  244. package/services/torrent/demand/DemandRegister.js +202 -202
  245. package/services/torrent/demand/Urgency.js +137 -137
  246. package/services/torrent/demand/Window.js +118 -118
  247. package/services/torrent/demand/index.js +8 -8
  248. package/services/torrent/demand/pieces.js +140 -140
  249. package/services/torrent/download/SwarmSelection.js +386 -386
  250. package/services/torrent/download/registry.js +102 -102
  251. package/services/torrent/download/withdraw-claim.js +80 -80
  252. package/services/torrent/files.js +455 -455
  253. package/services/torrent/naming.js +619 -619
  254. package/services/torrent/piece-store-of.js +29 -29
  255. package/services/torrent/supply-margin.js +262 -262
  256. package/services/torrent/torrent-pool.js +2688 -2675
  257. package/services/torrent/worker/background-fill.js +213 -213
  258. package/services/torrent/worker/channel.js +271 -271
  259. package/services/torrent/worker/client.js +765 -765
  260. package/services/torrent/worker/fastest-wires.js +342 -342
  261. package/services/torrent/worker/handle-state.js +23 -23
  262. package/services/torrent/worker/held-bytes.js +155 -155
  263. package/services/torrent/worker/install-webrtc-shim.js +32 -32
  264. package/services/torrent/worker/piece-reader.js +1078 -1078
  265. package/services/torrent/worker/pool-adapter.js +371 -371
  266. package/services/torrent/worker/protocol.js +184 -184
  267. package/services/torrent/worker/resume-warm.js +107 -107
  268. package/services/torrent/worker/webrtc-shim.js +219 -219
  269. package/services/torrent/worker/worker.js +1129 -1129
  270. package/services/transport/data-channel-handler.js +1369 -1369
  271. package/services/transport/delivery-probe.js +842 -842
  272. package/services/transport/health-collector.js +53 -53
  273. package/services/transport/nat-classifier.js +231 -231
  274. package/services/transport/packet-witness.js +808 -808
  275. package/services/transport/port-mapper.js +235 -235
  276. package/services/transport/registry-api.js +81 -81
  277. package/services/transport/tunnel-client.js +447 -447
  278. package/services/transport/usrsctp-state.js +175 -175
  279. package/services/transport/webrtc-manager.js +484 -484
  280. package/services/viewer/Assignments.js +340 -340
  281. package/services/viewer/PriorityMap.js +390 -390
  282. package/services/viewer/PriorityOrchestrator.js +325 -325
  283. package/services/viewer/Viewer.js +691 -691
  284. package/services/viewer/Viewers.js +368 -368
  285. package/services/viewer/active-output.js +37 -37
  286. package/services/viewer/audio-start.js +46 -46
  287. package/services/viewer/buffer-trend.js +81 -81
  288. package/services/viewer/choices.js +647 -647
  289. package/services/viewer/positions.js +126 -126
  290. package/services/viewer/report-intake.js +46 -46
  291. package/store/source-registry.js +71 -71
  292. package/test/asset-paths.test.js +28 -28
  293. package/test/audio-calibration.test.js +23 -0
  294. package/test/audio-inventory.test.js +212 -212
  295. package/test/audio-track-bitrate.test.js +40 -40
  296. package/test/auto-quality-step.test.js +707 -707
  297. package/test/available-share.test.js +76 -76
  298. package/test/background-fill.test.js +190 -190
  299. package/test/client-log-file.test.js +110 -110
  300. package/test/completed-files.test.js +115 -115
  301. package/test/concurrent-cost.test.js +148 -148
  302. package/test/container-media-info.test.js +228 -228
  303. package/test/contention.test.js +76 -76
  304. package/test/core-dumps.test.js +57 -57
  305. package/test/coverage-follows-the-disk.test.js +228 -228
  306. package/test/coverage-map.test.js +195 -195
  307. package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
  308. package/test/cut-times-timeline.test.js +65 -65
  309. package/test/cuts-follow-published-grid.test.js +109 -109
  310. package/test/data-channel-frame.test.js +63 -63
  311. package/test/data-channel-viewer-lifecycle.test.js +92 -92
  312. package/test/dead-torrent-handle.test.js +52 -52
  313. package/test/declared-rates.test.js +116 -116
  314. package/test/declared-tracks.test.js +43 -43
  315. package/test/decode-cost-fit.test.js +202 -202
  316. package/test/decode-cost.test.js +462 -462
  317. package/test/delivery-probe.test.js +393 -393
  318. package/test/demand-register.test.js +206 -206
  319. package/test/dht-bootstrap.test.js +72 -72
  320. package/test/drain-threat.test.js +65 -65
  321. package/test/duplicate-add.test.js +48 -48
  322. package/test/encode-admission.test.js +146 -146
  323. package/test/encode-cost.test.js +283 -283
  324. package/test/encode-exit.test.js +111 -111
  325. package/test/encode-orchestrator.test.js +419 -419
  326. package/test/encode-plan-viewers.test.js +718 -718
  327. package/test/encode-plan.test.js +545 -545
  328. package/test/encode-run-state.test.js +314 -314
  329. package/test/encode-run.test.js +162 -162
  330. package/test/encode-slope.test.js +46 -46
  331. package/test/encoded-output.test.js +39 -39
  332. package/test/encoder-kinds.test.js +122 -122
  333. package/test/encoder-readings.test.js +46 -46
  334. package/test/every-claimant-is-told-its-share.test.js +152 -152
  335. package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
  336. package/test/failed-start-breaker.test.js +202 -202
  337. package/test/fastest-wires.test.js +143 -143
  338. package/test/ffmpeg-stream-counts.test.js +78 -78
  339. package/test/file-edges.test.js +191 -191
  340. package/test/fmp4-presentation-clock.test.js +162 -147
  341. package/test/fmp4-self-contained.test.js +80 -80
  342. package/test/fmp4-timestamps.test.js +140 -140
  343. package/test/fmp4-track-completeness.test.js +41 -41
  344. package/test/format-keyed-outputs.test.js +78 -78
  345. package/test/health-metrics.test.js +38 -38
  346. package/test/held-request-width.test.js +157 -157
  347. package/test/helpers/encode-run.js +136 -136
  348. package/test/helpers/held-file.js +29 -29
  349. package/test/helpers/manager.js +57 -57
  350. package/test/helpers/output-spec.js +63 -63
  351. package/test/host-load.test.js +68 -68
  352. package/test/index-accuracy-is-about-the-file.test.js +128 -128
  353. package/test/init-compat.test.js +219 -219
  354. package/test/input-lost-quiets-the-plan.test.js +261 -261
  355. package/test/input-unavailable.test.js +48 -48
  356. package/test/installation-id.test.js +39 -39
  357. package/test/keeping-period.test.js +83 -83
  358. package/test/keyframe-index-accuracy.test.js +188 -188
  359. package/test/keyframe-table-budget.test.js +102 -102
  360. package/test/keyframes-belong-to-the-file.test.js +249 -249
  361. package/test/learned-median.test.js +37 -37
  362. package/test/letting-go-asks-who-is-watching.test.js +191 -191
  363. package/test/limit-row-by-area.test.js +60 -60
  364. package/test/link-budget.test.js +112 -112
  365. package/test/link-probe.test.js +56 -56
  366. package/test/local-encoding-data.test.js +273 -273
  367. package/test/logger-repeats.test.js +134 -134
  368. package/test/look-ahead-coverage.test.js +56 -56
  369. package/test/manager-surface.test.js +292 -292
  370. package/test/matroska-cues-track.test.js +192 -192
  371. package/test/memory-budget.test.js +390 -390
  372. package/test/move-cost.test.js +216 -216
  373. package/test/mp4-subtitles.test.js +173 -173
  374. package/test/one-authority.test.js +285 -285
  375. package/test/one-budget.test.js +149 -149
  376. package/test/one-output-one-session.test.js +273 -270
  377. package/test/one-resource-divided.test.js +185 -185
  378. package/test/one-serving-output-per-height.test.js +458 -458
  379. package/test/orchestrator-wired.test.js +206 -206
  380. package/test/output-catalog.test.js +39 -39
  381. package/test/output-shape.test.js +21 -21
  382. package/test/output-spec.test.js +251 -251
  383. package/test/output-speed.test.js +88 -88
  384. package/test/outputs-being-prepared.test.js +40 -40
  385. package/test/packet-witness-ring.test.js +236 -236
  386. package/test/packet-witness.test.js +148 -148
  387. package/test/piece-disk-store.test.js +401 -401
  388. package/test/piece-from-whole-file.test.js +129 -129
  389. package/test/piece-lru.test.js +319 -319
  390. package/test/piece-reader.test.js +162 -162
  391. package/test/piece-selection-offset.test.js +85 -85
  392. package/test/piece-store-eviction.test.js +595 -595
  393. package/test/piece-store-never-refuses.test.js +153 -153
  394. package/test/piece-store-reservations.test.js +403 -403
  395. package/test/piece-store-slow-disk.test.js +159 -159
  396. package/test/piece-tail.test.js +97 -97
  397. package/test/plan-host-timings.test.js +68 -68
  398. package/test/plan-is-steady.test.js +225 -225
  399. package/test/playback-readiness.test.js +483 -453
  400. package/test/playback-start-regressions.test.js +99 -99
  401. package/test/priority-map-download.test.js +174 -174
  402. package/test/priority-map-emptied.test.js +204 -204
  403. package/test/priority-map-per-output.test.js +215 -215
  404. package/test/priority-map.test.js +163 -163
  405. package/test/probe-wedge-certainty.test.js +60 -60
  406. package/test/produced-copy-choice.test.js +213 -213
  407. package/test/published-timeline.test.js +83 -83
  408. package/test/quality-offer.test.js +162 -162
  409. package/test/quality-variants.test.js +1257 -1257
  410. package/test/rate-control-in-the-output.test.js +180 -180
  411. package/test/read-survives-withdrawal.test.js +164 -164
  412. package/test/read-window.test.js +365 -365
  413. package/test/resume-warm.test.js +39 -39
  414. package/test/run-graph-drift.test.js +37 -37
  415. package/test/run-inputs.test.js +80 -80
  416. package/test/run-intervals.test.js +100 -100
  417. package/test/run-position-follows-published-grid.test.js +89 -89
  418. package/test/run-progress.test.js +65 -65
  419. package/test/same-height-switch.test.js +664 -664
  420. package/test/seek-frees-the-viewers-requests.test.js +217 -217
  421. package/test/seek-landing.test.js +109 -109
  422. package/test/seek-target-not-superseded.test.js +195 -195
  423. package/test/segment-closed-on-both-branches.test.js +96 -96
  424. package/test/segment-cut-times.test.js +63 -63
  425. package/test/segment-demand.test.js +80 -80
  426. package/test/segment-hold-supersede.test.js +91 -91
  427. package/test/segment-judged-by-its-own-header.test.js +221 -221
  428. package/test/segment-serve-wiring.test.js +475 -475
  429. package/test/segment-store-eviction.test.js +247 -247
  430. package/test/segment-store.test.js +352 -323
  431. package/test/segment-true-start.test.js +86 -86
  432. package/test/segments-are-shared.test.js +176 -176
  433. package/test/send-chunk-bytes.test.js +86 -86
  434. package/test/serving-existing-output.test.js +105 -105
  435. package/test/serving-output.test.js +102 -102
  436. package/test/shared-piece-store.test.js +310 -310
  437. package/test/sidecar-naming.test.js +142 -142
  438. package/test/soundtrack-rate-bound.test.js +289 -289
  439. package/test/source-file.test.js +133 -133
  440. package/test/source-registry.test.js +18 -18
  441. package/test/split-a-stretch.test.js +139 -139
  442. package/test/stale-request-after-seek.test.js +158 -158
  443. package/test/startup-readings.test.js +124 -124
  444. package/test/stream-route.test.js +209 -209
  445. package/test/subtitle-cue-framing.test.js +200 -200
  446. package/test/subtitle-cue-source.test.js +102 -102
  447. package/test/subtitle-cue-walk.test.js +370 -370
  448. package/test/subtitle-cursor.test.js +63 -63
  449. package/test/subtitle-defaults.test.js +118 -118
  450. package/test/subtitle-language.test.js +252 -252
  451. package/test/subtitle-track-numbering.test.js +386 -386
  452. package/test/supply-margin.test.js +177 -177
  453. package/test/swarm-follows-readers.test.js +174 -174
  454. package/test/swarm-reach.test.js +130 -130
  455. package/test/swarm-selection.test.js +220 -220
  456. package/test/tail-duplication.test.js +167 -167
  457. package/test/the-channel-has-both-ends.test.js +91 -91
  458. package/test/the-torrent-thread-serves-bytes.test.js +107 -107
  459. package/test/thread-pool.test.js +53 -53
  460. package/test/timeline.test.js +84 -84
  461. package/test/torrent-contents.test.js +235 -235
  462. package/test/torrent-cost.test.js +71 -71
  463. package/test/torrent-files.test.js +221 -221
  464. package/test/torrent-naming.test.js +255 -255
  465. package/test/tracks-begin-together.test.js +209 -209
  466. package/test/tunnel-renewal.test.js +167 -167
  467. package/test/two-viewers-one-picture.test.js +504 -504
  468. package/test/upload-hurry.test.js +186 -186
  469. package/test/usrsctp-state.test.js +131 -131
  470. package/test/video-facts.test.js +102 -102
  471. package/test/viewer-assignments.test.js +143 -143
  472. package/test/viewer-outputs.test.js +285 -285
  473. package/test/viewer-presence.test.js +116 -116
  474. package/test/viewer-requests-admission.test.js +89 -89
  475. package/test/viewer-states.test.js +189 -189
  476. package/test/viewer-subtitle-subscription.test.js +96 -96
  477. package/test/viewer.test.js +139 -139
  478. package/test/viewing-generations.test.js +390 -390
  479. package/test/visible-rung.test.js +37 -37
  480. package/test/wait-ledger.test.js +100 -100
  481. package/test/webrtc-shim.test.js +149 -149
  482. package/test/wedge-certainty.test.js +131 -131
  483. package/test/withdraw-piece-claim.test.js +212 -212
  484. package/test/worker-channel.test.js +158 -158
  485. package/utils/client-log-file.js +151 -151
  486. package/utils/logger.js +312 -312
  487. package/utils/parse-range.js +25 -25
  488. package/utils/perf.js +59 -59
  489. package/utils/torrent-source-key.js +38 -38
@@ -1,1931 +1,1937 @@
1
- /**
2
- * @file Torrent pieces in shared memory, with disk as the second tier.
3
- *
4
- * Replaces the chunk store WebTorrent would otherwise build for itself. Two
5
- * reasons, one forced and one chosen.
6
- *
7
- * **Forced.** WebTorrent's own piece cache hands out the buffer it keeps using
8
- * and slices again on the next read. The torrent client now runs on its own
9
- * thread, and moving a piece to the main thread by transferring ownership
10
- * detached the cache's memory: proxy 2.9.71-2.9.73 answered every read with an
11
- * empty body (`Stream ends prematurely at 0`) and the failure was invisible,
12
- * because the error never reached the reader. Owning the memory ourselves
13
- * removes the question of whose it was.
14
- *
15
- * **Chosen.** Memory this side of the thread boundary can be *shared* memory,
16
- * which the main thread reads without receiving bytes as a copy — see
17
- * {@link SharedPieceStore#locate}. Measured on the field host: a piece copy
18
- * costs 3.64 ms, a read from the page cache into a buffer we own 7.63 ms, and
19
- * re-downloading a piece from the swarm ~1430 ms. So memory first, disk under
20
- * it, and never the swarm twice.
21
- *
22
- * Per piece allocation: each resident piece owns its own `SharedArrayBuffer`.
23
- * Evicting a piece deletes its entry and the memory is reclaimable by GC.
24
- * `committed` therefore equals `resident`, not a high-water mark.
25
- *
26
- * **Room for a piece is an owned reservation, not a shared number.**
27
- * `#claimSlot` hands back a release the caller runs in a `finally`; nothing
28
- * else touches `#outstandingPieces`. It was a counter incremented in one
29
- * function and decremented in another, and every consequence of that was a
30
- * defect: a failure between the two lost a slot for the life of the process,
31
- * `put`'s error path guessed at the correction and could take back a
32
- * reservation belonging to a different claim, and one lost reservation made
33
- * the five-second "everything is pinned" error permanently unreachable, so a
34
- * read retried every 50 ms for ever without completing or failing. Read out of
35
- * the field failure of 2026-08-31 (`research/worker-heap-oom-2026-08-31.md`).
36
- *
37
- * What this deliberately does NOT do is manage the disk as a cache of its own.
38
- * Pieces evicted from memory are written once and read back on demand; the file
39
- * is discarded whole when the torrent goes away. libtorrent 2.0 and webtor's
40
- * seeder both concluded that a hand-rolled disk cache earns less than it costs,
41
- * and nothing here disagrees.
42
- */
43
-
44
- import { divideAllowance, machineAllowanceBytes, OtherDemand } from "../allowance.js";
45
- import { availableMemoryBytes as availableMemorySync } from "../machine-memory.js";
46
- import { PieceLru } from "./piece-lru.js";
47
- import { PieceDiskStore } from "./piece-disk-store.js";
48
-
49
- /**
50
- * Live stores, so the worker can report on them.
51
- *
52
- * @type {Set<SharedPieceStore>}
53
- */
54
- const liveStores = new Set();
55
-
56
- export function collectStoreStats() {
57
- return [...liveStores].map((store) => store.stats());
58
- }
59
-
60
- export function findSharedStore(torrent) {
61
- let candidate = torrent?.store;
62
- for (let depth = 0; candidate && depth < 8; depth += 1) {
63
- if (candidate instanceof SharedPieceStore) {
64
- return candidate;
65
- }
66
- candidate = candidate.store;
67
- }
68
- return null;
69
- }
70
-
71
- /**
72
- * The largest fall in the machine's available memory that this process has
73
- * seen and did not cause itself.
74
- *
75
- * What has to be left for everything else on the machine. It cannot be a share
76
- * of what is free — a share is a number chosen out of nothing, which is what
77
- * `AVAILABLE_MEMORY_SHARE = 0.25`, `MIN_BUDGET_BYTES` and
78
- * `MEMORY_BUDGET_CEILING_BYTES` were until 2026-09-02, all three traceable to
79
- * one observation of one host on 2026-08-03. It is measured instead: between
80
- * two readings, how much available memory went away beyond what the stores
81
- * themselves took. On the field host that quantity is large — while the proxy
82
- * held 76-133 MB overnight the machine's available memory fell from 2378 MB to
83
- * 306 MB and came back — and on a quiet host it stays near zero, which is the
84
- * right answer there.
85
- *
86
- * Starts at zero: nothing is reserved until somebody else has been seen to
87
- * need it.
88
- */
89
- const otherDemand = new OtherDemand();
90
-
91
- /**
92
- * Note what the machine had, and how much of the change was not ours.
93
- *
94
- * @param {number} availableBytes
95
- * @param {number} storeBytes - What the live stores hold right now.
96
- * @returns {number} The reserve, in bytes.
97
- */
98
- export function noteMachineMemory(availableBytes, storeBytes) {
99
- return otherDemand.note(availableBytes, storeBytes);
100
- }
101
-
102
- /** What has recently been observed to be needed by everything that is not us. */
103
- export function machineReserveBytes() {
104
- return otherDemand.reserve();
105
- }
106
-
107
- /** Forget what other processes have needed. For tests, which share a module. */
108
- export function forgetMachineMemory() {
109
- otherDemand.forget();
110
- }
111
-
112
- export { divideAllowance, machineAllowanceBytes };
113
-
114
- export function reviseStoreBudgets(allowanceBytes = null) {
115
- const stores = [...liveStores];
116
- const held = stores.reduce((sum, store) => sum + store.residentBytes, 0);
117
- // TOLD, NOT WORKED OUT. Memory has one owner and it is on the main thread,
118
- // where the disk's owner is — and it must be the same one, because the two
119
- // resources trade: pieces that do not fit in memory are spilled to disk, so
120
- // what this store is given decides how much disk it needs. What arrives is
121
- // this thread's whole share; the stores divide it between themselves.
122
- //
123
- // Null is a thread that has not been told yet, and it works its own share out
124
- // for that one pass rather than growing without a bound.
125
- const allowance = Number.isFinite(allowanceBytes) && allowanceBytes !== null
126
- ? Math.max(0, allowanceBytes)
127
- : machineAllowanceBytes(availableMemorySync(), held, noteMachineMemory(availableMemorySync(), held));
128
- const shares = divideAllowance(stores.map((store) => store.wantedBytes), allowance);
129
- const revised = [];
130
- for (const [position, store] of stores.entries()) {
131
- revised.push(store.reviseGrowthCeiling(shares[position]));
132
- }
133
- return revised;
134
- }
135
-
136
- /**
137
- * Say how much disk the spilled pieces may take between them.
138
- *
139
- * Told rather than worked out. The disk is one and this store is not its only
140
- * user — the segments an encoder produces are on it too — so a ceiling one of
141
- * two users sets for itself is not a ceiling. Until 2026-09-10 the spill had no
142
- * ceiling of any kind: 14 400 MB written in one fifty-minute viewing.
143
- *
144
- * @param {number | null} allowanceBytes - This thread's whole share, from the
145
- * owner of the disk on the main thread. Null means nobody has said yet, and
146
- * nothing is thrown away until somebody does.
147
- * @param {SharedPieceStore[]} [live] - The stores to divide between.
148
- * @returns {{ name: string, allowanceBytes: number | null, bytes: number }[]}
149
- */
150
- export function reviseSpillBudgets(allowanceBytes, live = [...liveStores]) {
151
- if (live.length === 0) {
152
- return [];
153
- }
154
- // NOT READ HERE. The disk has one owner and it is on the main thread, where
155
- // the segments an encoder produces live on the same disk. Reading it here as
156
- // well is exactly the fault this replaced: two ceilings, each standing for the
157
- // whole disk. What arrives is this thread's whole share.
158
- const total = Number.isFinite(allowanceBytes) && allowanceBytes >= 0 ? allowanceBytes : null;
159
- if (total === null) {
160
- return live.map((store) => store.reviseSpillCeiling(null));
161
- }
162
- // EQUALLY, unlike memory. A store's memory ask is its readers' declared
163
- // windows; the spill has no such statement, because whatever memory evicts
164
- // arrives here. Every store's ask is "all of it", nothing distinguishes them,
165
- // and an equal share is what that means.
166
- const share = Math.floor(total / live.length);
167
- return live.map((store) => store.reviseSpillCeiling(share));
168
- }
169
-
170
- function defaultMemoryBytes() {
171
- const stores = [...liveStores];
172
- const held = stores.reduce((sum, store) => sum + store.residentBytes, 0);
173
- const available = availableMemorySync();
174
- const allowance = machineAllowanceBytes(available, held, machineReserveBytes());
175
- // A store being created has no readers, so it has no demand to state and no
176
- // basis for asking for more or less than the others. It opens on an equal
177
- // share and the first revision — within a minute, and within seconds of a
178
- // read starting — replaces that with what its readers actually declare.
179
- // Deliberately not the whole allowance: on a machine with gigabytes free that
180
- // would let a torrent nobody is reading yet fill memory before the first
181
- // revision arrives.
182
- return Math.floor(allowance / (stores.length + 1));
183
- }
184
-
185
- /**
186
- * How many blocks of piece memory this thread has let go of, and how many the
187
- * collector has actually taken back.
188
- *
189
- * Since the store keeps a pool, one block serves many pieces, so this counts
190
- * blocks and not pieces — and a healthy store allocates only as many as its
191
- * allowance permits, so both numbers are now small and nearly equal.
192
- *
193
- * The one reading that separates the two explanations of the 700 MB nobody can
194
- * account for (roadmap item 2, field 2026-08-31): if the two numbers track each
195
- * other, this code is holding nothing and whatever grows is below us, in the
196
- * allocator — on musl there is no `malloc_trim` and no way to ask. If the gap
197
- * widens, a reference of ours outlives the piece, and then a heap snapshot can
198
- * name the holder.
199
- *
200
- * What it does NOT prove: a `SharedArrayBuffer`'s memory is shared between the
201
- * isolates, so this thread's handle going means only that THIS thread let go.
202
- * The main thread counts its own (`torrent/worker/client.js`), and the pair is
203
- * what answers the question.
204
- */
205
- const released = { count: 0, collected: 0 };
206
- const collector = typeof FinalizationRegistry === "function"
207
- ? new FinalizationRegistry(() => {
208
- released.collected += 1;
209
- })
210
- : null;
211
-
212
- /**
213
- * What the collector has taken back against what was let go.
214
- *
215
- * @returns {{ released: number, collected: number }}
216
- */
217
- export function pieceBufferCollection() {
218
- return { released: released.count, collected: released.collected };
219
- }
220
-
221
- const MIN_RESIDENT_PIECES = 2;
222
- /**
223
- * How long a claim may go without ANYTHING moving before it gives up.
224
- *
225
- * Measured against progress, not against activity. The earlier rule skipped
226
- * this timer entirely while a spill was in flight or a reservation was held —
227
- * so one reservation that was never returned made the timer unreachable and a
228
- * read retried every 50 ms for the life of the process, never completing and
229
- * never failing (field 2026-08-31).
230
- */
231
- const PINNED_WAIT_MS = 5_000;
232
-
233
- /**
234
- * How many revival ages are kept for the median. A window, not a history: two
235
- * hundred covers several minutes of the busiest session measured (7575
236
- * revivals in 44 minutes) and costs two hundred numbers.
237
- */
238
- const REVIVAL_AGE_SAMPLES = 200;
239
-
240
- /**
241
- * How many block re-use gaps are kept. The same window as the revival ages, and
242
- * for the same reason: what is wanted is the rhythm of recent work, not a
243
- * history of it.
244
- */
245
- const REUSE_GAP_SAMPLES = 200;
246
-
247
- /** How many write durations are kept for the median. */
248
- const WRITE_DURATION_SAMPLES = 50;
249
- /** How many admission times are kept, and how far back they are counted. */
250
- const ARRIVAL_SAMPLES = 100;
251
- const ARRIVAL_WINDOW_MS = 10_000;
252
-
253
- /**
254
- * The middle value of a sample, or null when there is nothing to take a middle
255
- * of. Null rather than zero: no revivals and instant revivals are different
256
- * facts and must not print the same.
257
- *
258
- * @param {number[]} values
259
- * @returns {number | null}
260
- */
261
- function median(values) {
262
- if (values.length === 0) {
263
- return null;
264
- }
265
- const sorted = [...values].sort((left, right) => left - right);
266
- const middle = Math.floor(sorted.length / 2);
267
- return sorted.length % 2 === 0
268
- ? Math.round((sorted[middle - 1] + sorted[middle]) / 2)
269
- : sorted[middle];
270
- }
271
- const CLAIM_RETRY_MS = 50;
272
-
273
- export class SharedPieceStore {
274
- #chunkLength;
275
- #lastChunkLength;
276
- #lastChunkIndex;
277
- #growthCeiling;
278
- /** Piece index → SharedArrayBuffer of that piece */
279
- #buffers = new Map();
280
- /** @type {Map<number, Promise<void>>} */
281
- #evicting = new Map();
282
- /**
283
- * Slots claimed but not yet filled.
284
- *
285
- * Handed out by {@link SharedPieceStore##claimSlot} as a release function the
286
- * caller must call in a `finally`, never as a number one function increments
287
- * and another decrements. The counter used to be paired across
288
- * `#claimSlot`/`#registerPiece`, so any failure between the two lost a slot
289
- * for the life of the process, and `put`'s error path tried to correct that
290
- * by guessing — which could take back a reservation belonging to a different
291
- * claim and let the store admit past its allowance.
292
- */
293
- #outstandingPieces = 0;
294
- /** When something last actually moved: a piece admitted, spilled or unpinned. */
295
- #lastProgressAt = 0;
296
- #waiters = [];
297
- #lru;
298
- #disk;
299
- #closed = false;
300
- #name;
301
- #counters = {
302
- fromMemory: 0,
303
- fromDisk: 0,
304
- // Reads answered from the file this torrent has been assembled into. Not a
305
- // miss and not a spill: the bytes were never lost, they are simply kept as
306
- // a file now.
307
- fromWholeFile: 0,
308
- spills: 0,
309
- revivals: 0,
310
- blockedByPins: 0,
311
- waitedForPins: 0,
312
- // Pieces that went to disk because no block could be had for them. Not a
313
- // failure — the piece is kept and a later read revives it — but it is the
314
- // measure of how often the store is asked to hold more than it may, and
315
- // before 2026-09-11 this case ended the torrent instead of being counted.
316
- admittedWithoutSlot: 0,
317
- evictedOnRevise: 0,
318
- spillFailures: 0,
319
- // Claims withdrawn: pieces this store stopped being able to produce at all,
320
- // and said so. It is the figure that says whether the bargain the eviction
321
- // states — "a seek back re-downloads it" — is being honoured, and before
322
- // 2026-09-12 it was not honoured once, because nothing was ever said.
323
- withdrawn: 0,
324
- // Whether the store is doing its job or being asked to hold more than it
325
- // has room for. An eviction that had to take a piece a reader declared it
326
- // wants is the second, and it comes back from disk moments later
327
- // (roadmap item 9).
328
- evictedProtected: 0,
329
- evictedDistanceSum: 0,
330
- evictedWithDistance: 0,
331
- // Where an arriving piece went. A piece nobody has declared is being
332
- // downloaded ahead of every reader; putting it in memory pushes out one
333
- // that IS declared, which is then read back from disk moments later
334
- // (roadmap item 9).
335
- admittedInsideWindow: 0,
336
- admittedOutsideWindow: 0,
337
- admittedToDisk: 0,
338
- /** Blocks given back to the operating system. */
339
- blocksReleased: 0,
340
- /**
341
- * A block put back for re-use while a reader still held the piece. Zero by
342
- * construction — a pinned piece is never evicted, and a re-put of a pinned
343
- * one drops its block instead of recycling it. Counted because if it ever
344
- * stops being zero, a consumer is reading another piece's bytes, and that
345
- * is invisible from anywhere else.
346
- */
347
- returnedWhilePinned: 0,
348
- /** Spills that found the disk already holding identical bytes. */
349
- spillsSkipped: 0,
350
- /**
351
- * Blocks a second registration of the same piece displaced. Expected to be
352
- * small and non-zero: two callers racing for one piece is ordinary. What is
353
- * NOT ordinary is the block going missing when it happens, which is what
354
- * killed the process on 2026-09-02.
355
- */
356
- blocksDisplaced: 0,
357
- /** Admissions that waited for the disk instead of evicting another piece. */
358
- waitedForDisk: 0,
359
- /**
360
- * Admissions that grew memory because the disk had stopped answering.
361
- * Non-zero means the store exceeded its allowance on purpose, and by how
362
- * many pieces.
363
- */
364
- grewWaitingForDisk: 0,
365
- /**
366
- * Times a block was wanted, none was free, and the pool was already at its
367
- * ceiling. Zero by construction — a block is only taken after a slot has
368
- * been claimed, and slots are what the ceiling counts — so a number here
369
- * means the two have come apart and the pool is growing past its allowance.
370
- */
371
- blocksBeyondCeiling: 0
372
- };
373
- /**
374
- * Blocks that hold no piece, most recently freed last.
375
- *
376
- * A block is one piece's worth of memory. Allocating a new one for every
377
- * piece meant 7575 allocations of 4 MiB in 44 minutes on 2026-09-02, each
378
- * released only when the collector got to it — which is why the process held
379
- * 1.86 GB while its own accounting said 352 MB. Blocks are taken from here
380
- * and put back here instead (roadmap item 2).
381
- *
382
- * @type {{ buffer: SharedArrayBuffer, freedAt: number }[]}
383
- */
384
- #freeBlocks = [];
385
- /** Blocks that exist at all: free plus holding a piece. */
386
- #blocksAllocated = 0;
387
- /**
388
- * The widest window any reader has declared here, in pieces.
389
- *
390
- * The floor this store asks for between readers: one window is what the next
391
- * read will ask for, and it is a measurement rather than a chosen number.
392
- * See `wantedBytes`.
393
- */
394
- #widestSeenPieces = 0;
395
- /** The torrent's files in order, passed through to whoever can read them. @type {object[]} */
396
- #files = [];
397
-
398
- /** What the whole torrent weighs, so a last piece is not read past its end. */
399
- #torrentLength = 0;
400
-
401
- /**
402
- * How to read a piece neither tier has, or null when there is nowhere else.
403
- *
404
- * @type {((where: object) => Promise<Uint8Array | null>) | null}
405
- */
406
- #readElsewhere = null;
407
-
408
- /** Reads waiting for a reservation, in arrival order. */
409
- #readerClaims = [];
410
-
411
- /** One revival per piece, shared by concurrent readers. */
412
- #revivals = new Map();
413
-
414
- /**
415
- * Whether a piece can be had elsewhere, asked without reading it — so a
416
- * spilled copy that has become a duplicate can be dropped.
417
- *
418
- * @type {((where: object) => boolean) | null}
419
- */
420
- #isElsewhere = null;
421
-
422
- /**
423
- * Said when this store can no longer produce a piece AT ALL — not resident,
424
- * not on disk, and not inside a file held whole.
425
- *
426
- * **Why it has to exist.** This store holds the bytes, so it owns the fact
427
- * "this proxy has piece N". Something else kept a second copy of that fact —
428
- * the torrent's own completion bitfield — and nothing reconciled them: the
429
- * disk tier drops a piece behind every read head, correctly, and the bitfield
430
- * goes on saying the piece is verified. A read then concludes the piece is
431
- * had, asks for it, is told it is absent, and fails; and it is never
432
- * re-downloaded either, because the library does not fetch what it believes
433
- * it already owns. Field 2026-09-12: a film played 80 seconds, the encoder
434
- * ran on to 725 s, `forgetBehind` dropped some 565 spilled pieces including
435
- * piece 0, the encoder restarted and re-opened its input at byte 0, and
436
- * `/stream` answered `0 of 2363497962 bytes: Piece 0 is verified but absent
437
- * from the store` for 92 minutes while the browser retried one segment.
438
- *
439
- * So the bitfield becomes a projection of this fact, maintained by this
440
- * announcement. This store does not know what a torrent is, who listens, or
441
- * what they do about it — the same shape as `readPieceElsewhere` above.
442
- *
443
- * @type {((what: object) => void) | null}
444
- */
445
- #onPieceGone = null;
446
-
447
- /** Whether the last revision had to exceed the machine's share. */
448
- #beyondTheMachine = false;
449
- /**
450
- * How long a block sat free before it was taken again, in milliseconds.
451
- * Bounded, because what is wanted is the longest gap of RECENT work: an
452
- * all-time maximum would be raised by one long pause and then never let a
453
- * block go again.
454
- *
455
- * @type {number[]}
456
- */
457
- #reuseGaps = [];
458
- /**
459
- * How long recent writes to disk took, in milliseconds, and when pieces were
460
- * admitted. Together they say how much room the store needs beyond what the
461
- * readers ask for: while one write is finishing, more pieces arrive, and each
462
- * needs somewhere to go. Without that room every arrival evicts something,
463
- * which is what produced 233 evictions against 119 completed writes in a
464
- * minute on 2026-09-02.
465
- *
466
- * @type {number[]}
467
- */
468
- #writeDurations = [];
469
- /** When the last few pieces were admitted, for the arrival rate. */
470
- #admittedAt = [];
471
- /** Piece index → when it was written out, for the age it comes back at. */
472
- #spilledAt = new Map();
473
- /**
474
- * Ages, in milliseconds, of the last revivals — bounded, because the figure
475
- * wanted is a median and not a history. A piece that comes back seconds
476
- * after it left should not have left.
477
- *
478
- * @type {number[]}
479
- */
480
- #revivalAges = [];
481
-
482
- constructor(chunkLength, options = {}) {
483
- if (!Number.isInteger(chunkLength) || chunkLength < 1) {
484
- throw new Error(`Chunk length must be a positive integer, got ${chunkLength}.`);
485
- }
486
- this.#chunkLength = chunkLength;
487
-
488
- const totalLength = Number.isFinite(options.length) ? options.length : 0;
489
- this.#lastChunkIndex = totalLength > 0 ? Math.ceil(totalLength / chunkLength) - 1 : -1;
490
- const remainder = totalLength % chunkLength;
491
- this.#lastChunkLength = remainder === 0 ? chunkLength : remainder;
492
-
493
- const memoryBytes = Number.isFinite(options.memoryBytes) && options.memoryBytes > 0
494
- ? options.memoryBytes
495
- : defaultMemoryBytes();
496
- // Only where this store STARTS. It is not kept, so it cannot come back as a
497
- // cap on the revision the way `#capacity` did.
498
- const openingCeiling = Math.max(MIN_RESIDENT_PIECES, Math.floor(memoryBytes / chunkLength));
499
- this.#growthCeiling = openingCeiling;
500
- this.#lru = new PieceLru(openingCeiling);
501
- this.#name = options.name ?? "pieces";
502
- // THE LAST PLACE A PIECE CAN COME FROM, handed in as two plain functions.
503
- // Where those bytes are, and what a file is, is not this store's business:
504
- // it holds pieces, and these say whether one can be had elsewhere and how.
505
- this.#readElsewhere = typeof options.readPieceElsewhere === "function"
506
- ? options.readPieceElsewhere
507
- : null;
508
- this.#isElsewhere = typeof options.isPieceElsewhere === "function"
509
- ? options.isPieceElsewhere
510
- : null;
511
- this.#onPieceGone = typeof options.onPieceGone === "function"
512
- ? options.onPieceGone
513
- : null;
514
- // Plain data the layer above needs to answer those two, and which this
515
- // store is handed anyway: where each file of the torrent begins and how
516
- // long the whole of it is.
517
- this.#files = Array.isArray(options.files) ? options.files : [];
518
- this.#torrentLength = totalLength;
519
- // `options.disk` exists so a test can hold a write open or make one fail on
520
- // purpose. Four of the defects fixed here live in what happens when the
521
- // disk tier does not answer immediately or at all, and none of them is
522
- // reachable from outside without saying so.
523
- this.#disk = options.disk ?? new PieceDiskStore({
524
- directory: options.path ?? ".",
525
- name: `${this.#name}.pieces`,
526
- // What it may hold is settled by the same revision that settles memory,
527
- // within a minute of the store existing. Until then it is unbounded, which
528
- // is what it has always been — the difference is that it now stops.
529
- allowanceBytes: null,
530
- // Where the live readers stand, so what goes first is decided by them and
531
- // not by which piece happened to be touched longest ago.
532
- readHeads: () => this.#lru.readHeads(),
533
- // EVERY WAY A PIECE LEAVES THE DISK COMES THROUGH HERE — behind the read
534
- // heads, over the allowance, dropped as a duplicate, or forgotten while a
535
- // spill finished. One listener instead of a list of call sites to
536
- // remember, which is what let the announcement be missed at three of them.
537
- onForgotten: (index) => this.#announceGoneIfNowhere(index)
538
- });
539
- liveStores.add(this);
540
- }
541
-
542
- stats() {
543
- const resident = this.#buffers.size;
544
- const residentBytes = resident * this.#chunkLength;
545
- // Last piece may be short but stats historically use chunkLength.
546
- return {
547
- name: this.#name,
548
- resident,
549
- capacity: this.#growthCeiling,
550
- residentBytes,
551
- allocatedSlots: this.#blocksAllocated,
552
- // What the process HOLDS, which with a pool is the blocks that exist —
553
- // not the pieces in them. Holding and using are different quantities and
554
- // the difference is the point of the reading.
555
- committedBytes: this.#blocksAllocated * this.#chunkLength,
556
- budgetBytes: this.#growthCeiling * this.#chunkLength,
557
- pinned: this.#lru.pinnedCount,
558
- // Slots claimed and not yet filled. Reported because a reservation that
559
- // is never returned is invisible until the store cannot admit anything,
560
- // and by then the reason is long gone. At rest this is zero.
561
- outstanding: this.#outstandingPieces,
562
- // How long since ANYTHING in this store moved — a write finishing, a
563
- // piece admitted, a pin released. A claim gives up after
564
- // `PINNED_WAIT_MS` of exactly this, so it is the figure that says whether
565
- // a refusal was the store being busy or the store being stuck, and until
566
- // 2026-09-11 nothing printed it: the field had 152 refusals in 104
567
- // seconds and no way to tell which.
568
- stillMs: this.#lastProgressAt > 0 ? Date.now() - this.#lastProgressAt : 0,
569
- spilled: this.#disk.size,
570
- // What the spill file ACTUALLY weighs, piece by piece, rather than the
571
- // piece count times a full piece length. The last piece of a torrent is
572
- // short, and until 2026-09-10 nothing here counted disk at all — the
573
- // figure was a multiplication, and the thing it stood for had no ceiling.
574
- spilledBytes: this.#disk.bytes,
575
- spillAllowanceBytes: this.#disk.allowanceBytes,
576
- spillEvictions: this.#disk.stats().evictions,
577
- // What the readers between them are asking this store to keep, against
578
- // what it may hold. A union wider than the capacity cannot be held
579
- // however the eviction is ordered, and that is the difference between a
580
- // policy to fix and arithmetic to accept (roadmap item 9).
581
- demand: this.#lru.demand(),
582
- // What the process actually holds for this store, which is not the same
583
- // as what it is using: blocks are kept for re-use. The difference is the
584
- // spare, and it is the whole of the question whether consumption only
585
- // grows (roadmap item 2).
586
- blocksAllocated: this.#blocksAllocated,
587
- blocksFree: this.#freeBlocks.length,
588
- blocksDisplaced: this.#counters.blocksDisplaced,
589
- blocksInUse: this.#blocksInUse(),
590
- blocksInFlight: this.#blocksInFlight(),
591
- blocksBeyondCeiling: this.#counters.blocksBeyondCeiling,
592
- blockBytes: this.#blocksAllocated * this.#chunkLength,
593
- reuseGapMs: this.#reuseGapCeilingMs(),
594
- revivalAgeMedianMs: median(this.#revivalAges),
595
- revivalAgeSamples: this.#revivalAges.length,
596
- revivedWithinFiveSeconds: this.#revivalAges.filter((age) => age <= 5_000).length,
597
- ...this.#counters
598
- };
599
- }
600
-
601
- /**
602
- * Whether the machine's share is smaller than current read demand.
603
- *
604
- * The store holds the window anyway — refusing would leave the read it is
605
- * serving unable to finish, which is worse — but it is the honest measure of
606
- * "this machine cannot take any more", and it is measured rather than
607
- * guessed: it is the last revision's comparison of the machine's share
608
- * against declared windows and distinct pieces pinned by live reads.
609
- */
610
- get isBeyondTheMachine() {
611
- return this.#beyondTheMachine;
612
- }
613
-
614
- /** What this store holds right now, in bytes. */
615
- get residentBytes() {
616
- return this.#buffers.size * this.#chunkLength;
617
- }
618
-
619
- /** What this store's spilled pieces weigh on disk. */
620
- get spilledBytes() {
621
- return this.#disk.bytes;
622
- }
623
-
624
- /**
625
- * Say how much disk this store's spilled pieces may take.
626
- *
627
- * The counterpart of `reviseGrowthCeiling`, and settled by the same pass: the
628
- * two tiers of one store are two readings of one machine.
629
- *
630
- * @param {number | null} allowedBytes
631
- * @returns {{ name: string, allowanceBytes: number | null, bytes: number }}
632
- */
633
- reviseSpillCeiling(allowedBytes) {
634
- // THE OTHER RULE, and it does not wait for the disk to be short. A piece
635
- // behind every read head has been read and will not be read again unless
636
- // somebody seeks back, and a seek back re-downloads it — the same bargain
637
- // this tier makes whenever it drops a piece for room. Without it the spill
638
- // is bounded only by a share of free space, which on a roomy host is tens of
639
- // gigabytes against a measured growth of 14 400 MB in one viewing: the
640
- // ceiling never binds and nothing is ever removed until the torrent goes.
641
- const behind = this.#disk.forgetBehind(this.#lru.readHeads());
642
- return {
643
- name: this.#name,
644
- allowanceBytes: this.#disk.reviseAllowance(allowedBytes),
645
- bytes: this.#disk.bytes,
646
- behind
647
- };
648
- }
649
-
650
- /**
651
- * What this store is asking to be allowed to hold, in bytes.
652
- *
653
- * The union of its readers' declared windows — what they have said they will
654
- * need — never below the widest single window, because a store that cannot
655
- * hold one reader's window cannot complete that reader's read at all: every
656
- * resident piece ends up pinned, the read returns zero bytes and ffmpeg takes
657
- * that for the end of the file (field 2026-08-15, roadmap item 9).
658
- *
659
- * A declared window is not the only evidence of demand: a live reader pins
660
- * each piece it is waiting on or handing to its consumer. Include those
661
- * distinct pins as well, because some byte reads do not have a priority-map
662
- * range to declare.
663
- */
664
- get wantedBytes() {
665
- const demand = this.#lru.demand();
666
- const pinned = this.#lru.pinnedCount;
667
- if (demand.readers > 0) {
668
- this.#widestSeenPieces = Math.max(this.#widestSeenPieces, demand.widestPieces);
669
- const pieces = Math.max(MIN_RESIDENT_PIECES, demand.unionPieces, demand.widestPieces, pinned);
670
- // Plus room to absorb what arrives while a write is finishing. Asking for
671
- // exactly what the readers want leaves no free place ever, so every
672
- // arrival evicts one of them — measured 2026-09-02: `6 reader(s) want 23
673
- // piece(s) of 23 the store may hold`, and 233 evictions in the minute
674
- // that followed.
675
- return (pieces + this.slackPieces()) * this.#chunkLength;
676
- }
677
- // WITH NO DECLARED RANGE, the floor is the widest prior window or the
678
- // distinct pieces pinned by current reads, both measured rather than
679
- // chosen. `MIN_RESIDENT_PIECES` applies until either has been observed.
680
- //
681
- // It used to fall to the minimum the moment the last reader went, and the
682
- // allowance is re-derived once a minute (`STORE_REPORT_INTERVAL_MS`) while
683
- // a claim gives up after five seconds. A reader arriving into that minute
684
- // met a store of two or three blocks: field 2026-09-11, the viewer switched
685
- // to another file of the same torrent and the store was at
686
- // `0/3 (0MB of 12MB allowed)` with the machine offering 4.3 GB.
687
- //
688
- // Retaining the last declared window between reads avoids dropping back to
689
- // the minimum; current pins cover reads that have no declared window. A
690
- // store that has never been read keeps only the measured minimum.
691
- const pieces = Math.max(MIN_RESIDENT_PIECES, this.#widestSeenPieces, pinned);
692
- return (pieces + this.slackPieces()) * this.#chunkLength;
693
- }
694
-
695
- reviseGrowthCeiling(allowedBytes) {
696
- // No cap at what the machine could spare when this store was CREATED.
697
- // `#capacity` was computed once in the constructor and used as an upper
698
- // bound here, so the allowance could only ever fall: a torrent opened while
699
- // the machine was full kept a small allowance for its whole life, however
700
- // much memory was freed afterwards (roadmap item 2, 2026-09-02).
701
- const wanted = Math.floor(Number(allowedBytes) / this.#chunkLength);
702
- // Never below what the live readers together need, even when the machine's
703
- // share says less. That is the union of declared windows, or the distinct
704
- // pieces pinned by direct reads that have no declared window. A store that
705
- // cannot hold what its readers are pinning cannot complete those reads:
706
- // every resident piece ends up pinned, a read returns zero bytes and
707
- // ffmpeg takes that for the end of the file, which killed every encoder on
708
- // that file in the field on
709
- // 2026-08-15 (one reader) and again on 2026-09-07 (three readers of one
710
- // file at once — picture, sound and the edge-warming read — where this
711
- // floor was still computed from only the widest one of them, `wantedBytes`
712
- // above already asks for the union and got it right; this is the same
713
- // union, used as the floor instead of only as the ask). A live read can
714
- // also pin pieces without a priority-map range, so those measured pins are
715
- // part of the same minimum. Exceeding the share is the lesser failure, and
716
- // the line below says when it happens.
717
- const demand = this.#lru.demand();
718
- const activeDemandPieces = Math.max(
719
- MIN_RESIDENT_PIECES,
720
- demand.readers > 0 ? demand.unionPieces : 0,
721
- this.#lru.pinnedCount
722
- );
723
- this.#growthCeiling = Math.max(
724
- activeDemandPieces,
725
- Number.isFinite(wanted) ? wanted : MIN_RESIDENT_PIECES
726
- );
727
- const belowActiveDemand = Number.isFinite(wanted) && wanted < activeDemandPieces;
728
- this.#beyondTheMachine = belowActiveDemand;
729
- // The LRU is told too. It was constructed with the store's original
730
- // capacity and never revised, so `isFull()` answered against a number that
731
- // had not been the limit for some time — dormant only because nothing calls
732
- // it, which is a trap for whoever calls it next.
733
- this.#lru.setCapacity(this.#growthCeiling);
734
- // With per-piece buffers memory CAN be given back immediately, unlike the
735
- // old growable pool. Eagerly evict excess to honour the new ceiling.
736
- let evicted = 0;
737
- while (this.#buffers.size > this.#growthCeiling) {
738
- const { index: victim, protectionYielded, distance } = this.#lru.evictionChoice();
739
- if (victim === null) {
740
- break;
741
- }
742
- const victimBuffer = this.#buffers.get(victim);
743
- if (victimBuffer === undefined) {
744
- this.#lru.remove(victim);
745
- continue;
746
- }
747
- this.#buffers.delete(victim);
748
- this.#lru.remove(victim);
749
- evicted += 1;
750
- this.#counters.evictedOnRevise += 1;
751
- this.#noteEviction(protectionYielded, distance);
752
- // Nobody awaits this spill, so its failure has to end here. Rethrowing
753
- // made it an unhandled rejection, and an unhandled rejection in the
754
- // torrent worker ends the thread — a second way to lose the torrent
755
- // client, on top of the one that already loses it.
756
- void this.#spill(victim, victimBuffer).catch(() => {
757
- this.#counters.spillFailures += 1;
758
- });
759
- }
760
- // Blocks the store is no longer using and has waited long enough to give
761
- // up. The allowance falling is the moment to ask, because that is when the
762
- // machine has been shown to need the memory.
763
- const releasedBlocks = this.sweepFreeBlocks();
764
- return {
765
- name: this.#name,
766
- ceilingBytes: this.#growthCeiling * this.#chunkLength,
767
- committedBytes: this.#blocksAllocated * this.#chunkLength,
768
- evicted,
769
- releasedBlocks,
770
- belowActiveDemand
771
- };
772
- }
773
-
774
- get chunkLength() {
775
- return this.#chunkLength;
776
- }
777
-
778
- get capacity() {
779
- return this.#growthCeiling;
780
- }
781
-
782
- get residentCount() {
783
- return this.#buffers.size;
784
- }
785
-
786
- get spilledCount() {
787
- return this.#disk.size;
788
- }
789
-
790
- #lengthOf(index) {
791
- return index === this.#lastChunkIndex ? this.#lastChunkLength : this.#chunkLength;
792
- }
793
-
794
- /**
795
- * Where a resident piece sits, or `null` if not resident.
796
- * Returns the piece's own SharedArrayBuffer and the intra-piece range.
797
- * @param {number} index
798
- * @returns {{ buffer: SharedArrayBuffer, offset: number, length: number } | null}
799
- */
800
- locate(index) {
801
- const buffer = this.#buffers.get(index);
802
- if (buffer === undefined) {
803
- return null;
804
- }
805
- return { buffer, offset: 0, length: this.#lengthOf(index) };
806
- }
807
-
808
- /**
809
- * Direct access to a piece's buffer for zero-copy consumers.
810
- * @param {number} index
811
- * @returns {SharedArrayBuffer | undefined}
812
- */
813
- getPieceBuffer(index) {
814
- return this.#buffers.get(index);
815
- }
816
-
817
- pin(index) {
818
- this.#lru.pin(index);
819
- // A piece reader pins before it waits for the bytes to arrive or return
820
- // from disk. That is the only live record of some reads; the priority map
821
- // can have no declared range for them (for example, a verified piece that
822
- // was spilled). Include the measured set of distinct active pins in the
823
- // immediate floor so a claim does not wait for the next budget pass to get
824
- // a slot. `wantedBytes` carries the same demand to the machine-wide owner.
825
- const pinned = this.#lru.pinnedCount;
826
- if (pinned > this.#growthCeiling) {
827
- this.#growthCeiling = pinned;
828
- this.#lru.setCapacity(pinned);
829
- this.#beyondTheMachine = true;
830
- this.#wake();
831
- }
832
- }
833
-
834
- unpin(index) {
835
- this.#lru.unpin(index);
836
- this.#noteProgress();
837
- }
838
-
839
- /**
840
- * Reserve room for one piece.
841
- *
842
- * @returns {Promise<() => void>} The release, which the caller MUST call in a
843
- * `finally`. Calling it twice is harmless.
844
- */
845
- /**
846
- * The same reservation, answering null instead of throwing.
847
- *
848
- * For callers that have somewhere else to go: an arriving piece has the disk,
849
- * so it writes through rather than failing. Keeping the throwing form for
850
- * callers that genuinely have no alternative is what makes the difference
851
- * visible at the call site rather than in a catch.
852
- *
853
- * @returns {Promise<(() => void) | null>}
854
- */
855
- async #claimSlotOrNull() {
856
- try {
857
- return await this.#claimSlot();
858
- } catch (error) {
859
- if (this.#closed) {
860
- throw error;
861
- }
862
- this.#counters.admittedWithoutSlot += 1;
863
- return null;
864
- }
865
- }
866
-
867
- async #claimSlot(forReader = false) {
868
- // How long THIS claim has been trying, kept here and not on the store.
869
- // It was a field, `#pinnedWaitStartedAt`, shared by the two waits inside
870
- // `#claimSlotOnce` — the one for the disk and the one for a piece that may
871
- // be evicted — and each of them zeroed it on giving up, which restarted the
872
- // other's clock. Measured 2026-09-03: the claim cycled for ever, five
873
- // seconds per side, `grewWaitingForDisk` and `waitedForPins` both climbing
874
- // once every five seconds while `blockedByPins` stayed at 0 — the refusal
875
- // was unreachable. A claim's patience belongs to the claim; with several
876
- // claimants a shared field is wrong anyway, since one caller giving up
877
- // would reset the wait of every other.
878
- const waitingSince = Date.now();
879
- const claimant = forReader ? {} : null;
880
- if (claimant) this.#readerClaims.push(claimant);
881
- try {
882
- for (;;) {
883
- if (this.#closed) {
884
- throw new Error("Piece store is closed.");
885
- }
886
- // New arrivals must not take every released block ahead of an encoder
887
- // already waiting to read. Readers share one ordered reservation queue.
888
- const hasTurn = this.#readerClaims.length === 0 || this.#readerClaims[0] === claimant;
889
- const isReserved = hasTurn && await this.#claimSlotOnce(waitingSince);
890
- if (isReserved) {
891
- let released = false;
892
- return () => {
893
- if (released) {
894
- return;
895
- }
896
- released = true;
897
- this.#outstandingPieces -= 1;
898
- this.#noteProgress();
899
- };
900
- }
901
- await this.#waitForSlot();
902
- }
903
- } finally {
904
- if (claimant) {
905
- const at = this.#readerClaims.indexOf(claimant);
906
- if (at >= 0) this.#readerClaims.splice(at, 1);
907
- this.#wake();
908
- }
909
- }
910
- }
911
-
912
- /**
913
- * Sleep until something moves, or until the retry interval, whichever first.
914
- *
915
- * One handler, idempotent, and its timer is cleared when it is woken. The
916
- * earlier version attached a fresh pair of handlers to EVERY pending spill on
917
- * every attempt and left a timer running each time, so a claim that could not
918
- * be satisfied allocated in proportion to attempts times pending spills. A
919
- * settling spill now wakes the store itself, which is where that belongs.
920
- *
921
- * @returns {Promise<void>}
922
- */
923
- #waitForSlot() {
924
- return new Promise((resolve) => {
925
- let settled = false;
926
- /** @type {ReturnType<typeof setTimeout> | null} */
927
- let retry = null;
928
- const finish = () => {
929
- if (settled) {
930
- return;
931
- }
932
- settled = true;
933
- if (retry !== null) {
934
- clearTimeout(retry);
935
- }
936
- resolve();
937
- };
938
- this.#waiters.push(finish);
939
- retry = setTimeout(finish, CLAIM_RETRY_MS);
940
- retry.unref?.();
941
- });
942
- }
943
-
944
- #registerPiece(index, buffer) {
945
- // A piece may already be here. Both paths that register one look first and
946
- // then await — `put` waits for a slot, `#revive` waits for the disk — and
947
- // in that gap another caller can register the same index. Overwriting the
948
- // entry used to drop the previous block on the floor: its memory was not
949
- // returned to the pool and the pool's own count was never decremented, so
950
- // the count climbed past the ceiling for ever and the pool degenerated into
951
- // allocating a fresh block per piece. Field 2026-09-02: a store holding
952
- // THREE pieces reported 812 MB committed against 68 MB allowed, 739 blocks
953
- // allocated and 676 still alive, and the process was killed at 4.37 GB.
954
- const displaced = this.#buffers.get(index);
955
- if (displaced !== undefined && displaced !== buffer) {
956
- this.#counters.blocksDisplaced += 1;
957
- // A reader may still hold the existing buffer pinned. Verified bytes for
958
- // this index are identical, so retain that buffer and return the duplicate.
959
- this.#returnBlock(buffer);
960
- this.#lru.touch(index);
961
- this.#noteProgress();
962
- return displaced;
963
- }
964
- this.#buffers.set(index, buffer);
965
- this.#lru.touch(index);
966
- this.#noteProgress();
967
- return buffer;
968
- }
969
-
970
- /**
971
- * Write a piece out and account for it, from the one place that does it.
972
- *
973
- * @param {number} index
974
- * @param {SharedArrayBuffer} buffer
975
- * @returns {Promise<void>}
976
- */
977
- #spill(index, buffer) {
978
- // The disk may already hold these very bytes. `#revive` reads a piece back
979
- // into memory and leaves the copy on disk, and only `put` removes it — so a
980
- // piece that was revived and not re-put is identical to what is already
981
- // written, and writing it again is work for nothing. There were 7575
982
- // revivals in one session on 2026-09-02, and every later eviction of one of
983
- // them wrote a second time (roadmap item 66: 14.4 GB written in a single
984
- // viewing).
985
- if (this.#disk.has(index)) {
986
- this.#counters.spills += 1;
987
- this.#counters.spillsSkipped += 1;
988
- this.#spilledAt.set(index, Date.now());
989
- this.#returnBlock(buffer);
990
- this.#noteProgress();
991
- return Promise.resolve();
992
- }
993
-
994
- const bytes = Buffer.from(buffer, 0, this.#lengthOf(index));
995
- const startedAt = Date.now();
996
- const spill = this.#disk.write(index, bytes).then(
997
- () => {
998
- this.#noteWriteDuration(Date.now() - startedAt);
999
- this.#counters.spills += 1;
1000
- this.#spilledAt.set(index, Date.now());
1001
- this.#evicting.delete(index);
1002
- // Only now. The write reads out of this block, so a block handed to
1003
- // another piece before the write finished would put that piece's bytes
1004
- // into this piece's place in the file.
1005
- this.#returnBlock(buffer);
1006
- this.#noteProgress();
1007
- },
1008
- (error) => {
1009
- this.#evicting.delete(index);
1010
- // The block is no longer holding anything either way; keeping it out of
1011
- // the pool because the write failed would lose it for good.
1012
- this.#returnBlock(buffer);
1013
- this.#noteProgress();
1014
- throw error;
1015
- }
1016
- );
1017
- this.#evicting.set(index, spill);
1018
- return spill;
1019
- }
1020
-
1021
- /** Something actually moved: wake whoever is waiting and restart the clock. */
1022
- #noteProgress() {
1023
- this.#lastProgressAt = Date.now();
1024
- this.#wake();
1025
- }
1026
-
1027
- /**
1028
- * Record how long one write to disk took.
1029
- *
1030
- * @param {number} durationMs
1031
- * @returns {void}
1032
- */
1033
- #noteWriteDuration(durationMs) {
1034
- this.#writeDurations.push(Math.max(0, durationMs));
1035
- if (this.#writeDurations.length > WRITE_DURATION_SAMPLES) {
1036
- this.#writeDurations.shift();
1037
- }
1038
- }
1039
-
1040
- /** Record that a piece arrived, for the arrival rate. */
1041
- #noteArrival() {
1042
- const now = Date.now();
1043
- this.#admittedAt.push(now);
1044
- while (this.#admittedAt.length > ARRIVAL_SAMPLES
1045
- || (this.#admittedAt.length > 0 && now - this.#admittedAt[0] > ARRIVAL_WINDOW_MS)) {
1046
- this.#admittedAt.shift();
1047
- }
1048
- }
1049
-
1050
- /**
1051
- * How many pieces the store needs room for beyond what the readers ask for.
1052
- *
1053
- * Measured, not chosen: the pieces that arrive while one write to disk is
1054
- * finishing. Without this room every arrival must evict something, and each
1055
- * eviction holds its block until its write completes — so a disk slower than
1056
- * the swarm turns every admission into one more block held. On 2026-09-02
1057
- * that was 233 evictions against 119 completed writes in a minute, and 203
1058
- * blocks held with three pieces resident.
1059
- *
1060
- * Zero until both quantities have been seen, because a slack invented before
1061
- * anything is measured is a chosen number, and this store has been bitten by
1062
- * those.
1063
- *
1064
- * @returns {number} Pieces.
1065
- */
1066
- slackPieces() {
1067
- if (this.#writeDurations.length === 0 || this.#admittedAt.length < 2) {
1068
- return 0;
1069
- }
1070
- const sorted = [...this.#writeDurations].sort((left, right) => left - right);
1071
- const writeMs = sorted[Math.floor(sorted.length / 2)];
1072
- const spanMs = this.#admittedAt[this.#admittedAt.length - 1] - this.#admittedAt[0];
1073
- if (!(spanMs > 0)) {
1074
- return 0;
1075
- }
1076
- const perMs = (this.#admittedAt.length - 1) / spanMs;
1077
- return Math.ceil(perMs * writeMs);
1078
- }
1079
-
1080
- /**
1081
- * Blocks that are not free: resident pieces, blocks being written out, and
1082
- * blocks taken but not yet registered.
1083
- *
1084
- * This is what the ceiling has to bound, and until 2026-09-02 it bounded
1085
- * resident pieces instead. The difference is exactly the memory that goes
1086
- * missing from the count: a piece being spilled leaves `#buffers` the moment
1087
- * the eviction begins, while its block stays held until the write it feeds
1088
- * has finished.
1089
- *
1090
- * @returns {number}
1091
- */
1092
- #blocksInUse() {
1093
- return this.#blocksAllocated - this.#freeBlocks.length;
1094
- }
1095
-
1096
- /**
1097
- * Blocks held by writes that have not finished.
1098
- *
1099
- * @returns {number}
1100
- */
1101
- #blocksInFlight() {
1102
- return Math.max(0, this.#blocksInUse() - this.#buffers.size);
1103
- }
1104
-
1105
- /**
1106
- * Whether admitting one more piece would need something evicted first.
1107
- *
1108
- * Reservations count: a slot claimed and not yet filled is as taken as a
1109
- * resident piece.
1110
- *
1111
- * @returns {boolean}
1112
- */
1113
- #isFullNow() {
1114
- return this.#blocksInUse() + this.#outstandingPieces >= this.#growthCeiling;
1115
- }
1116
-
1117
- /**
1118
- * Write an arriving piece straight to disk, without it ever occupying memory.
1119
- *
1120
- * Registered in `#evicting` like a spill, so a `get` for this index waits for
1121
- * the write instead of finding the piece on neither tier. Deliberately NOT
1122
- * recorded in `#spilledAt`: that clock measures how long an EVICTED piece
1123
- * stayed away, and a piece that was never resident has no such age.
1124
- *
1125
- * @param {number} index
1126
- * @param {Uint8Array} bytes
1127
- * @returns {Promise<void>}
1128
- */
1129
- #writeThrough(index, bytes) {
1130
- // Copied, not viewed. `PieceDiskStore.write` opens the file before it reads the
1131
- // bytes, so a view onto the caller's buffer could be written to in between
1132
- // and the file would get the wrong data. The spill path may pass a view
1133
- // because that memory is ours; this buffer belongs to the torrent client.
1134
- // It costs nothing extra: the piece was being copied into a shared buffer
1135
- // on this path before, and now it is copied here instead.
1136
- const copy = Buffer.from(bytes.subarray(0, Math.min(bytes.length, this.#lengthOf(index))));
1137
- const write = this.#disk.write(index, copy).then(
1138
- () => {
1139
- this.#evicting.delete(index);
1140
- this.#noteProgress();
1141
- },
1142
- (error) => {
1143
- this.#evicting.delete(index);
1144
- this.#noteProgress();
1145
- throw error;
1146
- }
1147
- );
1148
- this.#evicting.set(index, write);
1149
- return write;
1150
- }
1151
-
1152
- /**
1153
- * Record how long a piece stayed on disk before it was wanted again.
1154
- *
1155
- * A piece that comes back seconds after it left was evicted from a working
1156
- * set that does not fit, and the write and the read were both waste. Kept as
1157
- * a bounded window of ages because the figure wanted is a median, not a
1158
- * history.
1159
- *
1160
- * @param {number} index
1161
- * @returns {void}
1162
- */
1163
- #noteRevival(index) {
1164
- const spilledAt = this.#spilledAt.get(index);
1165
- if (spilledAt === undefined) {
1166
- return;
1167
- }
1168
- this.#spilledAt.delete(index);
1169
- this.#revivalAges.push(Date.now() - spilledAt);
1170
- if (this.#revivalAges.length > REVIVAL_AGE_SAMPLES) {
1171
- this.#revivalAges.shift();
1172
- }
1173
- }
1174
-
1175
- /**
1176
- * Record what an eviction had to take.
1177
- *
1178
- * @param {boolean} protectionYielded - The victim was inside a window a
1179
- * reader had declared, and was taken anyway because nothing else was free.
1180
- * @param {number} distance - Pieces from the nearest declared window, -1 when
1181
- * no reader declared one.
1182
- * @returns {void}
1183
- */
1184
- #noteEviction(protectionYielded, distance) {
1185
- if (protectionYielded) {
1186
- this.#counters.evictedProtected += 1;
1187
- }
1188
- if (distance >= 0) {
1189
- this.#counters.evictedDistanceSum += distance;
1190
- this.#counters.evictedWithDistance += 1;
1191
- }
1192
- }
1193
-
1194
- /**
1195
- * One piece, from wherever else it can be had.
1196
- *
1197
- * @param {number} index
1198
- * @returns {Promise<Buffer | null>}
1199
- */
1200
- async #fromWholeFiles(index) {
1201
- return this.#readElsewhere
1202
- ? this.#readElsewhere({
1203
- index,
1204
- pieceLength: this.#chunkLength,
1205
- length: this.#torrentLength,
1206
- files: this.#files
1207
- })
1208
- : null;
1209
- }
1210
-
1211
- /**
1212
- * Say a piece has gone, but only once it has gone from everywhere.
1213
- *
1214
- * The disk tier announces its own loss, which is not the same statement: a
1215
- * piece dropped as a duplicate of a file held whole has not been lost at all,
1216
- * and one still resident is about to be spilled again rather than gone. Only
1217
- * the third case — neither tier, no whole file — is a withdrawal of the claim
1218
- * that this proxy has those bytes.
1219
- *
1220
- * Nothing is said while the store is closing: the torrent it belongs to is
1221
- * being torn down, and a claim withdrawn against a dying torrent reaches
1222
- * either a destroyed object or, worse, one that has already been added back.
1223
- *
1224
- * @param {number} index
1225
- * @returns {void}
1226
- */
1227
- #announceGoneIfNowhere(index) {
1228
- if (this.#closed || this.#onPieceGone === null) {
1229
- return;
1230
- }
1231
- if (this.#buffers.has(index) || this.#disk.has(index) || this.isInWholeFiles(index)) {
1232
- return;
1233
- }
1234
- this.#counters.withdrawn += 1;
1235
- try {
1236
- this.#onPieceGone({ index, files: this.#files, name: this.#name });
1237
- } catch {
1238
- // silent-ok: whoever listens is diagnosing or bookkeeping, and a listener
1239
- // that throws must not be what fails an eviction the store needs to make.
1240
- }
1241
- }
1242
-
1243
- /**
1244
- * Whether a spilled piece is now a duplicate of bytes held elsewhere.
1245
- *
1246
- * @param {number} index
1247
- * @returns {boolean}
1248
- */
1249
- isInWholeFiles(index) {
1250
- return this.#isElsewhere
1251
- ? this.#isElsewhere({
1252
- index,
1253
- pieceLength: this.#chunkLength,
1254
- length: this.#torrentLength,
1255
- files: this.#files
1256
- }) === true
1257
- : false;
1258
- }
1259
-
1260
- /**
1261
- * Drop spilled pieces that are now duplicates of bytes held elsewhere.
1262
- *
1263
- * A film assembled into a file is on the disk twice until this runs: once as
1264
- * the file and once as the pieces it was built from. Field 2026-09-11, one
1265
- * episode: 1417 MB of segments beside 1424 MB of spilled pieces. Nothing is
1266
- * lost by dropping the second — a read that wants one of those pieces is
1267
- * answered from the file.
1268
- *
1269
- * @returns {number} How many were dropped.
1270
- */
1271
- dropDuplicatesHeldElsewhere() {
1272
- if (!this.#isElsewhere) {
1273
- return 0;
1274
- }
1275
- let dropped = 0;
1276
- for (const index of this.#disk.indexes()) {
1277
- if (this.#buffers.has(index) || this.#evicting.has(index)) {
1278
- continue;
1279
- }
1280
- if (this.isInWholeFiles(index)) {
1281
- this.#disk.forget(index);
1282
- dropped += 1;
1283
- }
1284
- }
1285
- return dropped;
1286
- }
1287
-
1288
- #wake() {
1289
- const waiting = this.#waiters;
1290
- this.#waiters = [];
1291
- for (const resolve of waiting) {
1292
- resolve();
1293
- }
1294
- }
1295
-
1296
- /**
1297
- * One attempt at a reservation.
1298
- *
1299
- * @param {number} waitingSince - When the claim this attempt belongs to began
1300
- * trying. Both waits below are measured from the later of it and the last
1301
- * time the store moved, so neither can restart the other's clock — see
1302
- * `#claimSlot`.
1303
- * @returns {Promise<boolean>} Whether a slot was reserved.
1304
- */
1305
- async #claimSlotOnce(waitingSince) {
1306
- // Reserve before suspension so concurrent callers see the reservation.
1307
- if (this.#blocksInUse() + this.#outstandingPieces < this.#growthCeiling) {
1308
- this.#outstandingPieces += 1;
1309
- return true;
1310
- }
1311
- const stillFor = () => Date.now() - Math.max(waitingSince, this.#lastProgressAt);
1312
-
1313
- // Full, and evicting would make it worse rather than better. A piece being
1314
- // written out has already left `#buffers` while its block is still held —
1315
- // the write reads from that block — so evicting another one converts a
1316
- // resident block into an in-flight block and takes a fresh block for the
1317
- // arrival: the memory in use goes UP by one per admission for as long as
1318
- // the disk is behind. Field 2026-09-02: 233 evictions against 119 completed
1319
- // writes in a minute, 203 blocks held with three pieces resident, and the
1320
- // process killed at 4.37 GB.
1321
- //
1322
- // So when the disk is what the store is waiting for, it waits. A completing
1323
- // write calls `#noteProgress`, which wakes whoever is here.
1324
- // THE SAME QUANTITY THE GRANT ABOVE TESTS. It used to ask whether
1325
- // `blocksInUse` alone had reached the ceiling, while what reaches the
1326
- // ceiling is `blocksInUse + outstanding` — so a store held at its ceiling
1327
- // by reservations fell past this branch and into the eviction below, which
1328
- // has nothing to evict when nothing is resident. Field 2026-09-11:
1329
- // `blocks=2 (0 spare)` with `outstanding=2` against a ceiling of 3, two
1330
- // writes in flight each of which would have returned a block, and the claim
1331
- // threw rather than waiting the moment out.
1332
- if (this.#blocksInFlight() > 0 && this.#blocksInUse() + this.#outstandingPieces >= this.#growthCeiling) {
1333
- if (stillFor() < PINNED_WAIT_MS) {
1334
- this.#counters.waitedForDisk += 1;
1335
- return false;
1336
- }
1337
- // The disk has stopped answering. Falling through to eviction is the
1338
- // lesser failure: it grows memory, and the line above says by how much,
1339
- // where refusing would fail the read outright. The clock is NOT restarted
1340
- // here: the wait that follows asks the same question of the same claim,
1341
- // and zeroing it was half of the loop described in `#claimSlot`.
1342
- this.#counters.grewWaitingForDisk += 1;
1343
- }
1344
-
1345
- const { index: victim, protectionYielded, distance } = this.#lru.evictionChoice();
1346
- if (victim === null) {
1347
- // Nothing may leave. Wait while the store is still MOVING — a spill
1348
- // completing, a piece admitted, a pin released — and give up when it has
1349
- // not moved for PINNED_WAIT_MS, whatever is nominally in flight. The old
1350
- // rule asked whether anything was in flight rather than whether anything
1351
- // had happened, which is why one lost reservation could hold a read here
1352
- // for ever.
1353
- if (stillFor() < PINNED_WAIT_MS) {
1354
- this.#counters.waitedForPins += 1;
1355
- return false;
1356
- }
1357
- this.#counters.blockedByPins += 1;
1358
- // SAY WHICH STATE REFUSED, with the numbers it was read from. The old
1359
- // text named one state — every resident piece pinned — and the field case
1360
- // of 2026-09-11 was a different one: `resident=0 pinned=0`, nothing to
1361
- // evict because nothing was in memory at all, the ceiling held by
1362
- // reservations and by two writes on their way to disk. A message that
1363
- // names a state that did not exist costs a day of reading the wrong code.
1364
- throw new Error(
1365
- `No block can be had for ${PINNED_WAIT_MS}ms: ` +
1366
- `${this.#buffers.size} resident (${this.#lru.pinnedCount} pinned, ` +
1367
- `${this.#lru.protectedCount} range(s) declared), ` +
1368
- `${this.#blocksInUse()} block(s) in use of ${this.#growthCeiling} allowed, ` +
1369
- `${this.#blocksInFlight()} on their way to disk, ` +
1370
- `${this.#outstandingPieces} reservation(s) outstanding.`
1371
- );
1372
- }
1373
-
1374
- const victimBuffer = this.#buffers.get(victim);
1375
- if (victimBuffer === undefined) {
1376
- // Should not happen: LRU says resident but buffer missing.
1377
- this.#lru.remove(victim);
1378
- return false;
1379
- }
1380
-
1381
- // Claim atomically before await.
1382
- this.#buffers.delete(victim);
1383
- this.#lru.remove(victim);
1384
- this.#outstandingPieces += 1;
1385
- this.#noteEviction(protectionYielded, distance);
1386
-
1387
- try {
1388
- await this.#spill(victim, victimBuffer);
1389
- } catch (error) {
1390
- // The caller never received a release for this reservation, so it is
1391
- // given back here rather than left outstanding for ever.
1392
- this.#outstandingPieces -= 1;
1393
- this.#noteProgress();
1394
- throw error;
1395
- }
1396
- // Outstanding stays +1 for the caller; the slot for the new piece is now free.
1397
- return true;
1398
- }
1399
-
1400
- /**
1401
- * Bring a spilled piece back into memory, once, however many callers ask.
1402
- *
1403
- * @param {number} index
1404
- * @returns {Promise<SharedArrayBuffer | null>} `null` when the piece is on
1405
- * neither tier.
1406
- */
1407
- async #revive(index) {
1408
- const spill = this.#evicting.get(index);
1409
- if (spill) {
1410
- await spill.catch(() => undefined);
1411
- }
1412
-
1413
- if (!this.#disk.has(index)) {
1414
- // Not spilled, but the file it belongs to may be here whole — which is
1415
- // the ordinary case once a film has been assembled and its spilled copy
1416
- // dropped as the duplicate it had become.
1417
- if (this.#isElsewhere && !this.isInWholeFiles(index)) return null;
1418
- // Fragment readers require shared memory, including when the bytes now
1419
- // live in an assembled file. Reserve before allocating ordinary bytes.
1420
- const release = await this.#claimSlot(true);
1421
- try {
1422
- const whole = await this.#fromWholeFiles(index);
1423
- if (!whole) return null;
1424
- this.#counters.fromWholeFile += 1;
1425
- return this.#registerPiece(index, this.#copyIntoNewBuffer(index, whole));
1426
- } finally {
1427
- release();
1428
- }
1429
- }
1430
-
1431
- // Hold the disk bytes before waiting for memory: another reader may move
1432
- // the eviction boundary while this read is waiting for a slot.
1433
- const releaseDisk = this.#disk.hold?.(index) ?? (() => {});
1434
- let release = null;
1435
- try {
1436
- release = await this.#claimSlot(true);
1437
- // Another caller may have brought it back while this one waited for a
1438
- // slot. Registering a second buffer for the same piece would leave
1439
- // whoever holds the first reading memory nothing evicts.
1440
- const already = this.#buffers.get(index);
1441
- if (already !== undefined) {
1442
- this.#lru.touch(index);
1443
- this.#counters.fromMemory += 1;
1444
- return already;
1445
- }
1446
- const target = this.#takeBlock();
1447
- try {
1448
- // Only this piece's own length: the block is a full piece long and the
1449
- // last piece of a file is shorter, so reading the whole block would ask
1450
- // the file for bytes past its end.
1451
- await this.#disk.read(index, Buffer.from(target, 0, this.#lengthOf(index)));
1452
- } catch (error) {
1453
- this.#returnBlock(target);
1454
- throw error;
1455
- }
1456
- const registered = this.#registerPiece(index, target);
1457
- this.#counters.fromDisk += 1;
1458
- this.#counters.revivals += 1;
1459
- this.#noteRevival(index);
1460
- return registered;
1461
- } finally {
1462
- release?.();
1463
- releaseDisk();
1464
- }
1465
- }
1466
-
1467
- /**
1468
- * A block to hold one piece: the most recently freed one, or a new one.
1469
- *
1470
- * Every block is a full piece long, whatever piece will live in it. The last
1471
- * piece of a file is shorter, and it occupies a full block with only its own
1472
- * bytes meaningful — `#lengthOf` is what decides how much is ever read out.
1473
- * Uniform blocks are what makes them interchangeable at all.
1474
- *
1475
- * Most recently freed first, deliberately: a few blocks then carry the whole
1476
- * of a busy store's traffic and the rest age out of use, which is what makes
1477
- * {@link SharedPieceStore#sweepFreeBlocks} able to tell a spare block from a
1478
- * working one.
1479
- *
1480
- * @returns {SharedArrayBuffer}
1481
- */
1482
- #takeBlock() {
1483
- const spare = this.#freeBlocks.pop();
1484
- if (spare !== undefined) {
1485
- this.#noteReuseGap(Date.now() - spare.freedAt);
1486
- return spare.buffer;
1487
- }
1488
- // Nothing spare and the pool is already as large as it is allowed to be.
1489
- // This cannot happen while the accounting is sound: a block is taken only
1490
- // after a slot has been claimed, and slots are exactly what the ceiling
1491
- // counts. It is recorded rather than hidden because when it does happen the
1492
- // pool grows without bound, and every block it then allocates is used once
1493
- // and thrown to a collector that has no reason to run — the heap stays at
1494
- // 50 MB of 2240 while the process reaches four gigabytes.
1495
- // Strictly greater: reaching the ceiling exactly is what a full store looks
1496
- // like, and a slot has just been claimed for this block. Being ALREADY past
1497
- // it and allocating anyway is the state that runs away.
1498
- if (this.#blocksAllocated > this.#growthCeiling) {
1499
- this.#counters.blocksBeyondCeiling += 1;
1500
- }
1501
- this.#blocksAllocated += 1;
1502
- return this.#watchForCollection(new SharedArrayBuffer(this.#chunkLength));
1503
- }
1504
-
1505
- /**
1506
- * Put a block back for re-use, or give it up.
1507
- *
1508
- * Given up when the allowance has fallen below the number of blocks that
1509
- * exist: keeping it would hold memory the machine has just been shown to
1510
- * need. Otherwise it waits in the free list for the next piece.
1511
- *
1512
- * @param {SharedArrayBuffer | undefined} buffer
1513
- * @returns {void}
1514
- */
1515
- #returnBlock(buffer) {
1516
- if (buffer === undefined) {
1517
- return;
1518
- }
1519
- if (this.#closed || this.#blocksAllocated > this.#growthCeiling) {
1520
- // Never below zero: `close` gives up every block at once, and a spill
1521
- // that was already in flight resolves afterwards and arrives here.
1522
- if (this.#blocksAllocated > 0) {
1523
- this.#blocksAllocated -= 1;
1524
- this.#counters.blocksReleased += 1;
1525
- }
1526
- return;
1527
- }
1528
- this.#freeBlocks.push({ buffer, freedAt: Date.now() });
1529
- }
1530
-
1531
- /**
1532
- * Record how long a block waited to be used again.
1533
- *
1534
- * @param {number} gapMs
1535
- * @returns {void}
1536
- */
1537
- #noteReuseGap(gapMs) {
1538
- this.#reuseGaps.push(Math.max(0, gapMs));
1539
- if (this.#reuseGaps.length > REUSE_GAP_SAMPLES) {
1540
- this.#reuseGaps.shift();
1541
- }
1542
- }
1543
-
1544
- /**
1545
- * The longest a block has recently waited before being wanted again, or null
1546
- * when no block has yet been re-used.
1547
- *
1548
- * This is the store's own working rhythm, measured rather than chosen: while
1549
- * a film is being watched a block is taken again within milliseconds, because
1550
- * one is taken for every piece that arrives. A block that has been sitting
1551
- * longer than the longest of those waits is not part of the work.
1552
- *
1553
- * @returns {number | null}
1554
- */
1555
- #reuseGapCeilingMs() {
1556
- if (this.#reuseGaps.length === 0) {
1557
- return null;
1558
- }
1559
- return Math.max(...this.#reuseGaps);
1560
- }
1561
-
1562
- /**
1563
- * Give up blocks that have sat unused longer than this store's own working
1564
- * rhythm.
1565
- *
1566
- * The case it is for: a torrent whose peers have gone. Its readers are still
1567
- * attached, so nothing removes the torrent — the pool's idle timer needs a
1568
- * refcount of zero and never starts. Its allowance falls to what those
1569
- * readers declared, the pieces beyond it are written out, and their blocks
1570
- * would otherwise wait in the free list for peers that may not return.
1571
- *
1572
- * @param {number} [now]
1573
- * @returns {number} Blocks given up.
1574
- */
1575
- sweepFreeBlocks(now = Date.now()) {
1576
- const ceiling = this.#reuseGapCeilingMs();
1577
- if (ceiling === null) {
1578
- return 0;
1579
- }
1580
- const keeping = [];
1581
- let released = 0;
1582
- for (const spare of this.#freeBlocks) {
1583
- if (now - spare.freedAt <= ceiling) {
1584
- keeping.push(spare);
1585
- continue;
1586
- }
1587
- this.#blocksAllocated -= 1;
1588
- this.#counters.blocksReleased += 1;
1589
- released += 1;
1590
- }
1591
- this.#freeBlocks = keeping;
1592
- return released;
1593
- }
1594
-
1595
- /**
1596
- * Count this buffer as one this thread will have to let go of, and notice
1597
- * when the collector takes it. See {@link pieceBufferCollection}.
1598
- *
1599
- * @param {SharedArrayBuffer} buffer
1600
- * @returns {SharedArrayBuffer} The same buffer.
1601
- */
1602
- #watchForCollection(buffer) {
1603
- released.count += 1;
1604
- collector?.register(buffer, null);
1605
- return buffer;
1606
- }
1607
-
1608
- /**
1609
- * A fresh buffer holding this piece's bytes.
1610
- *
1611
- * @param {number} index
1612
- * @param {Uint8Array} bytes
1613
- * @returns {SharedArrayBuffer}
1614
- */
1615
- #copyIntoNewBuffer(index, bytes) {
1616
- const length = this.#lengthOf(index);
1617
- const block = this.#takeBlock();
1618
- const view = Buffer.from(block, 0, length);
1619
- if (bytes.copy) {
1620
- bytes.copy(view, 0, 0, length);
1621
- } else {
1622
- view.set(bytes.subarray(0, length), 0);
1623
- }
1624
- return block;
1625
- }
1626
-
1627
- /**
1628
- * Drop the disk copy of a piece that memory now holds — after any spill of
1629
- * that same piece has finished.
1630
- *
1631
- * `PieceDiskStore.write` records the index when it COMPLETES, so forgetting while a
1632
- * spill of that index is still running let the completing write put it back,
1633
- * and a later read then returned the stale bytes.
1634
- *
1635
- * @param {number} index
1636
- * @returns {Promise<void>}
1637
- */
1638
- async #forgetOnDisk(index) {
1639
- const spill = this.#evicting.get(index);
1640
- if (spill) {
1641
- await spill.catch(() => undefined);
1642
- }
1643
- this.#disk.forget(index);
1644
- this.#spilledAt.delete(index);
1645
- }
1646
-
1647
- put(index, bytes, callback = () => undefined) {
1648
- if (this.#closed) {
1649
- queueMicrotask(() => callback(new Error("Piece store is closed.")));
1650
- return;
1651
- }
1652
-
1653
- const write = async () => {
1654
- // Already resident: the buffer is replaced, not added, so no slot is
1655
- // needed and none is claimed. A fresh buffer rather than a write into the
1656
- // old one, so a reader holding the old reference cannot see a torn write.
1657
- if (this.#buffers.has(index)) {
1658
- const previous = this.#buffers.get(index);
1659
- // A fresh block rather than a write into the old one, so a reader
1660
- // holding the old reference cannot see a torn write.
1661
- this.#buffers.set(index, this.#copyIntoNewBuffer(index, bytes));
1662
- this.#lru.touch(index);
1663
- // And the old block goes back for re-use only if nobody is reading it.
1664
- // A pinned piece has a view onto its memory somewhere; that block is
1665
- // given up instead, and the pool allocates another when it needs one.
1666
- if (this.#lru.isPinned(index)) {
1667
- if (this.#blocksAllocated > 0) {
1668
- this.#blocksAllocated -= 1;
1669
- this.#counters.blocksReleased += 1;
1670
- }
1671
- } else {
1672
- this.#returnBlock(previous);
1673
- }
1674
- await this.#forgetOnDisk(index);
1675
- this.#noteProgress();
1676
- return;
1677
- }
1678
-
1679
- this.#noteArrival();
1680
- const isDeclared = this.#lru.wants(index);
1681
- if (isDeclared) {
1682
- this.#counters.admittedInsideWindow += 1;
1683
- } else {
1684
- this.#counters.admittedOutsideWindow += 1;
1685
- }
1686
-
1687
- // A piece wanted LESS than the one it would displace goes straight to
1688
- // disk instead of being admitted.
1689
- //
1690
- // The same comparison eviction makes, in the same order, from the other
1691
- // end: the priority map's level first, and the distance to a read head
1692
- // only between pieces the map wants equally. Answering the two from
1693
- // different quantities would let the store admit a piece by one rule and
1694
- // evict it again by the other in the same second.
1695
- //
1696
- // Two cases, and the second was missing until 2026-09-02. The first: the
1697
- // arrival is in nobody's zone at all. The second: it IS in a zone, but
1698
- // one the map wants less than the zone holding the piece the store would
1699
- // have to evict for it. The field session had six readers whose windows
1700
- // covered the whole file, so the first case never applied and `0 of those
1701
- // went straight to disk` while the store spilled 233 pieces in a minute.
1702
- //
1703
- // Only when SOMETHING is stated: before that there is no basis for
1704
- // calling one piece more wanted than another.
1705
- const isWorseThanTheVictim = () => {
1706
- const victim = this.#lru.nextVictim();
1707
- if (victim.index === null) {
1708
- return false;
1709
- }
1710
- const arrivingWant = this.#lru.wantAt(index);
1711
- if (arrivingWant !== victim.want) {
1712
- return arrivingWant > victim.want;
1713
- }
1714
- const arriving = this.#lru.waitFor(index);
1715
- return arriving >= 0 && victim.wait >= 0 && arriving > victim.wait;
1716
- };
1717
- if (this.#lru.protectedCount > 0 && this.#isFullNow()
1718
- && (!isDeclared || isWorseThanTheVictim())) {
1719
- this.#counters.admittedToDisk += 1;
1720
- await this.#writeThrough(index, bytes);
1721
- this.#noteProgress();
1722
- return;
1723
- }
1724
-
1725
- // MEMORY IS NEVER A REASON TO REFUSE A PIECE. The piece is verified and
1726
- // the disk will take it; failing here fails the torrent client's own
1727
- // write, and the client answers that by destroying the torrent — field
1728
- // 2026-09-11, `WebTorrent client error: Every resident piece is pinned`,
1729
- // after which every read of that torrent answered `File 1 not found` for
1730
- // the life of the process and the viewer could not open anything.
1731
- //
1732
- // The branch above already writes through when the store is full and the
1733
- // arrival is wanted less than what it would displace. It cannot fire
1734
- // where nothing is declared (`protectedCount > 0`), which is exactly the
1735
- // state a store between readers is in — and that state is where the
1736
- // refusal happened.
1737
- const release = await this.#claimSlotOrNull();
1738
- if (release === null) {
1739
- this.#counters.admittedToDisk += 1;
1740
- await this.#writeThrough(index, bytes);
1741
- this.#noteProgress();
1742
- return;
1743
- }
1744
- try {
1745
- this.#registerPiece(index, this.#copyIntoNewBuffer(index, bytes));
1746
- await this.#forgetOnDisk(index);
1747
- } finally {
1748
- release();
1749
- }
1750
- };
1751
-
1752
- write().then(() => callback(null), (error) => callback(error));
1753
- }
1754
-
1755
- get(index, options, callback) {
1756
- if (typeof options === "function") {
1757
- return this.get(index, undefined, options);
1758
- }
1759
- const done = callback ?? (() => undefined);
1760
- if (this.#closed) {
1761
- queueMicrotask(() => done(new Error("Piece store is closed.")));
1762
- return;
1763
- }
1764
-
1765
- const pieceLength = this.#lengthOf(index);
1766
- const offset = options?.offset ?? 0;
1767
- const length = options?.length ?? pieceLength - offset;
1768
-
1769
- const fetch = async () => {
1770
- const buffer = this.#buffers.get(index);
1771
- if (buffer !== undefined) {
1772
- this.#lru.touch(index);
1773
- this.#counters.fromMemory += 1;
1774
- return Buffer.from(Buffer.from(buffer, offset, length));
1775
- }
1776
-
1777
- // NOT revived into memory. This entry point is the torrent client's: it
1778
- // reads to answer a peer, and a peer asks for 16 KB while a piece here is
1779
- // megabytes. Reviving would take a whole block for bytes nobody is going
1780
- // to read again — and it is the ONE read that can be answered without a
1781
- // block at all, since the caller is handed its own copy either way.
1782
- //
1783
- // Field 2026-09-11, and this is the whole reason the store could not
1784
- // serve anybody: an upload capped at 512 KB/s produced 63 416 reads of
1785
- // which 78.4 % missed memory, 49 696 pieces revived whole, and every one
1786
- // of those revivals competed for a ceiling of three blocks. Claiming a
1787
- // block for an upload is also how this path reached `#claimSlot`, whose
1788
- // failure the torrent client turns into a destroyed torrent.
1789
- // A spill that is still being written owns the only copy there is, so
1790
- // wait for it exactly as `#revive` does before asking the disk.
1791
- const spill = this.#evicting.get(index);
1792
- if (spill) {
1793
- await spill.catch(() => undefined);
1794
- }
1795
- const resident = this.#buffers.get(index);
1796
- if (resident !== undefined) {
1797
- this.#lru.touch(index);
1798
- this.#counters.fromMemory += 1;
1799
- return Buffer.from(Buffer.from(resident, offset, length));
1800
- }
1801
- if (this.#disk.has(index)) {
1802
- const want = Math.max(0, Math.min(length, this.#lengthOf(index) - offset));
1803
- const target = Buffer.allocUnsafe(want);
1804
- await this.#disk.read(index, target, offset);
1805
- this.#counters.fromDisk += 1;
1806
- return target;
1807
- }
1808
- const whole = await this.#fromWholeFiles(index);
1809
- if (whole) {
1810
- this.#counters.fromWholeFile += 1;
1811
- return Buffer.from(whole.subarray(offset, offset + length));
1812
- }
1813
- throw new Error(`Piece ${index} is not in the store.`);
1814
- };
1815
-
1816
- fetch().then((bytes) => done(null, bytes), (error) => done(error));
1817
- }
1818
-
1819
- /**
1820
- * State a range of pieces and how much they are wanted. The number is the
1821
- * priority map's own, and it is what eviction compares — see
1822
- * {@link PieceLru#protect}.
1823
- *
1824
- * @param {string|number} readerId
1825
- * @param {number} from
1826
- * @param {number} to
1827
- * @param {number} [urgency]
1828
- * @returns {void}
1829
- */
1830
- protectRange(readerId, from, to, urgency) {
1831
- this.#lru.protect(readerId, from, to, urgency);
1832
- // A READER DECLARES ITSELF IN A MOMENT; the allowance was re-derived once a
1833
- // minute. Between the two a read met whatever the store had shrunk to while
1834
- // nobody was reading, and a claim gives up after five seconds — twelve
1835
- // times sooner than the store could have grown (field 2026-09-11).
1836
- //
1837
- // DELIBERATELY NOT raising the ceiling here. A reader declaring a window
1838
- // wider than the store may hold is a shortage to absorb, not an instruction
1839
- // to take more memory: with several viewers on several films the unions add
1840
- // up across stores, and each store's floor already wins over the machine's
1841
- // share at the next revision. What the shortage must not do is fail a read,
1842
- // and that is answered where it arises — no path in this store refuses for
1843
- // want of memory any more.
1844
- }
1845
-
1846
- protectedRanges() {
1847
- return this.#lru.protectedRanges();
1848
- }
1849
-
1850
- releaseProtection(readerId) {
1851
- this.#lru.unprotect(readerId);
1852
- }
1853
-
1854
- warmRange(from, to, limit = Math.max(1, Math.floor(this.#growthCeiling / 4))) {
1855
- if (this.#closed || !Number.isInteger(from) || !Number.isInteger(to)) {
1856
- return 0;
1857
- }
1858
- let started = 0;
1859
- for (let index = from; index <= to && started < limit; index += 1) {
1860
- if (this.#buffers.has(index) || !this.#disk.has(index)) {
1861
- continue;
1862
- }
1863
- started += 1;
1864
- void this.reside(index).catch(() => undefined);
1865
- }
1866
- return started;
1867
- }
1868
-
1869
- async reside(index) {
1870
- if (this.#closed) {
1871
- throw new Error("Piece store is closed.");
1872
- }
1873
-
1874
- const buffer = this.#buffers.get(index);
1875
- if (buffer !== undefined) {
1876
- this.#lru.touch(index);
1877
- this.#counters.fromMemory += 1;
1878
- return this.locate(index);
1879
- }
1880
-
1881
- let revival = this.#revivals.get(index);
1882
- if (!revival) {
1883
- revival = this.#revive(index).finally(() => this.#revivals.delete(index));
1884
- this.#revivals.set(index, revival);
1885
- }
1886
- const revived = await revival;
1887
- if (revived === null) {
1888
- return null;
1889
- }
1890
- return { buffer: revived, offset: 0, length: this.#lengthOf(index) };
1891
- }
1892
-
1893
- close(callback = () => undefined) {
1894
- this.#closed = true;
1895
- liveStores.delete(this);
1896
- this.#buffers.clear();
1897
- this.#spilledAt.clear();
1898
- this.#counters.blocksReleased += this.#blocksAllocated;
1899
- this.#blocksAllocated = 0;
1900
- this.#freeBlocks = [];
1901
- // Whoever is waiting for a slot is woken and finds the store closed, which
1902
- // is an error they can report. Left asleep they simply never returned.
1903
- this.#wake();
1904
- this.#disk.close().then(() => callback(null), (error) => callback(error));
1905
- }
1906
-
1907
- destroy(callback = () => undefined) {
1908
- this.#closed = true;
1909
- liveStores.delete(this);
1910
- this.#buffers.clear();
1911
- this.#spilledAt.clear();
1912
- this.#counters.blocksReleased += this.#blocksAllocated;
1913
- this.#blocksAllocated = 0;
1914
- this.#freeBlocks = [];
1915
- this.#wake();
1916
- this.#disk.destroy().then(() => callback(null), (error) => callback(error));
1917
- }
1918
- }
1919
-
1920
- /**
1921
- * What the stores in this thread hold and would take, for the owner of memory.
1922
- *
1923
- * @returns {{ held: number, wanted: number }}
1924
- */
1925
- export function memoryClaim() {
1926
- const stores = [...liveStores];
1927
- return {
1928
- held: stores.reduce((sum, store) => sum + store.residentBytes, 0),
1929
- wanted: stores.reduce((sum, store) => sum + store.wantedBytes, 0)
1930
- };
1931
- }
1
+ /**
2
+ * @file Torrent pieces in shared memory, with disk as the second tier.
3
+ *
4
+ * Replaces the chunk store WebTorrent would otherwise build for itself. Two
5
+ * reasons, one forced and one chosen.
6
+ *
7
+ * **Forced.** WebTorrent's own piece cache hands out the buffer it keeps using
8
+ * and slices again on the next read. The torrent client now runs on its own
9
+ * thread, and moving a piece to the main thread by transferring ownership
10
+ * detached the cache's memory: proxy 2.9.71-2.9.73 answered every read with an
11
+ * empty body (`Stream ends prematurely at 0`) and the failure was invisible,
12
+ * because the error never reached the reader. Owning the memory ourselves
13
+ * removes the question of whose it was.
14
+ *
15
+ * **Chosen.** Memory this side of the thread boundary can be *shared* memory,
16
+ * which the main thread reads without receiving bytes as a copy — see
17
+ * {@link SharedPieceStore#locate}. Measured on the field host: a piece copy
18
+ * costs 3.64 ms, a read from the page cache into a buffer we own 7.63 ms, and
19
+ * re-downloading a piece from the swarm ~1430 ms. So memory first, disk under
20
+ * it, and never the swarm twice.
21
+ *
22
+ * Per piece allocation: each resident piece owns its own `SharedArrayBuffer`.
23
+ * Evicting a piece deletes its entry and the memory is reclaimable by GC.
24
+ * `committed` therefore equals `resident`, not a high-water mark.
25
+ *
26
+ * **Room for a piece is an owned reservation, not a shared number.**
27
+ * `#claimSlot` hands back a release the caller runs in a `finally`; nothing
28
+ * else touches `#outstandingPieces`. It was a counter incremented in one
29
+ * function and decremented in another, and every consequence of that was a
30
+ * defect: a failure between the two lost a slot for the life of the process,
31
+ * `put`'s error path guessed at the correction and could take back a
32
+ * reservation belonging to a different claim, and one lost reservation made
33
+ * the five-second "everything is pinned" error permanently unreachable, so a
34
+ * read retried every 50 ms for ever without completing or failing. Read out of
35
+ * the field failure of 2026-08-31 (`research/worker-heap-oom-2026-08-31.md`).
36
+ *
37
+ * What this deliberately does NOT do is manage the disk as a cache of its own.
38
+ * Pieces evicted from memory are written once and read back on demand; the file
39
+ * is discarded whole when the torrent goes away. libtorrent 2.0 and webtor's
40
+ * seeder both concluded that a hand-rolled disk cache earns less than it costs,
41
+ * and nothing here disagrees.
42
+ */
43
+
44
+ import { divideAllowance, machineAllowanceBytes, OtherDemand } from "../allowance.js";
45
+ import { availableMemoryBytes as availableMemorySync } from "../machine-memory.js";
46
+ import { PieceLru } from "./piece-lru.js";
47
+ import { PieceDiskStore } from "./piece-disk-store.js";
48
+
49
+ /**
50
+ * Live stores, so the worker can report on them.
51
+ *
52
+ * @type {Set<SharedPieceStore>}
53
+ */
54
+ const liveStores = new Set();
55
+
56
+ export function collectStoreStats() {
57
+ return [...liveStores].map((store) => store.stats());
58
+ }
59
+
60
+ export function findSharedStore(torrent) {
61
+ let candidate = torrent?.store;
62
+ for (let depth = 0; candidate && depth < 8; depth += 1) {
63
+ if (candidate instanceof SharedPieceStore) {
64
+ return candidate;
65
+ }
66
+ candidate = candidate.store;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * The largest fall in the machine's available memory that this process has
73
+ * seen and did not cause itself.
74
+ *
75
+ * What has to be left for everything else on the machine. It cannot be a share
76
+ * of what is free — a share is a number chosen out of nothing, which is what
77
+ * `AVAILABLE_MEMORY_SHARE = 0.25`, `MIN_BUDGET_BYTES` and
78
+ * `MEMORY_BUDGET_CEILING_BYTES` were until 2026-09-02, all three traceable to
79
+ * one observation of one host on 2026-08-03. It is measured instead: between
80
+ * two readings, how much available memory went away beyond what the stores
81
+ * themselves took. On the field host that quantity is large — while the proxy
82
+ * held 76-133 MB overnight the machine's available memory fell from 2378 MB to
83
+ * 306 MB and came back — and on a quiet host it stays near zero, which is the
84
+ * right answer there.
85
+ *
86
+ * Starts at zero: nothing is reserved until somebody else has been seen to
87
+ * need it.
88
+ */
89
+ const otherDemand = new OtherDemand();
90
+
91
+ /**
92
+ * Note what the machine had, and how much of the change was not ours.
93
+ *
94
+ * @param {number} availableBytes
95
+ * @param {number} storeBytes - What the live stores hold right now.
96
+ * @returns {number} The reserve, in bytes.
97
+ */
98
+ export function noteMachineMemory(availableBytes, storeBytes) {
99
+ return otherDemand.note(availableBytes, storeBytes);
100
+ }
101
+
102
+ /** What has recently been observed to be needed by everything that is not us. */
103
+ export function machineReserveBytes() {
104
+ return otherDemand.reserve();
105
+ }
106
+
107
+ /** Forget what other processes have needed. For tests, which share a module. */
108
+ export function forgetMachineMemory() {
109
+ otherDemand.forget();
110
+ }
111
+
112
+ export { divideAllowance, machineAllowanceBytes };
113
+
114
+ export function reviseStoreBudgets(allowanceBytes = null) {
115
+ const stores = [...liveStores];
116
+ const held = stores.reduce((sum, store) => sum + store.residentBytes, 0);
117
+ // TOLD, NOT WORKED OUT. Memory has one owner and it is on the main thread,
118
+ // where the disk's owner is — and it must be the same one, because the two
119
+ // resources trade: pieces that do not fit in memory are spilled to disk, so
120
+ // what this store is given decides how much disk it needs. What arrives is
121
+ // this thread's whole share; the stores divide it between themselves.
122
+ //
123
+ // Null is a thread that has not been told yet, and it works its own share out
124
+ // for that one pass rather than growing without a bound.
125
+ const allowance = Number.isFinite(allowanceBytes) && allowanceBytes !== null
126
+ ? Math.max(0, allowanceBytes)
127
+ : machineAllowanceBytes(availableMemorySync(), held, noteMachineMemory(availableMemorySync(), held));
128
+ const shares = divideAllowance(stores.map((store) => store.wantedBytes), allowance);
129
+ const revised = [];
130
+ for (const [position, store] of stores.entries()) {
131
+ revised.push(store.reviseGrowthCeiling(shares[position]));
132
+ }
133
+ return revised;
134
+ }
135
+
136
+ /**
137
+ * Say how much disk the spilled pieces may take between them.
138
+ *
139
+ * Told rather than worked out. The disk is one and this store is not its only
140
+ * user — the segments an encoder produces are on it too — so a ceiling one of
141
+ * two users sets for itself is not a ceiling. Until 2026-09-10 the spill had no
142
+ * ceiling of any kind: 14 400 MB written in one fifty-minute viewing.
143
+ *
144
+ * @param {number | null} allowanceBytes - This thread's whole share, from the
145
+ * owner of the disk on the main thread. Null means nobody has said yet, and
146
+ * nothing is thrown away until somebody does.
147
+ * @param {SharedPieceStore[]} [live] - The stores to divide between.
148
+ * @returns {{ name: string, allowanceBytes: number | null, bytes: number }[]}
149
+ */
150
+ export function reviseSpillBudgets(allowanceBytes, live = [...liveStores]) {
151
+ if (live.length === 0) {
152
+ return [];
153
+ }
154
+ // NOT READ HERE. The disk has one owner and it is on the main thread, where
155
+ // the segments an encoder produces live on the same disk. Reading it here as
156
+ // well is exactly the fault this replaced: two ceilings, each standing for the
157
+ // whole disk. What arrives is this thread's whole share.
158
+ const total = Number.isFinite(allowanceBytes) && allowanceBytes >= 0 ? allowanceBytes : null;
159
+ if (total === null) {
160
+ return live.map((store) => store.reviseSpillCeiling(null));
161
+ }
162
+ // EQUALLY, unlike memory. A store's memory ask is its readers' declared
163
+ // windows; the spill has no such statement, because whatever memory evicts
164
+ // arrives here. Every store's ask is "all of it", nothing distinguishes them,
165
+ // and an equal share is what that means.
166
+ const share = Math.floor(total / live.length);
167
+ return live.map((store) => store.reviseSpillCeiling(share));
168
+ }
169
+
170
+ function defaultMemoryBytes() {
171
+ const stores = [...liveStores];
172
+ const held = stores.reduce((sum, store) => sum + store.residentBytes, 0);
173
+ const available = availableMemorySync();
174
+ const allowance = machineAllowanceBytes(available, held, machineReserveBytes());
175
+ // A store being created has no readers, so it has no demand to state and no
176
+ // basis for asking for more or less than the others. It opens on an equal
177
+ // share and the first revision — within a minute, and within seconds of a
178
+ // read starting — replaces that with what its readers actually declare.
179
+ // Deliberately not the whole allowance: on a machine with gigabytes free that
180
+ // would let a torrent nobody is reading yet fill memory before the first
181
+ // revision arrives.
182
+ return Math.floor(allowance / (stores.length + 1));
183
+ }
184
+
185
+ /**
186
+ * How many blocks of piece memory this thread has let go of, and how many the
187
+ * collector has actually taken back.
188
+ *
189
+ * Since the store keeps a pool, one block serves many pieces, so this counts
190
+ * blocks and not pieces — and a healthy store allocates only as many as its
191
+ * allowance permits, so both numbers are now small and nearly equal.
192
+ *
193
+ * The one reading that separates the two explanations of the 700 MB nobody can
194
+ * account for (roadmap item 2, field 2026-08-31): if the two numbers track each
195
+ * other, this code is holding nothing and whatever grows is below us, in the
196
+ * allocator — on musl there is no `malloc_trim` and no way to ask. If the gap
197
+ * widens, a reference of ours outlives the piece, and then a heap snapshot can
198
+ * name the holder.
199
+ *
200
+ * What it does NOT prove: a `SharedArrayBuffer`'s memory is shared between the
201
+ * isolates, so this thread's handle going means only that THIS thread let go.
202
+ * The main thread counts its own (`torrent/worker/client.js`), and the pair is
203
+ * what answers the question.
204
+ */
205
+ const released = { count: 0, collected: 0 };
206
+ const collector = typeof FinalizationRegistry === "function"
207
+ ? new FinalizationRegistry(() => {
208
+ released.collected += 1;
209
+ })
210
+ : null;
211
+
212
+ /**
213
+ * What the collector has taken back against what was let go.
214
+ *
215
+ * @returns {{ released: number, collected: number }}
216
+ */
217
+ export function pieceBufferCollection() {
218
+ return { released: released.count, collected: released.collected };
219
+ }
220
+
221
+ const MIN_RESIDENT_PIECES = 2;
222
+ /**
223
+ * How long a claim may go without ANYTHING moving before it gives up.
224
+ *
225
+ * Measured against progress, not against activity. The earlier rule skipped
226
+ * this timer entirely while a spill was in flight or a reservation was held —
227
+ * so one reservation that was never returned made the timer unreachable and a
228
+ * read retried every 50 ms for the life of the process, never completing and
229
+ * never failing (field 2026-08-31).
230
+ */
231
+ const PINNED_WAIT_MS = 5_000;
232
+
233
+ /**
234
+ * How many revival ages are kept for the median. A window, not a history: two
235
+ * hundred covers several minutes of the busiest session measured (7575
236
+ * revivals in 44 minutes) and costs two hundred numbers.
237
+ */
238
+ const REVIVAL_AGE_SAMPLES = 200;
239
+
240
+ /**
241
+ * How many block re-use gaps are kept. The same window as the revival ages, and
242
+ * for the same reason: what is wanted is the rhythm of recent work, not a
243
+ * history of it.
244
+ */
245
+ const REUSE_GAP_SAMPLES = 200;
246
+
247
+ /** How many write durations are kept for the median. */
248
+ const WRITE_DURATION_SAMPLES = 50;
249
+ /** How many admission times are kept, and how far back they are counted. */
250
+ const ARRIVAL_SAMPLES = 100;
251
+ const ARRIVAL_WINDOW_MS = 10_000;
252
+
253
+ /**
254
+ * The middle value of a sample, or null when there is nothing to take a middle
255
+ * of. Null rather than zero: no revivals and instant revivals are different
256
+ * facts and must not print the same.
257
+ *
258
+ * @param {number[]} values
259
+ * @returns {number | null}
260
+ */
261
+ function median(values) {
262
+ if (values.length === 0) {
263
+ return null;
264
+ }
265
+ const sorted = [...values].sort((left, right) => left - right);
266
+ const middle = Math.floor(sorted.length / 2);
267
+ return sorted.length % 2 === 0
268
+ ? Math.round((sorted[middle - 1] + sorted[middle]) / 2)
269
+ : sorted[middle];
270
+ }
271
+ const CLAIM_RETRY_MS = 50;
272
+
273
+ export class SharedPieceStore {
274
+ #chunkLength;
275
+ #lastChunkLength;
276
+ #lastChunkIndex;
277
+ #growthCeiling;
278
+ /** Piece index → SharedArrayBuffer of that piece */
279
+ #buffers = new Map();
280
+ /** @type {Map<number, Promise<void>>} */
281
+ #evicting = new Map();
282
+ /**
283
+ * Slots claimed but not yet filled.
284
+ *
285
+ * Handed out by {@link SharedPieceStore##claimSlot} as a release function the
286
+ * caller must call in a `finally`, never as a number one function increments
287
+ * and another decrements. The counter used to be paired across
288
+ * `#claimSlot`/`#registerPiece`, so any failure between the two lost a slot
289
+ * for the life of the process, and `put`'s error path tried to correct that
290
+ * by guessing — which could take back a reservation belonging to a different
291
+ * claim and let the store admit past its allowance.
292
+ */
293
+ #outstandingPieces = 0;
294
+ /** When something last actually moved: a piece admitted, spilled or unpinned. */
295
+ #lastProgressAt = 0;
296
+ #waiters = [];
297
+ #lru;
298
+ #disk;
299
+ #closed = false;
300
+ #name;
301
+ #counters = {
302
+ fromMemory: 0,
303
+ fromDisk: 0,
304
+ // Reads answered from the file this torrent has been assembled into. Not a
305
+ // miss and not a spill: the bytes were never lost, they are simply kept as
306
+ // a file now.
307
+ fromWholeFile: 0,
308
+ spills: 0,
309
+ revivals: 0,
310
+ blockedByPins: 0,
311
+ waitedForPins: 0,
312
+ // Pieces that went to disk because no block could be had for them. Not a
313
+ // failure — the piece is kept and a later read revives it — but it is the
314
+ // measure of how often the store is asked to hold more than it may, and
315
+ // before 2026-09-11 this case ended the torrent instead of being counted.
316
+ admittedWithoutSlot: 0,
317
+ evictedOnRevise: 0,
318
+ spillFailures: 0,
319
+ // Claims withdrawn: pieces this store stopped being able to produce at all,
320
+ // and said so. It is the figure that says whether the bargain the eviction
321
+ // states — "a seek back re-downloads it" — is being honoured, and before
322
+ // 2026-09-12 it was not honoured once, because nothing was ever said.
323
+ withdrawn: 0,
324
+ // Whether the store is doing its job or being asked to hold more than it
325
+ // has room for. An eviction that had to take a piece a reader declared it
326
+ // wants is the second, and it comes back from disk moments later
327
+ // (roadmap item 9).
328
+ evictedProtected: 0,
329
+ evictedDistanceSum: 0,
330
+ evictedWithDistance: 0,
331
+ // Where an arriving piece went. A piece nobody has declared is being
332
+ // downloaded ahead of every reader; putting it in memory pushes out one
333
+ // that IS declared, which is then read back from disk moments later
334
+ // (roadmap item 9).
335
+ admittedInsideWindow: 0,
336
+ admittedOutsideWindow: 0,
337
+ admittedToDisk: 0,
338
+ /** Blocks given back to the operating system. */
339
+ blocksReleased: 0,
340
+ /**
341
+ * A block put back for re-use while a reader still held the piece. Zero by
342
+ * construction — a pinned piece is never evicted, and a re-put of a pinned
343
+ * one drops its block instead of recycling it. Counted because if it ever
344
+ * stops being zero, a consumer is reading another piece's bytes, and that
345
+ * is invisible from anywhere else.
346
+ */
347
+ returnedWhilePinned: 0,
348
+ /** Spills that found the disk already holding identical bytes. */
349
+ spillsSkipped: 0,
350
+ /**
351
+ * Blocks a second registration of the same piece displaced. Expected to be
352
+ * small and non-zero: two callers racing for one piece is ordinary. What is
353
+ * NOT ordinary is the block going missing when it happens, which is what
354
+ * killed the process on 2026-09-02.
355
+ */
356
+ blocksDisplaced: 0,
357
+ /** Admissions that waited for the disk instead of evicting another piece. */
358
+ waitedForDisk: 0,
359
+ /**
360
+ * Admissions that grew memory because the disk had stopped answering.
361
+ * Non-zero means the store exceeded its allowance on purpose, and by how
362
+ * many pieces.
363
+ */
364
+ grewWaitingForDisk: 0,
365
+ /**
366
+ * Times a block was wanted, none was free, and the pool was already at its
367
+ * ceiling. Zero by construction — a block is only taken after a slot has
368
+ * been claimed, and slots are what the ceiling counts — so a number here
369
+ * means the two have come apart and the pool is growing past its allowance.
370
+ */
371
+ blocksBeyondCeiling: 0
372
+ };
373
+ /**
374
+ * Blocks that hold no piece, most recently freed last.
375
+ *
376
+ * A block is one piece's worth of memory. Allocating a new one for every
377
+ * piece meant 7575 allocations of 4 MiB in 44 minutes on 2026-09-02, each
378
+ * released only when the collector got to it — which is why the process held
379
+ * 1.86 GB while its own accounting said 352 MB. Blocks are taken from here
380
+ * and put back here instead (roadmap item 2).
381
+ *
382
+ * @type {{ buffer: SharedArrayBuffer, freedAt: number }[]}
383
+ */
384
+ #freeBlocks = [];
385
+ /** Blocks that exist at all: free plus holding a piece. */
386
+ #blocksAllocated = 0;
387
+ /**
388
+ * The widest window any reader has declared here, in pieces.
389
+ *
390
+ * The floor this store asks for between readers: one window is what the next
391
+ * read will ask for, and it is a measurement rather than a chosen number.
392
+ * See `wantedBytes`.
393
+ */
394
+ #widestSeenPieces = 0;
395
+ /** The torrent's files in order, passed through to whoever can read them. @type {object[]} */
396
+ #files = [];
397
+
398
+ /** What the whole torrent weighs, so a last piece is not read past its end. */
399
+ #torrentLength = 0;
400
+
401
+ /**
402
+ * How to read a piece neither tier has, or null when there is nowhere else.
403
+ *
404
+ * @type {((where: object) => Promise<Uint8Array | null>) | null}
405
+ */
406
+ #readElsewhere = null;
407
+
408
+ /** Reads waiting for a reservation, in arrival order. */
409
+ #readerClaims = [];
410
+
411
+ /** One revival per piece, shared by concurrent readers. */
412
+ #revivals = new Map();
413
+
414
+ /**
415
+ * Whether a piece can be had elsewhere, asked without reading it — so a
416
+ * spilled copy that has become a duplicate can be dropped.
417
+ *
418
+ * @type {((where: object) => boolean) | null}
419
+ */
420
+ #isElsewhere = null;
421
+
422
+ /**
423
+ * Said when this store can no longer produce a piece AT ALL — not resident,
424
+ * not on disk, and not inside a file held whole.
425
+ *
426
+ * **Why it has to exist.** This store holds the bytes, so it owns the fact
427
+ * "this proxy has piece N". Something else kept a second copy of that fact —
428
+ * the torrent's own completion bitfield — and nothing reconciled them: the
429
+ * disk tier drops a piece behind every read head, correctly, and the bitfield
430
+ * goes on saying the piece is verified. A read then concludes the piece is
431
+ * had, asks for it, is told it is absent, and fails; and it is never
432
+ * re-downloaded either, because the library does not fetch what it believes
433
+ * it already owns. Field 2026-09-12: a film played 80 seconds, the encoder
434
+ * ran on to 725 s, `forgetBehind` dropped some 565 spilled pieces including
435
+ * piece 0, the encoder restarted and re-opened its input at byte 0, and
436
+ * `/stream` answered `0 of 2363497962 bytes: Piece 0 is verified but absent
437
+ * from the store` for 92 minutes while the browser retried one segment.
438
+ *
439
+ * So the bitfield becomes a projection of this fact, maintained by this
440
+ * announcement. This store does not know what a torrent is, who listens, or
441
+ * what they do about it — the same shape as `readPieceElsewhere` above.
442
+ *
443
+ * @type {((what: object) => void) | null}
444
+ */
445
+ #onPieceGone = null;
446
+
447
+ /** Whether the last revision had to exceed the machine's share. */
448
+ #beyondTheMachine = false;
449
+ /**
450
+ * How long a block sat free before it was taken again, in milliseconds.
451
+ * Bounded, because what is wanted is the longest gap of RECENT work: an
452
+ * all-time maximum would be raised by one long pause and then never let a
453
+ * block go again.
454
+ *
455
+ * @type {number[]}
456
+ */
457
+ #reuseGaps = [];
458
+ /**
459
+ * How long recent writes to disk took, in milliseconds, and when pieces were
460
+ * admitted. Together they say how much room the store needs beyond what the
461
+ * readers ask for: while one write is finishing, more pieces arrive, and each
462
+ * needs somewhere to go. Without that room every arrival evicts something,
463
+ * which is what produced 233 evictions against 119 completed writes in a
464
+ * minute on 2026-09-02.
465
+ *
466
+ * @type {number[]}
467
+ */
468
+ #writeDurations = [];
469
+ /** When the last few pieces were admitted, for the arrival rate. */
470
+ #admittedAt = [];
471
+ /** Piece index → when it was written out, for the age it comes back at. */
472
+ #spilledAt = new Map();
473
+ /**
474
+ * Ages, in milliseconds, of the last revivals — bounded, because the figure
475
+ * wanted is a median and not a history. A piece that comes back seconds
476
+ * after it left should not have left.
477
+ *
478
+ * @type {number[]}
479
+ */
480
+ #revivalAges = [];
481
+
482
+ constructor(chunkLength, options = {}) {
483
+ if (!Number.isInteger(chunkLength) || chunkLength < 1) {
484
+ throw new Error(`Chunk length must be a positive integer, got ${chunkLength}.`);
485
+ }
486
+ this.#chunkLength = chunkLength;
487
+
488
+ const totalLength = Number.isFinite(options.length) ? options.length : 0;
489
+ this.#lastChunkIndex = totalLength > 0 ? Math.ceil(totalLength / chunkLength) - 1 : -1;
490
+ const remainder = totalLength % chunkLength;
491
+ this.#lastChunkLength = remainder === 0 ? chunkLength : remainder;
492
+
493
+ const memoryBytes = Number.isFinite(options.memoryBytes) && options.memoryBytes > 0
494
+ ? options.memoryBytes
495
+ : defaultMemoryBytes();
496
+ // Only where this store STARTS. It is not kept, so it cannot come back as a
497
+ // cap on the revision the way `#capacity` did.
498
+ const openingCeiling = Math.max(MIN_RESIDENT_PIECES, Math.floor(memoryBytes / chunkLength));
499
+ this.#growthCeiling = openingCeiling;
500
+ this.#lru = new PieceLru(openingCeiling);
501
+ this.#name = options.name ?? "pieces";
502
+ // THE LAST PLACE A PIECE CAN COME FROM, handed in as two plain functions.
503
+ // Where those bytes are, and what a file is, is not this store's business:
504
+ // it holds pieces, and these say whether one can be had elsewhere and how.
505
+ this.#readElsewhere = typeof options.readPieceElsewhere === "function"
506
+ ? options.readPieceElsewhere
507
+ : null;
508
+ this.#isElsewhere = typeof options.isPieceElsewhere === "function"
509
+ ? options.isPieceElsewhere
510
+ : null;
511
+ this.#onPieceGone = typeof options.onPieceGone === "function"
512
+ ? options.onPieceGone
513
+ : null;
514
+ // Plain data the layer above needs to answer those two, and which this
515
+ // store is handed anyway: where each file of the torrent begins and how
516
+ // long the whole of it is.
517
+ this.#files = Array.isArray(options.files) ? options.files : [];
518
+ this.#torrentLength = totalLength;
519
+ // `options.disk` exists so a test can hold a write open or make one fail on
520
+ // purpose. Four of the defects fixed here live in what happens when the
521
+ // disk tier does not answer immediately or at all, and none of them is
522
+ // reachable from outside without saying so.
523
+ this.#disk = options.disk ?? new PieceDiskStore({
524
+ directory: options.path ?? ".",
525
+ name: `${this.#name}.pieces`,
526
+ // What it may hold is settled by the same revision that settles memory,
527
+ // within a minute of the store existing. Until then it is unbounded, which
528
+ // is what it has always been — the difference is that it now stops.
529
+ allowanceBytes: null,
530
+ // Where the live readers stand, so what goes first is decided by them and
531
+ // not by which piece happened to be touched longest ago.
532
+ readHeads: () => this.#lru.readHeads(),
533
+ // EVERY WAY A PIECE LEAVES THE DISK COMES THROUGH HERE — behind the read
534
+ // heads, over the allowance, dropped as a duplicate, or forgotten while a
535
+ // spill finished. One listener instead of a list of call sites to
536
+ // remember, which is what let the announcement be missed at three of them.
537
+ onForgotten: (index) => this.#announceGoneIfNowhere(index)
538
+ });
539
+ liveStores.add(this);
540
+ }
541
+
542
+ stats() {
543
+ const resident = this.#buffers.size;
544
+ const residentBytes = resident * this.#chunkLength;
545
+ // Last piece may be short but stats historically use chunkLength.
546
+ return {
547
+ name: this.#name,
548
+ resident,
549
+ capacity: this.#growthCeiling,
550
+ residentBytes,
551
+ allocatedSlots: this.#blocksAllocated,
552
+ // What the process HOLDS, which with a pool is the blocks that exist —
553
+ // not the pieces in them. Holding and using are different quantities and
554
+ // the difference is the point of the reading.
555
+ committedBytes: this.#blocksAllocated * this.#chunkLength,
556
+ budgetBytes: this.#growthCeiling * this.#chunkLength,
557
+ pinned: this.#lru.pinnedCount,
558
+ // Slots claimed and not yet filled. Reported because a reservation that
559
+ // is never returned is invisible until the store cannot admit anything,
560
+ // and by then the reason is long gone. At rest this is zero.
561
+ outstanding: this.#outstandingPieces,
562
+ // How long since ANYTHING in this store moved — a write finishing, a
563
+ // piece admitted, a pin released. A claim gives up after
564
+ // `PINNED_WAIT_MS` of exactly this, so it is the figure that says whether
565
+ // a refusal was the store being busy or the store being stuck, and until
566
+ // 2026-09-11 nothing printed it: the field had 152 refusals in 104
567
+ // seconds and no way to tell which.
568
+ stillMs: this.#lastProgressAt > 0 ? Date.now() - this.#lastProgressAt : 0,
569
+ spilled: this.#disk.size,
570
+ // What the spill file ACTUALLY weighs, piece by piece, rather than the
571
+ // piece count times a full piece length. The last piece of a torrent is
572
+ // short, and until 2026-09-10 nothing here counted disk at all — the
573
+ // figure was a multiplication, and the thing it stood for had no ceiling.
574
+ spilledBytes: this.#disk.bytes,
575
+ spillAllowanceBytes: this.#disk.allowanceBytes,
576
+ spillEvictions: this.#disk.stats().evictions,
577
+ // What the readers between them are asking this store to keep, against
578
+ // what it may hold. A union wider than the capacity cannot be held
579
+ // however the eviction is ordered, and that is the difference between a
580
+ // policy to fix and arithmetic to accept (roadmap item 9).
581
+ demand: this.#lru.demand(),
582
+ // What the process actually holds for this store, which is not the same
583
+ // as what it is using: blocks are kept for re-use. The difference is the
584
+ // spare, and it is the whole of the question whether consumption only
585
+ // grows (roadmap item 2).
586
+ blocksAllocated: this.#blocksAllocated,
587
+ blocksFree: this.#freeBlocks.length,
588
+ blocksDisplaced: this.#counters.blocksDisplaced,
589
+ blocksInUse: this.#blocksInUse(),
590
+ blocksInFlight: this.#blocksInFlight(),
591
+ blocksBeyondCeiling: this.#counters.blocksBeyondCeiling,
592
+ blockBytes: this.#blocksAllocated * this.#chunkLength,
593
+ reuseGapMs: this.#reuseGapCeilingMs(),
594
+ revivalAgeMedianMs: median(this.#revivalAges),
595
+ revivalAgeSamples: this.#revivalAges.length,
596
+ revivedWithinFiveSeconds: this.#revivalAges.filter((age) => age <= 5_000).length,
597
+ ...this.#counters
598
+ };
599
+ }
600
+
601
+ /**
602
+ * Whether the machine's share is smaller than current read demand.
603
+ *
604
+ * The store holds the window anyway — refusing would leave the read it is
605
+ * serving unable to finish, which is worse — but it is the honest measure of
606
+ * "this machine cannot take any more", and it is measured rather than
607
+ * guessed: it is the last revision's comparison of the machine's share
608
+ * against declared windows and distinct pieces pinned by live reads.
609
+ */
610
+ get isBeyondTheMachine() {
611
+ return this.#beyondTheMachine;
612
+ }
613
+
614
+ /** What this store holds right now, in bytes. */
615
+ get residentBytes() {
616
+ return this.#buffers.size * this.#chunkLength;
617
+ }
618
+
619
+ /** What this store's spilled pieces weigh on disk. */
620
+ get spilledBytes() {
621
+ return this.#disk.bytes;
622
+ }
623
+
624
+ /**
625
+ * Say how much disk this store's spilled pieces may take.
626
+ *
627
+ * The counterpart of `reviseGrowthCeiling`, and settled by the same pass: the
628
+ * two tiers of one store are two readings of one machine.
629
+ *
630
+ * @param {number | null} allowedBytes
631
+ * @returns {{ name: string, allowanceBytes: number | null, bytes: number }}
632
+ */
633
+ reviseSpillCeiling(allowedBytes) {
634
+ // THE OTHER RULE, and it does not wait for the disk to be short. A piece
635
+ // behind every read head has been read and will not be read again unless
636
+ // somebody seeks back, and a seek back re-downloads it — the same bargain
637
+ // this tier makes whenever it drops a piece for room. Without it the spill
638
+ // is bounded only by a share of free space, which on a roomy host is tens of
639
+ // gigabytes against a measured growth of 14 400 MB in one viewing: the
640
+ // ceiling never binds and nothing is ever removed until the torrent goes.
641
+ const behind = this.#disk.forgetBehind(this.#lru.readHeads());
642
+ return {
643
+ name: this.#name,
644
+ allowanceBytes: this.#disk.reviseAllowance(allowedBytes),
645
+ bytes: this.#disk.bytes,
646
+ behind
647
+ };
648
+ }
649
+
650
+ /**
651
+ * What this store is asking to be allowed to hold, in bytes.
652
+ *
653
+ * The union of its readers' declared windows — what they have said they will
654
+ * need — never below the widest single window, because a store that cannot
655
+ * hold one reader's window cannot complete that reader's read at all: every
656
+ * resident piece ends up pinned, the read returns zero bytes and ffmpeg takes
657
+ * that for the end of the file (field 2026-08-15, roadmap item 9).
658
+ *
659
+ * A declared window is not the only evidence of demand: a live reader pins
660
+ * each piece it is waiting on or handing to its consumer. Include those
661
+ * distinct pins as well, because some byte reads do not have a priority-map
662
+ * range to declare.
663
+ */
664
+ get wantedBytes() {
665
+ const demand = this.#lru.demand();
666
+ const pinned = this.#lru.pinnedCount;
667
+ if (demand.readers > 0) {
668
+ this.#widestSeenPieces = Math.max(this.#widestSeenPieces, demand.widestPieces);
669
+ const pieces = Math.max(MIN_RESIDENT_PIECES, demand.unionPieces, demand.widestPieces, pinned);
670
+ // Plus room to absorb what arrives while a write is finishing. Asking for
671
+ // exactly what the readers want leaves no free place ever, so every
672
+ // arrival evicts one of them — measured 2026-09-02: `6 reader(s) want 23
673
+ // piece(s) of 23 the store may hold`, and 233 evictions in the minute
674
+ // that followed.
675
+ return (pieces + this.slackPieces()) * this.#chunkLength;
676
+ }
677
+ // WITH NO DECLARED RANGE, the floor is the widest prior window or the
678
+ // distinct pieces pinned by current reads, both measured rather than
679
+ // chosen. `MIN_RESIDENT_PIECES` applies until either has been observed.
680
+ //
681
+ // It used to fall to the minimum the moment the last reader went, and the
682
+ // allowance is re-derived once a minute (`STORE_REPORT_INTERVAL_MS`) while
683
+ // a claim gives up after five seconds. A reader arriving into that minute
684
+ // met a store of two or three blocks: field 2026-09-11, the viewer switched
685
+ // to another file of the same torrent and the store was at
686
+ // `0/3 (0MB of 12MB allowed)` with the machine offering 4.3 GB.
687
+ //
688
+ // Retaining the last declared window between reads avoids dropping back to
689
+ // the minimum; current pins cover reads that have no declared window. A
690
+ // store that has never been read keeps only the measured minimum.
691
+ const pieces = Math.max(MIN_RESIDENT_PIECES, this.#widestSeenPieces, pinned);
692
+ return (pieces + this.slackPieces()) * this.#chunkLength;
693
+ }
694
+
695
+ reviseGrowthCeiling(allowedBytes) {
696
+ // No cap at what the machine could spare when this store was CREATED.
697
+ // `#capacity` was computed once in the constructor and used as an upper
698
+ // bound here, so the allowance could only ever fall: a torrent opened while
699
+ // the machine was full kept a small allowance for its whole life, however
700
+ // much memory was freed afterwards (roadmap item 2, 2026-09-02).
701
+ const wanted = Math.floor(Number(allowedBytes) / this.#chunkLength);
702
+ // Never below what the live readers together need, even when the machine's
703
+ // share says less. That is the union of declared windows, or the distinct
704
+ // pieces pinned by direct reads that have no declared window. A store that
705
+ // cannot hold what its readers are pinning cannot complete those reads:
706
+ // every resident piece ends up pinned, a read returns zero bytes and
707
+ // ffmpeg takes that for the end of the file, which killed every encoder on
708
+ // that file in the field on
709
+ // 2026-08-15 (one reader) and again on 2026-09-07 (three readers of one
710
+ // file at once — picture, sound and the edge-warming read — where this
711
+ // floor was still computed from only the widest one of them, `wantedBytes`
712
+ // above already asks for the union and got it right; this is the same
713
+ // union, used as the floor instead of only as the ask). A live read can
714
+ // also pin pieces without a priority-map range, so those measured pins are
715
+ // part of the same minimum. Exceeding the share is the lesser failure, and
716
+ // the line below says when it happens.
717
+ const demand = this.#lru.demand();
718
+ const activeDemandPieces = Math.max(
719
+ MIN_RESIDENT_PIECES,
720
+ demand.readers > 0 ? demand.unionPieces : 0,
721
+ this.#lru.pinnedCount
722
+ );
723
+ this.#growthCeiling = Math.max(
724
+ activeDemandPieces,
725
+ Number.isFinite(wanted) ? wanted : MIN_RESIDENT_PIECES
726
+ );
727
+ const belowActiveDemand = Number.isFinite(wanted) && wanted < activeDemandPieces;
728
+ this.#beyondTheMachine = belowActiveDemand;
729
+ // The LRU is told too. It was constructed with the store's original
730
+ // capacity and never revised, so `isFull()` answered against a number that
731
+ // had not been the limit for some time — dormant only because nothing calls
732
+ // it, which is a trap for whoever calls it next.
733
+ this.#lru.setCapacity(this.#growthCeiling);
734
+ // With per-piece buffers memory CAN be given back immediately, unlike the
735
+ // old growable pool. Eagerly evict excess to honour the new ceiling.
736
+ let evicted = 0;
737
+ while (this.#buffers.size > this.#growthCeiling) {
738
+ const { index: victim, protectionYielded, distance } = this.#lru.evictionChoice();
739
+ if (victim === null) {
740
+ break;
741
+ }
742
+ const victimBuffer = this.#buffers.get(victim);
743
+ if (victimBuffer === undefined) {
744
+ this.#lru.remove(victim);
745
+ continue;
746
+ }
747
+ this.#buffers.delete(victim);
748
+ this.#lru.remove(victim);
749
+ evicted += 1;
750
+ this.#counters.evictedOnRevise += 1;
751
+ this.#noteEviction(protectionYielded, distance);
752
+ // Nobody awaits this spill, so its failure has to end here. Rethrowing
753
+ // made it an unhandled rejection, and an unhandled rejection in the
754
+ // torrent worker ends the thread — a second way to lose the torrent
755
+ // client, on top of the one that already loses it.
756
+ void this.#spill(victim, victimBuffer).catch(() => {
757
+ this.#counters.spillFailures += 1;
758
+ });
759
+ }
760
+ // Blocks the store is no longer using and has waited long enough to give
761
+ // up. The allowance falling is the moment to ask, because that is when the
762
+ // machine has been shown to need the memory.
763
+ const releasedBlocks = this.sweepFreeBlocks();
764
+ return {
765
+ name: this.#name,
766
+ ceilingBytes: this.#growthCeiling * this.#chunkLength,
767
+ committedBytes: this.#blocksAllocated * this.#chunkLength,
768
+ evicted,
769
+ releasedBlocks,
770
+ belowActiveDemand
771
+ };
772
+ }
773
+
774
+ get chunkLength() {
775
+ return this.#chunkLength;
776
+ }
777
+
778
+ get capacity() {
779
+ return this.#growthCeiling;
780
+ }
781
+
782
+ get residentCount() {
783
+ return this.#buffers.size;
784
+ }
785
+
786
+ get spilledCount() {
787
+ return this.#disk.size;
788
+ }
789
+
790
+ #lengthOf(index) {
791
+ return index === this.#lastChunkIndex ? this.#lastChunkLength : this.#chunkLength;
792
+ }
793
+
794
+ /**
795
+ * Where a resident piece sits, or `null` if not resident.
796
+ * Returns the piece's own SharedArrayBuffer and the intra-piece range.
797
+ * @param {number} index
798
+ * @returns {{ buffer: SharedArrayBuffer, offset: number, length: number } | null}
799
+ */
800
+ locate(index) {
801
+ const buffer = this.#buffers.get(index);
802
+ if (buffer === undefined) {
803
+ return null;
804
+ }
805
+ return { buffer, offset: 0, length: this.#lengthOf(index) };
806
+ }
807
+
808
+ /**
809
+ * Direct access to a piece's buffer for zero-copy consumers.
810
+ * @param {number} index
811
+ * @returns {SharedArrayBuffer | undefined}
812
+ */
813
+ getPieceBuffer(index) {
814
+ return this.#buffers.get(index);
815
+ }
816
+
817
+ /** Availability belongs to the store, not to a second cached bitfield. */
818
+ locationOf(index) {
819
+ return this.#buffers.has(index) ? "memory" : this.#evicting.has(index) ? "writing" :
820
+ this.#disk.has(index) ? "disk" : this.isInWholeFiles(index) ? "whole-file" : "missing";
821
+ }
822
+
823
+ pin(index) {
824
+ this.#lru.pin(index);
825
+ // A piece reader pins before it waits for the bytes to arrive or return
826
+ // from disk. That is the only live record of some reads; the priority map
827
+ // can have no declared range for them (for example, a verified piece that
828
+ // was spilled). Include the measured set of distinct active pins in the
829
+ // immediate floor so a claim does not wait for the next budget pass to get
830
+ // a slot. `wantedBytes` carries the same demand to the machine-wide owner.
831
+ const pinned = this.#lru.pinnedCount;
832
+ if (pinned > this.#growthCeiling) {
833
+ this.#growthCeiling = pinned;
834
+ this.#lru.setCapacity(pinned);
835
+ this.#beyondTheMachine = true;
836
+ this.#wake();
837
+ }
838
+ }
839
+
840
+ unpin(index) {
841
+ this.#lru.unpin(index);
842
+ this.#noteProgress();
843
+ }
844
+
845
+ /**
846
+ * Reserve room for one piece.
847
+ *
848
+ * @returns {Promise<() => void>} The release, which the caller MUST call in a
849
+ * `finally`. Calling it twice is harmless.
850
+ */
851
+ /**
852
+ * The same reservation, answering null instead of throwing.
853
+ *
854
+ * For callers that have somewhere else to go: an arriving piece has the disk,
855
+ * so it writes through rather than failing. Keeping the throwing form for
856
+ * callers that genuinely have no alternative is what makes the difference
857
+ * visible at the call site rather than in a catch.
858
+ *
859
+ * @returns {Promise<(() => void) | null>}
860
+ */
861
+ async #claimSlotOrNull() {
862
+ try {
863
+ return await this.#claimSlot();
864
+ } catch (error) {
865
+ if (this.#closed) {
866
+ throw error;
867
+ }
868
+ this.#counters.admittedWithoutSlot += 1;
869
+ return null;
870
+ }
871
+ }
872
+
873
+ async #claimSlot(forReader = false) {
874
+ // How long THIS claim has been trying, kept here and not on the store.
875
+ // It was a field, `#pinnedWaitStartedAt`, shared by the two waits inside
876
+ // `#claimSlotOnce` — the one for the disk and the one for a piece that may
877
+ // be evicted — and each of them zeroed it on giving up, which restarted the
878
+ // other's clock. Measured 2026-09-03: the claim cycled for ever, five
879
+ // seconds per side, `grewWaitingForDisk` and `waitedForPins` both climbing
880
+ // once every five seconds while `blockedByPins` stayed at 0 — the refusal
881
+ // was unreachable. A claim's patience belongs to the claim; with several
882
+ // claimants a shared field is wrong anyway, since one caller giving up
883
+ // would reset the wait of every other.
884
+ const waitingSince = Date.now();
885
+ const claimant = forReader ? {} : null;
886
+ if (claimant) this.#readerClaims.push(claimant);
887
+ try {
888
+ for (;;) {
889
+ if (this.#closed) {
890
+ throw new Error("Piece store is closed.");
891
+ }
892
+ // New arrivals must not take every released block ahead of an encoder
893
+ // already waiting to read. Readers share one ordered reservation queue.
894
+ const hasTurn = this.#readerClaims.length === 0 || this.#readerClaims[0] === claimant;
895
+ const isReserved = hasTurn && await this.#claimSlotOnce(waitingSince);
896
+ if (isReserved) {
897
+ let released = false;
898
+ return () => {
899
+ if (released) {
900
+ return;
901
+ }
902
+ released = true;
903
+ this.#outstandingPieces -= 1;
904
+ this.#noteProgress();
905
+ };
906
+ }
907
+ await this.#waitForSlot();
908
+ }
909
+ } finally {
910
+ if (claimant) {
911
+ const at = this.#readerClaims.indexOf(claimant);
912
+ if (at >= 0) this.#readerClaims.splice(at, 1);
913
+ this.#wake();
914
+ }
915
+ }
916
+ }
917
+
918
+ /**
919
+ * Sleep until something moves, or until the retry interval, whichever first.
920
+ *
921
+ * One handler, idempotent, and its timer is cleared when it is woken. The
922
+ * earlier version attached a fresh pair of handlers to EVERY pending spill on
923
+ * every attempt and left a timer running each time, so a claim that could not
924
+ * be satisfied allocated in proportion to attempts times pending spills. A
925
+ * settling spill now wakes the store itself, which is where that belongs.
926
+ *
927
+ * @returns {Promise<void>}
928
+ */
929
+ #waitForSlot() {
930
+ return new Promise((resolve) => {
931
+ let settled = false;
932
+ /** @type {ReturnType<typeof setTimeout> | null} */
933
+ let retry = null;
934
+ const finish = () => {
935
+ if (settled) {
936
+ return;
937
+ }
938
+ settled = true;
939
+ if (retry !== null) {
940
+ clearTimeout(retry);
941
+ }
942
+ resolve();
943
+ };
944
+ this.#waiters.push(finish);
945
+ retry = setTimeout(finish, CLAIM_RETRY_MS);
946
+ retry.unref?.();
947
+ });
948
+ }
949
+
950
+ #registerPiece(index, buffer) {
951
+ // A piece may already be here. Both paths that register one look first and
952
+ // then await — `put` waits for a slot, `#revive` waits for the disk — and
953
+ // in that gap another caller can register the same index. Overwriting the
954
+ // entry used to drop the previous block on the floor: its memory was not
955
+ // returned to the pool and the pool's own count was never decremented, so
956
+ // the count climbed past the ceiling for ever and the pool degenerated into
957
+ // allocating a fresh block per piece. Field 2026-09-02: a store holding
958
+ // THREE pieces reported 812 MB committed against 68 MB allowed, 739 blocks
959
+ // allocated and 676 still alive, and the process was killed at 4.37 GB.
960
+ const displaced = this.#buffers.get(index);
961
+ if (displaced !== undefined && displaced !== buffer) {
962
+ this.#counters.blocksDisplaced += 1;
963
+ // A reader may still hold the existing buffer pinned. Verified bytes for
964
+ // this index are identical, so retain that buffer and return the duplicate.
965
+ this.#returnBlock(buffer);
966
+ this.#lru.touch(index);
967
+ this.#noteProgress();
968
+ return displaced;
969
+ }
970
+ this.#buffers.set(index, buffer);
971
+ this.#lru.touch(index);
972
+ this.#noteProgress();
973
+ return buffer;
974
+ }
975
+
976
+ /**
977
+ * Write a piece out and account for it, from the one place that does it.
978
+ *
979
+ * @param {number} index
980
+ * @param {SharedArrayBuffer} buffer
981
+ * @returns {Promise<void>}
982
+ */
983
+ #spill(index, buffer) {
984
+ // The disk may already hold these very bytes. `#revive` reads a piece back
985
+ // into memory and leaves the copy on disk, and only `put` removes it — so a
986
+ // piece that was revived and not re-put is identical to what is already
987
+ // written, and writing it again is work for nothing. There were 7575
988
+ // revivals in one session on 2026-09-02, and every later eviction of one of
989
+ // them wrote a second time (roadmap item 66: 14.4 GB written in a single
990
+ // viewing).
991
+ if (this.#disk.has(index)) {
992
+ this.#counters.spills += 1;
993
+ this.#counters.spillsSkipped += 1;
994
+ this.#spilledAt.set(index, Date.now());
995
+ this.#returnBlock(buffer);
996
+ this.#noteProgress();
997
+ return Promise.resolve();
998
+ }
999
+
1000
+ const bytes = Buffer.from(buffer, 0, this.#lengthOf(index));
1001
+ const startedAt = Date.now();
1002
+ const spill = this.#disk.write(index, bytes).then(
1003
+ () => {
1004
+ this.#noteWriteDuration(Date.now() - startedAt);
1005
+ this.#counters.spills += 1;
1006
+ this.#spilledAt.set(index, Date.now());
1007
+ this.#evicting.delete(index);
1008
+ // Only now. The write reads out of this block, so a block handed to
1009
+ // another piece before the write finished would put that piece's bytes
1010
+ // into this piece's place in the file.
1011
+ this.#returnBlock(buffer);
1012
+ this.#noteProgress();
1013
+ },
1014
+ (error) => {
1015
+ this.#evicting.delete(index);
1016
+ // The block is no longer holding anything either way; keeping it out of
1017
+ // the pool because the write failed would lose it for good.
1018
+ this.#returnBlock(buffer);
1019
+ this.#noteProgress();
1020
+ throw error;
1021
+ }
1022
+ );
1023
+ this.#evicting.set(index, spill);
1024
+ return spill;
1025
+ }
1026
+
1027
+ /** Something actually moved: wake whoever is waiting and restart the clock. */
1028
+ #noteProgress() {
1029
+ this.#lastProgressAt = Date.now();
1030
+ this.#wake();
1031
+ }
1032
+
1033
+ /**
1034
+ * Record how long one write to disk took.
1035
+ *
1036
+ * @param {number} durationMs
1037
+ * @returns {void}
1038
+ */
1039
+ #noteWriteDuration(durationMs) {
1040
+ this.#writeDurations.push(Math.max(0, durationMs));
1041
+ if (this.#writeDurations.length > WRITE_DURATION_SAMPLES) {
1042
+ this.#writeDurations.shift();
1043
+ }
1044
+ }
1045
+
1046
+ /** Record that a piece arrived, for the arrival rate. */
1047
+ #noteArrival() {
1048
+ const now = Date.now();
1049
+ this.#admittedAt.push(now);
1050
+ while (this.#admittedAt.length > ARRIVAL_SAMPLES
1051
+ || (this.#admittedAt.length > 0 && now - this.#admittedAt[0] > ARRIVAL_WINDOW_MS)) {
1052
+ this.#admittedAt.shift();
1053
+ }
1054
+ }
1055
+
1056
+ /**
1057
+ * How many pieces the store needs room for beyond what the readers ask for.
1058
+ *
1059
+ * Measured, not chosen: the pieces that arrive while one write to disk is
1060
+ * finishing. Without this room every arrival must evict something, and each
1061
+ * eviction holds its block until its write completes — so a disk slower than
1062
+ * the swarm turns every admission into one more block held. On 2026-09-02
1063
+ * that was 233 evictions against 119 completed writes in a minute, and 203
1064
+ * blocks held with three pieces resident.
1065
+ *
1066
+ * Zero until both quantities have been seen, because a slack invented before
1067
+ * anything is measured is a chosen number, and this store has been bitten by
1068
+ * those.
1069
+ *
1070
+ * @returns {number} Pieces.
1071
+ */
1072
+ slackPieces() {
1073
+ if (this.#writeDurations.length === 0 || this.#admittedAt.length < 2) {
1074
+ return 0;
1075
+ }
1076
+ const sorted = [...this.#writeDurations].sort((left, right) => left - right);
1077
+ const writeMs = sorted[Math.floor(sorted.length / 2)];
1078
+ const spanMs = this.#admittedAt[this.#admittedAt.length - 1] - this.#admittedAt[0];
1079
+ if (!(spanMs > 0)) {
1080
+ return 0;
1081
+ }
1082
+ const perMs = (this.#admittedAt.length - 1) / spanMs;
1083
+ return Math.ceil(perMs * writeMs);
1084
+ }
1085
+
1086
+ /**
1087
+ * Blocks that are not free: resident pieces, blocks being written out, and
1088
+ * blocks taken but not yet registered.
1089
+ *
1090
+ * This is what the ceiling has to bound, and until 2026-09-02 it bounded
1091
+ * resident pieces instead. The difference is exactly the memory that goes
1092
+ * missing from the count: a piece being spilled leaves `#buffers` the moment
1093
+ * the eviction begins, while its block stays held until the write it feeds
1094
+ * has finished.
1095
+ *
1096
+ * @returns {number}
1097
+ */
1098
+ #blocksInUse() {
1099
+ return this.#blocksAllocated - this.#freeBlocks.length;
1100
+ }
1101
+
1102
+ /**
1103
+ * Blocks held by writes that have not finished.
1104
+ *
1105
+ * @returns {number}
1106
+ */
1107
+ #blocksInFlight() {
1108
+ return Math.max(0, this.#blocksInUse() - this.#buffers.size);
1109
+ }
1110
+
1111
+ /**
1112
+ * Whether admitting one more piece would need something evicted first.
1113
+ *
1114
+ * Reservations count: a slot claimed and not yet filled is as taken as a
1115
+ * resident piece.
1116
+ *
1117
+ * @returns {boolean}
1118
+ */
1119
+ #isFullNow() {
1120
+ return this.#blocksInUse() + this.#outstandingPieces >= this.#growthCeiling;
1121
+ }
1122
+
1123
+ /**
1124
+ * Write an arriving piece straight to disk, without it ever occupying memory.
1125
+ *
1126
+ * Registered in `#evicting` like a spill, so a `get` for this index waits for
1127
+ * the write instead of finding the piece on neither tier. Deliberately NOT
1128
+ * recorded in `#spilledAt`: that clock measures how long an EVICTED piece
1129
+ * stayed away, and a piece that was never resident has no such age.
1130
+ *
1131
+ * @param {number} index
1132
+ * @param {Uint8Array} bytes
1133
+ * @returns {Promise<void>}
1134
+ */
1135
+ #writeThrough(index, bytes) {
1136
+ // Copied, not viewed. `PieceDiskStore.write` opens the file before it reads the
1137
+ // bytes, so a view onto the caller's buffer could be written to in between
1138
+ // and the file would get the wrong data. The spill path may pass a view
1139
+ // because that memory is ours; this buffer belongs to the torrent client.
1140
+ // It costs nothing extra: the piece was being copied into a shared buffer
1141
+ // on this path before, and now it is copied here instead.
1142
+ const copy = Buffer.from(bytes.subarray(0, Math.min(bytes.length, this.#lengthOf(index))));
1143
+ const write = this.#disk.write(index, copy).then(
1144
+ () => {
1145
+ this.#evicting.delete(index);
1146
+ this.#noteProgress();
1147
+ },
1148
+ (error) => {
1149
+ this.#evicting.delete(index);
1150
+ this.#noteProgress();
1151
+ throw error;
1152
+ }
1153
+ );
1154
+ this.#evicting.set(index, write);
1155
+ return write;
1156
+ }
1157
+
1158
+ /**
1159
+ * Record how long a piece stayed on disk before it was wanted again.
1160
+ *
1161
+ * A piece that comes back seconds after it left was evicted from a working
1162
+ * set that does not fit, and the write and the read were both waste. Kept as
1163
+ * a bounded window of ages because the figure wanted is a median, not a
1164
+ * history.
1165
+ *
1166
+ * @param {number} index
1167
+ * @returns {void}
1168
+ */
1169
+ #noteRevival(index) {
1170
+ const spilledAt = this.#spilledAt.get(index);
1171
+ if (spilledAt === undefined) {
1172
+ return;
1173
+ }
1174
+ this.#spilledAt.delete(index);
1175
+ this.#revivalAges.push(Date.now() - spilledAt);
1176
+ if (this.#revivalAges.length > REVIVAL_AGE_SAMPLES) {
1177
+ this.#revivalAges.shift();
1178
+ }
1179
+ }
1180
+
1181
+ /**
1182
+ * Record what an eviction had to take.
1183
+ *
1184
+ * @param {boolean} protectionYielded - The victim was inside a window a
1185
+ * reader had declared, and was taken anyway because nothing else was free.
1186
+ * @param {number} distance - Pieces from the nearest declared window, -1 when
1187
+ * no reader declared one.
1188
+ * @returns {void}
1189
+ */
1190
+ #noteEviction(protectionYielded, distance) {
1191
+ if (protectionYielded) {
1192
+ this.#counters.evictedProtected += 1;
1193
+ }
1194
+ if (distance >= 0) {
1195
+ this.#counters.evictedDistanceSum += distance;
1196
+ this.#counters.evictedWithDistance += 1;
1197
+ }
1198
+ }
1199
+
1200
+ /**
1201
+ * One piece, from wherever else it can be had.
1202
+ *
1203
+ * @param {number} index
1204
+ * @returns {Promise<Buffer | null>}
1205
+ */
1206
+ async #fromWholeFiles(index) {
1207
+ return this.#readElsewhere
1208
+ ? this.#readElsewhere({
1209
+ index,
1210
+ pieceLength: this.#chunkLength,
1211
+ length: this.#torrentLength,
1212
+ files: this.#files
1213
+ })
1214
+ : null;
1215
+ }
1216
+
1217
+ /**
1218
+ * Say a piece has gone, but only once it has gone from everywhere.
1219
+ *
1220
+ * The disk tier announces its own loss, which is not the same statement: a
1221
+ * piece dropped as a duplicate of a file held whole has not been lost at all,
1222
+ * and one still resident is about to be spilled again rather than gone. Only
1223
+ * the third case — neither tier, no whole file — is a withdrawal of the claim
1224
+ * that this proxy has those bytes.
1225
+ *
1226
+ * Nothing is said while the store is closing: the torrent it belongs to is
1227
+ * being torn down, and a claim withdrawn against a dying torrent reaches
1228
+ * either a destroyed object or, worse, one that has already been added back.
1229
+ *
1230
+ * @param {number} index
1231
+ * @returns {void}
1232
+ */
1233
+ #announceGoneIfNowhere(index) {
1234
+ if (this.#closed || this.#onPieceGone === null) {
1235
+ return;
1236
+ }
1237
+ if (this.#buffers.has(index) || this.#disk.has(index) || this.isInWholeFiles(index)) {
1238
+ return;
1239
+ }
1240
+ this.#counters.withdrawn += 1;
1241
+ try {
1242
+ this.#onPieceGone({ index, files: this.#files, name: this.#name });
1243
+ } catch {
1244
+ // silent-ok: whoever listens is diagnosing or bookkeeping, and a listener
1245
+ // that throws must not be what fails an eviction the store needs to make.
1246
+ }
1247
+ }
1248
+
1249
+ /**
1250
+ * Whether a spilled piece is now a duplicate of bytes held elsewhere.
1251
+ *
1252
+ * @param {number} index
1253
+ * @returns {boolean}
1254
+ */
1255
+ isInWholeFiles(index) {
1256
+ return this.#isElsewhere
1257
+ ? this.#isElsewhere({
1258
+ index,
1259
+ pieceLength: this.#chunkLength,
1260
+ length: this.#torrentLength,
1261
+ files: this.#files
1262
+ }) === true
1263
+ : false;
1264
+ }
1265
+
1266
+ /**
1267
+ * Drop spilled pieces that are now duplicates of bytes held elsewhere.
1268
+ *
1269
+ * A film assembled into a file is on the disk twice until this runs: once as
1270
+ * the file and once as the pieces it was built from. Field 2026-09-11, one
1271
+ * episode: 1417 MB of segments beside 1424 MB of spilled pieces. Nothing is
1272
+ * lost by dropping the second — a read that wants one of those pieces is
1273
+ * answered from the file.
1274
+ *
1275
+ * @returns {number} How many were dropped.
1276
+ */
1277
+ dropDuplicatesHeldElsewhere() {
1278
+ if (!this.#isElsewhere) {
1279
+ return 0;
1280
+ }
1281
+ let dropped = 0;
1282
+ for (const index of this.#disk.indexes()) {
1283
+ if (this.#buffers.has(index) || this.#evicting.has(index)) {
1284
+ continue;
1285
+ }
1286
+ if (this.isInWholeFiles(index)) {
1287
+ this.#disk.forget(index);
1288
+ dropped += 1;
1289
+ }
1290
+ }
1291
+ return dropped;
1292
+ }
1293
+
1294
+ #wake() {
1295
+ const waiting = this.#waiters;
1296
+ this.#waiters = [];
1297
+ for (const resolve of waiting) {
1298
+ resolve();
1299
+ }
1300
+ }
1301
+
1302
+ /**
1303
+ * One attempt at a reservation.
1304
+ *
1305
+ * @param {number} waitingSince - When the claim this attempt belongs to began
1306
+ * trying. Both waits below are measured from the later of it and the last
1307
+ * time the store moved, so neither can restart the other's clock — see
1308
+ * `#claimSlot`.
1309
+ * @returns {Promise<boolean>} Whether a slot was reserved.
1310
+ */
1311
+ async #claimSlotOnce(waitingSince) {
1312
+ // Reserve before suspension so concurrent callers see the reservation.
1313
+ if (this.#blocksInUse() + this.#outstandingPieces < this.#growthCeiling) {
1314
+ this.#outstandingPieces += 1;
1315
+ return true;
1316
+ }
1317
+ const stillFor = () => Date.now() - Math.max(waitingSince, this.#lastProgressAt);
1318
+
1319
+ // Full, and evicting would make it worse rather than better. A piece being
1320
+ // written out has already left `#buffers` while its block is still held —
1321
+ // the write reads from that block — so evicting another one converts a
1322
+ // resident block into an in-flight block and takes a fresh block for the
1323
+ // arrival: the memory in use goes UP by one per admission for as long as
1324
+ // the disk is behind. Field 2026-09-02: 233 evictions against 119 completed
1325
+ // writes in a minute, 203 blocks held with three pieces resident, and the
1326
+ // process killed at 4.37 GB.
1327
+ //
1328
+ // So when the disk is what the store is waiting for, it waits. A completing
1329
+ // write calls `#noteProgress`, which wakes whoever is here.
1330
+ // THE SAME QUANTITY THE GRANT ABOVE TESTS. It used to ask whether
1331
+ // `blocksInUse` alone had reached the ceiling, while what reaches the
1332
+ // ceiling is `blocksInUse + outstanding` — so a store held at its ceiling
1333
+ // by reservations fell past this branch and into the eviction below, which
1334
+ // has nothing to evict when nothing is resident. Field 2026-09-11:
1335
+ // `blocks=2 (0 spare)` with `outstanding=2` against a ceiling of 3, two
1336
+ // writes in flight each of which would have returned a block, and the claim
1337
+ // threw rather than waiting the moment out.
1338
+ if (this.#blocksInFlight() > 0 && this.#blocksInUse() + this.#outstandingPieces >= this.#growthCeiling) {
1339
+ if (stillFor() < PINNED_WAIT_MS) {
1340
+ this.#counters.waitedForDisk += 1;
1341
+ return false;
1342
+ }
1343
+ // The disk has stopped answering. Falling through to eviction is the
1344
+ // lesser failure: it grows memory, and the line above says by how much,
1345
+ // where refusing would fail the read outright. The clock is NOT restarted
1346
+ // here: the wait that follows asks the same question of the same claim,
1347
+ // and zeroing it was half of the loop described in `#claimSlot`.
1348
+ this.#counters.grewWaitingForDisk += 1;
1349
+ }
1350
+
1351
+ const { index: victim, protectionYielded, distance } = this.#lru.evictionChoice();
1352
+ if (victim === null) {
1353
+ // Nothing may leave. Wait while the store is still MOVING — a spill
1354
+ // completing, a piece admitted, a pin released — and give up when it has
1355
+ // not moved for PINNED_WAIT_MS, whatever is nominally in flight. The old
1356
+ // rule asked whether anything was in flight rather than whether anything
1357
+ // had happened, which is why one lost reservation could hold a read here
1358
+ // for ever.
1359
+ if (stillFor() < PINNED_WAIT_MS) {
1360
+ this.#counters.waitedForPins += 1;
1361
+ return false;
1362
+ }
1363
+ this.#counters.blockedByPins += 1;
1364
+ // SAY WHICH STATE REFUSED, with the numbers it was read from. The old
1365
+ // text named one state — every resident piece pinned — and the field case
1366
+ // of 2026-09-11 was a different one: `resident=0 pinned=0`, nothing to
1367
+ // evict because nothing was in memory at all, the ceiling held by
1368
+ // reservations and by two writes on their way to disk. A message that
1369
+ // names a state that did not exist costs a day of reading the wrong code.
1370
+ throw new Error(
1371
+ `No block can be had for ${PINNED_WAIT_MS}ms: ` +
1372
+ `${this.#buffers.size} resident (${this.#lru.pinnedCount} pinned, ` +
1373
+ `${this.#lru.protectedCount} range(s) declared), ` +
1374
+ `${this.#blocksInUse()} block(s) in use of ${this.#growthCeiling} allowed, ` +
1375
+ `${this.#blocksInFlight()} on their way to disk, ` +
1376
+ `${this.#outstandingPieces} reservation(s) outstanding.`
1377
+ );
1378
+ }
1379
+
1380
+ const victimBuffer = this.#buffers.get(victim);
1381
+ if (victimBuffer === undefined) {
1382
+ // Should not happen: LRU says resident but buffer missing.
1383
+ this.#lru.remove(victim);
1384
+ return false;
1385
+ }
1386
+
1387
+ // Claim atomically before await.
1388
+ this.#buffers.delete(victim);
1389
+ this.#lru.remove(victim);
1390
+ this.#outstandingPieces += 1;
1391
+ this.#noteEviction(protectionYielded, distance);
1392
+
1393
+ try {
1394
+ await this.#spill(victim, victimBuffer);
1395
+ } catch (error) {
1396
+ // The caller never received a release for this reservation, so it is
1397
+ // given back here rather than left outstanding for ever.
1398
+ this.#outstandingPieces -= 1;
1399
+ this.#noteProgress();
1400
+ throw error;
1401
+ }
1402
+ // Outstanding stays +1 for the caller; the slot for the new piece is now free.
1403
+ return true;
1404
+ }
1405
+
1406
+ /**
1407
+ * Bring a spilled piece back into memory, once, however many callers ask.
1408
+ *
1409
+ * @param {number} index
1410
+ * @returns {Promise<SharedArrayBuffer | null>} `null` when the piece is on
1411
+ * neither tier.
1412
+ */
1413
+ async #revive(index) {
1414
+ const spill = this.#evicting.get(index);
1415
+ if (spill) {
1416
+ await spill.catch(() => undefined);
1417
+ }
1418
+
1419
+ if (!this.#disk.has(index)) {
1420
+ // Not spilled, but the file it belongs to may be here whole — which is
1421
+ // the ordinary case once a film has been assembled and its spilled copy
1422
+ // dropped as the duplicate it had become.
1423
+ if (this.#isElsewhere && !this.isInWholeFiles(index)) return null;
1424
+ // Fragment readers require shared memory, including when the bytes now
1425
+ // live in an assembled file. Reserve before allocating ordinary bytes.
1426
+ const release = await this.#claimSlot(true);
1427
+ try {
1428
+ const whole = await this.#fromWholeFiles(index);
1429
+ if (!whole) return null;
1430
+ this.#counters.fromWholeFile += 1;
1431
+ return this.#registerPiece(index, this.#copyIntoNewBuffer(index, whole));
1432
+ } finally {
1433
+ release();
1434
+ }
1435
+ }
1436
+
1437
+ // Hold the disk bytes before waiting for memory: another reader may move
1438
+ // the eviction boundary while this read is waiting for a slot.
1439
+ const releaseDisk = this.#disk.hold?.(index) ?? (() => {});
1440
+ let release = null;
1441
+ try {
1442
+ release = await this.#claimSlot(true);
1443
+ // Another caller may have brought it back while this one waited for a
1444
+ // slot. Registering a second buffer for the same piece would leave
1445
+ // whoever holds the first reading memory nothing evicts.
1446
+ const already = this.#buffers.get(index);
1447
+ if (already !== undefined) {
1448
+ this.#lru.touch(index);
1449
+ this.#counters.fromMemory += 1;
1450
+ return already;
1451
+ }
1452
+ const target = this.#takeBlock();
1453
+ try {
1454
+ // Only this piece's own length: the block is a full piece long and the
1455
+ // last piece of a file is shorter, so reading the whole block would ask
1456
+ // the file for bytes past its end.
1457
+ await this.#disk.read(index, Buffer.from(target, 0, this.#lengthOf(index)));
1458
+ } catch (error) {
1459
+ this.#returnBlock(target);
1460
+ throw error;
1461
+ }
1462
+ const registered = this.#registerPiece(index, target);
1463
+ this.#counters.fromDisk += 1;
1464
+ this.#counters.revivals += 1;
1465
+ this.#noteRevival(index);
1466
+ return registered;
1467
+ } finally {
1468
+ release?.();
1469
+ releaseDisk();
1470
+ }
1471
+ }
1472
+
1473
+ /**
1474
+ * A block to hold one piece: the most recently freed one, or a new one.
1475
+ *
1476
+ * Every block is a full piece long, whatever piece will live in it. The last
1477
+ * piece of a file is shorter, and it occupies a full block with only its own
1478
+ * bytes meaningful — `#lengthOf` is what decides how much is ever read out.
1479
+ * Uniform blocks are what makes them interchangeable at all.
1480
+ *
1481
+ * Most recently freed first, deliberately: a few blocks then carry the whole
1482
+ * of a busy store's traffic and the rest age out of use, which is what makes
1483
+ * {@link SharedPieceStore#sweepFreeBlocks} able to tell a spare block from a
1484
+ * working one.
1485
+ *
1486
+ * @returns {SharedArrayBuffer}
1487
+ */
1488
+ #takeBlock() {
1489
+ const spare = this.#freeBlocks.pop();
1490
+ if (spare !== undefined) {
1491
+ this.#noteReuseGap(Date.now() - spare.freedAt);
1492
+ return spare.buffer;
1493
+ }
1494
+ // Nothing spare and the pool is already as large as it is allowed to be.
1495
+ // This cannot happen while the accounting is sound: a block is taken only
1496
+ // after a slot has been claimed, and slots are exactly what the ceiling
1497
+ // counts. It is recorded rather than hidden because when it does happen the
1498
+ // pool grows without bound, and every block it then allocates is used once
1499
+ // and thrown to a collector that has no reason to run — the heap stays at
1500
+ // 50 MB of 2240 while the process reaches four gigabytes.
1501
+ // Strictly greater: reaching the ceiling exactly is what a full store looks
1502
+ // like, and a slot has just been claimed for this block. Being ALREADY past
1503
+ // it and allocating anyway is the state that runs away.
1504
+ if (this.#blocksAllocated > this.#growthCeiling) {
1505
+ this.#counters.blocksBeyondCeiling += 1;
1506
+ }
1507
+ this.#blocksAllocated += 1;
1508
+ return this.#watchForCollection(new SharedArrayBuffer(this.#chunkLength));
1509
+ }
1510
+
1511
+ /**
1512
+ * Put a block back for re-use, or give it up.
1513
+ *
1514
+ * Given up when the allowance has fallen below the number of blocks that
1515
+ * exist: keeping it would hold memory the machine has just been shown to
1516
+ * need. Otherwise it waits in the free list for the next piece.
1517
+ *
1518
+ * @param {SharedArrayBuffer | undefined} buffer
1519
+ * @returns {void}
1520
+ */
1521
+ #returnBlock(buffer) {
1522
+ if (buffer === undefined) {
1523
+ return;
1524
+ }
1525
+ if (this.#closed || this.#blocksAllocated > this.#growthCeiling) {
1526
+ // Never below zero: `close` gives up every block at once, and a spill
1527
+ // that was already in flight resolves afterwards and arrives here.
1528
+ if (this.#blocksAllocated > 0) {
1529
+ this.#blocksAllocated -= 1;
1530
+ this.#counters.blocksReleased += 1;
1531
+ }
1532
+ return;
1533
+ }
1534
+ this.#freeBlocks.push({ buffer, freedAt: Date.now() });
1535
+ }
1536
+
1537
+ /**
1538
+ * Record how long a block waited to be used again.
1539
+ *
1540
+ * @param {number} gapMs
1541
+ * @returns {void}
1542
+ */
1543
+ #noteReuseGap(gapMs) {
1544
+ this.#reuseGaps.push(Math.max(0, gapMs));
1545
+ if (this.#reuseGaps.length > REUSE_GAP_SAMPLES) {
1546
+ this.#reuseGaps.shift();
1547
+ }
1548
+ }
1549
+
1550
+ /**
1551
+ * The longest a block has recently waited before being wanted again, or null
1552
+ * when no block has yet been re-used.
1553
+ *
1554
+ * This is the store's own working rhythm, measured rather than chosen: while
1555
+ * a film is being watched a block is taken again within milliseconds, because
1556
+ * one is taken for every piece that arrives. A block that has been sitting
1557
+ * longer than the longest of those waits is not part of the work.
1558
+ *
1559
+ * @returns {number | null}
1560
+ */
1561
+ #reuseGapCeilingMs() {
1562
+ if (this.#reuseGaps.length === 0) {
1563
+ return null;
1564
+ }
1565
+ return Math.max(...this.#reuseGaps);
1566
+ }
1567
+
1568
+ /**
1569
+ * Give up blocks that have sat unused longer than this store's own working
1570
+ * rhythm.
1571
+ *
1572
+ * The case it is for: a torrent whose peers have gone. Its readers are still
1573
+ * attached, so nothing removes the torrent — the pool's idle timer needs a
1574
+ * refcount of zero and never starts. Its allowance falls to what those
1575
+ * readers declared, the pieces beyond it are written out, and their blocks
1576
+ * would otherwise wait in the free list for peers that may not return.
1577
+ *
1578
+ * @param {number} [now]
1579
+ * @returns {number} Blocks given up.
1580
+ */
1581
+ sweepFreeBlocks(now = Date.now()) {
1582
+ const ceiling = this.#reuseGapCeilingMs();
1583
+ if (ceiling === null) {
1584
+ return 0;
1585
+ }
1586
+ const keeping = [];
1587
+ let released = 0;
1588
+ for (const spare of this.#freeBlocks) {
1589
+ if (now - spare.freedAt <= ceiling) {
1590
+ keeping.push(spare);
1591
+ continue;
1592
+ }
1593
+ this.#blocksAllocated -= 1;
1594
+ this.#counters.blocksReleased += 1;
1595
+ released += 1;
1596
+ }
1597
+ this.#freeBlocks = keeping;
1598
+ return released;
1599
+ }
1600
+
1601
+ /**
1602
+ * Count this buffer as one this thread will have to let go of, and notice
1603
+ * when the collector takes it. See {@link pieceBufferCollection}.
1604
+ *
1605
+ * @param {SharedArrayBuffer} buffer
1606
+ * @returns {SharedArrayBuffer} The same buffer.
1607
+ */
1608
+ #watchForCollection(buffer) {
1609
+ released.count += 1;
1610
+ collector?.register(buffer, null);
1611
+ return buffer;
1612
+ }
1613
+
1614
+ /**
1615
+ * A fresh buffer holding this piece's bytes.
1616
+ *
1617
+ * @param {number} index
1618
+ * @param {Uint8Array} bytes
1619
+ * @returns {SharedArrayBuffer}
1620
+ */
1621
+ #copyIntoNewBuffer(index, bytes) {
1622
+ const length = this.#lengthOf(index);
1623
+ const block = this.#takeBlock();
1624
+ const view = Buffer.from(block, 0, length);
1625
+ if (bytes.copy) {
1626
+ bytes.copy(view, 0, 0, length);
1627
+ } else {
1628
+ view.set(bytes.subarray(0, length), 0);
1629
+ }
1630
+ return block;
1631
+ }
1632
+
1633
+ /**
1634
+ * Drop the disk copy of a piece that memory now holds — after any spill of
1635
+ * that same piece has finished.
1636
+ *
1637
+ * `PieceDiskStore.write` records the index when it COMPLETES, so forgetting while a
1638
+ * spill of that index is still running let the completing write put it back,
1639
+ * and a later read then returned the stale bytes.
1640
+ *
1641
+ * @param {number} index
1642
+ * @returns {Promise<void>}
1643
+ */
1644
+ async #forgetOnDisk(index) {
1645
+ const spill = this.#evicting.get(index);
1646
+ if (spill) {
1647
+ await spill.catch(() => undefined);
1648
+ }
1649
+ this.#disk.forget(index);
1650
+ this.#spilledAt.delete(index);
1651
+ }
1652
+
1653
+ put(index, bytes, callback = () => undefined) {
1654
+ if (this.#closed) {
1655
+ queueMicrotask(() => callback(new Error("Piece store is closed.")));
1656
+ return;
1657
+ }
1658
+
1659
+ const write = async () => {
1660
+ // Already resident: the buffer is replaced, not added, so no slot is
1661
+ // needed and none is claimed. A fresh buffer rather than a write into the
1662
+ // old one, so a reader holding the old reference cannot see a torn write.
1663
+ if (this.#buffers.has(index)) {
1664
+ const previous = this.#buffers.get(index);
1665
+ // A fresh block rather than a write into the old one, so a reader
1666
+ // holding the old reference cannot see a torn write.
1667
+ this.#buffers.set(index, this.#copyIntoNewBuffer(index, bytes));
1668
+ this.#lru.touch(index);
1669
+ // And the old block goes back for re-use only if nobody is reading it.
1670
+ // A pinned piece has a view onto its memory somewhere; that block is
1671
+ // given up instead, and the pool allocates another when it needs one.
1672
+ if (this.#lru.isPinned(index)) {
1673
+ if (this.#blocksAllocated > 0) {
1674
+ this.#blocksAllocated -= 1;
1675
+ this.#counters.blocksReleased += 1;
1676
+ }
1677
+ } else {
1678
+ this.#returnBlock(previous);
1679
+ }
1680
+ await this.#forgetOnDisk(index);
1681
+ this.#noteProgress();
1682
+ return;
1683
+ }
1684
+
1685
+ this.#noteArrival();
1686
+ const isDeclared = this.#lru.wants(index);
1687
+ if (isDeclared) {
1688
+ this.#counters.admittedInsideWindow += 1;
1689
+ } else {
1690
+ this.#counters.admittedOutsideWindow += 1;
1691
+ }
1692
+
1693
+ // A piece wanted LESS than the one it would displace goes straight to
1694
+ // disk instead of being admitted.
1695
+ //
1696
+ // The same comparison eviction makes, in the same order, from the other
1697
+ // end: the priority map's level first, and the distance to a read head
1698
+ // only between pieces the map wants equally. Answering the two from
1699
+ // different quantities would let the store admit a piece by one rule and
1700
+ // evict it again by the other in the same second.
1701
+ //
1702
+ // Two cases, and the second was missing until 2026-09-02. The first: the
1703
+ // arrival is in nobody's zone at all. The second: it IS in a zone, but
1704
+ // one the map wants less than the zone holding the piece the store would
1705
+ // have to evict for it. The field session had six readers whose windows
1706
+ // covered the whole file, so the first case never applied and `0 of those
1707
+ // went straight to disk` while the store spilled 233 pieces in a minute.
1708
+ //
1709
+ // Only when SOMETHING is stated: before that there is no basis for
1710
+ // calling one piece more wanted than another.
1711
+ const isWorseThanTheVictim = () => {
1712
+ const victim = this.#lru.nextVictim();
1713
+ if (victim.index === null) {
1714
+ return false;
1715
+ }
1716
+ const arrivingWant = this.#lru.wantAt(index);
1717
+ if (arrivingWant !== victim.want) {
1718
+ return arrivingWant > victim.want;
1719
+ }
1720
+ const arriving = this.#lru.waitFor(index);
1721
+ return arriving >= 0 && victim.wait >= 0 && arriving > victim.wait;
1722
+ };
1723
+ if (this.#lru.protectedCount > 0 && this.#isFullNow()
1724
+ && (!isDeclared || isWorseThanTheVictim())) {
1725
+ this.#counters.admittedToDisk += 1;
1726
+ await this.#writeThrough(index, bytes);
1727
+ this.#noteProgress();
1728
+ return;
1729
+ }
1730
+
1731
+ // MEMORY IS NEVER A REASON TO REFUSE A PIECE. The piece is verified and
1732
+ // the disk will take it; failing here fails the torrent client's own
1733
+ // write, and the client answers that by destroying the torrent — field
1734
+ // 2026-09-11, `WebTorrent client error: Every resident piece is pinned`,
1735
+ // after which every read of that torrent answered `File 1 not found` for
1736
+ // the life of the process and the viewer could not open anything.
1737
+ //
1738
+ // The branch above already writes through when the store is full and the
1739
+ // arrival is wanted less than what it would displace. It cannot fire
1740
+ // where nothing is declared (`protectedCount > 0`), which is exactly the
1741
+ // state a store between readers is in — and that state is where the
1742
+ // refusal happened.
1743
+ const release = await this.#claimSlotOrNull();
1744
+ if (release === null) {
1745
+ this.#counters.admittedToDisk += 1;
1746
+ await this.#writeThrough(index, bytes);
1747
+ this.#noteProgress();
1748
+ return;
1749
+ }
1750
+ try {
1751
+ this.#registerPiece(index, this.#copyIntoNewBuffer(index, bytes));
1752
+ await this.#forgetOnDisk(index);
1753
+ } finally {
1754
+ release();
1755
+ }
1756
+ };
1757
+
1758
+ write().then(() => callback(null), (error) => callback(error));
1759
+ }
1760
+
1761
+ get(index, options, callback) {
1762
+ if (typeof options === "function") {
1763
+ return this.get(index, undefined, options);
1764
+ }
1765
+ const done = callback ?? (() => undefined);
1766
+ if (this.#closed) {
1767
+ queueMicrotask(() => done(new Error("Piece store is closed.")));
1768
+ return;
1769
+ }
1770
+
1771
+ const pieceLength = this.#lengthOf(index);
1772
+ const offset = options?.offset ?? 0;
1773
+ const length = options?.length ?? pieceLength - offset;
1774
+
1775
+ const fetch = async () => {
1776
+ const buffer = this.#buffers.get(index);
1777
+ if (buffer !== undefined) {
1778
+ this.#lru.touch(index);
1779
+ this.#counters.fromMemory += 1;
1780
+ return Buffer.from(Buffer.from(buffer, offset, length));
1781
+ }
1782
+
1783
+ // NOT revived into memory. This entry point is the torrent client's: it
1784
+ // reads to answer a peer, and a peer asks for 16 KB while a piece here is
1785
+ // megabytes. Reviving would take a whole block for bytes nobody is going
1786
+ // to read again — and it is the ONE read that can be answered without a
1787
+ // block at all, since the caller is handed its own copy either way.
1788
+ //
1789
+ // Field 2026-09-11, and this is the whole reason the store could not
1790
+ // serve anybody: an upload capped at 512 KB/s produced 63 416 reads of
1791
+ // which 78.4 % missed memory, 49 696 pieces revived whole, and every one
1792
+ // of those revivals competed for a ceiling of three blocks. Claiming a
1793
+ // block for an upload is also how this path reached `#claimSlot`, whose
1794
+ // failure the torrent client turns into a destroyed torrent.
1795
+ // A spill that is still being written owns the only copy there is, so
1796
+ // wait for it exactly as `#revive` does before asking the disk.
1797
+ const spill = this.#evicting.get(index);
1798
+ if (spill) {
1799
+ await spill.catch(() => undefined);
1800
+ }
1801
+ const resident = this.#buffers.get(index);
1802
+ if (resident !== undefined) {
1803
+ this.#lru.touch(index);
1804
+ this.#counters.fromMemory += 1;
1805
+ return Buffer.from(Buffer.from(resident, offset, length));
1806
+ }
1807
+ if (this.#disk.has(index)) {
1808
+ const want = Math.max(0, Math.min(length, this.#lengthOf(index) - offset));
1809
+ const target = Buffer.allocUnsafe(want);
1810
+ await this.#disk.read(index, target, offset);
1811
+ this.#counters.fromDisk += 1;
1812
+ return target;
1813
+ }
1814
+ const whole = await this.#fromWholeFiles(index);
1815
+ if (whole) {
1816
+ this.#counters.fromWholeFile += 1;
1817
+ return Buffer.from(whole.subarray(offset, offset + length));
1818
+ }
1819
+ throw new Error(`Piece ${index} is not in the store.`);
1820
+ };
1821
+
1822
+ fetch().then((bytes) => done(null, bytes), (error) => done(error));
1823
+ }
1824
+
1825
+ /**
1826
+ * State a range of pieces and how much they are wanted. The number is the
1827
+ * priority map's own, and it is what eviction compares — see
1828
+ * {@link PieceLru#protect}.
1829
+ *
1830
+ * @param {string|number} readerId
1831
+ * @param {number} from
1832
+ * @param {number} to
1833
+ * @param {number} [urgency]
1834
+ * @returns {void}
1835
+ */
1836
+ protectRange(readerId, from, to, urgency) {
1837
+ this.#lru.protect(readerId, from, to, urgency);
1838
+ // A READER DECLARES ITSELF IN A MOMENT; the allowance was re-derived once a
1839
+ // minute. Between the two a read met whatever the store had shrunk to while
1840
+ // nobody was reading, and a claim gives up after five seconds — twelve
1841
+ // times sooner than the store could have grown (field 2026-09-11).
1842
+ //
1843
+ // DELIBERATELY NOT raising the ceiling here. A reader declaring a window
1844
+ // wider than the store may hold is a shortage to absorb, not an instruction
1845
+ // to take more memory: with several viewers on several films the unions add
1846
+ // up across stores, and each store's floor already wins over the machine's
1847
+ // share at the next revision. What the shortage must not do is fail a read,
1848
+ // and that is answered where it arises — no path in this store refuses for
1849
+ // want of memory any more.
1850
+ }
1851
+
1852
+ protectedRanges() {
1853
+ return this.#lru.protectedRanges();
1854
+ }
1855
+
1856
+ releaseProtection(readerId) {
1857
+ this.#lru.unprotect(readerId);
1858
+ }
1859
+
1860
+ warmRange(from, to, limit = Math.max(1, Math.floor(this.#growthCeiling / 4))) {
1861
+ if (this.#closed || !Number.isInteger(from) || !Number.isInteger(to)) {
1862
+ return 0;
1863
+ }
1864
+ let started = 0;
1865
+ for (let index = from; index <= to && started < limit; index += 1) {
1866
+ if (this.#buffers.has(index) || !this.#disk.has(index)) {
1867
+ continue;
1868
+ }
1869
+ started += 1;
1870
+ void this.reside(index).catch(() => undefined);
1871
+ }
1872
+ return started;
1873
+ }
1874
+
1875
+ async reside(index) {
1876
+ if (this.#closed) {
1877
+ throw new Error("Piece store is closed.");
1878
+ }
1879
+
1880
+ const buffer = this.#buffers.get(index);
1881
+ if (buffer !== undefined) {
1882
+ this.#lru.touch(index);
1883
+ this.#counters.fromMemory += 1;
1884
+ return this.locate(index);
1885
+ }
1886
+
1887
+ let revival = this.#revivals.get(index);
1888
+ if (!revival) {
1889
+ revival = this.#revive(index).finally(() => this.#revivals.delete(index));
1890
+ this.#revivals.set(index, revival);
1891
+ }
1892
+ const revived = await revival;
1893
+ if (revived === null) {
1894
+ return null;
1895
+ }
1896
+ return { buffer: revived, offset: 0, length: this.#lengthOf(index) };
1897
+ }
1898
+
1899
+ close(callback = () => undefined) {
1900
+ this.#closed = true;
1901
+ liveStores.delete(this);
1902
+ this.#buffers.clear();
1903
+ this.#spilledAt.clear();
1904
+ this.#counters.blocksReleased += this.#blocksAllocated;
1905
+ this.#blocksAllocated = 0;
1906
+ this.#freeBlocks = [];
1907
+ // Whoever is waiting for a slot is woken and finds the store closed, which
1908
+ // is an error they can report. Left asleep they simply never returned.
1909
+ this.#wake();
1910
+ this.#disk.close().then(() => callback(null), (error) => callback(error));
1911
+ }
1912
+
1913
+ destroy(callback = () => undefined) {
1914
+ this.#closed = true;
1915
+ liveStores.delete(this);
1916
+ this.#buffers.clear();
1917
+ this.#spilledAt.clear();
1918
+ this.#counters.blocksReleased += this.#blocksAllocated;
1919
+ this.#blocksAllocated = 0;
1920
+ this.#freeBlocks = [];
1921
+ this.#wake();
1922
+ this.#disk.destroy().then(() => callback(null), (error) => callback(error));
1923
+ }
1924
+ }
1925
+
1926
+ /**
1927
+ * What the stores in this thread hold and would take, for the owner of memory.
1928
+ *
1929
+ * @returns {{ held: number, wanted: number }}
1930
+ */
1931
+ export function memoryClaim() {
1932
+ const stores = [...liveStores];
1933
+ return {
1934
+ held: stores.reduce((sum, store) => sum + store.residentBytes, 0),
1935
+ wanted: stores.reduce((sum, store) => sum + store.wantedBytes, 0)
1936
+ };
1937
+ }