prismcast 1.10.3 → 1.11.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 (674) hide show
  1. package/dist/app.d.ts +1 -0
  2. package/dist/app.d.ts.map +1 -0
  3. package/dist/app.js +103 -50
  4. package/dist/app.js.map +1 -1
  5. package/dist/browser/blockedPage.d.ts +80 -0
  6. package/dist/browser/blockedPage.d.ts.map +1 -0
  7. package/dist/browser/blockedPage.js +209 -0
  8. package/dist/browser/blockedPage.js.map +1 -0
  9. package/dist/browser/browserSupervisor.d.ts +80 -0
  10. package/dist/browser/browserSupervisor.d.ts.map +1 -0
  11. package/dist/browser/browserSupervisor.js +186 -0
  12. package/dist/browser/browserSupervisor.js.map +1 -0
  13. package/dist/browser/cdp.d.ts +1 -0
  14. package/dist/browser/cdp.d.ts.map +1 -0
  15. package/dist/browser/cdp.js +6 -5
  16. package/dist/browser/cdp.js.map +1 -1
  17. package/dist/browser/channelSelection.d.ts +45 -14
  18. package/dist/browser/channelSelection.d.ts.map +1 -0
  19. package/dist/browser/channelSelection.js +81 -18
  20. package/dist/browser/channelSelection.js.map +1 -1
  21. package/dist/browser/consent.d.ts +152 -0
  22. package/dist/browser/consent.d.ts.map +1 -0
  23. package/dist/browser/consent.js +418 -0
  24. package/dist/browser/consent.js.map +1 -0
  25. package/dist/browser/display.d.ts +1 -0
  26. package/dist/browser/display.d.ts.map +1 -0
  27. package/dist/browser/hlsPlaylistObserver.d.ts +23 -6
  28. package/dist/browser/hlsPlaylistObserver.d.ts.map +1 -0
  29. package/dist/browser/hlsPlaylistObserver.js +27 -8
  30. package/dist/browser/hlsPlaylistObserver.js.map +1 -1
  31. package/dist/browser/index.d.ts +44 -27
  32. package/dist/browser/index.d.ts.map +1 -0
  33. package/dist/browser/index.js +427 -244
  34. package/dist/browser/index.js.map +1 -1
  35. package/dist/browser/launchGovernor.d.ts +89 -0
  36. package/dist/browser/launchGovernor.d.ts.map +1 -0
  37. package/dist/browser/launchGovernor.js +96 -0
  38. package/dist/browser/launchGovernor.js.map +1 -0
  39. package/dist/browser/login.d.ts +10 -2
  40. package/dist/browser/login.d.ts.map +1 -0
  41. package/dist/browser/login.js +36 -5
  42. package/dist/browser/login.js.map +1 -1
  43. package/dist/browser/manifestInterceptor.d.ts +88 -35
  44. package/dist/browser/manifestInterceptor.d.ts.map +1 -0
  45. package/dist/browser/manifestInterceptor.js +165 -77
  46. package/dist/browser/manifestInterceptor.js.map +1 -1
  47. package/dist/browser/pageStaleness.d.ts +30 -0
  48. package/dist/browser/pageStaleness.d.ts.map +1 -0
  49. package/dist/browser/pageStaleness.js +76 -0
  50. package/dist/browser/pageStaleness.js.map +1 -0
  51. package/dist/browser/precaching.d.ts +95 -3
  52. package/dist/browser/precaching.d.ts.map +1 -0
  53. package/dist/browser/precaching.js +499 -65
  54. package/dist/browser/precaching.js.map +1 -1
  55. package/dist/browser/tabNetworkObserver.d.ts +2 -1
  56. package/dist/browser/tabNetworkObserver.d.ts.map +1 -0
  57. package/dist/browser/tabNetworkObserver.js +7 -3
  58. package/dist/browser/tabNetworkObserver.js.map +1 -1
  59. package/dist/browser/tuning/cache.d.ts +61 -0
  60. package/dist/browser/tuning/cache.d.ts.map +1 -0
  61. package/dist/browser/tuning/cache.js +62 -0
  62. package/dist/browser/tuning/cache.js.map +1 -0
  63. package/dist/browser/tuning/comcastPolymer.d.ts +3 -2
  64. package/dist/browser/tuning/comcastPolymer.d.ts.map +1 -0
  65. package/dist/browser/tuning/comcastPolymer.js +79 -102
  66. package/dist/browser/tuning/comcastPolymer.js.map +1 -1
  67. package/dist/browser/tuning/cox.d.ts +1 -0
  68. package/dist/browser/tuning/cox.d.ts.map +1 -0
  69. package/dist/browser/tuning/directv.d.ts +1 -0
  70. package/dist/browser/tuning/directv.d.ts.map +1 -0
  71. package/dist/browser/tuning/directv.js +52 -46
  72. package/dist/browser/tuning/directv.js.map +1 -1
  73. package/dist/browser/tuning/fox.d.ts +1 -0
  74. package/dist/browser/tuning/fox.d.ts.map +1 -0
  75. package/dist/browser/tuning/fox.js +13 -12
  76. package/dist/browser/tuning/fox.js.map +1 -1
  77. package/dist/browser/tuning/gridSearch.d.ts +68 -0
  78. package/dist/browser/tuning/gridSearch.d.ts.map +1 -0
  79. package/dist/browser/tuning/gridSearch.js +85 -0
  80. package/dist/browser/tuning/gridSearch.js.map +1 -0
  81. package/dist/browser/tuning/hbo.d.ts +1 -0
  82. package/dist/browser/tuning/hbo.d.ts.map +1 -0
  83. package/dist/browser/tuning/hbo.js +73 -45
  84. package/dist/browser/tuning/hbo.js.map +1 -1
  85. package/dist/browser/tuning/hulu.d.ts +1 -0
  86. package/dist/browser/tuning/hulu.d.ts.map +1 -0
  87. package/dist/browser/tuning/hulu.js +128 -173
  88. package/dist/browser/tuning/hulu.js.map +1 -1
  89. package/dist/browser/tuning/shared.d.ts +75 -5
  90. package/dist/browser/tuning/shared.d.ts.map +1 -0
  91. package/dist/browser/tuning/shared.js +216 -6
  92. package/dist/browser/tuning/shared.js.map +1 -1
  93. package/dist/browser/tuning/sling.d.ts +1 -0
  94. package/dist/browser/tuning/sling.d.ts.map +1 -0
  95. package/dist/browser/tuning/sling.js +105 -126
  96. package/dist/browser/tuning/sling.js.map +1 -1
  97. package/dist/browser/tuning/spectrum.d.ts +1 -0
  98. package/dist/browser/tuning/spectrum.d.ts.map +1 -0
  99. package/dist/browser/tuning/spectrum.js +80 -125
  100. package/dist/browser/tuning/spectrum.js.map +1 -1
  101. package/dist/browser/tuning/thumbnailRow.d.ts +1 -0
  102. package/dist/browser/tuning/thumbnailRow.d.ts.map +1 -0
  103. package/dist/browser/tuning/thumbnailRow.js +8 -2
  104. package/dist/browser/tuning/thumbnailRow.js.map +1 -1
  105. package/dist/browser/tuning/tileClick.d.ts +1 -0
  106. package/dist/browser/tuning/tileClick.d.ts.map +1 -0
  107. package/dist/browser/tuning/tileClick.js +3 -2
  108. package/dist/browser/tuning/tileClick.js.map +1 -1
  109. package/dist/browser/tuning/xfinity.d.ts +1 -0
  110. package/dist/browser/tuning/xfinity.d.ts.map +1 -0
  111. package/dist/browser/tuning/youtubeTv.d.ts +1 -0
  112. package/dist/browser/tuning/youtubeTv.d.ts.map +1 -0
  113. package/dist/browser/tuning/youtubeTv.js +74 -127
  114. package/dist/browser/tuning/youtubeTv.js.map +1 -1
  115. package/dist/browser/video.d.ts +54 -14
  116. package/dist/browser/video.d.ts.map +1 -0
  117. package/dist/browser/video.js +296 -138
  118. package/dist/browser/video.js.map +1 -1
  119. package/dist/channels/index.d.ts +1 -0
  120. package/dist/channels/index.d.ts.map +1 -0
  121. package/dist/channels/index.js +12 -9
  122. package/dist/channels/index.js.map +1 -1
  123. package/dist/config/channelForm.d.ts +15 -1
  124. package/dist/config/channelForm.d.ts.map +1 -0
  125. package/dist/config/channelForm.js +28 -12
  126. package/dist/config/channelForm.js.map +1 -1
  127. package/dist/config/consistencyProbe.d.ts +1 -0
  128. package/dist/config/consistencyProbe.d.ts.map +1 -0
  129. package/dist/config/consistencyProbe.js +15 -11
  130. package/dist/config/consistencyProbe.js.map +1 -1
  131. package/dist/config/health.d.ts +41 -8
  132. package/dist/config/health.d.ts.map +1 -0
  133. package/dist/config/health.js +189 -38
  134. package/dist/config/health.js.map +1 -1
  135. package/dist/config/index.d.ts +55 -3
  136. package/dist/config/index.d.ts.map +1 -0
  137. package/dist/config/index.js +320 -69
  138. package/dist/config/index.js.map +1 -1
  139. package/dist/config/paths.d.ts +7 -1
  140. package/dist/config/paths.d.ts.map +1 -0
  141. package/dist/config/paths.js +8 -1
  142. package/dist/config/paths.js.map +1 -1
  143. package/dist/config/persistence.context.d.ts +1 -0
  144. package/dist/config/persistence.context.d.ts.map +1 -0
  145. package/dist/config/persistence.d.ts +13 -10
  146. package/dist/config/persistence.d.ts.map +1 -0
  147. package/dist/config/persistence.js +40 -21
  148. package/dist/config/persistence.js.map +1 -1
  149. package/dist/config/presets.d.ts +1 -0
  150. package/dist/config/presets.d.ts.map +1 -0
  151. package/dist/config/profiles.d.ts +21 -14
  152. package/dist/config/profiles.d.ts.map +1 -0
  153. package/dist/config/profiles.js +77 -43
  154. package/dist/config/profiles.js.map +1 -1
  155. package/dist/config/providerLineups.d.ts +47 -0
  156. package/dist/config/providerLineups.d.ts.map +1 -0
  157. package/dist/config/providerLineups.js +137 -0
  158. package/dist/config/providerLineups.js.map +1 -0
  159. package/dist/config/reactivity.d.ts +84 -0
  160. package/dist/config/reactivity.d.ts.map +1 -0
  161. package/dist/config/reactivity.js +226 -0
  162. package/dist/config/reactivity.js.map +1 -0
  163. package/dist/config/servicePacks.d.ts +18 -2
  164. package/dist/config/servicePacks.d.ts.map +1 -0
  165. package/dist/config/servicePacks.js +53 -11
  166. package/dist/config/servicePacks.js.map +1 -1
  167. package/dist/config/services.d.ts +16 -10
  168. package/dist/config/services.d.ts.map +1 -0
  169. package/dist/config/services.js +27 -16
  170. package/dist/config/services.js.map +1 -1
  171. package/dist/config/sites.d.ts +4 -2
  172. package/dist/config/sites.d.ts.map +1 -0
  173. package/dist/config/sites.js +9 -5
  174. package/dist/config/sites.js.map +1 -1
  175. package/dist/config/userChannels.d.ts +23 -18
  176. package/dist/config/userChannels.d.ts.map +1 -0
  177. package/dist/config/userChannels.js +45 -34
  178. package/dist/config/userChannels.js.map +1 -1
  179. package/dist/config/userConfig.d.ts +14 -9
  180. package/dist/config/userConfig.d.ts.map +1 -0
  181. package/dist/config/userConfig.js +87 -25
  182. package/dist/config/userConfig.js.map +1 -1
  183. package/dist/config/userProfiles.d.ts +8 -4
  184. package/dist/config/userProfiles.d.ts.map +1 -0
  185. package/dist/config/userProfiles.js +48 -23
  186. package/dist/config/userProfiles.js.map +1 -1
  187. package/dist/hdhr/channelMap.d.ts +1 -0
  188. package/dist/hdhr/channelMap.d.ts.map +1 -0
  189. package/dist/hdhr/deviceId.d.ts +1 -0
  190. package/dist/hdhr/deviceId.d.ts.map +1 -0
  191. package/dist/hdhr/discover.d.ts +1 -0
  192. package/dist/hdhr/discover.d.ts.map +1 -0
  193. package/dist/hdhr/discover.js +48 -47
  194. package/dist/hdhr/discover.js.map +1 -1
  195. package/dist/hdhr/getHandlers.d.ts +29 -0
  196. package/dist/hdhr/getHandlers.d.ts.map +1 -0
  197. package/dist/hdhr/getHandlers.js +196 -0
  198. package/dist/hdhr/getHandlers.js.map +1 -0
  199. package/dist/hdhr/identity.d.ts +36 -0
  200. package/dist/hdhr/identity.d.ts.map +1 -0
  201. package/dist/hdhr/identity.js +51 -0
  202. package/dist/hdhr/identity.js.map +1 -0
  203. package/dist/hdhr/index.d.ts +22 -4
  204. package/dist/hdhr/index.d.ts.map +1 -0
  205. package/dist/hdhr/index.js +229 -54
  206. package/dist/hdhr/index.js.map +1 -1
  207. package/dist/hdhr/protocol.d.ts +75 -0
  208. package/dist/hdhr/protocol.d.ts.map +1 -0
  209. package/dist/hdhr/protocol.js +264 -0
  210. package/dist/hdhr/protocol.js.map +1 -0
  211. package/dist/hdhr/tunerState.d.ts +24 -0
  212. package/dist/hdhr/tunerState.d.ts.map +1 -0
  213. package/dist/hdhr/tunerState.js +67 -0
  214. package/dist/hdhr/tunerState.js.map +1 -0
  215. package/dist/hdhr/udp.d.ts +43 -0
  216. package/dist/hdhr/udp.d.ts.map +1 -0
  217. package/dist/hdhr/udp.js +277 -0
  218. package/dist/hdhr/udp.js.map +1 -0
  219. package/dist/identity.d.ts +1 -0
  220. package/dist/identity.d.ts.map +1 -0
  221. package/dist/identity.js +13 -11
  222. package/dist/identity.js.map +1 -1
  223. package/dist/index.d.ts +1 -0
  224. package/dist/index.d.ts.map +1 -0
  225. package/dist/index.js +4 -3
  226. package/dist/index.js.map +1 -1
  227. package/dist/native/codecInference.d.ts +1 -0
  228. package/dist/native/codecInference.d.ts.map +1 -0
  229. package/dist/native/codecInference.js +50 -5
  230. package/dist/native/codecInference.js.map +1 -1
  231. package/dist/native/decrypt.d.ts +6 -2
  232. package/dist/native/decrypt.d.ts.map +1 -0
  233. package/dist/native/decrypt.js +15 -8
  234. package/dist/native/decrypt.js.map +1 -1
  235. package/dist/native/index.d.ts +39 -16
  236. package/dist/native/index.d.ts.map +1 -0
  237. package/dist/native/index.js +273 -103
  238. package/dist/native/index.js.map +1 -1
  239. package/dist/native/probe.d.ts +114 -18
  240. package/dist/native/probe.d.ts.map +1 -0
  241. package/dist/native/probe.js +452 -115
  242. package/dist/native/probe.js.map +1 -1
  243. package/dist/native/proxy.d.ts +56 -1
  244. package/dist/native/proxy.d.ts.map +1 -0
  245. package/dist/native/proxy.js +604 -161
  246. package/dist/native/proxy.js.map +1 -1
  247. package/dist/native/tokenExpiry.d.ts +1 -0
  248. package/dist/native/tokenExpiry.d.ts.map +1 -0
  249. package/dist/native/tokenExpiry.js +1 -1
  250. package/dist/routes/assets.d.ts +1 -0
  251. package/dist/routes/assets.d.ts.map +1 -0
  252. package/dist/routes/assets.js +4 -2
  253. package/dist/routes/assets.js.map +1 -1
  254. package/dist/routes/auth.d.ts +1 -0
  255. package/dist/routes/auth.d.ts.map +1 -0
  256. package/dist/routes/auth.js +0 -1
  257. package/dist/routes/auth.js.map +1 -1
  258. package/dist/routes/cdp.d.ts +5 -5
  259. package/dist/routes/cdp.d.ts.map +1 -0
  260. package/dist/routes/cdp.js +59 -20
  261. package/dist/routes/cdp.js.map +1 -1
  262. package/dist/routes/channels.d.ts +1 -0
  263. package/dist/routes/channels.d.ts.map +1 -0
  264. package/dist/routes/channels.js +2 -1
  265. package/dist/routes/channels.js.map +1 -1
  266. package/dist/routes/clientActions.d.ts +4 -3
  267. package/dist/routes/clientActions.d.ts.map +1 -0
  268. package/dist/routes/components.d.ts +1 -0
  269. package/dist/routes/components.d.ts.map +1 -0
  270. package/dist/routes/components.js +2 -2
  271. package/dist/routes/components.js.map +1 -1
  272. package/dist/routes/config/channels/endpoints/browse.d.ts +1 -0
  273. package/dist/routes/config/channels/endpoints/browse.d.ts.map +1 -0
  274. package/dist/routes/config/channels/endpoints/browse.js +23 -10
  275. package/dist/routes/config/channels/endpoints/browse.js.map +1 -1
  276. package/dist/routes/config/channels/endpoints/bulk.d.ts +1 -0
  277. package/dist/routes/config/channels/endpoints/bulk.d.ts.map +1 -0
  278. package/dist/routes/config/channels/endpoints/bulk.js +5 -3
  279. package/dist/routes/config/channels/endpoints/bulk.js.map +1 -1
  280. package/dist/routes/config/channels/endpoints/crud.d.ts +1 -0
  281. package/dist/routes/config/channels/endpoints/crud.d.ts.map +1 -0
  282. package/dist/routes/config/channels/endpoints/crud.js +49 -22
  283. package/dist/routes/config/channels/endpoints/crud.js.map +1 -1
  284. package/dist/routes/config/channels/endpoints/importExport.d.ts +1 -0
  285. package/dist/routes/config/channels/endpoints/importExport.d.ts.map +1 -0
  286. package/dist/routes/config/channels/endpoints/importExport.js +6 -3
  287. package/dist/routes/config/channels/endpoints/importExport.js.map +1 -1
  288. package/dist/routes/config/channels/endpoints/predefined.d.ts +1 -0
  289. package/dist/routes/config/channels/endpoints/predefined.d.ts.map +1 -0
  290. package/dist/routes/config/channels/endpoints/prefs.d.ts +1 -0
  291. package/dist/routes/config/channels/endpoints/prefs.d.ts.map +1 -0
  292. package/dist/routes/config/channels/endpoints/prefs.js +2 -1
  293. package/dist/routes/config/channels/endpoints/prefs.js.map +1 -1
  294. package/dist/routes/config/channels/endpoints/service.d.ts +2 -1
  295. package/dist/routes/config/channels/endpoints/service.d.ts.map +1 -0
  296. package/dist/routes/config/channels/endpoints/service.js +3 -1
  297. package/dist/routes/config/channels/endpoints/service.js.map +1 -1
  298. package/dist/routes/config/channels/endpoints/tags.d.ts +1 -0
  299. package/dist/routes/config/channels/endpoints/tags.d.ts.map +1 -0
  300. package/dist/routes/config/channels/endpoints/tags.js +13 -7
  301. package/dist/routes/config/channels/endpoints/tags.js.map +1 -1
  302. package/dist/routes/config/channels/healthBridge.d.ts +1 -0
  303. package/dist/routes/config/channels/healthBridge.d.ts.map +1 -0
  304. package/dist/routes/config/channels/healthBridge.js +2 -2
  305. package/dist/routes/config/channels/http/handler.d.ts +1 -0
  306. package/dist/routes/config/channels/http/handler.d.ts.map +1 -0
  307. package/dist/routes/config/channels/http/playlistHint.d.ts +2 -1
  308. package/dist/routes/config/channels/http/playlistHint.d.ts.map +1 -0
  309. package/dist/routes/config/channels/http/playlistHint.js +16 -2
  310. package/dist/routes/config/channels/http/playlistHint.js.map +1 -1
  311. package/dist/routes/config/channels/http/serviceWarning.d.ts +4 -2
  312. package/dist/routes/config/channels/http/serviceWarning.d.ts.map +1 -0
  313. package/dist/routes/config/channels/http/serviceWarning.js +3 -2
  314. package/dist/routes/config/channels/http/serviceWarning.js.map +1 -1
  315. package/dist/routes/config/channels/index.d.ts +1 -0
  316. package/dist/routes/config/channels/index.d.ts.map +1 -0
  317. package/dist/routes/config/channels/setup.d.ts +1 -0
  318. package/dist/routes/config/channels/setup.d.ts.map +1 -0
  319. package/dist/routes/config/channels/table.d.ts +3 -3
  320. package/dist/routes/config/channels/table.d.ts.map +1 -0
  321. package/dist/routes/config/channels/table.js +55 -27
  322. package/dist/routes/config/channels/table.js.map +1 -1
  323. package/dist/routes/config/http/envelope.d.ts +1 -0
  324. package/dist/routes/config/http/envelope.d.ts.map +1 -0
  325. package/dist/routes/config/index.d.ts +35 -3
  326. package/dist/routes/config/index.d.ts.map +1 -0
  327. package/dist/routes/config/index.js +51 -2
  328. package/dist/routes/config/index.js.map +1 -1
  329. package/dist/routes/config/services.d.ts +5 -4
  330. package/dist/routes/config/services.d.ts.map +1 -0
  331. package/dist/routes/config/services.js +35 -24
  332. package/dist/routes/config/services.js.map +1 -1
  333. package/dist/routes/config/settings.d.ts +1 -0
  334. package/dist/routes/config/settings.d.ts.map +1 -0
  335. package/dist/routes/config/settings.js +89 -43
  336. package/dist/routes/config/settings.js.map +1 -1
  337. package/dist/routes/debug.d.ts +1 -0
  338. package/dist/routes/debug.d.ts.map +1 -0
  339. package/dist/routes/debug.js +2 -2
  340. package/dist/routes/debug.js.map +1 -1
  341. package/dist/routes/health.d.ts +39 -1
  342. package/dist/routes/health.d.ts.map +1 -0
  343. package/dist/routes/health.js +33 -27
  344. package/dist/routes/health.js.map +1 -1
  345. package/dist/routes/hls.d.ts +1 -0
  346. package/dist/routes/hls.d.ts.map +1 -0
  347. package/dist/routes/icons.d.ts +1 -0
  348. package/dist/routes/icons.d.ts.map +1 -0
  349. package/dist/routes/icons.js +1 -1
  350. package/dist/routes/index.d.ts +1 -0
  351. package/dist/routes/index.d.ts.map +1 -0
  352. package/dist/routes/logs.d.ts +1 -0
  353. package/dist/routes/logs.d.ts.map +1 -0
  354. package/dist/routes/logs.js +13 -3
  355. package/dist/routes/logs.js.map +1 -1
  356. package/dist/routes/mpegts.d.ts +1 -0
  357. package/dist/routes/mpegts.d.ts.map +1 -0
  358. package/dist/routes/play.d.ts +1 -0
  359. package/dist/routes/play.d.ts.map +1 -0
  360. package/dist/routes/playlist.d.ts +1 -0
  361. package/dist/routes/playlist.d.ts.map +1 -0
  362. package/dist/routes/playlist.js +7 -2
  363. package/dist/routes/playlist.js.map +1 -1
  364. package/dist/routes/root/content.d.ts +8 -5
  365. package/dist/routes/root/content.d.ts.map +1 -0
  366. package/dist/routes/root/content.js +11 -12
  367. package/dist/routes/root/content.js.map +1 -1
  368. package/dist/routes/root/index.d.ts +2 -1
  369. package/dist/routes/root/index.d.ts.map +1 -0
  370. package/dist/routes/root/index.js +10 -8
  371. package/dist/routes/root/index.js.map +1 -1
  372. package/dist/routes/root/scripts/channels.d.ts +6 -0
  373. package/dist/routes/root/scripts/channels.d.ts.map +1 -0
  374. package/dist/routes/root/scripts/channels.js +147 -97
  375. package/dist/routes/root/scripts/channels.js.map +1 -1
  376. package/dist/routes/root/scripts/clientEscape.d.ts +22 -0
  377. package/dist/routes/root/scripts/clientEscape.d.ts.map +1 -0
  378. package/dist/routes/root/scripts/clientEscape.js +41 -0
  379. package/dist/routes/root/scripts/clientEscape.js.map +1 -0
  380. package/dist/routes/root/scripts/clientUrl.d.ts +23 -0
  381. package/dist/routes/root/scripts/clientUrl.d.ts.map +1 -0
  382. package/dist/routes/root/scripts/clientUrl.js +44 -0
  383. package/dist/routes/root/scripts/clientUrl.js.map +1 -0
  384. package/dist/routes/root/scripts/config.d.ts +6 -0
  385. package/dist/routes/root/scripts/config.d.ts.map +1 -0
  386. package/dist/routes/root/scripts/config.js +39 -22
  387. package/dist/routes/root/scripts/config.js.map +1 -1
  388. package/dist/routes/root/scripts/index.d.ts +1 -0
  389. package/dist/routes/root/scripts/index.d.ts.map +1 -0
  390. package/dist/routes/root/scripts/shared.d.ts +1 -0
  391. package/dist/routes/root/scripts/shared.d.ts.map +1 -0
  392. package/dist/routes/root/scripts/shared.js +52 -26
  393. package/dist/routes/root/scripts/shared.js.map +1 -1
  394. package/dist/routes/root/scripts/status.d.ts +1 -0
  395. package/dist/routes/root/scripts/status.d.ts.map +1 -0
  396. package/dist/routes/root/scripts/status.handlers.d.ts +18 -6
  397. package/dist/routes/root/scripts/status.handlers.d.ts.map +1 -0
  398. package/dist/routes/root/scripts/status.handlers.js +38 -32
  399. package/dist/routes/root/scripts/status.handlers.js.map +1 -1
  400. package/dist/routes/root/scripts/status.js +15 -7
  401. package/dist/routes/root/scripts/status.js.map +1 -1
  402. package/dist/routes/root/styles.d.ts +1 -0
  403. package/dist/routes/root/styles.d.ts.map +1 -0
  404. package/dist/routes/root/styles.js +7 -4
  405. package/dist/routes/root/styles.js.map +1 -1
  406. package/dist/routes/services.d.ts +13 -1
  407. package/dist/routes/services.d.ts.map +1 -0
  408. package/dist/routes/services.js +81 -68
  409. package/dist/routes/services.js.map +1 -1
  410. package/dist/routes/sse.d.ts +1 -0
  411. package/dist/routes/sse.d.ts.map +1 -0
  412. package/dist/routes/sse.js +1 -1
  413. package/dist/routes/sse.js.map +1 -1
  414. package/dist/routes/streams.d.ts +2 -1
  415. package/dist/routes/streams.d.ts.map +1 -0
  416. package/dist/routes/streams.js +1 -1
  417. package/dist/routes/theme.d.ts +3 -2
  418. package/dist/routes/theme.d.ts.map +1 -0
  419. package/dist/routes/theme.js +6 -5
  420. package/dist/routes/theme.js.map +1 -1
  421. package/dist/routes/ui.d.ts +3 -1
  422. package/dist/routes/ui.d.ts.map +1 -0
  423. package/dist/routes/ui.js +16 -4
  424. package/dist/routes/ui.js.map +1 -1
  425. package/dist/routes/upgrade.d.ts +1 -0
  426. package/dist/routes/upgrade.d.ts.map +1 -0
  427. package/dist/routes/upgrade.js +6 -2
  428. package/dist/routes/upgrade.js.map +1 -1
  429. package/dist/service/commands.context.d.ts +1 -0
  430. package/dist/service/commands.context.d.ts.map +1 -0
  431. package/dist/service/commands.context.js +3 -2
  432. package/dist/service/commands.context.js.map +1 -1
  433. package/dist/service/commands.d.ts +1 -0
  434. package/dist/service/commands.d.ts.map +1 -0
  435. package/dist/service/commands.js +10 -13
  436. package/dist/service/commands.js.map +1 -1
  437. package/dist/service/generators.context.d.ts +1 -0
  438. package/dist/service/generators.context.d.ts.map +1 -0
  439. package/dist/service/generators.d.ts +3 -2
  440. package/dist/service/generators.d.ts.map +1 -0
  441. package/dist/service/generators.js +108 -20
  442. package/dist/service/generators.js.map +1 -1
  443. package/dist/service/index.d.ts +1 -0
  444. package/dist/service/index.d.ts.map +1 -0
  445. package/dist/streaming/captureLock.d.ts +44 -0
  446. package/dist/streaming/captureLock.d.ts.map +1 -0
  447. package/dist/streaming/captureLock.js +95 -0
  448. package/dist/streaming/captureLock.js.map +1 -0
  449. package/dist/streaming/captureSession.d.ts +31 -0
  450. package/dist/streaming/captureSession.d.ts.map +1 -0
  451. package/dist/streaming/captureSession.js +54 -0
  452. package/dist/streaming/captureSession.js.map +1 -0
  453. package/dist/streaming/clients.d.ts +1 -0
  454. package/dist/streaming/clients.d.ts.map +1 -0
  455. package/dist/streaming/codec.d.ts +1 -0
  456. package/dist/streaming/codec.d.ts.map +1 -0
  457. package/dist/streaming/fmp4Segmenter.d.ts +29 -2
  458. package/dist/streaming/fmp4Segmenter.d.ts.map +1 -0
  459. package/dist/streaming/fmp4Segmenter.js +79 -26
  460. package/dist/streaming/fmp4Segmenter.js.map +1 -1
  461. package/dist/streaming/hls.d.ts +18 -7
  462. package/dist/streaming/hls.d.ts.map +1 -0
  463. package/dist/streaming/hls.js +219 -115
  464. package/dist/streaming/hls.js.map +1 -1
  465. package/dist/streaming/hlsResume.d.ts +1 -0
  466. package/dist/streaming/hlsResume.d.ts.map +1 -0
  467. package/dist/streaming/hlsResume.js +1 -1
  468. package/dist/streaming/hlsResume.js.map +1 -1
  469. package/dist/streaming/hlsSegments.d.ts +58 -1
  470. package/dist/streaming/hlsSegments.d.ts.map +1 -0
  471. package/dist/streaming/hlsSegments.js +147 -17
  472. package/dist/streaming/hlsSegments.js.map +1 -1
  473. package/dist/streaming/lifecycle.d.ts +4 -1
  474. package/dist/streaming/lifecycle.d.ts.map +1 -0
  475. package/dist/streaming/lifecycle.js +65 -85
  476. package/dist/streaming/lifecycle.js.map +1 -1
  477. package/dist/streaming/monitor.d.ts +6 -4
  478. package/dist/streaming/monitor.d.ts.map +1 -0
  479. package/dist/streaming/monitor.js +343 -145
  480. package/dist/streaming/monitor.js.map +1 -1
  481. package/dist/streaming/mp4Parser.d.ts +5 -4
  482. package/dist/streaming/mp4Parser.d.ts.map +1 -0
  483. package/dist/streaming/mp4Parser.js +36 -13
  484. package/dist/streaming/mp4Parser.js.map +1 -1
  485. package/dist/streaming/mpegts.d.ts +16 -0
  486. package/dist/streaming/mpegts.d.ts.map +1 -0
  487. package/dist/streaming/mpegts.js +61 -17
  488. package/dist/streaming/mpegts.js.map +1 -1
  489. package/dist/streaming/playlistBuilder.d.ts +1 -0
  490. package/dist/streaming/playlistBuilder.d.ts.map +1 -0
  491. package/dist/streaming/preroll.d.ts +31 -4
  492. package/dist/streaming/preroll.d.ts.map +1 -0
  493. package/dist/streaming/preroll.js +69 -27
  494. package/dist/streaming/preroll.js.map +1 -1
  495. package/dist/streaming/pretune.d.ts +26 -1
  496. package/dist/streaming/pretune.d.ts.map +1 -0
  497. package/dist/streaming/pretune.js +22 -24
  498. package/dist/streaming/pretune.js.map +1 -1
  499. package/dist/streaming/pretuneTimers.d.ts +24 -0
  500. package/dist/streaming/pretuneTimers.d.ts.map +1 -0
  501. package/dist/streaming/pretuneTimers.js +56 -0
  502. package/dist/streaming/pretuneTimers.js.map +1 -0
  503. package/dist/streaming/recovery.d.ts +139 -5
  504. package/dist/streaming/recovery.d.ts.map +1 -0
  505. package/dist/streaming/recovery.js +160 -16
  506. package/dist/streaming/recovery.js.map +1 -1
  507. package/dist/streaming/registry.d.ts +27 -9
  508. package/dist/streaming/registry.d.ts.map +1 -0
  509. package/dist/streaming/registry.js +21 -3
  510. package/dist/streaming/registry.js.map +1 -1
  511. package/dist/streaming/setup.d.ts +157 -29
  512. package/dist/streaming/setup.d.ts.map +1 -0
  513. package/dist/streaming/setup.js +1034 -458
  514. package/dist/streaming/setup.js.map +1 -1
  515. package/dist/streaming/showInfo.d.ts +21 -1
  516. package/dist/streaming/showInfo.d.ts.map +1 -0
  517. package/dist/streaming/showInfo.js +31 -16
  518. package/dist/streaming/showInfo.js.map +1 -1
  519. package/dist/streaming/statusEmitter.d.ts +5 -2
  520. package/dist/streaming/statusEmitter.d.ts.map +1 -0
  521. package/dist/streaming/statusEmitter.js +5 -3
  522. package/dist/streaming/statusEmitter.js.map +1 -1
  523. package/dist/types/channels.d.ts +15 -12
  524. package/dist/types/channels.d.ts.map +1 -0
  525. package/dist/types/channels.js +4 -2
  526. package/dist/types/channels.js.map +1 -1
  527. package/dist/types/config.d.ts +16 -5
  528. package/dist/types/config.d.ts.map +1 -0
  529. package/dist/types/index.d.ts +3 -2
  530. package/dist/types/index.d.ts.map +1 -0
  531. package/dist/types/profiles.d.ts +4 -2
  532. package/dist/types/profiles.d.ts.map +1 -0
  533. package/dist/types/selection.d.ts +31 -3
  534. package/dist/types/selection.d.ts.map +1 -0
  535. package/dist/types/selection.js +4 -2
  536. package/dist/types/selection.js.map +1 -1
  537. package/dist/types/shared.d.ts +1 -0
  538. package/dist/types/shared.d.ts.map +1 -0
  539. package/dist/types/streaming.d.ts +8 -0
  540. package/dist/types/streaming.d.ts.map +1 -0
  541. package/dist/upgrade/commands.context.d.ts +1 -0
  542. package/dist/upgrade/commands.context.d.ts.map +1 -0
  543. package/dist/upgrade/commands.d.ts +1 -0
  544. package/dist/upgrade/commands.d.ts.map +1 -0
  545. package/dist/upgrade/commands.js +1 -1
  546. package/dist/upgrade/commands.js.map +1 -1
  547. package/dist/upgrade/detection.context.d.ts +1 -0
  548. package/dist/upgrade/detection.context.d.ts.map +1 -0
  549. package/dist/upgrade/detection.context.js +2 -0
  550. package/dist/upgrade/detection.context.js.map +1 -1
  551. package/dist/upgrade/detection.d.ts +7 -6
  552. package/dist/upgrade/detection.d.ts.map +1 -0
  553. package/dist/upgrade/detection.js +4 -4
  554. package/dist/upgrade/index.d.ts +1 -0
  555. package/dist/upgrade/index.d.ts.map +1 -0
  556. package/dist/upgrade/lifecycle.context.d.ts +1 -0
  557. package/dist/upgrade/lifecycle.context.d.ts.map +1 -0
  558. package/dist/upgrade/lifecycle.d.ts +7 -7
  559. package/dist/upgrade/lifecycle.d.ts.map +1 -0
  560. package/dist/upgrade/lifecycle.js +9 -10
  561. package/dist/upgrade/lifecycle.js.map +1 -1
  562. package/dist/upgrade/pathHandle.d.ts +5 -3
  563. package/dist/upgrade/pathHandle.d.ts.map +1 -0
  564. package/dist/upgrade/pathHandle.js +3 -2
  565. package/dist/upgrade/pathHandle.js.map +1 -1
  566. package/dist/utils/bootSession.context.d.ts +1 -0
  567. package/dist/utils/bootSession.context.d.ts.map +1 -0
  568. package/dist/utils/bootSession.context.js +6 -5
  569. package/dist/utils/bootSession.context.js.map +1 -1
  570. package/dist/utils/bootSession.d.ts +1 -0
  571. package/dist/utils/bootSession.d.ts.map +1 -0
  572. package/dist/utils/bootSession.js.map +1 -1
  573. package/dist/utils/chromeFetch.d.ts +1 -0
  574. package/dist/utils/chromeFetch.d.ts.map +1 -0
  575. package/dist/utils/cliOutput.d.ts +1 -0
  576. package/dist/utils/cliOutput.d.ts.map +1 -0
  577. package/dist/utils/clock.d.ts +5 -4
  578. package/dist/utils/clock.d.ts.map +1 -0
  579. package/dist/utils/clock.js +19 -16
  580. package/dist/utils/clock.js.map +1 -1
  581. package/dist/utils/debugFilter.d.ts +9 -1
  582. package/dist/utils/debugFilter.d.ts.map +1 -0
  583. package/dist/utils/debugFilter.js +60 -33
  584. package/dist/utils/debugFilter.js.map +1 -1
  585. package/dist/utils/delay.d.ts +48 -15
  586. package/dist/utils/delay.d.ts.map +1 -0
  587. package/dist/utils/delay.js +73 -20
  588. package/dist/utils/delay.js.map +1 -1
  589. package/dist/utils/errors.d.ts +32 -0
  590. package/dist/utils/errors.d.ts.map +1 -0
  591. package/dist/utils/errors.js +45 -4
  592. package/dist/utils/errors.js.map +1 -1
  593. package/dist/utils/evaluate.d.ts +1 -0
  594. package/dist/utils/evaluate.d.ts.map +1 -0
  595. package/dist/utils/evaluate.js +29 -23
  596. package/dist/utils/evaluate.js.map +1 -1
  597. package/dist/utils/ffmpeg.context.d.ts +1 -0
  598. package/dist/utils/ffmpeg.context.d.ts.map +1 -0
  599. package/dist/utils/ffmpeg.context.js.map +1 -1
  600. package/dist/utils/ffmpeg.d.ts +29 -7
  601. package/dist/utils/ffmpeg.d.ts.map +1 -0
  602. package/dist/utils/ffmpeg.js +31 -13
  603. package/dist/utils/ffmpeg.js.map +1 -1
  604. package/dist/utils/fileLogger.d.ts +3 -1
  605. package/dist/utils/fileLogger.d.ts.map +1 -0
  606. package/dist/utils/fileLogger.js +64 -29
  607. package/dist/utils/fileLogger.js.map +1 -1
  608. package/dist/utils/format.d.ts +14 -1
  609. package/dist/utils/format.d.ts.map +1 -0
  610. package/dist/utils/format.js +19 -5
  611. package/dist/utils/format.js.map +1 -1
  612. package/dist/utils/index.d.ts +1 -0
  613. package/dist/utils/index.d.ts.map +1 -0
  614. package/dist/utils/logEmitter.d.ts +1 -0
  615. package/dist/utils/logEmitter.d.ts.map +1 -0
  616. package/dist/utils/logger.d.ts +1 -0
  617. package/dist/utils/logger.d.ts.map +1 -0
  618. package/dist/utils/logger.js +10 -6
  619. package/dist/utils/logger.js.map +1 -1
  620. package/dist/utils/m3u.d.ts +3 -2
  621. package/dist/utils/m3u.d.ts.map +1 -0
  622. package/dist/utils/m3u.js +4 -3
  623. package/dist/utils/m3u.js.map +1 -1
  624. package/dist/utils/markup.d.ts +4 -2
  625. package/dist/utils/markup.d.ts.map +1 -0
  626. package/dist/utils/markup.js +6 -5
  627. package/dist/utils/markup.js.map +1 -1
  628. package/dist/utils/memo.d.ts +3 -2
  629. package/dist/utils/memo.d.ts.map +1 -0
  630. package/dist/utils/memo.js +3 -3
  631. package/dist/utils/morganStream.d.ts +1 -0
  632. package/dist/utils/morganStream.d.ts.map +1 -0
  633. package/dist/utils/network.d.ts +1 -0
  634. package/dist/utils/network.d.ts.map +1 -0
  635. package/dist/utils/network.js +2 -2
  636. package/dist/utils/pid.d.ts +1 -0
  637. package/dist/utils/pid.d.ts.map +1 -0
  638. package/dist/utils/pid.js +3 -3
  639. package/dist/utils/platform.d.ts +3 -1
  640. package/dist/utils/platform.d.ts.map +1 -0
  641. package/dist/utils/platform.js +2 -1
  642. package/dist/utils/platform.js.map +1 -1
  643. package/dist/utils/processInspector.context.d.ts +1 -0
  644. package/dist/utils/processInspector.context.d.ts.map +1 -0
  645. package/dist/utils/processInspector.context.js +3 -1
  646. package/dist/utils/processInspector.context.js.map +1 -1
  647. package/dist/utils/processInspector.d.ts +4 -3
  648. package/dist/utils/processInspector.d.ts.map +1 -0
  649. package/dist/utils/processInspector.js +2 -0
  650. package/dist/utils/processInspector.js.map +1 -1
  651. package/dist/utils/retry.d.ts +24 -1
  652. package/dist/utils/retry.d.ts.map +1 -0
  653. package/dist/utils/retry.js +32 -5
  654. package/dist/utils/retry.js.map +1 -1
  655. package/dist/utils/runtimeIdentity.context.d.ts +1 -0
  656. package/dist/utils/runtimeIdentity.context.d.ts.map +1 -0
  657. package/dist/utils/runtimeIdentity.context.js +30 -0
  658. package/dist/utils/runtimeIdentity.context.js.map +1 -1
  659. package/dist/utils/runtimeIdentity.d.ts +6 -4
  660. package/dist/utils/runtimeIdentity.d.ts.map +1 -0
  661. package/dist/utils/runtimeIdentity.js +15 -5
  662. package/dist/utils/runtimeIdentity.js.map +1 -1
  663. package/dist/utils/sanitize.d.ts +1 -0
  664. package/dist/utils/sanitize.d.ts.map +1 -0
  665. package/dist/utils/streamContext.d.ts +2 -1
  666. package/dist/utils/streamContext.d.ts.map +1 -0
  667. package/dist/utils/streamContext.js +1 -1
  668. package/dist/utils/timing.d.ts +1 -0
  669. package/dist/utils/timing.d.ts.map +1 -0
  670. package/dist/utils/version.d.ts +1 -0
  671. package/dist/utils/version.d.ts.map +1 -0
  672. package/dist/utils/version.js +7 -3
  673. package/dist/utils/version.js.map +1 -1
  674. package/package.json +26 -20
@@ -1,4 +1,4 @@
1
- import { LOG, cancellableTimeout, delay, evaluateWithAbort, formatError, isProcessRunning, listProcesses, startTimer } from "../utils/index.js";
1
+ import { LOG, boundedWait, delay, evaluateWithAbort, formatError, isProcessRunning, listProcesses, realClock, startTimer } from "../utils/index.js";
2
2
  import { clearLoginState, isLoginModeActive, setBrowserAccessors } from "./login.js";
3
3
  import { getAllStreams, getStreamCount } from "../streaming/registry.js";
4
4
  import { getChromeDataDir, getDataDir, getExtensionDir } from "../config/paths.js";
@@ -8,7 +8,9 @@ import { getGpuCapabilities, setBrowserChrome, setGpuCapabilities, setMaxSupport
8
8
  import { resizeAndMinimizeWindow, unminimizeWindow } from "./cdp.js";
9
9
  import { CONFIG } from "../config/index.js";
10
10
  import { clearChannelSelectionCaches } from "./channelSelection.js";
11
+ import { createBrowserSupervisor } from "./browserSupervisor.js";
11
12
  import { emitSystemStatusChanged } from "../streaming/statusEmitter.js";
13
+ import { evaluateStalePages } from "./pageStaleness.js";
12
14
  import fs from "node:fs";
13
15
  import path from "node:path";
14
16
  import { launch as puppeteerLaunch } from "puppeteer-core";
@@ -19,27 +21,101 @@ const { promises: fsPromises } = fs;
19
21
  /* Global variables maintain the application's runtime state across all operations. We minimize global state where possible, but some values must be shared across
20
22
  * the application lifecycle:
21
23
  *
22
- * - currentBrowser: The shared browser instance. All streaming sessions use a single Chrome process to avoid the overhead of launching multiple browsers. This is
23
- * created on first stream request (or during warmup) and persists until the application shuts down or the browser crashes.
24
+ * - supervisor: The browser capture-readiness supervisor. It is the single source of truth for the shared Chrome instance and its lifecycle (absent / launching /
25
+ * ready / degraded / trialing), so all streaming sessions use one Chrome process via supervisor.acquire(). It holds one discriminated-union lifecycle state that
26
+ * captures the browser reference, its launch timestamp, and whether a launch is in flight, and routes every relaunch through one loop-safe governor.
24
27
  *
25
- * - dataDir: The filesystem location for persistent data (Chrome profile, extension files). Resolved via config/paths.ts, which is created on startup if it doesn't
26
- * exist.
28
+ * - currentChromeVersion: The one piece of per-browser metadata the adapter holds directly, captured when the browser becomes ready and surfaced by the
29
+ * health endpoint.
27
30
  *
28
- * Stream tracking and ID generation live in streaming/registry.ts for unified stream management across all output types (HLS, MPEG-TS, etc.).
31
+ * Stream tracking and ID generation live in streaming/registry.ts for unified stream management across all output types (HLS, MPEG-TS, etc.). Filesystem path
32
+ * resolution for persistent data (the Chrome profile and the streaming extension files) is centralized in config/paths.ts, whose getters create the data directory on
33
+ * startup if it does not exist; it is resolved on demand rather than held as module state here.
29
34
  */
30
- // The shared browser instance used by all streaming sessions. Created on first stream request or during warmup. Set to null when the browser is not running or
31
- // has disconnected.
32
- let currentBrowser = null;
33
- // The Chrome version string (e.g., "Chrome/144.0.7559.110") captured when the browser launches. Cleared when the browser disconnects. Used by the
34
- // health endpoint to report the active Chrome version.
35
+ // The Chrome version string (e.g., "Chrome/144.0.7559.110") captured when the browser becomes capture-ready in launchReadyBrowser. Cleared when the browser
36
+ // disconnects or is closed. This is the one piece of per-browser metadata the adapter holds directly; the browser instance, its launch timestamp, and the
37
+ // launch mutex live inside the supervisor's lifecycle state, which is the single source of truth for "what is the browser doing, and is it usable?".
35
38
  let currentChromeVersion = null;
36
- // Timestamp (Date.now()) when the current browser instance was launched. Used by the opportunistic restart check to determine browser age. Cleared when the
37
- // browser disconnects.
38
- let browserLaunchTime = null;
39
- // Launch mutex. When a browser launch is in progress, this holds the pending promise so that concurrent callers piggyback on the same launch instead of
40
- // starting a second Chrome process. Cleared in a finally block once the launch settles.
41
- let browserLaunchPromise = null;
42
- // The data directory stores Chrome's profile data and the streaming extension files. Path resolution is centralized in config/paths.ts.
39
+ /* The browser relaunch governor's escalating cooldown ladder: 5 minutes, then 15, then 60. This is the escalation SHAPE - a design constant - rather than an
40
+ * operational tolerance, so it stays in code while the scalar tolerances (failure threshold, window, health hold) are operator-tunable via CONFIG.recovery. Each
41
+ * successive trip cools down for the next-longer rung; the final rung is the ceiling.
42
+ */
43
+ const RELAUNCH_COOLDOWN_LADDER_MS = [5 * 60 * 1000, 15 * 60 * 1000, 60 * 60 * 1000];
44
+ /* How long Chrome is given to exit after SIGTERM, and then after SIGKILL. The escalation is SIGTERM-first so Chrome can flush its profile databases (LevelDB,
45
+ * extension state, session storage) instead of having them corrupted by an immediate kill, and the SIGTERM window is generous because containerized environments
46
+ * with software rendering and shared CPU may need all of it. Every path that signals Chrome - the orderly close of a running instance and the startup sweep of
47
+ * stale ones - shares this pair, so the escalation behaves identically wherever it runs.
48
+ */
49
+ const TERM_WAIT_MS = 5000;
50
+ const KILL_WAIT_MS = 2000;
51
+ /* The worst case a browser teardown can take before the Chrome process is certainly gone. The supervisor's closing state hands this to requests that arrive
52
+ * mid-drain as their retry horizon, so it is derived from the waits above rather than restated: the bound cannot drift from what the teardown actually allows.
53
+ */
54
+ const BROWSER_TEARDOWN_DRAIN_BOUND_MS = TERM_WAIT_MS + KILL_WAIT_MS;
55
+ /**
56
+ * Builds the browser relaunch governor's policy from live configuration. The supervisor's policy port is a getter, so this is read fresh at each governor decision -
57
+ * an operator's change to the recovery.relaunch* settings takes effect without reconstructing the supervisor (and a deferred config reload that restarts the server
58
+ * applies it too). The scalar tolerances come from CONFIG.recovery (conservative, biased eager-for-the-first-failure: the first failures cost no cooldown; only
59
+ * repeated failures within the window trip the escalating cooldown); the cooldown ladder is the fixed escalation shape above.
60
+ * @returns The current launch governor policy.
61
+ */
62
+ function buildRelaunchPolicy() {
63
+ return {
64
+ cooldownLadderMs: RELAUNCH_COOLDOWN_LADDER_MS,
65
+ failureThreshold: CONFIG.recovery.relaunchFailureThreshold,
66
+ failureWindowMs: CONFIG.recovery.relaunchFailureWindow,
67
+ healthHoldMs: CONFIG.recovery.relaunchHealthHold
68
+ };
69
+ }
70
+ /* The one browser capture-readiness supervisor for the process lifetime. It owns the lifecycle state (absent/launching/ready/degraded/trialing) that unifies the
71
+ * browser reference, launch promise, and launch timestamp, and routes every relaunch through one loop-safe governor. The adapter injects the impure
72
+ * ports: launchReadyBrowser (spawn Chrome and run the readiness gate), closeBrowserInstance (teardown), realClock.now (time), buildRelaunchPolicy (live config
73
+ * bounds), and onSupervisorStateChange (the loud degraded alarm and the recovery notice). All browser access flows through it: getCurrentBrowser is acquire(); the
74
+ * non-launching reads derive from current() and currentLaunchTime(). The injected ports are hoisted function declarations, so referencing them here is safe even
75
+ * though they are defined further down the module.
76
+ */
77
+ const supervisor = createBrowserSupervisor({ close: closeBrowserInstance, launch: launchReadyBrowser, now: realClock.now, onStateChange: onSupervisorStateChange,
78
+ policy: buildRelaunchPolicy });
79
+ /**
80
+ * Observes supervisor lifecycle transitions purely for operator-visible signals; it never affects the transition (the supervisor treats it as best-effort, so a
81
+ * throwing logger cannot corrupt the lifecycle). It raises the loud degraded alarm when the relaunch governor trips, an info notice when capture readiness is
82
+ * restored after a degraded period, and emits the SSE system status whenever a ready browser is published.
83
+ * @param next - The state being entered.
84
+ * @param previous - The state being left.
85
+ */
86
+ function onSupervisorStateChange(next, previous) {
87
+ // The governor just tripped: relaunches are paused while the browser's capture system cools down. We log loudly at ERROR with the cooldown horizon so the
88
+ // condition is never invisible - the enforceable form of "it is impossible to be silently un-tunable."
89
+ if ((next.kind === "degraded") && (previous.kind !== "degraded")) {
90
+ const cooldownMinutes = Math.max(1, Math.round((next.until - realClock.now()) / 60000));
91
+ LOG.error("The browser capture system has degraded and the relaunch governor has tripped: %s Relaunches are paused for approximately %d minute(s) while it " +
92
+ "cools down; new stream requests will receive a 503 back-off until it recovers.", next.reason, cooldownMinutes);
93
+ }
94
+ else if ((next.kind === "ready") && ((previous.kind === "trialing") || (previous.kind === "degraded"))) {
95
+ // Capture readiness was restored by a successful trial after a degraded period. The ordinary first launch (absent -> launching -> ready) is intentionally
96
+ // silent; only a recovery from trialing/degraded is worth an operator notice.
97
+ LOG.info("The browser capture system has recovered and is serving captures again.");
98
+ }
99
+ // The browser's connectivity is part of the SSE system status, so emit when a ready browser is published. Readiness-loss emits are owned by handleBrowserDisconnect
100
+ // (genuine disconnect) and the shutdown path; emitSystemStatusChanged dedupes, so a redundant emit is a cheap no-op.
101
+ if (next.kind === "ready") {
102
+ void emitCurrentSystemStatus();
103
+ }
104
+ }
105
+ /* The capture-readiness probe (capability tier of the launch gate). Null until streaming/setup.ts injects the real getStream probe at module load, which the import
106
+ * order guarantees runs before any launch: app.ts imports the streaming layer, whose module bodies evaluate during import resolution, before startServer's warm-up.
107
+ * launchReadyBrowser refuses to publish a browser if it is somehow still null (see the call site), rather than serving an unverified one.
108
+ */
109
+ let captureProbe = null;
110
+ /**
111
+ * Injects the capture-readiness probe used as the capability tier of the launch gate. Called once from streaming/setup.ts at module load (which always precedes any
112
+ * launch, since the streaming layer is imported during server startup). Separating the wiring from the call keeps browser/index.ts free of a streaming-layer import.
113
+ * @param probe - The probe to run against a freshly-launched browser; it resolves when the browser can capture and rejects otherwise (or exits the process for the
114
+ * unrecoverable stale-mutex case).
115
+ */
116
+ export function setCaptureProbe(probe) {
117
+ captureProbe = probe;
118
+ }
43
119
  // The stale page cleanup interval handle, stored so we can clear it during graceful shutdown. The interval periodically checks for browser pages that are not
44
120
  // associated with active streams and closes them to prevent resource exhaustion.
45
121
  let stalePageCleanupInterval = null;
@@ -52,7 +128,7 @@ const BROWSER_MAX_AGE = 6 * 60 * 60 * 1000;
52
128
  // Duration of the quiet period (zero streams) required before executing the restart (5 minutes).
53
129
  const BROWSER_RESTART_QUIET_PERIOD = 5 * 60 * 1000;
54
130
  // How often to check whether the browser qualifies for a restart (30 seconds).
55
- const BROWSER_RESTART_CHECK_INTERVAL = 30_000;
131
+ const BROWSER_RESTART_CHECK_INTERVAL = 30000;
56
132
  // Timer handle for the quiet period countdown. When set, the browser has exceeded BROWSER_MAX_AGE and we are waiting for BROWSER_RESTART_QUIET_PERIOD to
57
133
  // elapse with zero active streams. Cancelled if a stream starts during the quiet period.
58
134
  let restartQuietTimer = null;
@@ -90,10 +166,20 @@ const managedPageIds = new Set();
90
166
  // Map from page ID to timestamp when a page was first observed as potentially stale (not associated with an active stream). Pages must remain in this state for
91
167
  // the configured grace period before being closed. This prevents race conditions where pages are briefly untracked during initialization or cleanup transitions.
92
168
  const potentiallyStalePages = new Map();
169
+ /* Set of IDs for pages that stream setup created and whose ownership the stream registry does not yet record. Setup writes the registry's page reference only once
170
+ * it completes, which on a slow tune is long enough for the cleanup walk to see the page as unowned and close it out from under the setup driving it; membership
171
+ * here exempts the page from staleness for exactly that window. An entry leaves the set when unregisterManagedPage releases the page as setup tears it down, when
172
+ * the cleanup walk sees the registry record the ownership the mark stood in for, and when clearPageTracking wipes the collections at the end of a browser session.
173
+ */
174
+ const inFlightSetupPageIds = new Set();
93
175
  // Login mode management. State and functions live in login.ts; re-exported here so existing consumers don't need import path changes. clearLoginState,
94
176
  // isLoginModeActive, and setBrowserAccessors are imported above; the first two for internal use, setBrowserAccessors for one-time initialization below.
95
177
  export { clearLoginState, isLoginModeActive };
96
- export { endLoginMode, getLoginPage, getLoginStatus, startLoginMode } from "./login.js";
178
+ export { endLoginMode, getLoginPage, getLoginStatus, setLoginModeEndObserver, startLoginMode } from "./login.js";
179
+ // Re-export the supervisor's acquire() rejection classes through the browser surface so the stream-setup layer can map them to a 503 back-off without reaching into
180
+ // the supervisor module directly. Both signal a transient "retry me" condition: BrowserUnavailableError while the relaunch governor is cooling, BrowserSupersededError
181
+ // when a launch was abandoned mid-flight by a readiness-loss.
182
+ export { BrowserSupersededError, BrowserUnavailableError } from "./browserSupervisor.js";
97
183
  // Inject browser accessors into the login module. This breaks the circular dependency (login needs getBrowserInstance/minimizeBrowserWindow, index needs login
98
184
  // functions) using the same setter/getter pattern as setChromeUserAgent in chromeFetch.ts. Function declarations are hoisted, so both accessors are available here.
99
185
  setBrowserAccessors({ getBrowserInstance, minimizeBrowserWindow });
@@ -102,9 +188,12 @@ setBrowserAccessors({ getBrowserInstance, minimizeBrowserWindow });
102
188
  */
103
189
  export async function emitCurrentSystemStatus() {
104
190
  let pageCount = 0;
191
+ // The published browser, or null when the supervisor is not in its ready state. Connectivity and page count derive from it - there is no separate browser
192
+ // reference to consult.
193
+ const browser = supervisor.current();
105
194
  try {
106
- if (currentBrowser?.connected) {
107
- const pages = await currentBrowser.pages();
195
+ if (browser?.connected) {
196
+ const pages = await browser.pages();
108
197
  pageCount = pages.length;
109
198
  }
110
199
  }
@@ -114,7 +203,7 @@ export async function emitCurrentSystemStatus() {
114
203
  const memUsage = process.memoryUsage();
115
204
  const status = {
116
205
  browser: {
117
- connected: !!currentBrowser && currentBrowser.connected,
206
+ connected: !!browser && browser.connected,
118
207
  pageCount
119
208
  },
120
209
  memory: {
@@ -136,14 +225,20 @@ export async function emitCurrentSystemStatus() {
136
225
  * Each registered page receives a unique ID that persists for the page's lifetime. This ID is used for comparison and staleness tracking, avoiding potential
137
226
  * issues with Page object reference identity.
138
227
  * @param page - The Puppeteer Page to register.
228
+ * @param options - Registration options. Set inFlightSetup when stream setup owns the page but has not yet recorded that ownership in the stream registry, so the
229
+ * stale page cleanup never closes a page whose stream is still being established.
139
230
  */
140
- export function registerManagedPage(page) {
231
+ export function registerManagedPage(page, options = {}) {
141
232
  // Generate a unique ID for this page.
142
233
  const pageId = "page-" + String(++managedPageIdCounter);
143
234
  // Associate the Page object with its ID.
144
235
  pageToId.set(page, pageId);
145
236
  // Track the ID as managed.
146
237
  managedPageIds.add(pageId);
238
+ // Exempt the page from staleness for as long as its stream setup is in flight.
239
+ if (options.inFlightSetup) {
240
+ inFlightSetupPageIds.add(pageId);
241
+ }
147
242
  }
148
243
  /**
149
244
  * Unregisters a page from PrismCast's management. This should be called when a page is being closed intentionally (during stream cleanup). Unregistering prevents the
@@ -156,6 +251,8 @@ export function unregisterManagedPage(page) {
156
251
  managedPageIds.delete(pageId);
157
252
  // Also remove from potentially stale tracking since we're intentionally closing it.
158
253
  potentiallyStalePages.delete(pageId);
254
+ // Release any in-flight setup mark. The page is leaving our management, so the exemption it carried has nothing left to protect.
255
+ inFlightSetupPageIds.delete(pageId);
159
256
  // Note: We don't delete from pageToId because WeakMap handles cleanup automatically when the Page is garbage collected.
160
257
  }
161
258
  }
@@ -167,6 +264,18 @@ export function unregisterManagedPage(page) {
167
264
  function getManagedPageId(page) {
168
265
  return pageToId.get(page);
169
266
  }
267
+ /**
268
+ * Discards every page-tracking collection. The ids they hold are scoped to one browser session's pages, so they mean nothing once that session ends and would
269
+ * otherwise carry into the next one - which matters most for the scheduled restart, where the process lives on across the swap. Every path that ends a PUBLISHED
270
+ * browser session routes through here, which is why the collections are cleared in one place rather than at each of those paths. A launch that is superseded
271
+ * before it is ever published does not, and needs no clear: it never held stream pages, and its readiness-gate probe pages are released at their own registration
272
+ * sites. The WeakMap needs no clear - it releases its entries when the Page objects are collected.
273
+ */
274
+ function clearPageTracking() {
275
+ managedPageIds.clear();
276
+ potentiallyStalePages.clear();
277
+ inFlightSetupPageIds.clear();
278
+ }
170
279
  /**
171
280
  * Ensures the data directory exists, creating it if necessary. This should be called during application startup before any operations that depend on the data
172
281
  * directory (like browser launch or extension preparation).
@@ -244,9 +353,8 @@ function syncSleep(ms) {
244
353
  * rely on an in-memory flag; the parent-child relationship in the OS process table IS the ownership proof, which means this function is safe to call from any
245
354
  * code path, including from a rejected-duplicate startup's exit handler.
246
355
  *
247
- * Why ownership matters. A duplicate PrismCast that the instance guard rejects must NOT signal Chrome that belongs to the legitimate holder. With PID-based
248
- * cleanup the only defense was an in-memory ownsChromeCleanup flag set at startup. With process-table discovery, the OS itself tells us who owns what: a Chrome
249
- * whose ppid is a live unrelated PID belongs to that parent, not to us.
356
+ * Why ownership matters. A duplicate PrismCast that the instance guard rejects must NOT signal Chrome that belongs to the legitimate holder. Process-table
357
+ * discovery lets the OS itself tell us who owns what: a Chrome whose ppid is a live unrelated PID belongs to that parent, not to us.
250
358
  * @param processes - The current process table snapshot.
251
359
  * @param profileDir - The Chrome user-data-dir to match against.
252
360
  * @param ownPid - The current process's PID (anything whose parent is us is ours to kill).
@@ -299,9 +407,6 @@ export function killStaleChrome() {
299
407
  // databases, poisoning the Docker volume for subsequent restarts.
300
408
  process.kill(pid, "SIGTERM");
301
409
  LOG.debug("browser:lifecycle", "Sent SIGTERM to Chrome process %d.", pid);
302
- // Wait up to 5 seconds for Chrome to flush its databases and exit after SIGTERM. Containerized environments with software rendering and shared CPU may
303
- // need the full window.
304
- const TERM_WAIT_MS = 5000;
305
410
  if (!waitForChromeExit(pid, TERM_WAIT_MS, POLL_INTERVAL_MS)) {
306
411
  // SIGTERM didn't work. Escalate to SIGKILL. Orphaned Chrome processes (from a crashed parent or previous container) may not respond to SIGTERM.
307
412
  LOG.debug("browser:lifecycle", "Chrome did not exit after SIGTERM. Escalating to SIGKILL.");
@@ -311,7 +416,6 @@ export function killStaleChrome() {
311
416
  catch (_error) {
312
417
  // ESRCH - Chrome exited between the poll check and the kill call.
313
418
  }
314
- const KILL_WAIT_MS = 2000;
315
419
  if (!waitForChromeExit(pid, KILL_WAIT_MS, POLL_INTERVAL_MS)) {
316
420
  LOG.warn("Chrome process %d did not exit after %dms of signal escalation. Proceeding anyway.", pid, TERM_WAIT_MS + KILL_WAIT_MS);
317
421
  }
@@ -403,8 +507,9 @@ export function getExecutablePath() {
403
507
  throw new Error("No Chrome installation found. Set CHROME_BIN environment variable.");
404
508
  }
405
509
  /* Chrome extension ID for puppeteer-stream's bundled capture extension. This is the deterministic ID Chrome assigns based on the extension's public key, mirrored
406
- * from their dist at node_modules/puppeteer-stream/dist/PuppeteerStream.js (the extensionId constant). We use it below in --allowlisted-extension-id to work around
407
- * the broken activeTab grant path introduced in puppeteer-stream 3.0.22...see the "--allowlisted-extension-id" entry in buildLaunchOptions for the full rationale.
510
+ * from their dist at node_modules/puppeteer-stream/dist/PuppeteerStream.js (the extensionId constant). We pass it below via --allowlisted-extension-id as a
511
+ * defensive duplicate of the flag puppeteer-stream's own launch() call already re-adds; see the "--allowlisted-extension-id" entry in buildLaunchOptions for
512
+ * the full rationale.
408
513
  */
409
514
  const PUPPETEER_STREAM_EXTENSION_ID = "jjndjgheafjngoipoacpjgeicjeomjli";
410
515
  /**
@@ -423,11 +528,12 @@ export function buildLaunchOptions() {
423
528
  * --allow-running-insecure-content: Some streaming sites serve mixed HTTP/HTTPS content. Without this flag, the browser blocks HTTP resources on HTTPS
424
529
  * pages, which can break video players that load some assets over HTTP.
425
530
  *
426
- * --allowlisted-extension-id=<extension-id>: Restores Chrome's global allowlist for puppeteer-stream's capture extension. puppeteer-stream 3.0.22 stopped
427
- * injecting this flag in favor of granting activeTab via a synthetic Cmd/Ctrl+Shift+Y keystroke, but CDP-synthesized keystrokes do not satisfy
428
- * chrome.commands under automation (the renderer sees the event but the browser-process accelerator dispatcher does not), so the new flow fails to grant
429
- * activeTab and capture is denied at the API level (see github.com/Flam3rboy/puppeteer-stream/issues/206). Re-emitting the flag ourselves bypasses the
430
- * broken activeTab path. Remove this entry and the PUPPETEER_STREAM_EXTENSION_ID constant when issue #206 closes with a real fix.
531
+ * --allowlisted-extension-id=<extension-id>: Restores Chrome's global allowlist for puppeteer-stream's capture extension. puppeteer-stream's own launch()
532
+ * call already re-adds this exact flag (see the addToArgs call for extensionId in node_modules/puppeteer-stream/dist/PuppeteerStream.js), so this entry
533
+ * is a defensive duplicate: if a future puppeteer-stream release drops the flag again in favor of granting activeTab via a synthetic keystroke,
534
+ * CDP-synthesized keystrokes do not satisfy chrome.commands under automation (the renderer sees the event but the browser-process accelerator dispatcher
535
+ * does not), so capture would be denied at the API level (see github.com/Flam3rboy/puppeteer-stream/issues/206) without this fallback in place. Confirm
536
+ * whether this duplicate is still warranted whenever the pinned puppeteer-stream version changes.
431
537
  *
432
538
  * --autoplay-policy=no-user-gesture-required: Allows video and audio to play without requiring a user click first. Essential for automated streaming
433
539
  * since we cannot simulate genuine user interaction for autoplay policy purposes.
@@ -522,9 +628,11 @@ export function buildLaunchOptions() {
522
628
  * @returns The launched browser instance.
523
629
  */
524
630
  async function launchWithCustomArgs(opts) {
525
- // When running as a packaged executable (process.pkg is set by the pkg bundler), we need to replace the extension paths. The puppeteer-stream library adds
526
- // --load-extension and --disable-extensions-except arguments pointing to node_modules, but these paths don't exist in the packaged executable. We replace
527
- // them with paths to our extracted extension files.
631
+ // When running as a packaged executable (process.pkg is set by the pkg bundler), we need to replace the extension paths. puppeteer-stream points
632
+ // opts.enableExtensions at its own node_modules-relative extension directory, which puppeteer-core installs via a CDP browser.installExtension() call after
633
+ // Chrome starts, rather than via --load-extension/--disable-extensions-except CLI flags - so that path still resolves inside node_modules, which does not
634
+ // exist at that location in the packaged executable. We route around it with Chrome's own native unpacked-extension flags, pointing them at our extracted
635
+ // extension files instead.
528
636
  if (process.pkg) {
529
637
  const extensionPath = getExtensionDir(CONFIG);
530
638
  // Remove any existing extension arguments and add our own pointing to the extracted extension.
@@ -557,11 +665,13 @@ function formatGpuSuffix(gpu) {
557
665
  return " (software rendering)";
558
666
  }
559
667
  /**
560
- * Detects the maximum supported viewport dimensions based on the user's display. This function measures the available screen space and subtracts browser chrome to
561
- * determine the largest viewport we can use for video capture.
668
+ * Detects the maximum supported viewport dimensions based on the user's display, and probes GPU hardware-encoding capabilities. The viewport half measures the
669
+ * available screen space and subtracts browser chrome to determine the largest viewport we can use for video capture; the GPU half queries CDP
670
+ * SystemInfo.getInfo for renderer identity and H.264/HEVC/AV1 hardware encoding support, falling back to a MediaRecorder capability probe where the CDP data
671
+ * is incomplete.
562
672
  *
563
- * The detection uses a temporary page (or existing page if available) to evaluate screen dimensions via JavaScript. The result is cached in the display module for
564
- * use by the preset system when determining effective viewport.
673
+ * The detection uses a temporary page (or existing page if available) to evaluate screen dimensions and GPU capabilities via JavaScript and CDP. Both results
674
+ * are cached in the display module for use by the preset system when determining effective viewport and capture codec.
565
675
  * @param browser - The browser instance to use for detection.
566
676
  */
567
677
  async function detectDisplayDimensions(browser) {
@@ -715,144 +825,176 @@ async function detectDisplayDimensions(browser) {
715
825
  }
716
826
  }
717
827
  /**
718
- * Handles browser disconnection events by cleaning up all active streams and resetting browser state. This function is called when the browser crashes, is closed
719
- * manually, or loses its connection for any other reason.
720
- *
721
- * When the browser disconnects, all pages (tabs) within it are immediately invalid and cannot be used. Any active streams using those pages will fail if they try
722
- * to interact with them. We proactively clean up by:
723
- * 1. Setting currentBrowser to null so the next stream request will launch a fresh browser
724
- * 2. Stopping all health monitors (they would fail trying to check page state)
725
- * 3. Removing all entries from activeStreams (the pages are gone)
726
- *
727
- * This ensures streams fail gracefully rather than hanging indefinitely trying to use closed pages.
828
+ * Relinquishes the current browser's capture readiness and tears down everything that depended on it. This is the single source of truth for "the published browser
829
+ * is no longer usable" - shared by the disconnect handler (the browser crashed) and by invalidateBrowser (the browser is alive but capture-dead). It drops the
830
+ * supervisor's readiness first (which supersedes any launch in flight, clears the governor's health anchor, and transitions the lifecycle to absent so the next
831
+ * request relaunches through the gate and governor), clears the adapter-held metadata and caches, ends login mode, terminates every active stream (they were
832
+ * capturing on the now-unusable browser), and emits status. Callers log the specific cause; the alive-but-dead caller additionally closes the Chrome instance.
833
+ * @param streamTerminationReason - The reason recorded against each terminated stream, for the stream-end logs.
728
834
  */
729
- function handleBrowserDisconnect() {
730
- // Clear the browser reference, launch timestamp, cached version, and user agent so getCurrentBrowser() will launch a new instance on the next call. We do not
731
- // track Chrome's PID directly anymore - killStaleChrome discovers orphans via the OS process table on the next startup, which removes a class of state that
732
- // could go stale.
733
- currentBrowser = null;
734
- browserLaunchTime = null;
835
+ function relinquishBrowserReadiness(streamTerminationReason) {
836
+ // Drop readiness first: supersede any in-flight launch, clear the governor's health anchor, and move the lifecycle to absent.
837
+ supervisor.noteReadinessLost();
838
+ // Clear the adapter-held Chrome version and the cached user agent so stale values are not served before the next ready browser. We do not track Chrome's PID
839
+ // directly - killStaleChrome discovers orphans via the OS process table on the next startup, which removes a class of state that could go stale.
735
840
  currentChromeVersion = null;
736
841
  setChromeUserAgent(null);
737
- // Cancel any pending restart quiet timer since the browser is already gone.
842
+ // Cancel any pending scheduled-restart quiet timer since the browser this readiness applied to is gone.
738
843
  if (restartQuietTimer) {
739
844
  clearTimeout(restartQuietTimer);
740
845
  restartQuietTimer = null;
741
846
  }
742
- // Clear all channel selection caches. Cached state (guide row positions, discovered page URLs) may be stale in a new browser session.
847
+ // Clear all channel selection caches. Cached state (guide row positions, discovered page URLs) belongs to the old browser session.
743
848
  clearChannelSelectionCaches();
744
- // Clear login state if login mode was active. We use clearLoginState() rather than endLoginMode() because the browser is already gone and we don't want to
745
- // attempt any browser operations (page close, window minimize).
849
+ // End login mode if it was active. We use clearLoginState() rather than endLoginMode() because the browser may already be gone and we do not want to attempt any
850
+ // browser operations (page close, window minimize).
746
851
  if (clearLoginState() && !gracefulShutdownInProgress) {
747
- LOG.info("Login mode ended due to browser disconnect.");
852
+ LOG.info("Login mode ended due to browser readiness loss.");
748
853
  }
749
- // Only log the error for unexpected disconnects. During graceful shutdown, closeBrowser() set the flag and this disconnect is intentional.
750
- if (!gracefulShutdownInProgress) {
751
- LOG.error("Browser disconnected unexpectedly. All active streams will be terminated.");
752
- }
753
- // Clean up all active streams using the authoritative terminateStream function for consistent cleanup. This is kept even during graceful shutdown as a defensive
754
- // measure - terminateStream() is idempotent, so if streams were already terminated by the caller, this harmlessly iterates an empty array.
755
- const streams = getAllStreams();
756
- for (const streamInfo of streams) {
757
- terminateStream(streamInfo.id, streamInfo.info.storeKey, "browser disconnect");
854
+ // Terminate every active stream using the authoritative terminateStream for consistent cleanup. Kept even during graceful shutdown as a defensive measure -
855
+ // terminateStream() is safe to call more than once, so if streams were already terminated by the caller, this harmlessly iterates an empty array.
856
+ for (const streamInfo of getAllStreams()) {
857
+ terminateStream(streamInfo.id, streamInfo.info.storeKey, streamTerminationReason);
758
858
  }
859
+ // The session those streams captured on is over, so whatever page tracking survived their termination belongs to a browser that is gone.
860
+ clearPageTracking();
759
861
  // Emit system status after stream cleanup. Skip during graceful shutdown since no clients are listening and the process is exiting.
760
862
  if (!gracefulShutdownInProgress) {
761
863
  void emitCurrentSystemStatus();
762
864
  }
763
865
  }
764
866
  /**
765
- * Provides access to the shared browser instance, launching one if needed. The browser is a shared resource used by all streaming sessions. This function handles:
766
- *
767
- * - Returning the existing browser if it's still connected
768
- * - Launching a new browser if none exists or the previous one disconnected
769
- * - Serializing concurrent callers so only one launch occurs at a time
770
- * - Waiting for the puppeteer-stream extension to initialize
771
- * - Setting up disconnect handlers for crash recovery
772
- * @returns The browser instance.
773
- * @throws If the browser cannot be launched.
867
+ * Handles browser disconnection events by relinquishing readiness and terminating all active streams. Called when the browser crashes, is closed externally, or
868
+ * otherwise loses its connection. It runs only for a genuine, unsolicited disconnect: every intentional teardown removes this listener via closeBrowserInstance
869
+ * first, so a scheduled restart, an orphan close, or an invalidation never reaches here. The browser is already gone, so there is nothing to close - relinquish
870
+ * readiness and the next request relaunches a fresh, gate-verified browser.
774
871
  */
775
- export async function getCurrentBrowser() {
776
- // Fast path: if we have a browser and it's still connected, return it immediately. The connected property verifies the DevTools Protocol connection is
777
- // still alive.
778
- if (currentBrowser?.connected) {
779
- return currentBrowser;
780
- }
781
- // If a launch is already in progress (e.g., from the restart path or a concurrent stream request), piggyback on that promise instead of starting a second
782
- // Chrome process. Two concurrent launches with the same profile directory would contend on Chrome's profile lock.
783
- if (browserLaunchPromise) {
784
- return browserLaunchPromise;
785
- }
786
- // We need to launch a new browser. Store the promise so concurrent callers can piggyback on this launch.
787
- browserLaunchPromise = launchBrowser();
788
- try {
789
- return await browserLaunchPromise;
872
+ function handleBrowserDisconnect() {
873
+ // Announce the unexpected disconnect before tearing down (the message says streams will be terminated, which relinquish then does). Suppressed during a full
874
+ // server shutdown, where closeBrowser() set the flag and the disconnect is intentional.
875
+ if (!gracefulShutdownInProgress) {
876
+ LOG.error("Browser disconnected unexpectedly. All active streams will be terminated.");
790
877
  }
791
- finally {
792
- browserLaunchPromise = null;
878
+ relinquishBrowserReadiness("browser disconnect");
879
+ }
880
+ /**
881
+ * Invalidates a specific browser that is still connected but can no longer capture - a mid-life capture death that no "disconnected" event would surface. This is the
882
+ * single recovery action generalized to the alive-but-incapable case: relinquish readiness and terminate the browser's streams (exactly as a disconnect does),
883
+ * then additionally close the still-running Chrome so a leaked process is not left behind. The lifecycle is already absent after relinquish, so the next request
884
+ * relaunches a fresh, gate-verified browser through the supervisor. Exported for the streaming layer's passive mid-life detector to call once its probe confirms the
885
+ * browser cannot capture. noteReadinessLost stays internal: callers signal intent through this function, never the supervisor directly.
886
+ *
887
+ * The caller passes the exact instance it verified, and we invalidate only if it is still the published browser. The detector's probe runs in the background for
888
+ * seconds, during which the verified browser could have disconnected and been replaced by a fresh relaunch; without this identity guard we would tear down that
889
+ * new, healthy browser on the strength of a probe against the old, dead one.
890
+ * @param browser - The specific browser instance the caller verified as unable to capture.
891
+ * @param reason - A short description of why the browser is being invalidated, for the alarm log.
892
+ */
893
+ export async function invalidateBrowser(browser, reason) {
894
+ // Only invalidate if this is still the published browser. If it was already superseded (a disconnect plus relaunch raced the caller's probe), there is nothing to
895
+ // do - the readiness loss was handled, and tearing down the current browser would wrongly disrupt a healthy, freshly-relaunched one.
896
+ if (supervisor.current() !== browser) {
897
+ return;
793
898
  }
899
+ LOG.error("The browser is connected but can no longer capture (%s). Invalidating it for a governed relaunch.", reason);
900
+ relinquishBrowserReadiness("capture system failure");
901
+ // Readiness was relinquished first, because that is what supersedes an in-flight launch; publishing the teardown synchronously, before any await, then keeps the
902
+ // launch window shut for the whole drain so nothing spawns a second Chrome against the profile lock this one still holds.
903
+ const teardown = closeBrowserInstance(browser);
904
+ supervisor.noteTeardownBegun(teardown, BROWSER_TEARDOWN_DRAIN_BOUND_MS);
905
+ await teardown;
794
906
  }
795
907
  /**
796
- * Launches a new browser instance and performs post-launch initialization (extension readiness, display detection, version capture). This is the inner launch
797
- * function called by getCurrentBrowser() and serialized by the browserLaunchPromise mutex.
798
- * @returns The browser instance.
799
- * @throws If the browser cannot be launched.
908
+ * Provides access to the capture-ready browser, launching one if needed. This is the single gated entry point for all browser access: it delegates to the
909
+ * supervisor's acquire(), which returns the ready browser, joins an in-flight launch (single-flight, so concurrent callers never contend on Chrome's profile lock),
910
+ * lazily launches when absent, or - while the relaunch governor is cooling after repeated failures - rejects fast with a BrowserUnavailableError WITHOUT spawning
911
+ * Chrome (the loop bound). The launch it drives runs the readiness gate, so a returned browser is verified capture-ready, not merely connected.
912
+ * @returns The capture-ready browser instance.
913
+ * @throws BrowserUnavailableError while the governor is cooling, BrowserSupersededError if an in-flight launch was abandoned by a readiness-loss, or the underlying
914
+ * launch error when a launch attempt fails.
800
915
  */
801
- async function launchBrowser() {
916
+ export async function getCurrentBrowser() {
917
+ return supervisor.acquire();
918
+ }
919
+ /**
920
+ * The supervisor's `launch` port: spawns Chrome, runs the readiness gate, performs post-launch initialization (display detection, version/UA capture, precaching),
921
+ * and resolves ONLY with a capture-ready browser. It builds into a local instance and publishes nothing - the supervisor owns publication and transitions to
922
+ * "ready" only after this resolves. A launch that fails the gate tears down its own Chrome here and throws, so a broken instance is never handed up; the supervisor
923
+ * counts the failure and decides whether to relaunch immediately or cool down. The gate throws rather than logging a failed extension load as a warning and serving
924
+ * the broken browser anyway, so only a verified-capturing instance is ever published.
925
+ * @returns The capture-ready browser instance.
926
+ * @throws If the launch or the readiness gate fails.
927
+ */
928
+ async function launchReadyBrowser() {
802
929
  const browserElapsed = startTimer();
803
- // This happens on first stream request, after a browser crash, during server warmup, or during an opportunistic restart.
930
+ // The launch function from puppeteer-stream wraps standard Puppeteer launch to inject the streaming extension. We pass our custom launch function that handles
931
+ // packaged-executable extension paths. This happens on first stream request, after a browser crash, during server warmup, or during a governed relaunch.
932
+ const browser = await launch({ launch: launchWithCustomArgs }, buildLaunchOptions());
804
933
  try {
805
- const options = buildLaunchOptions();
806
- // The launch function from puppeteer-stream wraps standard Puppeteer launch to inject the streaming extension. We pass our custom launch function that
807
- // handles packaged executable extension paths.
808
- currentBrowser = await launch({ launch: launchWithCustomArgs }, options);
809
- // Register a handler for browser disconnection. This ensures we clean up properly if the browser crashes or is closed unexpectedly.
810
- currentBrowser.on("disconnected", handleBrowserDisconnect);
811
934
  LOG.debug("timing:browser", "Chrome process spawned. (+%sms)", browserElapsed());
812
- // Poll for the puppeteer-stream extension to finish initializing. The extension injects a START_RECORDING function into its options page context. We poll
813
- // for this function's existence rather than using a fixed delay, so the browser is ready as soon as the extension loads - typically 200-500ms rather than the
814
- // full configured timeout. Uses getExtensionPage() from puppeteer-stream to locate the extension's options page.
935
+ // Readiness gate, handshake tier (cheap, on-suspicion). Poll for the puppeteer-stream extension to finish initializing - it injects a START_RECORDING function
936
+ // into its options page context, so its presence is the extension's own readiness signal. We poll rather than fixed-delay so the browser is ready as soon as the
937
+ // extension loads (typically 200-500ms). On failure this THROWS rather than warning-and-proceeding: an unregistered extension means chrome.tabs is undefined and
938
+ // every getStream() would hang, so the instance is not capture-ready and must not be published. We reclassify the raw waitForFunction timeout into a
939
+ // capture-infrastructure error carrying "timed out" so the setup layer maps it to a 503 back-off (the same as the capability-tier probe failure), rather than a
940
+ // 500 the client would not back off from - an unregistered extension is a capture-infrastructure fault, and a fresh relaunch usually clears it.
815
941
  try {
816
- const extensionPage = await getExtensionPage(currentBrowser);
942
+ const extensionPage = await getExtensionPage(browser);
817
943
  await extensionPage.waitForFunction("typeof START_RECORDING === 'function'", { timeout: CONFIG.browser.initTimeout });
818
944
  }
819
- catch {
820
- // If the extension page isn't found or START_RECORDING doesn't appear within the timeout, log a warning and proceed. The per-stream
821
- // assertExtensionLoaded() in puppeteer-stream will retry before each capture attempt, so this isn't fatal.
822
- LOG.warn("Extension did not initialize within %d ms. Streams may need additional time to start.", CONFIG.browser.initTimeout);
945
+ catch (handshakeError) {
946
+ throw new Error("The capture extension handshake timed out after " + String(CONFIG.browser.initTimeout) + " ms.", { cause: handshakeError });
823
947
  }
824
948
  LOG.debug("timing:browser", "Extension initialized. (+%sms)", browserElapsed());
825
- // Detect display dimensions to determine maximum supported viewport. This must happen before we start streaming so the preset system can degrade to a
826
- // smaller preset if needed.
827
- await detectDisplayDimensions(currentBrowser);
949
+ // Readiness gate, capability tier (the authoritative arbiter). Run the injected capture probe - a real getStream against a throwaway page on THIS instance - so
950
+ // "ready" means "really captured," not merely "the extension handshake responded." This predicate must run at every (re)launch:
951
+ // it exercises the exact getStream path that hangs when the extension is unregistered. A probe failure throws, so the supervisor counts the launch failure and the
952
+ // browser is never published; the unrecoverable stale-mutex case exits the process from inside the probe, since a Chrome restart cannot fix a leaked module mutex.
953
+ //
954
+ // If the probe is not wired (the injection point left unset by a refactor - impossible in the normal import order, which always wires it before any launch),
955
+ // we reject the launch rather than publish a handshake-only browser: serving an unverified browser would be the "proceed and hope" path this design
956
+ // eliminates. The supervisor counts the rejected launch and, on repetition, degrades loudly.
957
+ if (!captureProbe) {
958
+ throw new Error("The capture-readiness probe is not wired; refusing to publish a browser whose capture capability was not verified.");
959
+ }
960
+ await captureProbe(browser);
961
+ LOG.debug("timing:browser", "Capture probe complete. (+%sms)", browserElapsed());
962
+ // Detect display dimensions to determine the maximum supported viewport. This must happen before streaming so the preset system can degrade to a smaller preset
963
+ // if needed.
964
+ await detectDisplayDimensions(browser);
828
965
  LOG.debug("timing:browser", "Display detection complete. (+%sms)", browserElapsed());
829
- // Log the Chrome version for diagnostic reference. This helps correlate browser behavior changes (tab unresponsiveness, memory pressure, capture issues)
830
- // with specific Chrome releases. We also capture the User-Agent string so that server-side fetch() calls to service CDNs can match Chrome's identity.
831
- const chromeVersion = await currentBrowser.version();
832
- const userAgent = await currentBrowser.userAgent();
833
- browserLaunchTime = Date.now();
966
+ // Capture the Chrome version and User-Agent. The version is logged for diagnostics (correlating browser behavior changes with specific Chrome releases) and
967
+ // surfaced by the health endpoint; the User-Agent lets server-side fetch() calls to service CDNs match Chrome's identity.
968
+ const chromeVersion = await browser.version();
969
+ const userAgent = await browser.userAgent();
834
970
  currentChromeVersion = chromeVersion;
835
971
  setChromeUserAgent(userAgent);
836
972
  const gpu = getGpuCapabilities();
837
973
  const gpuSuffix = gpu ? formatGpuSuffix(gpu) : "";
838
974
  LOG.info("Chrome ready: %s%s.", chromeVersion, gpuSuffix);
839
975
  LOG.debug("timing:browser", "Browser ready. Total: %sms.", browserElapsed());
840
- // Emit system status update for SSE subscribers.
841
- await emitCurrentSystemStatus();
842
- // Start background precaching of selected service channel lineups. Fire-and-forget - the setTimeout inside startPrecaching() ensures the actual work is fully
843
- // async and non-blocking.
976
+ // Start background precaching of selected service channel lineups. Fire-and-forget - the setTimeout inside startPrecaching() defers the work until after this
977
+ // launch settles and the supervisor has published the ready browser, so its getCurrentBrowser() resolves immediately rather than re-entering this launch.
844
978
  startPrecaching();
979
+ // Arm the disconnect handler only now, as the very last step before the supervisor publishes this browser as ready. It is deliberately NOT armed earlier: during
980
+ // the gate/init window above, a Chrome crash surfaces as a thrown init step (CDP and waitForFunction reject on a dead browser), which the supervisor counts as a
981
+ // launch failure and feeds to the governor - keeping the relaunch loop bounded even if Chrome dies repeatedly during init. Arming the handler earlier would let
982
+ // its noteReadinessLost() bump the supervisor's launch generation and the launch would be treated as superseded (uncounted), defeating the loop bound. There is
983
+ // no gap: every statement from the last await to this return is synchronous, so a disconnect cannot be delivered between this registration and publication.
984
+ browser.on("disconnected", handleBrowserDisconnect);
985
+ return browser;
845
986
  }
846
987
  catch (error) {
847
988
  LOG.error("Failed to launch browser: %s.", formatError(error));
848
- // Clear the browser reference, launch timestamp, cached version, and user agent on failure so the next call will attempt to launch again.
849
- currentBrowser = null;
850
- browserLaunchTime = null;
989
+ // The gate (or post-launch init) failed. Clear the adapter-held metadata and tear down the Chrome instance we just spawned before propagating, so a failed
990
+ // launch never leaks a process and the next governed relaunch starts from a clean profile. The disconnect handler is not yet armed on this instance (it is armed
991
+ // only on the success path above), so this teardown never re-enters handleBrowserDisconnect - which is exactly what lets the supervisor count this as a launch
992
+ // failure rather than a supersession.
851
993
  currentChromeVersion = null;
852
994
  setChromeUserAgent(null);
995
+ await closeBrowserInstance(browser);
853
996
  throw error;
854
997
  }
855
- return currentBrowser;
856
998
  }
857
999
  /**
858
1000
  * Returns the Chrome version string captured when the browser launched, or null if the browser is not connected.
@@ -867,14 +1009,15 @@ export function getChromeVersion() {
867
1009
  * @returns The browser instance, or null if not running.
868
1010
  */
869
1011
  export function getBrowserInstance() {
870
- return currentBrowser;
1012
+ return supervisor.current();
871
1013
  }
872
1014
  /**
873
1015
  * Checks if the browser is currently connected and usable. This is a synchronous check that can be used before attempting browser operations.
874
1016
  * @returns True if the browser is connected and ready for use, false otherwise.
875
1017
  */
876
1018
  export function isBrowserConnected() {
877
- return !!currentBrowser && currentBrowser.connected;
1019
+ const browser = supervisor.current();
1020
+ return !!browser && browser.connected;
878
1021
  }
879
1022
  /**
880
1023
  * Resizes the browser window to the effective viewport and minimizes it. This function combines viewport sizing with minimization to ensure the window is
@@ -886,18 +1029,19 @@ export function isBrowserConnected() {
886
1029
  */
887
1030
  export async function minimizeBrowserWindow() {
888
1031
  // Guard against calling this when no browser is running.
889
- if (!currentBrowser?.connected) {
1032
+ const browser = supervisor.current();
1033
+ if (!browser?.connected) {
890
1034
  return;
891
1035
  }
892
1036
  let tempPage = null;
893
1037
  let usingTempPage = false;
894
1038
  try {
895
1039
  // Try to use an existing page first. Creating a new page can cause the window to restore/activate on macOS, which defeats the purpose of minimizing.
896
- const existingPages = await currentBrowser.pages();
1040
+ const existingPages = await browser.pages();
897
1041
  let targetPage = existingPages.find((p) => !p.isClosed()) ?? null;
898
1042
  // If no existing pages, we must create a temporary one. This is less ideal but necessary to get a CDP session target.
899
1043
  if (!targetPage) {
900
- tempPage = await currentBrowser.newPage();
1044
+ tempPage = await browser.newPage();
901
1045
  usingTempPage = true;
902
1046
  // Register the temp page so stale cleanup knows it's ours.
903
1047
  registerManagedPage(tempPage);
@@ -928,16 +1072,17 @@ export async function minimizeBrowserWindow() {
928
1072
  }
929
1073
  }
930
1074
  /**
931
- * Gets all open browser pages (tabs). This is used by the health check endpoint to report page count and by stale page cleanup to find orphaned pages.
1075
+ * Gets all open browser pages (tabs). This is used by the health check endpoint to report page count.
932
1076
  * @returns Array of pages, or empty array if the browser is not connected.
933
1077
  */
934
1078
  export async function getBrowserPages() {
935
- // Guard against calling this when no browser is running.
936
- if (!currentBrowser?.connected) {
1079
+ // Guard against calling this when no ready browser is running.
1080
+ const browser = supervisor.current();
1081
+ if (!browser?.connected) {
937
1082
  return [];
938
1083
  }
939
1084
  try {
940
- return await currentBrowser.pages();
1085
+ return await browser.pages();
941
1086
  }
942
1087
  catch (_error) {
943
1088
  // If getting pages fails (browser disconnecting, etc.), return empty array rather than throwing.
@@ -945,67 +1090,87 @@ export async function getBrowserPages() {
945
1090
  }
946
1091
  }
947
1092
  /**
948
- * Closes the browser and cleans up resources. This is called during graceful shutdown to ensure Chrome exits cleanly. After this call, the browser reference is
949
- * cleared and any subsequent stream requests will launch a fresh browser.
1093
+ * Tears down a specific Chrome instance and is the single teardown primitive: the supervisor's `close` port (for disposing an orphaned superseded launch), the
1094
+ * launch-failure cleanup in launchReadyBrowser, the scheduled-restart teardown in executeBrowserRestart, and the full-server closeBrowser all route through it. It
1095
+ * owns no lifecycle state - the supervisor is the single source of truth for that. It first removes the disconnect listener so this intentional teardown does not
1096
+ * trip handleBrowserDisconnect: the SIGTERM-induced "disconnected" event can arrive after this function returns, and without the removal it could supersede a fresh
1097
+ * launch the caller has already started (the late-disconnect race).
950
1098
  *
951
1099
  * Chrome termination uses Puppeteer's ChildProcess handle and its `exit` event for detection:
952
1100
  *
953
- * - browserRef.close() sends CDP Browser.close and waits for WebSocket teardown, which hangs 3-5 seconds even after Chrome exits.
954
- * - browserRef.disconnect() drops the WebSocket instantly but orphans Chrome as a Node child process, creating a zombie that process.kill(pid, 0) cannot detect.
1101
+ * - browser.close() sends CDP Browser.close and waits for WebSocket teardown, which hangs 3-5 seconds even after Chrome exits.
1102
+ * - browser.disconnect() drops the WebSocket instantly but orphans Chrome as a Node child process, creating a zombie that process.kill(pid, 0) cannot detect.
955
1103
  * - Synchronous polling (Atomics.wait) blocks the event loop, preventing Node from processing SIGCHLD to reap the child - Chrome becomes a zombie regardless
956
1104
  * of how SIGTERM was sent.
957
1105
  *
958
- * Instead, we send SIGTERM through the ChildProcess handle and listen for the `exit` event. This keeps the event loop running so Node can process SIGCHLD and
959
- * reap Chrome properly. The exit event fires only after the process is fully reaped - no zombies, no polling, no event loop blocking.
1106
+ * Instead, we send SIGTERM through the ChildProcess handle and listen for the `exit` event. This keeps the event loop running so Node can process SIGCHLD and reap
1107
+ * Chrome properly. The exit event fires only after the process is fully reaped - no zombies, no polling, no event loop blocking. The await on the exit event is what
1108
+ * lets the caller relaunch immediately afterward without contending on Chrome's profile lock.
1109
+ * @param browser - The Chrome instance to terminate.
960
1110
  */
961
- export async function closeBrowser() {
962
- // Ensure the flag is set so the disconnect handler knows this is intentional. Normally set earlier by app.ts shutdown(), but set here as a fallback for direct
963
- // calls to closeBrowser().
964
- setGracefulShutdown(true);
965
- const browserRef = currentBrowser;
966
- // Clear the reference, launch timestamp, cached version, and user agent early to prevent any new operations from using it.
967
- currentBrowser = null;
968
- browserLaunchTime = null;
969
- currentChromeVersion = null;
970
- setChromeUserAgent(null);
971
- if (!browserRef) {
972
- return;
973
- }
974
- // Send SIGTERM through Puppeteer's ChildProcess handle and wait for the `exit` event. The ChildProcess handle is only available when Puppeteer launched
975
- // Chrome (not when connecting to an existing browser), but PrismCast always launches Chrome directly.
976
- const chromeProcess = browserRef.process();
1111
+ async function closeBrowserInstance(browser) {
1112
+ // Remove the disconnect handler before signalling. Every call here is an intentional teardown, so the resulting "disconnected" event must not invoke the
1113
+ // unexpected-disconnect handler - which would clear caches, log an error, and (critically) call noteReadinessLost(), superseding any launch the caller starts next.
1114
+ browser.off("disconnected", handleBrowserDisconnect);
1115
+ // Send SIGTERM through Puppeteer's ChildProcess handle and wait for the `exit` event. The ChildProcess handle is only available when Puppeteer launched Chrome
1116
+ // (not when connecting to an existing browser), but PrismCast always launches Chrome directly.
1117
+ const chromeProcess = browser.process();
977
1118
  if (chromeProcess?.pid && !chromeProcess.killed) {
978
- const TERM_WAIT_MS = 5000;
979
- const KILL_WAIT_MS = 2000;
980
- // Listen for the exit event before sending the signal. The event fires after the OS reaps the process, so there is no zombie window. Resolves to true so
981
- // Promise.race can distinguish exit from timeout.
1119
+ // Listen for the exit event before sending the signal. The event fires after the OS reaps the process, so there is no zombie window. The promise only ever
1120
+ // resolves, so a null from either bounded wait below means the exit never came.
982
1121
  const { promise: exitPromise, resolve: signalExit } = Promise.withResolvers();
983
1122
  chromeProcess.on("exit", () => { signalExit(true); });
984
1123
  chromeProcess.kill("SIGTERM");
985
1124
  LOG.debug("browser:lifecycle", "Sent SIGTERM to Chrome process %d.", chromeProcess.pid);
986
- // Wait for Chrome to exit after SIGTERM, with a timeout. If Chrome doesn't exit in time, escalate to SIGKILL.
987
- const termTimeout = cancellableTimeout(TERM_WAIT_MS);
988
- const exitedAfterTerm = await Promise.race([exitPromise, termTimeout.promise]);
989
- termTimeout.cancel();
1125
+ // Wait for Chrome to exit after SIGTERM, with a bound. If Chrome doesn't exit in time, escalate to SIGKILL.
1126
+ const exitedAfterTerm = await boundedWait(exitPromise, TERM_WAIT_MS);
990
1127
  if (!exitedAfterTerm) {
991
- // SIGTERM didn't work within the timeout. Escalate to SIGKILL. Orphaned Chrome processes (from a crashed parent or previous container) may not
1128
+ // SIGTERM didn't work within the bound. Escalate to SIGKILL. Orphaned Chrome processes (from a crashed parent or previous container) may not
992
1129
  // respond to SIGTERM.
993
1130
  LOG.debug("browser:lifecycle", "Chrome did not exit after SIGTERM. Escalating to SIGKILL.");
994
1131
  chromeProcess.kill("SIGKILL");
995
- const killTimeout = cancellableTimeout(KILL_WAIT_MS);
996
- await Promise.race([exitPromise, killTimeout.promise]);
997
- killTimeout.cancel();
1132
+ // The same exit promise serves the second wait: if it already resolved, this returns its value immediately.
1133
+ await boundedWait(exitPromise, KILL_WAIT_MS);
998
1134
  }
999
1135
  }
1000
- // Disconnect the Puppeteer WebSocket after Chrome has exited. This cleans up Puppeteer's internal state (event listeners, pending CDP calls) without
1001
- // waiting for the WebSocket close handshake to complete on a dead connection.
1002
- if (browserRef.connected) {
1003
- void browserRef.disconnect();
1136
+ // Disconnect the Puppeteer WebSocket after Chrome has exited. This cleans up Puppeteer's internal state (event listeners, pending CDP calls) without waiting for
1137
+ // the WebSocket close handshake to complete on a dead connection. We catch the rejection: disconnect() on a connection whose underlying transport already died of
1138
+ // an unclean Chrome exit can reject, and an unhandled rejection on this fire-and-forget call would crash the process during an otherwise-successful teardown.
1139
+ if (browser.connected) {
1140
+ browser.disconnect().catch((error) => {
1141
+ LOG.debug("browser:lifecycle", "Ignoring browser disconnect error during teardown: %s.", formatError(error));
1142
+ });
1004
1143
  }
1005
1144
  // Remove stale Chrome profile lock files left behind by the disconnected browser. No per-PID state to clear here - killStaleChrome on the next launch will
1006
1145
  // discover any leftover Chrome via the OS process table.
1007
1146
  cleanStaleProfileFiles(getChromeDataDir(CONFIG));
1008
1147
  }
1148
+ /**
1149
+ * Closes the browser and cleans up resources during full server shutdown. After this call the supervisor reports absent and any subsequent stream request launches
1150
+ * a fresh browser. It retires the current instance from the lifecycle (so an in-flight launch is superseded and the metadata is cleared), then delegates the actual
1151
+ * Chrome teardown to closeBrowserInstance. The graceful-shutdown flag is set so handleBrowserDisconnect, if it runs for any reason, stays quiet.
1152
+ */
1153
+ export async function closeBrowser() {
1154
+ // Ensure the flag is set so the disconnect handler stays quiet. Normally set earlier by app.ts shutdown(), but set here as a fallback for direct calls.
1155
+ setGracefulShutdown(true);
1156
+ // Capture the ready browser before retiring it from the lifecycle. noteReadinessLost() supersedes any launch in flight and transitions to absent; we then clear
1157
+ // the adapter-held metadata so nothing stale is served.
1158
+ const browser = supervisor.current();
1159
+ supervisor.noteReadinessLost();
1160
+ // The session is ending, so its page ids are spent. The call sits ahead of the early return below so the clear happens whether or not there was a browser to
1161
+ // close.
1162
+ clearPageTracking();
1163
+ currentChromeVersion = null;
1164
+ setChromeUserAgent(null);
1165
+ if (!browser) {
1166
+ return;
1167
+ }
1168
+ // Readiness was relinquished first, because that is what supersedes an in-flight launch; publishing the teardown synchronously, before any await, then keeps the
1169
+ // launch window shut for the whole drain so nothing spawns a second Chrome against the profile lock this one still holds.
1170
+ const teardown = closeBrowserInstance(browser);
1171
+ supervisor.noteTeardownBegun(teardown, BROWSER_TEARDOWN_DRAIN_BOUND_MS);
1172
+ await teardown;
1173
+ }
1009
1174
  /* Over time, browser pages (tabs) may accumulate if cleanup fails during stream termination. This can happen due to race conditions, errors during cleanup, or
1010
1175
  * edge cases in stream lifecycle management. Each orphaned page consumes memory and may continue running JavaScript, so we periodically clean them up.
1011
1176
  *
@@ -1021,6 +1186,12 @@ export async function closeBrowser() {
1021
1186
  * briefly untracked during stream initialization or cleanup.
1022
1187
  *
1023
1188
  * 4. Minimum page preservation: We always keep at least one page open to prevent Chrome from exiting.
1189
+ *
1190
+ * 5. In-flight setup exemption: Pages whose stream setup is still running (tracked in inFlightSetupPageIds) are never considered stale. The registry records a
1191
+ * stream's page only once setup completes, so without this a slow tune would have its own page closed out from under it.
1192
+ *
1193
+ * The safeguards are expressed as rules in browser/pageStaleness.ts, which decides from a snapshot what to close, track, forget, and unmark. This function is the
1194
+ * I/O shell around that decision: it reads Chrome's page list, applies the decision to the tracking collections, and performs the closes.
1024
1195
  */
1025
1196
  /**
1026
1197
  * Cleans up browser pages that are not associated with active streams. This function runs periodically to catch any pages that were not properly closed during
@@ -1028,17 +1199,18 @@ export async function closeBrowser() {
1028
1199
  *
1029
1200
  * The cleanup uses a multi-stage filtering process:
1030
1201
  * 1. Only consider pages we created (in managedPageIds)
1031
- * 2. Exclude pages associated with active streams
1202
+ * 2. Exclude pages associated with active streams, and pages whose stream setup is still in flight
1032
1203
  * 3. Apply a grace period before closing (to handle race conditions)
1033
1204
  * 4. Preserve at least one page to keep the browser alive
1034
1205
  */
1035
1206
  export async function cleanupStalePages() {
1036
- // Guard against calling this when no browser is running.
1037
- if (!currentBrowser?.connected) {
1207
+ // Guard against calling this when no ready browser is running.
1208
+ const browser = supervisor.current();
1209
+ if (!browser?.connected) {
1038
1210
  return;
1039
1211
  }
1040
1212
  try {
1041
- const pages = await currentBrowser.pages();
1213
+ const pages = await browser.pages();
1042
1214
  // If there's only one page or fewer, we must preserve it to keep the browser alive. Don't attempt cleanup.
1043
1215
  if (pages.length <= 1) {
1044
1216
  return;
@@ -1054,53 +1226,37 @@ export async function cleanupStalePages() {
1054
1226
  }
1055
1227
  }
1056
1228
  const now = Date.now();
1057
- const gracePeriod = CONFIG.recovery.stalePageGracePeriod;
1058
- // Build a list of pages that are candidates for cleanup. A page is a candidate if:
1059
- // - It has a managed page ID (was created by PrismCast)
1060
- // - It is not associated with any active stream
1061
- // - It has been stale for longer than the grace period
1062
- const candidatePages = [];
1063
- // Track which managed page IDs we've seen in the current browser pages. Used for cleanup of stale tracking data.
1064
- const currentManagedIds = new Set();
1229
+ // Project the browser's pages into the shape the decision core reads: the managed ids in the browser's own order, with undefined standing in for pages we
1230
+ // did not create, plus a lookup back to the Page objects so the ids it returns can be resolved to something closable.
1231
+ const idToPage = new Map();
1232
+ const pageIds = [];
1065
1233
  for (const page of pages) {
1066
1234
  const pageId = getManagedPageId(page);
1067
- // Skip pages we didn't create. This preserves manually opened pages and site popups.
1068
- if (!pageId) {
1069
- continue;
1070
- }
1071
- currentManagedIds.add(pageId);
1072
- // Skip pages associated with active streams.
1073
- if (activePageIds.has(pageId)) {
1074
- // If this page was previously marked as potentially stale, remove it from tracking since it's now active.
1075
- potentiallyStalePages.delete(pageId);
1076
- continue;
1077
- }
1078
- // This page is potentially stale. Track when we first observed it as such.
1079
- if (!potentiallyStalePages.has(pageId)) {
1080
- potentiallyStalePages.set(pageId, now);
1081
- // Don't close it yet - wait for the grace period.
1082
- continue;
1083
- }
1084
- // Check if the grace period has elapsed.
1085
- const firstSeenStale = potentiallyStalePages.get(pageId) ?? now;
1086
- if ((now - firstSeenStale) < gracePeriod) {
1087
- // Grace period hasn't elapsed yet. Leave this page alone for now.
1088
- continue;
1235
+ pageIds.push(pageId);
1236
+ if (pageId !== undefined) {
1237
+ idToPage.set(pageId, page);
1089
1238
  }
1090
- // This page has been stale for longer than the grace period. It's a candidate for cleanup.
1091
- candidatePages.push({ page, pageId });
1092
1239
  }
1093
- // Clean up the potentiallyStalePages map by removing entries for pages that no longer exist. This handles cases where pages were closed by other means.
1094
- for (const trackedId of potentiallyStalePages.keys()) {
1095
- if (!currentManagedIds.has(trackedId)) {
1096
- potentiallyStalePages.delete(trackedId);
1097
- }
1240
+ // The staleness judgment - clocks, exemptions, the dead-entry sweep, and the preserve-one budget - belongs to the pure core; this function only carries it out.
1241
+ const actions = evaluateStalePages({ activePageIds, gracePeriodMs: CONFIG.recovery.stalePageGracePeriod, inFlightSetupPageIds, now, pageIds,
1242
+ staleFirstSeen: potentiallyStalePages });
1243
+ // Bring the tracking collections in line with the decision before any close runs, so a close that fails cannot leave the bookkeeping half-applied.
1244
+ for (const pageId of actions.forgetTrackedIds) {
1245
+ potentiallyStalePages.delete(pageId);
1246
+ }
1247
+ for (const pageId of actions.startTrackingIds) {
1248
+ potentiallyStalePages.set(pageId, now);
1249
+ }
1250
+ for (const pageId of actions.clearInFlightIds) {
1251
+ inFlightSetupPageIds.delete(pageId);
1098
1252
  }
1099
- // Calculate how many pages we can close while still keeping at least one page open.
1100
- const maxToClose = Math.max(0, pages.length - 1 - activePageIds.size);
1101
- const pagesToClose = candidatePages.slice(0, maxToClose);
1102
1253
  let closedCount = 0;
1103
- for (const { page, pageId } of pagesToClose) {
1254
+ for (const pageId of actions.closeIds) {
1255
+ // Every id the core returns for closing came from the page list built above, so this resolves; the check is what narrows it to a Page.
1256
+ const page = idToPage.get(pageId);
1257
+ if (!page) {
1258
+ continue;
1259
+ }
1104
1260
  try {
1105
1261
  // Unregister the page before closing to prevent any race with re-registration.
1106
1262
  managedPageIds.delete(pageId);
@@ -1146,16 +1302,29 @@ export function stopStalePageCleanup() {
1146
1302
  */
1147
1303
  /**
1148
1304
  * Checks whether the browser qualifies for an opportunistic restart. Called periodically by the restart check interval. The check skips when any of these
1149
- * conditions hold: graceful shutdown in progress, login mode active, browser not connected, browser age below threshold. If active streams exist, any pending
1150
- * quiet timer is cancelled (streams started during the quiet period reset the countdown). Otherwise a quiet timer is started if one is not already running.
1305
+ * conditions hold: graceful shutdown in progress, login mode active, browser not ready, browser age below threshold. On every eligible tick it also drives the
1306
+ * supervisor's health-gated governor reset. If active streams exist, any pending quiet timer is cancelled (streams started during the quiet period reset the
1307
+ * countdown). Otherwise a quiet timer is started if one is not already running.
1151
1308
  */
1152
1309
  function checkBrowserRestart() {
1153
- // Skip if the server is shutting down, login mode is active, or the browser is not connected.
1154
- if (gracefulShutdownInProgress || isLoginModeActive() || !currentBrowser || !currentBrowser.connected || !browserLaunchTime) {
1310
+ // Skip if the server is shutting down or login mode is active.
1311
+ if (gracefulShutdownInProgress || isLoginModeActive()) {
1155
1312
  return;
1156
1313
  }
1314
+ // Read the ready browser and its launch time from the supervisor. Both are non-null only in the ready state, so a single guard covers "no ready browser." The
1315
+ // launch time is on the supervisor's clock (realClock.now), so age must be measured against the same clock - not Date.now() - or the units would not match.
1316
+ const browser = supervisor.current();
1317
+ const launchTime = supervisor.currentLaunchTime();
1318
+ if (!browser?.connected || (launchTime === null)) {
1319
+ return;
1320
+ }
1321
+ // Health-gated governor reset. On every eligible tick, tell the supervisor the browser is still ready; once it has been continuously ready for the policy's
1322
+ // hold, this resets the relaunch governor to its normal state and returns true, so we log the recovery exactly once.
1323
+ if (supervisor.noteSustainedHealth()) {
1324
+ LOG.info("Browser capture readiness has been sustained; the relaunch governor has reset to its normal state.");
1325
+ }
1157
1326
  // Skip if the browser has not exceeded the maximum age.
1158
- const age = Date.now() - browserLaunchTime;
1327
+ const age = realClock.now() - launchTime;
1159
1328
  if (age < BROWSER_MAX_AGE) {
1160
1329
  return;
1161
1330
  }
@@ -1183,21 +1352,37 @@ function checkBrowserRestart() {
1183
1352
  async function executeBrowserRestart() {
1184
1353
  // Clear the timer handle.
1185
1354
  restartQuietTimer = null;
1186
- // Final guard: re-check all preconditions. Conditions may have changed during the quiet period (e.g., a stream started just before the timer fired, login
1187
- // mode was activated, or the browser disconnected on its own).
1188
- if (gracefulShutdownInProgress || isLoginModeActive() || (getStreamCount() > 0) || !currentBrowser || !currentBrowser.connected || !browserLaunchTime) {
1355
+ // Final guard: re-check all preconditions. Conditions may have changed during the quiet period (e.g., a stream started just before the timer fired, login mode
1356
+ // was activated, or the browser disconnected on its own). Reading current()/currentLaunchTime() together keeps the ready-state check and the age source consistent.
1357
+ const browser = supervisor.current();
1358
+ const launchTime = supervisor.currentLaunchTime();
1359
+ if (gracefulShutdownInProgress || isLoginModeActive() || (getStreamCount() > 0) || !browser?.connected || (launchTime === null)) {
1189
1360
  LOG.debug("browser:lifecycle", "Browser restart aborted - preconditions no longer met.");
1190
1361
  return;
1191
1362
  }
1192
- const age = Date.now() - browserLaunchTime;
1363
+ const age = realClock.now() - launchTime;
1193
1364
  const hours = Math.floor(age / 3600000);
1194
1365
  const minutes = Math.floor((age % 3600000) / 60000);
1195
1366
  LOG.info("Restarting browser for scheduled maintenance (uptime: %sh %sm).", hours, minutes);
1196
1367
  try {
1197
- // closeBrowser() sets gracefulShutdownInProgress = true internally and performs SIGTERM-based Chrome termination.
1198
- await closeBrowser();
1199
- // Reset the flag since the server is NOT shutting down - only the browser is restarting.
1200
- setGracefulShutdown(false);
1368
+ // Retire the current instance from the lifecycle, tear it down, then acquire a fresh one through the supervisor. We do NOT touch the graceful-shutdown flag (the
1369
+ // server is not shutting down): closeBrowserInstance removes the disconnect listener, which is what makes this intentional teardown quiet. acquire() publishes
1370
+ // "ready" only after the readiness gate passes, so the completion log below is truthful: it verifies capture capability before claiming readiness, not mere liveness.
1371
+ supervisor.noteReadinessLost();
1372
+ // The restart swaps the whole Chrome session inside a living process, so the retiring session's page ids must not carry into the fresh one.
1373
+ clearPageTracking();
1374
+ // Readiness was relinquished first, because that is what supersedes an in-flight launch; publishing the teardown synchronously, before any await, then keeps the
1375
+ // launch window shut for the whole drain so nothing spawns a second Chrome against the profile lock this one still holds.
1376
+ const teardown = closeBrowserInstance(browser);
1377
+ supervisor.noteTeardownBegun(teardown, BROWSER_TEARDOWN_DRAIN_BOUND_MS);
1378
+ await teardown;
1379
+ // The preconditions were checked before the teardown, but a shutdown can begin during the seconds it takes, and relaunching then would spawn Chrome into a
1380
+ // dying process. Re-check on the far side of the await, for the same reason the guard above re-checks on the far side of the quiet period.
1381
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the shutdown path sets this while the teardown is awaited; TS cannot see that.
1382
+ if (gracefulShutdownInProgress) {
1383
+ LOG.debug("browser:lifecycle", "Browser restart relaunch declined because shutdown began while the previous instance was closing.");
1384
+ return;
1385
+ }
1201
1386
  // Launch a fresh browser instance so it is ready for the next stream request.
1202
1387
  await getCurrentBrowser();
1203
1388
  // Minimize the new window to reduce GPU usage and desktop clutter.
@@ -1206,8 +1391,6 @@ async function executeBrowserRestart() {
1206
1391
  }
1207
1392
  catch (error) {
1208
1393
  LOG.error("Browser restart failed: %s.", formatError(error));
1209
- // Ensure the graceful shutdown flag is cleared even on failure so new stream requests can still launch a browser.
1210
- setGracefulShutdown(false);
1211
1394
  }
1212
1395
  }
1213
1396
  /**