@torrent-tv/proxy 2.89.1 → 2.89.2

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