@torrent-tv/proxy 2.89.5 → 2.89.7

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 (447) 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 +338 -338
  13. package/CHANGELOG.md +16 -0
  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/diagnostics/sctpstate.gdb +31 -31
  19. package/bin/cli.js +684 -684
  20. package/bin/thread-pool.js +30 -30
  21. package/docs/download-architecture.md +261 -261
  22. package/docs/encode-architecture.md +765 -765
  23. package/docs/encode-run-state.md +96 -96
  24. package/docs/logs.md +97 -97
  25. package/docs/playback-start-forecast.md +17 -2
  26. package/docs/socks5-egress-research.md +114 -114
  27. package/docs/storage-architecture.md +273 -273
  28. package/knip.json +14 -14
  29. package/openspec/changes/adaptive-bitrate/design.md +92 -92
  30. package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
  31. package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
  32. package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
  33. package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
  34. package/openspec/changes/binary-distribution/proposal.md +53 -53
  35. package/openspec/changes/chunked-request-bodies/design.md +81 -81
  36. package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
  37. package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
  38. package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
  39. package/openspec/changes/cold-start/design.md +98 -98
  40. package/openspec/changes/cold-start/proposal.md +62 -62
  41. package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
  42. package/openspec/changes/cold-start/tasks.md +48 -48
  43. package/openspec/changes/disk-cap/.openspec.yaml +2 -2
  44. package/openspec/changes/disk-cap/proposal.md +37 -37
  45. package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
  46. package/openspec/changes/disk-cap/tasks.md +19 -19
  47. package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
  48. package/openspec/changes/proxy-observability/design.md +38 -38
  49. package/openspec/changes/proxy-observability/proposal.md +49 -49
  50. package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
  51. package/openspec/changes/proxy-observability/tasks.md +21 -21
  52. package/openspec/changes/seek-debounce/design.md +118 -118
  53. package/openspec/changes/seek-debounce/proposal.md +59 -59
  54. package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
  55. package/openspec/changes/seek-debounce/tasks.md +33 -33
  56. package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
  57. package/openspec/changes/subtitle-language/proposal.md +50 -50
  58. package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
  59. package/openspec/changes/subtitle-language/tasks.md +26 -26
  60. package/openspec/changes/track-selection/.openspec.yaml +2 -2
  61. package/openspec/changes/track-selection/design.md +47 -47
  62. package/openspec/changes/track-selection/proposal.md +45 -45
  63. package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
  64. package/openspec/changes/track-selection/tasks.md +20 -20
  65. package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
  66. package/openspec/changes/transcode-quality/proposal.md +51 -51
  67. package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
  68. package/openspec/changes/transcode-quality/tasks.md +63 -63
  69. package/openspec/config.yaml +38 -38
  70. package/package.json +1 -1
  71. package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
  72. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  73. package/research/double-spawn-2026-09-10.md +171 -171
  74. package/research/handover-2026-09-13.md +209 -209
  75. package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
  76. package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
  77. package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
  78. package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
  79. package/routes/api/client-logs/post.js +86 -86
  80. package/routes/api/delivery-sink/get.js +72 -72
  81. package/routes/api/link-probe/get.js +51 -51
  82. package/routes/api/playback-plan/audio-tracks/post.js +29 -29
  83. package/routes/api/playback-plan/post.js +53 -53
  84. package/routes/api/sources/post.js +36 -36
  85. package/routes/api/sources/stats/get.js +132 -132
  86. package/routes/api/sources/warm/post.js +217 -217
  87. package/routes/api/subtitles/get.js +230 -230
  88. package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
  89. package/routes/api/transcode-sessions/net-report/post.js +138 -138
  90. package/routes/api/transcode-sessions/post.js +178 -178
  91. package/routes/api/transcode-sessions/progress/get.js +27 -27
  92. package/routes/api/transcode-sessions/release/post.js +41 -41
  93. package/routes/api/transcode-sessions/seek/post.js +63 -63
  94. package/routes/health/get.js +13 -13
  95. package/routes/healthz/get.js +13 -13
  96. package/routes/stream/get.js +416 -416
  97. package/routes/transcode/audio-file/get.js +64 -64
  98. package/routes/transcode/audio-warm/get.js +95 -95
  99. package/routes/transcode/session-file/get.js +52 -52
  100. package/routes/transcode/variant-file/get.js +95 -95
  101. package/routes/transcode/variant-warm/get.js +84 -84
  102. package/scripts/render-run-graph.js +173 -173
  103. package/server.js +748 -748
  104. package/services/encode/CoverageMap.js +428 -428
  105. package/services/encode/CushionReport.js +345 -345
  106. package/services/encode/EncodeAdmission.js +376 -376
  107. package/services/encode/EncodeOrchestrator.js +1192 -1192
  108. package/services/encode/EncodePlan.js +1368 -1368
  109. package/services/encode/EncodeRun.js +32 -4
  110. package/services/encode/EncodeRuns.js +19 -7
  111. package/services/encode/Encoder.js +142 -142
  112. package/services/encode/EncoderSelection.js +38 -38
  113. package/services/encode/LocalObservations.js +359 -359
  114. package/services/encode/NvencEncoder.js +50 -50
  115. package/services/encode/OutputTimes.js +579 -579
  116. package/services/encode/QsvEncoder.js +58 -58
  117. package/services/encode/Renditions.js +2283 -2283
  118. package/services/encode/RunClock.js +87 -87
  119. package/services/encode/RunProgress.js +79 -79
  120. package/services/encode/SegmentDemand.js +95 -95
  121. package/services/encode/SoftwareEncoder.js +121 -121
  122. package/services/encode/V4l2m2mEncoder.js +53 -53
  123. package/services/encode/VaapiEncoder.js +66 -66
  124. package/services/encode/args.js +418 -418
  125. package/services/encode/available-share.js +89 -89
  126. package/services/encode/calibration.js +361 -361
  127. package/services/encode/contention.js +128 -128
  128. package/services/encode/decode-cost-fit.js +363 -363
  129. package/services/encode/encode-exit.js +116 -114
  130. package/services/encode/encode-run-state.js +551 -551
  131. package/services/encode/encoder-readings.js +43 -43
  132. package/services/encode/fingerprint.js +322 -322
  133. package/services/encode/host-load.js +174 -174
  134. package/services/encode/index.js +9 -9
  135. package/services/encode/learned-median.js +90 -90
  136. package/services/encode/output/EncodedOutput.js +57 -57
  137. package/services/encode/output/Output.js +131 -131
  138. package/services/encode/output/OutputCatalog.js +414 -414
  139. package/services/encode/output/OutputSpec.js +390 -390
  140. package/services/encode/output/Timeline.js +308 -308
  141. package/services/encode/output/cut-grid.js +107 -107
  142. package/services/encode/output/ladder.js +32 -32
  143. package/services/encode/output/playlists.js +284 -284
  144. package/services/encode/output/rates.js +125 -125
  145. package/services/encode/output-key-format.js +21 -21
  146. package/services/encode/piece-completeness.js +60 -2
  147. package/services/encode/quality/EncodeCost.js +1043 -1043
  148. package/services/encode/quality/HostLoad.js +525 -525
  149. package/services/encode/quality/HostTimings.js +249 -249
  150. package/services/encode/quality/OutputQualityState.js +24 -24
  151. package/services/encode/quality/QualityController.js +758 -758
  152. package/services/encode/quality/QualityOffer.js +416 -416
  153. package/services/encode/quality/drain-threat.js +47 -47
  154. package/services/encode/quality/link-budget.js +337 -337
  155. package/services/encode/quality/output-format.js +319 -319
  156. package/services/encode/quality/serving-output.js +216 -216
  157. package/services/encode/quality/visible-rung.js +57 -57
  158. package/services/encode/run-budget.js +97 -97
  159. package/services/encode/run-command.js +3 -1
  160. package/services/encode/run-costs.js +155 -155
  161. package/services/encode/run-inputs.js +53 -53
  162. package/services/encode/segment-formats/fmp4.js +476 -476
  163. package/services/encode/segment-formats/index.js +1 -0
  164. package/services/encode/segment-formats/init-compat.js +324 -324
  165. package/services/encode/segment-formats/mp4-boxes.js +8 -1
  166. package/services/encode/segment-formats/mpegts.js +134 -134
  167. package/services/encode/start-stop-cost.js +178 -178
  168. package/services/encode/throughput.js +97 -97
  169. package/services/encode/torrent-cost.js +88 -88
  170. package/services/identity/installation-id.js +38 -38
  171. package/services/media/SourceFile.js +298 -298
  172. package/services/media/SubtitleOrchestrator.js +167 -167
  173. package/services/media/container/KeyframeTable.js +272 -272
  174. package/services/media/container/ebml-reader.js +159 -159
  175. package/services/media/ffmpeg-banner.js +260 -260
  176. package/services/media/keyframe-probe.js +157 -157
  177. package/services/media/media-info-probe.js +93 -93
  178. package/services/media/playback-planner.js +931 -931
  179. package/services/media/tracks/AudioTrack.js +143 -143
  180. package/services/media/tracks/ImageSubtitleTrack.js +19 -19
  181. package/services/media/tracks/SubtitleTrack.js +38 -38
  182. package/services/media/tracks/VideoTrack.js +33 -33
  183. package/services/media/tracks/audio-bound.js +233 -233
  184. package/services/media/tracks/index.js +15 -15
  185. package/services/media/tracks/language-detect.js +174 -174
  186. package/services/server/OutputLifecycle.js +418 -418
  187. package/services/server/SegmentServing.js +1152 -1152
  188. package/services/server/ViewerRequests.js +785 -785
  189. package/services/server/WaitLedger.js +142 -142
  190. package/services/server/controllers/PlaybackController.js +40 -40
  191. package/services/server/controllers/SubtitleController.js +109 -109
  192. package/services/server/http-responses.js +60 -60
  193. package/services/server/transcode-session-files.js +325 -325
  194. package/services/server/wire-outputs.js +1029 -1029
  195. package/services/storage/Diagnostics.js +202 -202
  196. package/services/storage/MachineBudget.js +235 -235
  197. package/services/storage/allowance.js +107 -107
  198. package/services/storage/core-dumps.js +106 -106
  199. package/services/storage/files/CompletedFiles.js +391 -391
  200. package/services/storage/files/piece-from-whole-file.js +118 -118
  201. package/services/storage/free.js +71 -71
  202. package/services/storage/keep.js +48 -48
  203. package/services/storage/machine-memory.js +56 -56
  204. package/services/storage/memory-report.js +575 -575
  205. package/services/storage/piece-store/piece-disk-store.js +539 -539
  206. package/services/storage/piece-store/piece-lru.js +495 -495
  207. package/services/storage/returns.js +103 -103
  208. package/services/storage/wire.js +175 -175
  209. package/services/torrent/demand/DemandRegister.js +202 -202
  210. package/services/torrent/demand/Urgency.js +137 -137
  211. package/services/torrent/demand/Window.js +118 -118
  212. package/services/torrent/demand/index.js +8 -8
  213. package/services/torrent/demand/pieces.js +140 -140
  214. package/services/torrent/download/SwarmSelection.js +386 -386
  215. package/services/torrent/download/registry.js +102 -102
  216. package/services/torrent/download/withdraw-claim.js +80 -80
  217. package/services/torrent/files.js +455 -455
  218. package/services/torrent/naming.js +619 -619
  219. package/services/torrent/piece-store-of.js +29 -29
  220. package/services/torrent/supply-margin.js +264 -264
  221. package/services/torrent/worker/background-fill.js +213 -213
  222. package/services/torrent/worker/channel.js +271 -271
  223. package/services/torrent/worker/fastest-wires.js +342 -342
  224. package/services/torrent/worker/handle-state.js +23 -23
  225. package/services/torrent/worker/install-webrtc-shim.js +32 -32
  226. package/services/torrent/worker/piece-reader.js +1078 -1078
  227. package/services/torrent/worker/pool-adapter.js +371 -371
  228. package/services/torrent/worker/protocol.js +184 -184
  229. package/services/torrent/worker/resume-warm.js +107 -107
  230. package/services/torrent/worker/webrtc-shim.js +219 -219
  231. package/services/transport/delivery-probe.js +842 -842
  232. package/services/transport/health-collector.js +53 -53
  233. package/services/transport/nat-classifier.js +231 -231
  234. package/services/transport/packet-witness.js +808 -808
  235. package/services/transport/port-mapper.js +235 -235
  236. package/services/transport/registry-api.js +81 -81
  237. package/services/transport/tunnel-client.js +447 -447
  238. package/services/transport/usrsctp-state.js +175 -175
  239. package/services/transport/webrtc-manager.js +484 -484
  240. package/services/viewer/Assignments.js +340 -340
  241. package/services/viewer/PriorityOrchestrator.js +325 -325
  242. package/services/viewer/Viewer.js +691 -691
  243. package/services/viewer/Viewers.js +368 -368
  244. package/services/viewer/active-output.js +37 -37
  245. package/services/viewer/audio-start.js +46 -46
  246. package/services/viewer/buffer-trend.js +81 -81
  247. package/services/viewer/choices.js +647 -647
  248. package/services/viewer/media-time.js +355 -355
  249. package/services/viewer/playback-readiness.js +635 -635
  250. package/services/viewer/positions.js +126 -126
  251. package/services/viewer/report-intake.js +46 -46
  252. package/store/source-registry.js +71 -71
  253. package/test/asset-paths.test.js +28 -28
  254. package/test/audio-buffer-continuity.test.js +77 -77
  255. package/test/audio-inventory.test.js +212 -212
  256. package/test/audio-track-bitrate.test.js +40 -40
  257. package/test/auto-quality-step.test.js +762 -753
  258. package/test/available-share.test.js +76 -76
  259. package/test/background-fill.test.js +190 -190
  260. package/test/client-log-file.test.js +110 -110
  261. package/test/completed-files.test.js +115 -115
  262. package/test/concurrent-cost.test.js +148 -148
  263. package/test/container-media-info.test.js +228 -228
  264. package/test/contention.test.js +76 -76
  265. package/test/core-dumps.test.js +57 -57
  266. package/test/coverage-follows-the-disk.test.js +228 -228
  267. package/test/coverage-map.test.js +195 -195
  268. package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
  269. package/test/cut-times-timeline.test.js +65 -65
  270. package/test/cuts-follow-published-grid.test.js +109 -109
  271. package/test/data-channel-frame.test.js +63 -63
  272. package/test/data-channel-viewer-lifecycle.test.js +92 -92
  273. package/test/dead-torrent-handle.test.js +52 -52
  274. package/test/declared-rates.test.js +116 -116
  275. package/test/declared-tracks.test.js +43 -43
  276. package/test/decode-cost-fit.test.js +202 -202
  277. package/test/decode-cost.test.js +475 -462
  278. package/test/delivery-probe.test.js +393 -393
  279. package/test/demand-register.test.js +206 -206
  280. package/test/dht-bootstrap.test.js +72 -72
  281. package/test/drain-threat.test.js +65 -65
  282. package/test/duplicate-add.test.js +48 -48
  283. package/test/encode-admission.test.js +146 -146
  284. package/test/encode-cost.test.js +275 -275
  285. package/test/encode-exit.test.js +111 -111
  286. package/test/encode-orchestrator.test.js +426 -426
  287. package/test/encode-plan-viewers.test.js +725 -725
  288. package/test/encode-plan.test.js +545 -545
  289. package/test/encode-run-state.test.js +314 -314
  290. package/test/encode-run.test.js +38 -0
  291. package/test/encode-slope.test.js +46 -46
  292. package/test/encoded-output.test.js +39 -39
  293. package/test/encoder-kinds.test.js +122 -122
  294. package/test/encoder-readings.test.js +68 -68
  295. package/test/every-claimant-is-told-its-share.test.js +152 -152
  296. package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
  297. package/test/failed-start-breaker.test.js +202 -202
  298. package/test/fastest-wires.test.js +143 -143
  299. package/test/ffmpeg-stream-counts.test.js +78 -78
  300. package/test/file-edges.test.js +191 -191
  301. package/test/fixtures/refused-audio-20261002.json +10 -0
  302. package/test/fmp4-presentation-clock.test.js +4 -2
  303. package/test/fmp4-self-contained.test.js +80 -80
  304. package/test/fmp4-timestamps.test.js +140 -140
  305. package/test/fmp4-track-completeness.test.js +41 -41
  306. package/test/format-keyed-outputs.test.js +78 -78
  307. package/test/health-metrics.test.js +38 -38
  308. package/test/held-request-width.test.js +157 -157
  309. package/test/helpers/encode-run.js +240 -240
  310. package/test/helpers/manager.js +62 -57
  311. package/test/helpers/output-spec.js +63 -63
  312. package/test/host-load.test.js +68 -68
  313. package/test/index-accuracy-is-about-the-file.test.js +128 -128
  314. package/test/init-compat.test.js +219 -219
  315. package/test/input-lost-quiets-the-plan.test.js +261 -261
  316. package/test/input-unavailable.test.js +48 -48
  317. package/test/installation-id.test.js +39 -39
  318. package/test/keeping-period.test.js +83 -83
  319. package/test/keyframe-index-accuracy.test.js +188 -188
  320. package/test/keyframes-belong-to-the-file.test.js +249 -249
  321. package/test/learned-median.test.js +37 -37
  322. package/test/letting-go-asks-who-is-watching.test.js +191 -191
  323. package/test/limit-row-by-area.test.js +60 -60
  324. package/test/link-budget.test.js +111 -111
  325. package/test/link-probe.test.js +56 -56
  326. package/test/local-encoding-data.test.js +273 -273
  327. package/test/logger-repeats.test.js +134 -134
  328. package/test/look-ahead-coverage.test.js +56 -56
  329. package/test/manager-surface.test.js +291 -291
  330. package/test/matroska-cues-track.test.js +192 -192
  331. package/test/media-time.test.js +62 -62
  332. package/test/memory-budget.test.js +390 -390
  333. package/test/move-cost.test.js +216 -216
  334. package/test/mp4-subtitles.test.js +173 -173
  335. package/test/one-authority.test.js +285 -285
  336. package/test/one-budget.test.js +149 -149
  337. package/test/one-resource-divided.test.js +185 -185
  338. package/test/one-serving-output-per-height.test.js +458 -458
  339. package/test/orchestrator-wired.test.js +207 -207
  340. package/test/output-catalog.test.js +39 -39
  341. package/test/output-shape.test.js +21 -21
  342. package/test/output-spec.test.js +251 -251
  343. package/test/output-speed.test.js +87 -87
  344. package/test/outputs-being-prepared.test.js +40 -40
  345. package/test/packet-witness-ring.test.js +236 -236
  346. package/test/packet-witness.test.js +148 -148
  347. package/test/piece-completeness.test.js +37 -1
  348. package/test/piece-disk-store.test.js +401 -401
  349. package/test/piece-from-whole-file.test.js +129 -129
  350. package/test/piece-lru.test.js +319 -319
  351. package/test/piece-reader.test.js +162 -162
  352. package/test/piece-selection-offset.test.js +85 -85
  353. package/test/piece-store-eviction.test.js +595 -595
  354. package/test/piece-store-never-refuses.test.js +153 -153
  355. package/test/piece-store-reservations.test.js +403 -403
  356. package/test/piece-store-slow-disk.test.js +159 -159
  357. package/test/piece-tail.test.js +97 -97
  358. package/test/plan-host-timings.test.js +68 -68
  359. package/test/plan-is-steady.test.js +225 -225
  360. package/test/playback-readiness.test.js +518 -518
  361. package/test/playback-start-regressions.test.js +101 -101
  362. package/test/priority-map-download.test.js +174 -174
  363. package/test/priority-map-emptied.test.js +204 -204
  364. package/test/priority-map-per-output.test.js +215 -215
  365. package/test/priority-map.test.js +163 -163
  366. package/test/probe-wedge-certainty.test.js +60 -60
  367. package/test/produced-copy-choice.test.js +213 -213
  368. package/test/published-timeline.test.js +83 -83
  369. package/test/quality-offer.test.js +160 -160
  370. package/test/quality-variants.test.js +1270 -1257
  371. package/test/rate-control-in-the-output.test.js +180 -180
  372. package/test/read-survives-withdrawal.test.js +164 -164
  373. package/test/read-window.test.js +365 -365
  374. package/test/resume-warm.test.js +39 -39
  375. package/test/run-graph-drift.test.js +37 -37
  376. package/test/run-inputs.test.js +80 -80
  377. package/test/run-intervals.test.js +100 -100
  378. package/test/run-position-follows-published-grid.test.js +89 -89
  379. package/test/run-progress.test.js +64 -64
  380. package/test/same-height-switch.test.js +663 -663
  381. package/test/seek-frees-the-viewers-requests.test.js +217 -217
  382. package/test/seek-landing.test.js +109 -109
  383. package/test/seek-target-not-superseded.test.js +195 -195
  384. package/test/segment-cut-times.test.js +63 -63
  385. package/test/segment-demand.test.js +80 -80
  386. package/test/segment-hold-supersede.test.js +91 -91
  387. package/test/segment-judged-by-its-own-header.test.js +222 -221
  388. package/test/segment-serve-wiring.test.js +9 -3
  389. package/test/segment-store-eviction.test.js +247 -247
  390. package/test/segment-store.test.js +352 -352
  391. package/test/segment-true-start.test.js +86 -86
  392. package/test/segments-are-shared.test.js +176 -176
  393. package/test/send-chunk-bytes.test.js +86 -86
  394. package/test/serving-existing-output.test.js +111 -105
  395. package/test/serving-output.test.js +102 -102
  396. package/test/shared-piece-store.test.js +310 -310
  397. package/test/sidecar-naming.test.js +142 -142
  398. package/test/soundtrack-rate-bound.test.js +289 -289
  399. package/test/source-file.test.js +133 -133
  400. package/test/source-registry.test.js +18 -18
  401. package/test/split-a-stretch.test.js +139 -139
  402. package/test/stale-request-after-seek.test.js +158 -158
  403. package/test/startup-readings.test.js +124 -124
  404. package/test/stream-route.test.js +237 -237
  405. package/test/subtitle-cue-framing.test.js +200 -200
  406. package/test/subtitle-cue-source.test.js +102 -102
  407. package/test/subtitle-cursor.test.js +63 -63
  408. package/test/subtitle-defaults.test.js +118 -118
  409. package/test/subtitle-language.test.js +252 -252
  410. package/test/supply-margin.test.js +190 -190
  411. package/test/swarm-follows-readers.test.js +174 -174
  412. package/test/swarm-reach.test.js +130 -130
  413. package/test/swarm-selection.test.js +220 -220
  414. package/test/tail-duplication.test.js +167 -167
  415. package/test/the-channel-has-both-ends.test.js +91 -91
  416. package/test/the-torrent-thread-serves-bytes.test.js +107 -107
  417. package/test/thread-pool.test.js +53 -53
  418. package/test/timeline.test.js +84 -84
  419. package/test/torrent-contents.test.js +235 -235
  420. package/test/torrent-cost.test.js +71 -71
  421. package/test/torrent-files.test.js +221 -221
  422. package/test/torrent-naming.test.js +255 -255
  423. package/test/tracks-begin-together.test.js +209 -209
  424. package/test/tunnel-renewal.test.js +167 -167
  425. package/test/two-viewers-one-picture.test.js +508 -504
  426. package/test/upload-hurry.test.js +186 -186
  427. package/test/usrsctp-state.test.js +131 -131
  428. package/test/video-facts.test.js +102 -102
  429. package/test/viewer-assignments.test.js +143 -143
  430. package/test/viewer-outputs.test.js +285 -285
  431. package/test/viewer-presence.test.js +116 -116
  432. package/test/viewer-requests-admission.test.js +92 -89
  433. package/test/viewer-states.test.js +189 -189
  434. package/test/viewer-subtitle-subscription.test.js +96 -96
  435. package/test/viewer.test.js +139 -139
  436. package/test/viewing-generations.test.js +390 -390
  437. package/test/visible-rung.test.js +37 -37
  438. package/test/wait-ledger.test.js +100 -100
  439. package/test/webrtc-shim.test.js +149 -149
  440. package/test/wedge-certainty.test.js +131 -131
  441. package/test/withdraw-piece-claim.test.js +212 -212
  442. package/test/worker-channel.test.js +158 -158
  443. package/utils/client-log-file.js +151 -151
  444. package/utils/logger.js +312 -312
  445. package/utils/parse-range.js +25 -25
  446. package/utils/perf.js +59 -59
  447. 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
+ }