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