@torrent-tv/proxy 2.87.4 → 2.87.6

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