prismcast 1.10.2 → 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 (693) hide show
  1. package/dist/app.d.ts +6 -3
  2. package/dist/app.d.ts.map +1 -0
  3. package/dist/app.js +140 -86
  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 +55 -0
  28. package/dist/browser/hlsPlaylistObserver.d.ts.map +1 -0
  29. package/dist/browser/hlsPlaylistObserver.js +132 -0
  30. package/dist/browser/hlsPlaylistObserver.js.map +1 -0
  31. package/dist/browser/index.d.ts +67 -40
  32. package/dist/browser/index.d.ts.map +1 -0
  33. package/dist/browser/index.js +508 -300
  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 +102 -49
  44. package/dist/browser/manifestInterceptor.d.ts.map +1 -0
  45. package/dist/browser/manifestInterceptor.js +219 -237
  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 +49 -0
  56. package/dist/browser/tabNetworkObserver.d.ts.map +1 -0
  57. package/dist/browser/tabNetworkObserver.js +144 -0
  58. package/dist/browser/tabNetworkObserver.js.map +1 -0
  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 +27 -21
  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 +10 -9
  140. package/dist/config/paths.d.ts.map +1 -0
  141. package/dist/config/paths.js +11 -11
  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 +22 -15
  152. package/dist/config/profiles.d.ts.map +1 -0
  153. package/dist/config/profiles.js +81 -47
  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 +21 -15
  168. package/dist/config/services.d.ts.map +1 -0
  169. package/dist/config/services.js +35 -24
  170. package/dist/config/services.js.map +1 -1
  171. package/dist/config/sites.d.ts +6 -4
  172. package/dist/config/sites.d.ts.map +1 -0
  173. package/dist/config/sites.js +15 -11
  174. package/dist/config/sites.js.map +1 -1
  175. package/dist/config/userChannels.d.ts +129 -30
  176. package/dist/config/userChannels.d.ts.map +1 -0
  177. package/dist/config/userChannels.js +333 -68
  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 +10 -6
  184. package/dist/config/userProfiles.d.ts.map +1 -0
  185. package/dist/config/userProfiles.js +64 -39
  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 +11 -12
  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 +40 -17
  236. package/dist/native/index.d.ts.map +1 -0
  237. package/dist/native/index.js +281 -120
  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 +57 -5
  244. package/dist/native/proxy.d.ts.map +1 -0
  245. package/dist/native/proxy.js +604 -170
  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 +98 -0
  267. package/dist/routes/clientActions.d.ts.map +1 -0
  268. package/dist/routes/clientActions.js +113 -0
  269. package/dist/routes/clientActions.js.map +1 -0
  270. package/dist/routes/components.d.ts +5 -3
  271. package/dist/routes/components.d.ts.map +1 -0
  272. package/dist/routes/components.js +36 -39
  273. package/dist/routes/components.js.map +1 -1
  274. package/dist/routes/config/channels/endpoints/browse.d.ts +1 -0
  275. package/dist/routes/config/channels/endpoints/browse.d.ts.map +1 -0
  276. package/dist/routes/config/channels/endpoints/browse.js +23 -10
  277. package/dist/routes/config/channels/endpoints/browse.js.map +1 -1
  278. package/dist/routes/config/channels/endpoints/bulk.d.ts +1 -0
  279. package/dist/routes/config/channels/endpoints/bulk.d.ts.map +1 -0
  280. package/dist/routes/config/channels/endpoints/bulk.js +5 -3
  281. package/dist/routes/config/channels/endpoints/bulk.js.map +1 -1
  282. package/dist/routes/config/channels/endpoints/crud.d.ts +1 -0
  283. package/dist/routes/config/channels/endpoints/crud.d.ts.map +1 -0
  284. package/dist/routes/config/channels/endpoints/crud.js +85 -93
  285. package/dist/routes/config/channels/endpoints/crud.js.map +1 -1
  286. package/dist/routes/config/channels/endpoints/importExport.d.ts +1 -0
  287. package/dist/routes/config/channels/endpoints/importExport.d.ts.map +1 -0
  288. package/dist/routes/config/channels/endpoints/importExport.js +6 -3
  289. package/dist/routes/config/channels/endpoints/importExport.js.map +1 -1
  290. package/dist/routes/config/channels/endpoints/predefined.d.ts +1 -0
  291. package/dist/routes/config/channels/endpoints/predefined.d.ts.map +1 -0
  292. package/dist/routes/config/channels/endpoints/prefs.d.ts +1 -0
  293. package/dist/routes/config/channels/endpoints/prefs.d.ts.map +1 -0
  294. package/dist/routes/config/channels/endpoints/prefs.js +2 -1
  295. package/dist/routes/config/channels/endpoints/prefs.js.map +1 -1
  296. package/dist/routes/config/channels/endpoints/service.d.ts +2 -1
  297. package/dist/routes/config/channels/endpoints/service.d.ts.map +1 -0
  298. package/dist/routes/config/channels/endpoints/service.js +3 -1
  299. package/dist/routes/config/channels/endpoints/service.js.map +1 -1
  300. package/dist/routes/config/channels/endpoints/tags.d.ts +1 -0
  301. package/dist/routes/config/channels/endpoints/tags.d.ts.map +1 -0
  302. package/dist/routes/config/channels/endpoints/tags.js +13 -7
  303. package/dist/routes/config/channels/endpoints/tags.js.map +1 -1
  304. package/dist/routes/config/channels/healthBridge.d.ts +24 -0
  305. package/dist/routes/config/channels/healthBridge.d.ts.map +1 -0
  306. package/dist/routes/config/channels/healthBridge.js +71 -0
  307. package/dist/routes/config/channels/healthBridge.js.map +1 -0
  308. package/dist/routes/config/channels/http/handler.d.ts +1 -0
  309. package/dist/routes/config/channels/http/handler.d.ts.map +1 -0
  310. package/dist/routes/config/channels/http/playlistHint.d.ts +2 -1
  311. package/dist/routes/config/channels/http/playlistHint.d.ts.map +1 -0
  312. package/dist/routes/config/channels/http/playlistHint.js +16 -2
  313. package/dist/routes/config/channels/http/playlistHint.js.map +1 -1
  314. package/dist/routes/config/channels/http/serviceWarning.d.ts +4 -2
  315. package/dist/routes/config/channels/http/serviceWarning.d.ts.map +1 -0
  316. package/dist/routes/config/channels/http/serviceWarning.js +3 -2
  317. package/dist/routes/config/channels/http/serviceWarning.js.map +1 -1
  318. package/dist/routes/config/channels/index.d.ts +1 -0
  319. package/dist/routes/config/channels/index.d.ts.map +1 -0
  320. package/dist/routes/config/channels/setup.d.ts +1 -0
  321. package/dist/routes/config/channels/setup.d.ts.map +1 -0
  322. package/dist/routes/config/channels/table.d.ts +3 -3
  323. package/dist/routes/config/channels/table.d.ts.map +1 -0
  324. package/dist/routes/config/channels/table.js +148 -107
  325. package/dist/routes/config/channels/table.js.map +1 -1
  326. package/dist/routes/config/http/envelope.d.ts +1 -0
  327. package/dist/routes/config/http/envelope.d.ts.map +1 -0
  328. package/dist/routes/config/index.d.ts +35 -3
  329. package/dist/routes/config/index.d.ts.map +1 -0
  330. package/dist/routes/config/index.js +51 -2
  331. package/dist/routes/config/index.js.map +1 -1
  332. package/dist/routes/config/services.d.ts +5 -4
  333. package/dist/routes/config/services.d.ts.map +1 -0
  334. package/dist/routes/config/services.js +57 -43
  335. package/dist/routes/config/services.js.map +1 -1
  336. package/dist/routes/config/settings.d.ts +1 -0
  337. package/dist/routes/config/settings.d.ts.map +1 -0
  338. package/dist/routes/config/settings.js +98 -49
  339. package/dist/routes/config/settings.js.map +1 -1
  340. package/dist/routes/debug.d.ts +1 -0
  341. package/dist/routes/debug.d.ts.map +1 -0
  342. package/dist/routes/debug.js +277 -139
  343. package/dist/routes/debug.js.map +1 -1
  344. package/dist/routes/health.d.ts +39 -1
  345. package/dist/routes/health.d.ts.map +1 -0
  346. package/dist/routes/health.js +33 -27
  347. package/dist/routes/health.js.map +1 -1
  348. package/dist/routes/hls.d.ts +1 -0
  349. package/dist/routes/hls.d.ts.map +1 -0
  350. package/dist/routes/icons.d.ts +1 -0
  351. package/dist/routes/icons.d.ts.map +1 -0
  352. package/dist/routes/icons.js +1 -1
  353. package/dist/routes/index.d.ts +1 -0
  354. package/dist/routes/index.d.ts.map +1 -0
  355. package/dist/routes/logs.d.ts +1 -0
  356. package/dist/routes/logs.d.ts.map +1 -0
  357. package/dist/routes/logs.js +14 -4
  358. package/dist/routes/logs.js.map +1 -1
  359. package/dist/routes/mpegts.d.ts +1 -0
  360. package/dist/routes/mpegts.d.ts.map +1 -0
  361. package/dist/routes/play.d.ts +1 -0
  362. package/dist/routes/play.d.ts.map +1 -0
  363. package/dist/routes/playlist.d.ts +1 -0
  364. package/dist/routes/playlist.d.ts.map +1 -0
  365. package/dist/routes/playlist.js +7 -2
  366. package/dist/routes/playlist.js.map +1 -1
  367. package/dist/routes/root/content.d.ts +8 -5
  368. package/dist/routes/root/content.d.ts.map +1 -0
  369. package/dist/routes/root/content.js +29 -26
  370. package/dist/routes/root/content.js.map +1 -1
  371. package/dist/routes/root/index.d.ts +2 -1
  372. package/dist/routes/root/index.d.ts.map +1 -0
  373. package/dist/routes/root/index.js +26 -21
  374. package/dist/routes/root/index.js.map +1 -1
  375. package/dist/routes/root/scripts/channels.d.ts +6 -0
  376. package/dist/routes/root/scripts/channels.d.ts.map +1 -0
  377. package/dist/routes/root/scripts/channels.js +197 -118
  378. package/dist/routes/root/scripts/channels.js.map +1 -1
  379. package/dist/routes/root/scripts/clientEscape.d.ts +22 -0
  380. package/dist/routes/root/scripts/clientEscape.d.ts.map +1 -0
  381. package/dist/routes/root/scripts/clientEscape.js +41 -0
  382. package/dist/routes/root/scripts/clientEscape.js.map +1 -0
  383. package/dist/routes/root/scripts/clientUrl.d.ts +23 -0
  384. package/dist/routes/root/scripts/clientUrl.d.ts.map +1 -0
  385. package/dist/routes/root/scripts/clientUrl.js +44 -0
  386. package/dist/routes/root/scripts/clientUrl.js.map +1 -0
  387. package/dist/routes/root/scripts/config.d.ts +6 -0
  388. package/dist/routes/root/scripts/config.d.ts.map +1 -0
  389. package/dist/routes/root/scripts/config.js +110 -24
  390. package/dist/routes/root/scripts/config.js.map +1 -1
  391. package/dist/routes/root/scripts/index.d.ts +1 -0
  392. package/dist/routes/root/scripts/index.d.ts.map +1 -0
  393. package/dist/routes/root/scripts/shared.d.ts +1 -0
  394. package/dist/routes/root/scripts/shared.d.ts.map +1 -0
  395. package/dist/routes/root/scripts/shared.js +112 -26
  396. package/dist/routes/root/scripts/shared.js.map +1 -1
  397. package/dist/routes/root/scripts/status.d.ts +1 -0
  398. package/dist/routes/root/scripts/status.d.ts.map +1 -0
  399. package/dist/routes/root/scripts/status.handlers.d.ts +23 -33
  400. package/dist/routes/root/scripts/status.handlers.d.ts.map +1 -0
  401. package/dist/routes/root/scripts/status.handlers.js +46 -92
  402. package/dist/routes/root/scripts/status.handlers.js.map +1 -1
  403. package/dist/routes/root/scripts/status.js +21 -8
  404. package/dist/routes/root/scripts/status.js.map +1 -1
  405. package/dist/routes/root/styles.d.ts +1 -0
  406. package/dist/routes/root/styles.d.ts.map +1 -0
  407. package/dist/routes/root/styles.js +7 -4
  408. package/dist/routes/root/styles.js.map +1 -1
  409. package/dist/routes/services.d.ts +13 -1
  410. package/dist/routes/services.d.ts.map +1 -0
  411. package/dist/routes/services.js +81 -68
  412. package/dist/routes/services.js.map +1 -1
  413. package/dist/routes/sse.d.ts +1 -0
  414. package/dist/routes/sse.d.ts.map +1 -0
  415. package/dist/routes/sse.js +1 -1
  416. package/dist/routes/sse.js.map +1 -1
  417. package/dist/routes/streams.d.ts +2 -1
  418. package/dist/routes/streams.d.ts.map +1 -0
  419. package/dist/routes/streams.js +6 -4
  420. package/dist/routes/streams.js.map +1 -1
  421. package/dist/routes/theme.d.ts +3 -2
  422. package/dist/routes/theme.d.ts.map +1 -0
  423. package/dist/routes/theme.js +6 -5
  424. package/dist/routes/theme.js.map +1 -1
  425. package/dist/routes/ui.d.ts +3 -1
  426. package/dist/routes/ui.d.ts.map +1 -0
  427. package/dist/routes/ui.js +16 -4
  428. package/dist/routes/ui.js.map +1 -1
  429. package/dist/routes/upgrade.d.ts +1 -0
  430. package/dist/routes/upgrade.d.ts.map +1 -0
  431. package/dist/routes/upgrade.js +6 -2
  432. package/dist/routes/upgrade.js.map +1 -1
  433. package/dist/service/commands.context.d.ts +1 -0
  434. package/dist/service/commands.context.d.ts.map +1 -0
  435. package/dist/service/commands.context.js +4 -3
  436. package/dist/service/commands.context.js.map +1 -1
  437. package/dist/service/commands.d.ts +1 -0
  438. package/dist/service/commands.d.ts.map +1 -0
  439. package/dist/service/commands.js +10 -13
  440. package/dist/service/commands.js.map +1 -1
  441. package/dist/service/generators.context.d.ts +1 -0
  442. package/dist/service/generators.context.d.ts.map +1 -0
  443. package/dist/service/generators.context.js +1 -1
  444. package/dist/service/generators.context.js.map +1 -1
  445. package/dist/service/generators.d.ts +4 -3
  446. package/dist/service/generators.d.ts.map +1 -0
  447. package/dist/service/generators.js +111 -31
  448. package/dist/service/generators.js.map +1 -1
  449. package/dist/service/index.d.ts +1 -0
  450. package/dist/service/index.d.ts.map +1 -0
  451. package/dist/streaming/captureLock.d.ts +44 -0
  452. package/dist/streaming/captureLock.d.ts.map +1 -0
  453. package/dist/streaming/captureLock.js +95 -0
  454. package/dist/streaming/captureLock.js.map +1 -0
  455. package/dist/streaming/captureSession.d.ts +31 -0
  456. package/dist/streaming/captureSession.d.ts.map +1 -0
  457. package/dist/streaming/captureSession.js +54 -0
  458. package/dist/streaming/captureSession.js.map +1 -0
  459. package/dist/streaming/clients.d.ts +1 -0
  460. package/dist/streaming/clients.d.ts.map +1 -0
  461. package/dist/streaming/codec.d.ts +1 -0
  462. package/dist/streaming/codec.d.ts.map +1 -0
  463. package/dist/streaming/fmp4Segmenter.d.ts +29 -2
  464. package/dist/streaming/fmp4Segmenter.d.ts.map +1 -0
  465. package/dist/streaming/fmp4Segmenter.js +79 -26
  466. package/dist/streaming/fmp4Segmenter.js.map +1 -1
  467. package/dist/streaming/hls.d.ts +18 -7
  468. package/dist/streaming/hls.d.ts.map +1 -0
  469. package/dist/streaming/hls.js +220 -120
  470. package/dist/streaming/hls.js.map +1 -1
  471. package/dist/streaming/hlsResume.d.ts +1 -0
  472. package/dist/streaming/hlsResume.d.ts.map +1 -0
  473. package/dist/streaming/hlsResume.js +1 -1
  474. package/dist/streaming/hlsResume.js.map +1 -1
  475. package/dist/streaming/hlsSegments.d.ts +59 -2
  476. package/dist/streaming/hlsSegments.d.ts.map +1 -0
  477. package/dist/streaming/hlsSegments.js +147 -17
  478. package/dist/streaming/hlsSegments.js.map +1 -1
  479. package/dist/streaming/lifecycle.d.ts +4 -1
  480. package/dist/streaming/lifecycle.d.ts.map +1 -0
  481. package/dist/streaming/lifecycle.js +65 -85
  482. package/dist/streaming/lifecycle.js.map +1 -1
  483. package/dist/streaming/monitor.d.ts +6 -4
  484. package/dist/streaming/monitor.d.ts.map +1 -0
  485. package/dist/streaming/monitor.js +343 -145
  486. package/dist/streaming/monitor.js.map +1 -1
  487. package/dist/streaming/mp4Parser.d.ts +5 -4
  488. package/dist/streaming/mp4Parser.d.ts.map +1 -0
  489. package/dist/streaming/mp4Parser.js +36 -13
  490. package/dist/streaming/mp4Parser.js.map +1 -1
  491. package/dist/streaming/mpegts.d.ts +16 -0
  492. package/dist/streaming/mpegts.d.ts.map +1 -0
  493. package/dist/streaming/mpegts.js +61 -17
  494. package/dist/streaming/mpegts.js.map +1 -1
  495. package/dist/streaming/playlistBuilder.d.ts +1 -0
  496. package/dist/streaming/playlistBuilder.d.ts.map +1 -0
  497. package/dist/streaming/preroll.d.ts +32 -5
  498. package/dist/streaming/preroll.d.ts.map +1 -0
  499. package/dist/streaming/preroll.js +70 -32
  500. package/dist/streaming/preroll.js.map +1 -1
  501. package/dist/streaming/pretune.d.ts +26 -1
  502. package/dist/streaming/pretune.d.ts.map +1 -0
  503. package/dist/streaming/pretune.js +22 -24
  504. package/dist/streaming/pretune.js.map +1 -1
  505. package/dist/streaming/pretuneTimers.d.ts +24 -0
  506. package/dist/streaming/pretuneTimers.d.ts.map +1 -0
  507. package/dist/streaming/pretuneTimers.js +56 -0
  508. package/dist/streaming/pretuneTimers.js.map +1 -0
  509. package/dist/streaming/recovery.d.ts +139 -5
  510. package/dist/streaming/recovery.d.ts.map +1 -0
  511. package/dist/streaming/recovery.js +160 -16
  512. package/dist/streaming/recovery.js.map +1 -1
  513. package/dist/streaming/registry.d.ts +27 -9
  514. package/dist/streaming/registry.d.ts.map +1 -0
  515. package/dist/streaming/registry.js +21 -3
  516. package/dist/streaming/registry.js.map +1 -1
  517. package/dist/streaming/setup.d.ts +157 -29
  518. package/dist/streaming/setup.d.ts.map +1 -0
  519. package/dist/streaming/setup.js +1034 -458
  520. package/dist/streaming/setup.js.map +1 -1
  521. package/dist/streaming/showInfo.d.ts +21 -1
  522. package/dist/streaming/showInfo.d.ts.map +1 -0
  523. package/dist/streaming/showInfo.js +31 -16
  524. package/dist/streaming/showInfo.js.map +1 -1
  525. package/dist/streaming/statusEmitter.d.ts +10 -10
  526. package/dist/streaming/statusEmitter.d.ts.map +1 -0
  527. package/dist/streaming/statusEmitter.js +5 -8
  528. package/dist/streaming/statusEmitter.js.map +1 -1
  529. package/dist/types/channels.d.ts +16 -4
  530. package/dist/types/channels.d.ts.map +1 -0
  531. package/dist/types/channels.js +4 -2
  532. package/dist/types/channels.js.map +1 -1
  533. package/dist/types/config.d.ts +16 -5
  534. package/dist/types/config.d.ts.map +1 -0
  535. package/dist/types/index.d.ts +3 -2
  536. package/dist/types/index.d.ts.map +1 -0
  537. package/dist/types/profiles.d.ts +6 -4
  538. package/dist/types/profiles.d.ts.map +1 -0
  539. package/dist/types/selection.d.ts +31 -3
  540. package/dist/types/selection.d.ts.map +1 -0
  541. package/dist/types/selection.js +4 -2
  542. package/dist/types/selection.js.map +1 -1
  543. package/dist/types/shared.d.ts +1 -0
  544. package/dist/types/shared.d.ts.map +1 -0
  545. package/dist/types/streaming.d.ts +8 -0
  546. package/dist/types/streaming.d.ts.map +1 -0
  547. package/dist/upgrade/commands.context.d.ts +1 -0
  548. package/dist/upgrade/commands.context.d.ts.map +1 -0
  549. package/dist/upgrade/commands.context.js +14 -10
  550. package/dist/upgrade/commands.context.js.map +1 -1
  551. package/dist/upgrade/commands.d.ts +8 -15
  552. package/dist/upgrade/commands.d.ts.map +1 -0
  553. package/dist/upgrade/commands.js +27 -28
  554. package/dist/upgrade/commands.js.map +1 -1
  555. package/dist/upgrade/detection.context.d.ts +1 -0
  556. package/dist/upgrade/detection.context.d.ts.map +1 -0
  557. package/dist/upgrade/detection.context.js +4 -1
  558. package/dist/upgrade/detection.context.js.map +1 -1
  559. package/dist/upgrade/detection.d.ts +9 -7
  560. package/dist/upgrade/detection.d.ts.map +1 -0
  561. package/dist/upgrade/detection.js +23 -54
  562. package/dist/upgrade/detection.js.map +1 -1
  563. package/dist/upgrade/index.d.ts +2 -0
  564. package/dist/upgrade/index.d.ts.map +1 -0
  565. package/dist/upgrade/index.js +1 -0
  566. package/dist/upgrade/index.js.map +1 -1
  567. package/dist/upgrade/lifecycle.context.d.ts +20 -0
  568. package/dist/upgrade/lifecycle.context.d.ts.map +1 -0
  569. package/dist/upgrade/lifecycle.context.js +47 -0
  570. package/dist/upgrade/lifecycle.context.js.map +1 -0
  571. package/dist/upgrade/lifecycle.d.ts +72 -0
  572. package/dist/upgrade/lifecycle.d.ts.map +1 -0
  573. package/dist/upgrade/lifecycle.js +175 -0
  574. package/dist/upgrade/lifecycle.js.map +1 -0
  575. package/dist/upgrade/pathHandle.d.ts +30 -0
  576. package/dist/upgrade/pathHandle.d.ts.map +1 -0
  577. package/dist/upgrade/pathHandle.js +102 -0
  578. package/dist/upgrade/pathHandle.js.map +1 -0
  579. package/dist/utils/bootSession.context.d.ts +16 -0
  580. package/dist/utils/bootSession.context.d.ts.map +1 -0
  581. package/dist/utils/bootSession.context.js +60 -0
  582. package/dist/utils/bootSession.context.js.map +1 -0
  583. package/dist/utils/bootSession.d.ts +17 -0
  584. package/dist/utils/bootSession.d.ts.map +1 -0
  585. package/dist/utils/bootSession.js +20 -0
  586. package/dist/utils/bootSession.js.map +1 -0
  587. package/dist/utils/chromeFetch.d.ts +1 -0
  588. package/dist/utils/chromeFetch.d.ts.map +1 -0
  589. package/dist/utils/cliOutput.d.ts +1 -0
  590. package/dist/utils/cliOutput.d.ts.map +1 -0
  591. package/dist/utils/clock.d.ts +5 -4
  592. package/dist/utils/clock.d.ts.map +1 -0
  593. package/dist/utils/clock.js +19 -16
  594. package/dist/utils/clock.js.map +1 -1
  595. package/dist/utils/debugFilter.d.ts +9 -1
  596. package/dist/utils/debugFilter.d.ts.map +1 -0
  597. package/dist/utils/debugFilter.js +60 -33
  598. package/dist/utils/debugFilter.js.map +1 -1
  599. package/dist/utils/delay.d.ts +48 -15
  600. package/dist/utils/delay.d.ts.map +1 -0
  601. package/dist/utils/delay.js +73 -20
  602. package/dist/utils/delay.js.map +1 -1
  603. package/dist/utils/errors.d.ts +32 -0
  604. package/dist/utils/errors.d.ts.map +1 -0
  605. package/dist/utils/errors.js +45 -4
  606. package/dist/utils/errors.js.map +1 -1
  607. package/dist/utils/evaluate.d.ts +1 -0
  608. package/dist/utils/evaluate.d.ts.map +1 -0
  609. package/dist/utils/evaluate.js +29 -23
  610. package/dist/utils/evaluate.js.map +1 -1
  611. package/dist/utils/ffmpeg.context.d.ts +1 -0
  612. package/dist/utils/ffmpeg.context.d.ts.map +1 -0
  613. package/dist/utils/ffmpeg.context.js.map +1 -1
  614. package/dist/utils/ffmpeg.d.ts +29 -7
  615. package/dist/utils/ffmpeg.d.ts.map +1 -0
  616. package/dist/utils/ffmpeg.js +32 -14
  617. package/dist/utils/ffmpeg.js.map +1 -1
  618. package/dist/utils/fileLogger.d.ts +3 -1
  619. package/dist/utils/fileLogger.d.ts.map +1 -0
  620. package/dist/utils/fileLogger.js +64 -29
  621. package/dist/utils/fileLogger.js.map +1 -1
  622. package/dist/utils/format.d.ts +14 -1
  623. package/dist/utils/format.d.ts.map +1 -0
  624. package/dist/utils/format.js +19 -5
  625. package/dist/utils/format.js.map +1 -1
  626. package/dist/utils/index.d.ts +4 -0
  627. package/dist/utils/index.d.ts.map +1 -0
  628. package/dist/utils/index.js +3 -0
  629. package/dist/utils/index.js.map +1 -1
  630. package/dist/utils/logEmitter.d.ts +1 -0
  631. package/dist/utils/logEmitter.d.ts.map +1 -0
  632. package/dist/utils/logger.d.ts +1 -0
  633. package/dist/utils/logger.d.ts.map +1 -0
  634. package/dist/utils/logger.js +10 -6
  635. package/dist/utils/logger.js.map +1 -1
  636. package/dist/utils/m3u.d.ts +3 -2
  637. package/dist/utils/m3u.d.ts.map +1 -0
  638. package/dist/utils/m3u.js +4 -3
  639. package/dist/utils/m3u.js.map +1 -1
  640. package/dist/utils/markup.d.ts +13 -1
  641. package/dist/utils/markup.d.ts.map +1 -0
  642. package/dist/utils/markup.js +28 -4
  643. package/dist/utils/markup.js.map +1 -1
  644. package/dist/utils/memo.d.ts +3 -2
  645. package/dist/utils/memo.d.ts.map +1 -0
  646. package/dist/utils/memo.js +3 -3
  647. package/dist/utils/morganStream.d.ts +1 -0
  648. package/dist/utils/morganStream.d.ts.map +1 -0
  649. package/dist/utils/network.d.ts +1 -0
  650. package/dist/utils/network.d.ts.map +1 -0
  651. package/dist/utils/network.js +2 -2
  652. package/dist/utils/pid.d.ts +3 -25
  653. package/dist/utils/pid.d.ts.map +1 -0
  654. package/dist/utils/pid.js +10 -49
  655. package/dist/utils/pid.js.map +1 -1
  656. package/dist/utils/platform.d.ts +3 -1
  657. package/dist/utils/platform.d.ts.map +1 -0
  658. package/dist/utils/platform.js +2 -1
  659. package/dist/utils/platform.js.map +1 -1
  660. package/dist/utils/processInspector.context.d.ts +8 -0
  661. package/dist/utils/processInspector.context.d.ts.map +1 -0
  662. package/dist/utils/processInspector.context.js +106 -0
  663. package/dist/utils/processInspector.context.js.map +1 -0
  664. package/dist/utils/processInspector.d.ts +55 -0
  665. package/dist/utils/processInspector.d.ts.map +1 -0
  666. package/dist/utils/processInspector.js +127 -0
  667. package/dist/utils/processInspector.js.map +1 -0
  668. package/dist/utils/retry.d.ts +25 -2
  669. package/dist/utils/retry.d.ts.map +1 -0
  670. package/dist/utils/retry.js +33 -6
  671. package/dist/utils/retry.js.map +1 -1
  672. package/dist/utils/runtimeIdentity.context.d.ts +7 -0
  673. package/dist/utils/runtimeIdentity.context.d.ts.map +1 -0
  674. package/dist/utils/runtimeIdentity.context.js +43 -0
  675. package/dist/utils/runtimeIdentity.context.js.map +1 -0
  676. package/dist/utils/runtimeIdentity.d.ts +98 -0
  677. package/dist/utils/runtimeIdentity.d.ts.map +1 -0
  678. package/dist/utils/runtimeIdentity.js +181 -0
  679. package/dist/utils/runtimeIdentity.js.map +1 -0
  680. package/dist/utils/sanitize.d.ts +1 -0
  681. package/dist/utils/sanitize.d.ts.map +1 -0
  682. package/dist/utils/streamContext.d.ts +2 -1
  683. package/dist/utils/streamContext.d.ts.map +1 -0
  684. package/dist/utils/streamContext.js +1 -1
  685. package/dist/utils/timing.d.ts +2 -1
  686. package/dist/utils/timing.d.ts.map +1 -0
  687. package/dist/utils/timing.js +0 -5
  688. package/dist/utils/timing.js.map +1 -1
  689. package/dist/utils/version.d.ts +1 -0
  690. package/dist/utils/version.d.ts.map +1 -0
  691. package/dist/utils/version.js +47 -13
  692. package/dist/utils/version.js.map +1 -1
  693. package/package.json +26 -20
@@ -1,14 +1,16 @@
1
- import { getGpuCapabilities, setBrowserChrome, setGpuCapabilities, setMaxSupportedViewport } from "./display.js";
2
- import { LOG, cancellableTimeout, clearPidFile, delay, evaluateWithAbort, formatError, isProcessRunning, readPidFile, startTimer, writePidFile } from "../utils/index.js";
1
+ import { LOG, boundedWait, delay, evaluateWithAbort, formatError, isProcessRunning, listProcesses, realClock, startTimer } from "../utils/index.js";
3
2
  import { clearLoginState, isLoginModeActive, setBrowserAccessors } from "./login.js";
4
3
  import { getAllStreams, getStreamCount } from "../streaming/registry.js";
5
- import { getChromeDataDir, getChromePidFilePath, getDataDir, getExtensionDir } from "../config/paths.js";
4
+ import { getChromeDataDir, getDataDir, getExtensionDir } from "../config/paths.js";
6
5
  import { getEffectivePreset, getPresetViewport } from "../config/presets.js";
7
6
  import { getExtensionPage, getStream, launch } from "puppeteer-stream";
7
+ import { getGpuCapabilities, setBrowserChrome, setGpuCapabilities, setMaxSupportedViewport } from "./display.js";
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,34 +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 PID of the Chrome process launched by Puppeteer. Tracked in memory for fast access and persisted to a PID file on disk so that orphaned Chrome processes
34
- // can be cleaned up after a crash or container restart without relying on Unix-only tools like pkill/pgrep.
35
- let chromePid = null;
36
- // Tracks whether this process has taken ownership of Chrome cleanup by running killStaleChrome() during startup. The exit handler checks this flag to avoid
37
- // killing Chrome that belongs to another running PrismCast instance - e.g., when a duplicate instance is rejected by the instance guard and exits before
38
- // killStaleChrome() runs in the startup sequence.
39
- let ownsChromeCleanup = false;
40
- // The Chrome version string (e.g., "Chrome/144.0.7559.110") captured when the browser launches. Cleared when the browser disconnects. Used by the
41
- // 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?".
42
38
  let currentChromeVersion = null;
43
- // Timestamp (Date.now()) when the current browser instance was launched. Used by the opportunistic restart check to determine browser age. Cleared when the
44
- // browser disconnects.
45
- let browserLaunchTime = null;
46
- // 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
47
- // starting a second Chrome process. Cleared in a finally block once the launch settles.
48
- let browserLaunchPromise = null;
49
- // 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
+ }
50
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
51
120
  // associated with active streams and closes them to prevent resource exhaustion.
52
121
  let stalePageCleanupInterval = null;
@@ -59,7 +128,7 @@ const BROWSER_MAX_AGE = 6 * 60 * 60 * 1000;
59
128
  // Duration of the quiet period (zero streams) required before executing the restart (5 minutes).
60
129
  const BROWSER_RESTART_QUIET_PERIOD = 5 * 60 * 1000;
61
130
  // How often to check whether the browser qualifies for a restart (30 seconds).
62
- const BROWSER_RESTART_CHECK_INTERVAL = 30_000;
131
+ const BROWSER_RESTART_CHECK_INTERVAL = 30000;
63
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
64
133
  // elapse with zero active streams. Cancelled if a stream starts during the quiet period.
65
134
  let restartQuietTimer = null;
@@ -97,10 +166,20 @@ const managedPageIds = new Set();
97
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
98
167
  // the configured grace period before being closed. This prevents race conditions where pages are briefly untracked during initialization or cleanup transitions.
99
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();
100
175
  // Login mode management. State and functions live in login.ts; re-exported here so existing consumers don't need import path changes. clearLoginState,
101
176
  // isLoginModeActive, and setBrowserAccessors are imported above; the first two for internal use, setBrowserAccessors for one-time initialization below.
102
177
  export { clearLoginState, isLoginModeActive };
103
- 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";
104
183
  // Inject browser accessors into the login module. This breaks the circular dependency (login needs getBrowserInstance/minimizeBrowserWindow, index needs login
105
184
  // functions) using the same setter/getter pattern as setChromeUserAgent in chromeFetch.ts. Function declarations are hoisted, so both accessors are available here.
106
185
  setBrowserAccessors({ getBrowserInstance, minimizeBrowserWindow });
@@ -109,9 +188,12 @@ setBrowserAccessors({ getBrowserInstance, minimizeBrowserWindow });
109
188
  */
110
189
  export async function emitCurrentSystemStatus() {
111
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();
112
194
  try {
113
- if (currentBrowser?.connected) {
114
- const pages = await currentBrowser.pages();
195
+ if (browser?.connected) {
196
+ const pages = await browser.pages();
115
197
  pageCount = pages.length;
116
198
  }
117
199
  }
@@ -121,7 +203,7 @@ export async function emitCurrentSystemStatus() {
121
203
  const memUsage = process.memoryUsage();
122
204
  const status = {
123
205
  browser: {
124
- connected: !!currentBrowser && currentBrowser.connected,
206
+ connected: !!browser && browser.connected,
125
207
  pageCount
126
208
  },
127
209
  memory: {
@@ -143,14 +225,20 @@ export async function emitCurrentSystemStatus() {
143
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
144
226
  * issues with Page object reference identity.
145
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.
146
230
  */
147
- export function registerManagedPage(page) {
231
+ export function registerManagedPage(page, options = {}) {
148
232
  // Generate a unique ID for this page.
149
233
  const pageId = "page-" + String(++managedPageIdCounter);
150
234
  // Associate the Page object with its ID.
151
235
  pageToId.set(page, pageId);
152
236
  // Track the ID as managed.
153
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
+ }
154
242
  }
155
243
  /**
156
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
@@ -163,6 +251,8 @@ export function unregisterManagedPage(page) {
163
251
  managedPageIds.delete(pageId);
164
252
  // Also remove from potentially stale tracking since we're intentionally closing it.
165
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);
166
256
  // Note: We don't delete from pageToId because WeakMap handles cleanup automatically when the Page is garbage collected.
167
257
  }
168
258
  }
@@ -174,6 +264,18 @@ export function unregisterManagedPage(page) {
174
264
  function getManagedPageId(page) {
175
265
  return pageToId.get(page);
176
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
+ }
177
279
  /**
178
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
179
281
  * directory (like browser launch or extension preparation).
@@ -191,6 +293,37 @@ export async function ensureDataDirectory() {
191
293
  LOG.error("Failed to create data directory %s: %s.", getDataDir(), formatError(error));
192
294
  throw error;
193
295
  }
296
+ // Purge on-disk artifacts retired in earlier releases. The data directory is the right boundary for this work - it runs once per startup, after the directory
297
+ // exists, before any subsequent step reads it. Future retirements add a single line to purgeLegacyArtifacts; the call site here does not change.
298
+ await purgeLegacyArtifacts();
299
+ }
300
+ const RETIRED_ARTIFACTS = [
301
+ {
302
+ filename: "chrome.pid",
303
+ replacedBy: "OS process-table discovery in utils/processInspector",
304
+ retiredIn: "1.10.3"
305
+ }
306
+ ];
307
+ /**
308
+ * Removes every on-disk artifact in RETIRED_ARTIFACTS. Failures are non-fatal: a missing file is the steady-state expectation (fresh installs and any
309
+ * post-first-run startup) and any other I/O error is logged but does not interrupt startup - the user's running configuration is not at risk from a leftover
310
+ * artifact. The data-directory boundary in ensureDataDirectory is the natural caller: it runs once per startup, after the directory exists, before any
311
+ * subsequent step reads it.
312
+ */
313
+ async function purgeLegacyArtifacts() {
314
+ for (const artifact of RETIRED_ARTIFACTS) {
315
+ const filePath = path.join(getDataDir(), artifact.filename);
316
+ try {
317
+ // eslint-disable-next-line no-await-in-loop
318
+ await fsPromises.unlink(filePath);
319
+ LOG.debug("browser:lifecycle", "Purged legacy artifact %s (retired in %s, replaced by %s).", filePath, artifact.retiredIn, artifact.replacedBy);
320
+ }
321
+ catch (error) {
322
+ if (error.code !== "ENOENT") {
323
+ LOG.warn("Failed to remove legacy artifact %s: %s.", filePath, formatError(error));
324
+ }
325
+ }
326
+ }
194
327
  }
195
328
  /* These functions handle the Chrome browser lifecycle: startup, cleanup, and instance management. The browser is a shared resource used by all streaming sessions,
196
329
  * so careful lifecycle management is essential for reliability. Key considerations:
@@ -206,30 +339,6 @@ export async function ensureDataDirectory() {
206
339
  * - Extension initialization: The puppeteer-stream extension needs time after browser launch to inject its recording APIs. We wait for this initialization before
207
340
  * attempting to capture streams.
208
341
  */
209
- /**
210
- * Persists the Chrome process PID to both the module-level variable (fast, in-memory) and a PID file on disk (survives crashes). The PID file allows the next
211
- * startup to find and terminate orphaned Chrome processes even if the Node process crashed without cleanup.
212
- * @param pid - The Chrome process ID to save.
213
- */
214
- function saveChromePid(pid) {
215
- chromePid = pid;
216
- writePidFile(getChromePidFilePath(), pid, "Chrome", LOG);
217
- }
218
- /**
219
- * Loads the Chrome PID from the module-level variable (fast path) or falls back to reading the PID file on disk (crash recovery path). Returns null if no PID
220
- * is available - either first run or the PID file was already cleaned up.
221
- * @returns The Chrome process ID, or null if unavailable.
222
- */
223
- function loadChromePid() {
224
- return chromePid ?? readPidFile(getChromePidFilePath(), "Chrome", LOG);
225
- }
226
- /**
227
- * Clears the Chrome PID from both the module-level variable and the PID file on disk. Called after successful process cleanup to prevent stale PID reuse.
228
- */
229
- function clearChromePid() {
230
- chromePid = null;
231
- clearPidFile(getChromePidFilePath(), "Chrome", LOG);
232
- }
233
342
  /**
234
343
  * Synchronous sleep using Atomics.wait(). This is a cross-platform replacement for execSync("sleep N") that works on all platforms without shelling out.
235
344
  * Required because killStaleChrome() runs in the synchronous process.on("exit") handler where async operations are not available.
@@ -239,37 +348,65 @@ function syncSleep(ms) {
239
348
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
240
349
  }
241
350
  /**
242
- * Ensures a clean slate for browser launch by terminating any stale Chrome processes and removing orphaned profile lock files. Chrome locks its profile directory
243
- * while running, and if a previous instance crashed without releasing the lock, we cannot launch a new browser with the same profile. This function uses the
244
- * saved Chrome PID to find and terminate the process via process.kill(), then polls for exit using signal 0. This approach is fully cross-platform - it does not
245
- * rely on Unix-only tools like pkill or pgrep.
351
+ * Identifies Chrome processes that this PrismCast instance is responsible for terminating. The filter has two stages: command-line discovery (anything using
352
+ * our profile directory) followed by ownership verification (we spawned it, or its parent is no longer alive). The ownership stage is structural - we do not
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
354
+ * code path, including from a rejected-duplicate startup's exit handler.
355
+ *
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.
358
+ * @param processes - The current process table snapshot.
359
+ * @param profileDir - The Chrome user-data-dir to match against.
360
+ * @param ownPid - The current process's PID (anything whose parent is us is ours to kill).
361
+ * @param isProcessAlive - Predicate that returns true when the given PID is currently a live process. Injected so tests do not depend on real PIDs.
362
+ * @returns The PIDs to terminate, in process-table order.
363
+ */
364
+ export function findChromeProcessesUsingProfile(processes, profileDir, ownPid, isProcessAlive) {
365
+ const target = "--user-data-dir=" + profileDir;
366
+ return processes.filter((p) => {
367
+ // Stage 1: command-line match. Chrome puppeteer launches with --user-data-dir=<path> (equals form, no quotes). We also verify the character following
368
+ // the path is whitespace or end-of-string, otherwise "/x/y" would match "--user-data-dir=/x/yz".
369
+ const idx = p.commandLine.indexOf(target);
370
+ if (idx === -1) {
371
+ return false;
372
+ }
373
+ const after = p.commandLine.charAt(idx + target.length);
374
+ if ((after !== "") && (after !== " ") && (after !== "\t")) {
375
+ return false;
376
+ }
377
+ // Stage 2: ownership verification. Kill if we spawned it (ppid is us) or if its parent is no longer alive (orphaned from a previous PrismCast that died).
378
+ // Skip if its parent is a live unrelated PID - that process owns it, not us.
379
+ return (p.ppid === ownPid) || !isProcessAlive(p.ppid);
380
+ }).map((p) => p.pid);
381
+ }
382
+ /**
383
+ * Ensures a clean slate for browser launch by terminating any stale Chrome processes and removing orphaned profile lock files. Chrome locks its profile
384
+ * directory while running; if a previous instance crashed without releasing the lock, we cannot launch a new browser with the same profile. Discovery is done
385
+ * via the OS process table (utils/processInspector) and filtered to Chrome processes using our profile directory whose ownership belongs to us - either because
386
+ * we spawned them (ppid === process.pid) or because their parent is no longer alive (orphaned from a previous instance).
246
387
  *
247
388
  * The termination strategy escalates from SIGTERM to SIGKILL. SIGTERM is sent first, giving Chrome up to 5 seconds to flush its profile databases (LevelDB,
248
389
  * extension state, session storage) and exit cleanly. If Chrome does not exit, SIGKILL is sent as a fallback. This escalation is critical when called from the
249
- * process exit handler - Chrome may be running normally (e.g., after a capture probe timeout), and an immediate SIGKILL would corrupt its profile databases,
390
+ * process exit handler: Chrome may be running normally (e.g., after a capture probe timeout) and an immediate SIGKILL would corrupt its profile databases,
250
391
  * poisoning the Docker volume for subsequent container restarts.
251
392
  *
252
- * If no PID is available (first run or clean shutdown where the PID file was already removed), process killing is skipped entirely and only lock file cleanup
253
- * runs. When a PID file does exist but the process is gone (Docker restart with a mounted volume - the PID belongs to the previous container's PID namespace),
254
- * process.kill() throws ESRCH, which is caught gracefully.
393
+ * The ownership filter makes this function safe to call from any context, including a rejected-duplicate startup's exit handler: a duplicate that never
394
+ * spawned Chrome will find nothing matching its ownership criteria and signal nothing.
255
395
  *
256
- * This is called at startup before launching the browser and from the process exit handler as a crash recovery fallback. It's safe to call even when no stale
257
- * processes or files exist.
396
+ * Called at startup before launching the browser and from the process exit handler as a crash recovery fallback. Safe to call when no stale processes or files
397
+ * exist - the discovery and lock-file cleanup are both no-ops in the empty case.
258
398
  */
259
399
  export function killStaleChrome() {
260
400
  const profileDir = getChromeDataDir(CONFIG);
261
- const pid = loadChromePid();
262
401
  const POLL_INTERVAL_MS = 200;
263
- if (pid !== null) {
402
+ const pidsToKill = findChromeProcessesUsingProfile(listProcesses(), profileDir, process.pid, isProcessRunning);
403
+ for (const pid of pidsToKill) {
264
404
  try {
265
405
  // Send SIGTERM first to give Chrome a chance to flush its profile databases (LevelDB, extension state, session storage) before exiting. This is critical
266
406
  // when called from the process exit handler - Chrome may be running normally (e.g., after a capture probe timeout) and SIGKILL would corrupt its profile
267
407
  // databases, poisoning the Docker volume for subsequent restarts.
268
408
  process.kill(pid, "SIGTERM");
269
409
  LOG.debug("browser:lifecycle", "Sent SIGTERM to Chrome process %d.", pid);
270
- // Wait up to 5 seconds for Chrome to flush its databases and exit after SIGTERM. Containerized environments with software rendering and shared CPU may
271
- // need the full window.
272
- const TERM_WAIT_MS = 5000;
273
410
  if (!waitForChromeExit(pid, TERM_WAIT_MS, POLL_INTERVAL_MS)) {
274
411
  // SIGTERM didn't work. Escalate to SIGKILL. Orphaned Chrome processes (from a crashed parent or previous container) may not respond to SIGTERM.
275
412
  LOG.debug("browser:lifecycle", "Chrome did not exit after SIGTERM. Escalating to SIGKILL.");
@@ -279,7 +416,6 @@ export function killStaleChrome() {
279
416
  catch (_error) {
280
417
  // ESRCH - Chrome exited between the poll check and the kill call.
281
418
  }
282
- const KILL_WAIT_MS = 2000;
283
419
  if (!waitForChromeExit(pid, KILL_WAIT_MS, POLL_INTERVAL_MS)) {
284
420
  LOG.warn("Chrome process %d did not exit after %dms of signal escalation. Proceeding anyway.", pid, TERM_WAIT_MS + KILL_WAIT_MS);
285
421
  }
@@ -292,20 +428,9 @@ export function killStaleChrome() {
292
428
  LOG.warn("Failed to signal Chrome process %d: %s.", pid, formatError(error));
293
429
  }
294
430
  }
295
- clearChromePid();
296
431
  }
297
432
  // Remove stale lock and port files left behind by an unclean Chrome exit.
298
433
  cleanStaleProfileFiles(profileDir);
299
- // Mark this process as owning Chrome cleanup. The exit handler checks this flag to avoid killing Chrome that belongs to another running instance.
300
- ownsChromeCleanup = true;
301
- }
302
- /**
303
- * Returns whether this process has taken ownership of Chrome cleanup by running killStaleChrome() during startup. Used by the exit handler to avoid killing
304
- * Chrome that belongs to another running PrismCast instance.
305
- * @returns True if killStaleChrome() has run in this process.
306
- */
307
- export function canCleanupChrome() {
308
- return ownsChromeCleanup;
309
434
  }
310
435
  /**
311
436
  * Polls until the Chrome process with the given PID has exited, or the timeout expires. Uses process.kill(pid, 0) to check process existence - throws ESRCH
@@ -381,6 +506,12 @@ export function getExecutablePath() {
381
506
  }
382
507
  throw new Error("No Chrome installation found. Set CHROME_BIN environment variable.");
383
508
  }
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
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.
513
+ */
514
+ const PUPPETEER_STREAM_EXTENSION_ID = "jjndjgheafjngoipoacpjgeicjeomjli";
384
515
  /**
385
516
  * Assembles the configuration options for launching Chrome with Puppeteer. These options are critical for reliable streaming:
386
517
  *
@@ -397,6 +528,13 @@ export function buildLaunchOptions() {
397
528
  * --allow-running-insecure-content: Some streaming sites serve mixed HTTP/HTTPS content. Without this flag, the browser blocks HTTP resources on HTTPS
398
529
  * pages, which can break video players that load some assets over HTTP.
399
530
  *
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.
537
+ *
400
538
  * --autoplay-policy=no-user-gesture-required: Allows video and audio to play without requiring a user click first. Essential for automated streaming
401
539
  * since we cannot simulate genuine user interaction for autoplay policy purposes.
402
540
  *
@@ -428,6 +566,7 @@ export function buildLaunchOptions() {
428
566
  */
429
567
  args: [
430
568
  "--allow-running-insecure-content",
569
+ "--allowlisted-extension-id=" + PUPPETEER_STREAM_EXTENSION_ID,
431
570
  "--autoplay-policy=no-user-gesture-required",
432
571
  "--disable-background-media-suspend",
433
572
  "--disable-background-networking",
@@ -489,9 +628,11 @@ export function buildLaunchOptions() {
489
628
  * @returns The launched browser instance.
490
629
  */
491
630
  async function launchWithCustomArgs(opts) {
492
- // 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
493
- // --load-extension and --disable-extensions-except arguments pointing to node_modules, but these paths don't exist in the packaged executable. We replace
494
- // 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.
495
636
  if (process.pkg) {
496
637
  const extensionPath = getExtensionDir(CONFIG);
497
638
  // Remove any existing extension arguments and add our own pointing to the extracted extension.
@@ -524,11 +665,13 @@ function formatGpuSuffix(gpu) {
524
665
  return " (software rendering)";
525
666
  }
526
667
  /**
527
- * Detects the maximum supported viewport dimensions based on the user's display. This function measures the available screen space and subtracts browser chrome to
528
- * 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.
529
672
  *
530
- * 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
531
- * 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.
532
675
  * @param browser - The browser instance to use for detection.
533
676
  */
534
677
  async function detectDisplayDimensions(browser) {
@@ -682,152 +825,176 @@ async function detectDisplayDimensions(browser) {
682
825
  }
683
826
  }
684
827
  /**
685
- * Handles browser disconnection events by cleaning up all active streams and resetting browser state. This function is called when the browser crashes, is closed
686
- * manually, or loses its connection for any other reason.
687
- *
688
- * 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
689
- * to interact with them. We proactively clean up by:
690
- * 1. Setting currentBrowser to null so the next stream request will launch a fresh browser
691
- * 2. Stopping all health monitors (they would fail trying to check page state)
692
- * 3. Removing all entries from activeStreams (the pages are gone)
693
- *
694
- * 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.
695
834
  */
696
- function handleBrowserDisconnect() {
697
- // Clear the browser reference, launch timestamp, cached version, user agent, and stale PID so getCurrentBrowser() will launch a new instance on the next call.
698
- currentBrowser = null;
699
- browserLaunchTime = null;
700
- chromePid = 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.
701
840
  currentChromeVersion = null;
702
841
  setChromeUserAgent(null);
703
- // 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.
704
843
  if (restartQuietTimer) {
705
844
  clearTimeout(restartQuietTimer);
706
845
  restartQuietTimer = null;
707
846
  }
708
- // 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.
709
848
  clearChannelSelectionCaches();
710
- // 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
711
- // 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).
712
851
  if (clearLoginState() && !gracefulShutdownInProgress) {
713
- LOG.info("Login mode ended due to browser disconnect.");
852
+ LOG.info("Login mode ended due to browser readiness loss.");
714
853
  }
715
- // Only log the error for unexpected disconnects. During graceful shutdown, closeBrowser() set the flag and this disconnect is intentional.
716
- if (!gracefulShutdownInProgress) {
717
- LOG.error("Browser disconnected unexpectedly. All active streams will be terminated.");
718
- }
719
- // Clean up all active streams using the authoritative terminateStream function for consistent cleanup. This is kept even during graceful shutdown as a defensive
720
- // measure - terminateStream() is idempotent, so if streams were already terminated by the caller, this harmlessly iterates an empty array.
721
- const streams = getAllStreams();
722
- for (const streamInfo of streams) {
723
- 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);
724
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();
725
861
  // Emit system status after stream cleanup. Skip during graceful shutdown since no clients are listening and the process is exiting.
726
862
  if (!gracefulShutdownInProgress) {
727
863
  void emitCurrentSystemStatus();
728
864
  }
729
865
  }
730
866
  /**
731
- * 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:
732
- *
733
- * - Returning the existing browser if it's still connected
734
- * - Launching a new browser if none exists or the previous one disconnected
735
- * - Serializing concurrent callers so only one launch occurs at a time
736
- * - Waiting for the puppeteer-stream extension to initialize
737
- * - Setting up disconnect handlers for crash recovery
738
- * @returns The browser instance.
739
- * @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.
740
871
  */
741
- export async function getCurrentBrowser() {
742
- // Fast path: if we have a browser and it's still connected, return it immediately. The connected property verifies the DevTools Protocol connection is
743
- // still alive.
744
- if (currentBrowser?.connected) {
745
- return currentBrowser;
746
- }
747
- // 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
748
- // Chrome process. Two concurrent launches with the same profile directory would contend on Chrome's profile lock.
749
- if (browserLaunchPromise) {
750
- return browserLaunchPromise;
751
- }
752
- // We need to launch a new browser. Store the promise so concurrent callers can piggyback on this launch.
753
- browserLaunchPromise = launchBrowser();
754
- try {
755
- 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.");
756
877
  }
757
- finally {
758
- 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;
759
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;
760
906
  }
761
907
  /**
762
- * Launches a new browser instance and performs post-launch initialization (extension readiness, display detection, version capture). This is the inner launch
763
- * function called by getCurrentBrowser() and serialized by the browserLaunchPromise mutex.
764
- * @returns The browser instance.
765
- * @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.
766
915
  */
767
- 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() {
768
929
  const browserElapsed = startTimer();
769
- // 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());
770
933
  try {
771
- const options = buildLaunchOptions();
772
- // The launch function from puppeteer-stream wraps standard Puppeteer launch to inject the streaming extension. We pass our custom launch function that
773
- // handles packaged executable extension paths.
774
- currentBrowser = await launch({ launch: launchWithCustomArgs }, options);
775
- // Persist the Chrome PID for cross-platform process cleanup. The PID file survives Node crashes, allowing the next startup to find and terminate orphaned
776
- // Chrome processes without relying on Unix-only tools like pkill/pgrep.
777
- const launchedPid = currentBrowser.process()?.pid;
778
- if (launchedPid) {
779
- saveChromePid(launchedPid);
780
- }
781
- else {
782
- LOG.warn("Chrome process PID is unavailable. Orphaned process cleanup after a crash will be limited to lock file removal.");
783
- }
784
- // Register a handler for browser disconnection. This ensures we clean up properly if the browser crashes or is closed unexpectedly.
785
- currentBrowser.on("disconnected", handleBrowserDisconnect);
786
934
  LOG.debug("timing:browser", "Chrome process spawned. (+%sms)", browserElapsed());
787
- // Poll for the puppeteer-stream extension to finish initializing. The extension injects a START_RECORDING function into its options page context. We poll
788
- // 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
789
- // 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.
790
941
  try {
791
- const extensionPage = await getExtensionPage(currentBrowser);
942
+ const extensionPage = await getExtensionPage(browser);
792
943
  await extensionPage.waitForFunction("typeof START_RECORDING === 'function'", { timeout: CONFIG.browser.initTimeout });
793
944
  }
794
- catch {
795
- // If the extension page isn't found or START_RECORDING doesn't appear within the timeout, log a warning and proceed. The per-stream
796
- // assertExtensionLoaded() in puppeteer-stream will retry before each capture attempt, so this isn't fatal.
797
- 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 });
798
947
  }
799
948
  LOG.debug("timing:browser", "Extension initialized. (+%sms)", browserElapsed());
800
- // Detect display dimensions to determine maximum supported viewport. This must happen before we start streaming so the preset system can degrade to a
801
- // smaller preset if needed.
802
- 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);
803
965
  LOG.debug("timing:browser", "Display detection complete. (+%sms)", browserElapsed());
804
- // Log the Chrome version for diagnostic reference. This helps correlate browser behavior changes (tab unresponsiveness, memory pressure, capture issues)
805
- // 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.
806
- const chromeVersion = await currentBrowser.version();
807
- const userAgent = await currentBrowser.userAgent();
808
- 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();
809
970
  currentChromeVersion = chromeVersion;
810
971
  setChromeUserAgent(userAgent);
811
972
  const gpu = getGpuCapabilities();
812
973
  const gpuSuffix = gpu ? formatGpuSuffix(gpu) : "";
813
974
  LOG.info("Chrome ready: %s%s.", chromeVersion, gpuSuffix);
814
975
  LOG.debug("timing:browser", "Browser ready. Total: %sms.", browserElapsed());
815
- // Emit system status update for SSE subscribers.
816
- await emitCurrentSystemStatus();
817
- // Start background precaching of selected service channel lineups. Fire-and-forget - the setTimeout inside startPrecaching() ensures the actual work is fully
818
- // 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.
819
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;
820
986
  }
821
987
  catch (error) {
822
988
  LOG.error("Failed to launch browser: %s.", formatError(error));
823
- // Clear the browser reference, launch timestamp, cached version, and user agent on failure so the next call will attempt to launch again.
824
- currentBrowser = null;
825
- 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.
826
993
  currentChromeVersion = null;
827
994
  setChromeUserAgent(null);
995
+ await closeBrowserInstance(browser);
828
996
  throw error;
829
997
  }
830
- return currentBrowser;
831
998
  }
832
999
  /**
833
1000
  * Returns the Chrome version string captured when the browser launched, or null if the browser is not connected.
@@ -842,14 +1009,15 @@ export function getChromeVersion() {
842
1009
  * @returns The browser instance, or null if not running.
843
1010
  */
844
1011
  export function getBrowserInstance() {
845
- return currentBrowser;
1012
+ return supervisor.current();
846
1013
  }
847
1014
  /**
848
1015
  * Checks if the browser is currently connected and usable. This is a synchronous check that can be used before attempting browser operations.
849
1016
  * @returns True if the browser is connected and ready for use, false otherwise.
850
1017
  */
851
1018
  export function isBrowserConnected() {
852
- return !!currentBrowser && currentBrowser.connected;
1019
+ const browser = supervisor.current();
1020
+ return !!browser && browser.connected;
853
1021
  }
854
1022
  /**
855
1023
  * Resizes the browser window to the effective viewport and minimizes it. This function combines viewport sizing with minimization to ensure the window is
@@ -861,18 +1029,19 @@ export function isBrowserConnected() {
861
1029
  */
862
1030
  export async function minimizeBrowserWindow() {
863
1031
  // Guard against calling this when no browser is running.
864
- if (!currentBrowser?.connected) {
1032
+ const browser = supervisor.current();
1033
+ if (!browser?.connected) {
865
1034
  return;
866
1035
  }
867
1036
  let tempPage = null;
868
1037
  let usingTempPage = false;
869
1038
  try {
870
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.
871
- const existingPages = await currentBrowser.pages();
1040
+ const existingPages = await browser.pages();
872
1041
  let targetPage = existingPages.find((p) => !p.isClosed()) ?? null;
873
1042
  // If no existing pages, we must create a temporary one. This is less ideal but necessary to get a CDP session target.
874
1043
  if (!targetPage) {
875
- tempPage = await currentBrowser.newPage();
1044
+ tempPage = await browser.newPage();
876
1045
  usingTempPage = true;
877
1046
  // Register the temp page so stale cleanup knows it's ours.
878
1047
  registerManagedPage(tempPage);
@@ -903,16 +1072,17 @@ export async function minimizeBrowserWindow() {
903
1072
  }
904
1073
  }
905
1074
  /**
906
- * 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.
907
1076
  * @returns Array of pages, or empty array if the browser is not connected.
908
1077
  */
909
1078
  export async function getBrowserPages() {
910
- // Guard against calling this when no browser is running.
911
- if (!currentBrowser?.connected) {
1079
+ // Guard against calling this when no ready browser is running.
1080
+ const browser = supervisor.current();
1081
+ if (!browser?.connected) {
912
1082
  return [];
913
1083
  }
914
1084
  try {
915
- return await currentBrowser.pages();
1085
+ return await browser.pages();
916
1086
  }
917
1087
  catch (_error) {
918
1088
  // If getting pages fails (browser disconnecting, etc.), return empty array rather than throwing.
@@ -920,67 +1090,87 @@ export async function getBrowserPages() {
920
1090
  }
921
1091
  }
922
1092
  /**
923
- * 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
924
- * 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).
925
1098
  *
926
1099
  * Chrome termination uses Puppeteer's ChildProcess handle and its `exit` event for detection:
927
1100
  *
928
- * - browserRef.close() sends CDP Browser.close and waits for WebSocket teardown, which hangs 3-5 seconds even after Chrome exits.
929
- * - 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.
930
1103
  * - Synchronous polling (Atomics.wait) blocks the event loop, preventing Node from processing SIGCHLD to reap the child - Chrome becomes a zombie regardless
931
1104
  * of how SIGTERM was sent.
932
1105
  *
933
- * 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
934
- * 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.
935
1110
  */
936
- export async function closeBrowser() {
937
- // 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
938
- // calls to closeBrowser().
939
- setGracefulShutdown(true);
940
- const browserRef = currentBrowser;
941
- // Clear the reference, launch timestamp, cached version, and user agent early to prevent any new operations from using it.
942
- currentBrowser = null;
943
- browserLaunchTime = null;
944
- currentChromeVersion = null;
945
- setChromeUserAgent(null);
946
- if (!browserRef) {
947
- return;
948
- }
949
- // Send SIGTERM through Puppeteer's ChildProcess handle and wait for the `exit` event. The ChildProcess handle is only available when Puppeteer launched
950
- // Chrome (not when connecting to an existing browser), but PrismCast always launches Chrome directly.
951
- 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();
952
1118
  if (chromeProcess?.pid && !chromeProcess.killed) {
953
- const TERM_WAIT_MS = 5000;
954
- const KILL_WAIT_MS = 2000;
955
- // 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
956
- // 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.
957
1121
  const { promise: exitPromise, resolve: signalExit } = Promise.withResolvers();
958
1122
  chromeProcess.on("exit", () => { signalExit(true); });
959
1123
  chromeProcess.kill("SIGTERM");
960
1124
  LOG.debug("browser:lifecycle", "Sent SIGTERM to Chrome process %d.", chromeProcess.pid);
961
- // Wait for Chrome to exit after SIGTERM, with a timeout. If Chrome doesn't exit in time, escalate to SIGKILL.
962
- const termTimeout = cancellableTimeout(TERM_WAIT_MS);
963
- const exitedAfterTerm = await Promise.race([exitPromise, termTimeout.promise]);
964
- 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);
965
1127
  if (!exitedAfterTerm) {
966
- // 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
967
1129
  // respond to SIGTERM.
968
1130
  LOG.debug("browser:lifecycle", "Chrome did not exit after SIGTERM. Escalating to SIGKILL.");
969
1131
  chromeProcess.kill("SIGKILL");
970
- const killTimeout = cancellableTimeout(KILL_WAIT_MS);
971
- await Promise.race([exitPromise, killTimeout.promise]);
972
- 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);
973
1134
  }
974
1135
  }
975
- // Disconnect the Puppeteer WebSocket after Chrome has exited. This cleans up Puppeteer's internal state (event listeners, pending CDP calls) without
976
- // waiting for the WebSocket close handshake to complete on a dead connection.
977
- if (browserRef.connected) {
978
- 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
+ });
979
1143
  }
980
- // Clear the PID file and remove stale Chrome profile lock files.
981
- clearChromePid();
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
1145
+ // discover any leftover Chrome via the OS process table.
982
1146
  cleanStaleProfileFiles(getChromeDataDir(CONFIG));
983
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
+ }
984
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
985
1175
  * edge cases in stream lifecycle management. Each orphaned page consumes memory and may continue running JavaScript, so we periodically clean them up.
986
1176
  *
@@ -996,6 +1186,12 @@ export async function closeBrowser() {
996
1186
  * briefly untracked during stream initialization or cleanup.
997
1187
  *
998
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.
999
1195
  */
1000
1196
  /**
1001
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
@@ -1003,17 +1199,18 @@ export async function closeBrowser() {
1003
1199
  *
1004
1200
  * The cleanup uses a multi-stage filtering process:
1005
1201
  * 1. Only consider pages we created (in managedPageIds)
1006
- * 2. Exclude pages associated with active streams
1202
+ * 2. Exclude pages associated with active streams, and pages whose stream setup is still in flight
1007
1203
  * 3. Apply a grace period before closing (to handle race conditions)
1008
1204
  * 4. Preserve at least one page to keep the browser alive
1009
1205
  */
1010
1206
  export async function cleanupStalePages() {
1011
- // Guard against calling this when no browser is running.
1012
- if (!currentBrowser?.connected) {
1207
+ // Guard against calling this when no ready browser is running.
1208
+ const browser = supervisor.current();
1209
+ if (!browser?.connected) {
1013
1210
  return;
1014
1211
  }
1015
1212
  try {
1016
- const pages = await currentBrowser.pages();
1213
+ const pages = await browser.pages();
1017
1214
  // If there's only one page or fewer, we must preserve it to keep the browser alive. Don't attempt cleanup.
1018
1215
  if (pages.length <= 1) {
1019
1216
  return;
@@ -1029,53 +1226,37 @@ export async function cleanupStalePages() {
1029
1226
  }
1030
1227
  }
1031
1228
  const now = Date.now();
1032
- const gracePeriod = CONFIG.recovery.stalePageGracePeriod;
1033
- // Build a list of pages that are candidates for cleanup. A page is a candidate if:
1034
- // - It has a managed page ID (was created by PrismCast)
1035
- // - It is not associated with any active stream
1036
- // - It has been stale for longer than the grace period
1037
- const candidatePages = [];
1038
- // Track which managed page IDs we've seen in the current browser pages. Used for cleanup of stale tracking data.
1039
- 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 = [];
1040
1233
  for (const page of pages) {
1041
1234
  const pageId = getManagedPageId(page);
1042
- // Skip pages we didn't create. This preserves manually opened pages and site popups.
1043
- if (!pageId) {
1044
- continue;
1235
+ pageIds.push(pageId);
1236
+ if (pageId !== undefined) {
1237
+ idToPage.set(pageId, page);
1045
1238
  }
1046
- currentManagedIds.add(pageId);
1047
- // Skip pages associated with active streams.
1048
- if (activePageIds.has(pageId)) {
1049
- // If this page was previously marked as potentially stale, remove it from tracking since it's now active.
1050
- potentiallyStalePages.delete(pageId);
1051
- continue;
1052
- }
1053
- // This page is potentially stale. Track when we first observed it as such.
1054
- if (!potentiallyStalePages.has(pageId)) {
1055
- potentiallyStalePages.set(pageId, now);
1056
- // Don't close it yet - wait for the grace period.
1057
- continue;
1058
- }
1059
- // Check if the grace period has elapsed.
1060
- const firstSeenStale = potentiallyStalePages.get(pageId) ?? now;
1061
- if ((now - firstSeenStale) < gracePeriod) {
1062
- // Grace period hasn't elapsed yet. Leave this page alone for now.
1063
- continue;
1064
- }
1065
- // This page has been stale for longer than the grace period. It's a candidate for cleanup.
1066
- candidatePages.push({ page, pageId });
1067
1239
  }
1068
- // Clean up the potentiallyStalePages map by removing entries for pages that no longer exist. This handles cases where pages were closed by other means.
1069
- for (const trackedId of potentiallyStalePages.keys()) {
1070
- if (!currentManagedIds.has(trackedId)) {
1071
- potentiallyStalePages.delete(trackedId);
1072
- }
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);
1073
1252
  }
1074
- // Calculate how many pages we can close while still keeping at least one page open.
1075
- const maxToClose = Math.max(0, pages.length - 1 - activePageIds.size);
1076
- const pagesToClose = candidatePages.slice(0, maxToClose);
1077
1253
  let closedCount = 0;
1078
- 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
+ }
1079
1260
  try {
1080
1261
  // Unregister the page before closing to prevent any race with re-registration.
1081
1262
  managedPageIds.delete(pageId);
@@ -1121,16 +1302,29 @@ export function stopStalePageCleanup() {
1121
1302
  */
1122
1303
  /**
1123
1304
  * Checks whether the browser qualifies for an opportunistic restart. Called periodically by the restart check interval. The check skips when any of these
1124
- * conditions hold: graceful shutdown in progress, login mode active, browser not connected, browser age below threshold. If active streams exist, any pending
1125
- * 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.
1126
1308
  */
1127
1309
  function checkBrowserRestart() {
1128
- // Skip if the server is shutting down, login mode is active, or the browser is not connected.
1129
- if (gracefulShutdownInProgress || isLoginModeActive() || !currentBrowser || !currentBrowser.connected || !browserLaunchTime) {
1310
+ // Skip if the server is shutting down or login mode is active.
1311
+ if (gracefulShutdownInProgress || isLoginModeActive()) {
1312
+ return;
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)) {
1130
1319
  return;
1131
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
+ }
1132
1326
  // Skip if the browser has not exceeded the maximum age.
1133
- const age = Date.now() - browserLaunchTime;
1327
+ const age = realClock.now() - launchTime;
1134
1328
  if (age < BROWSER_MAX_AGE) {
1135
1329
  return;
1136
1330
  }
@@ -1158,21 +1352,37 @@ function checkBrowserRestart() {
1158
1352
  async function executeBrowserRestart() {
1159
1353
  // Clear the timer handle.
1160
1354
  restartQuietTimer = null;
1161
- // 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
1162
- // mode was activated, or the browser disconnected on its own).
1163
- 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)) {
1164
1360
  LOG.debug("browser:lifecycle", "Browser restart aborted - preconditions no longer met.");
1165
1361
  return;
1166
1362
  }
1167
- const age = Date.now() - browserLaunchTime;
1363
+ const age = realClock.now() - launchTime;
1168
1364
  const hours = Math.floor(age / 3600000);
1169
1365
  const minutes = Math.floor((age % 3600000) / 60000);
1170
1366
  LOG.info("Restarting browser for scheduled maintenance (uptime: %sh %sm).", hours, minutes);
1171
1367
  try {
1172
- // closeBrowser() sets gracefulShutdownInProgress = true internally and performs SIGTERM-based Chrome termination.
1173
- await closeBrowser();
1174
- // Reset the flag since the server is NOT shutting down - only the browser is restarting.
1175
- 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
+ }
1176
1386
  // Launch a fresh browser instance so it is ready for the next stream request.
1177
1387
  await getCurrentBrowser();
1178
1388
  // Minimize the new window to reduce GPU usage and desktop clutter.
@@ -1181,8 +1391,6 @@ async function executeBrowserRestart() {
1181
1391
  }
1182
1392
  catch (error) {
1183
1393
  LOG.error("Browser restart failed: %s.", formatError(error));
1184
- // Ensure the graceful shutdown flag is cleared even on failure so new stream requests can still launch a browser.
1185
- setGracefulShutdown(false);
1186
1394
  }
1187
1395
  }
1188
1396
  /**