@torrent-tv/proxy 2.89.7 → 2.89.9

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 (498) 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 +363 -338
  13. package/CHANGELOG.md +2345 -2335
  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 -19
  19. package/assets/calibration/audio/manifest.json +98 -98
  20. package/assets/diagnostics/sctpstate.gdb +31 -31
  21. package/bin/cli.js +684 -684
  22. package/bin/thread-pool.js +30 -30
  23. package/biome.json +412 -412
  24. package/docs/browser-proxy-contract.md +155 -155
  25. package/docs/container-architecture.md +273 -273
  26. package/docs/download-architecture.md +261 -261
  27. package/docs/encode-architecture.md +765 -765
  28. package/docs/encode-run-state.md +96 -96
  29. package/docs/logs.md +97 -97
  30. package/docs/playback-start-forecast.md +113 -113
  31. package/docs/socks5-egress-research.md +114 -114
  32. package/docs/storage-architecture.md +273 -273
  33. package/knip.json +14 -14
  34. package/openspec/changes/adaptive-bitrate/design.md +92 -92
  35. package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
  36. package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
  37. package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
  38. package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
  39. package/openspec/changes/binary-distribution/proposal.md +53 -53
  40. package/openspec/changes/chunked-request-bodies/design.md +81 -81
  41. package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
  42. package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
  43. package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
  44. package/openspec/changes/cold-start/design.md +98 -98
  45. package/openspec/changes/cold-start/proposal.md +62 -62
  46. package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
  47. package/openspec/changes/cold-start/tasks.md +48 -48
  48. package/openspec/changes/disk-cap/.openspec.yaml +2 -2
  49. package/openspec/changes/disk-cap/proposal.md +37 -37
  50. package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
  51. package/openspec/changes/disk-cap/tasks.md +19 -19
  52. package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
  53. package/openspec/changes/proxy-observability/design.md +38 -38
  54. package/openspec/changes/proxy-observability/proposal.md +49 -49
  55. package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
  56. package/openspec/changes/proxy-observability/tasks.md +21 -21
  57. package/openspec/changes/seek-debounce/design.md +118 -118
  58. package/openspec/changes/seek-debounce/proposal.md +59 -59
  59. package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
  60. package/openspec/changes/seek-debounce/tasks.md +33 -33
  61. package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
  62. package/openspec/changes/subtitle-language/proposal.md +50 -50
  63. package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
  64. package/openspec/changes/subtitle-language/tasks.md +26 -26
  65. package/openspec/changes/track-selection/.openspec.yaml +2 -2
  66. package/openspec/changes/track-selection/design.md +47 -47
  67. package/openspec/changes/track-selection/proposal.md +45 -45
  68. package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
  69. package/openspec/changes/track-selection/tasks.md +20 -20
  70. package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
  71. package/openspec/changes/transcode-quality/proposal.md +51 -51
  72. package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
  73. package/openspec/changes/transcode-quality/tasks.md +63 -63
  74. package/openspec/config.yaml +38 -38
  75. package/package.json +51 -50
  76. package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
  77. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  78. package/research/double-spawn-2026-09-10.md +171 -171
  79. package/research/handover-2026-09-13.md +209 -209
  80. package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
  81. package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
  82. package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
  83. package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
  84. package/routes/api/client-logs/post.js +86 -86
  85. package/routes/api/delivery-sink/get.js +72 -72
  86. package/routes/api/link-probe/get.js +51 -51
  87. package/routes/api/playback-plan/audio-tracks/post.js +29 -29
  88. package/routes/api/playback-plan/post.js +53 -53
  89. package/routes/api/sources/files/get.js +106 -106
  90. package/routes/api/sources/post.js +36 -36
  91. package/routes/api/sources/stats/get.js +132 -132
  92. package/routes/api/sources/warm/post.js +217 -217
  93. package/routes/api/subtitles/get.js +230 -230
  94. package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
  95. package/routes/api/transcode-sessions/net-report/post.js +138 -138
  96. package/routes/api/transcode-sessions/post.js +178 -178
  97. package/routes/api/transcode-sessions/progress/get.js +27 -27
  98. package/routes/api/transcode-sessions/release/post.js +41 -41
  99. package/routes/api/transcode-sessions/seek/post.js +63 -63
  100. package/routes/health/get.js +13 -13
  101. package/routes/healthz/get.js +13 -13
  102. package/routes/stream/get.js +416 -416
  103. package/routes/transcode/audio-file/get.js +64 -64
  104. package/routes/transcode/audio-warm/get.js +95 -95
  105. package/routes/transcode/session-file/get.js +52 -52
  106. package/routes/transcode/variant-file/get.js +95 -95
  107. package/routes/transcode/variant-warm/get.js +84 -84
  108. package/scripts/build-audio-samples.mjs +31 -31
  109. package/scripts/render-run-graph.js +173 -173
  110. package/server.js +748 -748
  111. package/services/encode/CoverageMap.js +428 -428
  112. package/services/encode/CushionReport.js +345 -345
  113. package/services/encode/EncodeAdmission.js +376 -376
  114. package/services/encode/EncodeOrchestrator.js +1192 -1192
  115. package/services/encode/EncodePlan.js +1368 -1368
  116. package/services/encode/EncodeRun.js +36 -12
  117. package/services/encode/Encoder.js +142 -142
  118. package/services/encode/EncoderSelection.js +38 -38
  119. package/services/encode/LocalObservations.js +359 -359
  120. package/services/encode/NvencEncoder.js +50 -50
  121. package/services/encode/OutputOpening.js +784 -784
  122. package/services/encode/OutputTimes.js +579 -579
  123. package/services/encode/QsvEncoder.js +58 -58
  124. package/services/encode/Renditions.js +2283 -2283
  125. package/services/encode/RunClock.js +87 -87
  126. package/services/encode/RunProgress.js +79 -79
  127. package/services/encode/SegmentDemand.js +95 -95
  128. package/services/encode/SoftwareEncoder.js +121 -121
  129. package/services/encode/V4l2m2mEncoder.js +53 -53
  130. package/services/encode/VaapiEncoder.js +66 -66
  131. package/services/encode/args.js +418 -418
  132. package/services/encode/audio-calibration.js +67 -67
  133. package/services/encode/audio-work-rate.js +15 -15
  134. package/services/encode/available-share.js +89 -89
  135. package/services/encode/calibration.js +361 -361
  136. package/services/encode/contention.js +128 -128
  137. package/services/encode/decode-cost-fit.js +363 -363
  138. package/services/encode/encode-exit.js +116 -116
  139. package/services/encode/encode-run-state.js +551 -551
  140. package/services/encode/encoder-readings.js +43 -43
  141. package/services/encode/fingerprint.js +322 -322
  142. package/services/encode/host-load.js +174 -174
  143. package/services/encode/hwaccel.js +1894 -1894
  144. package/services/encode/index.js +9 -9
  145. package/services/encode/learned-median.js +90 -90
  146. package/services/encode/output/EncodedOutput.js +57 -57
  147. package/services/encode/output/Output.js +131 -131
  148. package/services/encode/output/OutputCatalog.js +414 -414
  149. package/services/encode/output/OutputSpec.js +390 -390
  150. package/services/encode/output/Timeline.js +308 -308
  151. package/services/encode/output/cut-grid.js +107 -107
  152. package/services/encode/output/index.js +24 -24
  153. package/services/encode/output/ladder.js +32 -32
  154. package/services/encode/output/playlists.js +284 -284
  155. package/services/encode/output/rates.js +125 -125
  156. package/services/encode/output-key-format.js +21 -21
  157. package/services/encode/quality/EncodeCost.js +1043 -1043
  158. package/services/encode/quality/HostLoad.js +525 -525
  159. package/services/encode/quality/HostTimings.js +249 -249
  160. package/services/encode/quality/OutputQualityState.js +24 -24
  161. package/services/encode/quality/QualityController.js +758 -758
  162. package/services/encode/quality/QualityOffer.js +416 -416
  163. package/services/encode/quality/drain-threat.js +47 -47
  164. package/services/encode/quality/link-budget.js +337 -337
  165. package/services/encode/quality/output-format.js +319 -319
  166. package/services/encode/quality/serving-output.js +216 -216
  167. package/services/encode/quality/visible-rung.js +57 -57
  168. package/services/encode/run-budget.js +97 -97
  169. package/services/encode/run-command.js +757 -757
  170. package/services/encode/run-costs.js +155 -155
  171. package/services/encode/run-inputs.js +53 -53
  172. package/services/encode/segment-formats/fmp4.js +476 -476
  173. package/services/encode/segment-formats/init-compat.js +324 -324
  174. package/services/encode/segment-formats/mpegts.js +134 -134
  175. package/services/encode/start-stop-cost.js +178 -178
  176. package/services/encode/throughput.js +97 -97
  177. package/services/encode/torrent-cost.js +88 -88
  178. package/services/identity/installation-id.js +38 -38
  179. package/services/media/ContainerOrchestrator.js +202 -202
  180. package/services/media/KeyframeTables.js +356 -356
  181. package/services/media/SourceFile.js +298 -298
  182. package/services/media/SubtitleCues.js +592 -592
  183. package/services/media/SubtitleOrchestrator.js +167 -167
  184. package/services/media/audio-inventory.js +299 -299
  185. package/services/media/container/AviContainer.js +268 -268
  186. package/services/media/container/Container.js +632 -632
  187. package/services/media/container/ContainerFactory.js +38 -38
  188. package/services/media/container/KeyframeTable.js +272 -272
  189. package/services/media/container/MatroskaContainer.js +1031 -1031
  190. package/services/media/container/Mp4Container.js +1354 -1354
  191. package/services/media/container/SubtitleFileContainer.js +339 -339
  192. package/services/media/container/ebml-reader.js +159 -159
  193. package/services/media/container/ebml-stream.js +174 -174
  194. package/services/media/container/matroska-clusters.js +1165 -1165
  195. package/services/media/container/unavailable.js +77 -77
  196. package/services/media/ffmpeg-banner.js +260 -260
  197. package/services/media/keyframe-probe.js +157 -157
  198. package/services/media/media-info-probe.js +93 -93
  199. package/services/media/playback-planner.js +931 -931
  200. package/services/media/tracks/AudioTrack.js +143 -143
  201. package/services/media/tracks/ContainerTrack.js +84 -84
  202. package/services/media/tracks/ImageSubtitleTrack.js +19 -19
  203. package/services/media/tracks/SubtitleTrack.js +38 -38
  204. package/services/media/tracks/TextSubtitleTrack.js +295 -295
  205. package/services/media/tracks/VideoTrack.js +33 -33
  206. package/services/media/tracks/audio-bound.js +233 -233
  207. package/services/media/tracks/index.js +15 -15
  208. package/services/media/tracks/language-detect.js +174 -174
  209. package/services/server/OutputLifecycle.js +418 -418
  210. package/services/server/SegmentServing.js +1152 -1152
  211. package/services/server/ViewerRequests.js +785 -785
  212. package/services/server/WaitLedger.js +142 -142
  213. package/services/server/controllers/PlaybackController.js +40 -40
  214. package/services/server/controllers/SubtitleController.js +109 -109
  215. package/services/server/http-responses.js +60 -60
  216. package/services/server/transcode-session-files.js +325 -325
  217. package/services/server/wire-outputs.js +1029 -1029
  218. package/services/storage/Diagnostics.js +202 -202
  219. package/services/storage/MachineBudget.js +235 -235
  220. package/services/storage/allowance.js +107 -107
  221. package/services/storage/core-dumps.js +106 -106
  222. package/services/storage/files/CompletedFiles.js +391 -391
  223. package/services/storage/files/piece-from-whole-file.js +118 -118
  224. package/services/storage/free.js +71 -71
  225. package/services/storage/keep.js +48 -48
  226. package/services/storage/machine-memory.js +56 -56
  227. package/services/storage/memory-report.js +575 -575
  228. package/services/storage/piece-store/piece-disk-store.js +539 -539
  229. package/services/storage/piece-store/piece-lru.js +495 -495
  230. package/services/storage/piece-store/shared-piece-store.js +1937 -1937
  231. package/services/storage/returns.js +103 -103
  232. package/services/storage/segment-store/SegmentStore.js +1294 -1294
  233. package/services/storage/wire.js +175 -175
  234. package/services/torrent/Contents.js +382 -382
  235. package/services/torrent/demand/DemandRegister.js +202 -202
  236. package/services/torrent/demand/Urgency.js +137 -137
  237. package/services/torrent/demand/Window.js +118 -118
  238. package/services/torrent/demand/index.js +8 -8
  239. package/services/torrent/demand/pieces.js +140 -140
  240. package/services/torrent/download/SwarmSelection.js +386 -386
  241. package/services/torrent/download/registry.js +102 -102
  242. package/services/torrent/download/withdraw-claim.js +80 -80
  243. package/services/torrent/episode-naming.js +182 -182
  244. package/services/torrent/files.js +455 -455
  245. package/services/torrent/naming.js +619 -619
  246. package/services/torrent/piece-store-of.js +29 -29
  247. package/services/torrent/supply-margin.js +264 -264
  248. package/services/torrent/torrent-pool.js +2688 -2688
  249. package/services/torrent/worker/background-fill.js +213 -213
  250. package/services/torrent/worker/channel.js +271 -271
  251. package/services/torrent/worker/client.js +836 -836
  252. package/services/torrent/worker/fastest-wires.js +342 -342
  253. package/services/torrent/worker/handle-state.js +23 -23
  254. package/services/torrent/worker/held-bytes.js +231 -231
  255. package/services/torrent/worker/install-webrtc-shim.js +32 -32
  256. package/services/torrent/worker/piece-reader.js +1078 -1078
  257. package/services/torrent/worker/pool-adapter.js +371 -371
  258. package/services/torrent/worker/protocol.js +184 -184
  259. package/services/torrent/worker/resume-warm.js +107 -107
  260. package/services/torrent/worker/webrtc-shim.js +219 -219
  261. package/services/torrent/worker/worker.js +1137 -1137
  262. package/services/transport/data-channel-handler.js +1406 -1373
  263. package/services/transport/delivery-probe.js +798 -842
  264. package/services/transport/health-collector.js +53 -53
  265. package/services/transport/nat-classifier.js +231 -231
  266. package/services/transport/packet-witness.js +808 -808
  267. package/services/transport/port-mapper.js +235 -235
  268. package/services/transport/registry-api.js +81 -81
  269. package/services/transport/tunnel-client.js +447 -447
  270. package/services/transport/usrsctp-state.js +175 -175
  271. package/services/transport/webrtc-manager.js +484 -484
  272. package/services/viewer/Assignments.js +340 -340
  273. package/services/viewer/PriorityMap.js +421 -421
  274. package/services/viewer/PriorityOrchestrator.js +325 -325
  275. package/services/viewer/Viewer.js +691 -691
  276. package/services/viewer/Viewers.js +368 -368
  277. package/services/viewer/active-output.js +37 -37
  278. package/services/viewer/audio-start.js +46 -46
  279. package/services/viewer/buffer-trend.js +81 -81
  280. package/services/viewer/choices.js +647 -647
  281. package/services/viewer/media-time.js +355 -355
  282. package/services/viewer/playback-readiness.js +635 -635
  283. package/services/viewer/positions.js +126 -126
  284. package/services/viewer/report-intake.js +46 -46
  285. package/store/source-registry.js +71 -71
  286. package/test/asset-paths.test.js +28 -28
  287. package/test/audio-buffer-continuity.test.js +77 -77
  288. package/test/audio-calibration.test.js +23 -23
  289. package/test/audio-inventory.test.js +212 -212
  290. package/test/audio-track-bitrate.test.js +40 -40
  291. package/test/auto-quality-step.test.js +762 -762
  292. package/test/available-share.test.js +76 -76
  293. package/test/background-fill.test.js +190 -190
  294. package/test/client-log-file.test.js +110 -110
  295. package/test/coalesce.test.js +39 -39
  296. package/test/completed-files.test.js +115 -115
  297. package/test/concurrent-cost.test.js +148 -148
  298. package/test/container-media-info.test.js +228 -228
  299. package/test/contention.test.js +76 -76
  300. package/test/core-dumps.test.js +57 -57
  301. package/test/coverage-follows-the-disk.test.js +233 -228
  302. package/test/coverage-map.test.js +195 -195
  303. package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
  304. package/test/cut-times-timeline.test.js +65 -65
  305. package/test/cuts-follow-published-grid.test.js +109 -109
  306. package/test/data-channel-frame.test.js +63 -63
  307. package/test/data-channel-viewer-lifecycle.test.js +162 -92
  308. package/test/dead-torrent-handle.test.js +52 -52
  309. package/test/declared-rates.test.js +116 -116
  310. package/test/declared-tracks.test.js +43 -43
  311. package/test/decode-cost-fit.test.js +202 -202
  312. package/test/decode-cost.test.js +475 -475
  313. package/test/delivery-probe-field.test.js +173 -0
  314. package/test/delivery-probe.test.js +382 -393
  315. package/test/demand-register.test.js +206 -206
  316. package/test/dht-bootstrap.test.js +72 -72
  317. package/test/drain-threat.test.js +65 -65
  318. package/test/duplicate-add.test.js +48 -48
  319. package/test/encode-admission.test.js +146 -146
  320. package/test/encode-cost.test.js +275 -275
  321. package/test/encode-exit.test.js +111 -111
  322. package/test/encode-orchestrator.test.js +426 -426
  323. package/test/encode-plan-viewers.test.js +725 -725
  324. package/test/encode-plan.test.js +545 -545
  325. package/test/encode-run-state.test.js +314 -314
  326. package/test/encode-run.test.js +20 -0
  327. package/test/encode-slope.test.js +46 -46
  328. package/test/encoded-output.test.js +39 -39
  329. package/test/encoder-kinds.test.js +122 -122
  330. package/test/encoder-readings.test.js +68 -68
  331. package/test/episode-naming.test.js +131 -131
  332. package/test/every-claimant-is-told-its-share.test.js +152 -152
  333. package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
  334. package/test/failed-start-breaker.test.js +202 -202
  335. package/test/fastest-wires.test.js +143 -143
  336. package/test/ffmpeg-stream-counts.test.js +78 -78
  337. package/test/file-edges.test.js +191 -191
  338. package/test/fixtures/audio-frame-boundary.json +1 -1
  339. package/test/fmp4-self-contained.test.js +80 -80
  340. package/test/fmp4-timestamps.test.js +140 -140
  341. package/test/fmp4-track-completeness.test.js +41 -41
  342. package/test/format-keyed-outputs.test.js +78 -78
  343. package/test/health-metrics.test.js +36 -38
  344. package/test/held-read-ownership.test.js +77 -77
  345. package/test/held-request-width.test.js +157 -157
  346. package/test/helpers/encode-run.js +248 -240
  347. package/test/helpers/held-file.js +77 -77
  348. package/test/helpers/manager.js +62 -62
  349. package/test/helpers/matroska-file.js +296 -296
  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 +123 -123
  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 +111 -111
  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 +291 -291
  370. package/test/matroska-cluster-discovery.test.js +323 -323
  371. package/test/matroska-cues-track.test.js +192 -192
  372. package/test/media-time.test.js +62 -62
  373. package/test/memory-budget.test.js +390 -390
  374. package/test/move-cost.test.js +216 -216
  375. package/test/mp4-subtitles.test.js +173 -173
  376. package/test/one-authority.test.js +285 -285
  377. package/test/one-budget.test.js +149 -149
  378. package/test/one-output-one-session.test.js +273 -273
  379. package/test/one-resource-divided.test.js +185 -185
  380. package/test/one-serving-output-per-height.test.js +458 -458
  381. package/test/orchestrator-wired.test.js +207 -207
  382. package/test/output-catalog.test.js +39 -39
  383. package/test/output-shape.test.js +21 -21
  384. package/test/output-spec.test.js +251 -251
  385. package/test/output-speed.test.js +92 -87
  386. package/test/outputs-being-prepared.test.js +40 -40
  387. package/test/packet-witness-ring.test.js +236 -236
  388. package/test/packet-witness.test.js +148 -148
  389. package/test/piece-disk-store.test.js +401 -401
  390. package/test/piece-from-whole-file.test.js +129 -129
  391. package/test/piece-lru.test.js +319 -319
  392. package/test/piece-reader.test.js +162 -162
  393. package/test/piece-selection-offset.test.js +85 -85
  394. package/test/piece-store-eviction.test.js +595 -595
  395. package/test/piece-store-never-refuses.test.js +153 -153
  396. package/test/piece-store-reservations.test.js +403 -403
  397. package/test/piece-store-slow-disk.test.js +159 -159
  398. package/test/piece-tail.test.js +97 -97
  399. package/test/plan-host-timings.test.js +68 -68
  400. package/test/plan-is-steady.test.js +225 -225
  401. package/test/playback-readiness.test.js +518 -518
  402. package/test/playback-start-regressions.test.js +101 -101
  403. package/test/priority-map-download.test.js +174 -174
  404. package/test/priority-map-emptied.test.js +204 -204
  405. package/test/priority-map-per-output.test.js +215 -215
  406. package/test/priority-map.test.js +163 -163
  407. package/test/probe-wedge-certainty.test.js +60 -60
  408. package/test/produced-copy-choice.test.js +213 -213
  409. package/test/published-timeline.test.js +83 -83
  410. package/test/quality-offer.test.js +160 -160
  411. package/test/quality-variants.test.js +1274 -1270
  412. package/test/rate-control-in-the-output.test.js +181 -180
  413. package/test/read-survives-withdrawal.test.js +164 -164
  414. package/test/read-window.test.js +365 -365
  415. package/test/reads-not-yet-arrived.test.js +218 -218
  416. package/test/resume-warm.test.js +39 -39
  417. package/test/run-graph-drift.test.js +37 -37
  418. package/test/run-inputs.test.js +80 -80
  419. package/test/run-intervals.test.js +100 -100
  420. package/test/run-position-follows-published-grid.test.js +89 -89
  421. package/test/run-progress.test.js +64 -64
  422. package/test/same-height-switch.test.js +663 -663
  423. package/test/seek-frees-the-viewers-requests.test.js +217 -217
  424. package/test/seek-landing.test.js +109 -109
  425. package/test/seek-target-not-superseded.test.js +195 -195
  426. package/test/segment-closed-on-both-branches.test.js +114 -114
  427. package/test/segment-cut-times.test.js +63 -63
  428. package/test/segment-demand.test.js +80 -80
  429. package/test/segment-hold-supersede.test.js +91 -91
  430. package/test/segment-judged-by-its-own-header.test.js +222 -222
  431. package/test/segment-serve-wiring.test.js +481 -481
  432. package/test/segment-store-eviction.test.js +247 -247
  433. package/test/segment-store.test.js +352 -352
  434. package/test/segment-true-start.test.js +86 -86
  435. package/test/segments-are-shared.test.js +176 -176
  436. package/test/send-chunk-bytes.test.js +86 -86
  437. package/test/serving-existing-output.test.js +111 -111
  438. package/test/serving-output.test.js +102 -102
  439. package/test/shared-piece-store.test.js +310 -310
  440. package/test/sidecar-naming.test.js +142 -142
  441. package/test/soundtrack-rate-bound.test.js +289 -289
  442. package/test/source-file.test.js +133 -133
  443. package/test/source-files-route.test.js +113 -113
  444. package/test/source-registry.test.js +18 -18
  445. package/test/split-a-stretch.test.js +139 -139
  446. package/test/stale-request-after-seek.test.js +158 -158
  447. package/test/startup-readings.test.js +124 -124
  448. package/test/stream-route.test.js +237 -237
  449. package/test/subtitle-cue-framing.test.js +200 -200
  450. package/test/subtitle-cue-source.test.js +102 -102
  451. package/test/subtitle-cue-walk.test.js +385 -385
  452. package/test/subtitle-cursor.test.js +63 -63
  453. package/test/subtitle-defaults.test.js +118 -118
  454. package/test/subtitle-language.test.js +252 -252
  455. package/test/subtitle-metadata.test.js +15 -15
  456. package/test/subtitle-plan-waits.test.js +125 -125
  457. package/test/subtitle-track-numbering.test.js +391 -391
  458. package/test/supply-margin.test.js +190 -190
  459. package/test/swarm-follows-readers.test.js +174 -174
  460. package/test/swarm-reach.test.js +130 -130
  461. package/test/swarm-selection.test.js +220 -220
  462. package/test/tail-duplication.test.js +167 -167
  463. package/test/the-channel-has-both-ends.test.js +91 -91
  464. package/test/the-torrent-thread-serves-bytes.test.js +107 -107
  465. package/test/thread-pool.test.js +53 -53
  466. package/test/timeline.test.js +84 -84
  467. package/test/torrent-contents.test.js +235 -235
  468. package/test/torrent-cost.test.js +71 -71
  469. package/test/torrent-files.test.js +221 -221
  470. package/test/torrent-naming.test.js +255 -255
  471. package/test/tracks-begin-together.test.js +209 -209
  472. package/test/tunnel-renewal.test.js +167 -167
  473. package/test/two-viewers-one-picture.test.js +508 -508
  474. package/test/upload-hurry.test.js +186 -186
  475. package/test/usrsctp-state.test.js +131 -131
  476. package/test/video-facts.test.js +102 -102
  477. package/test/viewer-assignments.test.js +143 -143
  478. package/test/viewer-outputs.test.js +285 -285
  479. package/test/viewer-presence.test.js +116 -116
  480. package/test/viewer-requests-admission.test.js +92 -92
  481. package/test/viewer-starts-on-map.test.js +27 -27
  482. package/test/viewer-states.test.js +192 -189
  483. package/test/viewer-subtitle-subscription.test.js +96 -96
  484. package/test/viewer.test.js +139 -139
  485. package/test/viewing-generations.test.js +390 -390
  486. package/test/visible-rung.test.js +37 -37
  487. package/test/wait-ledger.test.js +100 -100
  488. package/test/webrtc-shim.test.js +149 -149
  489. package/test/wedge-certainty.test.js +131 -131
  490. package/test/whole-file-held-read.test.js +30 -30
  491. package/test/withdraw-piece-claim.test.js +212 -212
  492. package/test/worker-channel.test.js +158 -158
  493. package/utils/client-log-file.js +151 -151
  494. package/utils/coalesce.js +41 -41
  495. package/utils/logger.js +312 -312
  496. package/utils/parse-range.js +25 -25
  497. package/utils/perf.js +59 -59
  498. package/utils/torrent-source-key.js +38 -38
@@ -1,1078 +1,1078 @@
1
- /**
2
- * @file Reading a byte range as positions in shared memory, not as bytes.
3
- *
4
- * The pieces already live in a `SharedArrayBuffer` the main thread can map. So
5
- * the torrent thread does not need to hand over any bytes at all: it can say
6
- * *where* a piece sits and let the other side read it there. What crosses the
7
- * boundary is two numbers per piece.
8
- *
9
- * That is the whole point of the exercise. The alternative — copying each piece
10
- * into memory we own and transferring it — costs 18.84 ms per 10 MB segment on
11
- * the field host, and costs it **on the critical path**, in the thread that is
12
- * also running the torrent, at the moment a viewer is waiting for that segment.
13
- * Here the copy is gone entirely rather than moved.
14
- *
15
- * Two obligations come with it, and both are enforced rather than assumed:
16
- *
17
- * - a piece being read is **pinned**, so eviction cannot take the memory out
18
- * from under the reader mid-read;
19
- * - the pin is released only once the other thread reports it has finished
20
- * with those bytes — not when they were sent, because nothing was sent.
21
- */
22
-
23
- import { pieceStoreOf } from "../piece-store-of.js";
24
- import { bytesOf, Urgency, urgencyName } from "../demand/index.js";
25
- import { demandFor } from "../download/registry.js";
26
- import { logger } from "../../../utils/logger.js";
27
- import {
28
- askFastestWiresFor,
29
- canPlaceRequests,
30
- describePieceTail,
31
- duplicateTailFor
32
- } from "./fastest-wires.js";
33
- import { minimumBufferFrom, requiredSpeedFrom } from "../supply-margin.js";
34
-
35
- /** Only waits at least this long are reported; sequential reading stays silent. */
36
- const PIECE_WAIT_LOG_MS = 1_000;
37
-
38
- /** Distinguishes concurrent readers to the piece store. Never reused. */
39
- let readerSequence = 0;
40
-
41
- /**
42
- * How far ahead of the read head pieces are asked for.
43
- *
44
- * A read is open-ended — ffmpeg opens its input as `bytes <position>-<EOF>` and
45
- * keeps it for the whole film — so taking the requested range literally asks
46
- * for everything from the seek point to the end of the file at once. That is
47
- * what a seek used to do: the swarm was told the entire tail was wanted, went
48
- * at it from its first missing piece, and the one piece the decoder was blocked
49
- * on arrived only when the sequential scan reached it. Measured on a 4.7 GB
50
- * film: a seek to 89.1% took 93 s and pulled 2.47 GB.
51
- *
52
- * So the reader asks for a window and moves it as it goes. The size is a
53
- * compromise the caller cannot yet express: the right unit is seconds of
54
- * playback (duration and size are both known — to the transcode session, not to
55
- * this thread), and 32 MB is about 34 s of a 1080p film but only a few seconds
56
- * of a disc remux. Sizing it from the real byte rate is a follow-up; what
57
- * matters here is that it is bounded and moving rather than "to the end".
58
- */
59
- const READ_WINDOW_BYTES = 32 * 1024 * 1024;
60
-
61
- /**
62
- * The pieces a reader at `pieceIndex` wants next, clamped to its own range.
63
- *
64
- * @param {{ pieceIndex: number, lastPiece: number, windowPieces: number }} params
65
- * @returns {{ from: number, to: number }}
66
- */
67
- export function readWindowFor({ pieceIndex, lastPiece, windowPieces }) {
68
- const span = Math.max(1, windowPieces);
69
- return { from: pieceIndex, to: Math.min(lastPiece, pieceIndex + span - 1) };
70
- }
71
-
72
- /**
73
- * How wide the window should be after a piece that made the reader wait — or
74
- * did not.
75
- *
76
- * The swarm's surplus is what pays for this. Measured 2026-08-17 on the field
77
- * torrent: 5.1-5.9 MB/s delivered against a film consumed at about 1 MB/s, and
78
- * the reader still blocked 47 times in two minutes, median 1.5 s, worst 4.5 s.
79
- * A fivefold surplus never became distance ahead of the head, because the
80
- * window is a fixed number of seconds of playback and everything past it is
81
- * ordinary background fill at no priority.
82
- *
83
- * So the window follows the evidence: every wait that mattered widens it by a
84
- * piece, every piece that was already there narrows it back toward the size the
85
- * caller asked for. Nothing here is chosen — the wait is measured, the
86
- * threshold is the one that already defines "a wait worth recording", and the
87
- * ceiling is this reader's share of the store's memory, so widening can never
88
- * cost more than the store can hold.
89
- *
90
- * @param {{ current: number, base: number, ceiling: number, waitedMs: number, waitThresholdMs: number }} params
91
- * @returns {number}
92
- */
93
- export function nextWindowPieces({ current, base, ceiling, waitedMs, waitThresholdMs }) {
94
- const floor = Math.max(1, Math.floor(base));
95
- const top = Math.max(floor, Math.floor(ceiling));
96
- const now = Math.min(top, Math.max(floor, Math.floor(current)));
97
- if (waitedMs >= waitThresholdMs) {
98
- return Math.min(top, now + 1);
99
- }
100
- return Math.max(floor, now - 1);
101
- }
102
-
103
- /**
104
- * Who is working on the piece a reader is blocked on, right now.
105
- *
106
- * The open question about a seek: a single 8 MiB piece takes 3.0-4.6 s to
107
- * arrive while the swarm as a whole is moving 4-6 MB/s, so roughly 2 MB/s is
108
- * reaching the piece that is actually being waited for. Whether that is because
109
- * few peers hold it, few are being asked, or each is slow cannot be told apart
110
- * from the outside — these three counts tell them apart.
111
- *
112
- * `wire.requests` is what has been asked of that peer and not yet answered; a
113
- * block is 16 KB, so `blocks x 16 KB` is the work in flight on this piece.
114
- *
115
- * @param {import("webtorrent").Torrent} torrent
116
- * @param {number} pieceIndex
117
- * @returns {{ peers: number, holders: number, askedOf: number, blocks: number }}
118
- */
119
- export function pieceSupply(torrent, pieceIndex) {
120
- const wires = Array.isArray(torrent?.wires) ? torrent.wires : [];
121
- let holders = 0;
122
- let askedOf = 0;
123
- let blocks = 0;
124
- for (const wire of wires) {
125
- if (wire?.peerPieces?.get?.(pieceIndex)) {
126
- holders += 1;
127
- }
128
- const requests = Array.isArray(wire?.requests) ? wire.requests : [];
129
- const forThisPiece = requests.filter((request) => request?.piece === pieceIndex).length;
130
- if (forThisPiece > 0) {
131
- askedOf += 1;
132
- blocks += forThisPiece;
133
- }
134
- }
135
- return { peers: wires.length, holders, askedOf, blocks };
136
- }
137
-
138
- /**
139
- * Wait until a piece has been downloaded and verified.
140
- *
141
- * WebTorrent announces this as `verified`. The bitfield is re-checked after the
142
- * listener is attached because the piece can complete in between, and a missed
143
- * event here would wait forever.
144
- *
145
- * @param {import("webtorrent").Torrent} torrent
146
- * @param {number} index
147
- * @param {{ isCancelled: () => boolean, onCancel?: (listener: () => void) => () => void }} cancellation
148
- * @returns {Promise<void>}
149
- */
150
- function whenPieceReady(torrent, index, cancellation) {
151
- if (torrent.bitfield?.get(index)) {
152
- return Promise.resolve();
153
- }
154
-
155
- return new Promise((resolve, reject) => {
156
- /** @param {number} verifiedIndex */
157
- const onVerified = (verifiedIndex) => {
158
- if (verifiedIndex === index) {
159
- cleanup();
160
- resolve();
161
- }
162
- };
163
- const onDestroyed = () => {
164
- cleanup();
165
- reject(new Error(`Torrent went away while waiting for piece ${index}.`));
166
- };
167
- // A superseded seek destroys the read, and without this the wait would
168
- // outlive it and hold a pin. The read's own stream says so the moment it
169
- // happens; it was polled every 250 ms before. A cancellation that cannot
170
- // announce itself is one that is never cancelled (a test's plain object).
171
- if (cancellation.isCancelled()) {
172
- reject(new Error(`Read cancelled while waiting for piece ${index}.`));
173
- return;
174
- }
175
- const stopListening = cancellation.onCancel?.(() => {
176
- cleanup();
177
- reject(new Error(`Read cancelled while waiting for piece ${index}.`));
178
- }) ?? (() => {});
179
-
180
- function cleanup() {
181
- stopListening();
182
- torrent.removeListener("verified", onVerified);
183
- torrent.removeListener("close", onDestroyed);
184
- }
185
-
186
- torrent.on("verified", onVerified);
187
- torrent.once("close", onDestroyed);
188
-
189
- // The piece may have arrived between the check above and this listener.
190
- if (torrent.bitfield?.get(index)) {
191
- cleanup();
192
- resolve();
193
- }
194
- });
195
- }
196
-
197
- /**
198
- * A fragment of a read: where to find it, and how to let it go.
199
- *
200
- * @typedef {object} PieceFragment
201
- * @property {number} pieceIndex
202
- * @property {number} offset - Byte offset into the shared pool.
203
- * @property {number} length
204
- * @property {() => void} release - Drops this fragment's pin. Call exactly once.
205
- */
206
-
207
- /**
208
- * Walk a byte range of a file, yielding each piece's position in shared memory.
209
- *
210
- * Yields at most one fragment per piece; the first and last are usually partial.
211
- * The caller must `release()` every fragment it receives, including on failure —
212
- * an unreleased pin permanently costs a slot.
213
- *
214
- * @param {object} params
215
- * @param {import("webtorrent").Torrent} params.torrent
216
- * @param {number} params.fileIndex
217
- * @param {number} params.start - Inclusive, relative to the file.
218
- * @param {number} params.end - Inclusive, relative to the file.
219
- * @param {{ isCancelled: () => boolean }} params.cancellation
220
- * @param {number} [params.windowBytes] - How far ahead of the read head to ask
221
- * the swarm for. Defaults to {@link READ_WINDOW_BYTES}; a caller that knows
222
- * the media's byte rate should size it in seconds of playback instead.
223
- * @returns {AsyncGenerator<PieceFragment>}
224
- */
225
- /**
226
- * The last interruptions this file's readers met, newest last.
227
- *
228
- * Bounded and per file, because both figures derived from it describe THIS
229
- * file on THIS swarm: a piece is 8 MiB here and 512 KiB elsewhere, and a swarm
230
- * that answers in 200 ms today may not tomorrow. Nothing is stored beyond the
231
- * process — a restart starts from no evidence, which is the honest state.
232
- *
233
- * @type {Map<string, Array<{ waitedMs: number, at: number }>>}
234
- */
235
- const supplyWaits = new Map();
236
-
237
- /** How many interruptions are kept per file. */
238
- const SUPPLY_WAIT_HISTORY = 40;
239
-
240
- /** How often the derived figures are printed, at most. */
241
- const SUPPLY_REPORT_INTERVAL_MS = 30_000;
242
-
243
- /** When each file's figures were last printed. */
244
- const supplyReportedAt = new Map();
245
-
246
- /**
247
- * Record one interruption and, at most twice a minute, say what it implies.
248
- *
249
- * The two figures are the whole of roadmap item 3: the speed a step must
250
- * sustain to survive this supply (`1 / (1 - the share of time lost)`), and the
251
- * smallest buffer that hides an interruption from the viewer. Both are printed
252
- * before either is USED, so the field says whether the arithmetic describes
253
- * reality before anything is decided by it.
254
- *
255
- * @param {string} key - Something stable per file.
256
- * @param {string} label - What to call it in the log.
257
- * @param {number} waitedMs
258
- * @returns {void}
259
- */
260
- /**
261
- * What this file's recent interruptions demand, for a caller that has to decide
262
- * something with them.
263
- *
264
- * Exported because the figures are measured HERE — the reader is the only place
265
- * that knows how long it waited — while the decisions they feed are made
266
- * elsewhere: the smallest buffer that hides an interruption goes to the browser,
267
- * and the speed a step must sustain goes to the quality offer.
268
- *
269
- * @param {string} infoHash
270
- * @param {string} fileName
271
- * @param {number} segmentSeconds - The session's own segment duration.
272
- * @returns {{ requiredSpeed: number, worstWaitSec: number, medianIntervalSec: number, lostShare: number, spanSec: number, lostSec: number, samples: number, minimumBufferSec: number } | null}
273
- */
274
- export function supplyFiguresFor(infoHash, fileName, segmentSeconds) {
275
- const history = supplyWaits.get(`${infoHash ?? "?"}/${fileName ?? "?"}`);
276
- const demand = requiredSpeedFrom(history ?? []);
277
- if (!demand) {
278
- return null;
279
- }
280
- const buffer = minimumBufferFrom({
281
- segmentSeconds,
282
- worstSupplyWaitSec: demand.worstWaitSec
283
- });
284
- return {
285
- requiredSpeed: demand.requiredSpeed,
286
- worstWaitSec: demand.worstWaitSec,
287
- medianIntervalSec: demand.medianIntervalSec,
288
- samples: demand.samples,
289
- minimumBufferSec: buffer ? buffer.seconds : null
290
- };
291
- }
292
-
293
- /**
294
- * Waits split by whether the blocked piece was steered onto another peer.
295
- *
296
- * The steering is visible per wait already — how many peers held the piece, how
297
- * many were asked, what the tail looked like. What was NOT visible is what it
298
- * bought, and that cannot be read off one line: it is the difference between
299
- * the waits where a second peer was asked and the waits where none could be.
300
- * Kept per file, reported with the same summary, so a session says by number
301
- * whether the swap shortens the tail instead of leaving it to impression.
302
- *
303
- * @type {Map<string, { swapped: number[], alone: number[] }>}
304
- */
305
- const waitsBySteering = new Map();
306
-
307
- /**
308
- * Record one wait against the priority level of the requested piece. The level
309
- * identifies which part of the priority map needs more lead time.
310
- *
311
- * @param {string} key
312
- * @param {number} waitedMs
313
- * @param {number} urgency
314
- * @returns {void}
315
- */
316
- function noteWaitLevel(key, waitedMs, urgency) {
317
- let byLevel = waitsByLevel.get(key);
318
- if (!byLevel) {
319
- byLevel = new Map();
320
- waitsByLevel.set(key, byLevel);
321
- }
322
- const waits = byLevel.get(urgency) ?? [];
323
- waits.push(waitedMs);
324
- while (waits.length > SUPPLY_WAIT_HISTORY) {
325
- waits.shift();
326
- }
327
- byLevel.set(urgency, waits);
328
- }
329
-
330
- /**
331
- * Where the waits fell, by level, or null while nothing has waited.
332
- *
333
- * @param {string} key
334
- * @returns {string | null}
335
- */
336
- function describeWaitLevels(key) {
337
- const byLevel = waitsByLevel.get(key);
338
- if (!byLevel || byLevel.size === 0) {
339
- return null;
340
- }
341
- const middle = (values) => {
342
- const sorted = [...values].sort((left, right) => left - right);
343
- return sorted[Math.floor(sorted.length / 2)];
344
- };
345
- return [...byLevel.entries()]
346
- .sort(([left], [right]) => left - right)
347
- .map(([urgency, waits]) =>
348
- `${urgencyName(urgency)} ${waits.length} waits median ${middle(waits)}ms ` +
349
- `worst ${Math.max(...waits)}ms`)
350
- .join(", ");
351
- }
352
-
353
- /**
354
- * Waits split by the level the reader was stopped in.
355
- *
356
- * @type {Map<string, Map<number, number[]>>}
357
- */
358
- const waitsByLevel = new Map();
359
-
360
- function noteSteeringOutcome(key, waitedMs, steered) {
361
- let split = waitsBySteering.get(key);
362
- if (!split) {
363
- split = { swapped: [], alone: [] };
364
- waitsBySteering.set(key, split);
365
- }
366
- const into = steered ? split.swapped : split.alone;
367
- into.push(waitedMs);
368
- while (into.length > SUPPLY_WAIT_HISTORY) {
369
- into.shift();
370
- }
371
- }
372
-
373
- /**
374
- * What the split says, or null while one side of it is still empty — a
375
- * comparison needs both.
376
- *
377
- * @param {string} key
378
- * @returns {string | null}
379
- */
380
- function describeSteering(key) {
381
- const split = waitsBySteering.get(key);
382
- if (!split || split.swapped.length === 0 || split.alone.length === 0) {
383
- return null;
384
- }
385
- const middle = (values) => {
386
- const sorted = [...values].sort((left, right) => left - right);
387
- return sorted[Math.floor(sorted.length / 2)];
388
- };
389
- return (
390
- `steered ${split.swapped.length} waits median ${middle(split.swapped)}ms, ` +
391
- `unsteered ${split.alone.length} waits median ${middle(split.alone)}ms`
392
- );
393
- }
394
-
395
- /**
396
- * How many readers are blocked on a torrent AT THIS MOMENT, by infohash.
397
- *
398
- * Not a history and not an average: the question it answers is "is anything the
399
- * viewer is watching waiting for the swarm right now", and the only honest
400
- * answer is a count of readers currently inside a wait.
401
- *
402
- * It exists so that work which is NOT what the viewer is watching — fetching a
403
- * soundtrack or a subtitle file they may switch to later — can proceed while the
404
- * swarm has room and stand aside the instant it does not. That ordering is the
405
- * whole of the requirement: the picture and the track being played come first,
406
- * the other tracks next, and reading the film far ahead last.
407
- *
408
- * @type {Map<string, number>}
409
- */
410
- const blockedReaders = new Map();
411
-
412
- /**
413
- * How many stalls a torrent's readers have had, ever. Only differences between
414
- * two readings of it mean anything.
415
- *
416
- * @type {Map<string, number>}
417
- */
418
- const stallsSeen = new Map();
419
-
420
- /**
421
- * Whether any reader on this torrent is waiting for a piece right now.
422
- *
423
- * @param {string} infoHash
424
- * @returns {boolean}
425
- */
426
- export function readersAreBlockedOn(infoHash) {
427
- return (blockedReaders.get(infoHash) ?? 0) > 0;
428
- }
429
-
430
- /**
431
- * How many times a reader on this torrent has been blocked since the process
432
- * started.
433
- *
434
- * Exists so that work of lower importance can ask "did the viewer stall while I
435
- * was busy?" — which is a different and stricter question than "is the viewer
436
- * stalled right now". On a swarm delivering exactly what the film needs, a
437
- * background fetch that only pauses DURING a stall still takes bandwidth
438
- * between them, and the stalls are the proof it had none to spare. Field
439
- * 2026-08-31: the swarm delivered 200-600 KB/s against the 399 KB/s the film
440
- * needs, and the picture stood still 145.6 s.
441
- *
442
- * @param {string} infoHash
443
- * @returns {number}
444
- */
445
- export function stallsSeenOn(infoHash) {
446
- return stallsSeen.get(infoHash) ?? 0;
447
- }
448
-
449
- /**
450
- * @param {string} infoHash
451
- * @param {number} delta
452
- * @returns {void}
453
- */
454
- function countBlockedReader(infoHash, delta) {
455
- if (!infoHash) {
456
- return;
457
- }
458
- if (delta > 0) {
459
- stallsSeen.set(infoHash, (stallsSeen.get(infoHash) ?? 0) + 1);
460
- }
461
- const next = (blockedReaders.get(infoHash) ?? 0) + delta;
462
- if (next > 0) {
463
- blockedReaders.set(infoHash, next);
464
- return;
465
- }
466
- blockedReaders.delete(infoHash);
467
- }
468
-
469
- function noteSupplyWait(key, label, waitedMs) {
470
- const history = supplyWaits.get(key) ?? [];
471
- history.push({ waitedMs, at: Date.now() });
472
- while (history.length > SUPPLY_WAIT_HISTORY) {
473
- history.shift();
474
- }
475
- supplyWaits.set(key, history);
476
-
477
- const now = Date.now();
478
- if (now - (supplyReportedAt.get(key) ?? 0) < SUPPLY_REPORT_INTERVAL_MS) {
479
- return;
480
- }
481
- const demand = requiredSpeedFrom(history);
482
- if (!demand) {
483
- return;
484
- }
485
- supplyReportedAt.set(key, now);
486
- const buffer = minimumBufferFrom({
487
- segmentSeconds: SEGMENT_SECONDS_FOR_BUFFER,
488
- worstSupplyWaitSec: demand.worstWaitSec
489
- });
490
- logger.info(
491
- `supply "${label.slice(0, 40)}": a step must run at ${demand.requiredSpeed.toFixed(2)}x ` +
492
- // "Interruption", not "wait": several readers walk one file — the picture
493
- // and each audio rendition — so one missing piece produces one stall and
494
- // several waits. Saying how many of each is what makes the figure readable;
495
- // reporting the waits alone made `2 measured` look like two interruptions
496
- // 3 ms apart, and the demanded speed came out at 4422x.
497
- // THE NUMBERS THE FIGURE IS MADE OF, so a wrong one can be seen to be wrong.
498
- // The share of time lost is what the speed now comes from; the worst stall
499
- // and the typical gap are printed beside it because they are what the
500
- // cushion is sized by and what the old formula divided one by the other.
501
- `to survive this swarm (lost ${demand.lostSec.toFixed(2)}s of ${demand.spanSec.toFixed(2)}s ` +
502
- `= ${(demand.lostShare * 100).toFixed(1)}%, worst stall ${demand.worstWaitSec.toFixed(2)}s, ` +
503
- `one every ${demand.medianIntervalSec.toFixed(2)}s of running, ${demand.samples} stall(s) ` +
504
- `from ${demand.waits} wait(s)) — ` +
505
- `and the smallest buffer that hides it is ${buffer ? buffer.seconds.toFixed(1) : "?"}s` +
506
- // What steering the blocked piece onto another peer bought, as the
507
- // difference between the waits where it placed something and the waits
508
- // where it could not. Absent until both sides have a sample, because a
509
- // comparison with one side empty is not a comparison.
510
- (describeSteering(key) ? ` — ${describeSteering(key)}` : "") +
511
- // The comparison this release exists to make. Read it first.
512
- (describeWaitLevels(key) ? ` — ${describeWaitLevels(key)}` : "")
513
- );
514
- }
515
-
516
- /**
517
- * The segment length the buffer figure is stated against. The reader does not
518
- * know the session's own, and this is a REPORT rather than a decision — the
519
- * decision, when it is made, will use the session's real one.
520
- */
521
- const SEGMENT_SECONDS_FOR_BUFFER = 4;
522
-
523
- export async function* readFragments({
524
- torrent,
525
- fileIndex,
526
- start,
527
- end,
528
- cancellation,
529
- windowBytes = READ_WINDOW_BYTES
530
- }) {
531
- const store = pieceStoreOf(torrent);
532
- if (!store) {
533
- throw new Error("This torrent is not backed by a shared piece store.");
534
- }
535
- // What this reader wants goes here and nowhere else. `SwarmSelection` is the
536
- // only thing that turns any of it into a request to the swarm, so a reader
537
- // can no longer contradict the background fill or the pool.
538
- const { register, selection } = demandFor(torrent);
539
-
540
- const file = torrent.files?.[fileIndex];
541
- if (!file) {
542
- throw new Error(`File ${fileIndex} not found.`);
543
- }
544
-
545
- const pieceLength = torrent.pieceLength;
546
- // Piece numbers are torrent-wide, so a file's own offsets have to be lifted
547
- // into the torrent's address space first.
548
- const absoluteStart = file.offset + start;
549
- const absoluteEnd = file.offset + end;
550
- const firstPiece = Math.floor(absoluteStart / pieceLength);
551
- const lastPiece = Math.floor(absoluteEnd / pieceLength);
552
-
553
- // This reader owns what it asks for, and gives it back when it is done. The
554
- // window is a STREAM selection: those are removed by exact bounds and several
555
- // identical ones coexist — WebTorrent's own source calls that "in a way a
556
- // count" — so N readers on one torrent produce the union of their windows,
557
- // and each one leaving takes away only its own. That is what makes several
558
- // parallel readers (the codec probe's head and tail, subtitles, one input per
559
- // viewer) cooperate instead of overwrite each other.
560
- //
561
- // The previous code selected the whole requested range, marked all of it
562
- // critical, and never deselected anything — so ffmpeg's opening
563
- // `bytes 0-<EOF>` left a permanent selection over the entire file, and no
564
- // later prioritisation could outrank it.
565
- const basePieces = Math.max(1, Math.ceil(Math.max(1, windowBytes) / pieceLength));
566
- // What the window is RIGHT NOW. It starts at what the caller sized in seconds
567
- // of playback and grows while the reader keeps being made to wait — see
568
- // `nextWindowPieces`.
569
- let windowPieces = basePieces;
570
- /**
571
- * The widest this reader may go: its share of what the store can hold in
572
- * memory. Measured rather than chosen — the capacity is the store's own, and
573
- * the number of readers is how many windows are declared on it right now.
574
- *
575
- * @returns {number}
576
- */
577
- const ceilingPieces = () => {
578
- const capacity = Number(store?.capacity);
579
- if (!Number.isFinite(capacity) || capacity <= 0) {
580
- return basePieces;
581
- }
582
- const readers = Math.max(1, store.protectedRanges?.().length ?? 1);
583
- return Math.max(basePieces, Math.floor(capacity / readers));
584
- };
585
- /** @type {{ from: number, to: number } | null} */
586
- let window = null;
587
- /** @type {{ from: number, to: number } | null} */
588
- let blockedStated = false;
589
- /**
590
- * Drops the pin of the fragment currently in the consumer's hands, if it
591
- * still holds one. See where it is assigned.
592
- *
593
- * @type {(() => void) | null}
594
- */
595
- let releaseHeldPin = null;
596
-
597
- // Identity of this read, so the store can tell one reader's window from
598
- // another's. Each read gets its own; `readerSequence` never repeats within a
599
- // process.
600
- const readerId = `read-${(readerSequence += 1)}`;
601
-
602
- /**
603
- * Set when the window JUMPS, cleared by the first wait after it.
604
- *
605
- * The wait that follows a jump is the cost of the jump: the pieces at the new
606
- * position have not been asked for yet, and the encoder is restarting. It is
607
- * not evidence about how well this swarm SUSTAINS a read, which is the only
608
- * thing `requiredSpeed` is about — and letting it in is what collapsed the
609
- * quality offer 131 ms after the seek measured on 2026-08-18, refusing every
610
- * re-encoded rung on the strength of one jump.
611
- *
612
- * @type {boolean}
613
- */
614
- let waitBelongsToJump = false;
615
-
616
- /** What the consumer has taken from this read, in bytes. */
617
- let deliveredBytes = 0;
618
- // How often this read had to stop, and for how long in total. Reported at the
619
- // end whatever the outcome, so a read that never stopped is counted too.
620
- let waitCount = 0;
621
- let waitedTotalMs = 0;
622
- const readStartedAt = Date.now();
623
-
624
- /**
625
- * Where the reader's claim starts: the first piece it does not already have. Everything between the read position and that piece
626
- * is on disk or in memory, so claiming it asks the swarm for what we hold.
627
- *
628
- * @param {number} pieceIndex
629
- * @returns {number}
630
- */
631
- const firstMissingFrom = (pieceIndex) => {
632
- for (let index = pieceIndex; index <= lastPiece; index += 1) {
633
- if (!torrent.bitfield?.get?.(index)) {
634
- return index;
635
- }
636
- }
637
- return pieceIndex;
638
- };
639
-
640
-
641
- /**
642
- * State one band as a need, in bytes.
643
- *
644
- * The urgency is part of the claimant key, so this blocked-read claim can be
645
- * withdrawn without changing the priority map's claims for the same file.
646
- *
647
- * @param {{ from: number, to: number, urgency: number }} band
648
- * @returns {void}
649
- */
650
- const stateBand = (band) => {
651
- const range = bytesOf({
652
- fileOffset: Number(file.offset),
653
- fileLength: Number(file.length),
654
- from: band.from,
655
- to: band.to,
656
- pieceLength
657
- });
658
- if (!range) {
659
- return;
660
- }
661
- register.state({
662
- claimant: `${readerId}:${urgencyName(band.urgency)}`,
663
- fileIndex,
664
- byteStart: range.byteStart,
665
- byteEnd: range.byteEnd,
666
- urgency: band.urgency
667
- });
668
- };
669
-
670
- /**
671
- * Move the window the reader is working through.
672
- *
673
- * It states NOTHING to the swarm. What should be downloaded ahead of a viewer
674
- * is the priority map's answer, stated once for the whole file by the side
675
- * that knows where the viewers are; a read is consumption, not a forecast.
676
- *
677
- * A read used to declare a rolling window of its own — the piece it wanted
678
- * and two bands beyond it — and with fifteen reads on one file that was
679
- * fifteen windows on a piece store holding sixteen pieces. Half of all
680
- * evictions then took a piece a reader had declared, two thirds of reads came
681
- * back from disk, and the bytes handed out stopped being the file's: twenty-
682
- * two source-parse errors, a segment the player refused, and an empty picture
683
- * for six minutes (field 2026-09-05).
684
- *
685
- * What the window is still for: the pieces to mark critical when the reader
686
- * is actually stopped, and the range to bring back from disk after a jump.
687
- *
688
- * @param {number} pieceIndex
689
- * @returns {void}
690
- */
691
- /**
692
- * Where the priority map puts one piece, in the map's own levels.
693
- *
694
- * A stated window is file-relative — `bytesOf` subtracts the file's offset
695
- * within the torrent — so the piece is put in that frame before it is looked
696
- * up, or every answer belongs to some other part of the torrent.
697
- *
698
- * @param {number} pieceIndex
699
- * @returns {number | null}
700
- */
701
- const mapLevelOf = (pieceIndex) =>
702
- register.urgencyAt(fileIndex, pieceIndex * pieceLength - Number(file.offset));
703
-
704
- const moveWindowTo = (pieceIndex) => {
705
- const anchor = firstMissingFrom(pieceIndex);
706
- const next = readWindowFor({ pieceIndex: anchor, lastPiece, windowPieces });
707
- if (window && window.from === next.from && window.to === next.to) {
708
- return;
709
- }
710
- const isJump = !window || next.from > window.to || next.from < window.from;
711
- window = next;
712
- if (isJump) {
713
- waitBelongsToJump = true;
714
- // A jump — a seek, not the window sliding along — can land on pieces that
715
- // are already downloaded but have been spilled to disk. Bring the whole
716
- // window back at once instead of one disk round trip per piece as the
717
- // reader reaches them.
718
- const revived = store.warmRange?.(next.from, next.to) ?? 0;
719
- if (revived > 0) {
720
- logger.info(
721
- `piece-reader: reviving ${revived} spilled piece(s) of ${next.from}-${next.to} ` +
722
- `for a jump to ${(start / 1024 / 1024).toFixed(0)}MB of "${file.name}"`
723
- );
724
- }
725
- }
726
- };
727
-
728
- // How many times THIS piece has been asked for again after the store
729
- // withdrew it. Reset once a piece is in hand, so the allowance is per piece.
730
- let withdrawnRetries = 0;
731
-
732
- try {
733
- for (let pieceIndex = firstPiece; pieceIndex <= lastPiece; pieceIndex += 1) {
734
- if (cancellation.isCancelled()) {
735
- return;
736
- }
737
-
738
- const pieceStart = pieceIndex * pieceLength;
739
- const fromWithinPiece = Math.max(absoluteStart, pieceStart) - pieceStart;
740
- const toWithinPiece = Math.min(absoluteEnd, pieceStart + pieceLength - 1) - pieceStart;
741
-
742
- moveWindowTo(pieceIndex);
743
-
744
- if (!torrent.bitfield?.get(pieceIndex)) {
745
- // Everything from here to the end of the window is wanted NOW, so all
746
- // of it is marked, not just the piece under the head. `critical`
747
- // enables hotswap: a block reserved by a slow peer is re-requested from
748
- // a faster one instead of holding up the reader. Measured 2026-08-04
749
- // with only the blocked piece marked, the first segment after a seek
750
- // took 7.2 s while its four 4 MB pieces arrived one after another at
751
- // ~2.2 MB/s, with waits of 1.3 s and 2.8 s on single pieces.
752
- //
753
- // This is not the old behaviour returning: that marked the whole
754
- // REQUESTED RANGE, which for ffmpeg's input means every piece to the
755
- // end of the file — hundreds of them, at which point the flag says
756
- // nothing. A window is what a reader genuinely needs next.
757
- // Stated as its own need at the level that is being waited on, which
758
- // is what carries the permission to take a block from a slow peer.
759
- // Nothing here calls the library: `reconcile` reads what is stated.
760
- stateBand({ from: pieceIndex, to: window.to, urgency: Urgency.BLOCKED });
761
- blockedStated = true;
762
- selection.reconcile();
763
- }
764
-
765
- const waitStartedAt = Date.now();
766
- // What the WHOLE torrent received while this one piece was missing. It is
767
- // the reading that separates the two causes a wait can have, and neither
768
- // could be told from the other before: bytes arriving briskly throughout
769
- // mean the swarm had capacity and this piece was stuck behind the wire
770
- // that reserved it — the blocked-piece tail; bytes barely moving mean
771
- // there was nothing to be had, and no reordering of requests would have
772
- // helped. Asked of 2026-08-31, when a session with 4-5 peers of 38 known
773
- // waited 41.32 s at worst and the log could not say which it was.
774
- const downloadedAtWaitStart = Number(torrent?.downloaded) || 0;
775
- // The reader is blocked, so this piece is now the only thing that matters
776
- // on this torrent: hand it to the fastest wires that hold it. A block is
777
- // reserved for exactly one wire, and the read ends when the slowest
778
- // holder delivers — measured 2026-08-17, the swarm had a fivefold surplus
779
- // of bandwidth and the reader still waited 1.0-4.5 s, 47 times in two
780
- // minutes, on pieces five peers already had.
781
- let pushed = { asked: 0, attempted: 0, considered: 0, fastestBytesPerSecond: 0 };
782
- // The tail as it stood at an attempt that placed NOTHING — the state the
783
- // duplication work has to answer, and the only one worth a line. Sampled
784
- // at that instant rather than once up front, because the steering runs
785
- // again every half second and the piece changes under it; the last such
786
- // reading is kept, so the line describes the most recent failure.
787
- let tailWhenNothingPlaced = null;
788
- // What duplicating the tail placed, summed over the wait. Measured
789
- // 2026-08-19 on a real swarm: the blocks a reader waits on are 2-14 of
790
- // 512 and sit on wires the library considers fast, so its own hotswap
791
- // never fires for them — see `duplicateTailFor`.
792
- let duplicated = 0;
793
- const pushToFastest = () => {
794
- try {
795
- const result = askFastestWiresFor(torrent, pieceIndex);
796
- if (result.asked === 0) {
797
- tailWhenNothingPlaced = describePieceTail(torrent, pieceIndex);
798
- }
799
- // Every attempt, not only the ones where nothing else could be placed.
800
- // The ordinary steering asks for whatever blocks are still free; the
801
- // read, meanwhile, ends when the LAST block arrives, and that block is
802
- // reserved to one wire whether or not other blocks could be asked for.
803
- // Field 2026-09-03: 46.3 s on one piece, ordinary requests placed on
804
- // 54 of 87 attempts throughout, and a tail of 3 blocks of 512 held by
805
- // wires at 51-99 KB/s to the end — while duplication, which ran only
806
- // on the 33 attempts that placed nothing, managed 5 blocks in the
807
- // whole wait. `duplicateTailFor` bounds itself by the tail's length,
808
- // so a piece that is merely still arriving is left alone.
809
- duplicated += duplicateTailFor(torrent, pieceIndex).duplicated;
810
- pushed = {
811
- asked: pushed.asked + result.asked,
812
- refusedWhileReserved:
813
- (pushed.refusedWhileReserved ?? 0) + (result.refusedWhileReserved ?? 0),
814
- // Summed like the successes, so the line compares two totals over
815
- // the same attempts instead of a total against a snapshot.
816
- attempted: (pushed.attempted ?? 0) + result.attempted,
817
- considered: result.considered,
818
- fastestBytesPerSecond: result.fastestBytesPerSecond
819
- };
820
- } catch (error) {
821
- // The entry is internal to the library; if a version changes it, this
822
- // lever stops working and that must be visible rather than silent.
823
- logger.warn(`piece-reader: could not steer piece ${pieceIndex} — ${error?.message ?? error}`);
824
- }
825
- };
826
- if (canPlaceRequests(torrent)) {
827
- pushToFastest();
828
- } else {
829
- // Nothing can be placed at all on this build, so the tail is the whole
830
- // of the answer.
831
- tailWhenNothingPlaced = describePieceTail(torrent, pieceIndex);
832
- logger.warn(
833
- "piece-reader: this webtorrent build offers no way to place a request; " +
834
- "the blocked piece cannot be steered onto a faster peer"
835
- );
836
- }
837
- // Sampled while waiting rather than after: once the piece lands, nothing
838
- // is outstanding on it any more and every count reads zero.
839
- let supply = null;
840
- const supplyProbe = setInterval(() => {
841
- const sample = pieceSupply(torrent, pieceIndex);
842
- if (!supply || sample.blocks > supply.blocks) {
843
- supply = sample;
844
- }
845
- // Wires come and go, and their speeds change: a holder that was slow a
846
- // moment ago may now be the fastest one available.
847
- pushToFastest();
848
- }, 500);
849
- // Counted for exactly as long as this reader is inside the wait, so that
850
- // background work can stand aside while the viewer's own reading is
851
- // starving. The `finally` is what makes it safe: a cancelled or failed
852
- // read must not leave the torrent looking permanently blocked, which
853
- // would stop that background work for the rest of the session.
854
- countBlockedReader(torrent?.infoHash, 1);
855
- try {
856
- await whenPieceReady(torrent, pieceIndex, cancellation);
857
- } finally {
858
- countBlockedReader(torrent?.infoHash, -1);
859
- clearInterval(supplyProbe);
860
- }
861
- // What a reader spent waiting for data, attributed to the exact piece. A
862
- // seek's cost is dominated by the first segment after the encoder
863
- // restarts (measured 9.2-9.4 s), and without this there is no way to say
864
- // whether that is the swarm, the picker, or ffmpeg. Logged only when the
865
- // wait is long enough to matter, so ordinary sequential reading is silent.
866
- const waitedMs = Date.now() - waitStartedAt;
867
- // Where the map puts this piece, read once and used by both the
868
- // attribution below and the line beside it.
869
- const wantedBy = mapLevelOf(pieceIndex);
870
- // The window answers to what just happened: a wait means the lead was too
871
- // short, an immediate hit means it is longer than it needs to be. Applied
872
- // before the logging below so the line reports the window the next piece
873
- // will actually use.
874
- if (waitBelongsToJump) {
875
- // Recorded nowhere: see `waitBelongsToJump`. Said out loud, because a
876
- // gap in the supply history is otherwise indistinguishable from a swarm
877
- // that never made the reader wait.
878
- logger.info(
879
- `piece-reader: ${waitedMs}ms on the first piece after a jump — the cost of moving, ` +
880
- `not of this swarm's supply, so it is not counted against the quality offer`
881
- );
882
- waitBelongsToJump = false;
883
- } else {
884
- const supplyKey = `${torrent?.infoHash ?? "?"}/${file?.name ?? "?"}`;
885
- noteSteeringOutcome(supplyKey, waitedMs, pushed.asked > 0 || duplicated > 0);
886
- // Which level of the priority map the reader was stopped in. A wait
887
- // belongs to a level, and the level says whether that zone is asked for
888
- // too late — the reader itself no longer has an opinion about it.
889
- noteWaitLevel(
890
- supplyKey,
891
- waitedMs,
892
- wantedBy ?? Urgency.BLOCKED
893
- );
894
- waitCount += 1;
895
- waitedTotalMs += waitedMs;
896
- noteSupplyWait(supplyKey, file?.name ?? "", waitedMs);
897
- }
898
- const widened = nextWindowPieces({
899
- current: windowPieces,
900
- base: basePieces,
901
- ceiling: ceilingPieces(),
902
- waitedMs,
903
- waitThresholdMs: PIECE_WAIT_LOG_MS
904
- });
905
- if (widened !== windowPieces) {
906
- windowPieces = widened;
907
- }
908
- if (waitedMs >= PIECE_WAIT_LOG_MS) {
909
- const rateKbps = Math.round(pieceLength / 1024 / (waitedMs / 1000));
910
- // Two rates, side by side, and no verdict word between them: the swarm's
911
- // own delivery during the wait against what this piece managed. Both are
912
- // measured; which one a reader calls "the cause" follows from the pair
913
- // without a threshold having to be chosen here. Far apart means the
914
- // bytes were flowing and this piece was not among them; close together,
915
- // or both near zero, means there was nothing to deliver.
916
- const swarmBytes = Math.max(0, (Number(torrent?.downloaded) || 0) - downloadedAtWaitStart);
917
- const swarmKbps = Math.round(swarmBytes / 1024 / (waitedMs / 1000));
918
- logger.info(
919
- `piece-reader: waited ${waitedMs}ms for piece ${pieceIndex} ` +
920
- `(${pieceIndex - firstPiece + 1} of ${lastPiece - firstPiece + 1} in a read from ` +
921
- `${(start / 1024 / 1024).toFixed(0)}MB of "${file.name}") ` +
922
- `— ${rateKbps}KB/s on this piece while the swarm delivered ` +
923
- `${swarmKbps}KB/s (${(swarmBytes / 1024 / 1024).toFixed(1)}MB) across the torrent, ` +
924
- `${Number(torrent?.numPeers) || 0} peers connected; ` +
925
- (supply
926
- ? `${supply.holders}/${supply.peers} peers had it, ${supply.askedOf} were asked, ` +
927
- `${supply.blocks} blocks (${Math.round((supply.blocks * 16384) / 1024)}KB) in flight at peak`
928
- : "no sample taken") +
929
- // What WE did about it, so the next session says whether steering
930
- // the piece onto faster holders shortens the tail — by number
931
- // rather than by impression.
932
- `; steered onto ${pushed.asked} of ${pushed.attempted} asks (${pushed.considered} peers held it)` +
933
- // How often a fast peer we picked was refused because every block
934
- // was already spoken for and the library declined to take one from
935
- // a slow holder. Its thresholds are constants, not settings: the
936
- // asker must be above 16 KB/s, the holder below 48 KB/s and twice
937
- // as slow. A number here is what would justify replacing that rule;
938
- // a zero says the thresholds are not what we are short of.
939
- ((pushed.refusedWhileReserved ?? 0) > 0
940
- ? `; ${pushed.refusedWhileReserved} refused with every block reserved`
941
- : "") +
942
- (pushed.fastestBytesPerSecond > 0
943
- ? `, fastest ${Math.round(pushed.fastestBytesPerSecond / 1024)}KB/s`
944
- : "") +
945
- // Only when the steering placed nothing, which is the case that
946
- // decides whether duplicating the tail is worth building: it says
947
- // how much of the piece is still missing and which wires are
948
- // holding it, slowest first.
949
- (tailWhenNothingPlaced
950
- ? `; tail ${tailWhenNothingPlaced.missing}/${tailWhenNothingPlaced.chunks} blocks missing, held by ` +
951
- (tailWhenNothingPlaced.outstanding.length > 0
952
- ? tailWhenNothingPlaced.outstanding
953
- .map((wire) => `${wire.blocks}@${Math.round(wire.bytesPerSecond / 1024)}KB/s` +
954
- (wire.choking ? " (choking)" : ""))
955
- .join(" ")
956
- : "nobody")
957
- : "") +
958
- // What we did about the tail, so the next session says by number
959
- // whether a second copy of those blocks shortens the wait.
960
- (duplicated > 0 ? `; duplicated ${duplicated} blocks` : "") +
961
- // Where the priority map puts this piece. A wait at the level the
962
- // viewer is about to reach is a different fault from a wait on the
963
- // speculative tail, and the reader states nothing of its own that
964
- // could be read instead.
965
- `; the map wants it ${wantedBy === null
966
- ? "nowhere — nobody asked for this piece"
967
- : urgencyName(wantedBy)}`
968
- );
969
- }
970
-
971
- // Pinned BEFORE it is located, and before any await that could let an
972
- // eviction run: the offset is only meaningful while the piece is held.
973
- store.pin(pieceIndex);
974
- let located = null;
975
- try {
976
- located = await store.reside(pieceIndex);
977
- } catch (error) {
978
- store.unpin(pieceIndex);
979
- throw error;
980
- }
981
-
982
- if (!located) {
983
- store.unpin(pieceIndex);
984
- // THE CLAIM HAS JUST BEEN WITHDRAWN, so this is a wait and not a
985
- // failure. The store drops a piece behind every read head, and a read
986
- // that re-opens an input at byte 0 — which is every encoder restart —
987
- // asks for exactly those pieces. Until 2026-09-12 this threw, ffmpeg
988
- // read the empty body as the end of the file, and the encoder died and
989
- // was restarted into the same emptiness: field, `Piece 0 is verified
990
- // but absent from the store` answered every read for 92 minutes while
991
- // the viewer looked at a still picture.
992
- //
993
- // Going back one step re-runs this piece's whole path rather than a
994
- // shortened copy of it: the bitfield now says the piece is missing, so
995
- // the block above declares it, steers it onto the fastest holders and
996
- // waits for it, exactly as it does for a piece that was never here.
997
- // Once per piece — a second emptiness means the bytes are not coming
998
- // and the caller must hear so.
999
- if (withdrawnRetries === 0) {
1000
- withdrawnRetries += 1;
1001
- pieceIndex -= 1;
1002
- continue;
1003
- }
1004
- throw new Error(
1005
- `Piece ${pieceIndex} was withdrawn from the store and did not come back.`
1006
- );
1007
- }
1008
- // Counted per piece: a long read may legitimately meet this more than
1009
- // once, and each piece is entitled to its own second chance.
1010
- withdrawnRetries = 0;
1011
-
1012
- let releasedThisPiece = false;
1013
- // Remembered so the generator can drop it itself. The pin is taken here
1014
- // and the consumer is expected to release it — but a consumer that
1015
- // ABANDONS the iterator never gets the chance, and a seek abandons it
1016
- // every time: the encoder is killed, the response is torn down, and the
1017
- // loop is left between two fragments. Field 2026-08-06: after one seek
1018
- // every slot in the store was pinned, the store answered
1019
- // `Every resident piece is pinned; no slot can be freed` — to the
1020
- // WebTorrent client, which closed the store and destroyed the torrent —
1021
- // and the session died with `File 0 not found`.
1022
- releaseHeldPin = () => {
1023
- if (!releasedThisPiece) {
1024
- releasedThisPiece = true;
1025
- store.unpin(pieceIndex);
1026
- }
1027
- };
1028
- // What the consumer takes, counted as it is handed over: for a viewer this
1029
- // is the film's own byte rate, which is what the band widths are derived
1030
- // against.
1031
- deliveredBytes += toWithinPiece - fromWithinPiece + 1;
1032
- yield {
1033
- pieceIndex,
1034
- buffer: located.buffer,
1035
- offset: located.offset + fromWithinPiece,
1036
- length: toWithinPiece - fromWithinPiece + 1,
1037
- release() {
1038
- if (releasedThisPiece) {
1039
- return;
1040
- }
1041
- releasedThisPiece = true;
1042
- store.unpin(pieceIndex);
1043
- }
1044
- };
1045
- // Handed back, and released by the consumer or not at all — either way
1046
- // this reader no longer owes anything for it.
1047
- releaseHeldPin = null;
1048
- }
1049
- } finally {
1050
- // A fragment handed out and never released is a slot lost for the life of
1051
- // the process. Reached on every exit, including the consumer walking away.
1052
- if (releaseHeldPin) {
1053
- releaseHeldPin();
1054
- releaseHeldPin = null;
1055
- }
1056
- // What this read did, said once at its end and under EVERY outcome: how
1057
- // much it delivered, and how much of that time it spent stopped. Said even
1058
- // when it never stopped, because "no wait" is the result worth counting and
1059
- // a line printed only beside a wait cannot report it.
1060
- const readSeconds = (Date.now() - readStartedAt) / 1000;
1061
- if (deliveredBytes > 0) {
1062
- logger.info(
1063
- `read "${String(file?.name ?? "?").slice(0, 40)}" ` +
1064
- `delivered=${(deliveredBytes / 1e6).toFixed(1)}MB in ${readSeconds.toFixed(1)}s ` +
1065
- `waits=${waitCount} waited=${(waitedTotalMs / 1000).toFixed(1)}s`
1066
- );
1067
- }
1068
- // Reached on completion, on cancellation, on a throw, and when the consumer
1069
- // stops iterating — a claim left behind would keep the swarm fetching for a
1070
- // reader that no longer exists.
1071
- if (blockedStated) {
1072
- register.withdraw(`${readerId}:${urgencyName(Urgency.BLOCKED)}`);
1073
- }
1074
- // Once, after everything has been withdrawn — and it releases this reader's
1075
- // hold on memory as well, because both views come from the same statement.
1076
- selection.reconcile();
1077
- }
1078
- }
1
+ /**
2
+ * @file Reading a byte range as positions in shared memory, not as bytes.
3
+ *
4
+ * The pieces already live in a `SharedArrayBuffer` the main thread can map. So
5
+ * the torrent thread does not need to hand over any bytes at all: it can say
6
+ * *where* a piece sits and let the other side read it there. What crosses the
7
+ * boundary is two numbers per piece.
8
+ *
9
+ * That is the whole point of the exercise. The alternative — copying each piece
10
+ * into memory we own and transferring it — costs 18.84 ms per 10 MB segment on
11
+ * the field host, and costs it **on the critical path**, in the thread that is
12
+ * also running the torrent, at the moment a viewer is waiting for that segment.
13
+ * Here the copy is gone entirely rather than moved.
14
+ *
15
+ * Two obligations come with it, and both are enforced rather than assumed:
16
+ *
17
+ * - a piece being read is **pinned**, so eviction cannot take the memory out
18
+ * from under the reader mid-read;
19
+ * - the pin is released only once the other thread reports it has finished
20
+ * with those bytes — not when they were sent, because nothing was sent.
21
+ */
22
+
23
+ import { pieceStoreOf } from "../piece-store-of.js";
24
+ import { bytesOf, Urgency, urgencyName } from "../demand/index.js";
25
+ import { demandFor } from "../download/registry.js";
26
+ import { logger } from "../../../utils/logger.js";
27
+ import {
28
+ askFastestWiresFor,
29
+ canPlaceRequests,
30
+ describePieceTail,
31
+ duplicateTailFor
32
+ } from "./fastest-wires.js";
33
+ import { minimumBufferFrom, requiredSpeedFrom } from "../supply-margin.js";
34
+
35
+ /** Only waits at least this long are reported; sequential reading stays silent. */
36
+ const PIECE_WAIT_LOG_MS = 1_000;
37
+
38
+ /** Distinguishes concurrent readers to the piece store. Never reused. */
39
+ let readerSequence = 0;
40
+
41
+ /**
42
+ * How far ahead of the read head pieces are asked for.
43
+ *
44
+ * A read is open-ended — ffmpeg opens its input as `bytes <position>-<EOF>` and
45
+ * keeps it for the whole film — so taking the requested range literally asks
46
+ * for everything from the seek point to the end of the file at once. That is
47
+ * what a seek used to do: the swarm was told the entire tail was wanted, went
48
+ * at it from its first missing piece, and the one piece the decoder was blocked
49
+ * on arrived only when the sequential scan reached it. Measured on a 4.7 GB
50
+ * film: a seek to 89.1% took 93 s and pulled 2.47 GB.
51
+ *
52
+ * So the reader asks for a window and moves it as it goes. The size is a
53
+ * compromise the caller cannot yet express: the right unit is seconds of
54
+ * playback (duration and size are both known — to the transcode session, not to
55
+ * this thread), and 32 MB is about 34 s of a 1080p film but only a few seconds
56
+ * of a disc remux. Sizing it from the real byte rate is a follow-up; what
57
+ * matters here is that it is bounded and moving rather than "to the end".
58
+ */
59
+ const READ_WINDOW_BYTES = 32 * 1024 * 1024;
60
+
61
+ /**
62
+ * The pieces a reader at `pieceIndex` wants next, clamped to its own range.
63
+ *
64
+ * @param {{ pieceIndex: number, lastPiece: number, windowPieces: number }} params
65
+ * @returns {{ from: number, to: number }}
66
+ */
67
+ export function readWindowFor({ pieceIndex, lastPiece, windowPieces }) {
68
+ const span = Math.max(1, windowPieces);
69
+ return { from: pieceIndex, to: Math.min(lastPiece, pieceIndex + span - 1) };
70
+ }
71
+
72
+ /**
73
+ * How wide the window should be after a piece that made the reader wait — or
74
+ * did not.
75
+ *
76
+ * The swarm's surplus is what pays for this. Measured 2026-08-17 on the field
77
+ * torrent: 5.1-5.9 MB/s delivered against a film consumed at about 1 MB/s, and
78
+ * the reader still blocked 47 times in two minutes, median 1.5 s, worst 4.5 s.
79
+ * A fivefold surplus never became distance ahead of the head, because the
80
+ * window is a fixed number of seconds of playback and everything past it is
81
+ * ordinary background fill at no priority.
82
+ *
83
+ * So the window follows the evidence: every wait that mattered widens it by a
84
+ * piece, every piece that was already there narrows it back toward the size the
85
+ * caller asked for. Nothing here is chosen — the wait is measured, the
86
+ * threshold is the one that already defines "a wait worth recording", and the
87
+ * ceiling is this reader's share of the store's memory, so widening can never
88
+ * cost more than the store can hold.
89
+ *
90
+ * @param {{ current: number, base: number, ceiling: number, waitedMs: number, waitThresholdMs: number }} params
91
+ * @returns {number}
92
+ */
93
+ export function nextWindowPieces({ current, base, ceiling, waitedMs, waitThresholdMs }) {
94
+ const floor = Math.max(1, Math.floor(base));
95
+ const top = Math.max(floor, Math.floor(ceiling));
96
+ const now = Math.min(top, Math.max(floor, Math.floor(current)));
97
+ if (waitedMs >= waitThresholdMs) {
98
+ return Math.min(top, now + 1);
99
+ }
100
+ return Math.max(floor, now - 1);
101
+ }
102
+
103
+ /**
104
+ * Who is working on the piece a reader is blocked on, right now.
105
+ *
106
+ * The open question about a seek: a single 8 MiB piece takes 3.0-4.6 s to
107
+ * arrive while the swarm as a whole is moving 4-6 MB/s, so roughly 2 MB/s is
108
+ * reaching the piece that is actually being waited for. Whether that is because
109
+ * few peers hold it, few are being asked, or each is slow cannot be told apart
110
+ * from the outside — these three counts tell them apart.
111
+ *
112
+ * `wire.requests` is what has been asked of that peer and not yet answered; a
113
+ * block is 16 KB, so `blocks x 16 KB` is the work in flight on this piece.
114
+ *
115
+ * @param {import("webtorrent").Torrent} torrent
116
+ * @param {number} pieceIndex
117
+ * @returns {{ peers: number, holders: number, askedOf: number, blocks: number }}
118
+ */
119
+ export function pieceSupply(torrent, pieceIndex) {
120
+ const wires = Array.isArray(torrent?.wires) ? torrent.wires : [];
121
+ let holders = 0;
122
+ let askedOf = 0;
123
+ let blocks = 0;
124
+ for (const wire of wires) {
125
+ if (wire?.peerPieces?.get?.(pieceIndex)) {
126
+ holders += 1;
127
+ }
128
+ const requests = Array.isArray(wire?.requests) ? wire.requests : [];
129
+ const forThisPiece = requests.filter((request) => request?.piece === pieceIndex).length;
130
+ if (forThisPiece > 0) {
131
+ askedOf += 1;
132
+ blocks += forThisPiece;
133
+ }
134
+ }
135
+ return { peers: wires.length, holders, askedOf, blocks };
136
+ }
137
+
138
+ /**
139
+ * Wait until a piece has been downloaded and verified.
140
+ *
141
+ * WebTorrent announces this as `verified`. The bitfield is re-checked after the
142
+ * listener is attached because the piece can complete in between, and a missed
143
+ * event here would wait forever.
144
+ *
145
+ * @param {import("webtorrent").Torrent} torrent
146
+ * @param {number} index
147
+ * @param {{ isCancelled: () => boolean, onCancel?: (listener: () => void) => () => void }} cancellation
148
+ * @returns {Promise<void>}
149
+ */
150
+ function whenPieceReady(torrent, index, cancellation) {
151
+ if (torrent.bitfield?.get(index)) {
152
+ return Promise.resolve();
153
+ }
154
+
155
+ return new Promise((resolve, reject) => {
156
+ /** @param {number} verifiedIndex */
157
+ const onVerified = (verifiedIndex) => {
158
+ if (verifiedIndex === index) {
159
+ cleanup();
160
+ resolve();
161
+ }
162
+ };
163
+ const onDestroyed = () => {
164
+ cleanup();
165
+ reject(new Error(`Torrent went away while waiting for piece ${index}.`));
166
+ };
167
+ // A superseded seek destroys the read, and without this the wait would
168
+ // outlive it and hold a pin. The read's own stream says so the moment it
169
+ // happens; it was polled every 250 ms before. A cancellation that cannot
170
+ // announce itself is one that is never cancelled (a test's plain object).
171
+ if (cancellation.isCancelled()) {
172
+ reject(new Error(`Read cancelled while waiting for piece ${index}.`));
173
+ return;
174
+ }
175
+ const stopListening = cancellation.onCancel?.(() => {
176
+ cleanup();
177
+ reject(new Error(`Read cancelled while waiting for piece ${index}.`));
178
+ }) ?? (() => {});
179
+
180
+ function cleanup() {
181
+ stopListening();
182
+ torrent.removeListener("verified", onVerified);
183
+ torrent.removeListener("close", onDestroyed);
184
+ }
185
+
186
+ torrent.on("verified", onVerified);
187
+ torrent.once("close", onDestroyed);
188
+
189
+ // The piece may have arrived between the check above and this listener.
190
+ if (torrent.bitfield?.get(index)) {
191
+ cleanup();
192
+ resolve();
193
+ }
194
+ });
195
+ }
196
+
197
+ /**
198
+ * A fragment of a read: where to find it, and how to let it go.
199
+ *
200
+ * @typedef {object} PieceFragment
201
+ * @property {number} pieceIndex
202
+ * @property {number} offset - Byte offset into the shared pool.
203
+ * @property {number} length
204
+ * @property {() => void} release - Drops this fragment's pin. Call exactly once.
205
+ */
206
+
207
+ /**
208
+ * Walk a byte range of a file, yielding each piece's position in shared memory.
209
+ *
210
+ * Yields at most one fragment per piece; the first and last are usually partial.
211
+ * The caller must `release()` every fragment it receives, including on failure —
212
+ * an unreleased pin permanently costs a slot.
213
+ *
214
+ * @param {object} params
215
+ * @param {import("webtorrent").Torrent} params.torrent
216
+ * @param {number} params.fileIndex
217
+ * @param {number} params.start - Inclusive, relative to the file.
218
+ * @param {number} params.end - Inclusive, relative to the file.
219
+ * @param {{ isCancelled: () => boolean }} params.cancellation
220
+ * @param {number} [params.windowBytes] - How far ahead of the read head to ask
221
+ * the swarm for. Defaults to {@link READ_WINDOW_BYTES}; a caller that knows
222
+ * the media's byte rate should size it in seconds of playback instead.
223
+ * @returns {AsyncGenerator<PieceFragment>}
224
+ */
225
+ /**
226
+ * The last interruptions this file's readers met, newest last.
227
+ *
228
+ * Bounded and per file, because both figures derived from it describe THIS
229
+ * file on THIS swarm: a piece is 8 MiB here and 512 KiB elsewhere, and a swarm
230
+ * that answers in 200 ms today may not tomorrow. Nothing is stored beyond the
231
+ * process — a restart starts from no evidence, which is the honest state.
232
+ *
233
+ * @type {Map<string, Array<{ waitedMs: number, at: number }>>}
234
+ */
235
+ const supplyWaits = new Map();
236
+
237
+ /** How many interruptions are kept per file. */
238
+ const SUPPLY_WAIT_HISTORY = 40;
239
+
240
+ /** How often the derived figures are printed, at most. */
241
+ const SUPPLY_REPORT_INTERVAL_MS = 30_000;
242
+
243
+ /** When each file's figures were last printed. */
244
+ const supplyReportedAt = new Map();
245
+
246
+ /**
247
+ * Record one interruption and, at most twice a minute, say what it implies.
248
+ *
249
+ * The two figures are the whole of roadmap item 3: the speed a step must
250
+ * sustain to survive this supply (`1 / (1 - the share of time lost)`), and the
251
+ * smallest buffer that hides an interruption from the viewer. Both are printed
252
+ * before either is USED, so the field says whether the arithmetic describes
253
+ * reality before anything is decided by it.
254
+ *
255
+ * @param {string} key - Something stable per file.
256
+ * @param {string} label - What to call it in the log.
257
+ * @param {number} waitedMs
258
+ * @returns {void}
259
+ */
260
+ /**
261
+ * What this file's recent interruptions demand, for a caller that has to decide
262
+ * something with them.
263
+ *
264
+ * Exported because the figures are measured HERE — the reader is the only place
265
+ * that knows how long it waited — while the decisions they feed are made
266
+ * elsewhere: the smallest buffer that hides an interruption goes to the browser,
267
+ * and the speed a step must sustain goes to the quality offer.
268
+ *
269
+ * @param {string} infoHash
270
+ * @param {string} fileName
271
+ * @param {number} segmentSeconds - The session's own segment duration.
272
+ * @returns {{ requiredSpeed: number, worstWaitSec: number, medianIntervalSec: number, lostShare: number, spanSec: number, lostSec: number, samples: number, minimumBufferSec: number } | null}
273
+ */
274
+ export function supplyFiguresFor(infoHash, fileName, segmentSeconds) {
275
+ const history = supplyWaits.get(`${infoHash ?? "?"}/${fileName ?? "?"}`);
276
+ const demand = requiredSpeedFrom(history ?? []);
277
+ if (!demand) {
278
+ return null;
279
+ }
280
+ const buffer = minimumBufferFrom({
281
+ segmentSeconds,
282
+ worstSupplyWaitSec: demand.worstWaitSec
283
+ });
284
+ return {
285
+ requiredSpeed: demand.requiredSpeed,
286
+ worstWaitSec: demand.worstWaitSec,
287
+ medianIntervalSec: demand.medianIntervalSec,
288
+ samples: demand.samples,
289
+ minimumBufferSec: buffer ? buffer.seconds : null
290
+ };
291
+ }
292
+
293
+ /**
294
+ * Waits split by whether the blocked piece was steered onto another peer.
295
+ *
296
+ * The steering is visible per wait already — how many peers held the piece, how
297
+ * many were asked, what the tail looked like. What was NOT visible is what it
298
+ * bought, and that cannot be read off one line: it is the difference between
299
+ * the waits where a second peer was asked and the waits where none could be.
300
+ * Kept per file, reported with the same summary, so a session says by number
301
+ * whether the swap shortens the tail instead of leaving it to impression.
302
+ *
303
+ * @type {Map<string, { swapped: number[], alone: number[] }>}
304
+ */
305
+ const waitsBySteering = new Map();
306
+
307
+ /**
308
+ * Record one wait against the priority level of the requested piece. The level
309
+ * identifies which part of the priority map needs more lead time.
310
+ *
311
+ * @param {string} key
312
+ * @param {number} waitedMs
313
+ * @param {number} urgency
314
+ * @returns {void}
315
+ */
316
+ function noteWaitLevel(key, waitedMs, urgency) {
317
+ let byLevel = waitsByLevel.get(key);
318
+ if (!byLevel) {
319
+ byLevel = new Map();
320
+ waitsByLevel.set(key, byLevel);
321
+ }
322
+ const waits = byLevel.get(urgency) ?? [];
323
+ waits.push(waitedMs);
324
+ while (waits.length > SUPPLY_WAIT_HISTORY) {
325
+ waits.shift();
326
+ }
327
+ byLevel.set(urgency, waits);
328
+ }
329
+
330
+ /**
331
+ * Where the waits fell, by level, or null while nothing has waited.
332
+ *
333
+ * @param {string} key
334
+ * @returns {string | null}
335
+ */
336
+ function describeWaitLevels(key) {
337
+ const byLevel = waitsByLevel.get(key);
338
+ if (!byLevel || byLevel.size === 0) {
339
+ return null;
340
+ }
341
+ const middle = (values) => {
342
+ const sorted = [...values].sort((left, right) => left - right);
343
+ return sorted[Math.floor(sorted.length / 2)];
344
+ };
345
+ return [...byLevel.entries()]
346
+ .sort(([left], [right]) => left - right)
347
+ .map(([urgency, waits]) =>
348
+ `${urgencyName(urgency)} ${waits.length} waits median ${middle(waits)}ms ` +
349
+ `worst ${Math.max(...waits)}ms`)
350
+ .join(", ");
351
+ }
352
+
353
+ /**
354
+ * Waits split by the level the reader was stopped in.
355
+ *
356
+ * @type {Map<string, Map<number, number[]>>}
357
+ */
358
+ const waitsByLevel = new Map();
359
+
360
+ function noteSteeringOutcome(key, waitedMs, steered) {
361
+ let split = waitsBySteering.get(key);
362
+ if (!split) {
363
+ split = { swapped: [], alone: [] };
364
+ waitsBySteering.set(key, split);
365
+ }
366
+ const into = steered ? split.swapped : split.alone;
367
+ into.push(waitedMs);
368
+ while (into.length > SUPPLY_WAIT_HISTORY) {
369
+ into.shift();
370
+ }
371
+ }
372
+
373
+ /**
374
+ * What the split says, or null while one side of it is still empty — a
375
+ * comparison needs both.
376
+ *
377
+ * @param {string} key
378
+ * @returns {string | null}
379
+ */
380
+ function describeSteering(key) {
381
+ const split = waitsBySteering.get(key);
382
+ if (!split || split.swapped.length === 0 || split.alone.length === 0) {
383
+ return null;
384
+ }
385
+ const middle = (values) => {
386
+ const sorted = [...values].sort((left, right) => left - right);
387
+ return sorted[Math.floor(sorted.length / 2)];
388
+ };
389
+ return (
390
+ `steered ${split.swapped.length} waits median ${middle(split.swapped)}ms, ` +
391
+ `unsteered ${split.alone.length} waits median ${middle(split.alone)}ms`
392
+ );
393
+ }
394
+
395
+ /**
396
+ * How many readers are blocked on a torrent AT THIS MOMENT, by infohash.
397
+ *
398
+ * Not a history and not an average: the question it answers is "is anything the
399
+ * viewer is watching waiting for the swarm right now", and the only honest
400
+ * answer is a count of readers currently inside a wait.
401
+ *
402
+ * It exists so that work which is NOT what the viewer is watching — fetching a
403
+ * soundtrack or a subtitle file they may switch to later — can proceed while the
404
+ * swarm has room and stand aside the instant it does not. That ordering is the
405
+ * whole of the requirement: the picture and the track being played come first,
406
+ * the other tracks next, and reading the film far ahead last.
407
+ *
408
+ * @type {Map<string, number>}
409
+ */
410
+ const blockedReaders = new Map();
411
+
412
+ /**
413
+ * How many stalls a torrent's readers have had, ever. Only differences between
414
+ * two readings of it mean anything.
415
+ *
416
+ * @type {Map<string, number>}
417
+ */
418
+ const stallsSeen = new Map();
419
+
420
+ /**
421
+ * Whether any reader on this torrent is waiting for a piece right now.
422
+ *
423
+ * @param {string} infoHash
424
+ * @returns {boolean}
425
+ */
426
+ export function readersAreBlockedOn(infoHash) {
427
+ return (blockedReaders.get(infoHash) ?? 0) > 0;
428
+ }
429
+
430
+ /**
431
+ * How many times a reader on this torrent has been blocked since the process
432
+ * started.
433
+ *
434
+ * Exists so that work of lower importance can ask "did the viewer stall while I
435
+ * was busy?" — which is a different and stricter question than "is the viewer
436
+ * stalled right now". On a swarm delivering exactly what the film needs, a
437
+ * background fetch that only pauses DURING a stall still takes bandwidth
438
+ * between them, and the stalls are the proof it had none to spare. Field
439
+ * 2026-08-31: the swarm delivered 200-600 KB/s against the 399 KB/s the film
440
+ * needs, and the picture stood still 145.6 s.
441
+ *
442
+ * @param {string} infoHash
443
+ * @returns {number}
444
+ */
445
+ export function stallsSeenOn(infoHash) {
446
+ return stallsSeen.get(infoHash) ?? 0;
447
+ }
448
+
449
+ /**
450
+ * @param {string} infoHash
451
+ * @param {number} delta
452
+ * @returns {void}
453
+ */
454
+ function countBlockedReader(infoHash, delta) {
455
+ if (!infoHash) {
456
+ return;
457
+ }
458
+ if (delta > 0) {
459
+ stallsSeen.set(infoHash, (stallsSeen.get(infoHash) ?? 0) + 1);
460
+ }
461
+ const next = (blockedReaders.get(infoHash) ?? 0) + delta;
462
+ if (next > 0) {
463
+ blockedReaders.set(infoHash, next);
464
+ return;
465
+ }
466
+ blockedReaders.delete(infoHash);
467
+ }
468
+
469
+ function noteSupplyWait(key, label, waitedMs) {
470
+ const history = supplyWaits.get(key) ?? [];
471
+ history.push({ waitedMs, at: Date.now() });
472
+ while (history.length > SUPPLY_WAIT_HISTORY) {
473
+ history.shift();
474
+ }
475
+ supplyWaits.set(key, history);
476
+
477
+ const now = Date.now();
478
+ if (now - (supplyReportedAt.get(key) ?? 0) < SUPPLY_REPORT_INTERVAL_MS) {
479
+ return;
480
+ }
481
+ const demand = requiredSpeedFrom(history);
482
+ if (!demand) {
483
+ return;
484
+ }
485
+ supplyReportedAt.set(key, now);
486
+ const buffer = minimumBufferFrom({
487
+ segmentSeconds: SEGMENT_SECONDS_FOR_BUFFER,
488
+ worstSupplyWaitSec: demand.worstWaitSec
489
+ });
490
+ logger.info(
491
+ `supply "${label.slice(0, 40)}": a step must run at ${demand.requiredSpeed.toFixed(2)}x ` +
492
+ // "Interruption", not "wait": several readers walk one file — the picture
493
+ // and each audio rendition — so one missing piece produces one stall and
494
+ // several waits. Saying how many of each is what makes the figure readable;
495
+ // reporting the waits alone made `2 measured` look like two interruptions
496
+ // 3 ms apart, and the demanded speed came out at 4422x.
497
+ // THE NUMBERS THE FIGURE IS MADE OF, so a wrong one can be seen to be wrong.
498
+ // The share of time lost is what the speed now comes from; the worst stall
499
+ // and the typical gap are printed beside it because they are what the
500
+ // cushion is sized by and what the old formula divided one by the other.
501
+ `to survive this swarm (lost ${demand.lostSec.toFixed(2)}s of ${demand.spanSec.toFixed(2)}s ` +
502
+ `= ${(demand.lostShare * 100).toFixed(1)}%, worst stall ${demand.worstWaitSec.toFixed(2)}s, ` +
503
+ `one every ${demand.medianIntervalSec.toFixed(2)}s of running, ${demand.samples} stall(s) ` +
504
+ `from ${demand.waits} wait(s)) — ` +
505
+ `and the smallest buffer that hides it is ${buffer ? buffer.seconds.toFixed(1) : "?"}s` +
506
+ // What steering the blocked piece onto another peer bought, as the
507
+ // difference between the waits where it placed something and the waits
508
+ // where it could not. Absent until both sides have a sample, because a
509
+ // comparison with one side empty is not a comparison.
510
+ (describeSteering(key) ? ` — ${describeSteering(key)}` : "") +
511
+ // The comparison this release exists to make. Read it first.
512
+ (describeWaitLevels(key) ? ` — ${describeWaitLevels(key)}` : "")
513
+ );
514
+ }
515
+
516
+ /**
517
+ * The segment length the buffer figure is stated against. The reader does not
518
+ * know the session's own, and this is a REPORT rather than a decision — the
519
+ * decision, when it is made, will use the session's real one.
520
+ */
521
+ const SEGMENT_SECONDS_FOR_BUFFER = 4;
522
+
523
+ export async function* readFragments({
524
+ torrent,
525
+ fileIndex,
526
+ start,
527
+ end,
528
+ cancellation,
529
+ windowBytes = READ_WINDOW_BYTES
530
+ }) {
531
+ const store = pieceStoreOf(torrent);
532
+ if (!store) {
533
+ throw new Error("This torrent is not backed by a shared piece store.");
534
+ }
535
+ // What this reader wants goes here and nowhere else. `SwarmSelection` is the
536
+ // only thing that turns any of it into a request to the swarm, so a reader
537
+ // can no longer contradict the background fill or the pool.
538
+ const { register, selection } = demandFor(torrent);
539
+
540
+ const file = torrent.files?.[fileIndex];
541
+ if (!file) {
542
+ throw new Error(`File ${fileIndex} not found.`);
543
+ }
544
+
545
+ const pieceLength = torrent.pieceLength;
546
+ // Piece numbers are torrent-wide, so a file's own offsets have to be lifted
547
+ // into the torrent's address space first.
548
+ const absoluteStart = file.offset + start;
549
+ const absoluteEnd = file.offset + end;
550
+ const firstPiece = Math.floor(absoluteStart / pieceLength);
551
+ const lastPiece = Math.floor(absoluteEnd / pieceLength);
552
+
553
+ // This reader owns what it asks for, and gives it back when it is done. The
554
+ // window is a STREAM selection: those are removed by exact bounds and several
555
+ // identical ones coexist — WebTorrent's own source calls that "in a way a
556
+ // count" — so N readers on one torrent produce the union of their windows,
557
+ // and each one leaving takes away only its own. That is what makes several
558
+ // parallel readers (the codec probe's head and tail, subtitles, one input per
559
+ // viewer) cooperate instead of overwrite each other.
560
+ //
561
+ // The previous code selected the whole requested range, marked all of it
562
+ // critical, and never deselected anything — so ffmpeg's opening
563
+ // `bytes 0-<EOF>` left a permanent selection over the entire file, and no
564
+ // later prioritisation could outrank it.
565
+ const basePieces = Math.max(1, Math.ceil(Math.max(1, windowBytes) / pieceLength));
566
+ // What the window is RIGHT NOW. It starts at what the caller sized in seconds
567
+ // of playback and grows while the reader keeps being made to wait — see
568
+ // `nextWindowPieces`.
569
+ let windowPieces = basePieces;
570
+ /**
571
+ * The widest this reader may go: its share of what the store can hold in
572
+ * memory. Measured rather than chosen — the capacity is the store's own, and
573
+ * the number of readers is how many windows are declared on it right now.
574
+ *
575
+ * @returns {number}
576
+ */
577
+ const ceilingPieces = () => {
578
+ const capacity = Number(store?.capacity);
579
+ if (!Number.isFinite(capacity) || capacity <= 0) {
580
+ return basePieces;
581
+ }
582
+ const readers = Math.max(1, store.protectedRanges?.().length ?? 1);
583
+ return Math.max(basePieces, Math.floor(capacity / readers));
584
+ };
585
+ /** @type {{ from: number, to: number } | null} */
586
+ let window = null;
587
+ /** @type {{ from: number, to: number } | null} */
588
+ let blockedStated = false;
589
+ /**
590
+ * Drops the pin of the fragment currently in the consumer's hands, if it
591
+ * still holds one. See where it is assigned.
592
+ *
593
+ * @type {(() => void) | null}
594
+ */
595
+ let releaseHeldPin = null;
596
+
597
+ // Identity of this read, so the store can tell one reader's window from
598
+ // another's. Each read gets its own; `readerSequence` never repeats within a
599
+ // process.
600
+ const readerId = `read-${(readerSequence += 1)}`;
601
+
602
+ /**
603
+ * Set when the window JUMPS, cleared by the first wait after it.
604
+ *
605
+ * The wait that follows a jump is the cost of the jump: the pieces at the new
606
+ * position have not been asked for yet, and the encoder is restarting. It is
607
+ * not evidence about how well this swarm SUSTAINS a read, which is the only
608
+ * thing `requiredSpeed` is about — and letting it in is what collapsed the
609
+ * quality offer 131 ms after the seek measured on 2026-08-18, refusing every
610
+ * re-encoded rung on the strength of one jump.
611
+ *
612
+ * @type {boolean}
613
+ */
614
+ let waitBelongsToJump = false;
615
+
616
+ /** What the consumer has taken from this read, in bytes. */
617
+ let deliveredBytes = 0;
618
+ // How often this read had to stop, and for how long in total. Reported at the
619
+ // end whatever the outcome, so a read that never stopped is counted too.
620
+ let waitCount = 0;
621
+ let waitedTotalMs = 0;
622
+ const readStartedAt = Date.now();
623
+
624
+ /**
625
+ * Where the reader's claim starts: the first piece it does not already have. Everything between the read position and that piece
626
+ * is on disk or in memory, so claiming it asks the swarm for what we hold.
627
+ *
628
+ * @param {number} pieceIndex
629
+ * @returns {number}
630
+ */
631
+ const firstMissingFrom = (pieceIndex) => {
632
+ for (let index = pieceIndex; index <= lastPiece; index += 1) {
633
+ if (!torrent.bitfield?.get?.(index)) {
634
+ return index;
635
+ }
636
+ }
637
+ return pieceIndex;
638
+ };
639
+
640
+
641
+ /**
642
+ * State one band as a need, in bytes.
643
+ *
644
+ * The urgency is part of the claimant key, so this blocked-read claim can be
645
+ * withdrawn without changing the priority map's claims for the same file.
646
+ *
647
+ * @param {{ from: number, to: number, urgency: number }} band
648
+ * @returns {void}
649
+ */
650
+ const stateBand = (band) => {
651
+ const range = bytesOf({
652
+ fileOffset: Number(file.offset),
653
+ fileLength: Number(file.length),
654
+ from: band.from,
655
+ to: band.to,
656
+ pieceLength
657
+ });
658
+ if (!range) {
659
+ return;
660
+ }
661
+ register.state({
662
+ claimant: `${readerId}:${urgencyName(band.urgency)}`,
663
+ fileIndex,
664
+ byteStart: range.byteStart,
665
+ byteEnd: range.byteEnd,
666
+ urgency: band.urgency
667
+ });
668
+ };
669
+
670
+ /**
671
+ * Move the window the reader is working through.
672
+ *
673
+ * It states NOTHING to the swarm. What should be downloaded ahead of a viewer
674
+ * is the priority map's answer, stated once for the whole file by the side
675
+ * that knows where the viewers are; a read is consumption, not a forecast.
676
+ *
677
+ * A read used to declare a rolling window of its own — the piece it wanted
678
+ * and two bands beyond it — and with fifteen reads on one file that was
679
+ * fifteen windows on a piece store holding sixteen pieces. Half of all
680
+ * evictions then took a piece a reader had declared, two thirds of reads came
681
+ * back from disk, and the bytes handed out stopped being the file's: twenty-
682
+ * two source-parse errors, a segment the player refused, and an empty picture
683
+ * for six minutes (field 2026-09-05).
684
+ *
685
+ * What the window is still for: the pieces to mark critical when the reader
686
+ * is actually stopped, and the range to bring back from disk after a jump.
687
+ *
688
+ * @param {number} pieceIndex
689
+ * @returns {void}
690
+ */
691
+ /**
692
+ * Where the priority map puts one piece, in the map's own levels.
693
+ *
694
+ * A stated window is file-relative — `bytesOf` subtracts the file's offset
695
+ * within the torrent — so the piece is put in that frame before it is looked
696
+ * up, or every answer belongs to some other part of the torrent.
697
+ *
698
+ * @param {number} pieceIndex
699
+ * @returns {number | null}
700
+ */
701
+ const mapLevelOf = (pieceIndex) =>
702
+ register.urgencyAt(fileIndex, pieceIndex * pieceLength - Number(file.offset));
703
+
704
+ const moveWindowTo = (pieceIndex) => {
705
+ const anchor = firstMissingFrom(pieceIndex);
706
+ const next = readWindowFor({ pieceIndex: anchor, lastPiece, windowPieces });
707
+ if (window && window.from === next.from && window.to === next.to) {
708
+ return;
709
+ }
710
+ const isJump = !window || next.from > window.to || next.from < window.from;
711
+ window = next;
712
+ if (isJump) {
713
+ waitBelongsToJump = true;
714
+ // A jump — a seek, not the window sliding along — can land on pieces that
715
+ // are already downloaded but have been spilled to disk. Bring the whole
716
+ // window back at once instead of one disk round trip per piece as the
717
+ // reader reaches them.
718
+ const revived = store.warmRange?.(next.from, next.to) ?? 0;
719
+ if (revived > 0) {
720
+ logger.info(
721
+ `piece-reader: reviving ${revived} spilled piece(s) of ${next.from}-${next.to} ` +
722
+ `for a jump to ${(start / 1024 / 1024).toFixed(0)}MB of "${file.name}"`
723
+ );
724
+ }
725
+ }
726
+ };
727
+
728
+ // How many times THIS piece has been asked for again after the store
729
+ // withdrew it. Reset once a piece is in hand, so the allowance is per piece.
730
+ let withdrawnRetries = 0;
731
+
732
+ try {
733
+ for (let pieceIndex = firstPiece; pieceIndex <= lastPiece; pieceIndex += 1) {
734
+ if (cancellation.isCancelled()) {
735
+ return;
736
+ }
737
+
738
+ const pieceStart = pieceIndex * pieceLength;
739
+ const fromWithinPiece = Math.max(absoluteStart, pieceStart) - pieceStart;
740
+ const toWithinPiece = Math.min(absoluteEnd, pieceStart + pieceLength - 1) - pieceStart;
741
+
742
+ moveWindowTo(pieceIndex);
743
+
744
+ if (!torrent.bitfield?.get(pieceIndex)) {
745
+ // Everything from here to the end of the window is wanted NOW, so all
746
+ // of it is marked, not just the piece under the head. `critical`
747
+ // enables hotswap: a block reserved by a slow peer is re-requested from
748
+ // a faster one instead of holding up the reader. Measured 2026-08-04
749
+ // with only the blocked piece marked, the first segment after a seek
750
+ // took 7.2 s while its four 4 MB pieces arrived one after another at
751
+ // ~2.2 MB/s, with waits of 1.3 s and 2.8 s on single pieces.
752
+ //
753
+ // This is not the old behaviour returning: that marked the whole
754
+ // REQUESTED RANGE, which for ffmpeg's input means every piece to the
755
+ // end of the file — hundreds of them, at which point the flag says
756
+ // nothing. A window is what a reader genuinely needs next.
757
+ // Stated as its own need at the level that is being waited on, which
758
+ // is what carries the permission to take a block from a slow peer.
759
+ // Nothing here calls the library: `reconcile` reads what is stated.
760
+ stateBand({ from: pieceIndex, to: window.to, urgency: Urgency.BLOCKED });
761
+ blockedStated = true;
762
+ selection.reconcile();
763
+ }
764
+
765
+ const waitStartedAt = Date.now();
766
+ // What the WHOLE torrent received while this one piece was missing. It is
767
+ // the reading that separates the two causes a wait can have, and neither
768
+ // could be told from the other before: bytes arriving briskly throughout
769
+ // mean the swarm had capacity and this piece was stuck behind the wire
770
+ // that reserved it — the blocked-piece tail; bytes barely moving mean
771
+ // there was nothing to be had, and no reordering of requests would have
772
+ // helped. Asked of 2026-08-31, when a session with 4-5 peers of 38 known
773
+ // waited 41.32 s at worst and the log could not say which it was.
774
+ const downloadedAtWaitStart = Number(torrent?.downloaded) || 0;
775
+ // The reader is blocked, so this piece is now the only thing that matters
776
+ // on this torrent: hand it to the fastest wires that hold it. A block is
777
+ // reserved for exactly one wire, and the read ends when the slowest
778
+ // holder delivers — measured 2026-08-17, the swarm had a fivefold surplus
779
+ // of bandwidth and the reader still waited 1.0-4.5 s, 47 times in two
780
+ // minutes, on pieces five peers already had.
781
+ let pushed = { asked: 0, attempted: 0, considered: 0, fastestBytesPerSecond: 0 };
782
+ // The tail as it stood at an attempt that placed NOTHING — the state the
783
+ // duplication work has to answer, and the only one worth a line. Sampled
784
+ // at that instant rather than once up front, because the steering runs
785
+ // again every half second and the piece changes under it; the last such
786
+ // reading is kept, so the line describes the most recent failure.
787
+ let tailWhenNothingPlaced = null;
788
+ // What duplicating the tail placed, summed over the wait. Measured
789
+ // 2026-08-19 on a real swarm: the blocks a reader waits on are 2-14 of
790
+ // 512 and sit on wires the library considers fast, so its own hotswap
791
+ // never fires for them — see `duplicateTailFor`.
792
+ let duplicated = 0;
793
+ const pushToFastest = () => {
794
+ try {
795
+ const result = askFastestWiresFor(torrent, pieceIndex);
796
+ if (result.asked === 0) {
797
+ tailWhenNothingPlaced = describePieceTail(torrent, pieceIndex);
798
+ }
799
+ // Every attempt, not only the ones where nothing else could be placed.
800
+ // The ordinary steering asks for whatever blocks are still free; the
801
+ // read, meanwhile, ends when the LAST block arrives, and that block is
802
+ // reserved to one wire whether or not other blocks could be asked for.
803
+ // Field 2026-09-03: 46.3 s on one piece, ordinary requests placed on
804
+ // 54 of 87 attempts throughout, and a tail of 3 blocks of 512 held by
805
+ // wires at 51-99 KB/s to the end — while duplication, which ran only
806
+ // on the 33 attempts that placed nothing, managed 5 blocks in the
807
+ // whole wait. `duplicateTailFor` bounds itself by the tail's length,
808
+ // so a piece that is merely still arriving is left alone.
809
+ duplicated += duplicateTailFor(torrent, pieceIndex).duplicated;
810
+ pushed = {
811
+ asked: pushed.asked + result.asked,
812
+ refusedWhileReserved:
813
+ (pushed.refusedWhileReserved ?? 0) + (result.refusedWhileReserved ?? 0),
814
+ // Summed like the successes, so the line compares two totals over
815
+ // the same attempts instead of a total against a snapshot.
816
+ attempted: (pushed.attempted ?? 0) + result.attempted,
817
+ considered: result.considered,
818
+ fastestBytesPerSecond: result.fastestBytesPerSecond
819
+ };
820
+ } catch (error) {
821
+ // The entry is internal to the library; if a version changes it, this
822
+ // lever stops working and that must be visible rather than silent.
823
+ logger.warn(`piece-reader: could not steer piece ${pieceIndex} — ${error?.message ?? error}`);
824
+ }
825
+ };
826
+ if (canPlaceRequests(torrent)) {
827
+ pushToFastest();
828
+ } else {
829
+ // Nothing can be placed at all on this build, so the tail is the whole
830
+ // of the answer.
831
+ tailWhenNothingPlaced = describePieceTail(torrent, pieceIndex);
832
+ logger.warn(
833
+ "piece-reader: this webtorrent build offers no way to place a request; " +
834
+ "the blocked piece cannot be steered onto a faster peer"
835
+ );
836
+ }
837
+ // Sampled while waiting rather than after: once the piece lands, nothing
838
+ // is outstanding on it any more and every count reads zero.
839
+ let supply = null;
840
+ const supplyProbe = setInterval(() => {
841
+ const sample = pieceSupply(torrent, pieceIndex);
842
+ if (!supply || sample.blocks > supply.blocks) {
843
+ supply = sample;
844
+ }
845
+ // Wires come and go, and their speeds change: a holder that was slow a
846
+ // moment ago may now be the fastest one available.
847
+ pushToFastest();
848
+ }, 500);
849
+ // Counted for exactly as long as this reader is inside the wait, so that
850
+ // background work can stand aside while the viewer's own reading is
851
+ // starving. The `finally` is what makes it safe: a cancelled or failed
852
+ // read must not leave the torrent looking permanently blocked, which
853
+ // would stop that background work for the rest of the session.
854
+ countBlockedReader(torrent?.infoHash, 1);
855
+ try {
856
+ await whenPieceReady(torrent, pieceIndex, cancellation);
857
+ } finally {
858
+ countBlockedReader(torrent?.infoHash, -1);
859
+ clearInterval(supplyProbe);
860
+ }
861
+ // What a reader spent waiting for data, attributed to the exact piece. A
862
+ // seek's cost is dominated by the first segment after the encoder
863
+ // restarts (measured 9.2-9.4 s), and without this there is no way to say
864
+ // whether that is the swarm, the picker, or ffmpeg. Logged only when the
865
+ // wait is long enough to matter, so ordinary sequential reading is silent.
866
+ const waitedMs = Date.now() - waitStartedAt;
867
+ // Where the map puts this piece, read once and used by both the
868
+ // attribution below and the line beside it.
869
+ const wantedBy = mapLevelOf(pieceIndex);
870
+ // The window answers to what just happened: a wait means the lead was too
871
+ // short, an immediate hit means it is longer than it needs to be. Applied
872
+ // before the logging below so the line reports the window the next piece
873
+ // will actually use.
874
+ if (waitBelongsToJump) {
875
+ // Recorded nowhere: see `waitBelongsToJump`. Said out loud, because a
876
+ // gap in the supply history is otherwise indistinguishable from a swarm
877
+ // that never made the reader wait.
878
+ logger.info(
879
+ `piece-reader: ${waitedMs}ms on the first piece after a jump — the cost of moving, ` +
880
+ `not of this swarm's supply, so it is not counted against the quality offer`
881
+ );
882
+ waitBelongsToJump = false;
883
+ } else {
884
+ const supplyKey = `${torrent?.infoHash ?? "?"}/${file?.name ?? "?"}`;
885
+ noteSteeringOutcome(supplyKey, waitedMs, pushed.asked > 0 || duplicated > 0);
886
+ // Which level of the priority map the reader was stopped in. A wait
887
+ // belongs to a level, and the level says whether that zone is asked for
888
+ // too late — the reader itself no longer has an opinion about it.
889
+ noteWaitLevel(
890
+ supplyKey,
891
+ waitedMs,
892
+ wantedBy ?? Urgency.BLOCKED
893
+ );
894
+ waitCount += 1;
895
+ waitedTotalMs += waitedMs;
896
+ noteSupplyWait(supplyKey, file?.name ?? "", waitedMs);
897
+ }
898
+ const widened = nextWindowPieces({
899
+ current: windowPieces,
900
+ base: basePieces,
901
+ ceiling: ceilingPieces(),
902
+ waitedMs,
903
+ waitThresholdMs: PIECE_WAIT_LOG_MS
904
+ });
905
+ if (widened !== windowPieces) {
906
+ windowPieces = widened;
907
+ }
908
+ if (waitedMs >= PIECE_WAIT_LOG_MS) {
909
+ const rateKbps = Math.round(pieceLength / 1024 / (waitedMs / 1000));
910
+ // Two rates, side by side, and no verdict word between them: the swarm's
911
+ // own delivery during the wait against what this piece managed. Both are
912
+ // measured; which one a reader calls "the cause" follows from the pair
913
+ // without a threshold having to be chosen here. Far apart means the
914
+ // bytes were flowing and this piece was not among them; close together,
915
+ // or both near zero, means there was nothing to deliver.
916
+ const swarmBytes = Math.max(0, (Number(torrent?.downloaded) || 0) - downloadedAtWaitStart);
917
+ const swarmKbps = Math.round(swarmBytes / 1024 / (waitedMs / 1000));
918
+ logger.info(
919
+ `piece-reader: waited ${waitedMs}ms for piece ${pieceIndex} ` +
920
+ `(${pieceIndex - firstPiece + 1} of ${lastPiece - firstPiece + 1} in a read from ` +
921
+ `${(start / 1024 / 1024).toFixed(0)}MB of "${file.name}") ` +
922
+ `— ${rateKbps}KB/s on this piece while the swarm delivered ` +
923
+ `${swarmKbps}KB/s (${(swarmBytes / 1024 / 1024).toFixed(1)}MB) across the torrent, ` +
924
+ `${Number(torrent?.numPeers) || 0} peers connected; ` +
925
+ (supply
926
+ ? `${supply.holders}/${supply.peers} peers had it, ${supply.askedOf} were asked, ` +
927
+ `${supply.blocks} blocks (${Math.round((supply.blocks * 16384) / 1024)}KB) in flight at peak`
928
+ : "no sample taken") +
929
+ // What WE did about it, so the next session says whether steering
930
+ // the piece onto faster holders shortens the tail — by number
931
+ // rather than by impression.
932
+ `; steered onto ${pushed.asked} of ${pushed.attempted} asks (${pushed.considered} peers held it)` +
933
+ // How often a fast peer we picked was refused because every block
934
+ // was already spoken for and the library declined to take one from
935
+ // a slow holder. Its thresholds are constants, not settings: the
936
+ // asker must be above 16 KB/s, the holder below 48 KB/s and twice
937
+ // as slow. A number here is what would justify replacing that rule;
938
+ // a zero says the thresholds are not what we are short of.
939
+ ((pushed.refusedWhileReserved ?? 0) > 0
940
+ ? `; ${pushed.refusedWhileReserved} refused with every block reserved`
941
+ : "") +
942
+ (pushed.fastestBytesPerSecond > 0
943
+ ? `, fastest ${Math.round(pushed.fastestBytesPerSecond / 1024)}KB/s`
944
+ : "") +
945
+ // Only when the steering placed nothing, which is the case that
946
+ // decides whether duplicating the tail is worth building: it says
947
+ // how much of the piece is still missing and which wires are
948
+ // holding it, slowest first.
949
+ (tailWhenNothingPlaced
950
+ ? `; tail ${tailWhenNothingPlaced.missing}/${tailWhenNothingPlaced.chunks} blocks missing, held by ` +
951
+ (tailWhenNothingPlaced.outstanding.length > 0
952
+ ? tailWhenNothingPlaced.outstanding
953
+ .map((wire) => `${wire.blocks}@${Math.round(wire.bytesPerSecond / 1024)}KB/s` +
954
+ (wire.choking ? " (choking)" : ""))
955
+ .join(" ")
956
+ : "nobody")
957
+ : "") +
958
+ // What we did about the tail, so the next session says by number
959
+ // whether a second copy of those blocks shortens the wait.
960
+ (duplicated > 0 ? `; duplicated ${duplicated} blocks` : "") +
961
+ // Where the priority map puts this piece. A wait at the level the
962
+ // viewer is about to reach is a different fault from a wait on the
963
+ // speculative tail, and the reader states nothing of its own that
964
+ // could be read instead.
965
+ `; the map wants it ${wantedBy === null
966
+ ? "nowhere — nobody asked for this piece"
967
+ : urgencyName(wantedBy)}`
968
+ );
969
+ }
970
+
971
+ // Pinned BEFORE it is located, and before any await that could let an
972
+ // eviction run: the offset is only meaningful while the piece is held.
973
+ store.pin(pieceIndex);
974
+ let located = null;
975
+ try {
976
+ located = await store.reside(pieceIndex);
977
+ } catch (error) {
978
+ store.unpin(pieceIndex);
979
+ throw error;
980
+ }
981
+
982
+ if (!located) {
983
+ store.unpin(pieceIndex);
984
+ // THE CLAIM HAS JUST BEEN WITHDRAWN, so this is a wait and not a
985
+ // failure. The store drops a piece behind every read head, and a read
986
+ // that re-opens an input at byte 0 — which is every encoder restart —
987
+ // asks for exactly those pieces. Until 2026-09-12 this threw, ffmpeg
988
+ // read the empty body as the end of the file, and the encoder died and
989
+ // was restarted into the same emptiness: field, `Piece 0 is verified
990
+ // but absent from the store` answered every read for 92 minutes while
991
+ // the viewer looked at a still picture.
992
+ //
993
+ // Going back one step re-runs this piece's whole path rather than a
994
+ // shortened copy of it: the bitfield now says the piece is missing, so
995
+ // the block above declares it, steers it onto the fastest holders and
996
+ // waits for it, exactly as it does for a piece that was never here.
997
+ // Once per piece — a second emptiness means the bytes are not coming
998
+ // and the caller must hear so.
999
+ if (withdrawnRetries === 0) {
1000
+ withdrawnRetries += 1;
1001
+ pieceIndex -= 1;
1002
+ continue;
1003
+ }
1004
+ throw new Error(
1005
+ `Piece ${pieceIndex} was withdrawn from the store and did not come back.`
1006
+ );
1007
+ }
1008
+ // Counted per piece: a long read may legitimately meet this more than
1009
+ // once, and each piece is entitled to its own second chance.
1010
+ withdrawnRetries = 0;
1011
+
1012
+ let releasedThisPiece = false;
1013
+ // Remembered so the generator can drop it itself. The pin is taken here
1014
+ // and the consumer is expected to release it — but a consumer that
1015
+ // ABANDONS the iterator never gets the chance, and a seek abandons it
1016
+ // every time: the encoder is killed, the response is torn down, and the
1017
+ // loop is left between two fragments. Field 2026-08-06: after one seek
1018
+ // every slot in the store was pinned, the store answered
1019
+ // `Every resident piece is pinned; no slot can be freed` — to the
1020
+ // WebTorrent client, which closed the store and destroyed the torrent —
1021
+ // and the session died with `File 0 not found`.
1022
+ releaseHeldPin = () => {
1023
+ if (!releasedThisPiece) {
1024
+ releasedThisPiece = true;
1025
+ store.unpin(pieceIndex);
1026
+ }
1027
+ };
1028
+ // What the consumer takes, counted as it is handed over: for a viewer this
1029
+ // is the film's own byte rate, which is what the band widths are derived
1030
+ // against.
1031
+ deliveredBytes += toWithinPiece - fromWithinPiece + 1;
1032
+ yield {
1033
+ pieceIndex,
1034
+ buffer: located.buffer,
1035
+ offset: located.offset + fromWithinPiece,
1036
+ length: toWithinPiece - fromWithinPiece + 1,
1037
+ release() {
1038
+ if (releasedThisPiece) {
1039
+ return;
1040
+ }
1041
+ releasedThisPiece = true;
1042
+ store.unpin(pieceIndex);
1043
+ }
1044
+ };
1045
+ // Handed back, and released by the consumer or not at all — either way
1046
+ // this reader no longer owes anything for it.
1047
+ releaseHeldPin = null;
1048
+ }
1049
+ } finally {
1050
+ // A fragment handed out and never released is a slot lost for the life of
1051
+ // the process. Reached on every exit, including the consumer walking away.
1052
+ if (releaseHeldPin) {
1053
+ releaseHeldPin();
1054
+ releaseHeldPin = null;
1055
+ }
1056
+ // What this read did, said once at its end and under EVERY outcome: how
1057
+ // much it delivered, and how much of that time it spent stopped. Said even
1058
+ // when it never stopped, because "no wait" is the result worth counting and
1059
+ // a line printed only beside a wait cannot report it.
1060
+ const readSeconds = (Date.now() - readStartedAt) / 1000;
1061
+ if (deliveredBytes > 0) {
1062
+ logger.info(
1063
+ `read "${String(file?.name ?? "?").slice(0, 40)}" ` +
1064
+ `delivered=${(deliveredBytes / 1e6).toFixed(1)}MB in ${readSeconds.toFixed(1)}s ` +
1065
+ `waits=${waitCount} waited=${(waitedTotalMs / 1000).toFixed(1)}s`
1066
+ );
1067
+ }
1068
+ // Reached on completion, on cancellation, on a throw, and when the consumer
1069
+ // stops iterating — a claim left behind would keep the swarm fetching for a
1070
+ // reader that no longer exists.
1071
+ if (blockedStated) {
1072
+ register.withdraw(`${readerId}:${urgencyName(Urgency.BLOCKED)}`);
1073
+ }
1074
+ // Once, after everything has been withdrawn — and it releases this reader's
1075
+ // hold on memory as well, because both views come from the same statement.
1076
+ selection.reconcile();
1077
+ }
1078
+ }