@torrent-tv/proxy 2.86.0 → 2.87.0

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 (382) hide show
  1. package/CHANGELOG.md +2138 -1915
  2. package/CLAUDE.md +318 -197
  3. package/assets/calibration/NOTICE.md +2 -2
  4. package/bin/cli.js +684 -616
  5. package/biome.json +409 -233
  6. package/docs/container-architecture.md +218 -213
  7. package/docs/download-architecture.md +7 -7
  8. package/docs/encode-architecture.md +287 -11
  9. package/docs/encode-run-state.md +2 -2
  10. package/docs/logs.md +97 -97
  11. package/docs/{disk-architecture.md → storage-architecture.md} +107 -1
  12. package/openspec/changes/adaptive-bitrate/design.md +2 -2
  13. package/openspec/changes/adaptive-bitrate/proposal.md +1 -1
  14. package/openspec/changes/adaptive-bitrate/tasks.md +1 -1
  15. package/openspec/changes/chunked-request-bodies/design.md +1 -1
  16. package/openspec/changes/chunked-request-bodies/proposal.md +1 -1
  17. package/openspec/changes/chunked-request-bodies/tasks.md +1 -1
  18. package/openspec/changes/cold-start/design.md +1 -1
  19. package/openspec/changes/disk-cap/proposal.md +1 -1
  20. package/openspec/changes/proxy-observability/proposal.md +2 -2
  21. package/openspec/changes/track-selection/proposal.md +1 -1
  22. package/openspec/changes/transcode-quality/proposal.md +1 -1
  23. package/package.json +2 -2
  24. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  25. package/research/double-spawn-2026-09-10.md +171 -171
  26. package/routes/api/playback-plan/post.js +2 -2
  27. package/routes/api/sources/files/get.js +1 -1
  28. package/routes/api/sources/stats/get.js +1 -1
  29. package/routes/api/sources/warm/post.js +10 -3
  30. package/routes/api/subtitles/get.js +230 -230
  31. package/routes/api/transcode-sessions/fragment-far/post.js +3 -3
  32. package/routes/api/transcode-sessions/net-report/post.js +129 -94
  33. package/routes/api/transcode-sessions/post.js +160 -139
  34. package/routes/api/transcode-sessions/progress/get.js +3 -3
  35. package/routes/api/transcode-sessions/release/post.js +3 -3
  36. package/routes/api/transcode-sessions/seek/post.js +12 -3
  37. package/routes/stream/get.js +2 -2
  38. package/routes/transcode/audio-file/get.js +15 -6
  39. package/routes/transcode/audio-warm/get.js +8 -5
  40. package/routes/transcode/session-file/get.js +429 -268
  41. package/routes/transcode/variant-file/get.js +47 -7
  42. package/routes/transcode/variant-warm/get.js +18 -6
  43. package/scripts/render-run-graph.js +1 -1
  44. package/server.js +720 -465
  45. package/services/encode/CoverageMap.js +428 -428
  46. package/services/encode/CushionReport.js +345 -0
  47. package/services/encode/EncodeAdmission.js +332 -0
  48. package/services/{orchestrators → encode}/EncodeOrchestrator.js +285 -17
  49. package/services/encode/EncodeRun.js +16 -9
  50. package/services/encode/EncodeRuns.js +1219 -0
  51. package/services/encode/Encoder.js +18 -2
  52. package/services/encode/EncoderSelection.js +38 -0
  53. package/services/encode/LocalObservations.js +359 -0
  54. package/services/encode/NvencEncoder.js +5 -0
  55. package/services/encode/OutputOpening.js +745 -0
  56. package/services/encode/OutputTimes.js +579 -0
  57. package/services/encode/Renditions.js +2162 -0
  58. package/services/encode/RunProgress.js +84 -0
  59. package/services/encode/SoftwareEncoder.js +12 -7
  60. package/services/encode/args.js +402 -186
  61. package/services/encode/calibration.js +361 -0
  62. package/services/encode/contention.js +128 -128
  63. package/services/encode/encode-run-state.js +16 -20
  64. package/services/encode/fingerprint.js +322 -0
  65. package/services/{hwaccel.js → encode/hwaccel.js} +82 -42
  66. package/services/{learned-median.js → encode/learned-median.js} +11 -0
  67. package/services/encode/output/EncodedOutput.js +57 -0
  68. package/services/encode/output/Output.js +131 -0
  69. package/services/{output/LiveOutputs.js → encode/output/OutputCatalog.js} +141 -40
  70. package/services/encode/output/OutputSpec.js +390 -0
  71. package/services/{output → encode/output}/Timeline.js +12 -37
  72. package/services/encode/output/index.js +20 -0
  73. package/services/encode/output/ladder.js +32 -0
  74. package/services/{output → encode/output}/playlists.js +3 -0
  75. package/services/encode/output-key-format.js +21 -0
  76. package/services/encode/quality/EncodeCost.js +1083 -0
  77. package/services/encode/quality/HostLoad.js +525 -0
  78. package/services/encode/quality/HostTimings.js +249 -0
  79. package/services/encode/quality/OutputQualityState.js +28 -0
  80. package/services/encode/quality/QualityController.js +931 -0
  81. package/services/encode/quality/QualityOffer.js +416 -0
  82. package/services/encode/quality/drain-threat.js +47 -0
  83. package/services/encode/quality/link-budget.js +254 -0
  84. package/services/encode/quality/output-format.js +266 -0
  85. package/services/encode/quality/serving-output.js +216 -0
  86. package/services/encode/quality/visible-rung.js +57 -0
  87. package/services/encode/run-command.js +23 -20
  88. package/services/encode/run-inputs.js +42 -0
  89. package/services/{segment-formats → encode/segment-formats}/fmp4.js +63 -0
  90. package/services/{segment-formats → encode/segment-formats}/index.js +3 -3
  91. package/services/encode/segment-formats/init-compat.js +324 -0
  92. package/services/{segment-formats → encode/segment-formats}/mp4-boxes.js +8 -3
  93. package/services/encode/throughput.js +97 -0
  94. package/services/{orchestrators → media}/ContainerOrchestrator.js +45 -7
  95. package/services/media/KeyframeTables.js +316 -0
  96. package/services/{source → media}/SourceFile.js +7 -55
  97. package/services/{torrent-worker/subtitle-cues.js → media/SubtitleCues.js} +103 -132
  98. package/services/media/SubtitleOrchestrator.js +167 -0
  99. package/services/{audio-inventory.js → media/audio-inventory.js} +7 -1
  100. package/services/{container → media/container}/AviContainer.js +266 -266
  101. package/services/{container → media/container}/Container.js +596 -601
  102. package/services/media/container/ContainerFactory.js +56 -0
  103. package/services/media/container/KeyframeTable.js +272 -0
  104. package/services/{container → media/container}/MatroskaContainer.js +1485 -1485
  105. package/services/{container → media/container}/Mp4Container.js +1244 -1244
  106. package/services/{container → media/container}/SubtitleFileContainer.js +322 -322
  107. package/services/media/keyframe-probe.js +157 -0
  108. package/services/media/media-info-probe.js +93 -0
  109. package/services/{playback-planner.js → media/playback-planner.js} +82 -39
  110. package/services/{tracks → media/tracks}/AudioTrack.js +131 -131
  111. package/services/{tracks → media/tracks}/language-detect.js +174 -174
  112. package/services/server/OutputLifecycle.js +418 -0
  113. package/services/server/SegmentServing.js +1146 -0
  114. package/services/server/ViewerRequests.js +522 -0
  115. package/services/server/controllers/PlaybackController.js +36 -0
  116. package/services/{controllers → server/controllers}/SubtitleController.js +17 -33
  117. package/services/server/wire-outputs.js +958 -0
  118. package/services/storage/Diagnostics.js +202 -0
  119. package/services/storage/MachineBudget.js +235 -0
  120. package/services/{piece-store → storage}/allowance.js +107 -107
  121. package/services/{core-dumps.js → storage/core-dumps.js} +1 -1
  122. package/services/{files → storage/files}/CompletedFiles.js +116 -1
  123. package/services/{disk → storage}/free.js +33 -0
  124. package/services/storage/machine-memory.js +56 -0
  125. package/services/{memory-report.js → storage/memory-report.js} +575 -596
  126. package/services/{piece-store → storage/piece-store}/piece-disk-store.js +0 -7
  127. package/services/{piece-store → storage/piece-store}/shared-piece-store.js +28 -21
  128. package/services/{encode → storage/segment-store}/SegmentStore.js +395 -8
  129. package/services/storage/wire.js +175 -0
  130. package/services/{download → torrent/download}/SwarmSelection.js +2 -2
  131. package/services/{download → torrent/download}/registry.js +2 -2
  132. package/services/torrent/files.js +1 -1
  133. package/services/torrent/piece-store-of.js +29 -0
  134. package/services/{torrent-pool.js → torrent/torrent-pool.js} +26 -119
  135. package/services/{torrent-worker → torrent/worker}/background-fill.js +2 -2
  136. package/services/{torrent-worker → torrent/worker}/channel.js +271 -250
  137. package/services/{torrent-worker → torrent/worker}/client.js +77 -58
  138. package/services/torrent/worker/held-bytes.js +155 -0
  139. package/services/{torrent-worker → torrent/worker}/install-webrtc-shim.js +1 -1
  140. package/services/{torrent-worker → torrent/worker}/piece-reader.js +21 -30
  141. package/services/{torrent-worker → torrent/worker}/pool-adapter.js +101 -137
  142. package/services/{torrent-worker → torrent/worker}/protocol.js +26 -30
  143. package/services/torrent/worker/resume-warm.js +107 -0
  144. package/services/{torrent-worker → torrent/worker}/webrtc-shim.js +2 -2
  145. package/services/{torrent-worker → torrent/worker}/worker.js +68 -159
  146. package/services/{data-channel-handler.js → transport/data-channel-handler.js} +1356 -1356
  147. package/services/{delivery-probe.js → transport/delivery-probe.js} +842 -842
  148. package/services/{health-collector.js → transport/health-collector.js} +6 -35
  149. package/services/{packet-witness.js → transport/packet-witness.js} +808 -784
  150. package/services/{port-mapper.js → transport/port-mapper.js} +1 -1
  151. package/services/{usrsctp-state.js → transport/usrsctp-state.js} +1 -1
  152. package/services/{webrtc-manager.js → transport/webrtc-manager.js} +1 -1
  153. package/services/viewer/Assignments.js +340 -0
  154. package/services/{priority → viewer}/PriorityMap.js +390 -390
  155. package/services/{priority → viewer}/PriorityOrchestrator.js +325 -320
  156. package/services/viewer/Viewer.js +657 -483
  157. package/services/viewer/Viewers.js +368 -257
  158. package/services/viewer/active-output.js +37 -54
  159. package/services/viewer/audio-start.js +4 -3
  160. package/services/viewer/buffer-trend.js +81 -0
  161. package/services/viewer/choices.js +647 -0
  162. package/services/viewer/positions.js +126 -120
  163. package/services/viewer/report-intake.js +46 -45
  164. package/test/asset-paths.test.js +28 -0
  165. package/test/audio-inventory.test.js +3 -3
  166. package/test/audio-track-bitrate.test.js +40 -0
  167. package/test/auto-quality-step.test.js +707 -515
  168. package/test/available-share.test.js +1 -1
  169. package/test/background-fill.test.js +1 -1
  170. package/test/client-log-file.test.js +110 -110
  171. package/test/completed-files.test.js +1 -1
  172. package/test/concurrent-cost.test.js +13 -3
  173. package/test/container-media-info.test.js +3 -3
  174. package/test/core-dumps.test.js +1 -1
  175. package/test/coverage-follows-the-disk.test.js +40 -8
  176. package/test/cue-push-carries-what-the-walk-found.test.js +58 -0
  177. package/test/cut-times-timeline.test.js +2 -2
  178. package/test/cuts-follow-published-grid.test.js +9 -8
  179. package/test/data-channel-frame.test.js +1 -1
  180. package/test/dead-torrent-handle.test.js +1 -1
  181. package/test/declared-rates.test.js +2 -2
  182. package/test/declared-tracks.test.js +2 -2
  183. package/test/decode-cost-fit.test.js +1 -1
  184. package/test/decode-cost.test.js +34 -25
  185. package/test/delivery-probe.test.js +1 -1
  186. package/test/demand-register.test.js +4 -4
  187. package/test/dht-bootstrap.test.js +1 -1
  188. package/test/drain-threat.test.js +65 -0
  189. package/test/duplicate-add.test.js +1 -1
  190. package/test/encode-admission.test.js +146 -0
  191. package/test/encode-cost.test.js +199 -10
  192. package/test/encode-orchestrator.test.js +39 -1
  193. package/test/encode-plan-viewers.test.js +2 -2
  194. package/test/encode-run.test.js +2 -2
  195. package/test/encode-slope.test.js +1 -1
  196. package/test/encoded-output.test.js +39 -0
  197. package/test/encoder-readings.test.js +1 -1
  198. package/test/every-claimant-is-told-its-share.test.js +152 -0
  199. package/test/failed-encoder-still-serves-what-exists.test.js +354 -0
  200. package/test/failed-start-breaker.test.js +81 -30
  201. package/test/fastest-wires.test.js +1 -1
  202. package/test/ffmpeg-stream-counts.test.js +1 -1
  203. package/test/file-edges.test.js +3 -3
  204. package/test/fmp4-self-contained.test.js +1 -1
  205. package/test/fmp4-timestamps.test.js +1 -1
  206. package/test/fmp4-track-completeness.test.js +1 -1
  207. package/test/format-keyed-outputs.test.js +78 -0
  208. package/test/health-metrics.test.js +3 -2
  209. package/test/held-request-width.test.js +22 -23
  210. package/test/helpers/encode-run.js +136 -136
  211. package/test/helpers/held-file.js +29 -0
  212. package/test/helpers/manager.js +57 -0
  213. package/test/helpers/output-spec.js +63 -0
  214. package/test/host-load.test.js +1 -1
  215. package/test/index-accuracy-is-about-the-file.test.js +128 -0
  216. package/test/init-compat.test.js +219 -0
  217. package/test/input-lost-quiets-the-plan.test.js +1 -1
  218. package/test/input-unavailable.test.js +1 -1
  219. package/test/keeping-period.test.js +4 -4
  220. package/test/keyframe-index-accuracy.test.js +97 -45
  221. package/test/keyframe-table-budget.test.js +28 -33
  222. package/test/keyframes-belong-to-the-file.test.js +174 -57
  223. package/test/learned-median.test.js +1 -1
  224. package/test/letting-go-asks-who-is-watching.test.js +191 -0
  225. package/test/limit-row-by-area.test.js +60 -0
  226. package/test/link-budget.test.js +111 -0
  227. package/test/link-probe.test.js +56 -56
  228. package/test/local-encoding-data.test.js +273 -0
  229. package/test/look-ahead-coverage.test.js +1 -1
  230. package/test/manager-surface.test.js +294 -0
  231. package/test/matroska-blocks.test.js +0 -0
  232. package/test/matroska-cues-track.test.js +1 -1
  233. package/test/memory-budget.test.js +2 -2
  234. package/test/mp4-composition-times.test.js +0 -0
  235. package/test/mp4-subtitles.test.js +1 -1
  236. package/test/one-authority.test.js +45 -41
  237. package/test/one-budget.test.js +149 -0
  238. package/test/one-output-one-session.test.js +204 -0
  239. package/test/{disk-space.test.js → one-resource-divided.test.js} +30 -10
  240. package/test/one-serving-output-per-height.test.js +458 -0
  241. package/test/orchestrator-wired.test.js +46 -45
  242. package/test/output-catalog.test.js +39 -0
  243. package/test/output-shape.test.js +4 -51
  244. package/test/output-spec.test.js +251 -157
  245. package/test/output-speed.test.js +17 -15
  246. package/test/outputs-being-prepared.test.js +40 -0
  247. package/test/packet-witness-ring.test.js +2 -2
  248. package/test/packet-witness.test.js +1 -1
  249. package/test/piece-disk-store.test.js +2 -2
  250. package/test/piece-from-whole-file.test.js +1 -1
  251. package/test/piece-lru.test.js +1 -1
  252. package/test/piece-reader.test.js +2 -2
  253. package/test/piece-store-eviction.test.js +1 -1
  254. package/test/piece-store-never-refuses.test.js +1 -1
  255. package/test/piece-store-reservations.test.js +1 -1
  256. package/test/piece-store-slow-disk.test.js +1 -1
  257. package/test/piece-tail.test.js +1 -1
  258. package/test/plan-host-timings.test.js +1 -1
  259. package/test/plan-is-steady.test.js +225 -225
  260. package/test/priority-map-download.test.js +4 -4
  261. package/test/priority-map-emptied.test.js +204 -205
  262. package/test/priority-map-per-output.test.js +215 -209
  263. package/test/priority-map.test.js +1 -1
  264. package/test/probe-wedge-certainty.test.js +1 -1
  265. package/test/produced-copy-choice.test.js +15 -60
  266. package/test/quality-offer.test.js +162 -0
  267. package/test/quality-variants.test.js +1229 -1167
  268. package/test/rate-control-in-the-output.test.js +180 -0
  269. package/test/read-survives-withdrawal.test.js +2 -2
  270. package/test/read-window.test.js +10 -3
  271. package/test/resume-warm.test.js +1 -1
  272. package/test/run-inputs.test.js +80 -0
  273. package/test/run-position-follows-published-grid.test.js +13 -11
  274. package/test/run-progress.test.js +65 -0
  275. package/test/same-height-switch.test.js +664 -0
  276. package/test/seek-frees-the-viewers-requests.test.js +217 -0
  277. package/test/seek-landing.test.js +13 -13
  278. package/test/seek-target-not-superseded.test.js +28 -29
  279. package/test/segment-closed-on-both-branches.test.js +96 -0
  280. package/test/segment-cut-times.test.js +1 -1
  281. package/test/segment-hold-supersede.test.js +28 -35
  282. package/test/segment-judged-by-its-own-header.test.js +221 -0
  283. package/test/segment-serve-wiring.test.js +130 -39
  284. package/test/segment-store-eviction.test.js +22 -7
  285. package/test/segment-store.test.js +98 -14
  286. package/test/segment-true-start.test.js +1 -1
  287. package/test/segments-are-shared.test.js +17 -16
  288. package/test/send-chunk-bytes.test.js +1 -1
  289. package/test/serving-existing-output.test.js +102 -0
  290. package/test/serving-output.test.js +102 -0
  291. package/test/shared-piece-store.test.js +1 -1
  292. package/test/source-file.test.js +1 -1
  293. package/test/stale-request-after-seek.test.js +17 -21
  294. package/test/startup-readings.test.js +10 -5
  295. package/test/subtitle-cue-framing.test.js +5 -5
  296. package/test/subtitle-cue-source.test.js +36 -38
  297. package/test/subtitle-cue-walk.test.js +11 -10
  298. package/test/subtitle-defaults.test.js +1 -1
  299. package/test/subtitle-language.test.js +4 -4
  300. package/test/subtitle-track-numbering.test.js +5 -4
  301. package/test/supply-margin.test.js +1 -1
  302. package/test/swarm-follows-readers.test.js +3 -3
  303. package/test/swarm-reach.test.js +1 -1
  304. package/test/swarm-selection.test.js +3 -3
  305. package/test/tail-duplication.test.js +1 -1
  306. package/test/the-channel-has-both-ends.test.js +91 -0
  307. package/test/the-torrent-thread-serves-bytes.test.js +107 -0
  308. package/test/thread-pool.test.js +3 -3
  309. package/test/timeline.test.js +1 -12
  310. package/test/torrent-cost.test.js +1 -1
  311. package/test/tracks-begin-together.test.js +38 -24
  312. package/test/tunnel-renewal.test.js +1 -1
  313. package/test/two-viewers-one-picture.test.js +120 -87
  314. package/test/upload-hurry.test.js +3 -3
  315. package/test/usrsctp-state.test.js +1 -1
  316. package/test/video-facts.test.js +1 -1
  317. package/test/viewer-assignments.test.js +143 -0
  318. package/test/viewer-outputs.test.js +60 -52
  319. package/test/viewer-presence.test.js +22 -25
  320. package/test/viewer-requests-admission.test.js +89 -0
  321. package/test/viewer-states.test.js +189 -202
  322. package/test/viewer-subtitle-subscription.test.js +1 -1
  323. package/test/viewer.test.js +139 -136
  324. package/test/viewing-generations.test.js +390 -0
  325. package/test/visible-rung.test.js +37 -0
  326. package/test/wait-ledger.test.js +1 -1
  327. package/test/webrtc-shim.test.js +3 -3
  328. package/test/wedge-certainty.test.js +2 -2
  329. package/test/withdraw-piece-claim.test.js +2 -2
  330. package/test/worker-channel.test.js +1 -31
  331. package/utils/client-log-file.js +151 -151
  332. package/scripts/read-container-index.mjs +0 -108
  333. package/services/container/ContainerFactory.js +0 -122
  334. package/services/controllers/PlaybackController.js +0 -33
  335. package/services/disk/DiskSpace.js +0 -150
  336. package/services/disk/wire.js +0 -61
  337. package/services/hls-session-manager.js +0 -9608
  338. package/services/orchestrators/SubtitleOrchestrator.js +0 -160
  339. package/services/output/Output.js +0 -94
  340. package/services/output/OutputSpec.js +0 -202
  341. package/services/output/index.js +0 -1
  342. package/services/output/ladder.js +0 -26
  343. package/services/produced-index.js +0 -222
  344. package/services/quality/EncodeCost.js +0 -555
  345. package/services/torrent-worker/container-tracks.js +0 -414
  346. package/services/viewer/link-readings.js +0 -56
  347. package/test/produced-index.test.js +0 -142
  348. /package/{services → bin}/thread-pool.js +0 -0
  349. /package/services/{available-share.js → encode/available-share.js} +0 -0
  350. /package/services/{decode-cost-fit.js → encode/decode-cost-fit.js} +0 -0
  351. /package/services/{encoder-readings.js → encode/encoder-readings.js} +0 -0
  352. /package/services/{host-load.js → encode/host-load.js} +0 -0
  353. /package/services/{output → encode/output}/cut-grid.js +0 -0
  354. /package/services/{output → encode/output}/rates.js +0 -0
  355. /package/services/{segment-formats → encode/segment-formats}/mpegts.js +0 -0
  356. /package/services/{torrent-cost.js → encode/torrent-cost.js} +0 -0
  357. /package/services/{container → media/container}/ebml-reader.js +0 -0
  358. /package/services/{ffmpeg-banner.js → media/ffmpeg-banner.js} +0 -0
  359. /package/services/{tracks → media/tracks}/ContainerTrack.js +0 -0
  360. /package/services/{tracks → media/tracks}/ImageSubtitleTrack.js +0 -0
  361. /package/services/{tracks → media/tracks}/SubtitleTrack.js +0 -0
  362. /package/services/{tracks → media/tracks}/TextSubtitleTrack.js +0 -0
  363. /package/services/{tracks → media/tracks}/VideoTrack.js +0 -0
  364. /package/services/{tracks → media/tracks}/index.js +0 -0
  365. /package/services/{files → storage/files}/piece-from-whole-file.js +0 -0
  366. /package/services/{disk → storage}/keep.js +0 -0
  367. /package/services/{piece-store → storage/piece-store}/piece-lru.js +0 -0
  368. /package/services/{disk → storage}/returns.js +0 -0
  369. /package/services/{demand → torrent/demand}/DemandRegister.js +0 -0
  370. /package/services/{demand → torrent/demand}/Urgency.js +0 -0
  371. /package/services/{demand → torrent/demand}/Window.js +0 -0
  372. /package/services/{demand → torrent/demand}/index.js +0 -0
  373. /package/services/{demand → torrent/demand}/pieces.js +0 -0
  374. /package/services/{download → torrent/download}/withdraw-claim.js +0 -0
  375. /package/services/{supply-margin.js → torrent/supply-margin.js} +0 -0
  376. /package/services/{torrent-source-key.js → torrent/torrent-source-key.js} +0 -0
  377. /package/services/{torrent-worker → torrent/worker}/fastest-wires.js +0 -0
  378. /package/services/{torrent-worker → torrent/worker}/handle-state.js +0 -0
  379. /package/services/{nat-classifier.js → transport/nat-classifier.js} +0 -0
  380. /package/services/{registry-api.js → transport/registry-api.js} +0 -0
  381. /package/services/{tunnel-client.js → transport/tunnel-client.js} +0 -0
  382. /package/services/{priority → viewer}/WaitLedger.js +0 -0
@@ -54,8 +54,6 @@ const REMOVAL = { force: true, maxRetries: 10, retryDelay: 20 };
54
54
  export class PieceDiskStore {
55
55
  #directory;
56
56
 
57
- #chunkLength;
58
-
59
57
  /** Piece index → its length on disk. @type {Map<number, number>} */
60
58
  #stored = new Map();
61
59
 
@@ -99,9 +97,6 @@ export class PieceDiskStore {
99
97
  * @param {string} params.directory - Where this torrent's pieces live.
100
98
  * @param {string} params.name - A name unique to the torrent; it becomes the
101
99
  * directory inside `directory`.
102
- * @param {number} params.chunkLength - The torrent's piece length. Kept for
103
- * the caller's arithmetic; a piece's own length is recorded as it is written,
104
- * because the last piece of a torrent is shorter.
105
100
  * @param {number | null} [params.allowanceBytes] - What it may hold. Null
106
101
  * means nobody has said yet, and nothing is evicted until somebody does.
107
102
  * @param {() => number} [params.now]
@@ -109,14 +104,12 @@ export class PieceDiskStore {
109
104
  constructor({
110
105
  directory,
111
106
  name,
112
- chunkLength,
113
107
  allowanceBytes = null,
114
108
  now = Date.now,
115
109
  readHeads = () => [],
116
110
  onForgotten = () => undefined
117
111
  }) {
118
112
  this.#directory = path.join(directory, name);
119
- this.#chunkLength = chunkLength;
120
113
  this.#allowanceBytes = Number.isFinite(allowanceBytes) && allowanceBytes >= 0 ? allowanceBytes : null;
121
114
  this.#now = now;
122
115
  this.#readHeads = typeof readHeads === "function" ? readHeads : () => [];
@@ -41,9 +41,8 @@
41
41
  * and nothing here disagrees.
42
42
  */
43
43
 
44
- import os from "node:os";
45
- import { readFileSync } from "node:fs";
46
- import { divideAllowance, machineAllowanceBytes, OtherDemand } from "./allowance.js";
44
+ import { divideAllowance, machineAllowanceBytes, OtherDemand } from "../allowance.js";
45
+ import { availableMemoryBytes as availableMemorySync } from "../machine-memory.js";
47
46
  import { PieceLru } from "./piece-lru.js";
48
47
  import { PieceDiskStore } from "./piece-disk-store.js";
49
48
 
@@ -112,12 +111,20 @@ export function forgetMachineMemory() {
112
111
 
113
112
  export { divideAllowance, machineAllowanceBytes };
114
113
 
115
- export function reviseStoreBudgets() {
114
+ export function reviseStoreBudgets(allowanceBytes = null) {
116
115
  const stores = [...liveStores];
117
116
  const held = stores.reduce((sum, store) => sum + store.residentBytes, 0);
118
- const available = availableMemorySync();
119
- const reserve = noteMachineMemory(available, held);
120
- const allowance = machineAllowanceBytes(available, held, reserve);
117
+ // TOLD, NOT WORKED OUT. Memory has one owner and it is on the main thread,
118
+ // where the disk's owner is — and it must be the same one, because the two
119
+ // resources trade: pieces that do not fit in memory are spilled to disk, so
120
+ // what this store is given decides how much disk it needs. What arrives is
121
+ // this thread's whole share; the stores divide it between themselves.
122
+ //
123
+ // Null is a thread that has not been told yet, and it works its own share out
124
+ // for that one pass rather than growing without a bound.
125
+ const allowance = Number.isFinite(allowanceBytes) && allowanceBytes !== null
126
+ ? Math.max(0, allowanceBytes)
127
+ : machineAllowanceBytes(availableMemorySync(), held, noteMachineMemory(availableMemorySync(), held));
121
128
  const shares = divideAllowance(stores.map((store) => store.wantedBytes), allowance);
122
129
  const revised = [];
123
130
  for (const [position, store] of stores.entries()) {
@@ -175,18 +182,6 @@ function defaultMemoryBytes() {
175
182
  return Math.floor(allowance / (stores.length + 1));
176
183
  }
177
184
 
178
- function availableMemorySync() {
179
- try {
180
- const text = readFileSync("/proc/meminfo", "utf8");
181
- const match = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(text);
182
- if (match) {
183
- return Number(match[1]) * 1024;
184
- }
185
- } catch {
186
- }
187
- return os.freemem();
188
- }
189
-
190
185
  /**
191
186
  * How many blocks of piece memory this thread has let go of, and how many the
192
187
  * collector has actually taken back.
@@ -204,7 +199,7 @@ function availableMemorySync() {
204
199
  *
205
200
  * What it does NOT prove: a `SharedArrayBuffer`'s memory is shared between the
206
201
  * isolates, so this thread's handle going means only that THIS thread let go.
207
- * The main thread counts its own (`torrent-worker/client.js`), and the pair is
202
+ * The main thread counts its own (`torrent/worker/client.js`), and the pair is
208
203
  * what answers the question.
209
204
  */
210
205
  const released = { count: 0, collected: 0 };
@@ -522,7 +517,6 @@ export class SharedPieceStore {
522
517
  this.#disk = options.disk ?? new PieceDiskStore({
523
518
  directory: options.path ?? ".",
524
519
  name: `${this.#name}.pieces`,
525
- chunkLength,
526
520
  // What it may hold is settled by the same revision that settles memory,
527
521
  // within a minute of the store existing. Until then it is unbounded, which
528
522
  // is what it has always been — the difference is that it now stops.
@@ -1871,3 +1865,16 @@ export class SharedPieceStore {
1871
1865
  this.#disk.destroy().then(() => callback(null), (error) => callback(error));
1872
1866
  }
1873
1867
  }
1868
+
1869
+ /**
1870
+ * What the stores in this thread hold and would take, for the owner of memory.
1871
+ *
1872
+ * @returns {{ held: number, wanted: number }}
1873
+ */
1874
+ export function memoryClaim() {
1875
+ const stores = [...liveStores];
1876
+ return {
1877
+ held: stores.reduce((sum, store) => sum + store.residentBytes, 0),
1878
+ wanted: stores.reduce((sum, store) => sum + store.wantedBytes, 0)
1879
+ };
1880
+ }
@@ -40,7 +40,7 @@
40
40
  */
41
41
 
42
42
  import { createHash } from "node:crypto";
43
- import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
43
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, watch, writeFileSync } from "node:fs";
44
44
  import { rmSync } from "node:fs";
45
45
  import os from "node:os";
46
46
  import path from "node:path";
@@ -90,6 +90,27 @@ export class SegmentStore {
90
90
  /** Output key → when it was last asked for. @type {Map<string, number>} */
91
91
  #touched = new Map();
92
92
 
93
+ /**
94
+ * Output key → the init segment served for it, once one has been read whole.
95
+ *
96
+ * A property of the output's produced material, so it lives with that
97
+ * material and goes when it goes. The player fetches it once and never again,
98
+ * so the first whole one is kept and every later request gets the same bytes,
99
+ * even though a re-encoding run rewrites the file on disk.
100
+ *
101
+ * @type {Map<string, Buffer>}
102
+ */
103
+ #inits = new Map();
104
+
105
+ /** Told of every segment published, whatever it is. @type {Set<(key: string, index: number) => void>} */
106
+ #publishedListeners = new Set();
107
+
108
+ /** Segment publication waiters by output key and segment number. @type {Map<string, Map<number, Set<Function>>>} */
109
+ #waiters = new Map();
110
+
111
+ /** Output key → the watch on its directory. @type {Map<string, import("node:fs").FSWatcher>} */
112
+ #watchers = new Map();
113
+
93
114
  /** @type {{ info: Function, warn: Function }} */
94
115
  #logger;
95
116
 
@@ -144,6 +165,9 @@ export class SegmentStore {
144
165
  writeFileSync(path.join(dir, KEY_FILE), `${key}\n`, "utf8");
145
166
  }
146
167
  this.#touched.set(key, this.#now());
168
+ // Production is about to begin here, and a piece closing in this directory
169
+ // is what every held request is waiting for.
170
+ this.#watch(key);
147
171
  return dir;
148
172
  }
149
173
 
@@ -187,6 +211,7 @@ export class SegmentStore {
187
211
  return known;
188
212
  }
189
213
  const byNumber = new Map();
214
+ const sizes = new Map();
190
215
  let bytes = 0;
191
216
  let highest = -1;
192
217
  // The biggest piece and which number it is. What reads it is the figure the
@@ -220,6 +245,7 @@ export class SegmentStore {
220
245
  continue;
221
246
  }
222
247
  byNumber.set(index, full);
248
+ sizes.set(index, size);
223
249
  bytes += size;
224
250
  if (index > highest) {
225
251
  highest = index;
@@ -232,7 +258,7 @@ export class SegmentStore {
232
258
  this.#held.delete(key);
233
259
  return empty;
234
260
  }
235
- const contents = { readAt: mtime, byNumber, bytes, unproven: highest, largest };
261
+ const contents = { readAt: mtime, byNumber, sizes, bytes, unproven: highest, largest };
236
262
  this.#held.set(key, contents);
237
263
  return contents;
238
264
  }
@@ -276,6 +302,20 @@ export class SegmentStore {
276
302
  return this.refresh(key).byNumber.size;
277
303
  }
278
304
 
305
+ /**
306
+ * The size in bytes of every finished piece of this output, by number.
307
+ *
308
+ * Read by whoever keeps what an output's segments carried (roadmap item 97,
309
+ * step 14). A reading, not a request: it does not mark the output as read,
310
+ * so it does not keep an output nobody is watching.
311
+ *
312
+ * @param {string} key
313
+ * @returns {Map<number, number>}
314
+ */
315
+ sizesOf(key) {
316
+ return new Map(this.refresh(key).sizes ?? []);
317
+ }
318
+
279
319
  /**
280
320
  * The biggest piece this output has made, and which number it is.
281
321
  *
@@ -292,6 +332,16 @@ export class SegmentStore {
292
332
  }
293
333
 
294
334
 
335
+ /**
336
+ * Every output whose pieces this store knows how to read: those in use and
337
+ * those a previous life of the process left.
338
+ *
339
+ * @returns {string[]}
340
+ */
341
+ addresses() {
342
+ return [...this.#formats.keys()];
343
+ }
344
+
295
345
  /**
296
346
  * Whether this piece is finished, and may therefore be served.
297
347
  *
@@ -395,9 +445,287 @@ export class SegmentStore {
395
445
  }
396
446
  // What the directory holds has changed, so the memory of it is stale.
397
447
  this.#held.delete(key);
448
+ const index = format?.segmentIndexFromName?.(served) ?? -1;
449
+ if (index >= 0) {
450
+ this.announce(key, index);
451
+ for (const listener of this.#publishedListeners) {
452
+ listener(key, index);
453
+ }
454
+ }
398
455
  return served;
399
456
  }
400
457
 
458
+ /**
459
+ * The init segment kept for this output, or null until one was read whole.
460
+ *
461
+ * @param {string} key
462
+ * @returns {Buffer | null}
463
+ */
464
+ initOf(key) {
465
+ return this.#inits.get(key) ?? null;
466
+ }
467
+
468
+ /**
469
+ * Keep the init segment served for this output. The first whole one stands;
470
+ * an empty read is not an init (ffmpeg creates the file before it writes the
471
+ * header into it) and is refused.
472
+ *
473
+ * @param {string} key
474
+ * @param {Buffer | null} bytes
475
+ * @returns {Buffer | null} The init that now stands for this output.
476
+ */
477
+ keepInit(key, bytes) {
478
+ if (!this.#inits.has(key) && bytes && bytes.length > 0) {
479
+ this.#inits.set(key, bytes);
480
+ }
481
+ return this.initOf(key);
482
+ }
483
+
484
+ /**
485
+ * Be told of every segment published: what the store holds has grown, which
486
+ * is when whether it still fits its share is worth asking.
487
+ *
488
+ * @param {(key: string, index: number) => void} listener
489
+ * @returns {() => void} Stop being told.
490
+ */
491
+ onPublished(listener) {
492
+ this.#publishedListeners.add(listener);
493
+ return () => this.#publishedListeners.delete(listener);
494
+ }
495
+
496
+ /**
497
+ * Watch this output's directory, so a piece closed by ANY writer is an event.
498
+ *
499
+ * WHY IT EXISTS, and it is one fault rather than a precaution. A piece is
500
+ * finished in one of two ways, and only one of them said so. The `segment`
501
+ * muxer writes under a working name and reports the closure on a channel of
502
+ * its own (`publish`); the `hls` muxer writes through a temporary name of its
503
+ * own (`+temp_file`) and reports nothing, so the file simply appears under the
504
+ * name it is served as. That branch is taken by every re-encoded output on the
505
+ * even grid — the ordinary quality step — and for those `waitFor` could only
506
+ * end on its deadline: the segment was on disk and the request that wanted it
507
+ * went on waiting, up to a full minute, and was then answered 503.
508
+ *
509
+ * So the store watches what it owns. The rename into place moves the
510
+ * directory's modification time, the kernel says so, and both branches reach
511
+ * one statement: this number is now present. Nothing polls.
512
+ *
513
+ * Idempotent, and started from the two places that need it — the directory
514
+ * being made for a run, and a wait beginning on an output this process has
515
+ * adopted rather than made.
516
+ *
517
+ * WHOEVER REMOVES ONE OF THESE DIRECTORIES GOES THROUGH `drop`, which stops
518
+ * the watch first. On Linux, where this runs, an open watch does not prevent
519
+ * a directory being removed; on Windows it does, and `rmSync` then retries
520
+ * until it gives up — measured, a check that removed a store's directory
521
+ * behind its back took 110 s instead of 3.
522
+ *
523
+ * @param {string} key
524
+ * @returns {void}
525
+ */
526
+ #watch(key) {
527
+ if (this.#watchers.has(key)) {
528
+ return;
529
+ }
530
+ const dir = path.join(this.#root, directoryNameFor(key));
531
+ // Nothing has been made for this output yet. A wait that begins first will
532
+ // ask again — `directoryFor` is what a run calls before it writes — and
533
+ // asking the kernel to watch what is not there throws, once for every wait
534
+ // that begins meanwhile.
535
+ if (!existsSync(dir)) {
536
+ return;
537
+ }
538
+ try {
539
+ const watcher = watch(dir, { persistent: false }, (_event, name) => {
540
+ this.#noticed(key, typeof name === "string" ? name : null);
541
+ });
542
+ // A directory that goes away takes its watch with it rather than leaving
543
+ // an error nobody reads.
544
+ watcher.on("error", () => this.#unwatch(key));
545
+ this.#watchers.set(key, watcher);
546
+ } catch (error) {
547
+ // Watching is how a wait ends early, not how a segment is found: every
548
+ // reader still reads the disk. Said rather than swallowed, because a
549
+ // store that cannot watch holds each request for its whole deadline, and
550
+ // that is worth knowing before the field says it.
551
+ this.#logger?.warn?.(
552
+ `segment store: cannot watch ${key.slice(0, 60)}: ` +
553
+ `${error instanceof Error ? error.message : String(error)}`
554
+ );
555
+ }
556
+ }
557
+
558
+ /**
559
+ * Stop watching one output's directory.
560
+ *
561
+ * @param {string} key
562
+ * @returns {void}
563
+ */
564
+ #unwatch(key) {
565
+ const watcher = this.#watchers.get(key);
566
+ if (!watcher) {
567
+ return;
568
+ }
569
+ this.#watchers.delete(key);
570
+ try {
571
+ watcher.close();
572
+ } catch {
573
+ // Already closed with its directory.
574
+ }
575
+ }
576
+
577
+ /**
578
+ * The directory moved: say what is now there.
579
+ *
580
+ * A REMOVAL MOVES IT TOO, so the name is checked against the disk before
581
+ * anything is announced — otherwise clearing up after a dead run would read
582
+ * as that run's pieces arriving.
583
+ *
584
+ * @param {string} key
585
+ * @param {string | null} name - What changed, where the platform says.
586
+ * @returns {void}
587
+ */
588
+ #noticed(key, name) {
589
+ // WHAT WAS READ OF THIS DIRECTORY IS STALE. Dropped rather than re-read:
590
+ // re-reading HERE is quadratic in the pieces of a film — one listing per
591
+ // file written, of a directory that grows with every file, measured at 110 s
592
+ // against 3 s over 482 pieces — while dropping costs one listing to whoever
593
+ // asks next, which is bounded by the questions and not by the writes.
594
+ //
595
+ // IT IS NOT ENOUGH TO LET `refresh` NOTICE BY ITSELF. It re-reads when the
596
+ // directory's modification time has moved, and that time is stated in
597
+ // milliseconds: a file written in the same millisecond as the last reading
598
+ // leaves it unmoved, and the piece is then reported absent although it is
599
+ // there. That is the hazard `refresh` documents and `publish` has always
600
+ // guarded against by dropping the memory.
601
+ this.#held.delete(key);
602
+ const format = this.#formats.get(key);
603
+ const dir = path.join(this.#root, directoryNameFor(key));
604
+ const index = name && format?.isSegmentFileName?.(name)
605
+ ? (format.segmentIndexFromName?.(name) ?? -1)
606
+ : -1;
607
+ if (index >= 0) {
608
+ // A REMOVAL MOVES THE DIRECTORY TOO, so the one file is asked about
609
+ // before anything is announced — otherwise clearing up after a dead run
610
+ // would read as that run's pieces arriving.
611
+ if (!existsSync(path.join(dir, name))) {
612
+ return;
613
+ }
614
+ this.#announceArrival(key, index);
615
+ return;
616
+ }
617
+ if (name) {
618
+ return;
619
+ }
620
+ // The platform named nothing. Then whoever is waiting asks about their own
621
+ // piece, which is the same question with the same answer and no guessing.
622
+ const byIndex = this.#waiters.get(key);
623
+ if (!byIndex || !format?.segmentFileName) {
624
+ return;
625
+ }
626
+ for (const waiting of [...byIndex.keys()]) {
627
+ if (existsSync(path.join(dir, format.segmentFileName(waiting)))) {
628
+ this.#announceArrival(key, waiting);
629
+ }
630
+ }
631
+ }
632
+
633
+ /**
634
+ * One statement that a number is now present here.
635
+ *
636
+ * @param {string} key
637
+ * @param {number} index
638
+ * @returns {void}
639
+ */
640
+ #announceArrival(key, index) {
641
+ this.announce(key, index);
642
+ for (const listener of this.#publishedListeners) {
643
+ listener(key, index);
644
+ }
645
+ }
646
+
647
+ /**
648
+ * Announce that a segment is now present. This stores no readiness state;
649
+ * the file under its served name remains the only fact.
650
+ *
651
+ * @param {string} key
652
+ * @param {number} index
653
+ */
654
+ announce(key, index) {
655
+ const byIndex = this.#waiters.get(key);
656
+ const waiting = byIndex?.get(index);
657
+ if (!waiting) return;
658
+ byIndex.delete(index);
659
+ if (byIndex.size === 0) this.#waiters.delete(key);
660
+ for (const resolve of waiting) resolve(true);
661
+ }
662
+
663
+ /**
664
+ * How many requests are waiting on this output right now.
665
+ *
666
+ * The store's own state, and the one fact that says a wait EXISTS rather than
667
+ * is about to: a check that ends a wait it has not yet registered proves
668
+ * nothing, and passes just as well without the thing it is checking.
669
+ *
670
+ * @param {string} key
671
+ * @returns {number}
672
+ */
673
+ waitingFor(key) {
674
+ let waiting = 0;
675
+ for (const holders of this.#waiters.get(key)?.values() ?? []) {
676
+ waiting += holders.size;
677
+ }
678
+ return waiting;
679
+ }
680
+
681
+ /**
682
+ * Wait for a segment publication event or one deadline. This does not poll
683
+ * the filesystem.
684
+ *
685
+ * @param {string} key
686
+ * @param {number} index
687
+ * @param {number} timeoutMs - May be infinite: then only the publication or
688
+ * `cancelled` ends the wait.
689
+ * @param {Promise<unknown> | null} [cancelled] - Settles when whoever waits
690
+ * has gone, so the waiter does not outlive them.
691
+ * @returns {Promise<boolean>}
692
+ */
693
+ waitFor(key, index, timeoutMs, cancelled = null) {
694
+ if (this.pathOf(key, index)) return Promise.resolve(true);
695
+ // An output adopted from a previous process has a directory nobody made in
696
+ // this one, so the wait itself is the first moment it is watched.
697
+ this.#watch(key);
698
+ return new Promise((resolve) => {
699
+ const byIndex = this.#waiters.get(key) ?? new Map();
700
+ const waiting = byIndex.get(index) ?? new Set();
701
+ this.#waiters.set(key, byIndex);
702
+ byIndex.set(index, waiting);
703
+ let settled = false;
704
+ const finish = (published) => {
705
+ if (settled) return;
706
+ settled = true;
707
+ if (timer) clearTimeout(timer);
708
+ waiting.delete(finish);
709
+ // Removed only if they are still the sets this waiter joined: an
710
+ // announcement takes the set out before calling its waiters, and a new
711
+ // waiter may have registered a fresh one under the same number since.
712
+ if (waiting.size === 0 && byIndex.get(index) === waiting) byIndex.delete(index);
713
+ if (byIndex.size === 0 && this.#waiters.get(key) === byIndex) this.#waiters.delete(key);
714
+ resolve(published);
715
+ };
716
+ waiting.add(finish);
717
+ // No deadline is a wait that ends only on the publication: a timer given
718
+ // an infinite delay fires at once, which would turn the wait into a poll.
719
+ const timer = Number.isFinite(timeoutMs) ? setTimeout(() => finish(false), Math.max(0, timeoutMs)) : null;
720
+ timer?.unref?.();
721
+ cancelled?.then(() => finish(false));
722
+ // Publication may have landed after the first path check and before this
723
+ // waiter was registered. Recheck once after registration so that window
724
+ // cannot turn a present segment into a full-deadline wait.
725
+ if (this.pathOf(key, index)) finish(true);
726
+ });
727
+ }
728
+
401
729
  /**
402
730
  * Clear up after a run that has ended: remove what it left unfinished.
403
731
  *
@@ -437,6 +765,42 @@ export class SegmentStore {
437
765
  }
438
766
 
439
767
 
768
+ /**
769
+ * Where the file of this name is, or null when this output does not hold it.
770
+ *
771
+ * By NAME rather than by number, because that is what a request carries. The
772
+ * conversion is the store's own business: it is told how this output's files
773
+ * are named (`useFormat`) and therefore needs nobody else to read one.
774
+ *
775
+ * @param {string} key
776
+ * @param {string} fileName
777
+ * @returns {string | null}
778
+ */
779
+ pathOfName(key, fileName) {
780
+ const format = this.#formats.get(key);
781
+ const index = format?.segmentIndexFromName?.(fileName) ?? -1;
782
+ if (!Number.isInteger(index) || index < 0) {
783
+ return null;
784
+ }
785
+ return this.pathOf(key, index);
786
+ }
787
+
788
+ /**
789
+ * Forget what this output's directory was last seen to hold.
790
+ *
791
+ * What is held is re-read when the directory's modification time moves, which
792
+ * is how a quiet request costs one `stat`. A caller that has just removed a
793
+ * file and asks again in the same tick would otherwise be told it is still
794
+ * there — the time has moved, but only by as much as the clock's own
795
+ * granularity, and that is not something to depend on.
796
+ *
797
+ * @param {string} key
798
+ * @returns {void}
799
+ */
800
+ forget(key) {
801
+ this.#held.delete(key);
802
+ }
803
+
440
804
  /**
441
805
  * Where a segment is, or null when this output does not hold it.
442
806
  *
@@ -475,9 +839,11 @@ export class SegmentStore {
475
839
  } catch {
476
840
  // Already gone, or in use; the next sweep sees it either way.
477
841
  }
842
+ this.#unwatch(key);
478
843
  this.#held.delete(key);
479
844
  this.#formats.delete(key);
480
845
  this.#touched.delete(key);
846
+ this.#inits.delete(key);
481
847
  this.#logger.info(`segment-store dropped ${directoryNameFor(key)} (${because})`);
482
848
  }
483
849
 
@@ -496,12 +862,30 @@ export class SegmentStore {
496
862
  }
497
863
 
498
864
  /**
499
- * Throw away everything this store owns, and the root with it.
865
+ * Throw away everything this store owns.
500
866
  *
501
867
  * For a clean exit. What is left on disk afterwards is by definition from a
502
868
  * kill, which is the case the startup sweep exists for — and without this the
503
869
  * sweep adopts, the exit leaves, and the next start adopts again, for ever.
504
870
  *
871
+ * WHAT IT OWNS IS ITS DIRECTORIES, NOT THE ROOT. The root is a fixed path —
872
+ * `os.tmpdir()/torrent-tv-hls` — so it belongs to the machine and not to any
873
+ * one process, and this used to remove it whole. The comment that stood here
874
+ * named the hazard exactly ("another process may share the root") and did it
875
+ * anyway.
876
+ *
877
+ * Measured 2026-09-14, where a second process is ordinary: `node --test` runs
878
+ * every test file in its own process in parallel, and four files call this
879
+ * forty times between them. `segment-serve-wiring.test.js` then reported a
880
+ * segment missing that it had written itself a moment earlier, 2 to 4 checks
881
+ * per run, never the same ones — because another process had deleted the
882
+ * directory under it. The same thing happens to two proxies on one machine:
883
+ * a bare npm run beside the addon, or the wedge stand beside the product, and
884
+ * one of them exiting takes the other's segments mid-serve.
885
+ *
886
+ * A store removes what it made. It does not remove the container it made it
887
+ * in, and an empty root costs nothing.
888
+ *
505
889
  * @param {string} because
506
890
  * @returns {number} How many outputs went.
507
891
  */
@@ -511,11 +895,14 @@ export class SegmentStore {
511
895
  this.drop(key, because);
512
896
  dropped += 1;
513
897
  }
514
- try {
515
- rmSync(this.#root, { recursive: true, force: true });
516
- } catch {
517
- // Another process may share the root and hold a directory open. What is
518
- // ours is gone either way.
898
+ // EVERY WATCH GOES, not only the watches of outputs that named their file
899
+ // format. A directory is made before anything says how its files are named
900
+ // — `directoryFor` is what a run calls, `useFormat` is a separate statement
901
+ // — so a store can hold a watch on a key that is in no format list, and
902
+ // dropping by that list left it open: on Windows the process then does not
903
+ // exit, which is how this was found.
904
+ for (const key of [...this.#watchers.keys()]) {
905
+ this.#unwatch(key);
519
906
  }
520
907
  return dropped;
521
908
  }