@torrent-tv/proxy 2.89.7 → 2.89.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (498) hide show
  1. package/.claude/commands/opsx/apply.md +155 -155
  2. package/.claude/commands/opsx/archive.md +160 -160
  3. package/.claude/commands/opsx/explore.md +174 -174
  4. package/.claude/commands/opsx/propose.md +109 -109
  5. package/.claude/commands/opsx/sync.md +143 -143
  6. package/.claude/skills/openspec-apply-change/SKILL.md +159 -159
  7. package/.claude/skills/openspec-archive-change/SKILL.md +117 -117
  8. package/.claude/skills/openspec-explore/SKILL.md +289 -289
  9. package/.claude/skills/openspec-propose/SKILL.md +113 -113
  10. package/.claude/skills/openspec-sync-specs/SKILL.md +147 -147
  11. package/.dockerignore +5 -5
  12. package/AGENTS.md +363 -338
  13. package/CHANGELOG.md +2345 -2335
  14. package/Dockerfile +30 -30
  15. package/LICENSE +16 -16
  16. package/README.md +369 -369
  17. package/assets/calibration/NOTICE.md +100 -100
  18. package/assets/calibration/audio/NOTICE.md +19 -19
  19. package/assets/calibration/audio/manifest.json +98 -98
  20. package/assets/diagnostics/sctpstate.gdb +31 -31
  21. package/bin/cli.js +684 -684
  22. package/bin/thread-pool.js +30 -30
  23. package/biome.json +412 -412
  24. package/docs/browser-proxy-contract.md +155 -155
  25. package/docs/container-architecture.md +273 -273
  26. package/docs/download-architecture.md +261 -261
  27. package/docs/encode-architecture.md +765 -765
  28. package/docs/encode-run-state.md +96 -96
  29. package/docs/logs.md +97 -97
  30. package/docs/playback-start-forecast.md +113 -113
  31. package/docs/socks5-egress-research.md +114 -114
  32. package/docs/storage-architecture.md +273 -273
  33. package/knip.json +14 -14
  34. package/openspec/changes/adaptive-bitrate/design.md +92 -92
  35. package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
  36. package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
  37. package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
  38. package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
  39. package/openspec/changes/binary-distribution/proposal.md +53 -53
  40. package/openspec/changes/chunked-request-bodies/design.md +81 -81
  41. package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
  42. package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
  43. package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
  44. package/openspec/changes/cold-start/design.md +98 -98
  45. package/openspec/changes/cold-start/proposal.md +62 -62
  46. package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
  47. package/openspec/changes/cold-start/tasks.md +48 -48
  48. package/openspec/changes/disk-cap/.openspec.yaml +2 -2
  49. package/openspec/changes/disk-cap/proposal.md +37 -37
  50. package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
  51. package/openspec/changes/disk-cap/tasks.md +19 -19
  52. package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
  53. package/openspec/changes/proxy-observability/design.md +38 -38
  54. package/openspec/changes/proxy-observability/proposal.md +49 -49
  55. package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
  56. package/openspec/changes/proxy-observability/tasks.md +21 -21
  57. package/openspec/changes/seek-debounce/design.md +118 -118
  58. package/openspec/changes/seek-debounce/proposal.md +59 -59
  59. package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
  60. package/openspec/changes/seek-debounce/tasks.md +33 -33
  61. package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
  62. package/openspec/changes/subtitle-language/proposal.md +50 -50
  63. package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
  64. package/openspec/changes/subtitle-language/tasks.md +26 -26
  65. package/openspec/changes/track-selection/.openspec.yaml +2 -2
  66. package/openspec/changes/track-selection/design.md +47 -47
  67. package/openspec/changes/track-selection/proposal.md +45 -45
  68. package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
  69. package/openspec/changes/track-selection/tasks.md +20 -20
  70. package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
  71. package/openspec/changes/transcode-quality/proposal.md +51 -51
  72. package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
  73. package/openspec/changes/transcode-quality/tasks.md +63 -63
  74. package/openspec/config.yaml +38 -38
  75. package/package.json +51 -50
  76. package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
  77. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  78. package/research/double-spawn-2026-09-10.md +171 -171
  79. package/research/handover-2026-09-13.md +209 -209
  80. package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
  81. package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
  82. package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
  83. package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
  84. package/routes/api/client-logs/post.js +86 -86
  85. package/routes/api/delivery-sink/get.js +72 -72
  86. package/routes/api/link-probe/get.js +51 -51
  87. package/routes/api/playback-plan/audio-tracks/post.js +29 -29
  88. package/routes/api/playback-plan/post.js +53 -53
  89. package/routes/api/sources/files/get.js +106 -106
  90. package/routes/api/sources/post.js +36 -36
  91. package/routes/api/sources/stats/get.js +132 -132
  92. package/routes/api/sources/warm/post.js +217 -217
  93. package/routes/api/subtitles/get.js +230 -230
  94. package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
  95. package/routes/api/transcode-sessions/net-report/post.js +138 -138
  96. package/routes/api/transcode-sessions/post.js +178 -178
  97. package/routes/api/transcode-sessions/progress/get.js +27 -27
  98. package/routes/api/transcode-sessions/release/post.js +41 -41
  99. package/routes/api/transcode-sessions/seek/post.js +63 -63
  100. package/routes/health/get.js +13 -13
  101. package/routes/healthz/get.js +13 -13
  102. package/routes/stream/get.js +416 -416
  103. package/routes/transcode/audio-file/get.js +64 -64
  104. package/routes/transcode/audio-warm/get.js +95 -95
  105. package/routes/transcode/session-file/get.js +52 -52
  106. package/routes/transcode/variant-file/get.js +95 -95
  107. package/routes/transcode/variant-warm/get.js +84 -84
  108. package/scripts/build-audio-samples.mjs +31 -31
  109. package/scripts/render-run-graph.js +173 -173
  110. package/server.js +748 -748
  111. package/services/encode/CoverageMap.js +428 -428
  112. package/services/encode/CushionReport.js +345 -345
  113. package/services/encode/EncodeAdmission.js +376 -376
  114. package/services/encode/EncodeOrchestrator.js +1192 -1192
  115. package/services/encode/EncodePlan.js +1368 -1368
  116. package/services/encode/EncodeRun.js +36 -12
  117. package/services/encode/Encoder.js +142 -142
  118. package/services/encode/EncoderSelection.js +38 -38
  119. package/services/encode/LocalObservations.js +359 -359
  120. package/services/encode/NvencEncoder.js +50 -50
  121. package/services/encode/OutputOpening.js +784 -784
  122. package/services/encode/OutputTimes.js +579 -579
  123. package/services/encode/QsvEncoder.js +58 -58
  124. package/services/encode/Renditions.js +2283 -2283
  125. package/services/encode/RunClock.js +87 -87
  126. package/services/encode/RunProgress.js +79 -79
  127. package/services/encode/SegmentDemand.js +95 -95
  128. package/services/encode/SoftwareEncoder.js +121 -121
  129. package/services/encode/V4l2m2mEncoder.js +53 -53
  130. package/services/encode/VaapiEncoder.js +66 -66
  131. package/services/encode/args.js +418 -418
  132. package/services/encode/audio-calibration.js +67 -67
  133. package/services/encode/audio-work-rate.js +15 -15
  134. package/services/encode/available-share.js +89 -89
  135. package/services/encode/calibration.js +361 -361
  136. package/services/encode/contention.js +128 -128
  137. package/services/encode/decode-cost-fit.js +363 -363
  138. package/services/encode/encode-exit.js +116 -116
  139. package/services/encode/encode-run-state.js +551 -551
  140. package/services/encode/encoder-readings.js +43 -43
  141. package/services/encode/fingerprint.js +322 -322
  142. package/services/encode/host-load.js +174 -174
  143. package/services/encode/hwaccel.js +1894 -1894
  144. package/services/encode/index.js +9 -9
  145. package/services/encode/learned-median.js +90 -90
  146. package/services/encode/output/EncodedOutput.js +57 -57
  147. package/services/encode/output/Output.js +131 -131
  148. package/services/encode/output/OutputCatalog.js +414 -414
  149. package/services/encode/output/OutputSpec.js +390 -390
  150. package/services/encode/output/Timeline.js +308 -308
  151. package/services/encode/output/cut-grid.js +107 -107
  152. package/services/encode/output/index.js +24 -24
  153. package/services/encode/output/ladder.js +32 -32
  154. package/services/encode/output/playlists.js +284 -284
  155. package/services/encode/output/rates.js +125 -125
  156. package/services/encode/output-key-format.js +21 -21
  157. package/services/encode/quality/EncodeCost.js +1043 -1043
  158. package/services/encode/quality/HostLoad.js +525 -525
  159. package/services/encode/quality/HostTimings.js +249 -249
  160. package/services/encode/quality/OutputQualityState.js +24 -24
  161. package/services/encode/quality/QualityController.js +758 -758
  162. package/services/encode/quality/QualityOffer.js +416 -416
  163. package/services/encode/quality/drain-threat.js +47 -47
  164. package/services/encode/quality/link-budget.js +337 -337
  165. package/services/encode/quality/output-format.js +319 -319
  166. package/services/encode/quality/serving-output.js +216 -216
  167. package/services/encode/quality/visible-rung.js +57 -57
  168. package/services/encode/run-budget.js +97 -97
  169. package/services/encode/run-command.js +757 -757
  170. package/services/encode/run-costs.js +155 -155
  171. package/services/encode/run-inputs.js +53 -53
  172. package/services/encode/segment-formats/fmp4.js +476 -476
  173. package/services/encode/segment-formats/init-compat.js +324 -324
  174. package/services/encode/segment-formats/mpegts.js +134 -134
  175. package/services/encode/start-stop-cost.js +178 -178
  176. package/services/encode/throughput.js +97 -97
  177. package/services/encode/torrent-cost.js +88 -88
  178. package/services/identity/installation-id.js +38 -38
  179. package/services/media/ContainerOrchestrator.js +202 -202
  180. package/services/media/KeyframeTables.js +356 -356
  181. package/services/media/SourceFile.js +298 -298
  182. package/services/media/SubtitleCues.js +592 -592
  183. package/services/media/SubtitleOrchestrator.js +167 -167
  184. package/services/media/audio-inventory.js +299 -299
  185. package/services/media/container/AviContainer.js +268 -268
  186. package/services/media/container/Container.js +632 -632
  187. package/services/media/container/ContainerFactory.js +38 -38
  188. package/services/media/container/KeyframeTable.js +272 -272
  189. package/services/media/container/MatroskaContainer.js +1031 -1031
  190. package/services/media/container/Mp4Container.js +1354 -1354
  191. package/services/media/container/SubtitleFileContainer.js +339 -339
  192. package/services/media/container/ebml-reader.js +159 -159
  193. package/services/media/container/ebml-stream.js +174 -174
  194. package/services/media/container/matroska-clusters.js +1165 -1165
  195. package/services/media/container/unavailable.js +77 -77
  196. package/services/media/ffmpeg-banner.js +260 -260
  197. package/services/media/keyframe-probe.js +157 -157
  198. package/services/media/media-info-probe.js +93 -93
  199. package/services/media/playback-planner.js +931 -931
  200. package/services/media/tracks/AudioTrack.js +143 -143
  201. package/services/media/tracks/ContainerTrack.js +84 -84
  202. package/services/media/tracks/ImageSubtitleTrack.js +19 -19
  203. package/services/media/tracks/SubtitleTrack.js +38 -38
  204. package/services/media/tracks/TextSubtitleTrack.js +295 -295
  205. package/services/media/tracks/VideoTrack.js +33 -33
  206. package/services/media/tracks/audio-bound.js +233 -233
  207. package/services/media/tracks/index.js +15 -15
  208. package/services/media/tracks/language-detect.js +174 -174
  209. package/services/server/OutputLifecycle.js +418 -418
  210. package/services/server/SegmentServing.js +1152 -1152
  211. package/services/server/ViewerRequests.js +785 -785
  212. package/services/server/WaitLedger.js +142 -142
  213. package/services/server/controllers/PlaybackController.js +40 -40
  214. package/services/server/controllers/SubtitleController.js +109 -109
  215. package/services/server/http-responses.js +60 -60
  216. package/services/server/transcode-session-files.js +325 -325
  217. package/services/server/wire-outputs.js +1029 -1029
  218. package/services/storage/Diagnostics.js +202 -202
  219. package/services/storage/MachineBudget.js +235 -235
  220. package/services/storage/allowance.js +107 -107
  221. package/services/storage/core-dumps.js +106 -106
  222. package/services/storage/files/CompletedFiles.js +391 -391
  223. package/services/storage/files/piece-from-whole-file.js +118 -118
  224. package/services/storage/free.js +71 -71
  225. package/services/storage/keep.js +48 -48
  226. package/services/storage/machine-memory.js +56 -56
  227. package/services/storage/memory-report.js +575 -575
  228. package/services/storage/piece-store/piece-disk-store.js +539 -539
  229. package/services/storage/piece-store/piece-lru.js +495 -495
  230. package/services/storage/piece-store/shared-piece-store.js +1937 -1937
  231. package/services/storage/returns.js +103 -103
  232. package/services/storage/segment-store/SegmentStore.js +1294 -1294
  233. package/services/storage/wire.js +175 -175
  234. package/services/torrent/Contents.js +382 -382
  235. package/services/torrent/demand/DemandRegister.js +202 -202
  236. package/services/torrent/demand/Urgency.js +137 -137
  237. package/services/torrent/demand/Window.js +118 -118
  238. package/services/torrent/demand/index.js +8 -8
  239. package/services/torrent/demand/pieces.js +140 -140
  240. package/services/torrent/download/SwarmSelection.js +386 -386
  241. package/services/torrent/download/registry.js +102 -102
  242. package/services/torrent/download/withdraw-claim.js +80 -80
  243. package/services/torrent/episode-naming.js +182 -182
  244. package/services/torrent/files.js +455 -455
  245. package/services/torrent/naming.js +619 -619
  246. package/services/torrent/piece-store-of.js +29 -29
  247. package/services/torrent/supply-margin.js +264 -264
  248. package/services/torrent/torrent-pool.js +2688 -2688
  249. package/services/torrent/worker/background-fill.js +213 -213
  250. package/services/torrent/worker/channel.js +271 -271
  251. package/services/torrent/worker/client.js +836 -836
  252. package/services/torrent/worker/fastest-wires.js +342 -342
  253. package/services/torrent/worker/handle-state.js +23 -23
  254. package/services/torrent/worker/held-bytes.js +231 -231
  255. package/services/torrent/worker/install-webrtc-shim.js +32 -32
  256. package/services/torrent/worker/piece-reader.js +1078 -1078
  257. package/services/torrent/worker/pool-adapter.js +371 -371
  258. package/services/torrent/worker/protocol.js +184 -184
  259. package/services/torrent/worker/resume-warm.js +107 -107
  260. package/services/torrent/worker/webrtc-shim.js +219 -219
  261. package/services/torrent/worker/worker.js +1137 -1137
  262. package/services/transport/data-channel-handler.js +1406 -1373
  263. package/services/transport/delivery-probe.js +798 -842
  264. package/services/transport/health-collector.js +53 -53
  265. package/services/transport/nat-classifier.js +231 -231
  266. package/services/transport/packet-witness.js +808 -808
  267. package/services/transport/port-mapper.js +235 -235
  268. package/services/transport/registry-api.js +81 -81
  269. package/services/transport/tunnel-client.js +447 -447
  270. package/services/transport/usrsctp-state.js +175 -175
  271. package/services/transport/webrtc-manager.js +484 -484
  272. package/services/viewer/Assignments.js +340 -340
  273. package/services/viewer/PriorityMap.js +421 -421
  274. package/services/viewer/PriorityOrchestrator.js +325 -325
  275. package/services/viewer/Viewer.js +691 -691
  276. package/services/viewer/Viewers.js +368 -368
  277. package/services/viewer/active-output.js +37 -37
  278. package/services/viewer/audio-start.js +46 -46
  279. package/services/viewer/buffer-trend.js +81 -81
  280. package/services/viewer/choices.js +647 -647
  281. package/services/viewer/media-time.js +355 -355
  282. package/services/viewer/playback-readiness.js +635 -635
  283. package/services/viewer/positions.js +126 -126
  284. package/services/viewer/report-intake.js +46 -46
  285. package/store/source-registry.js +71 -71
  286. package/test/asset-paths.test.js +28 -28
  287. package/test/audio-buffer-continuity.test.js +77 -77
  288. package/test/audio-calibration.test.js +23 -23
  289. package/test/audio-inventory.test.js +212 -212
  290. package/test/audio-track-bitrate.test.js +40 -40
  291. package/test/auto-quality-step.test.js +762 -762
  292. package/test/available-share.test.js +76 -76
  293. package/test/background-fill.test.js +190 -190
  294. package/test/client-log-file.test.js +110 -110
  295. package/test/coalesce.test.js +39 -39
  296. package/test/completed-files.test.js +115 -115
  297. package/test/concurrent-cost.test.js +148 -148
  298. package/test/container-media-info.test.js +228 -228
  299. package/test/contention.test.js +76 -76
  300. package/test/core-dumps.test.js +57 -57
  301. package/test/coverage-follows-the-disk.test.js +233 -228
  302. package/test/coverage-map.test.js +195 -195
  303. package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
  304. package/test/cut-times-timeline.test.js +65 -65
  305. package/test/cuts-follow-published-grid.test.js +109 -109
  306. package/test/data-channel-frame.test.js +63 -63
  307. package/test/data-channel-viewer-lifecycle.test.js +162 -92
  308. package/test/dead-torrent-handle.test.js +52 -52
  309. package/test/declared-rates.test.js +116 -116
  310. package/test/declared-tracks.test.js +43 -43
  311. package/test/decode-cost-fit.test.js +202 -202
  312. package/test/decode-cost.test.js +475 -475
  313. package/test/delivery-probe-field.test.js +173 -0
  314. package/test/delivery-probe.test.js +382 -393
  315. package/test/demand-register.test.js +206 -206
  316. package/test/dht-bootstrap.test.js +72 -72
  317. package/test/drain-threat.test.js +65 -65
  318. package/test/duplicate-add.test.js +48 -48
  319. package/test/encode-admission.test.js +146 -146
  320. package/test/encode-cost.test.js +275 -275
  321. package/test/encode-exit.test.js +111 -111
  322. package/test/encode-orchestrator.test.js +426 -426
  323. package/test/encode-plan-viewers.test.js +725 -725
  324. package/test/encode-plan.test.js +545 -545
  325. package/test/encode-run-state.test.js +314 -314
  326. package/test/encode-run.test.js +20 -0
  327. package/test/encode-slope.test.js +46 -46
  328. package/test/encoded-output.test.js +39 -39
  329. package/test/encoder-kinds.test.js +122 -122
  330. package/test/encoder-readings.test.js +68 -68
  331. package/test/episode-naming.test.js +131 -131
  332. package/test/every-claimant-is-told-its-share.test.js +152 -152
  333. package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
  334. package/test/failed-start-breaker.test.js +202 -202
  335. package/test/fastest-wires.test.js +143 -143
  336. package/test/ffmpeg-stream-counts.test.js +78 -78
  337. package/test/file-edges.test.js +191 -191
  338. package/test/fixtures/audio-frame-boundary.json +1 -1
  339. package/test/fmp4-self-contained.test.js +80 -80
  340. package/test/fmp4-timestamps.test.js +140 -140
  341. package/test/fmp4-track-completeness.test.js +41 -41
  342. package/test/format-keyed-outputs.test.js +78 -78
  343. package/test/health-metrics.test.js +36 -38
  344. package/test/held-read-ownership.test.js +77 -77
  345. package/test/held-request-width.test.js +157 -157
  346. package/test/helpers/encode-run.js +248 -240
  347. package/test/helpers/held-file.js +77 -77
  348. package/test/helpers/manager.js +62 -62
  349. package/test/helpers/matroska-file.js +296 -296
  350. package/test/helpers/output-spec.js +63 -63
  351. package/test/host-load.test.js +68 -68
  352. package/test/index-accuracy-is-about-the-file.test.js +128 -128
  353. package/test/init-compat.test.js +219 -219
  354. package/test/input-lost-quiets-the-plan.test.js +261 -261
  355. package/test/input-unavailable.test.js +48 -48
  356. package/test/installation-id.test.js +39 -39
  357. package/test/keeping-period.test.js +83 -83
  358. package/test/keyframe-index-accuracy.test.js +188 -188
  359. package/test/keyframe-table-budget.test.js +123 -123
  360. package/test/keyframes-belong-to-the-file.test.js +249 -249
  361. package/test/learned-median.test.js +37 -37
  362. package/test/letting-go-asks-who-is-watching.test.js +191 -191
  363. package/test/limit-row-by-area.test.js +60 -60
  364. package/test/link-budget.test.js +111 -111
  365. package/test/link-probe.test.js +56 -56
  366. package/test/local-encoding-data.test.js +273 -273
  367. package/test/logger-repeats.test.js +134 -134
  368. package/test/look-ahead-coverage.test.js +56 -56
  369. package/test/manager-surface.test.js +291 -291
  370. package/test/matroska-cluster-discovery.test.js +323 -323
  371. package/test/matroska-cues-track.test.js +192 -192
  372. package/test/media-time.test.js +62 -62
  373. package/test/memory-budget.test.js +390 -390
  374. package/test/move-cost.test.js +216 -216
  375. package/test/mp4-subtitles.test.js +173 -173
  376. package/test/one-authority.test.js +285 -285
  377. package/test/one-budget.test.js +149 -149
  378. package/test/one-output-one-session.test.js +273 -273
  379. package/test/one-resource-divided.test.js +185 -185
  380. package/test/one-serving-output-per-height.test.js +458 -458
  381. package/test/orchestrator-wired.test.js +207 -207
  382. package/test/output-catalog.test.js +39 -39
  383. package/test/output-shape.test.js +21 -21
  384. package/test/output-spec.test.js +251 -251
  385. package/test/output-speed.test.js +92 -87
  386. package/test/outputs-being-prepared.test.js +40 -40
  387. package/test/packet-witness-ring.test.js +236 -236
  388. package/test/packet-witness.test.js +148 -148
  389. package/test/piece-disk-store.test.js +401 -401
  390. package/test/piece-from-whole-file.test.js +129 -129
  391. package/test/piece-lru.test.js +319 -319
  392. package/test/piece-reader.test.js +162 -162
  393. package/test/piece-selection-offset.test.js +85 -85
  394. package/test/piece-store-eviction.test.js +595 -595
  395. package/test/piece-store-never-refuses.test.js +153 -153
  396. package/test/piece-store-reservations.test.js +403 -403
  397. package/test/piece-store-slow-disk.test.js +159 -159
  398. package/test/piece-tail.test.js +97 -97
  399. package/test/plan-host-timings.test.js +68 -68
  400. package/test/plan-is-steady.test.js +225 -225
  401. package/test/playback-readiness.test.js +518 -518
  402. package/test/playback-start-regressions.test.js +101 -101
  403. package/test/priority-map-download.test.js +174 -174
  404. package/test/priority-map-emptied.test.js +204 -204
  405. package/test/priority-map-per-output.test.js +215 -215
  406. package/test/priority-map.test.js +163 -163
  407. package/test/probe-wedge-certainty.test.js +60 -60
  408. package/test/produced-copy-choice.test.js +213 -213
  409. package/test/published-timeline.test.js +83 -83
  410. package/test/quality-offer.test.js +160 -160
  411. package/test/quality-variants.test.js +1274 -1270
  412. package/test/rate-control-in-the-output.test.js +181 -180
  413. package/test/read-survives-withdrawal.test.js +164 -164
  414. package/test/read-window.test.js +365 -365
  415. package/test/reads-not-yet-arrived.test.js +218 -218
  416. package/test/resume-warm.test.js +39 -39
  417. package/test/run-graph-drift.test.js +37 -37
  418. package/test/run-inputs.test.js +80 -80
  419. package/test/run-intervals.test.js +100 -100
  420. package/test/run-position-follows-published-grid.test.js +89 -89
  421. package/test/run-progress.test.js +64 -64
  422. package/test/same-height-switch.test.js +663 -663
  423. package/test/seek-frees-the-viewers-requests.test.js +217 -217
  424. package/test/seek-landing.test.js +109 -109
  425. package/test/seek-target-not-superseded.test.js +195 -195
  426. package/test/segment-closed-on-both-branches.test.js +114 -114
  427. package/test/segment-cut-times.test.js +63 -63
  428. package/test/segment-demand.test.js +80 -80
  429. package/test/segment-hold-supersede.test.js +91 -91
  430. package/test/segment-judged-by-its-own-header.test.js +222 -222
  431. package/test/segment-serve-wiring.test.js +481 -481
  432. package/test/segment-store-eviction.test.js +247 -247
  433. package/test/segment-store.test.js +352 -352
  434. package/test/segment-true-start.test.js +86 -86
  435. package/test/segments-are-shared.test.js +176 -176
  436. package/test/send-chunk-bytes.test.js +86 -86
  437. package/test/serving-existing-output.test.js +111 -111
  438. package/test/serving-output.test.js +102 -102
  439. package/test/shared-piece-store.test.js +310 -310
  440. package/test/sidecar-naming.test.js +142 -142
  441. package/test/soundtrack-rate-bound.test.js +289 -289
  442. package/test/source-file.test.js +133 -133
  443. package/test/source-files-route.test.js +113 -113
  444. package/test/source-registry.test.js +18 -18
  445. package/test/split-a-stretch.test.js +139 -139
  446. package/test/stale-request-after-seek.test.js +158 -158
  447. package/test/startup-readings.test.js +124 -124
  448. package/test/stream-route.test.js +237 -237
  449. package/test/subtitle-cue-framing.test.js +200 -200
  450. package/test/subtitle-cue-source.test.js +102 -102
  451. package/test/subtitle-cue-walk.test.js +385 -385
  452. package/test/subtitle-cursor.test.js +63 -63
  453. package/test/subtitle-defaults.test.js +118 -118
  454. package/test/subtitle-language.test.js +252 -252
  455. package/test/subtitle-metadata.test.js +15 -15
  456. package/test/subtitle-plan-waits.test.js +125 -125
  457. package/test/subtitle-track-numbering.test.js +391 -391
  458. package/test/supply-margin.test.js +190 -190
  459. package/test/swarm-follows-readers.test.js +174 -174
  460. package/test/swarm-reach.test.js +130 -130
  461. package/test/swarm-selection.test.js +220 -220
  462. package/test/tail-duplication.test.js +167 -167
  463. package/test/the-channel-has-both-ends.test.js +91 -91
  464. package/test/the-torrent-thread-serves-bytes.test.js +107 -107
  465. package/test/thread-pool.test.js +53 -53
  466. package/test/timeline.test.js +84 -84
  467. package/test/torrent-contents.test.js +235 -235
  468. package/test/torrent-cost.test.js +71 -71
  469. package/test/torrent-files.test.js +221 -221
  470. package/test/torrent-naming.test.js +255 -255
  471. package/test/tracks-begin-together.test.js +209 -209
  472. package/test/tunnel-renewal.test.js +167 -167
  473. package/test/two-viewers-one-picture.test.js +508 -508
  474. package/test/upload-hurry.test.js +186 -186
  475. package/test/usrsctp-state.test.js +131 -131
  476. package/test/video-facts.test.js +102 -102
  477. package/test/viewer-assignments.test.js +143 -143
  478. package/test/viewer-outputs.test.js +285 -285
  479. package/test/viewer-presence.test.js +116 -116
  480. package/test/viewer-requests-admission.test.js +92 -92
  481. package/test/viewer-starts-on-map.test.js +27 -27
  482. package/test/viewer-states.test.js +192 -189
  483. package/test/viewer-subtitle-subscription.test.js +96 -96
  484. package/test/viewer.test.js +139 -139
  485. package/test/viewing-generations.test.js +390 -390
  486. package/test/visible-rung.test.js +37 -37
  487. package/test/wait-ledger.test.js +100 -100
  488. package/test/webrtc-shim.test.js +149 -149
  489. package/test/wedge-certainty.test.js +131 -131
  490. package/test/whole-file-held-read.test.js +30 -30
  491. package/test/withdraw-piece-claim.test.js +212 -212
  492. package/test/worker-channel.test.js +158 -158
  493. package/utils/client-log-file.js +151 -151
  494. package/utils/coalesce.js +41 -41
  495. package/utils/logger.js +312 -312
  496. package/utils/parse-range.js +25 -25
  497. package/utils/perf.js +59 -59
  498. package/utils/torrent-source-key.js +38 -38
@@ -1,1373 +1,1406 @@
1
- /**
2
- * @file WebRTC data channel request handler (proxy side).
3
- *
4
- * When a browser opens a data channel to this proxy, this handler wires up
5
- * message handlers that implement an HTTP-over-DataChannel protocol:
6
- * each incoming `request` message triggers a local `fetch` to the Fastify
7
- * server, and the response is streamed back as base64-encoded chunks.
8
- *
9
- * ## Wire protocol
10
- *
11
- * Browser → Proxy
12
- * ```
13
- * { type: "request", requestId, method, path, query, headers, body }
14
- * { type: "ping", id }
15
- * { type: "probe-echo", seen: { <label>: seq }, report }
16
- * ```
17
- *
18
- * Proxy → Browser
19
- * ```
20
- * { type: "probe", seq, sentAt } (JSON string)
21
- * { type: "response-start", requestId, status, headers } (JSON string)
22
- * { type: "response-error", requestId, error: string } (JSON string)
23
- * { type: "pong", id } (JSON string)
24
- * { type: "subtitle-cues", fileIndex, trackIndex, cues, language, cursor } (JSON string)
25
- * ```
26
- * The last one is unsolicited — sent the moment new cues are read from a
27
- * file's already-downloaded pieces, to whichever channel last asked for that
28
- * file's subtitles over `/api/subtitles`. Not a response to any `requestId`.
29
- *
30
- * Response bodies are sent as BINARY data-channel messages (not JSON), to
31
- * avoid the ~33% base64 overhead and the JSON encode/decode cost. Each binary
32
- * frame is laid out as:
33
- * ```
34
- * byte 0 flags (bit 0: done)
35
- * byte 1 idLen (length of the requestId in bytes)
36
- * bytes 2..2+N requestId (ASCII)
37
- * bytes 2+N.. payload (raw body bytes; empty on the final done frame)
38
- * ```
39
- * Control messages stay JSON strings so the browser can distinguish them from
40
- * body frames by message type (string vs ArrayBuffer).
41
- *
42
- * The protocol mirrors the tunnel relay protocol so both transports share
43
- * the same mental model and the same browser-side `WebRtcProxy` implementation.
44
- */
45
-
46
- /** @import { DataChannel } from 'node-datachannel' */
47
-
48
- import { createDeliveryProbe, PROBE_INTERVAL_MS } from "./delivery-probe.js";
49
-
50
- /**
51
- * Configuration for the data channel handler.
52
- *
53
- * @typedef {Object} DataChannelHandlerOptions
54
- * @property {number} proxyPort
55
- * Local port the proxy's Fastify HTTP server is listening on.
56
- * Incoming requests are forwarded to `http://127.0.0.1:{proxyPort}`.
57
- * @property {(message: string) => void} [onLog]
58
- * Optional log sink.
59
- * @property {{ maybeCapture: (trigger: {
60
- * sessionId: string, tag: string, label: string,
61
- * remote: { address: string, port: number } | null,
62
- * queuedBytes: number, stuckForMs: number
63
- * }) => boolean }} [witness]
64
- * The packet witness (services/transport/packet-witness.js). When {@link wedgeIsCertain}
65
- * says delivery has stopped, the watcher hands it the transport snapshot's
66
- * remote endpoint: the ring's history is kept and a tail capture records what
67
- * the wire actually does. Optional; absent means no captures are taken.
68
- */
69
-
70
- /**
71
- * An incoming request message received over the data channel.
72
- *
73
- * @typedef {Object} DataChannelRequest
74
- * @property {string} requestId
75
- * @property {string} method - HTTP method (GET, POST, …).
76
- * @property {string} path - Request path (e.g. "/api/sources").
77
- * @property {string} query - Raw query string without the leading "?".
78
- * @property {Record<string, string>} headers - Headers to forward.
79
- * @property {string | null} body - Request body string, or null.
80
- */
81
-
82
- /**
83
- * The object returned by {@link createDataChannelHandler}.
84
- *
85
- * @typedef {Object} DataChannelHandler
86
- * @property {(sessionId: string, channel: DataChannel) => void} handleChannel
87
- * Wire message handlers onto a freshly opened data channel.
88
- */
89
-
90
- /**
91
- * How long a wedge must hold before the packet witness is asked for evidence.
92
- *
93
- * Derived per connection rather than chosen, because a chosen number is what
94
- * cost the two field captures their onset: the previous rule waited a flat 30 s
95
- * and the recording therefore began half a minute after the interesting part.
96
- *
97
- * Three quantities, all measured on this connection:
98
- *
99
- * the queue's own drain time — `queuedBytes / bytesPerSecond`, how long a
100
- * healthy channel would need to clear what is sitting in it, at the best rate
101
- * this very connection has been seen to move bytes at;
102
- *
103
- * the longest this connection has EVER paused while healthy — an ordinary
104
- * retransmission timeout stops the accepted-byte counter dead for as long as
105
- * it lasts, because a full send buffer accepts nothing, and a link with loss
106
- * does that routinely. The longest such pause already observed here is what
107
- * the link's own behaviour says a legitimate pause looks like;
108
- *
109
- * the interval at which we offer bytes at all — the delivery probe hands
110
- * every channel a message every {@linkcode PROBE_INTERVAL_MS}, so in health
111
- * the accepted-byte counter cannot stand still for longer than that.
112
- *
113
- * A wedge is certain once ALL of them have passed with the counter unmoved.
114
- * Without a rate there is nothing to divide by, and the function says so
115
- * instead of guessing.
116
- *
117
- * @param {{ queuedBytes: number, bytesPerSecond: number, flatForMs: number, longestHealthyFlatMs?: number }} state
118
- * @returns {{ certain: boolean, needMs: number | null }}
119
- */
120
- export function wedgeIsCertain({ queuedBytes, bytesPerSecond, flatForMs, longestHealthyFlatMs = 0 }) {
121
- if (!(bytesPerSecond > 0)) {
122
- return { certain: false, needMs: null };
123
- }
124
- const drainMs = (Math.max(queuedBytes, 0) / bytesPerSecond) * 1000;
125
- const needMs = Math.max(drainMs, longestHealthyFlatMs, PROBE_INTERVAL_MS);
126
- return { certain: flatForMs >= needMs, needMs };
127
- }
128
-
129
- /**
130
- * Watch one channel's send queue and, when it stops draining, say WHY.
131
- *
132
- * A channel that is open, keeps accepting requests and delivers nothing was
133
- * seen in the field 2026-08-06: the queue grew from 214 049 to 239 731 bytes in
134
- * fourteen seconds and never fell, while every layer above reported success —
135
- * the route answered in 15 ms, the handler sent 378 bytes, the channel was
136
- * open. The viewer sat in front of a spinner for eleven minutes.
137
- *
138
- * `bufferedAmount` alone cannot say why: it only proves the bytes are still
139
- * OURS. The transport counters can, and this is the table the snapshot is read
140
- * against — written down in advance so the answer is a reading, not an opinion:
141
- *
142
- * bytesSent rising, queue rising → packets leave, nothing acknowledges
143
- * them: the return path is broken.
144
- * bytesSent flat, queue rising → SCTP is not transmitting: the peer's
145
- * receive window is shut or congestion
146
- * control has collapsed.
147
- * bytesReceived rising either way → the peer is alive and its packets do
148
- * reach us; the failure is one-way.
149
- * both flat → nothing crosses at all.
150
- *
151
- * Sampled every second; reported only once the queue has failed to fall for
152
- * {@link SEND_QUEUE_STUCK_MS}, then every second while it lasts, so the trend
153
- * of every counter is in the log rather than one snapshot of it.
154
- *
155
- * @param {string} sessionId
156
- * @param {string} tag
157
- * @param {string} label
158
- * @param {DataChannel} channel
159
- * @returns {() => void} Stops the watch.
160
- */
161
- function makeSendQueueWatcher({ log, getTransportSnapshot, witness, usrsctpState, onConnectionGone }) {
162
- // Every channel of one connection reads the SAME transport counters — the
163
- // snapshot describes the peer connection, not the channel — so the heartbeat
164
- // belongs to the connection and is printed once for it. Printed per channel
165
- // it produced two byte-for-byte identical lines (measured 2026-08-14:
166
- // `sent=5153491` under both "proxy" and "proxy-control"), which read as two
167
- // independent readings agreeing and made the second channel invisible: the
168
- // one thing that IS per channel, its queue depth, was the only real
169
- // difference and it was buried in a line that looked like a duplicate.
170
- //
171
- // sessionId → the channels currently open on that connection, and when it was
172
- // last reported. Channels are keyed by the channel OBJECT, not by its label:
173
- // a label is whatever the peer chose and two channels can carry the same one
174
- // (or none, where `getLabel` is missing and both fall back to "?"), and a
175
- // Map keyed on that would let one channel evict the other and then, on
176
- // closing, delete the survivor's entry. `captureStarted` rides on the same
177
- // record: both channels of one wedged connection must ask the witness once,
178
- // not once per channel.
179
- /** @type {Map<string, { channels: Map<DataChannel, string>, at: number, previous: object | null, unknown: number, captureStarted: boolean }>} */
180
- const connections = new Map();
181
-
182
- /**
183
- * What each channel of a connection is holding, right now.
184
- *
185
- * @param {Map<DataChannel, string>} channels
186
- * @returns {string} `label:NB` per channel, in the order they opened.
187
- */
188
- const queueDepths = (channels) => {
189
- const parts = [];
190
- for (const [openChannel, channelLabel] of channels) {
191
- let depth = -1;
192
- try {
193
- depth = typeof openChannel.bufferedAmount === "function" ? openChannel.bufferedAmount() : 0;
194
- } catch {
195
- depth = -1;
196
- }
197
- parts.push(`${channelLabel}:${depth}B`);
198
- }
199
- return parts.join(" ");
200
- };
201
-
202
- /**
203
- * What this connection is getting away, for whoever else needs it.
204
- *
205
- * The delivery probe judges a late probe against the queue ahead of it, and
206
- * the queue's drain time needs a rate. It is measured here already, once a
207
- * second, so it is read from here rather than measured twice.
208
- *
209
- * @param {string} sessionId
210
- * @returns {{ bytesPerSecond: number, rttMs: number } | null}
211
- */
212
- const readDelivery = (sessionId) => {
213
- const connection = connections.get(sessionId);
214
- if (!connection) {
215
- return null;
216
- }
217
- return {
218
- bytesPerSecond: connection.bytesPerSecond,
219
- rttMs: Number(connection.previous?.rtt) || 0
220
- };
221
- };
222
-
223
- /**
224
- * Update the browser-side channel byte counter for a session.
225
- *
226
- * The transport's `bytesReceived` advances on SACKs (140 B/s on a wedged
227
- * association) even though every data channel is flat — so it gives a false
228
- * "peer still sending" through every other tick (field 2026-09-06: 6 h 50 min
229
- * wedge, `flowing`/`association-stopped` alternating every 500 ms). Channel
230
- * counters are in the same `probe-echo` report and are flat on a wedge.
231
- *
232
- * @param {string} sessionId
233
- * @param {object} report - The `report` object from `probe-echo`.
234
- * @returns {void}
235
- */
236
- const noteBrowserReport = (sessionId, report) => {
237
- const connection = connections.get(sessionId);
238
- if (!connection || !report || typeof report !== "object") return;
239
- const channels = report.channels;
240
- if (!channels || typeof channels !== "object") return;
241
- let total = 0;
242
- let hasBytes = false;
243
- for (const v of Object.values(channels)) {
244
- if (v && typeof v.bytes === "number" && Number.isFinite(v.bytes)) {
245
- total += v.bytes;
246
- hasBytes = true;
247
- }
248
- }
249
- if (hasBytes) {
250
- connection.browserChannelBytes = total;
251
- }
252
- };
253
-
254
- /**
255
- * @param {string} sessionId
256
- * @param {string} tag
257
- * @param {string} label
258
- * @param {DataChannel} channel
259
- * @returns {() => void} Stops the watch.
260
- */
261
- const watchSendQueue = (sessionId, tag, label, channel) => {
262
- let lowestSinceDrain = Number.POSITIVE_INFINITY;
263
- let stuckSince = 0;
264
- let previous = null;
265
- // The peer's byte count when this queue stopped falling. What separates a
266
- // wedge from an ordinary dead connection is that the far end keeps sending
267
- // throughout — measured across the whole wedge window rather than sampled
268
- // in a one-second slice, because the browser polls every 1.5 s and plenty
269
- // of individual seconds are legitimately empty.
270
- let receivedWhenStuck = -1;
271
- // Per CHANNEL, not per connection: `proxy-control` and `proxy-fast` hold an
272
- // empty queue in health and tick every second, so a flag shared with them
273
- // would be cleared a second after the wedged channel set it and the line
274
- // would print for every second of a 54-minute episode.
275
- let wedgeSaid = false;
276
- let lastStuckLogAt = 0;
277
- let connection = connections.get(sessionId);
278
- if (!connection) {
279
- connection = {
280
- channels: new Map(),
281
- at: 0,
282
- previous: null,
283
- unknown: 0,
284
- captureStarted: false,
285
- // The accepted-byte counter and when it last moved, plus the rate it
286
- // was moving at. `wedgeIsCertain` divides the queue by that rate.
287
- rateAt: 0,
288
- sentAt: 0,
289
- sentBytes: 0,
290
- bytesPerSecond: 0,
291
- longestHealthyFlatMs: 0,
292
- // Browser-side channel bytes (sum of report.channels[].bytes). Transport's
293
- // bytesReceived advances on SACKs (140 B/s on a wedge) and gives a false
294
- // "peer still sending" every other tick — channel bytes are flat on a wedge.
295
- browserChannelBytes: 0,
296
- browserChannelBytesAtStuck: -1
297
- };
298
- connections.set(sessionId, connection);
299
- }
300
- connection.channels.set(channel, label);
301
- /** @type {ReturnType<typeof setInterval> | null} */
302
- let timer = null;
303
- let stopped = false;
304
- // Record the wire for as long as this channel is open. Held here rather
305
- // than beside `onClosed`, because `onClosed` does not always come — a peer
306
- // connection can die without it — and the watch below already ends itself
307
- // when the transport stops answering. A hold that outlives its channel
308
- // would leave tcpdump writing on an idle proxy for the life of the process.
309
- witness?.holdRing?.();
310
- /**
311
- * End this channel's watch and let go of its entry.
312
- *
313
- * @returns {void}
314
- */
315
- const stop = (because = "the transport stopped answering") => {
316
- if (stopped) {
317
- return;
318
- }
319
- stopped = true;
320
- witness?.releaseRing?.();
321
- if (timer) {
322
- clearInterval(timer);
323
- }
324
- connection.channels.delete(channel);
325
- // Only if the map still holds THIS record: a late stop, after the same
326
- // session id has been reused and a new record made for it, must not evict
327
- // the live one.
328
- if (connection.channels.size === 0 && connections.get(sessionId) === connection) {
329
- connections.delete(sessionId);
330
- // Every channel of this connection is down, so the person on it is
331
- // gone. This is the SECOND way that becomes known, and it exists
332
- // because the first does not always come: a peer connection can die
333
- // without `onClosed`, which is why this watch was written in the first
334
- // place.
335
- onConnectionGone?.(sessionId, because);
336
- }
337
- };
338
- // Independent of the queue: the transport's own counters, sampled for as
339
- // long as the channel is open. The queue was the wrong thing to watch —
340
- // field 2026-08-06, a 9.26 MB segment was accepted by the transport with
341
- // `maxBuffered=0 bufferedAtEnd=0`, reported as sent at 274 Mbit/s, and
342
- // never arrived; everything the proxy sent from that moment on was lost the
343
- // same way while requests kept coming the other direction. With nothing
344
- // queued this watcher never woke, so the one question that matters — did
345
- // those bytes leave the machine — has no answer in the log. It does now.
346
- // A connection the transport no longer knows about is gone, whatever the
347
- // channel says. `onClosed` is the ordinary way this watch ends, and it does
348
- // not always come — a peer connection can die without it, leaving the timer
349
- // and this channel's entry behind for the life of the process.
350
- //
351
- // The count is kept on the CONNECTION: exactly one channel enters the
352
- // heartbeat branch per interval, so a per-channel count would advance only
353
- // on that channel's turn and the teardown would take three heartbeats per
354
- // channel rather than three in total.
355
- timer = setInterval(() => {
356
- const sampledAt = Date.now();
357
- // Whichever channel's timer arrives first past the interval reports for
358
- // the whole connection; the others find the timestamp already moved and
359
- // skip. So the line appears once however many channels are open.
360
- if (sampledAt - connection.at >= TRANSPORT_HEARTBEAT_MS) {
361
- connection.at = sampledAt;
362
- const snapshot = getTransportSnapshot?.(sessionId) ?? null;
363
- connection.unknown = snapshot ? 0 : connection.unknown + 1;
364
- if (connection.unknown >= TRANSPORT_UNKNOWN_HEARTBEATS) {
365
- // THE SECOND DETECTOR, and it is the one that has to work: `onClosed`
366
- // does not always come — a peer connection can die without it — and
367
- // presence is the connection and nothing else, so nothing else will
368
- // ever say these people have gone. Until now this ended the watch and
369
- // left them registered for the life of the process, holding the
370
- // outputs they were watching.
371
- // Named for what it is: the watcher is handed this, it does not
372
- // hold the registry. Written as a bare `connectionGone` it was a
373
- // reference to a function of the FACTORY, which is not in scope here,
374
- // so the one detector that has to work threw a `ReferenceError`
375
- // inside its own timer instead of releasing the viewer.
376
- onConnectionGone?.(sessionId, "the transport stopped answering");
377
- stop();
378
- return;
379
- }
380
- if (snapshot) {
381
- const sent = connection.previous ? snapshot.bytesSent - connection.previous.bytesSent : null;
382
- const received = connection.previous
383
- ? snapshot.bytesReceived - connection.previous.bytesReceived
384
- : null;
385
- connection.previous = snapshot;
386
- log(
387
- `[dc-transport] ${tag} sent=${snapshot.bytesSent}` +
388
- `${sent === null ? "" : ` (+${sent})`} received=${snapshot.bytesReceived}` +
389
- `${received === null ? "" : ` (+${received})`} queued[${queueDepths(connection.channels)}] ` +
390
- `rtt=${snapshot.rtt}ms pc=${snapshot.state} ice=${snapshot.iceState} pair=${snapshot.pair}`
391
- );
392
- }
393
- }
394
- // The rate this connection accepts bytes at, and how long that counter
395
- // has stood still — both measured every second, whatever the queue is
396
- // doing, because the rate has to come from the HEALTHY stretch that
397
- // precedes a wedge. One channel updates it for the whole connection.
398
- if (sampledAt - connection.rateAt >= SEND_QUEUE_SAMPLE_MS) {
399
- connection.rateAt = sampledAt;
400
- const snapshot = getTransportSnapshot?.(sessionId) ?? null;
401
- const sentNow = Number(snapshot?.bytesSent);
402
- if (Number.isFinite(sentNow) && sentNow >= 0) {
403
- if (connection.sentAt === 0 || sentNow < connection.sentBytes) {
404
- // First reading, or the counter went backwards — a fresh peer
405
- // connection reusing this session id. Either way the old baseline
406
- // describes a transport that no longer exists, so start over
407
- // rather than measure a pause against it for ever.
408
- connection.sentBytes = sentNow;
409
- connection.sentAt = sampledAt;
410
- } else if (sentNow > connection.sentBytes) {
411
- const seconds = (sampledAt - connection.sentAt) / 1000;
412
- if (seconds > 0) {
413
- // The BEST rate this connection has shown, not the latest one.
414
- // The latest is usually the quietest: with the browser's buffer
415
- // full nothing is requested for tens of seconds and the only
416
- // traffic is the probe, a few hundred bytes a second. Dividing a
417
- // queue by that gives hours, and the wedge would never be called.
418
- const rate = (sentNow - connection.sentBytes) / seconds;
419
- if (rate > connection.bytesPerSecond) {
420
- connection.bytesPerSecond = rate;
421
- }
422
- }
423
- // How long the counter stood still before this advance. While the
424
- // queue is draining that pause was legitimate, so it is the link's
425
- // own answer to "how long may a healthy pause be".
426
- const pausedMs = sampledAt - connection.sentAt;
427
- if (stuckSince === 0 && pausedMs > connection.longestHealthyFlatMs) {
428
- connection.longestHealthyFlatMs = pausedMs;
429
- }
430
- connection.sentBytes = sentNow;
431
- connection.sentAt = sampledAt;
432
- }
433
- }
434
- }
435
- let queued = 0;
436
- try {
437
- queued = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0;
438
- } catch {
439
- return;
440
- }
441
- if (queued === 0 || queued < lowestSinceDrain) {
442
- lowestSinceDrain = queued;
443
- stuckSince = 0;
444
- previous = null;
445
- receivedWhenStuck = -1;
446
- connection.browserChannelBytesAtStuck = -1;
447
- wedgeSaid = false;
448
- // The queue moved, so whatever was called a wedge has cleared. Let a
449
- // later one be recorded too: one mistaken call must not spend the
450
- // session's only capture.
451
- connection.captureStarted = false;
452
- return;
453
- }
454
- const now = Date.now();
455
- if (stuckSince === 0) {
456
- stuckSince = now;
457
- return;
458
- }
459
- const snapshot = getTransportSnapshot?.(sessionId) ?? null;
460
- if (!snapshot) {
461
- if (now - stuckSince >= SEND_QUEUE_STUCK_MS && now - lastStuckLogAt >= 30_000) {
462
- lastStuckLogAt = now;
463
- log(`[dc] Session ${tag} "${label}": send queue stuck at ${queued}B for ` +
464
- `${Math.round((now - stuckSince) / 1000)}s — no transport to ask`);
465
- }
466
- return;
467
- }
468
- const sentDelta = previous ? snapshot.bytesSent - previous.bytesSent : null;
469
- const recvDelta = previous ? snapshot.bytesReceived - previous.bytesReceived : null;
470
- previous = snapshot;
471
- if (receivedWhenStuck < 0) {
472
- receivedWhenStuck = snapshot.bytesReceived;
473
- }
474
- if (connection.browserChannelBytesAtStuck < 0) {
475
- connection.browserChannelBytesAtStuck = connection.browserChannelBytes;
476
- }
477
- // The periodic line waits for {@link SEND_QUEUE_STUCK_MS}, because a
478
- // queue that has merely not fallen for a second is ordinary and a line a
479
- // second for it is noise. The WEDGE below does not wait for it: its own
480
- // condition already says how long this queue may legitimately take, and
481
- // on a fast link that is under a second. Holding the evidence back for a
482
- // fixed five seconds would repeat, in miniature, the mistake that left
483
- // both field captures without an onset in them.
484
- // Rate-limit to 30 s — field 2026-09-06 printed 49 295 lines (31 % of log)
485
- // for one 6 h 50 min wedge, one line per second per channel.
486
- if (now - stuckSince >= SEND_QUEUE_STUCK_MS && now - lastStuckLogAt >= 30_000) {
487
- lastStuckLogAt = now;
488
- log(
489
- `[dc] Session ${tag} "${label}": send queue stuck at ${queued}B for ` +
490
- `${Math.round((now - stuckSince) / 1000)}s — transport ` +
491
- `sent=${snapshot.bytesSent}${sentDelta === null ? "" : ` (+${sentDelta})`} ` +
492
- `received=${snapshot.bytesReceived}${recvDelta === null ? "" : ` (+${recvDelta})`} ` +
493
- `rtt=${snapshot.rtt}ms pc=${snapshot.state} ice=${snapshot.iceState} pair=${snapshot.pair}`
494
- );
495
- }
496
- // Roadmap item 11: ask for the packet-level truth the moment the wedge is
497
- // CERTAIN, not after a chosen delay. The three facts below are not
498
- // ambiguous together — the queue has not fallen, the accepted-byte
499
- // counter has not moved for longer than the queue's own drain time at
500
- // this link's own speed, and the peer is still sending. The previous
501
- // rule's flat 30 s is what left both field captures with no onset in
502
- // them. One attempt per connection — the witness applies its own
503
- // single-flight and cooldown rules after that.
504
- const flatForMs = connection.sentAt === 0 ? 0 : now - connection.sentAt;
505
- const verdict = wedgeIsCertain({
506
- queuedBytes: queued,
507
- bytesPerSecond: connection.bytesPerSecond,
508
- flatForMs,
509
- longestHealthyFlatMs: connection.longestHealthyFlatMs
510
- });
511
- const hasBrowserBytes = Number.isFinite(connection.browserChannelBytes) && connection.browserChannelBytesAtStuck >= 0;
512
- const peerStillSending = hasBrowserBytes
513
- ? connection.browserChannelBytes > connection.browserChannelBytesAtStuck
514
- : snapshot.bytesReceived > receivedWhenStuck;
515
- if (verdict.certain && peerStillSending && !wedgeSaid) {
516
- wedgeSaid = true;
517
- log(
518
- `[dc] Session ${tag} "${label}": delivery has stopped — ${queued}B queued, ` +
519
- `accepted-byte counter unmoved for ${Math.round(flatForMs / 1000)}s against the ` +
520
- `${(verdict.needMs / 1000).toFixed(1)}s this queue needs at the ` +
521
- `${(connection.bytesPerSecond / 1024).toFixed(0)} KB/s last measured here, ` +
522
- "and the peer is still sending"
523
- );
524
- }
525
- if (
526
- witness &&
527
- !connection.captureStarted &&
528
- verdict.certain &&
529
- peerStillSending
530
- ) {
531
- connection.captureStarted = true;
532
- witness.maybeCapture({
533
- sessionId,
534
- tag,
535
- label,
536
- remote: snapshot.remote ?? null,
537
- queuedBytes: queued,
538
- stuckForMs: now - stuckSince
539
- });
540
- }
541
- if (usrsctpState && verdict.certain && peerStillSending) {
542
- // Its own single-flight and cooldown, independent of the witness's —
543
- // one gdb attach per wedge is enough, and a refusal here (already
544
- // read, cooling down) costs nothing to retry on the next tick.
545
- usrsctpState.maybeRead(
546
- `send queue stuck ${queued}B, accepted-byte counter unmoved ${Math.round(flatForMs / 1000)}s`
547
- );
548
- }
549
- }, SEND_QUEUE_SAMPLE_MS);
550
-
551
- if (typeof timer.unref === "function") {
552
- timer.unref();
553
- }
554
- return stop;
555
- };
556
-
557
- return { watchSendQueue, readDelivery, noteBrowserReport };
558
- }
559
-
560
- /**
561
- * Create a handler for incoming WebRTC data channels.
562
- *
563
- * @param {DataChannelHandlerOptions} options
564
- * @returns {DataChannelHandler}
565
- */
566
- import { performance } from "node:perf_hooks";
567
- import { eventLoopDelay, resetEventLoopDelay } from "../../utils/perf.js";
568
-
569
- /**
570
- * Build one body frame: `[flags(1)][idLen(1)][requestId][payload]`.
571
- *
572
- * One allocation and one copy. The previous version made two of each — a copy
573
- * of the chunk into a `Buffer`, then a `concat` that copied it again into the
574
- * frame — which measured 75.9 ms per 13 MB segment on the field host against
575
- * 40.0 ms this way, and allocated ~600 extra buffers over a segment's 208
576
- * chunks. One copy is the floor: chunks arrive from a web stream that allocates
577
- * them itself, so there is no buffer of ours to read them into.
578
- *
579
- * @param {Buffer} idBytes - The request id, already encoded.
580
- * @param {Uint8Array | null} bytes - Payload, or nothing for the done frame.
581
- * @param {boolean} done
582
- * @returns {Buffer}
583
- */
584
- export function encodeFrame(idBytes, bytes, done) {
585
- const payloadLength = bytes?.length ?? 0;
586
- const frame = Buffer.allocUnsafe(2 + idBytes.length + payloadLength);
587
- frame[0] = done ? 1 : 0;
588
- frame[1] = idBytes.length;
589
- idBytes.copy(frame, 2);
590
- if (payloadLength > 0) {
591
- frame.set(bytes, 2 + idBytes.length);
592
- }
593
- return frame;
594
- }
595
-
596
- /**
597
- * Reshapes a body into messages of `sizeBytes` each, handing every finished one
598
- * to `sendOne`. A size of zero passes each body read through untouched, which
599
- * is what the field does today — 10 657 210 bytes over 163 messages, so 65 382
600
- * each, measured 2026-09-12.
601
- *
602
- * Only the SIZE changes: the same bytes reach the far end in the same order,
603
- * over the same channel. That is what makes a comparison between two sizes a
604
- * comparison of one thing, and it is why the wedge's dependence on the size can
605
- * be measured at all — the wedge of 2026-09-12 arrived in the middle of one
606
- * message, with 63 % of a segment on the wire and then nothing.
607
- *
608
- * @param {(bytes: Uint8Array) => void} sendOne - Given each finished message.
609
- * @param {number} sizeBytes - 0 to leave reads as they are.
610
- * @returns {{ push: (bytes: Uint8Array) => number, flush: () => number }}
611
- * Both answer how many messages they sent, so a caller counting messages
612
- * counts messages rather than body reads.
613
- */
614
- export function bodySender(sendOne, sizeBytes) {
615
- if (!(sizeBytes > 0)) {
616
- return {
617
- push(bytes) {
618
- sendOne(bytes);
619
- return 1;
620
- },
621
- flush() {
622
- return 0;
623
- }
624
- };
625
- }
626
- /** @type {Uint8Array[]} */
627
- let held = [];
628
- let heldBytes = 0;
629
- const release = () => {
630
- const message = held.length === 1 ? held[0] : Buffer.concat(held);
631
- held = [];
632
- heldBytes = 0;
633
- sendOne(message);
634
- };
635
- return {
636
- push(bytes) {
637
- let sent = 0;
638
- let offset = 0;
639
- while (offset < bytes.length) {
640
- const take = Math.min(sizeBytes - heldBytes, bytes.length - offset);
641
- held.push(bytes.subarray(offset, offset + take));
642
- heldBytes += take;
643
- offset += take;
644
- if (heldBytes === sizeBytes) {
645
- release();
646
- sent += 1;
647
- }
648
- }
649
- return sent;
650
- },
651
- flush() {
652
- if (heldBytes === 0) return 0;
653
- release();
654
- return 1;
655
- }
656
- };
657
- }
658
-
659
- export function createDataChannelHandler({
660
- proxyPort,
661
- onLog,
662
- getTransportSnapshot,
663
- // Who wants pushed subtitle cues for a file, BY NAME. A plain function
664
- // returning opaque ids: this layer never learns what a viewer is, and the
665
- // subscription it used to hold against a channel — and had to sniff
666
- // `/api/subtitles` to build — now belongs to the person and outlives the
667
- // channel, a reconnect and a deliberate rotation alike.
668
- viewersWantingCues = () => [],
669
- witness,
670
- // Reads usrsctp's own association state (services/transport/usrsctp-state.js) the
671
- // moment a wedge is declared, from either detector below. Optional: a host
672
- // without gdb simply never gets a reading, same as the witness without
673
- // tcpdump.
674
- usrsctpState,
675
- // Presence, both directions. A viewer is present because their connection
676
- // is, and gone because it went — which is a fact about the PERSON and reaches
677
- // every output they were watching at once. Before this, nothing anywhere
678
- // released a viewer when their connection closed: the only exits were the
679
- // browser's own `release` and a silence long enough to be called an absence.
680
- onViewerPresent = () => {},
681
- onViewerGone = () => {},
682
- // The size of one data-channel message carrying body bytes. Zero keeps
683
- // whatever the body read hands over, which in the field is 65 382 bytes —
684
- // 10 657 210 over 163 chunks, measured 2026-09-12. It is settable because
685
- // that day's wedge arrived in the MIDDLE of one message (63 % of a segment
686
- // on the wire, then nothing), and whether the fault depends on the size is
687
- // the one branch no capture has been able to close.
688
- sendChunkBytes = 0
689
- }) {
690
- /**
691
- * Who is on each connection, by WebRTC session id.
692
- *
693
- * One connection carries one page, and a page holds one name per film it has
694
- * open — so this is normally one id, and more than one only where a page has
695
- * opened more than one.
696
- *
697
- * @type {Map<string, Set<string>>}
698
- */
699
- const viewersOnConnection = new Map();
700
- /**
701
- * The newest connection currently carrying each viewer's identity.
702
- *
703
- * A reconnect can open its channels before the old connection finishes
704
- * closing. The old connection must not release a viewer who has already
705
- * identified themselves on the replacement.
706
- *
707
- * @type {Map<string, string>}
708
- */
709
- const connectionOfViewer = new Map();
710
-
711
- /**
712
- * Everyone on this connection has gone, because the connection has.
713
- *
714
- * Said once per connection: a second call after the set is emptied must not
715
- * announce departures a second time, and the two detectors below — the close
716
- * event and the transport's own counters — can both fire for one death.
717
- *
718
- * @param {string} sessionId
719
- * @param {string} because
720
- * @returns {void}
721
- */
722
- function connectionGone(sessionId, because) {
723
- const viewers = viewersOnConnection.get(sessionId);
724
- viewersOnConnection.delete(sessionId);
725
- if (!viewers || viewers.size === 0) {
726
- return;
727
- }
728
- for (const consumerId of viewers) {
729
- if (connectionOfViewer.get(consumerId) !== sessionId) {
730
- continue;
731
- }
732
- connectionOfViewer.delete(consumerId);
733
- onLog?.(`[dc] Session ${sessionId.slice(0, 8)}: viewer ${consumerId} left — ${because}`);
734
- try {
735
- onViewerGone(consumerId, because);
736
- } catch {
737
- // A viewer failing to be let go must not stop the others being let go.
738
- }
739
- }
740
- }
741
- /**
742
- * The channel each viewer is reachable on right now, by the name the far end
743
- * presented when it opened.
744
- *
745
- * The transport knows a NAME and nothing else about a viewer: what it means,
746
- * who holds it and what they are watching are somebody else's facts. Keeping
747
- * only "this name is reachable here" is what lets a subscription made once
748
- * outlive the channel it was made on — a reconnect, and a rotation done on
749
- * purpose, both just rewrite this entry.
750
- *
751
- * @type {Map<string, DataChannel>}
752
- */
753
- const channelOfViewer = new Map();
754
-
755
- /**
756
- * Forget every name that was reachable on this channel.
757
- *
758
- * @param {DataChannel} channel
759
- * @returns {void}
760
- */
761
- function forgetChannel(channel) {
762
- for (const [consumerId, on] of channelOfViewer) {
763
- if (on === channel) {
764
- channelOfViewer.delete(consumerId);
765
- }
766
- }
767
- }
768
-
769
- /**
770
- * Send new cues to every channel watching this file — the push side of
771
- * subtitles arriving as they download rather than being polled for. Cues
772
- * are tiny (kilobytes at most for a whole track), so this is one message,
773
- * not a stream.
774
- *
775
- * `withdrawn` names, by their found-order numbers, cues sent earlier that the
776
- * proxy has taken back — read from a stretch that turned out not to be a
777
- * cluster. Each cue carries its own number (`seq`) for that purpose.
778
- *
779
- * @param {{ sourceKey: string, fileIndex: number, trackIndex: number, cues: object[],
780
- * withdrawn?: number[], language: string, detectedLanguage: { code: string, name: string } | null,
781
- * cursor: number }} event
782
- * @returns {void}
783
- */
784
- function publishSubtitleCues({ sourceKey, fileIndex, trackIndex, cues, withdrawn = [], language, detectedLanguage, cursor }) {
785
- const names = viewersWantingCues(sourceKey, fileIndex);
786
- if (names.length === 0) {
787
- log(
788
- `[dc] subtitle push: ${cues.length} cue(s) for ${sourceKey.slice(0, 8)}:${fileIndex} track ${trackIndex} ` +
789
- "found nobody who wants them"
790
- );
791
- return;
792
- }
793
- const message = JSON.stringify({
794
- type: "subtitle-cues", fileIndex, trackIndex, cues, withdrawn, language, detectedLanguage, cursor
795
- });
796
- let sent = 0;
797
- for (const consumerId of names) {
798
- const channel = channelOfViewer.get(consumerId);
799
- if (!channel) {
800
- // Subscribed but not reachable this instant — between connections, or
801
- // rotating. Nothing is dropped: they are still subscribed, and the walk
802
- // keeps a per-viewer cursor, so what they missed comes on the next ask.
803
- continue;
804
- }
805
- try {
806
- channel.sendMessage(message);
807
- sent += 1;
808
- } catch {
809
- // Closed between the lookup and the send. The subscription belongs to
810
- // the person and stays; only this delivery is lost.
811
- }
812
- }
813
- log(
814
- `[dc] subtitle push: sent ${cues.length} cue(s) for ${sourceKey.slice(0, 8)}:${fileIndex} track ${trackIndex} ` +
815
- `to ${sent}/${names.length} viewer(s)`
816
- );
817
- }
818
-
819
- /** Request id → its ASCII bytes; see {@link requestIdBytes}. */
820
- const requestIdCache = new Map();
821
-
822
- const { watchSendQueue, readDelivery, noteBrowserReport } = makeSendQueueWatcher({
823
- log: (message) => log(message),
824
- getTransportSnapshot,
825
- witness,
826
- usrsctpState,
827
- // The watcher is where a connection's death is noticed when no close event
828
- // arrives, so it is the second door presence leaves by. Given rather than
829
- // reached for: it is a module-level function and cannot see this closure.
830
- onConnectionGone: connectionGone
831
- });
832
- // Numbered probes on every channel, and the browser's echo of what it saw.
833
- // The proxy's own counters cannot say whether bytes it handed to usrsctp were
834
- // ever put on the wire; the far end can, and it keeps answering throughout a
835
- // freeze. See services/transport/delivery-probe.js.
836
- //
837
- // Also the ONLY wedge signal that does not require a nonzero channel queue —
838
- // `wedgeIsCertain` above needs `queuedBytes > 0`, which small, infrequent
839
- // traffic never produces (confirmed 2026-08-28: the 2026-08-27 episode's
840
- // ring was never saved automatically for exactly this reason). So this is
841
- // wired to the same evidence-gathering as the queue watcher.
842
- const deliveryProbe = createDeliveryProbe({
843
- log: (message) => log(message),
844
- readDelivery,
845
- getTransportSnapshot,
846
- witness,
847
- usrsctpState
848
- });
849
-
850
- /**
851
- * @param {string} message
852
- * @returns {void}
853
- */
854
- function log(message) {
855
- if (typeof onLog === "function") {
856
- onLog(message);
857
- }
858
- }
859
-
860
- /**
861
- * Wire up the `onMessage`, `onClosed`, and `onError` handlers for a channel.
862
- *
863
- * @param {string} sessionId
864
- * @param {DataChannel} channel
865
- * @returns {void}
866
- */
867
- function handleChannel(sessionId, channel) {
868
- const tag = sessionId.slice(0, 8);
869
- const label = typeof channel.getLabel === "function" ? channel.getLabel() : "?";
870
- log(`[dc] Session ${tag}: channel open`);
871
- const stopWatchdog = watchSendQueue(sessionId, tag, label, channel);
872
- deliveryProbe.attach(sessionId, tag, label, channel);
873
-
874
- // Partial chunked-request bodies in flight on THIS channel, keyed by
875
- // requestId. Each entry buffers frames until the done frame, then runs the
876
- // assembled request through the same path as a single-message request.
877
- /** @type {Map<string, { meta: object, chunks: Buffer[], receivedBytes: number, bodyBytes: number, timer: ReturnType<typeof setTimeout> }>} */
878
- const partials = new Map();
879
-
880
- const dropPartial = (requestId) => {
881
- const entry = partials.get(requestId);
882
- if (entry) {
883
- clearTimeout(entry.timer);
884
- partials.delete(requestId);
885
- }
886
- };
887
-
888
- /**
889
- * Begin assembling a chunked request. Validates the path and size up front
890
- * so an invalid or oversized request never buffers a body.
891
- *
892
- * @param {any} message - The `request-start` control message.
893
- */
894
- const startPartialRequest = (message) => {
895
- const { requestId, method, path, query, headers, bodyBytes } = message ?? {};
896
- if (typeof requestId !== "string" || requestId.length === 0) {
897
- return;
898
- }
899
- if (!isValidRequestPath(path)) {
900
- send(channel, { type: "response-error", requestId, error: "Invalid request path." });
901
- return;
902
- }
903
- if (!Number.isInteger(bodyBytes) || bodyBytes < 0 || bodyBytes > PROXY_MAX_REQUEST_BODY_BYTES) {
904
- send(channel, { type: "response-error", requestId, error: "Request body too large." });
905
- return;
906
- }
907
- dropPartial(requestId); // replace any stale entry with the same id
908
- const timer = setTimeout(() => {
909
- const entry = partials.get(requestId);
910
- partials.delete(requestId);
911
- log(`[dc] Session ${tag}: dropped stale partial request ${requestId.slice(0, 8)} (${entry?.receivedBytes ?? 0}B)`);
912
- }, PARTIAL_REQUEST_TTL_MS);
913
- partials.set(requestId, {
914
- meta: { requestId, method, path, query, headers },
915
- chunks: [],
916
- receivedBytes: 0,
917
- bodyBytes,
918
- timer
919
- });
920
- };
921
-
922
- /**
923
- * Handle a binary body frame for a chunked request.
924
- * Layout: [flags(1)][idLen(1)][requestId(ASCII)][payload].
925
- *
926
- * @param {Buffer} buf
927
- */
928
- const handleBodyFrame = (buf) => {
929
- if (buf.length < 2) {
930
- return;
931
- }
932
- const flags = buf[0];
933
- const idLen = buf[1];
934
- if (buf.length < 2 + idLen) {
935
- return;
936
- }
937
- const requestId = buf.toString("ascii", 2, 2 + idLen);
938
- const entry = partials.get(requestId);
939
- if (!entry) {
940
- return; // stale / already-dropped / aborted
941
- }
942
- if (flags & 2) {
943
- // Aborted by the browser — drop silently, no reply.
944
- dropPartial(requestId);
945
- return;
946
- }
947
- if (buf.length > 2 + idLen) {
948
- const payload = buf.subarray(2 + idLen);
949
- entry.chunks.push(Buffer.from(payload));
950
- entry.receivedBytes += payload.length;
951
- }
952
- if (entry.receivedBytes > entry.bodyBytes || entry.receivedBytes > PROXY_MAX_REQUEST_BODY_BYTES) {
953
- dropPartial(requestId);
954
- send(channel, { type: "response-error", requestId, error: "Request body size mismatch." });
955
- return;
956
- }
957
- if (flags & 1) {
958
- // Done frame — assemble and execute.
959
- dropPartial(requestId);
960
- if (entry.receivedBytes !== entry.bodyBytes) {
961
- send(channel, { type: "response-error", requestId, error: "Request body size mismatch." });
962
- return;
963
- }
964
- const body = Buffer.concat(entry.chunks).toString("utf8");
965
- void handleRequest(channel, { ...entry.meta, body }, true).catch((error) => {
966
- log(`[dc] Session ${tag}: request error: ${error?.message ?? error}`);
967
- });
968
- }
969
- };
970
-
971
- channel.onMessage((raw) => {
972
- // Binary messages are chunked-request body frames; the proxy otherwise
973
- // only ever receives JSON strings, so the type discriminates cleanly.
974
- if (typeof raw !== "string") {
975
- handleBodyFrame(Buffer.isBuffer(raw) ? raw : Buffer.from(raw));
976
- return;
977
- }
978
-
979
- /** @type {DataChannelRequest | { type: string, id?: string }} */
980
- let message;
981
- try {
982
- message = JSON.parse(raw);
983
- } catch {
984
- return;
985
- }
986
-
987
- if (message.type === "request") {
988
- void handleRequest(channel, message).catch((error) => {
989
- log(`[dc] Session ${tag}: request error: ${error?.message ?? error}`);
990
- });
991
- return;
992
- }
993
-
994
- if (message.type === "request-start") {
995
- startPartialRequest(message);
996
- return;
997
- }
998
-
999
- if (message.type === "ping") {
1000
- send(channel, { type: "pong", id: message.id });
1001
- return;
1002
- }
1003
-
1004
- // WHO is on this connection. Sent once when the page opens its control
1005
- // channel, and it is what lets a closed connection say that a PERSON has
1006
- // gone rather than that one output lost a request.
1007
- //
1008
- // The name is the page's, not this connection's: it is minted once per
1009
- // film opened in the tab and survives the reconnect ladder swapping the
1010
- // transport underneath a running player. A name minted here would make
1011
- // one person two the first time that happened, each with their own
1012
- // position.
1013
- if (message.type === "viewer" && typeof message.consumerId === "string" && message.consumerId) {
1014
- const known = viewersOnConnection.get(sessionId) ?? new Set();
1015
- const isNew = !known.has(message.consumerId);
1016
- known.add(message.consumerId);
1017
- viewersOnConnection.set(sessionId, known);
1018
- connectionOfViewer.set(message.consumerId, sessionId);
1019
- channelOfViewer.set(message.consumerId, channel);
1020
- if (isNew) {
1021
- log(`[dc] Session ${tag}: viewer ${message.consumerId} is on this connection`);
1022
- }
1023
- onViewerPresent(message.consumerId);
1024
- return;
1025
- }
1026
-
1027
- // The far end's answer to the numbered probes, plus what it can see of
1028
- // its own receiving. It travels browser to proxy, the direction that goes
1029
- // on working through a freeze, so it arrives when nothing else does.
1030
- if (message.type === "probe-echo") {
1031
- deliveryProbe.noteEcho(sessionId, message);
1032
- noteBrowserReport(sessionId, message.report);
1033
- if (message.report && typeof message.report === "object") {
1034
- const report = message.report;
1035
- const channels = report.channels && typeof report.channels === "object"
1036
- ? Object.entries(report.channels)
1037
- .map(([name, counters]) => `${name}=${counters?.messages ?? "?"}msg/${counters?.bytes ?? "?"}B`)
1038
- .join(" ")
1039
- : "";
1040
- log(
1041
- `[dc-far] ${tag} visibility=${report.visibility ?? "?"} ` +
1042
- `loopLag=${report.loopLagMs ?? "?"}ms handler=${report.handlerMaxMs ?? "?"}ms ` +
1043
- `transportIn=${report.transportBytesReceived ?? "?"} ${channels} ` +
1044
- `pending=${report.pending ?? "?"} at=${new Date().toISOString()}`
1045
- );
1046
- }
1047
- return;
1048
- }
1049
- });
1050
-
1051
- channel.onClosed(() => {
1052
- stopWatchdog("the connection closed");
1053
- deliveryProbe.detach(sessionId, channel);
1054
- for (const entry of partials.values()) {
1055
- clearTimeout(entry.timer);
1056
- }
1057
- partials.clear();
1058
- forgetChannel(channel);
1059
- log(`[dc] Session ${tag}: channel closed`);
1060
- // One closed data channel does not mean the peer connection is gone:
1061
- // media, control and probe channels can close independently. The shared
1062
- // transport watcher calls `connectionGone` when its last channel closes.
1063
- });
1064
-
1065
- channel.onError((err) => {
1066
- log(`[dc] Session ${tag}: channel error: ${err}`);
1067
- });
1068
- }
1069
-
1070
- /**
1071
- * Fetch a resource from the local proxy HTTP server and stream the response
1072
- * back to the browser over the data channel.
1073
- *
1074
- * The `Host` header is rewritten to `127.0.0.1:{proxyPort}` so that Fastify
1075
- * routes the request correctly regardless of what the browser sent.
1076
- *
1077
- * @param {DataChannel} channel
1078
- * @param {DataChannelRequest} req
1079
- * @returns {Promise<void>}
1080
- */
1081
- async function handleRequest(channel, req, viaChunks = false) {
1082
- const { requestId, method, path, query, headers: forwardedHeaders, body } = req;
1083
-
1084
- // Reject paths that are not absolute, contain traversal sequences, or
1085
- // do not start with a known proxy route prefix. All valid browser-side
1086
- // requests use /api/*, /stream, /transcode/*, /health, or /healthz.
1087
- if (!isValidRequestPath(path)) {
1088
- send(channel, { type: "response-error", requestId, error: "Invalid request path." });
1089
- return;
1090
- }
1091
-
1092
-
1093
- const queryInfo = query ? `?${query}` : "";
1094
- const bodyInfo =
1095
- body != null && typeof body === "string" && body.length > 0
1096
- ? ` body=${body.length} bytes${viaChunks ? " (chunked)" : ""}`
1097
- : "";
1098
- log(`[dc] ${method} ${path}${queryInfo}${bodyInfo}`);
1099
-
1100
- const targetUrl = `http://127.0.0.1:${proxyPort}${path}${query ? `?${query}` : ""}`;
1101
- const requestHeaders = { ...(forwardedHeaders ?? {}), host: `127.0.0.1:${proxyPort}` };
1102
-
1103
- let response;
1104
- // [net-debug] TEMPORARY: time spent in the local fetch (waiting for the
1105
- // route to return a response — e.g. long-polling until an HLS segment is
1106
- // finalized by ffmpeg) vs. the body transfer over the data channel.
1107
- const fetchStartedAt = Date.now();
1108
- try {
1109
- response = await fetch(targetUrl, {
1110
- method,
1111
- headers: requestHeaders,
1112
- body: body != null ? body : undefined,
1113
- redirect: "manual"
1114
- });
1115
- } catch (fetchError) {
1116
- log(`[dc] ${method} ${path}${queryInfo} → error: ${fetchError?.message ?? String(fetchError)}`);
1117
- send(channel, { type: "response-error", requestId, error: fetchError?.message ?? String(fetchError) });
1118
- return;
1119
- }
1120
-
1121
- if (response.status !== 200 && response.status !== 206) {
1122
- log(`[dc] ${method} ${path}${queryInfo} → ${response.status}`);
1123
- }
1124
-
1125
- /** @type {Record<string, string>} */
1126
- const responseHeaders = {};
1127
- for (const [name, value] of response.headers.entries()) {
1128
- responseHeaders[name] = value;
1129
- }
1130
-
1131
- send(channel, { type: "response-start", requestId, status: response.status, headers: responseHeaders });
1132
-
1133
- if (!response.body) {
1134
- sendChunk(channel, requestId, null, true);
1135
- return;
1136
- }
1137
-
1138
- try {
1139
- const reader = response.body.getReader();
1140
- // [net-debug] TEMPORARY: measure transfer size/time and channel buffering.
1141
- // fetchMs = time waiting for the route (incl. ffmpeg segment finalization).
1142
- // ttfbMs = time from body-read start to the first chunk with data (loopback).
1143
- // sendMs = total body read+send duration over the data channel.
1144
- const fetchMs = Date.now() - fetchStartedAt;
1145
- const sendStartedAt = Date.now();
1146
- let firstByteMs = -1;
1147
- let chunks = 0;
1148
- let totalBytes = 0;
1149
- let maxBuffered = 0;
1150
- // Attribute the transfer to the step that actually consumes the time.
1151
- // Without this split a slow transfer is indistinguishable between "the
1152
- // source is slow", "the channel is slow" and "the event loop is blocked",
1153
- // which is exactly the argument a field seek left unresolved.
1154
- let readMs = 0;
1155
- let sendMs2 = 0;
1156
- let drainMs = 0;
1157
- const body = bodySender((bytes) => sendChunk(channel, requestId, bytes, false), sendChunkBytes);
1158
- resetEventLoopDelay();
1159
- while (true) {
1160
- const readStartedAt = performance.now();
1161
- const { done, value } = await reader.read();
1162
- readMs += performance.now() - readStartedAt;
1163
- if (done) {
1164
- chunks += body.flush();
1165
- sendChunk(channel, requestId, null, true);
1166
- const elapsedMs = Date.now() - sendStartedAt;
1167
- let bufferedNow = 0;
1168
- try { bufferedNow = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0; } catch { /* ignore */ }
1169
- const loop = eventLoopDelay();
1170
- const mbps = elapsedMs > 0 ? (totalBytes * 8) / (elapsedMs * 1000) : 0;
1171
- log(
1172
- `[net-debug] sent ${path}${queryInfo} bytes=${totalBytes} fetchMs=${fetchMs} ` +
1173
- `ttfbMs=${firstByteMs} sendMs=${elapsedMs} chunks=${chunks} ` +
1174
- // The size of one message, so a reading can be attributed to the
1175
- // size it was taken at rather than to the run it came from.
1176
- `msgBytes=${sendChunkBytes > 0 ? sendChunkBytes : "asread"} ` +
1177
- `maxBuffered=${maxBuffered} bufferedAtEnd=${bufferedNow} ` +
1178
- // Where the time went: reading the body from the local route,
1179
- // handing chunks to the channel, or waiting for its queue. Plus
1180
- // the event-loop delay over the same window — a large max here
1181
- // means the transfer was blocked by synchronous work, not by the
1182
- // network, and the three figures above will all look inflated.
1183
- `readMs=${readMs.toFixed(0)} chanMs=${sendMs2.toFixed(0)} drainMs=${drainMs.toFixed(0)} ` +
1184
- `loopMean=${loop.meanMs.toFixed(1)} loopP99=${loop.p99Ms.toFixed(1)} loopMax=${loop.maxMs.toFixed(1)} ` +
1185
- `rate=${mbps.toFixed(1)}Mbps`
1186
- );
1187
- break;
1188
- }
1189
- if (firstByteMs < 0) firstByteMs = Date.now() - sendStartedAt;
1190
- totalBytes += value.length;
1191
- try {
1192
- const b = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0;
1193
- if (b > maxBuffered) maxBuffered = b;
1194
- } catch { /* ignore */ }
1195
- const sendStepAt = performance.now();
1196
- chunks += body.push(value);
1197
- sendMs2 += performance.now() - sendStepAt;
1198
- // Backpressure: do not keep queuing chunks once the channel's outgoing
1199
- // buffer is large — wait for it to drain. Prevents the SCTP send buffer
1200
- // from ballooning, which stalls throughput.
1201
- const drainStepAt = performance.now();
1202
- await waitForBufferDrain(channel);
1203
- drainMs += performance.now() - drainStepAt;
1204
- }
1205
- } catch {
1206
- sendChunk(channel, requestId, null, true);
1207
- }
1208
- }
1209
-
1210
- /**
1211
- * Send a response body frame as a BINARY data-channel message.
1212
- * Layout: [flags(1)][idLen(1)][requestId(ASCII)][payload].
1213
- *
1214
- * @param {DataChannel} channel
1215
- * @param {string} requestId
1216
- * @param {Uint8Array | null} bytes - Body bytes, or null/empty for the done frame.
1217
- * @param {boolean} done
1218
- * @returns {void}
1219
- */
1220
- /**
1221
- * The request id as bytes, prepared once per request rather than per chunk.
1222
- *
1223
- * A segment is a couple of hundred chunks, and each one was re-encoding the
1224
- * same 32-character string. The map is bounded because request ids are
1225
- * short-lived and unbounded in number — dropping the whole cache when it
1226
- * grows costs one re-encode per live request and cannot leak.
1227
- *
1228
- * @param {string} requestId
1229
- * @returns {Buffer}
1230
- */
1231
- function requestIdBytes(requestId) {
1232
- let bytes = requestIdCache.get(requestId);
1233
- if (!bytes) {
1234
- if (requestIdCache.size > 64) {
1235
- requestIdCache.clear();
1236
- }
1237
- bytes = Buffer.from(requestId, "ascii");
1238
- requestIdCache.set(requestId, bytes);
1239
- }
1240
- return bytes;
1241
- }
1242
-
1243
- function sendChunk(channel, requestId, bytes, done) {
1244
- try {
1245
- channel.sendMessageBinary(encodeFrame(requestIdBytes(requestId), bytes, done));
1246
- } catch {
1247
- // Channel closed between check and send — safe to ignore.
1248
- }
1249
- }
1250
-
1251
- /**
1252
- * Resolve once the channel's outgoing buffer has drained below the low-water
1253
- * mark. No-op (resolves immediately) when the buffer is already small or the
1254
- * channel does not expose buffer APIs. The previous implementation resolved
1255
- * after {@link DC_BUFFER_DRAIN_TIMEOUT_MS} even though the buffer was still
1256
- * high, so the send loop kept queueing into a wedged channel and the queue
1257
- * grew to 399 MB (field 2026-09-06). This version waits until the buffer
1258
- * actually drains, polling as a fallback for a missed low-water event, and
1259
- * never resolves while the channel is still holding bytes above the low-water
1260
- * mark.
1261
- *
1262
- * @param {DataChannel} channel
1263
- * @returns {Promise<void>}
1264
- */
1265
- function waitForBufferDrain(channel) {
1266
- return new Promise((resolve) => {
1267
- try {
1268
- if (typeof channel.bufferedAmount !== "function" || channel.bufferedAmount() <= DC_BUFFER_HIGH_WATER) {
1269
- resolve();
1270
- return;
1271
- }
1272
- let poll = null;
1273
- let settled = false;
1274
- const done = () => {
1275
- if (settled) return;
1276
- settled = true;
1277
- if (poll) clearInterval(poll);
1278
- try { channel.onBufferedAmountLow(() => {}); } catch {}
1279
- resolve();
1280
- };
1281
- channel.setBufferedAmountLowThreshold(DC_BUFFER_LOW_WATER);
1282
- channel.onBufferedAmountLow(done);
1283
- // Poll as a fallback for a missed low-water event — the previous
1284
- // 5 s timeout resolved while the buffer was still high, which is the
1285
- // defect. Polling waits until the condition is actually met.
1286
- poll = setInterval(() => {
1287
- try {
1288
- if (channel.bufferedAmount() <= DC_BUFFER_LOW_WATER) {
1289
- done();
1290
- }
1291
- } catch {
1292
- done();
1293
- }
1294
- }, 50);
1295
- if (typeof poll.unref === "function") poll.unref();
1296
- // Guard against a race where the buffer drained between the check above
1297
- // and registering the callback (the low-water event would never fire).
1298
- if (channel.bufferedAmount() <= DC_BUFFER_LOW_WATER) {
1299
- done();
1300
- }
1301
- } catch {
1302
- resolve();
1303
- }
1304
- });
1305
- }
1306
-
1307
- /**
1308
- * Serialise `message` to JSON and send it over the data channel.
1309
- * Errors are silently swallowed — the channel may have closed between
1310
- * the open check and the actual send.
1311
- *
1312
- * @param {DataChannel} channel
1313
- * @param {object} message
1314
- * @returns {void}
1315
- */
1316
- function send(channel, message) {
1317
- try {
1318
- channel.sendMessage(JSON.stringify(message));
1319
- } catch {
1320
- // Channel closed between check and send — safe to ignore.
1321
- }
1322
- }
1323
-
1324
- return { handleChannel, publishSubtitleCues };
1325
- }
1326
-
1327
- /**
1328
- * Allowed path prefixes for data-channel requests.
1329
- * Only the known proxy API and streaming routes are accepted.
1330
- */
1331
- const PATH_ALLOWLIST_RE = /^(?:\/api\/|\/stream(?:$|\?)|\/?transcode\/|\/health(?:z)?(?:$|\?))/;
1332
-
1333
- /**
1334
- * True when `path` is an absolute, traversal-free path on a known proxy route.
1335
- * Shared by the single-message and chunked request entry points.
1336
- *
1337
- * @param {unknown} path
1338
- * @returns {boolean}
1339
- */
1340
- function isValidRequestPath(path) {
1341
- return (
1342
- typeof path === "string" &&
1343
- path.startsWith("/") &&
1344
- !path.includes("..") &&
1345
- PATH_ALLOWLIST_RE.test(path)
1346
- );
1347
- }
1348
-
1349
- /** Max assembled size of a chunked request body (guards proxy memory). */
1350
- const PROXY_MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024;
1351
- /** Drop an incomplete chunked body if no further frame arrives within this window. */
1352
- const PARTIAL_REQUEST_TTL_MS = 60_000;
1353
-
1354
- /** Pause sending body chunks once the channel buffer exceeds this many bytes. */
1355
- // How often the send queue is sampled, and how long it must fail to fall
1356
- // before the transport is asked what it is doing. Five seconds is far longer
1357
- // than any healthy burst drains in — measured, a 6-11 MB segment leaves in
1358
- // well under a second on the LAN — and short enough that a stuck channel is
1359
- // named while the viewer is still looking at it.
1360
- const SEND_QUEUE_SAMPLE_MS = 1_000;
1361
- // How often the transport's own counters are written to the log, whatever the
1362
- // send queue is doing. Frequent enough to place a loss within a few seconds,
1363
- // sparse enough that a two-hour film costs a few hundred lines.
1364
- const TRANSPORT_HEARTBEAT_MS = 5_000;
1365
-
1366
- // How many heartbeats in a row may find no transport for this session before
1367
- // the watch gives up. Several rather than one, so a momentary gap in the
1368
- // registry does not end a healthy watch.
1369
- const TRANSPORT_UNKNOWN_HEARTBEATS = 3;
1370
- const SEND_QUEUE_STUCK_MS = 5_000;
1371
- const DC_BUFFER_HIGH_WATER = 8 * 1024 * 1024;
1372
- /** Resume sending once the channel buffer drains to this many bytes. */
1373
- const DC_BUFFER_LOW_WATER = 1 * 1024 * 1024;
1
+ /**
2
+ * @file WebRTC data channel request handler (proxy side).
3
+ *
4
+ * When a browser opens a data channel to this proxy, this handler wires up
5
+ * message handlers that implement an HTTP-over-DataChannel protocol:
6
+ * each incoming `request` message triggers a local `fetch` to the Fastify
7
+ * server, and the response is streamed back as base64-encoded chunks.
8
+ *
9
+ * ## Wire protocol
10
+ *
11
+ * Browser → Proxy
12
+ * ```
13
+ * { type: "request", requestId, method, path, query, headers, body }
14
+ * { type: "ping", id }
15
+ * { type: "probe-echo", seen: { <label>: seq }, report }
16
+ * ```
17
+ *
18
+ * Proxy → Browser
19
+ * ```
20
+ * { type: "probe", seq, sentAt } (JSON string)
21
+ * { type: "response-start", requestId, status, headers } (JSON string)
22
+ * { type: "response-error", requestId, error: string } (JSON string)
23
+ * { type: "pong", id } (JSON string)
24
+ * { type: "subtitle-cues", fileIndex, trackIndex, cues, language, cursor } (JSON string)
25
+ * ```
26
+ * The last one is unsolicited — sent the moment new cues are read from a
27
+ * file's already-downloaded pieces, to whichever channel last asked for that
28
+ * file's subtitles over `/api/subtitles`. Not a response to any `requestId`.
29
+ *
30
+ * Response bodies are sent as BINARY data-channel messages (not JSON), to
31
+ * avoid the ~33% base64 overhead and the JSON encode/decode cost. Each binary
32
+ * frame is laid out as:
33
+ * ```
34
+ * byte 0 flags (bit 0: done)
35
+ * byte 1 idLen (length of the requestId in bytes)
36
+ * bytes 2..2+N requestId (ASCII)
37
+ * bytes 2+N.. payload (raw body bytes; empty on the final done frame)
38
+ * ```
39
+ * Control messages stay JSON strings so the browser can distinguish them from
40
+ * body frames by message type (string vs ArrayBuffer).
41
+ *
42
+ * The protocol mirrors the tunnel relay protocol so both transports share
43
+ * the same mental model and the same browser-side `WebRtcProxy` implementation.
44
+ */
45
+
46
+ /** @import { DataChannel } from 'node-datachannel' */
47
+
48
+ import { createDeliveryProbe, PROBE_INTERVAL_MS } from "./delivery-probe.js";
49
+
50
+ /**
51
+ * Configuration for the data channel handler.
52
+ *
53
+ * @typedef {Object} DataChannelHandlerOptions
54
+ * @property {number} proxyPort
55
+ * Local port the proxy's Fastify HTTP server is listening on.
56
+ * Incoming requests are forwarded to `http://127.0.0.1:{proxyPort}`.
57
+ * @property {(message: string) => void} [onLog]
58
+ * Optional log sink.
59
+ * @property {{ maybeCapture: (trigger: {
60
+ * sessionId: string, tag: string, label: string,
61
+ * remote: { address: string, port: number } | null,
62
+ * queuedBytes: number, stuckForMs: number
63
+ * }) => boolean }} [witness]
64
+ * The packet witness (services/transport/packet-witness.js). When {@link wedgeIsCertain}
65
+ * says delivery has stopped, the watcher hands it the transport snapshot's
66
+ * remote endpoint: the ring's history is kept and a tail capture records what
67
+ * the wire actually does. Optional; absent means no captures are taken.
68
+ */
69
+
70
+ /**
71
+ * An incoming request message received over the data channel.
72
+ *
73
+ * @typedef {Object} DataChannelRequest
74
+ * @property {string} requestId
75
+ * @property {string} method - HTTP method (GET, POST, …).
76
+ * @property {string} path - Request path (e.g. "/api/sources").
77
+ * @property {string} query - Raw query string without the leading "?".
78
+ * @property {Record<string, string>} headers - Headers to forward.
79
+ * @property {string | null} body - Request body string, or null.
80
+ */
81
+
82
+ /**
83
+ * The object returned by {@link createDataChannelHandler}.
84
+ *
85
+ * @typedef {Object} DataChannelHandler
86
+ * @property {(sessionId: string, channel: DataChannel) => void} handleChannel
87
+ * Wire message handlers onto a freshly opened data channel.
88
+ */
89
+
90
+ /**
91
+ * How long a wedge must hold before the packet witness is asked for evidence.
92
+ *
93
+ * Derived per connection rather than chosen, because a chosen number is what
94
+ * cost the two field captures their onset: the previous rule waited a flat 30 s
95
+ * and the recording therefore began half a minute after the interesting part.
96
+ *
97
+ * Three quantities, all measured on this connection:
98
+ *
99
+ * the queue's own drain time — `queuedBytes / bytesPerSecond`, how long a
100
+ * healthy channel would need to clear what is sitting in it, at the best rate
101
+ * this very connection has been seen to move bytes at;
102
+ *
103
+ * the longest this connection has EVER paused while healthy — an ordinary
104
+ * retransmission timeout stops the accepted-byte counter dead for as long as
105
+ * it lasts, because a full send buffer accepts nothing, and a link with loss
106
+ * does that routinely. The longest such pause already observed here is what
107
+ * the link's own behaviour says a legitimate pause looks like;
108
+ *
109
+ * the interval at which we offer bytes at all — the delivery probe hands
110
+ * every channel a message every {@linkcode PROBE_INTERVAL_MS}, so in health
111
+ * the accepted-byte counter cannot stand still for longer than that.
112
+ *
113
+ * A wedge is certain once ALL of them have passed with the counter unmoved.
114
+ * Without a rate there is nothing to divide by, and the function says so
115
+ * instead of guessing.
116
+ *
117
+ * @param {{ queuedBytes: number, bytesPerSecond: number, flatForMs: number, longestHealthyFlatMs?: number }} state
118
+ * @returns {{ certain: boolean, needMs: number | null }}
119
+ */
120
+ export function wedgeIsCertain({ queuedBytes, bytesPerSecond, flatForMs, longestHealthyFlatMs = 0 }) {
121
+ if (!(bytesPerSecond > 0)) {
122
+ return { certain: false, needMs: null };
123
+ }
124
+ const drainMs = (Math.max(queuedBytes, 0) / bytesPerSecond) * 1000;
125
+ const needMs = Math.max(drainMs, longestHealthyFlatMs, PROBE_INTERVAL_MS);
126
+ return { certain: flatForMs >= needMs, needMs };
127
+ }
128
+
129
+ /**
130
+ * Watch one channel's send queue and, when it stops draining, say WHY.
131
+ *
132
+ * A channel that is open, keeps accepting requests and delivers nothing was
133
+ * seen in the field 2026-08-06: the queue grew from 214 049 to 239 731 bytes in
134
+ * fourteen seconds and never fell, while every layer above reported success —
135
+ * the route answered in 15 ms, the handler sent 378 bytes, the channel was
136
+ * open. The viewer sat in front of a spinner for eleven minutes.
137
+ *
138
+ * `bufferedAmount` alone cannot say why: it only proves the bytes are still
139
+ * OURS. The transport counters can, and this is the table the snapshot is read
140
+ * against — written down in advance so the answer is a reading, not an opinion:
141
+ *
142
+ * bytesSent rising, queue rising → packets leave, nothing acknowledges
143
+ * them: the return path is broken.
144
+ * bytesSent flat, queue rising → SCTP is not transmitting: the peer's
145
+ * receive window is shut or congestion
146
+ * control has collapsed.
147
+ * bytesReceived rising either way → the peer is alive and its packets do
148
+ * reach us; the failure is one-way.
149
+ * both flat → nothing crosses at all.
150
+ *
151
+ * Sampled every second; reported only once the queue has failed to fall for
152
+ * {@link SEND_QUEUE_STUCK_MS}, then every second while it lasts, so the trend
153
+ * of every counter is in the log rather than one snapshot of it.
154
+ *
155
+ * @param {string} sessionId
156
+ * @param {string} tag
157
+ * @param {string} label
158
+ * @param {DataChannel} channel
159
+ * @returns {() => void} Stops the watch.
160
+ */
161
+ function makeSendQueueWatcher({ log, getTransportSnapshot, witness, usrsctpState, onConnectionGone }) {
162
+ // Every channel of one connection reads the SAME transport counters — the
163
+ // snapshot describes the peer connection, not the channel — so the heartbeat
164
+ // belongs to the connection and is printed once for it. Printed per channel
165
+ // it produced two byte-for-byte identical lines (measured 2026-08-14:
166
+ // `sent=5153491` under both "proxy" and "proxy-control"), which read as two
167
+ // independent readings agreeing and made the second channel invisible: the
168
+ // one thing that IS per channel, its queue depth, was the only real
169
+ // difference and it was buried in a line that looked like a duplicate.
170
+ //
171
+ // sessionId → the channels currently open on that connection, and when it was
172
+ // last reported. Channels are keyed by the channel OBJECT, not by its label:
173
+ // a label is whatever the peer chose and two channels can carry the same one
174
+ // (or none, where `getLabel` is missing and both fall back to "?"), and a
175
+ // Map keyed on that would let one channel evict the other and then, on
176
+ // closing, delete the survivor's entry. `captureStarted` rides on the same
177
+ // record: both channels of one wedged connection must ask the witness once,
178
+ // not once per channel.
179
+ /** @type {Map<string, { channels: Map<DataChannel, string>, at: number, previous: object | null, unknown: number, captureStarted: boolean }>} */
180
+ const connections = new Map();
181
+
182
+ /**
183
+ * What each channel of a connection is holding, right now.
184
+ *
185
+ * @param {Map<DataChannel, string>} channels
186
+ * @returns {string} `label:NB` per channel, in the order they opened.
187
+ */
188
+ const queueDepths = (channels) => {
189
+ const parts = [];
190
+ for (const [openChannel, channelLabel] of channels) {
191
+ let depth = -1;
192
+ try {
193
+ depth = typeof openChannel.bufferedAmount === "function" ? openChannel.bufferedAmount() : 0;
194
+ } catch {
195
+ depth = -1;
196
+ }
197
+ parts.push(`${channelLabel}:${depth}B`);
198
+ }
199
+ return parts.join(" ");
200
+ };
201
+
202
+ /**
203
+ * What this connection is getting away, for whoever else needs it.
204
+ *
205
+ * The delivery probe judges a late probe against the queue ahead of it, and
206
+ * the queue's drain time needs a rate. It is measured here already, once a
207
+ * second, so it is read from here rather than measured twice.
208
+ *
209
+ * @param {string} sessionId
210
+ * @returns {{ bytesPerSecond: number, rttMs: number } | null}
211
+ */
212
+ const readDelivery = (sessionId) => {
213
+ const connection = connections.get(sessionId);
214
+ if (!connection) {
215
+ return null;
216
+ }
217
+ return {
218
+ bytesPerSecond: connection.bytesPerSecond,
219
+ rttMs: Number(connection.previous?.rtt) || 0
220
+ };
221
+ };
222
+
223
+ /**
224
+ * Update the browser-side channel byte counter for a session.
225
+ *
226
+ * The transport's `bytesReceived` advances on SACKs (140 B/s on a wedged
227
+ * association) even though every data channel is flat — so it gives a false
228
+ * "peer still sending" through every other tick (field 2026-09-06: 6 h 50 min
229
+ * wedge, `flowing`/`association-stopped` alternating every 500 ms). Channel
230
+ * counters are in the same `probe-echo` report and are flat on a wedge.
231
+ *
232
+ * @param {string} sessionId
233
+ * @param {object} report - The `report` object from `probe-echo`.
234
+ * @returns {void}
235
+ */
236
+ const noteBrowserReport = (sessionId, report) => {
237
+ const connection = connections.get(sessionId);
238
+ if (!connection || !report || typeof report !== "object") return;
239
+ const channels = report.channels;
240
+ if (!channels || typeof channels !== "object") return;
241
+ let total = 0;
242
+ let hasBytes = false;
243
+ for (const v of Object.values(channels)) {
244
+ if (v && typeof v.bytes === "number" && Number.isFinite(v.bytes)) {
245
+ total += v.bytes;
246
+ hasBytes = true;
247
+ }
248
+ }
249
+ if (hasBytes) {
250
+ connection.browserChannelBytes = total;
251
+ }
252
+ };
253
+
254
+ /**
255
+ * @param {string} sessionId
256
+ * @param {string} tag
257
+ * @param {string} label
258
+ * @param {DataChannel} channel
259
+ * @returns {() => void} Stops the watch.
260
+ */
261
+ const watchSendQueue = (sessionId, tag, label, channel) => {
262
+ let lowestSinceDrain = Number.POSITIVE_INFINITY;
263
+ let stuckSince = 0;
264
+ let previous = null;
265
+ // The peer's byte count when this queue stopped falling. What separates a
266
+ // wedge from an ordinary dead connection is that the far end keeps sending
267
+ // throughout — measured across the whole wedge window rather than sampled
268
+ // in a one-second slice, because the browser polls every 1.5 s and plenty
269
+ // of individual seconds are legitimately empty.
270
+ let receivedWhenStuck = -1;
271
+ // Per CHANNEL, not per connection: `proxy-control` and `proxy-fast` hold an
272
+ // empty queue in health and tick every second, so a flag shared with them
273
+ // would be cleared a second after the wedged channel set it and the line
274
+ // would print for every second of a 54-minute episode.
275
+ let wedgeSaid = false;
276
+ let lastStuckLogAt = 0;
277
+ let connection = connections.get(sessionId);
278
+ if (!connection) {
279
+ connection = {
280
+ channels: new Map(),
281
+ at: 0,
282
+ previous: null,
283
+ unknown: 0,
284
+ captureStarted: false,
285
+ // The accepted-byte counter and when it last moved, plus the rate it
286
+ // was moving at. `wedgeIsCertain` divides the queue by that rate.
287
+ rateAt: 0,
288
+ sentAt: 0,
289
+ sentBytes: 0,
290
+ bytesPerSecond: 0,
291
+ longestHealthyFlatMs: 0,
292
+ // Browser-side channel bytes (sum of report.channels[].bytes). Transport's
293
+ // bytesReceived advances on SACKs (140 B/s on a wedge) and gives a false
294
+ // "peer still sending" every other tick — channel bytes are flat on a wedge.
295
+ browserChannelBytes: 0,
296
+ browserChannelBytesAtStuck: -1
297
+ };
298
+ connections.set(sessionId, connection);
299
+ }
300
+ connection.channels.set(channel, label);
301
+ /** @type {ReturnType<typeof setInterval> | null} */
302
+ let timer = null;
303
+ let stopped = false;
304
+ // Record the wire for as long as this channel is open. Held here rather
305
+ // than beside `onClosed`, because `onClosed` does not always come — a peer
306
+ // connection can die without it — and the watch below already ends itself
307
+ // when the transport stops answering. A hold that outlives its channel
308
+ // would leave tcpdump writing on an idle proxy for the life of the process.
309
+ witness?.holdRing?.();
310
+ /**
311
+ * End this channel's watch and let go of its entry.
312
+ *
313
+ * @returns {void}
314
+ */
315
+ const stop = (because = "the transport stopped answering") => {
316
+ if (stopped) {
317
+ return;
318
+ }
319
+ stopped = true;
320
+ witness?.releaseRing?.();
321
+ if (timer) {
322
+ clearInterval(timer);
323
+ }
324
+ connection.channels.delete(channel);
325
+ // Only if the map still holds THIS record: a late stop, after the same
326
+ // session id has been reused and a new record made for it, must not evict
327
+ // the live one.
328
+ if (connection.channels.size === 0 && connections.get(sessionId) === connection) {
329
+ connections.delete(sessionId);
330
+ // Every channel of this connection is down, so the person on it is
331
+ // gone. This is the SECOND way that becomes known, and it exists
332
+ // because the first does not always come: a peer connection can die
333
+ // without `onClosed`, which is why this watch was written in the first
334
+ // place.
335
+ onConnectionGone?.(sessionId, because);
336
+ }
337
+ };
338
+ // Independent of the queue: the transport's own counters, sampled for as
339
+ // long as the channel is open. The queue was the wrong thing to watch —
340
+ // field 2026-08-06, a 9.26 MB segment was accepted by the transport with
341
+ // `maxBuffered=0 bufferedAtEnd=0`, reported as sent at 274 Mbit/s, and
342
+ // never arrived; everything the proxy sent from that moment on was lost the
343
+ // same way while requests kept coming the other direction. With nothing
344
+ // queued this watcher never woke, so the one question that matters — did
345
+ // those bytes leave the machine — has no answer in the log. It does now.
346
+ // A connection the transport no longer knows about is gone, whatever the
347
+ // channel says. `onClosed` is the ordinary way this watch ends, and it does
348
+ // not always come — a peer connection can die without it, leaving the timer
349
+ // and this channel's entry behind for the life of the process.
350
+ //
351
+ // The count is kept on the CONNECTION: exactly one channel enters the
352
+ // heartbeat branch per interval, so a per-channel count would advance only
353
+ // on that channel's turn and the teardown would take three heartbeats per
354
+ // channel rather than three in total.
355
+ timer = setInterval(() => {
356
+ const sampledAt = Date.now();
357
+ // Whichever channel's timer arrives first past the interval reports for
358
+ // the whole connection; the others find the timestamp already moved and
359
+ // skip. So the line appears once however many channels are open.
360
+ if (sampledAt - connection.at >= TRANSPORT_HEARTBEAT_MS) {
361
+ connection.at = sampledAt;
362
+ const snapshot = getTransportSnapshot?.(sessionId) ?? null;
363
+ connection.unknown = snapshot ? 0 : connection.unknown + 1;
364
+ if (connection.unknown >= TRANSPORT_UNKNOWN_HEARTBEATS) {
365
+ // THE SECOND DETECTOR, and it is the one that has to work: `onClosed`
366
+ // does not always come — a peer connection can die without it — and
367
+ // presence is the connection and nothing else, so nothing else will
368
+ // ever say these people have gone. Until now this ended the watch and
369
+ // left them registered for the life of the process, holding the
370
+ // outputs they were watching.
371
+ // Named for what it is: the watcher is handed this, it does not
372
+ // hold the registry. Written as a bare `connectionGone` it was a
373
+ // reference to a function of the FACTORY, which is not in scope here,
374
+ // so the one detector that has to work threw a `ReferenceError`
375
+ // inside its own timer instead of releasing the viewer.
376
+ onConnectionGone?.(sessionId, "the transport stopped answering");
377
+ stop();
378
+ return;
379
+ }
380
+ if (snapshot) {
381
+ const sent = connection.previous ? snapshot.bytesSent - connection.previous.bytesSent : null;
382
+ const received = connection.previous
383
+ ? snapshot.bytesReceived - connection.previous.bytesReceived
384
+ : null;
385
+ connection.previous = snapshot;
386
+ log(
387
+ `[dc-transport] ${tag} sent=${snapshot.bytesSent}` +
388
+ `${sent === null ? "" : ` (+${sent})`} received=${snapshot.bytesReceived}` +
389
+ `${received === null ? "" : ` (+${received})`} queued[${queueDepths(connection.channels)}] ` +
390
+ `rtt=${snapshot.rtt}ms pc=${snapshot.state} ice=${snapshot.iceState} pair=${snapshot.pair}`
391
+ );
392
+ }
393
+ }
394
+ // The rate this connection accepts bytes at, and how long that counter
395
+ // has stood still — both measured every second, whatever the queue is
396
+ // doing, because the rate has to come from the HEALTHY stretch that
397
+ // precedes a wedge. One channel updates it for the whole connection.
398
+ if (sampledAt - connection.rateAt >= SEND_QUEUE_SAMPLE_MS) {
399
+ connection.rateAt = sampledAt;
400
+ const snapshot = getTransportSnapshot?.(sessionId) ?? null;
401
+ const sentNow = Number(snapshot?.bytesSent);
402
+ if (Number.isFinite(sentNow) && sentNow >= 0) {
403
+ if (connection.sentAt === 0 || sentNow < connection.sentBytes) {
404
+ // First reading, or the counter went backwards — a fresh peer
405
+ // connection reusing this session id. Either way the old baseline
406
+ // describes a transport that no longer exists, so start over
407
+ // rather than measure a pause against it for ever.
408
+ connection.sentBytes = sentNow;
409
+ connection.sentAt = sampledAt;
410
+ } else if (sentNow > connection.sentBytes) {
411
+ const seconds = (sampledAt - connection.sentAt) / 1000;
412
+ if (seconds > 0) {
413
+ // The BEST rate this connection has shown, not the latest one.
414
+ // The latest is usually the quietest: with the browser's buffer
415
+ // full nothing is requested for tens of seconds and the only
416
+ // traffic is the probe, a few hundred bytes a second. Dividing a
417
+ // queue by that gives hours, and the wedge would never be called.
418
+ const rate = (sentNow - connection.sentBytes) / seconds;
419
+ if (rate > connection.bytesPerSecond) {
420
+ connection.bytesPerSecond = rate;
421
+ }
422
+ }
423
+ // How long the counter stood still before this advance. While the
424
+ // queue is draining that pause was legitimate, so it is the link's
425
+ // own answer to "how long may a healthy pause be".
426
+ const pausedMs = sampledAt - connection.sentAt;
427
+ if (stuckSince === 0 && pausedMs > connection.longestHealthyFlatMs) {
428
+ connection.longestHealthyFlatMs = pausedMs;
429
+ }
430
+ connection.sentBytes = sentNow;
431
+ connection.sentAt = sampledAt;
432
+ }
433
+ }
434
+ }
435
+ let queued = 0;
436
+ try {
437
+ queued = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0;
438
+ } catch {
439
+ return;
440
+ }
441
+ if (queued === 0 || queued < lowestSinceDrain) {
442
+ lowestSinceDrain = queued;
443
+ stuckSince = 0;
444
+ previous = null;
445
+ receivedWhenStuck = -1;
446
+ connection.browserChannelBytesAtStuck = -1;
447
+ wedgeSaid = false;
448
+ // The queue moved, so whatever was called a wedge has cleared. Let a
449
+ // later one be recorded too: one mistaken call must not spend the
450
+ // session's only capture.
451
+ connection.captureStarted = false;
452
+ return;
453
+ }
454
+ const now = Date.now();
455
+ if (stuckSince === 0) {
456
+ stuckSince = now;
457
+ return;
458
+ }
459
+ const snapshot = getTransportSnapshot?.(sessionId) ?? null;
460
+ if (!snapshot) {
461
+ if (now - stuckSince >= SEND_QUEUE_STUCK_MS && now - lastStuckLogAt >= 30_000) {
462
+ lastStuckLogAt = now;
463
+ log(`[dc] Session ${tag} "${label}": send queue stuck at ${queued}B for ` +
464
+ `${Math.round((now - stuckSince) / 1000)}s — no transport to ask`);
465
+ }
466
+ return;
467
+ }
468
+ const sentDelta = previous ? snapshot.bytesSent - previous.bytesSent : null;
469
+ const recvDelta = previous ? snapshot.bytesReceived - previous.bytesReceived : null;
470
+ previous = snapshot;
471
+ if (receivedWhenStuck < 0) {
472
+ receivedWhenStuck = snapshot.bytesReceived;
473
+ }
474
+ if (connection.browserChannelBytesAtStuck < 0) {
475
+ connection.browserChannelBytesAtStuck = connection.browserChannelBytes;
476
+ }
477
+ // The periodic line waits for {@link SEND_QUEUE_STUCK_MS}, because a
478
+ // queue that has merely not fallen for a second is ordinary and a line a
479
+ // second for it is noise. The WEDGE below does not wait for it: its own
480
+ // condition already says how long this queue may legitimately take, and
481
+ // on a fast link that is under a second. Holding the evidence back for a
482
+ // fixed five seconds would repeat, in miniature, the mistake that left
483
+ // both field captures without an onset in them.
484
+ // Rate-limit to 30 s — field 2026-09-06 printed 49 295 lines (31 % of log)
485
+ // for one 6 h 50 min wedge, one line per second per channel.
486
+ if (now - stuckSince >= SEND_QUEUE_STUCK_MS && now - lastStuckLogAt >= 30_000) {
487
+ lastStuckLogAt = now;
488
+ log(
489
+ `[dc] Session ${tag} "${label}": send queue stuck at ${queued}B for ` +
490
+ `${Math.round((now - stuckSince) / 1000)}s — transport ` +
491
+ `sent=${snapshot.bytesSent}${sentDelta === null ? "" : ` (+${sentDelta})`} ` +
492
+ `received=${snapshot.bytesReceived}${recvDelta === null ? "" : ` (+${recvDelta})`} ` +
493
+ `rtt=${snapshot.rtt}ms pc=${snapshot.state} ice=${snapshot.iceState} pair=${snapshot.pair}`
494
+ );
495
+ }
496
+ // Roadmap item 11: ask for the packet-level truth the moment the wedge is
497
+ // CERTAIN, not after a chosen delay. The three facts below are not
498
+ // ambiguous together — the queue has not fallen, the accepted-byte
499
+ // counter has not moved for longer than the queue's own drain time at
500
+ // this link's own speed, and the peer is still sending. The previous
501
+ // rule's flat 30 s is what left both field captures with no onset in
502
+ // them. One attempt per connection — the witness applies its own
503
+ // single-flight and cooldown rules after that.
504
+ const flatForMs = connection.sentAt === 0 ? 0 : now - connection.sentAt;
505
+ const verdict = wedgeIsCertain({
506
+ queuedBytes: queued,
507
+ bytesPerSecond: connection.bytesPerSecond,
508
+ flatForMs,
509
+ longestHealthyFlatMs: connection.longestHealthyFlatMs
510
+ });
511
+ const hasBrowserBytes = Number.isFinite(connection.browserChannelBytes) && connection.browserChannelBytesAtStuck >= 0;
512
+ const peerStillSending = hasBrowserBytes
513
+ ? connection.browserChannelBytes > connection.browserChannelBytesAtStuck
514
+ : snapshot.bytesReceived > receivedWhenStuck;
515
+ if (verdict.certain && peerStillSending && !wedgeSaid) {
516
+ wedgeSaid = true;
517
+ log(
518
+ `[dc] Session ${tag} "${label}": delivery has stopped — ${queued}B queued, ` +
519
+ `accepted-byte counter unmoved for ${Math.round(flatForMs / 1000)}s against the ` +
520
+ `${(verdict.needMs / 1000).toFixed(1)}s this queue needs at the ` +
521
+ `${(connection.bytesPerSecond / 1024).toFixed(0)} KB/s last measured here, ` +
522
+ "and the peer is still sending"
523
+ );
524
+ }
525
+ if (
526
+ witness &&
527
+ !connection.captureStarted &&
528
+ verdict.certain &&
529
+ peerStillSending
530
+ ) {
531
+ connection.captureStarted = true;
532
+ witness.maybeCapture({
533
+ sessionId,
534
+ tag,
535
+ label,
536
+ remote: snapshot.remote ?? null,
537
+ queuedBytes: queued,
538
+ stuckForMs: now - stuckSince
539
+ });
540
+ }
541
+ if (usrsctpState && verdict.certain && peerStillSending) {
542
+ // Its own single-flight and cooldown, independent of the witness's —
543
+ // one gdb attach per wedge is enough, and a refusal here (already
544
+ // read, cooling down) costs nothing to retry on the next tick.
545
+ usrsctpState.maybeRead(
546
+ `send queue stuck ${queued}B, accepted-byte counter unmoved ${Math.round(flatForMs / 1000)}s`
547
+ );
548
+ }
549
+ }, SEND_QUEUE_SAMPLE_MS);
550
+
551
+ if (typeof timer.unref === "function") {
552
+ timer.unref();
553
+ }
554
+ return stop;
555
+ };
556
+
557
+ return { watchSendQueue, readDelivery, noteBrowserReport };
558
+ }
559
+
560
+ /**
561
+ * Create a handler for incoming WebRTC data channels.
562
+ *
563
+ * @param {DataChannelHandlerOptions} options
564
+ * @returns {DataChannelHandler}
565
+ */
566
+ import { performance } from "node:perf_hooks";
567
+ import { eventLoopDelay, resetEventLoopDelay } from "../../utils/perf.js";
568
+
569
+ /**
570
+ * Build one body frame: `[flags(1)][idLen(1)][requestId][payload]`.
571
+ *
572
+ * One allocation and one copy. The previous version made two of each — a copy
573
+ * of the chunk into a `Buffer`, then a `concat` that copied it again into the
574
+ * frame — which measured 75.9 ms per 13 MB segment on the field host against
575
+ * 40.0 ms this way, and allocated ~600 extra buffers over a segment's 208
576
+ * chunks. One copy is the floor: chunks arrive from a web stream that allocates
577
+ * them itself, so there is no buffer of ours to read them into.
578
+ *
579
+ * @param {Buffer} idBytes - The request id, already encoded.
580
+ * @param {Uint8Array | null} bytes - Payload, or nothing for the done frame.
581
+ * @param {boolean} done
582
+ * @returns {Buffer}
583
+ */
584
+ export function encodeFrame(idBytes, bytes, done) {
585
+ const payloadLength = bytes?.length ?? 0;
586
+ const frame = Buffer.allocUnsafe(2 + idBytes.length + payloadLength);
587
+ frame[0] = done ? 1 : 0;
588
+ frame[1] = idBytes.length;
589
+ idBytes.copy(frame, 2);
590
+ if (payloadLength > 0) {
591
+ frame.set(bytes, 2 + idBytes.length);
592
+ }
593
+ return frame;
594
+ }
595
+
596
+ /**
597
+ * Reshapes a body into messages of `sizeBytes` each, handing every finished one
598
+ * to `sendOne`. A size of zero passes each body read through untouched, which
599
+ * is what the field does today — 10 657 210 bytes over 163 messages, so 65 382
600
+ * each, measured 2026-09-12.
601
+ *
602
+ * Only the SIZE changes: the same bytes reach the far end in the same order,
603
+ * over the same channel. That is what makes a comparison between two sizes a
604
+ * comparison of one thing, and it is why the wedge's dependence on the size can
605
+ * be measured at all — the wedge of 2026-09-12 arrived in the middle of one
606
+ * message, with 63 % of a segment on the wire and then nothing.
607
+ *
608
+ * @param {(bytes: Uint8Array) => void} sendOne - Given each finished message.
609
+ * @param {number} sizeBytes - 0 to leave reads as they are.
610
+ * @returns {{ push: (bytes: Uint8Array) => number, flush: () => number }}
611
+ * Both answer how many messages they sent, so a caller counting messages
612
+ * counts messages rather than body reads.
613
+ */
614
+ export function bodySender(sendOne, sizeBytes) {
615
+ if (!(sizeBytes > 0)) {
616
+ return {
617
+ push(bytes) {
618
+ sendOne(bytes);
619
+ return 1;
620
+ },
621
+ flush() {
622
+ return 0;
623
+ }
624
+ };
625
+ }
626
+ /** @type {Uint8Array[]} */
627
+ let held = [];
628
+ let heldBytes = 0;
629
+ const release = () => {
630
+ const message = held.length === 1 ? held[0] : Buffer.concat(held);
631
+ held = [];
632
+ heldBytes = 0;
633
+ sendOne(message);
634
+ };
635
+ return {
636
+ push(bytes) {
637
+ let sent = 0;
638
+ let offset = 0;
639
+ while (offset < bytes.length) {
640
+ const take = Math.min(sizeBytes - heldBytes, bytes.length - offset);
641
+ held.push(bytes.subarray(offset, offset + take));
642
+ heldBytes += take;
643
+ offset += take;
644
+ if (heldBytes === sizeBytes) {
645
+ release();
646
+ sent += 1;
647
+ }
648
+ }
649
+ return sent;
650
+ },
651
+ flush() {
652
+ if (heldBytes === 0) return 0;
653
+ release();
654
+ return 1;
655
+ }
656
+ };
657
+ }
658
+
659
+ export function createDataChannelHandler({
660
+ proxyPort,
661
+ onLog,
662
+ getTransportSnapshot,
663
+ // Who wants pushed subtitle cues for a file, BY NAME. A plain function
664
+ // returning opaque ids: this layer never learns what a viewer is, and the
665
+ // subscription it used to hold against a channel — and had to sniff
666
+ // `/api/subtitles` to build — now belongs to the person and outlives the
667
+ // channel, a reconnect and a deliberate rotation alike.
668
+ viewersWantingCues = () => [],
669
+ witness,
670
+ // Reads usrsctp's own association state (services/transport/usrsctp-state.js) the
671
+ // moment a wedge is declared, from either detector below. Optional: a host
672
+ // without gdb simply never gets a reading, same as the witness without
673
+ // tcpdump.
674
+ usrsctpState,
675
+ // Presence, both directions. A viewer is present because their connection
676
+ // is, and gone because it went — which is a fact about the PERSON and reaches
677
+ // every output they were watching at once. Before this, nothing anywhere
678
+ // released a viewer when their connection closed: the only exits were the
679
+ // browser's own `release` and a silence long enough to be called an absence.
680
+ onViewerPresent = () => {},
681
+ onViewerGone = () => {},
682
+ // The size of one data-channel message carrying body bytes. Zero keeps
683
+ // whatever the body read hands over, which in the field is 65 382 bytes —
684
+ // 10 657 210 over 163 chunks, measured 2026-09-12. It is settable because
685
+ // that day's wedge arrived in the MIDDLE of one message (63 % of a segment
686
+ // on the wire, then nothing), and whether the fault depends on the size is
687
+ // the one branch no capture has been able to close.
688
+ sendChunkBytes = 0
689
+ }) {
690
+ /**
691
+ * Who is on each connection, by WebRTC session id, and the channel each of
692
+ * them named themselves on — null once that channel has closed.
693
+ *
694
+ * One connection carries one page, and a page holds one name per film it has
695
+ * open — so this is normally one id, and more than one only where a page has
696
+ * opened more than one.
697
+ *
698
+ * ONE PERSON MAY BE ON TWO CONNECTIONS AT ONCE, and this is the only record of
699
+ * it. The page raises a second connection before it lets go of the first
700
+ * when the first stops delivering, and it may then keep the first and close
701
+ * the second (it delivered again) or the reverse. The relation used to be
702
+ * held twice — this map and a second one naming each viewer's NEWEST
703
+ * connection — and the second could only point at one: a viewer named on a
704
+ * trial connection that was then closed was released while still watching
705
+ * through the old one. Both questions below are asked of this map instead.
706
+ *
707
+ * @type {Map<string, Map<string, DataChannel | null>>}
708
+ */
709
+ const viewersOnConnection = new Map();
710
+
711
+ /**
712
+ * The connections a viewer is still named on, oldest first.
713
+ *
714
+ * @param {string} consumerId
715
+ * @returns {string[]}
716
+ */
717
+ function connectionsCarrying(consumerId) {
718
+ const carrying = [];
719
+ for (const [sessionId, viewers] of viewersOnConnection) {
720
+ if (viewers.has(consumerId)) {
721
+ carrying.push(sessionId);
722
+ }
723
+ }
724
+ return carrying;
725
+ }
726
+
727
+ /**
728
+ * The channel a viewer can be reached on right now: the one they named
729
+ * themselves on, on the newest connection that still carries them with that
730
+ * channel open.
731
+ *
732
+ * The transport knows a NAME and nothing else about a viewer: what it means,
733
+ * who holds it and what they are watching are somebody else's facts. Keeping
734
+ * only "this name is reachable here" is what lets a subscription made once
735
+ * outlive the channel it was made on — a reconnect, and a rotation done on
736
+ * purpose, both just add a connection to the map.
737
+ *
738
+ * @param {string} consumerId
739
+ * @returns {DataChannel | null}
740
+ */
741
+ function channelOfViewer(consumerId) {
742
+ let reachable = null;
743
+ for (const viewers of viewersOnConnection.values()) {
744
+ const channel = viewers.get(consumerId);
745
+ if (channel) {
746
+ reachable = channel;
747
+ }
748
+ }
749
+ return reachable;
750
+ }
751
+
752
+ /**
753
+ * This connection has gone, and with it everyone it was the last to carry.
754
+ *
755
+ * Said once per connection: a second call after the entry is removed must not
756
+ * announce departures a second time, and the two detectors below — the close
757
+ * event and the transport's own counters — can both fire for one death.
758
+ *
759
+ * @param {string} sessionId
760
+ * @param {string} because
761
+ * @returns {void}
762
+ */
763
+ function connectionGone(sessionId, because) {
764
+ const viewers = viewersOnConnection.get(sessionId);
765
+ viewersOnConnection.delete(sessionId);
766
+ if (!viewers || viewers.size === 0) {
767
+ return;
768
+ }
769
+ for (const consumerId of viewers.keys()) {
770
+ const still = connectionsCarrying(consumerId);
771
+ if (still.length > 0) {
772
+ onLog?.(
773
+ `[dc] Session ${sessionId.slice(0, 8)}: viewer ${consumerId} stays — still on ` +
774
+ `${still.map((id) => id.slice(0, 8)).join(", ")} (${because})`
775
+ );
776
+ continue;
777
+ }
778
+ onLog?.(`[dc] Session ${sessionId.slice(0, 8)}: viewer ${consumerId} left — ${because}`);
779
+ try {
780
+ onViewerGone(consumerId, because);
781
+ } catch {
782
+ // A viewer failing to be let go must not stop the others being let go.
783
+ }
784
+ }
785
+ }
786
+
787
+ /**
788
+ * This channel has closed: nobody can be reached on it any more. Who is on
789
+ * the connection does not change — that ends with the connection.
790
+ *
791
+ * @param {DataChannel} channel
792
+ * @returns {void}
793
+ */
794
+ function forgetChannel(channel) {
795
+ for (const viewers of viewersOnConnection.values()) {
796
+ for (const [consumerId, on] of viewers) {
797
+ if (on === channel) {
798
+ viewers.set(consumerId, null);
799
+ }
800
+ }
801
+ }
802
+ }
803
+
804
+ /**
805
+ * Send new cues to every channel watching this file — the push side of
806
+ * subtitles arriving as they download rather than being polled for. Cues
807
+ * are tiny (kilobytes at most for a whole track), so this is one message,
808
+ * not a stream.
809
+ *
810
+ * `withdrawn` names, by their found-order numbers, cues sent earlier that the
811
+ * proxy has taken back — read from a stretch that turned out not to be a
812
+ * cluster. Each cue carries its own number (`seq`) for that purpose.
813
+ *
814
+ * @param {{ sourceKey: string, fileIndex: number, trackIndex: number, cues: object[],
815
+ * withdrawn?: number[], language: string, detectedLanguage: { code: string, name: string } | null,
816
+ * cursor: number }} event
817
+ * @returns {void}
818
+ */
819
+ function publishSubtitleCues({ sourceKey, fileIndex, trackIndex, cues, withdrawn = [], language, detectedLanguage, cursor }) {
820
+ const names = viewersWantingCues(sourceKey, fileIndex);
821
+ if (names.length === 0) {
822
+ log(
823
+ `[dc] subtitle push: ${cues.length} cue(s) for ${sourceKey.slice(0, 8)}:${fileIndex} track ${trackIndex} ` +
824
+ "found nobody who wants them"
825
+ );
826
+ return;
827
+ }
828
+ const message = JSON.stringify({
829
+ type: "subtitle-cues", fileIndex, trackIndex, cues, withdrawn, language, detectedLanguage, cursor
830
+ });
831
+ let sent = 0;
832
+ for (const consumerId of names) {
833
+ const channel = channelOfViewer(consumerId);
834
+ if (!channel) {
835
+ // Subscribed but not reachable this instant — between connections, or
836
+ // rotating. Nothing is dropped: they are still subscribed, and the walk
837
+ // keeps a per-viewer cursor, so what they missed comes on the next ask.
838
+ continue;
839
+ }
840
+ try {
841
+ channel.sendMessage(message);
842
+ sent += 1;
843
+ } catch {
844
+ // Closed between the lookup and the send. The subscription belongs to
845
+ // the person and stays; only this delivery is lost.
846
+ }
847
+ }
848
+ log(
849
+ `[dc] subtitle push: sent ${cues.length} cue(s) for ${sourceKey.slice(0, 8)}:${fileIndex} track ${trackIndex} ` +
850
+ `to ${sent}/${names.length} viewer(s)`
851
+ );
852
+ }
853
+
854
+ /** Request id → its ASCII bytes; see {@link requestIdBytes}. */
855
+ const requestIdCache = new Map();
856
+
857
+ const { watchSendQueue, readDelivery, noteBrowserReport } = makeSendQueueWatcher({
858
+ log: (message) => log(message),
859
+ getTransportSnapshot,
860
+ witness,
861
+ usrsctpState,
862
+ // The watcher is where a connection's death is noticed when no close event
863
+ // arrives, so it is the second door presence leaves by. Given rather than
864
+ // reached for: it is a module-level function and cannot see this closure.
865
+ onConnectionGone: connectionGone
866
+ });
867
+ // Numbered probes on every channel, and the browser's echo of what it saw.
868
+ // The proxy's own counters cannot say whether bytes it handed to usrsctp were
869
+ // ever put on the wire; the far end can, and it keeps answering throughout a
870
+ // freeze. See services/transport/delivery-probe.js.
871
+ //
872
+ // Also the ONLY wedge signal that does not require a nonzero channel queue —
873
+ // `wedgeIsCertain` above needs `queuedBytes > 0`, which small, infrequent
874
+ // traffic never produces (confirmed 2026-08-28: the 2026-08-27 episode's
875
+ // ring was never saved automatically for exactly this reason). So this is
876
+ // wired to the same evidence-gathering as the queue watcher.
877
+ const deliveryProbe = createDeliveryProbe({
878
+ log: (message) => log(message),
879
+ readDelivery,
880
+ getTransportSnapshot,
881
+ witness,
882
+ usrsctpState
883
+ });
884
+
885
+ /**
886
+ * @param {string} message
887
+ * @returns {void}
888
+ */
889
+ function log(message) {
890
+ if (typeof onLog === "function") {
891
+ onLog(message);
892
+ }
893
+ }
894
+
895
+ /**
896
+ * Wire up the `onMessage`, `onClosed`, and `onError` handlers for a channel.
897
+ *
898
+ * @param {string} sessionId
899
+ * @param {DataChannel} channel
900
+ * @returns {void}
901
+ */
902
+ function handleChannel(sessionId, channel) {
903
+ const tag = sessionId.slice(0, 8);
904
+ const label = typeof channel.getLabel === "function" ? channel.getLabel() : "?";
905
+ log(`[dc] Session ${tag}: channel open`);
906
+ const stopWatchdog = watchSendQueue(sessionId, tag, label, channel);
907
+ deliveryProbe.attach(sessionId, tag, label, channel);
908
+
909
+ // Partial chunked-request bodies in flight on THIS channel, keyed by
910
+ // requestId. Each entry buffers frames until the done frame, then runs the
911
+ // assembled request through the same path as a single-message request.
912
+ /** @type {Map<string, { meta: object, chunks: Buffer[], receivedBytes: number, bodyBytes: number, timer: ReturnType<typeof setTimeout> }>} */
913
+ const partials = new Map();
914
+
915
+ const dropPartial = (requestId) => {
916
+ const entry = partials.get(requestId);
917
+ if (entry) {
918
+ clearTimeout(entry.timer);
919
+ partials.delete(requestId);
920
+ }
921
+ };
922
+
923
+ /**
924
+ * Begin assembling a chunked request. Validates the path and size up front
925
+ * so an invalid or oversized request never buffers a body.
926
+ *
927
+ * @param {any} message - The `request-start` control message.
928
+ */
929
+ const startPartialRequest = (message) => {
930
+ const { requestId, method, path, query, headers, bodyBytes } = message ?? {};
931
+ if (typeof requestId !== "string" || requestId.length === 0) {
932
+ return;
933
+ }
934
+ if (!isValidRequestPath(path)) {
935
+ send(channel, { type: "response-error", requestId, error: "Invalid request path." });
936
+ return;
937
+ }
938
+ if (!Number.isInteger(bodyBytes) || bodyBytes < 0 || bodyBytes > PROXY_MAX_REQUEST_BODY_BYTES) {
939
+ send(channel, { type: "response-error", requestId, error: "Request body too large." });
940
+ return;
941
+ }
942
+ dropPartial(requestId); // replace any stale entry with the same id
943
+ const timer = setTimeout(() => {
944
+ const entry = partials.get(requestId);
945
+ partials.delete(requestId);
946
+ log(`[dc] Session ${tag}: dropped stale partial request ${requestId.slice(0, 8)} (${entry?.receivedBytes ?? 0}B)`);
947
+ }, PARTIAL_REQUEST_TTL_MS);
948
+ partials.set(requestId, {
949
+ meta: { requestId, method, path, query, headers },
950
+ chunks: [],
951
+ receivedBytes: 0,
952
+ bodyBytes,
953
+ timer
954
+ });
955
+ };
956
+
957
+ /**
958
+ * Handle a binary body frame for a chunked request.
959
+ * Layout: [flags(1)][idLen(1)][requestId(ASCII)][payload].
960
+ *
961
+ * @param {Buffer} buf
962
+ */
963
+ const handleBodyFrame = (buf) => {
964
+ if (buf.length < 2) {
965
+ return;
966
+ }
967
+ const flags = buf[0];
968
+ const idLen = buf[1];
969
+ if (buf.length < 2 + idLen) {
970
+ return;
971
+ }
972
+ const requestId = buf.toString("ascii", 2, 2 + idLen);
973
+ const entry = partials.get(requestId);
974
+ if (!entry) {
975
+ return; // stale / already-dropped / aborted
976
+ }
977
+ if (flags & 2) {
978
+ // Aborted by the browser — drop silently, no reply.
979
+ dropPartial(requestId);
980
+ return;
981
+ }
982
+ if (buf.length > 2 + idLen) {
983
+ const payload = buf.subarray(2 + idLen);
984
+ entry.chunks.push(Buffer.from(payload));
985
+ entry.receivedBytes += payload.length;
986
+ }
987
+ if (entry.receivedBytes > entry.bodyBytes || entry.receivedBytes > PROXY_MAX_REQUEST_BODY_BYTES) {
988
+ dropPartial(requestId);
989
+ send(channel, { type: "response-error", requestId, error: "Request body size mismatch." });
990
+ return;
991
+ }
992
+ if (flags & 1) {
993
+ // Done frame — assemble and execute.
994
+ dropPartial(requestId);
995
+ if (entry.receivedBytes !== entry.bodyBytes) {
996
+ send(channel, { type: "response-error", requestId, error: "Request body size mismatch." });
997
+ return;
998
+ }
999
+ const body = Buffer.concat(entry.chunks).toString("utf8");
1000
+ void handleRequest(channel, { ...entry.meta, body }, true).catch((error) => {
1001
+ log(`[dc] Session ${tag}: request error: ${error?.message ?? error}`);
1002
+ });
1003
+ }
1004
+ };
1005
+
1006
+ channel.onMessage((raw) => {
1007
+ // Binary messages are chunked-request body frames; the proxy otherwise
1008
+ // only ever receives JSON strings, so the type discriminates cleanly.
1009
+ if (typeof raw !== "string") {
1010
+ handleBodyFrame(Buffer.isBuffer(raw) ? raw : Buffer.from(raw));
1011
+ return;
1012
+ }
1013
+
1014
+ /** @type {DataChannelRequest | { type: string, id?: string }} */
1015
+ let message;
1016
+ try {
1017
+ message = JSON.parse(raw);
1018
+ } catch {
1019
+ return;
1020
+ }
1021
+
1022
+ if (message.type === "request") {
1023
+ void handleRequest(channel, message).catch((error) => {
1024
+ log(`[dc] Session ${tag}: request error: ${error?.message ?? error}`);
1025
+ });
1026
+ return;
1027
+ }
1028
+
1029
+ if (message.type === "request-start") {
1030
+ startPartialRequest(message);
1031
+ return;
1032
+ }
1033
+
1034
+ if (message.type === "ping") {
1035
+ send(channel, { type: "pong", id: message.id });
1036
+ return;
1037
+ }
1038
+
1039
+ // WHO is on this connection. Sent once when the page opens its control
1040
+ // channel, and it is what lets a closed connection say that a PERSON has
1041
+ // gone rather than that one output lost a request.
1042
+ //
1043
+ // The name is the page's, not this connection's: it is minted once per
1044
+ // film opened in the tab and survives the reconnect ladder swapping the
1045
+ // transport underneath a running player. A name minted here would make
1046
+ // one person two the first time that happened, each with their own
1047
+ // position.
1048
+ if (message.type === "viewer" && typeof message.consumerId === "string" && message.consumerId) {
1049
+ const known = viewersOnConnection.get(sessionId) ?? new Map();
1050
+ const isNew = !known.has(message.consumerId);
1051
+ known.set(message.consumerId, channel);
1052
+ viewersOnConnection.set(sessionId, known);
1053
+ if (isNew) {
1054
+ log(`[dc] Session ${tag}: viewer ${message.consumerId} is on this connection`);
1055
+ }
1056
+ onViewerPresent(message.consumerId);
1057
+ return;
1058
+ }
1059
+
1060
+ // The far end's answer to the numbered probes, plus what it can see of
1061
+ // its own receiving. It travels browser to proxy, the direction that goes
1062
+ // on working through a freeze, so it arrives when nothing else does.
1063
+ if (message.type === "probe-echo") {
1064
+ deliveryProbe.noteEcho(sessionId, message);
1065
+ noteBrowserReport(sessionId, message.report);
1066
+ if (message.report && typeof message.report === "object") {
1067
+ const report = message.report;
1068
+ const channels = report.channels && typeof report.channels === "object"
1069
+ ? Object.entries(report.channels)
1070
+ .map(([name, counters]) => `${name}=${counters?.messages ?? "?"}msg/${counters?.bytes ?? "?"}B`)
1071
+ .join(" ")
1072
+ : "";
1073
+ log(
1074
+ `[dc-far] ${tag} visibility=${report.visibility ?? "?"} ` +
1075
+ `loopLag=${report.loopLagMs ?? "?"}ms handler=${report.handlerMaxMs ?? "?"}ms ` +
1076
+ `transportIn=${report.transportBytesReceived ?? "?"} ${channels} ` +
1077
+ `pending=${report.pending ?? "?"} at=${new Date().toISOString()}`
1078
+ );
1079
+ }
1080
+ return;
1081
+ }
1082
+ });
1083
+
1084
+ channel.onClosed(() => {
1085
+ stopWatchdog("the connection closed");
1086
+ deliveryProbe.detach(sessionId, channel);
1087
+ for (const entry of partials.values()) {
1088
+ clearTimeout(entry.timer);
1089
+ }
1090
+ partials.clear();
1091
+ forgetChannel(channel);
1092
+ log(`[dc] Session ${tag}: channel closed`);
1093
+ // One closed data channel does not mean the peer connection is gone:
1094
+ // media, control and probe channels can close independently. The shared
1095
+ // transport watcher calls `connectionGone` when its last channel closes.
1096
+ });
1097
+
1098
+ channel.onError((err) => {
1099
+ log(`[dc] Session ${tag}: channel error: ${err}`);
1100
+ });
1101
+ }
1102
+
1103
+ /**
1104
+ * Fetch a resource from the local proxy HTTP server and stream the response
1105
+ * back to the browser over the data channel.
1106
+ *
1107
+ * The `Host` header is rewritten to `127.0.0.1:{proxyPort}` so that Fastify
1108
+ * routes the request correctly regardless of what the browser sent.
1109
+ *
1110
+ * @param {DataChannel} channel
1111
+ * @param {DataChannelRequest} req
1112
+ * @returns {Promise<void>}
1113
+ */
1114
+ async function handleRequest(channel, req, viaChunks = false) {
1115
+ const { requestId, method, path, query, headers: forwardedHeaders, body } = req;
1116
+
1117
+ // Reject paths that are not absolute, contain traversal sequences, or
1118
+ // do not start with a known proxy route prefix. All valid browser-side
1119
+ // requests use /api/*, /stream, /transcode/*, /health, or /healthz.
1120
+ if (!isValidRequestPath(path)) {
1121
+ send(channel, { type: "response-error", requestId, error: "Invalid request path." });
1122
+ return;
1123
+ }
1124
+
1125
+
1126
+ const queryInfo = query ? `?${query}` : "";
1127
+ const bodyInfo =
1128
+ body != null && typeof body === "string" && body.length > 0
1129
+ ? ` body=${body.length} bytes${viaChunks ? " (chunked)" : ""}`
1130
+ : "";
1131
+ log(`[dc] ${method} ${path}${queryInfo}${bodyInfo}`);
1132
+
1133
+ const targetUrl = `http://127.0.0.1:${proxyPort}${path}${query ? `?${query}` : ""}`;
1134
+ const requestHeaders = { ...(forwardedHeaders ?? {}), host: `127.0.0.1:${proxyPort}` };
1135
+
1136
+ let response;
1137
+ // [net-debug] TEMPORARY: time spent in the local fetch (waiting for the
1138
+ // route to return a response — e.g. long-polling until an HLS segment is
1139
+ // finalized by ffmpeg) vs. the body transfer over the data channel.
1140
+ const fetchStartedAt = Date.now();
1141
+ try {
1142
+ response = await fetch(targetUrl, {
1143
+ method,
1144
+ headers: requestHeaders,
1145
+ body: body != null ? body : undefined,
1146
+ redirect: "manual"
1147
+ });
1148
+ } catch (fetchError) {
1149
+ log(`[dc] ${method} ${path}${queryInfo} → error: ${fetchError?.message ?? String(fetchError)}`);
1150
+ send(channel, { type: "response-error", requestId, error: fetchError?.message ?? String(fetchError) });
1151
+ return;
1152
+ }
1153
+
1154
+ if (response.status !== 200 && response.status !== 206) {
1155
+ log(`[dc] ${method} ${path}${queryInfo} → ${response.status}`);
1156
+ }
1157
+
1158
+ /** @type {Record<string, string>} */
1159
+ const responseHeaders = {};
1160
+ for (const [name, value] of response.headers.entries()) {
1161
+ responseHeaders[name] = value;
1162
+ }
1163
+
1164
+ send(channel, { type: "response-start", requestId, status: response.status, headers: responseHeaders });
1165
+
1166
+ if (!response.body) {
1167
+ sendChunk(channel, requestId, null, true);
1168
+ return;
1169
+ }
1170
+
1171
+ try {
1172
+ const reader = response.body.getReader();
1173
+ // [net-debug] TEMPORARY: measure transfer size/time and channel buffering.
1174
+ // fetchMs = time waiting for the route (incl. ffmpeg segment finalization).
1175
+ // ttfbMs = time from body-read start to the first chunk with data (loopback).
1176
+ // sendMs = total body read+send duration over the data channel.
1177
+ const fetchMs = Date.now() - fetchStartedAt;
1178
+ const sendStartedAt = Date.now();
1179
+ let firstByteMs = -1;
1180
+ let chunks = 0;
1181
+ let totalBytes = 0;
1182
+ let maxBuffered = 0;
1183
+ // Attribute the transfer to the step that actually consumes the time.
1184
+ // Without this split a slow transfer is indistinguishable between "the
1185
+ // source is slow", "the channel is slow" and "the event loop is blocked",
1186
+ // which is exactly the argument a field seek left unresolved.
1187
+ let readMs = 0;
1188
+ let sendMs2 = 0;
1189
+ let drainMs = 0;
1190
+ const body = bodySender((bytes) => sendChunk(channel, requestId, bytes, false), sendChunkBytes);
1191
+ resetEventLoopDelay();
1192
+ while (true) {
1193
+ const readStartedAt = performance.now();
1194
+ const { done, value } = await reader.read();
1195
+ readMs += performance.now() - readStartedAt;
1196
+ if (done) {
1197
+ chunks += body.flush();
1198
+ sendChunk(channel, requestId, null, true);
1199
+ const elapsedMs = Date.now() - sendStartedAt;
1200
+ let bufferedNow = 0;
1201
+ try { bufferedNow = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0; } catch { /* ignore */ }
1202
+ const loop = eventLoopDelay();
1203
+ const mbps = elapsedMs > 0 ? (totalBytes * 8) / (elapsedMs * 1000) : 0;
1204
+ log(
1205
+ `[net-debug] sent ${path}${queryInfo} bytes=${totalBytes} fetchMs=${fetchMs} ` +
1206
+ `ttfbMs=${firstByteMs} sendMs=${elapsedMs} chunks=${chunks} ` +
1207
+ // The size of one message, so a reading can be attributed to the
1208
+ // size it was taken at rather than to the run it came from.
1209
+ `msgBytes=${sendChunkBytes > 0 ? sendChunkBytes : "asread"} ` +
1210
+ `maxBuffered=${maxBuffered} bufferedAtEnd=${bufferedNow} ` +
1211
+ // Where the time went: reading the body from the local route,
1212
+ // handing chunks to the channel, or waiting for its queue. Plus
1213
+ // the event-loop delay over the same window — a large max here
1214
+ // means the transfer was blocked by synchronous work, not by the
1215
+ // network, and the three figures above will all look inflated.
1216
+ `readMs=${readMs.toFixed(0)} chanMs=${sendMs2.toFixed(0)} drainMs=${drainMs.toFixed(0)} ` +
1217
+ `loopMean=${loop.meanMs.toFixed(1)} loopP99=${loop.p99Ms.toFixed(1)} loopMax=${loop.maxMs.toFixed(1)} ` +
1218
+ `rate=${mbps.toFixed(1)}Mbps`
1219
+ );
1220
+ break;
1221
+ }
1222
+ if (firstByteMs < 0) firstByteMs = Date.now() - sendStartedAt;
1223
+ totalBytes += value.length;
1224
+ try {
1225
+ const b = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0;
1226
+ if (b > maxBuffered) maxBuffered = b;
1227
+ } catch { /* ignore */ }
1228
+ const sendStepAt = performance.now();
1229
+ chunks += body.push(value);
1230
+ sendMs2 += performance.now() - sendStepAt;
1231
+ // Backpressure: do not keep queuing chunks once the channel's outgoing
1232
+ // buffer is large — wait for it to drain. Prevents the SCTP send buffer
1233
+ // from ballooning, which stalls throughput.
1234
+ const drainStepAt = performance.now();
1235
+ await waitForBufferDrain(channel);
1236
+ drainMs += performance.now() - drainStepAt;
1237
+ }
1238
+ } catch {
1239
+ sendChunk(channel, requestId, null, true);
1240
+ }
1241
+ }
1242
+
1243
+ /**
1244
+ * Send a response body frame as a BINARY data-channel message.
1245
+ * Layout: [flags(1)][idLen(1)][requestId(ASCII)][payload].
1246
+ *
1247
+ * @param {DataChannel} channel
1248
+ * @param {string} requestId
1249
+ * @param {Uint8Array | null} bytes - Body bytes, or null/empty for the done frame.
1250
+ * @param {boolean} done
1251
+ * @returns {void}
1252
+ */
1253
+ /**
1254
+ * The request id as bytes, prepared once per request rather than per chunk.
1255
+ *
1256
+ * A segment is a couple of hundred chunks, and each one was re-encoding the
1257
+ * same 32-character string. The map is bounded because request ids are
1258
+ * short-lived and unbounded in number — dropping the whole cache when it
1259
+ * grows costs one re-encode per live request and cannot leak.
1260
+ *
1261
+ * @param {string} requestId
1262
+ * @returns {Buffer}
1263
+ */
1264
+ function requestIdBytes(requestId) {
1265
+ let bytes = requestIdCache.get(requestId);
1266
+ if (!bytes) {
1267
+ if (requestIdCache.size > 64) {
1268
+ requestIdCache.clear();
1269
+ }
1270
+ bytes = Buffer.from(requestId, "ascii");
1271
+ requestIdCache.set(requestId, bytes);
1272
+ }
1273
+ return bytes;
1274
+ }
1275
+
1276
+ function sendChunk(channel, requestId, bytes, done) {
1277
+ try {
1278
+ channel.sendMessageBinary(encodeFrame(requestIdBytes(requestId), bytes, done));
1279
+ } catch {
1280
+ // Channel closed between check and send — safe to ignore.
1281
+ }
1282
+ }
1283
+
1284
+ /**
1285
+ * Resolve once the channel's outgoing buffer has drained below the low-water
1286
+ * mark. No-op (resolves immediately) when the buffer is already small or the
1287
+ * channel does not expose buffer APIs. The previous implementation resolved
1288
+ * after {@link DC_BUFFER_DRAIN_TIMEOUT_MS} even though the buffer was still
1289
+ * high, so the send loop kept queueing into a wedged channel and the queue
1290
+ * grew to 399 MB (field 2026-09-06). This version waits until the buffer
1291
+ * actually drains, polling as a fallback for a missed low-water event, and
1292
+ * never resolves while the channel is still holding bytes above the low-water
1293
+ * mark.
1294
+ *
1295
+ * @param {DataChannel} channel
1296
+ * @returns {Promise<void>}
1297
+ */
1298
+ function waitForBufferDrain(channel) {
1299
+ return new Promise((resolve) => {
1300
+ try {
1301
+ if (typeof channel.bufferedAmount !== "function" || channel.bufferedAmount() <= DC_BUFFER_HIGH_WATER) {
1302
+ resolve();
1303
+ return;
1304
+ }
1305
+ let poll = null;
1306
+ let settled = false;
1307
+ const done = () => {
1308
+ if (settled) return;
1309
+ settled = true;
1310
+ if (poll) clearInterval(poll);
1311
+ try { channel.onBufferedAmountLow(() => {}); } catch {}
1312
+ resolve();
1313
+ };
1314
+ channel.setBufferedAmountLowThreshold(DC_BUFFER_LOW_WATER);
1315
+ channel.onBufferedAmountLow(done);
1316
+ // Poll as a fallback for a missed low-water event — the previous
1317
+ // 5 s timeout resolved while the buffer was still high, which is the
1318
+ // defect. Polling waits until the condition is actually met.
1319
+ poll = setInterval(() => {
1320
+ try {
1321
+ if (channel.bufferedAmount() <= DC_BUFFER_LOW_WATER) {
1322
+ done();
1323
+ }
1324
+ } catch {
1325
+ done();
1326
+ }
1327
+ }, 50);
1328
+ if (typeof poll.unref === "function") poll.unref();
1329
+ // Guard against a race where the buffer drained between the check above
1330
+ // and registering the callback (the low-water event would never fire).
1331
+ if (channel.bufferedAmount() <= DC_BUFFER_LOW_WATER) {
1332
+ done();
1333
+ }
1334
+ } catch {
1335
+ resolve();
1336
+ }
1337
+ });
1338
+ }
1339
+
1340
+ /**
1341
+ * Serialise `message` to JSON and send it over the data channel.
1342
+ * Errors are silently swallowed — the channel may have closed between
1343
+ * the open check and the actual send.
1344
+ *
1345
+ * @param {DataChannel} channel
1346
+ * @param {object} message
1347
+ * @returns {void}
1348
+ */
1349
+ function send(channel, message) {
1350
+ try {
1351
+ channel.sendMessage(JSON.stringify(message));
1352
+ } catch {
1353
+ // Channel closed between check and send — safe to ignore.
1354
+ }
1355
+ }
1356
+
1357
+ return { handleChannel, publishSubtitleCues };
1358
+ }
1359
+
1360
+ /**
1361
+ * Allowed path prefixes for data-channel requests.
1362
+ * Only the known proxy API and streaming routes are accepted.
1363
+ */
1364
+ const PATH_ALLOWLIST_RE = /^(?:\/api\/|\/stream(?:$|\?)|\/?transcode\/|\/health(?:z)?(?:$|\?))/;
1365
+
1366
+ /**
1367
+ * True when `path` is an absolute, traversal-free path on a known proxy route.
1368
+ * Shared by the single-message and chunked request entry points.
1369
+ *
1370
+ * @param {unknown} path
1371
+ * @returns {boolean}
1372
+ */
1373
+ function isValidRequestPath(path) {
1374
+ return (
1375
+ typeof path === "string" &&
1376
+ path.startsWith("/") &&
1377
+ !path.includes("..") &&
1378
+ PATH_ALLOWLIST_RE.test(path)
1379
+ );
1380
+ }
1381
+
1382
+ /** Max assembled size of a chunked request body (guards proxy memory). */
1383
+ const PROXY_MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024;
1384
+ /** Drop an incomplete chunked body if no further frame arrives within this window. */
1385
+ const PARTIAL_REQUEST_TTL_MS = 60_000;
1386
+
1387
+ /** Pause sending body chunks once the channel buffer exceeds this many bytes. */
1388
+ // How often the send queue is sampled, and how long it must fail to fall
1389
+ // before the transport is asked what it is doing. Five seconds is far longer
1390
+ // than any healthy burst drains in — measured, a 6-11 MB segment leaves in
1391
+ // well under a second on the LAN — and short enough that a stuck channel is
1392
+ // named while the viewer is still looking at it.
1393
+ const SEND_QUEUE_SAMPLE_MS = 1_000;
1394
+ // How often the transport's own counters are written to the log, whatever the
1395
+ // send queue is doing. Frequent enough to place a loss within a few seconds,
1396
+ // sparse enough that a two-hour film costs a few hundred lines.
1397
+ const TRANSPORT_HEARTBEAT_MS = 5_000;
1398
+
1399
+ // How many heartbeats in a row may find no transport for this session before
1400
+ // the watch gives up. Several rather than one, so a momentary gap in the
1401
+ // registry does not end a healthy watch.
1402
+ const TRANSPORT_UNKNOWN_HEARTBEATS = 3;
1403
+ const SEND_QUEUE_STUCK_MS = 5_000;
1404
+ const DC_BUFFER_HIGH_WATER = 8 * 1024 * 1024;
1405
+ /** Resume sending once the channel buffer drains to this many bytes. */
1406
+ const DC_BUFFER_LOW_WATER = 1 * 1024 * 1024;