@torrent-tv/proxy 2.87.6 → 2.87.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/browser-proxy-contract.md +101 -94
- 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 -0
- 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/server.js +4 -0
- 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/audio-inventory.js +245 -212
- 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/playback-planner.js +131 -59
- 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/PlaybackController.js +40 -36
- 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 +1356 -1356
- 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-inventory.test.js +212 -176
- 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/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
package/test/read-window.test.js
CHANGED
|
@@ -1,365 +1,365 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file What a read asks the torrent to download, and what it gives back.
|
|
3
|
-
*
|
|
4
|
-
* A read used to select its whole requested range and never deselect it.
|
|
5
|
-
* ffmpeg opens its input as `bytes <position>-<EOF>`, so the first read of a
|
|
6
|
-
* session claimed the entire file and marked every piece of it critical — and
|
|
7
|
-
* the claim outlived the read, which is abandoned a second later when ffmpeg
|
|
8
|
-
* seeks. Nothing after that could outrank it: measured on a 4.7 GB film, a seek
|
|
9
|
-
* to 89.1% waited 93 s while the swarm fetched 2.47 GB in file order.
|
|
10
|
-
*
|
|
11
|
-
* Now a read claims only the piece it is STOPPED on, and gives it back when it
|
|
12
|
-
* ends. What should be downloaded ahead of a viewer is the priority map's
|
|
13
|
-
* answer, stated once for the whole file by the side that knows where the
|
|
14
|
-
* viewers are; a read is consumption, not a forecast. Fifteen reads on one file
|
|
15
|
-
* used to declare fifteen windows on a piece store holding sixteen pieces, and
|
|
16
|
-
* half of all evictions then took a piece a reader had declared (field
|
|
17
|
-
* 2026-09-05).
|
|
18
|
-
*
|
|
19
|
-
* These tests pin what is left: a read that is not stopped claims nothing, a
|
|
20
|
-
* read that is stopped claims the pieces it is waiting for and nothing beyond
|
|
21
|
-
* them, and every claim is given back.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import test from "node:test";
|
|
25
|
-
import assert from "node:assert/strict";
|
|
26
|
-
import { EventEmitter } from "node:events";
|
|
27
|
-
import os from "node:os";
|
|
28
|
-
import path from "node:path";
|
|
29
|
-
import fs from "node:fs/promises";
|
|
30
|
-
import {
|
|
31
|
-
nextWindowPieces,
|
|
32
|
-
readFragments,
|
|
33
|
-
readWindowFor
|
|
34
|
-
} from "../services/torrent/worker/piece-reader.js";
|
|
35
|
-
import { SharedPieceStore } from "../services/storage/piece-store/shared-piece-store.js";
|
|
36
|
-
|
|
37
|
-
const PIECE = 1024;
|
|
38
|
-
// The production window is 32 MB against 8 MiB pieces — four of them. Sized
|
|
39
|
-
// here in pieces so the test does not depend on either constant.
|
|
40
|
-
const WINDOW_PIECES = 4;
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* A torrent that records every selection call instead of downloading anything.
|
|
44
|
-
*
|
|
45
|
-
* @param {{ pieceCount: number, present?: (index: number) => boolean }} shape
|
|
46
|
-
*/
|
|
47
|
-
async function recordingTorrent({ pieceCount, present = () => true }) {
|
|
48
|
-
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "read-window-test-"));
|
|
49
|
-
const totalLength = pieceCount * PIECE;
|
|
50
|
-
// ROOM FOR THE WHOLE FIXTURE, because none of these tests is about eviction.
|
|
51
|
-
// At 64 pieces of memory an 8000-piece fixture spilled 7936 of them to disk —
|
|
52
|
-
// a file each — and read most of them back, so four checks about which piece
|
|
53
|
-
// RANGES a reader claims cost 25-28 seconds apiece and this one file took
|
|
54
|
-
// 132 s of a 140 s suite. The store's own behaviour under pressure is
|
|
55
|
-
// measured where that is the subject (`piece-store-eviction`,
|
|
56
|
-
// `piece-lru`, `piece-disk-store`).
|
|
57
|
-
const store = new SharedPieceStore(PIECE, {
|
|
58
|
-
length: totalLength,
|
|
59
|
-
memoryBytes: totalLength,
|
|
60
|
-
path: directory,
|
|
61
|
-
name: "test"
|
|
62
|
-
});
|
|
63
|
-
for (let index = 0; index < pieceCount; index += 1) {
|
|
64
|
-
await new Promise((resolve, reject) => {
|
|
65
|
-
store.put(index, Buffer.alloc(PIECE, index % 251), (error) => (error ? reject(error) : resolve()));
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** @type {Array<{ call: string, from: number, to: number, stream?: boolean }>} */
|
|
70
|
-
const calls = [];
|
|
71
|
-
/** Live stream selections, as WebTorrent counts them: exact bounds, duplicates allowed. */
|
|
72
|
-
const held = [];
|
|
73
|
-
|
|
74
|
-
const torrent = Object.assign(new EventEmitter(), {
|
|
75
|
-
pieceLength: PIECE,
|
|
76
|
-
store,
|
|
77
|
-
bitfield: { get: (index) => present(index) },
|
|
78
|
-
files: [{ offset: 0, length: totalLength, name: "file.bin" }],
|
|
79
|
-
_critical: [],
|
|
80
|
-
_selections: { _items: [] },
|
|
81
|
-
calls,
|
|
82
|
-
held,
|
|
83
|
-
_select(from, to, _priority, _notify, isStreamSelection) {
|
|
84
|
-
calls.push({ call: "select", from, to, stream: isStreamSelection === true });
|
|
85
|
-
held.push(`${from}-${to}`);
|
|
86
|
-
this._selections._items.push({ from, to });
|
|
87
|
-
},
|
|
88
|
-
_deselect(from, to, isStreamSelection) {
|
|
89
|
-
calls.push({ call: "deselect", from, to, stream: isStreamSelection === true });
|
|
90
|
-
const at = held.indexOf(`${from}-${to}`);
|
|
91
|
-
if (at >= 0) {
|
|
92
|
-
held.splice(at, 1);
|
|
93
|
-
}
|
|
94
|
-
const item = this._selections._items.findIndex((one) => one.from === from && one.to === to);
|
|
95
|
-
if (item >= 0) {
|
|
96
|
-
this._selections._items.splice(item, 1);
|
|
97
|
-
}
|
|
98
|
-
},
|
|
99
|
-
critical(from, to) {
|
|
100
|
-
calls.push({ call: "critical", from, to });
|
|
101
|
-
for (let index = from; index <= to; index += 1) {
|
|
102
|
-
this._critical[index] = true;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
return { torrent, store, directory };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** Read a range to the end, releasing every fragment. */
|
|
111
|
-
async function drain(torrent, start, end) {
|
|
112
|
-
for await (const fragment of readFragments({
|
|
113
|
-
torrent,
|
|
114
|
-
fileIndex: 0,
|
|
115
|
-
start,
|
|
116
|
-
end,
|
|
117
|
-
cancellation: { isCancelled: () => false }
|
|
118
|
-
})) {
|
|
119
|
-
fragment.release();
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
test("the window is bounded and clamped to the end of the read", () => {
|
|
124
|
-
assert.deepEqual(readWindowFor({ pieceIndex: 10, lastPiece: 999, windowPieces: 4 }), { from: 10, to: 13 });
|
|
125
|
-
assert.deepEqual(
|
|
126
|
-
readWindowFor({ pieceIndex: 997, lastPiece: 999, windowPieces: 4 }),
|
|
127
|
-
{ from: 997, to: 999 },
|
|
128
|
-
"the window never reaches past the range the reader was given"
|
|
129
|
-
);
|
|
130
|
-
assert.deepEqual(
|
|
131
|
-
readWindowFor({ pieceIndex: 5, lastPiece: 999, windowPieces: 0 }),
|
|
132
|
-
{ from: 5, to: 5 },
|
|
133
|
-
"a degenerate size still asks for the piece under the head"
|
|
134
|
-
);
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
test("a read that is not stopped claims nothing", async () => {
|
|
138
|
-
// 8000 pieces of 1 KB, every one of them present, and the read asks as ffmpeg
|
|
139
|
-
// does: to the last byte of the file. Nothing is missing, so this read is
|
|
140
|
-
// waiting for nothing, so it wants nothing of the swarm — what lies ahead of
|
|
141
|
-
// it belongs to the priority map.
|
|
142
|
-
const { torrent, store, directory } = await recordingTorrent({ pieceCount: 8000 });
|
|
143
|
-
try {
|
|
144
|
-
const iterator = readFragments({
|
|
145
|
-
torrent,
|
|
146
|
-
fileIndex: 0,
|
|
147
|
-
start: 0,
|
|
148
|
-
end: 8000 * PIECE - 1,
|
|
149
|
-
cancellation: { isCancelled: () => false },
|
|
150
|
-
windowBytes: WINDOW_PIECES * PIECE
|
|
151
|
-
});
|
|
152
|
-
const first = await iterator.next();
|
|
153
|
-
first.value.release();
|
|
154
|
-
|
|
155
|
-
assert.deepEqual(
|
|
156
|
-
torrent.held,
|
|
157
|
-
[],
|
|
158
|
-
"the read declared a window of its own, which is the forecast that has to come from the map"
|
|
159
|
-
);
|
|
160
|
-
|
|
161
|
-
await iterator.return();
|
|
162
|
-
} finally {
|
|
163
|
-
store.destroy(() => undefined);
|
|
164
|
-
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
165
|
-
}
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
test("a finished read leaves nothing selected", async () => {
|
|
169
|
-
const { torrent, store, directory } = await recordingTorrent({ pieceCount: 40 });
|
|
170
|
-
try {
|
|
171
|
-
await drain(torrent, 0, 40 * PIECE - 1);
|
|
172
|
-
assert.deepEqual(torrent.held, [], "the read kept its claim after finishing");
|
|
173
|
-
} finally {
|
|
174
|
-
store.destroy(() => undefined);
|
|
175
|
-
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
176
|
-
}
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
test("an abandoned read leaves nothing selected", async () => {
|
|
180
|
-
const { torrent, store, directory } = await recordingTorrent({ pieceCount: 8000 });
|
|
181
|
-
try {
|
|
182
|
-
const iterator = readFragments({
|
|
183
|
-
torrent,
|
|
184
|
-
fileIndex: 0,
|
|
185
|
-
start: 0,
|
|
186
|
-
end: 8000 * PIECE - 1,
|
|
187
|
-
cancellation: { isCancelled: () => false },
|
|
188
|
-
windowBytes: WINDOW_PIECES * PIECE
|
|
189
|
-
});
|
|
190
|
-
const first = await iterator.next();
|
|
191
|
-
first.value.release();
|
|
192
|
-
// What ffmpeg does to its opening read the moment it seeks.
|
|
193
|
-
await iterator.return();
|
|
194
|
-
|
|
195
|
-
assert.deepEqual(torrent.held, [], "an abandoned read kept its claim forever");
|
|
196
|
-
} finally {
|
|
197
|
-
store.destroy(() => undefined);
|
|
198
|
-
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
199
|
-
}
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
test("two stopped readers add up, and one leaving takes only its own", async () => {
|
|
203
|
-
// Nothing has arrived yet, so both readers are stopped on their first piece
|
|
204
|
-
// and each states it. Two claims, each withdrawn on its own.
|
|
205
|
-
let arrived = false;
|
|
206
|
-
const { torrent, store, directory } = await recordingTorrent({
|
|
207
|
-
pieceCount: 8000,
|
|
208
|
-
present: () => arrived
|
|
209
|
-
});
|
|
210
|
-
try {
|
|
211
|
-
const head = readFragments({
|
|
212
|
-
torrent, fileIndex: 0, start: 0, end: 8000 * PIECE - 1,
|
|
213
|
-
cancellation: { isCancelled: () => false },
|
|
214
|
-
windowBytes: WINDOW_PIECES * PIECE
|
|
215
|
-
});
|
|
216
|
-
const tail = readFragments({
|
|
217
|
-
torrent, fileIndex: 0, start: 4000 * PIECE, end: 8000 * PIECE - 1,
|
|
218
|
-
cancellation: { isCancelled: () => false },
|
|
219
|
-
windowBytes: WINDOW_PIECES * PIECE
|
|
220
|
-
});
|
|
221
|
-
const headPending = head.next();
|
|
222
|
-
const tailPending = tail.next();
|
|
223
|
-
await new Promise((resolve) => setImmediate(resolve));
|
|
224
|
-
|
|
225
|
-
const pieceOf = (range) => Number(range.split("-")[0]);
|
|
226
|
-
const held = [...torrent.held];
|
|
227
|
-
assert.ok(held.some((range) => pieceOf(range) < 4000), "the head reader claimed nothing");
|
|
228
|
-
assert.ok(held.some((range) => pieceOf(range) >= 4000), "the tail reader claimed nothing");
|
|
229
|
-
|
|
230
|
-
// Let both through to a yield, so ending them runs their `finally` at once.
|
|
231
|
-
arrived = true;
|
|
232
|
-
torrent.emit("verified", 0);
|
|
233
|
-
torrent.emit("verified", 4000);
|
|
234
|
-
(await headPending).value.release();
|
|
235
|
-
(await tailPending).value.release();
|
|
236
|
-
|
|
237
|
-
await tail.return();
|
|
238
|
-
const after = [...torrent.held];
|
|
239
|
-
assert.ok(
|
|
240
|
-
after.every((range) => pieceOf(range) < 4000),
|
|
241
|
-
"the tail reader left its claim behind"
|
|
242
|
-
);
|
|
243
|
-
|
|
244
|
-
await head.return();
|
|
245
|
-
assert.deepEqual(torrent.held, [], "the last reader left something behind");
|
|
246
|
-
} finally {
|
|
247
|
-
store.destroy(() => undefined);
|
|
248
|
-
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
249
|
-
}
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
test("criticality marks the window being waited for, not the whole range", async () => {
|
|
253
|
-
// Nothing is present, so the reader blocks on its first piece and marks it.
|
|
254
|
-
let arrived = false;
|
|
255
|
-
const { torrent, store, directory } = await recordingTorrent({
|
|
256
|
-
pieceCount: 8000,
|
|
257
|
-
present: () => arrived
|
|
258
|
-
});
|
|
259
|
-
try {
|
|
260
|
-
const iterator = readFragments({
|
|
261
|
-
torrent, fileIndex: 0, start: 0, end: 8000 * PIECE - 1,
|
|
262
|
-
cancellation: { isCancelled: () => false },
|
|
263
|
-
windowBytes: WINDOW_PIECES * PIECE
|
|
264
|
-
});
|
|
265
|
-
const pending = iterator.next();
|
|
266
|
-
await new Promise((resolve) => setImmediate(resolve));
|
|
267
|
-
|
|
268
|
-
const criticals = torrent.calls.filter((entry) => entry.call === "critical");
|
|
269
|
-
assert.equal(criticals.length, 1);
|
|
270
|
-
const marked = criticals[0].to - criticals[0].from + 1;
|
|
271
|
-
// The window, and nothing beyond it. Marking only the blocked piece made
|
|
272
|
-
// the pieces after it arrive strictly one at a time — measured, the first
|
|
273
|
-
// segment after a seek took 7.2 s for four pieces. Marking the whole
|
|
274
|
-
// requested range would be the old mistake: for ffmpeg's input that is
|
|
275
|
-
// every piece to the end of the file, and the flag stops meaning anything.
|
|
276
|
-
assert.equal(marked, WINDOW_PIECES, `marked ${marked} pieces critical`);
|
|
277
|
-
assert.ok(criticals[0].to < 8000 - 1, "criticality must not reach the end of the file");
|
|
278
|
-
|
|
279
|
-
arrived = true;
|
|
280
|
-
torrent.emit("verified", 0);
|
|
281
|
-
(await pending).value.release();
|
|
282
|
-
await iterator.return();
|
|
283
|
-
} finally {
|
|
284
|
-
store.destroy(() => undefined);
|
|
285
|
-
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
286
|
-
}
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
test("a reader that is abandoned mid-fragment does not keep the piece pinned", async () => {
|
|
290
|
-
// The pin is taken before the fragment is handed out and released by the
|
|
291
|
-
// consumer — but a seek abandons the iterator between two fragments, and the
|
|
292
|
-
// consumer never gets the chance. Field 2026-08-06: after one seek every slot
|
|
293
|
-
// in the store was pinned, the store answered `Every resident piece is
|
|
294
|
-
// pinned; no slot can be freed` to the WebTorrent client, which closed the
|
|
295
|
-
// store and destroyed the torrent.
|
|
296
|
-
const { torrent, store, directory } = await recordingTorrent({ pieceCount: 40 });
|
|
297
|
-
try {
|
|
298
|
-
const iterator = readFragments({
|
|
299
|
-
torrent,
|
|
300
|
-
fileIndex: 0,
|
|
301
|
-
start: 0,
|
|
302
|
-
end: 40 * PIECE - 1,
|
|
303
|
-
cancellation: { isCancelled: () => false },
|
|
304
|
-
windowBytes: WINDOW_PIECES * PIECE
|
|
305
|
-
});
|
|
306
|
-
await iterator.next(); // held, deliberately NOT released
|
|
307
|
-
await iterator.return(); // what a seek does
|
|
308
|
-
|
|
309
|
-
assert.equal(
|
|
310
|
-
store.stats().pinned,
|
|
311
|
-
0,
|
|
312
|
-
"the abandoned fragment's piece is still pinned; slots leak one per seek"
|
|
313
|
-
);
|
|
314
|
-
} finally {
|
|
315
|
-
store.destroy(() => undefined);
|
|
316
|
-
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
317
|
-
}
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
// ------------------------------------------ the window that grows into a lead
|
|
321
|
-
|
|
322
|
-
test("a wait that mattered widens the window by a piece", () => {
|
|
323
|
-
// Field 2026-08-17: 5.1-5.9 MB/s delivered against ~1 MB/s consumed, and the
|
|
324
|
-
// reader still blocked 47 times in two minutes. The surplus never became
|
|
325
|
-
// distance ahead of the head.
|
|
326
|
-
assert.equal(
|
|
327
|
-
nextWindowPieces({ current: 4, base: 4, ceiling: 12, waitedMs: 1457, waitThresholdMs: 1000 }),
|
|
328
|
-
5
|
|
329
|
-
);
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
test("a piece that was already there gives a piece back", () => {
|
|
333
|
-
assert.equal(
|
|
334
|
-
nextWindowPieces({ current: 7, base: 4, ceiling: 12, waitedMs: 0, waitThresholdMs: 1000 }),
|
|
335
|
-
6
|
|
336
|
-
);
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
test("it never shrinks below what the caller asked for", () => {
|
|
340
|
-
assert.equal(
|
|
341
|
-
nextWindowPieces({ current: 4, base: 4, ceiling: 12, waitedMs: 0, waitThresholdMs: 1000 }),
|
|
342
|
-
4
|
|
343
|
-
);
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
test("it never grows past this reader's share of the store", () => {
|
|
347
|
-
assert.equal(
|
|
348
|
-
nextWindowPieces({ current: 12, base: 4, ceiling: 12, waitedMs: 4453, waitThresholdMs: 1000 }),
|
|
349
|
-
12
|
|
350
|
-
);
|
|
351
|
-
// A ceiling below the base cannot pull the window under it: the caller sized
|
|
352
|
-
// the base from the file's own byte rate, and a store too small to hold it is
|
|
353
|
-
// an argument about memory, not about what the reader needs next.
|
|
354
|
-
assert.equal(
|
|
355
|
-
nextWindowPieces({ current: 4, base: 4, ceiling: 1, waitedMs: 2000, waitThresholdMs: 1000 }),
|
|
356
|
-
4
|
|
357
|
-
);
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
test("a wait exactly at the threshold counts as a wait", () => {
|
|
361
|
-
assert.equal(
|
|
362
|
-
nextWindowPieces({ current: 4, base: 4, ceiling: 9, waitedMs: 1000, waitThresholdMs: 1000 }),
|
|
363
|
-
5
|
|
364
|
-
);
|
|
365
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* @file What a read asks the torrent to download, and what it gives back.
|
|
3
|
+
*
|
|
4
|
+
* A read used to select its whole requested range and never deselect it.
|
|
5
|
+
* ffmpeg opens its input as `bytes <position>-<EOF>`, so the first read of a
|
|
6
|
+
* session claimed the entire file and marked every piece of it critical — and
|
|
7
|
+
* the claim outlived the read, which is abandoned a second later when ffmpeg
|
|
8
|
+
* seeks. Nothing after that could outrank it: measured on a 4.7 GB film, a seek
|
|
9
|
+
* to 89.1% waited 93 s while the swarm fetched 2.47 GB in file order.
|
|
10
|
+
*
|
|
11
|
+
* Now a read claims only the piece it is STOPPED on, and gives it back when it
|
|
12
|
+
* ends. What should be downloaded ahead of a viewer is the priority map's
|
|
13
|
+
* answer, stated once for the whole file by the side that knows where the
|
|
14
|
+
* viewers are; a read is consumption, not a forecast. Fifteen reads on one file
|
|
15
|
+
* used to declare fifteen windows on a piece store holding sixteen pieces, and
|
|
16
|
+
* half of all evictions then took a piece a reader had declared (field
|
|
17
|
+
* 2026-09-05).
|
|
18
|
+
*
|
|
19
|
+
* These tests pin what is left: a read that is not stopped claims nothing, a
|
|
20
|
+
* read that is stopped claims the pieces it is waiting for and nothing beyond
|
|
21
|
+
* them, and every claim is given back.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import test from "node:test";
|
|
25
|
+
import assert from "node:assert/strict";
|
|
26
|
+
import { EventEmitter } from "node:events";
|
|
27
|
+
import os from "node:os";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import fs from "node:fs/promises";
|
|
30
|
+
import {
|
|
31
|
+
nextWindowPieces,
|
|
32
|
+
readFragments,
|
|
33
|
+
readWindowFor
|
|
34
|
+
} from "../services/torrent/worker/piece-reader.js";
|
|
35
|
+
import { SharedPieceStore } from "../services/storage/piece-store/shared-piece-store.js";
|
|
36
|
+
|
|
37
|
+
const PIECE = 1024;
|
|
38
|
+
// The production window is 32 MB against 8 MiB pieces — four of them. Sized
|
|
39
|
+
// here in pieces so the test does not depend on either constant.
|
|
40
|
+
const WINDOW_PIECES = 4;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A torrent that records every selection call instead of downloading anything.
|
|
44
|
+
*
|
|
45
|
+
* @param {{ pieceCount: number, present?: (index: number) => boolean }} shape
|
|
46
|
+
*/
|
|
47
|
+
async function recordingTorrent({ pieceCount, present = () => true }) {
|
|
48
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "read-window-test-"));
|
|
49
|
+
const totalLength = pieceCount * PIECE;
|
|
50
|
+
// ROOM FOR THE WHOLE FIXTURE, because none of these tests is about eviction.
|
|
51
|
+
// At 64 pieces of memory an 8000-piece fixture spilled 7936 of them to disk —
|
|
52
|
+
// a file each — and read most of them back, so four checks about which piece
|
|
53
|
+
// RANGES a reader claims cost 25-28 seconds apiece and this one file took
|
|
54
|
+
// 132 s of a 140 s suite. The store's own behaviour under pressure is
|
|
55
|
+
// measured where that is the subject (`piece-store-eviction`,
|
|
56
|
+
// `piece-lru`, `piece-disk-store`).
|
|
57
|
+
const store = new SharedPieceStore(PIECE, {
|
|
58
|
+
length: totalLength,
|
|
59
|
+
memoryBytes: totalLength,
|
|
60
|
+
path: directory,
|
|
61
|
+
name: "test"
|
|
62
|
+
});
|
|
63
|
+
for (let index = 0; index < pieceCount; index += 1) {
|
|
64
|
+
await new Promise((resolve, reject) => {
|
|
65
|
+
store.put(index, Buffer.alloc(PIECE, index % 251), (error) => (error ? reject(error) : resolve()));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @type {Array<{ call: string, from: number, to: number, stream?: boolean }>} */
|
|
70
|
+
const calls = [];
|
|
71
|
+
/** Live stream selections, as WebTorrent counts them: exact bounds, duplicates allowed. */
|
|
72
|
+
const held = [];
|
|
73
|
+
|
|
74
|
+
const torrent = Object.assign(new EventEmitter(), {
|
|
75
|
+
pieceLength: PIECE,
|
|
76
|
+
store,
|
|
77
|
+
bitfield: { get: (index) => present(index) },
|
|
78
|
+
files: [{ offset: 0, length: totalLength, name: "file.bin" }],
|
|
79
|
+
_critical: [],
|
|
80
|
+
_selections: { _items: [] },
|
|
81
|
+
calls,
|
|
82
|
+
held,
|
|
83
|
+
_select(from, to, _priority, _notify, isStreamSelection) {
|
|
84
|
+
calls.push({ call: "select", from, to, stream: isStreamSelection === true });
|
|
85
|
+
held.push(`${from}-${to}`);
|
|
86
|
+
this._selections._items.push({ from, to });
|
|
87
|
+
},
|
|
88
|
+
_deselect(from, to, isStreamSelection) {
|
|
89
|
+
calls.push({ call: "deselect", from, to, stream: isStreamSelection === true });
|
|
90
|
+
const at = held.indexOf(`${from}-${to}`);
|
|
91
|
+
if (at >= 0) {
|
|
92
|
+
held.splice(at, 1);
|
|
93
|
+
}
|
|
94
|
+
const item = this._selections._items.findIndex((one) => one.from === from && one.to === to);
|
|
95
|
+
if (item >= 0) {
|
|
96
|
+
this._selections._items.splice(item, 1);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
critical(from, to) {
|
|
100
|
+
calls.push({ call: "critical", from, to });
|
|
101
|
+
for (let index = from; index <= to; index += 1) {
|
|
102
|
+
this._critical[index] = true;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return { torrent, store, directory };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Read a range to the end, releasing every fragment. */
|
|
111
|
+
async function drain(torrent, start, end) {
|
|
112
|
+
for await (const fragment of readFragments({
|
|
113
|
+
torrent,
|
|
114
|
+
fileIndex: 0,
|
|
115
|
+
start,
|
|
116
|
+
end,
|
|
117
|
+
cancellation: { isCancelled: () => false }
|
|
118
|
+
})) {
|
|
119
|
+
fragment.release();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
test("the window is bounded and clamped to the end of the read", () => {
|
|
124
|
+
assert.deepEqual(readWindowFor({ pieceIndex: 10, lastPiece: 999, windowPieces: 4 }), { from: 10, to: 13 });
|
|
125
|
+
assert.deepEqual(
|
|
126
|
+
readWindowFor({ pieceIndex: 997, lastPiece: 999, windowPieces: 4 }),
|
|
127
|
+
{ from: 997, to: 999 },
|
|
128
|
+
"the window never reaches past the range the reader was given"
|
|
129
|
+
);
|
|
130
|
+
assert.deepEqual(
|
|
131
|
+
readWindowFor({ pieceIndex: 5, lastPiece: 999, windowPieces: 0 }),
|
|
132
|
+
{ from: 5, to: 5 },
|
|
133
|
+
"a degenerate size still asks for the piece under the head"
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("a read that is not stopped claims nothing", async () => {
|
|
138
|
+
// 8000 pieces of 1 KB, every one of them present, and the read asks as ffmpeg
|
|
139
|
+
// does: to the last byte of the file. Nothing is missing, so this read is
|
|
140
|
+
// waiting for nothing, so it wants nothing of the swarm — what lies ahead of
|
|
141
|
+
// it belongs to the priority map.
|
|
142
|
+
const { torrent, store, directory } = await recordingTorrent({ pieceCount: 8000 });
|
|
143
|
+
try {
|
|
144
|
+
const iterator = readFragments({
|
|
145
|
+
torrent,
|
|
146
|
+
fileIndex: 0,
|
|
147
|
+
start: 0,
|
|
148
|
+
end: 8000 * PIECE - 1,
|
|
149
|
+
cancellation: { isCancelled: () => false },
|
|
150
|
+
windowBytes: WINDOW_PIECES * PIECE
|
|
151
|
+
});
|
|
152
|
+
const first = await iterator.next();
|
|
153
|
+
first.value.release();
|
|
154
|
+
|
|
155
|
+
assert.deepEqual(
|
|
156
|
+
torrent.held,
|
|
157
|
+
[],
|
|
158
|
+
"the read declared a window of its own, which is the forecast that has to come from the map"
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
await iterator.return();
|
|
162
|
+
} finally {
|
|
163
|
+
store.destroy(() => undefined);
|
|
164
|
+
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("a finished read leaves nothing selected", async () => {
|
|
169
|
+
const { torrent, store, directory } = await recordingTorrent({ pieceCount: 40 });
|
|
170
|
+
try {
|
|
171
|
+
await drain(torrent, 0, 40 * PIECE - 1);
|
|
172
|
+
assert.deepEqual(torrent.held, [], "the read kept its claim after finishing");
|
|
173
|
+
} finally {
|
|
174
|
+
store.destroy(() => undefined);
|
|
175
|
+
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("an abandoned read leaves nothing selected", async () => {
|
|
180
|
+
const { torrent, store, directory } = await recordingTorrent({ pieceCount: 8000 });
|
|
181
|
+
try {
|
|
182
|
+
const iterator = readFragments({
|
|
183
|
+
torrent,
|
|
184
|
+
fileIndex: 0,
|
|
185
|
+
start: 0,
|
|
186
|
+
end: 8000 * PIECE - 1,
|
|
187
|
+
cancellation: { isCancelled: () => false },
|
|
188
|
+
windowBytes: WINDOW_PIECES * PIECE
|
|
189
|
+
});
|
|
190
|
+
const first = await iterator.next();
|
|
191
|
+
first.value.release();
|
|
192
|
+
// What ffmpeg does to its opening read the moment it seeks.
|
|
193
|
+
await iterator.return();
|
|
194
|
+
|
|
195
|
+
assert.deepEqual(torrent.held, [], "an abandoned read kept its claim forever");
|
|
196
|
+
} finally {
|
|
197
|
+
store.destroy(() => undefined);
|
|
198
|
+
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("two stopped readers add up, and one leaving takes only its own", async () => {
|
|
203
|
+
// Nothing has arrived yet, so both readers are stopped on their first piece
|
|
204
|
+
// and each states it. Two claims, each withdrawn on its own.
|
|
205
|
+
let arrived = false;
|
|
206
|
+
const { torrent, store, directory } = await recordingTorrent({
|
|
207
|
+
pieceCount: 8000,
|
|
208
|
+
present: () => arrived
|
|
209
|
+
});
|
|
210
|
+
try {
|
|
211
|
+
const head = readFragments({
|
|
212
|
+
torrent, fileIndex: 0, start: 0, end: 8000 * PIECE - 1,
|
|
213
|
+
cancellation: { isCancelled: () => false },
|
|
214
|
+
windowBytes: WINDOW_PIECES * PIECE
|
|
215
|
+
});
|
|
216
|
+
const tail = readFragments({
|
|
217
|
+
torrent, fileIndex: 0, start: 4000 * PIECE, end: 8000 * PIECE - 1,
|
|
218
|
+
cancellation: { isCancelled: () => false },
|
|
219
|
+
windowBytes: WINDOW_PIECES * PIECE
|
|
220
|
+
});
|
|
221
|
+
const headPending = head.next();
|
|
222
|
+
const tailPending = tail.next();
|
|
223
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
224
|
+
|
|
225
|
+
const pieceOf = (range) => Number(range.split("-")[0]);
|
|
226
|
+
const held = [...torrent.held];
|
|
227
|
+
assert.ok(held.some((range) => pieceOf(range) < 4000), "the head reader claimed nothing");
|
|
228
|
+
assert.ok(held.some((range) => pieceOf(range) >= 4000), "the tail reader claimed nothing");
|
|
229
|
+
|
|
230
|
+
// Let both through to a yield, so ending them runs their `finally` at once.
|
|
231
|
+
arrived = true;
|
|
232
|
+
torrent.emit("verified", 0);
|
|
233
|
+
torrent.emit("verified", 4000);
|
|
234
|
+
(await headPending).value.release();
|
|
235
|
+
(await tailPending).value.release();
|
|
236
|
+
|
|
237
|
+
await tail.return();
|
|
238
|
+
const after = [...torrent.held];
|
|
239
|
+
assert.ok(
|
|
240
|
+
after.every((range) => pieceOf(range) < 4000),
|
|
241
|
+
"the tail reader left its claim behind"
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
await head.return();
|
|
245
|
+
assert.deepEqual(torrent.held, [], "the last reader left something behind");
|
|
246
|
+
} finally {
|
|
247
|
+
store.destroy(() => undefined);
|
|
248
|
+
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("criticality marks the window being waited for, not the whole range", async () => {
|
|
253
|
+
// Nothing is present, so the reader blocks on its first piece and marks it.
|
|
254
|
+
let arrived = false;
|
|
255
|
+
const { torrent, store, directory } = await recordingTorrent({
|
|
256
|
+
pieceCount: 8000,
|
|
257
|
+
present: () => arrived
|
|
258
|
+
});
|
|
259
|
+
try {
|
|
260
|
+
const iterator = readFragments({
|
|
261
|
+
torrent, fileIndex: 0, start: 0, end: 8000 * PIECE - 1,
|
|
262
|
+
cancellation: { isCancelled: () => false },
|
|
263
|
+
windowBytes: WINDOW_PIECES * PIECE
|
|
264
|
+
});
|
|
265
|
+
const pending = iterator.next();
|
|
266
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
267
|
+
|
|
268
|
+
const criticals = torrent.calls.filter((entry) => entry.call === "critical");
|
|
269
|
+
assert.equal(criticals.length, 1);
|
|
270
|
+
const marked = criticals[0].to - criticals[0].from + 1;
|
|
271
|
+
// The window, and nothing beyond it. Marking only the blocked piece made
|
|
272
|
+
// the pieces after it arrive strictly one at a time — measured, the first
|
|
273
|
+
// segment after a seek took 7.2 s for four pieces. Marking the whole
|
|
274
|
+
// requested range would be the old mistake: for ffmpeg's input that is
|
|
275
|
+
// every piece to the end of the file, and the flag stops meaning anything.
|
|
276
|
+
assert.equal(marked, WINDOW_PIECES, `marked ${marked} pieces critical`);
|
|
277
|
+
assert.ok(criticals[0].to < 8000 - 1, "criticality must not reach the end of the file");
|
|
278
|
+
|
|
279
|
+
arrived = true;
|
|
280
|
+
torrent.emit("verified", 0);
|
|
281
|
+
(await pending).value.release();
|
|
282
|
+
await iterator.return();
|
|
283
|
+
} finally {
|
|
284
|
+
store.destroy(() => undefined);
|
|
285
|
+
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("a reader that is abandoned mid-fragment does not keep the piece pinned", async () => {
|
|
290
|
+
// The pin is taken before the fragment is handed out and released by the
|
|
291
|
+
// consumer — but a seek abandons the iterator between two fragments, and the
|
|
292
|
+
// consumer never gets the chance. Field 2026-08-06: after one seek every slot
|
|
293
|
+
// in the store was pinned, the store answered `Every resident piece is
|
|
294
|
+
// pinned; no slot can be freed` to the WebTorrent client, which closed the
|
|
295
|
+
// store and destroyed the torrent.
|
|
296
|
+
const { torrent, store, directory } = await recordingTorrent({ pieceCount: 40 });
|
|
297
|
+
try {
|
|
298
|
+
const iterator = readFragments({
|
|
299
|
+
torrent,
|
|
300
|
+
fileIndex: 0,
|
|
301
|
+
start: 0,
|
|
302
|
+
end: 40 * PIECE - 1,
|
|
303
|
+
cancellation: { isCancelled: () => false },
|
|
304
|
+
windowBytes: WINDOW_PIECES * PIECE
|
|
305
|
+
});
|
|
306
|
+
await iterator.next(); // held, deliberately NOT released
|
|
307
|
+
await iterator.return(); // what a seek does
|
|
308
|
+
|
|
309
|
+
assert.equal(
|
|
310
|
+
store.stats().pinned,
|
|
311
|
+
0,
|
|
312
|
+
"the abandoned fragment's piece is still pinned; slots leak one per seek"
|
|
313
|
+
);
|
|
314
|
+
} finally {
|
|
315
|
+
store.destroy(() => undefined);
|
|
316
|
+
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// ------------------------------------------ the window that grows into a lead
|
|
321
|
+
|
|
322
|
+
test("a wait that mattered widens the window by a piece", () => {
|
|
323
|
+
// Field 2026-08-17: 5.1-5.9 MB/s delivered against ~1 MB/s consumed, and the
|
|
324
|
+
// reader still blocked 47 times in two minutes. The surplus never became
|
|
325
|
+
// distance ahead of the head.
|
|
326
|
+
assert.equal(
|
|
327
|
+
nextWindowPieces({ current: 4, base: 4, ceiling: 12, waitedMs: 1457, waitThresholdMs: 1000 }),
|
|
328
|
+
5
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("a piece that was already there gives a piece back", () => {
|
|
333
|
+
assert.equal(
|
|
334
|
+
nextWindowPieces({ current: 7, base: 4, ceiling: 12, waitedMs: 0, waitThresholdMs: 1000 }),
|
|
335
|
+
6
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("it never shrinks below what the caller asked for", () => {
|
|
340
|
+
assert.equal(
|
|
341
|
+
nextWindowPieces({ current: 4, base: 4, ceiling: 12, waitedMs: 0, waitThresholdMs: 1000 }),
|
|
342
|
+
4
|
|
343
|
+
);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("it never grows past this reader's share of the store", () => {
|
|
347
|
+
assert.equal(
|
|
348
|
+
nextWindowPieces({ current: 12, base: 4, ceiling: 12, waitedMs: 4453, waitThresholdMs: 1000 }),
|
|
349
|
+
12
|
|
350
|
+
);
|
|
351
|
+
// A ceiling below the base cannot pull the window under it: the caller sized
|
|
352
|
+
// the base from the file's own byte rate, and a store too small to hold it is
|
|
353
|
+
// an argument about memory, not about what the reader needs next.
|
|
354
|
+
assert.equal(
|
|
355
|
+
nextWindowPieces({ current: 4, base: 4, ceiling: 1, waitedMs: 2000, waitThresholdMs: 1000 }),
|
|
356
|
+
4
|
|
357
|
+
);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("a wait exactly at the threshold counts as a wait", () => {
|
|
361
|
+
assert.equal(
|
|
362
|
+
nextWindowPieces({ current: 4, base: 4, ceiling: 9, waitedMs: 1000, waitThresholdMs: 1000 }),
|
|
363
|
+
5
|
|
364
|
+
);
|
|
365
|
+
});
|