prismcast 1.5.2 → 1.7.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 (239) hide show
  1. package/README.md +44 -28
  2. package/dist/app.d.ts +5 -0
  3. package/dist/app.js +63 -5
  4. package/dist/app.js.map +1 -1
  5. package/dist/browser/cdp.js +1 -1
  6. package/dist/browser/cdp.js.map +1 -1
  7. package/dist/browser/channelSelection.d.ts +10 -34
  8. package/dist/browser/channelSelection.js +31 -96
  9. package/dist/browser/channelSelection.js.map +1 -1
  10. package/dist/browser/display.d.ts +19 -0
  11. package/dist/browser/display.js +16 -11
  12. package/dist/browser/display.js.map +1 -1
  13. package/dist/browser/index.d.ts +31 -54
  14. package/dist/browser/index.js +334 -272
  15. package/dist/browser/index.js.map +1 -1
  16. package/dist/browser/login.d.ts +65 -0
  17. package/dist/browser/login.js +193 -0
  18. package/dist/browser/login.js.map +1 -0
  19. package/dist/browser/precaching.js +15 -3
  20. package/dist/browser/precaching.js.map +1 -1
  21. package/dist/browser/tuning/comcastPolymer.d.ts +23 -0
  22. package/dist/browser/tuning/comcastPolymer.js +655 -0
  23. package/dist/browser/tuning/comcastPolymer.js.map +1 -0
  24. package/dist/browser/tuning/cox.d.ts +2 -0
  25. package/dist/browser/tuning/cox.js +14 -0
  26. package/dist/browser/tuning/cox.js.map +1 -0
  27. package/dist/browser/tuning/directv.js +14 -1
  28. package/dist/browser/tuning/directv.js.map +1 -1
  29. package/dist/browser/tuning/fox.js +14 -2
  30. package/dist/browser/tuning/fox.js.map +1 -1
  31. package/dist/browser/tuning/hbo.js +12 -1
  32. package/dist/browser/tuning/hbo.js.map +1 -1
  33. package/dist/browser/tuning/hulu.js +24 -1
  34. package/dist/browser/tuning/hulu.js.map +1 -1
  35. package/dist/browser/tuning/shared.d.ts +46 -0
  36. package/dist/browser/tuning/shared.js +100 -0
  37. package/dist/browser/tuning/shared.js.map +1 -0
  38. package/dist/browser/tuning/sling.js +35 -5
  39. package/dist/browser/tuning/sling.js.map +1 -1
  40. package/dist/browser/tuning/spectrum.js +13 -1
  41. package/dist/browser/tuning/spectrum.js.map +1 -1
  42. package/dist/browser/tuning/thumbnailRow.js +1 -1
  43. package/dist/browser/tuning/thumbnailRow.js.map +1 -1
  44. package/dist/browser/tuning/tileClick.js +1 -1
  45. package/dist/browser/tuning/tileClick.js.map +1 -1
  46. package/dist/browser/tuning/xfinity.d.ts +2 -0
  47. package/dist/browser/tuning/xfinity.js +14 -0
  48. package/dist/browser/tuning/xfinity.js.map +1 -0
  49. package/dist/browser/tuning/youtubeTv.js +15 -1
  50. package/dist/browser/tuning/youtubeTv.js.map +1 -1
  51. package/dist/browser/video.d.ts +20 -0
  52. package/dist/browser/video.js +168 -124
  53. package/dist/browser/video.js.map +1 -1
  54. package/dist/channels/index.d.ts +2 -2
  55. package/dist/channels/index.js +2075 -683
  56. package/dist/channels/index.js.map +1 -1
  57. package/dist/config/health.d.ts +28 -28
  58. package/dist/config/health.js +72 -54
  59. package/dist/config/health.js.map +1 -1
  60. package/dist/config/index.js +29 -64
  61. package/dist/config/index.js.map +1 -1
  62. package/dist/config/paths.d.ts +12 -0
  63. package/dist/config/paths.js +16 -0
  64. package/dist/config/paths.js.map +1 -1
  65. package/dist/config/profiles.d.ts +3 -2
  66. package/dist/config/profiles.js +45 -34
  67. package/dist/config/profiles.js.map +1 -1
  68. package/dist/config/providerPacks.js +4 -1
  69. package/dist/config/providerPacks.js.map +1 -1
  70. package/dist/config/providers.d.ts +19 -5
  71. package/dist/config/providers.js +123 -93
  72. package/dist/config/providers.js.map +1 -1
  73. package/dist/config/sites.d.ts +28 -0
  74. package/dist/config/sites.js +104 -127
  75. package/dist/config/sites.js.map +1 -1
  76. package/dist/config/userChannels.d.ts +53 -2
  77. package/dist/config/userChannels.js +222 -13
  78. package/dist/config/userChannels.js.map +1 -1
  79. package/dist/config/userConfig.d.ts +2 -1
  80. package/dist/config/userConfig.js +6 -15
  81. package/dist/config/userConfig.js.map +1 -1
  82. package/dist/config/userProfiles.d.ts +0 -1
  83. package/dist/config/userProfiles.js +18 -10
  84. package/dist/config/userProfiles.js.map +1 -1
  85. package/dist/hdhr/channelMap.d.ts +1 -1
  86. package/dist/hdhr/channelMap.js +4 -4
  87. package/dist/hdhr/channelMap.js.map +1 -1
  88. package/dist/index.js +25 -22
  89. package/dist/index.js.map +1 -1
  90. package/dist/native/decrypt.d.ts +32 -0
  91. package/dist/native/decrypt.js +85 -0
  92. package/dist/native/decrypt.js.map +1 -0
  93. package/dist/native/index.d.ts +61 -0
  94. package/dist/native/index.js +342 -0
  95. package/dist/native/index.js.map +1 -0
  96. package/dist/native/intercept.d.ts +35 -0
  97. package/dist/native/intercept.js +184 -0
  98. package/dist/native/intercept.js.map +1 -0
  99. package/dist/native/probe.d.ts +49 -0
  100. package/dist/native/probe.js +294 -0
  101. package/dist/native/probe.js.map +1 -0
  102. package/dist/native/proxy.d.ts +54 -0
  103. package/dist/native/proxy.js +947 -0
  104. package/dist/native/proxy.js.map +1 -0
  105. package/dist/routes/components.d.ts +43 -0
  106. package/dist/routes/components.js +122 -0
  107. package/dist/routes/components.js.map +1 -1
  108. package/dist/routes/config/channels/index.d.ts +2 -2
  109. package/dist/routes/config/channels/index.js +1 -1
  110. package/dist/routes/config/channels/index.js.map +1 -1
  111. package/dist/routes/config/channels/routes.js +443 -65
  112. package/dist/routes/config/channels/routes.js.map +1 -1
  113. package/dist/routes/config/channels/table.d.ts +76 -0
  114. package/dist/routes/config/channels/table.js +369 -89
  115. package/dist/routes/config/channels/table.js.map +1 -1
  116. package/dist/routes/config/index.d.ts +1 -1
  117. package/dist/routes/config/index.js +1 -1
  118. package/dist/routes/config/index.js.map +1 -1
  119. package/dist/routes/config/providers.d.ts +6 -6
  120. package/dist/routes/config/providers.js +73 -99
  121. package/dist/routes/config/providers.js.map +1 -1
  122. package/dist/routes/debug.js +5 -5
  123. package/dist/routes/debug.js.map +1 -1
  124. package/dist/routes/hls.js +12 -2
  125. package/dist/routes/hls.js.map +1 -1
  126. package/dist/routes/index.d.ts +0 -15
  127. package/dist/routes/index.js +2 -16
  128. package/dist/routes/index.js.map +1 -1
  129. package/dist/routes/providers.js +106 -3
  130. package/dist/routes/providers.js.map +1 -1
  131. package/dist/routes/root/content.js +124 -47
  132. package/dist/routes/root/content.js.map +1 -1
  133. package/dist/routes/root/index.js +2 -1
  134. package/dist/routes/root/index.js.map +1 -1
  135. package/dist/routes/root/scripts/channels.js +772 -312
  136. package/dist/routes/root/scripts/channels.js.map +1 -1
  137. package/dist/routes/root/scripts/config.js +596 -544
  138. package/dist/routes/root/scripts/config.js.map +1 -1
  139. package/dist/routes/root/scripts/index.d.ts +1 -0
  140. package/dist/routes/root/scripts/index.js +1 -0
  141. package/dist/routes/root/scripts/index.js.map +1 -1
  142. package/dist/routes/root/scripts/shared.d.ts +6 -0
  143. package/dist/routes/root/scripts/shared.js +391 -0
  144. package/dist/routes/root/scripts/shared.js.map +1 -0
  145. package/dist/routes/root/scripts/status.js +49 -33
  146. package/dist/routes/root/scripts/status.js.map +1 -1
  147. package/dist/routes/root/styles.js +117 -10
  148. package/dist/routes/root/styles.js.map +1 -1
  149. package/dist/service/commands.js +2 -1
  150. package/dist/service/commands.js.map +1 -1
  151. package/dist/streaming/fmp4Segmenter.d.ts +5 -0
  152. package/dist/streaming/fmp4Segmenter.js +146 -42
  153. package/dist/streaming/fmp4Segmenter.js.map +1 -1
  154. package/dist/streaming/hls.d.ts +24 -19
  155. package/dist/streaming/hls.js +603 -297
  156. package/dist/streaming/hls.js.map +1 -1
  157. package/dist/streaming/hlsResume.d.ts +16 -3
  158. package/dist/streaming/hlsResume.js +35 -8
  159. package/dist/streaming/hlsResume.js.map +1 -1
  160. package/dist/streaming/hlsSegments.d.ts +41 -2
  161. package/dist/streaming/hlsSegments.js +131 -28
  162. package/dist/streaming/hlsSegments.js.map +1 -1
  163. package/dist/streaming/lifecycle.js +27 -13
  164. package/dist/streaming/lifecycle.js.map +1 -1
  165. package/dist/streaming/monitor.d.ts +1 -0
  166. package/dist/streaming/monitor.js +862 -474
  167. package/dist/streaming/monitor.js.map +1 -1
  168. package/dist/streaming/mp4Parser.d.ts +52 -6
  169. package/dist/streaming/mp4Parser.js +156 -30
  170. package/dist/streaming/mp4Parser.js.map +1 -1
  171. package/dist/streaming/mpegts.d.ts +2 -2
  172. package/dist/streaming/mpegts.js +156 -109
  173. package/dist/streaming/mpegts.js.map +1 -1
  174. package/dist/streaming/playlistBuilder.d.ts +38 -0
  175. package/dist/streaming/playlistBuilder.js +66 -0
  176. package/dist/streaming/playlistBuilder.js.map +1 -0
  177. package/dist/streaming/preroll.d.ts +126 -0
  178. package/dist/streaming/preroll.js +434 -0
  179. package/dist/streaming/preroll.js.map +1 -0
  180. package/dist/streaming/pretune.d.ts +8 -0
  181. package/dist/streaming/pretune.js +226 -0
  182. package/dist/streaming/pretune.js.map +1 -0
  183. package/dist/streaming/recovery.d.ts +0 -6
  184. package/dist/streaming/recovery.js +0 -8
  185. package/dist/streaming/recovery.js.map +1 -1
  186. package/dist/streaming/registry.d.ts +57 -9
  187. package/dist/streaming/registry.js +26 -7
  188. package/dist/streaming/registry.js.map +1 -1
  189. package/dist/streaming/setup.d.ts +8 -1
  190. package/dist/streaming/setup.js +71 -27
  191. package/dist/streaming/setup.js.map +1 -1
  192. package/dist/streaming/showInfo.d.ts +29 -4
  193. package/dist/streaming/showInfo.js +210 -38
  194. package/dist/streaming/showInfo.js.map +1 -1
  195. package/dist/streaming/statusEmitter.d.ts +20 -6
  196. package/dist/streaming/statusEmitter.js +31 -7
  197. package/dist/streaming/statusEmitter.js.map +1 -1
  198. package/dist/types/channels.d.ts +32 -0
  199. package/dist/types/config.d.ts +2 -2
  200. package/dist/types/index.d.ts +2 -2
  201. package/dist/types/index.js.map +1 -1
  202. package/dist/types/profiles.d.ts +13 -1
  203. package/dist/types/selection.d.ts +5 -1
  204. package/dist/types/shared.d.ts +1 -1
  205. package/dist/types/streaming.d.ts +7 -0
  206. package/dist/utils/chromeFetch.d.ts +20 -0
  207. package/dist/utils/chromeFetch.js +43 -0
  208. package/dist/utils/chromeFetch.js.map +1 -0
  209. package/dist/utils/debugFilter.d.ts +0 -5
  210. package/dist/utils/debugFilter.js +20 -11
  211. package/dist/utils/debugFilter.js.map +1 -1
  212. package/dist/utils/delay.d.ts +28 -0
  213. package/dist/utils/delay.js +35 -1
  214. package/dist/utils/delay.js.map +1 -1
  215. package/dist/utils/evaluate.js +6 -11
  216. package/dist/utils/evaluate.js.map +1 -1
  217. package/dist/utils/ffmpeg.d.ts +14 -12
  218. package/dist/utils/ffmpeg.js +114 -134
  219. package/dist/utils/ffmpeg.js.map +1 -1
  220. package/dist/utils/format.d.ts +6 -0
  221. package/dist/utils/format.js +8 -0
  222. package/dist/utils/format.js.map +1 -1
  223. package/dist/utils/index.d.ts +3 -0
  224. package/dist/utils/index.js +3 -0
  225. package/dist/utils/index.js.map +1 -1
  226. package/dist/utils/logEmitter.js +1 -1
  227. package/dist/utils/logEmitter.js.map +1 -1
  228. package/dist/utils/pid.d.ts +40 -0
  229. package/dist/utils/pid.js +83 -0
  230. package/dist/utils/pid.js.map +1 -0
  231. package/dist/utils/retry.d.ts +15 -9
  232. package/dist/utils/retry.js +7 -23
  233. package/dist/utils/retry.js.map +1 -1
  234. package/dist/utils/timing.d.ts +5 -0
  235. package/dist/utils/timing.js +13 -0
  236. package/dist/utils/timing.js.map +1 -0
  237. package/dist/utils/version.js +2 -2
  238. package/dist/utils/version.js.map +1 -1
  239. package/package.json +7 -7
@@ -1,17 +1,18 @@
1
- import { LOG, evaluateWithAbort, formatError, startTimer } from "../utils/index.js";
1
+ import { getGpuCapabilities, setBrowserChrome, setGpuCapabilities, setMaxSupportedViewport } from "./display.js";
2
+ import { LOG, cancellableTimeout, clearPidFile, evaluateWithAbort, formatError, isProcessRunning, readPidFile, startTimer, writePidFile } from "../utils/index.js";
3
+ import { clearLoginState, isLoginModeActive, setBrowserAccessors } from "./login.js";
2
4
  import { getAllStreams, getStreamCount } from "../streaming/registry.js";
3
- import { getChromeDataDir, getDataDir, getExtensionDir } from "../config/paths.js";
5
+ import { getChromeDataDir, getChromePidFilePath, getDataDir, getExtensionDir } from "../config/paths.js";
4
6
  import { getEffectivePreset, getPresetViewport } from "../config/presets.js";
5
7
  import { getExtensionPage, getStream, launch } from "puppeteer-stream";
6
8
  import { resizeAndMinimizeWindow, unminimizeWindow } from "./cdp.js";
7
- import { setBrowserChrome, setMaxSupportedViewport } from "./display.js";
8
9
  import { CONFIG } from "../config/index.js";
9
10
  import { clearChannelSelectionCaches } from "./channelSelection.js";
10
11
  import { emitSystemStatusChanged } from "../streaming/statusEmitter.js";
11
- import { execSync } from "node:child_process";
12
12
  import fs from "node:fs";
13
13
  import path from "node:path";
14
14
  import { launch as puppeteerLaunch } from "puppeteer-core";
15
+ import { setChromeUserAgent } from "../utils/index.js";
15
16
  import { startPrecaching } from "./precaching.js";
16
17
  import { terminateStream } from "../streaming/lifecycle.js";
17
18
  const { promises: fsPromises } = fs;
@@ -24,11 +25,18 @@ const { promises: fsPromises } = fs;
24
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
25
26
  * exist.
26
27
  *
27
- * Stream tracking and ID generation have been moved to streaming/registry.ts for unified stream management across all output types (direct WebM, HLS, etc.).
28
+ * Stream tracking and ID generation have been moved to streaming/registry.ts for unified stream management across all output types (HLS, MPEG-TS, etc.).
28
29
  */
29
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
30
31
  // has disconnected.
31
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;
32
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
33
41
  // health endpoint to report the active Chrome version.
34
42
  let currentChromeVersion = null;
@@ -89,27 +97,13 @@ const managedPageIds = new Set();
89
97
  // 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
90
98
  // the configured grace period before being closed. This prevents race conditions where pages are briefly untracked during initialization or cleanup transitions.
91
99
  const potentiallyStalePages = new Map();
92
- /* Login mode allows users to authenticate with TV providers directly from the PrismCast web UI. When login mode is active:
93
- *
94
- * - A dedicated login tab is open in the browser showing the channel's URL
95
- * - The browser window is un-minimized so the user can interact with it
96
- * - New stream requests are blocked to prevent interference with the login process
97
- * - A 15-minute timeout automatically ends login mode if the user forgets
98
- *
99
- * The login page is NOT registered as a managed page to exclude it from stale page cleanup. We manage its lifecycle explicitly through startLoginMode/endLoginMode.
100
- */
101
- // Whether login mode is currently active.
102
- let loginModeActive = false;
103
- // The browser page (tab) used for login. Set when login starts, cleared when login ends.
104
- let loginPage = null;
105
- // The URL being used for login. Stored for status reporting.
106
- let loginUrl = null;
107
- // Timestamp when login mode started. Used for status reporting and timeout calculation.
108
- let loginStartTime = null;
109
- // Timeout handle for auto-ending login mode after 15 minutes.
110
- let loginTimeoutHandle = null;
111
- // Login timeout duration (15 minutes).
112
- const LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
100
+ // Login mode management. State and functions live in login.ts; re-exported here so existing consumers don't need import path changes. clearLoginState,
101
+ // isLoginModeActive, and setBrowserAccessors are imported above; the first two for internal use, setBrowserAccessors for one-time initialization below.
102
+ export { clearLoginState, isLoginModeActive };
103
+ export { endLoginMode, getLoginPage, getLoginStatus, startLoginMode } from "./login.js";
104
+ // Inject browser accessors into the login module. This breaks the circular dependency (login needs getBrowserInstance/minimizeBrowserWindow, index needs login
105
+ // functions) using the same setter/getter pattern as setChromeUserAgent in chromeFetch.ts. Function declarations are hoisted, so both accessors are available here.
106
+ setBrowserAccessors({ getBrowserInstance, minimizeBrowserWindow });
113
107
  /**
114
108
  * Computes the current system status and emits it to SSE subscribers. Called when browser state changes significantly or when streams are added/removed.
115
109
  */
@@ -191,7 +185,7 @@ function getManagedPageId(page) {
191
185
  export async function ensureDataDirectory() {
192
186
  try {
193
187
  await fsPromises.mkdir(getDataDir(), { recursive: true });
194
- LOG.debug("browser", "Data directory ready: %s.", getDataDir());
188
+ LOG.debug("browser:lifecycle", "Data directory ready: %s.", getDataDir());
195
189
  }
196
190
  catch (error) {
197
191
  LOG.error("Failed to create data directory %s: %s.", getDataDir(), formatError(error));
@@ -212,78 +206,124 @@ export async function ensureDataDirectory() {
212
206
  * - Extension initialization: The puppeteer-stream extension needs time after browser launch to inject its recording APIs. We wait for this initialization before
213
207
  * attempting to capture streams.
214
208
  */
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
+ /**
234
+ * Synchronous sleep using Atomics.wait(). This is a cross-platform replacement for execSync("sleep N") that works on all platforms without shelling out.
235
+ * Required because killStaleChrome() runs in the synchronous process.on("exit") handler where async operations are not available.
236
+ * @param ms - Duration to sleep in milliseconds.
237
+ */
238
+ function syncSleep(ms) {
239
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
240
+ }
215
241
  /**
216
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
217
- * 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 pkill to
218
- * find and terminate any Chrome processes whose command line contains our profile directory path, then polls pgrep to verify the processes have actually exited.
219
- * After process cleanup, it removes stale lock files (SingletonLock, SingletonCookie, SingletonSocket) and DevToolsActivePort from the 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.
220
246
  *
221
247
  * The termination strategy escalates from SIGTERM to SIGKILL. SIGTERM is sent first, giving Chrome up to 5 seconds to flush its profile databases (LevelDB,
222
248
  * 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
223
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,
224
250
  * poisoning the Docker volume for subsequent container restarts.
225
251
  *
226
- * The file cleanup is essential for Docker deployments. Container restarts destroy Chrome processes without giving them a chance to release profile locks, but the
227
- * lock files persist in the mounted volume. Without removing them, Chrome cannot start in the new container, causing a crash loop.
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.
228
255
  *
229
- * This is called at startup before launching the browser and after closeBrowser() during shutdown. It's safe to call even when no stale processes or files exist.
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.
230
258
  */
231
259
  export function killStaleChrome() {
232
- // Build the profile directory path that would appear in Chrome's command-line arguments.
233
260
  const profileDir = getChromeDataDir(CONFIG);
261
+ const pid = loadChromePid();
234
262
  const POLL_INTERVAL_MS = 200;
235
- try {
236
- // Send SIGTERM first to give Chrome a chance to flush its profile databases (LevelDB, extension state, session storage) before exiting. This is critical
237
- // 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
238
- // databases, poisoning the Docker volume for subsequent restarts.
239
- execSync(["pkill -f \"", profileDir, "\""].join(""));
240
- LOG.debug("browser", "Sent SIGTERM to Chrome instances using %s.", profileDir);
241
- // Wait up to 5 seconds for Chrome to flush its databases and exit after SIGTERM. Containerized environments with software rendering and shared CPU may
242
- // need the full window.
243
- const TERM_WAIT_MS = 5000;
244
- if (!waitForChromeExit(profileDir, TERM_WAIT_MS, POLL_INTERVAL_MS)) {
245
- // SIGTERM didn't work. Escalate to SIGKILL. Orphaned Chrome processes (from a crashed parent or previous container) may not respond to SIGTERM.
246
- LOG.debug("browser", "Chrome did not exit after SIGTERM. Escalating to SIGKILL.");
247
- try {
248
- execSync(["pkill -9 -f \"", profileDir, "\""].join(""));
249
- }
250
- catch (_error) {
251
- // No matching processes — Chrome may have exited between the pgrep check and the pkill.
263
+ if (pid !== null) {
264
+ try {
265
+ // Send SIGTERM first to give Chrome a chance to flush its profile databases (LevelDB, extension state, session storage) before exiting. This is critical
266
+ // 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
+ // databases, poisoning the Docker volume for subsequent restarts.
268
+ process.kill(pid, "SIGTERM");
269
+ 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
+ if (!waitForChromeExit(pid, TERM_WAIT_MS, POLL_INTERVAL_MS)) {
274
+ // SIGTERM didn't work. Escalate to SIGKILL. Orphaned Chrome processes (from a crashed parent or previous container) may not respond to SIGTERM.
275
+ LOG.debug("browser:lifecycle", "Chrome did not exit after SIGTERM. Escalating to SIGKILL.");
276
+ try {
277
+ process.kill(pid, "SIGKILL");
278
+ }
279
+ catch (_error) {
280
+ // ESRCH — Chrome exited between the poll check and the kill call.
281
+ }
282
+ const KILL_WAIT_MS = 2000;
283
+ if (!waitForChromeExit(pid, KILL_WAIT_MS, POLL_INTERVAL_MS)) {
284
+ LOG.warn("Chrome process %d did not exit after %dms of signal escalation. Proceeding anyway.", pid, TERM_WAIT_MS + KILL_WAIT_MS);
285
+ }
252
286
  }
253
- const KILL_WAIT_MS = 2000;
254
- if (!waitForChromeExit(profileDir, KILL_WAIT_MS, POLL_INTERVAL_MS)) {
255
- LOG.warn("Chrome processes did not exit after %sms of signal escalation. Proceeding anyway.", TERM_WAIT_MS + KILL_WAIT_MS);
287
+ }
288
+ catch (error) {
289
+ // ESRCH means the process does not exist expected when there are no stale processes from a clean shutdown, or in Docker where the PID belongs to a
290
+ // previous container's PID namespace.
291
+ if (error.code !== "ESRCH") {
292
+ LOG.warn("Failed to signal Chrome process %d: %s.", pid, formatError(error));
256
293
  }
257
294
  }
258
- }
259
- catch (_error) {
260
- // When pkill finds no matching processes, it returns a non-zero exit code. This is expected when there are no stale processes from a clean shutdown.
295
+ clearChromePid();
261
296
  }
262
297
  // Remove stale lock and port files left behind by an unclean Chrome exit.
263
298
  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;
264
309
  }
265
310
  /**
266
- * Polls pgrep until no Chrome processes matching the profile directory remain, or the timeout expires. pgrep returns exit code 0 when matching processes exist
267
- * and non-zero when none remain.
268
- * @param profileDir - The Chrome profile directory path to match against process command lines.
311
+ * 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
312
+ * when the process is gone. Between polls, sleeps synchronously using Atomics.wait() for cross-platform compatibility.
313
+ * @param pid - The Chrome process ID to wait for.
269
314
  * @param timeoutMs - Maximum time to wait in milliseconds.
270
- * @param pollIntervalMs - Time between pgrep checks in milliseconds.
271
- * @returns True if all matching processes exited within the timeout, false otherwise.
315
+ * @param pollIntervalMs - Time between existence checks in milliseconds.
316
+ * @returns True if the process exited within the timeout, false otherwise.
272
317
  */
273
- function waitForChromeExit(profileDir, timeoutMs, pollIntervalMs) {
318
+ function waitForChromeExit(pid, timeoutMs, pollIntervalMs) {
274
319
  const deadline = Date.now() + timeoutMs;
275
320
  while (Date.now() < deadline) {
276
- try {
277
- execSync(["pgrep -f \"", profileDir, "\""].join(""), { stdio: "ignore" });
278
- // Processes still exist. Wait and check again.
279
- execSync(["sleep ", String(pollIntervalMs / 1000)].join(""));
280
- }
281
- catch (_error) {
282
- // pgrep returned non-zero — no matching processes remain.
321
+ if (!isProcessRunning(pid)) {
283
322
  return true;
284
323
  }
324
+ syncSleep(pollIntervalMs);
285
325
  }
286
- return false;
326
+ return !isProcessRunning(pid);
287
327
  }
288
328
  /**
289
329
  * Removes stale Chrome profile lock files and the DevTools port file. Chrome writes these while running and removes them on clean shutdown, but an unclean exit
@@ -300,7 +340,7 @@ function cleanStaleProfileFiles(profileDir) {
300
340
  const filePath = path.join(profileDir, file);
301
341
  try {
302
342
  fs.unlinkSync(filePath);
303
- LOG.debug("browser", "Removed stale profile file: %s.", file);
343
+ LOG.debug("browser:lifecycle", "Removed stale profile file: %s.", file);
304
344
  }
305
345
  catch (error) {
306
346
  // ENOENT means the file doesn't exist, which is the expected case after a clean shutdown. Any other error (permissions, filesystem issues) is worth
@@ -469,6 +509,28 @@ async function launchWithCustomArgs(opts) {
469
509
  * use by the preset system when determining effective viewport.
470
510
  * @param browser - The browser instance to use for detection.
471
511
  */
512
+ /**
513
+ * Formats the GPU capabilities into a human-readable suffix for the "Chrome ready" log line. The renderer string is already cleaned (ANGLE wrapper and Metal
514
+ * prefix stripped) at detection time, so this function uses it directly and appends hardware-accelerated codec names in brackets when available.
515
+ * @param gpu - The detected GPU capabilities.
516
+ * @returns A formatted string like " (GPU: Apple M1 [H264, HEVC])" or " (software rendering)".
517
+ */
518
+ function formatGpuSuffix(gpu) {
519
+ const codecs = [
520
+ gpu.av1HardwareEncoding && "AV1",
521
+ gpu.h264HardwareEncoding && "H264",
522
+ gpu.hevcHardwareEncoding && "HEVC"
523
+ ].filter(Boolean);
524
+ if (codecs.length > 0) {
525
+ return " (GPU: " + gpu.renderer + " [" + codecs.join(", ") + "])";
526
+ }
527
+ // No hardware encoding available. A GPU may be present for rendering but lack hardware encoding — show the GPU name without codecs if we have a non-trivial
528
+ // renderer string, otherwise label as software rendering.
529
+ if (gpu.renderer && (gpu.renderer !== "unknown")) {
530
+ return " (GPU: " + gpu.renderer + ")";
531
+ }
532
+ return " (software rendering)";
533
+ }
472
534
  async function detectDisplayDimensions(browser) {
473
535
  let tempPage = null;
474
536
  let usingTempPage = false;
@@ -481,24 +543,123 @@ async function detectDisplayDimensions(browser) {
481
543
  usingTempPage = true;
482
544
  targetPage = tempPage;
483
545
  }
484
- // Measure display dimensions and browser chrome via JavaScript.
485
- const dimensions = await evaluateWithAbort(targetPage, () => {
486
- return {
487
- // Available screen dimensions (excludes taskbar, dock, menu bar).
488
- availHeight: screen.availHeight,
489
- availWidth: screen.availWidth,
490
- // Browser chrome dimensions (title bar, toolbar, borders).
491
- chromeHeight: window.outerHeight - window.innerHeight,
492
- chromeWidth: window.outerWidth - window.innerWidth
493
- };
494
- });
546
+ // Ensure the window is in normal state before measuring. Chrome restores window state from the persistent user data directory, so after a scheduled browser
547
+ // restart the window may launch minimized. A minimized window reports outerWidth/outerHeight as 0 while innerWidth/innerHeight retains the viewport dimensions,
548
+ // producing negative chrome measurements that poison all subsequent window sizing.
549
+ await unminimizeWindow(targetPage);
550
+ // Measure display dimensions and browser chrome via JavaScript. The measurement is retried if chrome dimensions are negative, which indicates the macOS window
551
+ // manager has not yet finished the minimize-to-normal state transition (the animation is asynchronous relative to the CDP command).
552
+ let dimensions;
553
+ for (let attempt = 0; attempt < 3; attempt++) {
554
+ // eslint-disable-next-line no-await-in-loop
555
+ dimensions = await evaluateWithAbort(targetPage, () => {
556
+ return {
557
+ // Available screen dimensions (excludes taskbar, dock, menu bar).
558
+ availHeight: screen.availHeight,
559
+ availWidth: screen.availWidth,
560
+ // Browser chrome dimensions (title bar, toolbar, borders).
561
+ chromeHeight: window.outerHeight - window.innerHeight,
562
+ chromeWidth: window.outerWidth - window.innerWidth
563
+ };
564
+ });
565
+ if ((dimensions.chromeWidth >= 0) && (dimensions.chromeHeight >= 0)) {
566
+ break;
567
+ }
568
+ // Chrome dimensions are negative — the window manager is still transitioning. Wait briefly and remeasure.
569
+ if (attempt < 2) {
570
+ LOG.debug("browser:lifecycle", "Display detection measured negative chrome dimensions (%s\u00d7%s, attempt %s). Retrying after window state settles.", dimensions.chromeWidth, dimensions.chromeHeight, attempt + 1);
571
+ // eslint-disable-next-line no-await-in-loop
572
+ await new Promise((resolve) => setTimeout(resolve, 100));
573
+ }
574
+ }
575
+ // If all attempts produced negative chrome dimensions, skip caching to avoid poisoning window sizing. The preset system will use the configured preset without
576
+ // degradation, and resizeAndMinimizeWindow will fall back to measuring chrome dimensions via page.evaluate() on each call.
577
+ if (!dimensions) {
578
+ return;
579
+ }
580
+ if ((dimensions.chromeWidth < 0) || (dimensions.chromeHeight < 0)) {
581
+ LOG.warn("Display detection produced invalid chrome dimensions after 3 attempts (%s\u00d7%s). Window sizing may be incorrect.", dimensions.chromeWidth, dimensions.chromeHeight);
582
+ return;
583
+ }
495
584
  // Calculate maximum viewport: available screen space minus browser chrome.
496
585
  const maxWidth = dimensions.availWidth - dimensions.chromeWidth;
497
586
  const maxHeight = dimensions.availHeight - dimensions.chromeHeight;
498
587
  // Cache the results for use by the preset system and window sizing.
499
588
  setBrowserChrome(dimensions.chromeWidth, dimensions.chromeHeight);
500
589
  setMaxSupportedViewport(maxWidth, maxHeight);
501
- LOG.debug("browser", "Display detection complete: screen %s\u00d7%s, chrome %s\u00d7%s, max viewport %s\u00d7%s.", dimensions.availWidth, dimensions.availHeight, dimensions.chromeWidth, dimensions.chromeHeight, maxWidth, maxHeight);
590
+ LOG.debug("browser:lifecycle", "Display detection complete: screen %s\u00d7%s, chrome %s\u00d7%s, max viewport %s\u00d7%s.", dimensions.availWidth, dimensions.availHeight, dimensions.chromeWidth, dimensions.chromeHeight, maxWidth, maxHeight);
591
+ // Detect GPU capabilities via CDP SystemInfo.getInfo. This is the authoritative source for GPU identity and hardware encoding capabilities — it runs at the
592
+ // browser level (no page context or secure context required) and returns the actual list of hardware-accelerated video encoding profiles.
593
+ try {
594
+ const cdpSession = await browser.target().createCDPSession();
595
+ try {
596
+ const sysInfo = await cdpSession.send("SystemInfo.getInfo");
597
+ // Extract the GPU renderer from the primary device. The WebGL unmasked renderer provides a richer string (includes ANGLE backend info), so we query
598
+ // that as well and prefer it when available.
599
+ const deviceName = (sysInfo.gpu.devices.length > 0) ? sysInfo.gpu.devices[0].deviceString : "unknown";
600
+ // Get the unmasked WebGL renderer for a more descriptive GPU identity string.
601
+ const webglRenderer = await evaluateWithAbort(targetPage, () => {
602
+ const canvas = document.createElement("canvas");
603
+ const gl = canvas.getContext("webgl");
604
+ if (!gl) {
605
+ return "unknown";
606
+ }
607
+ const ext = gl.getExtension("WEBGL_debug_renderer_info");
608
+ return ext ? String(gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)) : String(gl.getParameter(gl.RENDERER));
609
+ });
610
+ // Extract the meaningful GPU name. ANGLE wraps the actual GPU identity: "ANGLE (Vendor, GPU Name, API Version)". The GPU name is the second
611
+ // comma-separated field. For non-ANGLE renderers, use the device string from CDP.
612
+ let renderer = webglRenderer;
613
+ const angleMatch = /^ANGLE \([^,]+, ([^,]+)/.exec(webglRenderer);
614
+ if (angleMatch) {
615
+ renderer = angleMatch[1].trim();
616
+ // Strip the "ANGLE Metal Renderer: " prefix that macOS adds.
617
+ const metalPrefix = "ANGLE Metal Renderer: ";
618
+ if (renderer.startsWith(metalPrefix)) {
619
+ renderer = renderer.slice(metalPrefix.length);
620
+ }
621
+ }
622
+ else if ((webglRenderer === "WebKit WebGL") || (webglRenderer === "unknown")) {
623
+ renderer = deviceName;
624
+ }
625
+ // Determine hardware encoding capability. Two paths:
626
+ // 1. featureStatus.video_encode === "enabled" — authoritative Chrome-level flag indicating the platform's hardware encoding framework is active
627
+ // (VideoToolbox on macOS, VA-API on Linux, DXVA on Windows). When enabled, H.264 hardware encoding is always available.
628
+ // 2. videoEncoding profile array — lists specific hardware-accelerated codec profiles (e.g., "H264 Main", "HEVC Main"). Populated on Linux/Windows
629
+ // via VA-API/DXVA but empty on macOS where VideoToolbox doesn't enumerate through this interface.
630
+ const videoEncodeEnabled = sysInfo.gpu.featureStatus?.video_encode === "enabled";
631
+ const h264FromProfiles = sysInfo.gpu.videoEncoding.some((e) => e.profile.startsWith("H264"));
632
+ const hevcFromProfiles = sysInfo.gpu.videoEncoding.some((e) => e.profile.startsWith("HEVC"));
633
+ const av1FromProfiles = sysInfo.gpu.videoEncoding.some((e) => e.profile.startsWith("AV1"));
634
+ // H.264 hardware encoding is available when either the feature flag or the profile list confirms it.
635
+ const h264Hardware = videoEncodeEnabled || h264FromProfiles;
636
+ // HEVC and AV1 hardware encoding: check the profile list first (authoritative on Linux/Windows). On macOS (empty profile list), probe via MediaRecorder
637
+ // in the page context — MediaRecorder.isTypeSupported works in non-secure contexts unlike VideoEncoder.
638
+ let hevcHardware = hevcFromProfiles;
639
+ let av1Hardware = av1FromProfiles;
640
+ if (videoEncodeEnabled && (!hevcHardware || !av1Hardware)) {
641
+ const [hevcSupported, av1Supported] = await evaluateWithAbort(targetPage, () => {
642
+ if (typeof MediaRecorder === "undefined") {
643
+ return [false, false];
644
+ }
645
+ return [
646
+ MediaRecorder.isTypeSupported("video/mp4;codecs=hvc1.1.6.L93.B0"),
647
+ MediaRecorder.isTypeSupported("video/mp4;codecs=av01.0.08M.08")
648
+ ];
649
+ });
650
+ hevcHardware ||= hevcSupported;
651
+ av1Hardware ||= av1Supported;
652
+ }
653
+ setGpuCapabilities({ av1HardwareEncoding: av1Hardware, h264HardwareEncoding: h264Hardware, hevcHardwareEncoding: hevcHardware, renderer });
654
+ LOG.debug("browser:lifecycle", "GPU detection: device=%s, renderer=%s, H.264=%s, HEVC=%s, AV1=%s, video_encode=%s, encoding profiles=%s.", deviceName, renderer, h264Hardware, hevcHardware, av1Hardware, sysInfo.gpu.featureStatus?.video_encode ?? "unknown", sysInfo.gpu.videoEncoding.map((e) => e.profile).join(", ") || "none");
655
+ }
656
+ finally {
657
+ void cdpSession.detach().catch(() => { });
658
+ }
659
+ }
660
+ catch (gpuError) {
661
+ LOG.debug("browser:lifecycle", "GPU detection failed: %s.", String(gpuError));
662
+ }
502
663
  // Check if the configured preset needs to be degraded and warn the user.
503
664
  const presetResult = getEffectivePreset(CONFIG);
504
665
  if (presetResult.degraded && presetResult.maxViewport) {
@@ -533,10 +694,12 @@ async function detectDisplayDimensions(browser) {
533
694
  * This ensures streams fail gracefully rather than hanging indefinitely trying to use closed pages.
534
695
  */
535
696
  function handleBrowserDisconnect() {
536
- // Clear the browser reference, launch timestamp, and cached version so getCurrentBrowser() will launch a new instance on the next call.
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.
537
698
  currentBrowser = null;
538
699
  browserLaunchTime = null;
700
+ chromePid = null;
539
701
  currentChromeVersion = null;
702
+ setChromeUserAgent(null);
540
703
  // Cancel any pending restart quiet timer since the browser is already gone.
541
704
  if (restartQuietTimer) {
542
705
  clearTimeout(restartQuietTimer);
@@ -544,20 +707,10 @@ function handleBrowserDisconnect() {
544
707
  }
545
708
  // Clear all channel selection caches. Cached state (guide row positions, discovered page URLs) may be stale in a new browser session.
546
709
  clearChannelSelectionCaches();
547
- // Clear login state if login mode was active. We clear directly rather than calling endLoginMode() because the browser is already gone and we don't want to
548
- // attempt any browser operations.
549
- if (loginModeActive) {
550
- if (loginTimeoutHandle) {
551
- clearTimeout(loginTimeoutHandle);
552
- loginTimeoutHandle = null;
553
- }
554
- loginModeActive = false;
555
- loginPage = null;
556
- loginUrl = null;
557
- loginStartTime = null;
558
- if (!gracefulShutdownInProgress) {
559
- LOG.info("Login mode ended due to browser disconnect.");
560
- }
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).
712
+ if (clearLoginState() && !gracefulShutdownInProgress) {
713
+ LOG.info("Login mode ended due to browser disconnect.");
561
714
  }
562
715
  // Only log the error for unexpected disconnects. During graceful shutdown, closeBrowser() set the flag and this disconnect is intentional.
563
716
  if (!gracefulShutdownInProgress) {
@@ -619,6 +772,15 @@ async function launchBrowser() {
619
772
  // The launch function from puppeteer-stream wraps standard Puppeteer launch to inject the streaming extension. We pass our custom launch function that
620
773
  // handles packaged executable extension paths.
621
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
+ }
622
784
  // Register a handler for browser disconnection. This ensures we clean up properly if the browser crashes or is closed unexpectedly.
623
785
  currentBrowser.on("disconnected", handleBrowserDisconnect);
624
786
  LOG.debug("timing:browser", "Chrome process spawned. (+%sms)", browserElapsed());
@@ -640,11 +802,15 @@ async function launchBrowser() {
640
802
  await detectDisplayDimensions(currentBrowser);
641
803
  LOG.debug("timing:browser", "Display detection complete. (+%sms)", browserElapsed());
642
804
  // Log the Chrome version for diagnostic reference. This helps correlate browser behavior changes (tab unresponsiveness, memory pressure, capture issues)
643
- // with specific Chrome releases.
805
+ // with specific Chrome releases. We also capture the User-Agent string so that server-side fetch() calls to provider CDNs can match Chrome's identity.
644
806
  const chromeVersion = await currentBrowser.version();
807
+ const userAgent = await currentBrowser.userAgent();
645
808
  browserLaunchTime = Date.now();
646
809
  currentChromeVersion = chromeVersion;
647
- LOG.info("Chrome ready: %s.", chromeVersion);
810
+ setChromeUserAgent(userAgent);
811
+ const gpu = getGpuCapabilities();
812
+ const gpuSuffix = gpu ? formatGpuSuffix(gpu) : "";
813
+ LOG.info("Chrome ready: %s%s.", chromeVersion, gpuSuffix);
648
814
  LOG.debug("timing:browser", "Browser ready. Total: %sms.", browserElapsed());
649
815
  // Emit system status update for SSE subscribers.
650
816
  await emitCurrentSystemStatus();
@@ -654,10 +820,11 @@ async function launchBrowser() {
654
820
  }
655
821
  catch (error) {
656
822
  LOG.error("Failed to launch browser: %s.", formatError(error));
657
- // Clear the browser reference, launch timestamp, and cached version on failure so the next call will attempt to launch again.
823
+ // Clear the browser reference, launch timestamp, cached version, and user agent on failure so the next call will attempt to launch again.
658
824
  currentBrowser = null;
659
825
  browserLaunchTime = null;
660
826
  currentChromeVersion = null;
827
+ setChromeUserAgent(null);
661
828
  throw error;
662
829
  }
663
830
  return currentBrowser;
@@ -669,6 +836,14 @@ async function launchBrowser() {
669
836
  export function getChromeVersion() {
670
837
  return currentChromeVersion;
671
838
  }
839
+ /**
840
+ * Returns the current browser instance, or null if not launched. Unlike getCurrentBrowser(), this does not lazily launch. Used by modules that need to check
841
+ * browser state without triggering a launch (e.g., login mode checking connectivity before opening a tab).
842
+ * @returns The browser instance, or null if not running.
843
+ */
844
+ export function getBrowserInstance() {
845
+ return currentBrowser;
846
+ }
672
847
  /**
673
848
  * Checks if the browser is currently connected and usable. This is a synchronous check that can be used before attempting browser operations.
674
849
  * @returns True if the browser is connected and ready for use, false otherwise.
@@ -724,7 +899,7 @@ export async function minimizeBrowserWindow() {
724
899
  }
725
900
  }
726
901
  // Resizing/minimizing is not critical - log a warning but don't fail the operation.
727
- LOG.debug("browser", "Could not resize and minimize browser window: %s.", formatError(error));
902
+ LOG.debug("browser:lifecycle", "Could not resize and minimize browser window: %s.", formatError(error));
728
903
  }
729
904
  }
730
905
  /**
@@ -748,179 +923,64 @@ export async function getBrowserPages() {
748
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
749
924
  * cleared and any subsequent stream requests will launch a fresh browser.
750
925
  *
751
- * The function uses a two-stage approach to ensure Chrome actually exits:
752
- * 1. Try browser.close() with a 5-second timeout (DevTools Protocol graceful close)
753
- * 2. Run killStaleChrome() to catch anything Stage 1 missed, using SIGTERM→SIGKILL escalation to give Chrome a chance to flush its profile databases
926
+ * Chrome termination uses Puppeteer's ChildProcess handle and its `exit` event for detection:
927
+ *
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.
930
+ * - Synchronous polling (Atomics.wait) blocks the event loop, preventing Node from processing SIGCHLD to reap the child — Chrome becomes a zombie regardless
931
+ * of how SIGTERM was sent.
932
+ *
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.
754
935
  */
755
936
  export async function closeBrowser() {
756
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
757
938
  // calls to closeBrowser().
758
939
  setGracefulShutdown(true);
759
940
  const browserRef = currentBrowser;
760
- // Clear the reference, launch timestamp, and cached version early to prevent any new operations from using it.
941
+ // Clear the reference, launch timestamp, cached version, and user agent early to prevent any new operations from using it.
761
942
  currentBrowser = null;
762
943
  browserLaunchTime = null;
763
944
  currentChromeVersion = null;
945
+ setChromeUserAgent(null);
764
946
  if (!browserRef) {
765
947
  return;
766
948
  }
767
- // Stage 1: Try graceful close with a timeout. We use Promise.race to avoid hanging indefinitely if Chrome is unresponsive.
768
- if (browserRef.connected) {
769
- try {
770
- await Promise.race([
771
- browserRef.close(),
772
- new Promise((_, reject) => setTimeout(() => { reject(new Error("Browser close timed out")); }, 5000))
773
- ]);
774
- }
775
- catch (error) {
776
- const message = formatError(error);
777
- if (message.includes("timed out")) {
778
- LOG.warn("Browser did not close within 5 seconds. Forcing termination.");
779
- }
780
- else {
781
- LOG.debug("browser", "Browser close error: %s.", message);
782
- }
783
- }
784
- }
785
- // Stage 2: Catch anything Stage 1 missed. If Chrome didn't respond to the DevTools close command (broken WebSocket, hung process), killStaleChrome()
786
- // sends SIGTERM first to give Chrome a chance to flush its profile databases, then escalates to SIGKILL if needed.
787
- killStaleChrome();
788
- }
789
- /* These functions manage the login mode workflow, allowing users to authenticate with TV providers through the browser. The workflow is:
790
- *
791
- * 1. User clicks "Login" on a channel in the web UI
792
- * 2. startLoginMode() opens a new tab with the channel's URL and un-minimizes the browser
793
- * 3. User completes authentication in the visible browser window
794
- * 4. User clicks "Done" in the web UI, or closes the tab, or the 15-minute timeout fires
795
- * 5. endLoginMode() closes the login tab (if still open) and re-minimizes the browser
796
- *
797
- * During login mode, new stream requests are blocked to prevent the browser from navigating away or creating conflicting tabs.
798
- */
799
- /**
800
- * Starts login mode by opening a new browser tab with the specified URL and un-minimizing the browser window. The user can then authenticate with their TV
801
- * provider in the visible browser.
802
- *
803
- * Login mode blocks new stream requests until it ends (via endLoginMode, tab close detection, or timeout).
804
- * @param url - The URL to navigate to for authentication.
805
- * @returns Object indicating success or failure with optional error message.
806
- */
807
- export async function startLoginMode(url) {
808
- // Check if login mode is already active.
809
- if (loginModeActive) {
810
- return { error: "Login is already in progress.", success: false };
811
- }
812
- // Ensure browser is available.
813
- if (!currentBrowser?.connected) {
814
- return { error: "Browser is not connected.", success: false };
815
- }
816
- try {
817
- // Create a new page for login. We intentionally do NOT register it as a managed page so stale page cleanup ignores it.
818
- loginPage = await currentBrowser.newPage();
819
- // Set up handler for tab close detection. If the user closes the tab manually, we should end login mode automatically.
820
- loginPage.on("close", () => {
821
- // Only auto-end if this is still the active login page.
822
- if (loginModeActive && loginPage) {
823
- LOG.info("Login tab was closed. Ending login mode.");
824
- // Use void to handle the promise without awaiting (we're in an event handler).
825
- void endLoginMode();
826
- }
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();
952
+ 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.
957
+ const exitPromise = new Promise((resolve) => {
958
+ chromeProcess.on("exit", () => { resolve(true); });
827
959
  });
828
- // Navigate to the login URL.
829
- await loginPage.goto(url, { waitUntil: "domcontentloaded" });
830
- // Un-minimize the browser window so the user can see and interact with it.
831
- await unminimizeWindow(loginPage);
832
- // Set login state.
833
- loginModeActive = true;
834
- loginUrl = url;
835
- loginStartTime = Date.now();
836
- // Set up the 15-minute timeout.
837
- loginTimeoutHandle = setTimeout(() => {
838
- LOG.warn("Login mode timed out after 15 minutes. Ending login mode.");
839
- void endLoginMode();
840
- }, LOGIN_TIMEOUT_MS);
841
- LOG.info("Login mode started for %s.", url);
842
- return { success: true };
843
- }
844
- catch (error) {
845
- // Clean up on failure.
846
- if (loginPage && !loginPage.isClosed()) {
847
- try {
848
- await loginPage.close();
849
- }
850
- catch (_closeError) {
851
- // Ignore close errors.
852
- }
853
- }
854
- loginPage = null;
855
- return { error: formatError(error), success: false };
856
- }
857
- }
858
- /**
859
- * Ends login mode by closing the login tab (if still open) and re-minimizing the browser window. This function is idempotent - it's safe to call multiple times
860
- * or when login mode is not active.
861
- *
862
- * Called by:
863
- * - User clicking "Done" in the web UI (POST /auth/done)
864
- * - Tab close detection (user closes the tab manually)
865
- * - 15-minute timeout
866
- * - Browser disconnect handler (cleanup)
867
- */
868
- export async function endLoginMode() {
869
- // Clear the timeout if it hasn't fired yet.
870
- if (loginTimeoutHandle) {
871
- clearTimeout(loginTimeoutHandle);
872
- loginTimeoutHandle = null;
873
- }
874
- // Close the login page if it's still open.
875
- if (loginPage && !loginPage.isClosed()) {
876
- try {
877
- await loginPage.close();
878
- }
879
- catch (_error) {
880
- // Ignore close errors - the page may have already been closed.
960
+ chromeProcess.kill("SIGTERM");
961
+ LOG.debug("browser:lifecycle", "Sent SIGTERM to Chrome process %d.", chromeProcess.pid);
962
+ // Wait for Chrome to exit after SIGTERM, with a timeout. If Chrome doesn't exit in time, escalate to SIGKILL.
963
+ const termTimeout = cancellableTimeout(TERM_WAIT_MS);
964
+ const exitedAfterTerm = await Promise.race([exitPromise, termTimeout.promise]);
965
+ termTimeout.cancel();
966
+ if (!exitedAfterTerm) {
967
+ // SIGTERM didn't work within the timeout. Escalate to SIGKILL. Orphaned Chrome processes (from a crashed parent or previous container) may not
968
+ // respond to SIGTERM.
969
+ LOG.debug("browser:lifecycle", "Chrome did not exit after SIGTERM. Escalating to SIGKILL.");
970
+ chromeProcess.kill("SIGKILL");
971
+ const killTimeout = cancellableTimeout(KILL_WAIT_MS);
972
+ await Promise.race([exitPromise, killTimeout.promise]);
973
+ killTimeout.cancel();
881
974
  }
882
975
  }
883
- // Clear login state.
884
- const wasActive = loginModeActive;
885
- loginModeActive = false;
886
- loginPage = null;
887
- loginUrl = null;
888
- loginStartTime = null;
889
- // Re-minimize the browser window.
890
- if (wasActive && currentBrowser?.connected) {
891
- await minimizeBrowserWindow();
892
- }
893
- if (wasActive) {
894
- LOG.info("Login mode ended.");
895
- }
896
- }
897
- /**
898
- * Returns whether login mode is currently active. Used by the stream handler to block new stream requests during login.
899
- * @returns True if login mode is active, false otherwise.
900
- */
901
- export function isLoginModeActive() {
902
- return loginModeActive;
903
- }
904
- /**
905
- * Returns the current login status including whether active, the URL being used, and when login started. Used by the /auth/status API endpoint.
906
- * @returns Login status object.
907
- */
908
- export function getLoginStatus() {
909
- return {
910
- active: loginModeActive,
911
- startTime: loginStartTime,
912
- url: loginUrl
913
- };
914
- }
915
- /**
916
- * Returns the active login page if login mode is currently active. Used by the profile test flow to evaluate CSS selectors against the live page DOM.
917
- * @returns The login page, or null if login mode is not active.
918
- */
919
- export function getLoginPage() {
920
- if (!loginModeActive) {
921
- return null;
976
+ // Disconnect the Puppeteer WebSocket after Chrome has exited. This cleans up Puppeteer's internal state (event listeners, pending CDP calls) without
977
+ // waiting for the WebSocket close handshake to complete on a dead connection.
978
+ if (browserRef.connected) {
979
+ void browserRef.disconnect();
922
980
  }
923
- return loginPage;
981
+ // Clear the PID file and remove stale Chrome profile lock files.
982
+ clearChromePid();
983
+ cleanStaleProfileFiles(getChromeDataDir(CONFIG));
924
984
  }
925
985
  /* Over time, browser pages (tabs) may accumulate if cleanup fails during stream termination. This can happen due to race conditions, errors during cleanup, or
926
986
  * edge cases in stream lifecycle management. Each orphaned page consumes memory and may continue running JavaScript, so we periodically clean them up.
@@ -962,9 +1022,11 @@ export async function cleanupStalePages() {
962
1022
  // Build a set of page IDs for pages currently in use by active streams.
963
1023
  const activePageIds = new Set();
964
1024
  for (const streamInfo of getAllStreams()) {
965
- const pageId = getManagedPageId(streamInfo.page);
966
- if (pageId) {
967
- activePageIds.add(pageId);
1025
+ if (streamInfo.page) {
1026
+ const pageId = getManagedPageId(streamInfo.page);
1027
+ if (pageId) {
1028
+ activePageIds.add(pageId);
1029
+ }
968
1030
  }
969
1031
  }
970
1032
  const now = Date.now();
@@ -1029,12 +1091,12 @@ export async function cleanupStalePages() {
1029
1091
  }
1030
1092
  // Log only if we actually closed something, to avoid log spam from idle cleanup runs.
1031
1093
  if (closedCount > 0) {
1032
- LOG.debug("browser", "Cleaned up %s stale page(s).", closedCount);
1094
+ LOG.debug("browser:lifecycle", "Cleaned up %s stale page(s).", closedCount);
1033
1095
  }
1034
1096
  }
1035
1097
  catch (error) {
1036
1098
  // Cleanup failure is not critical - log a warning and try again next interval.
1037
- LOG.debug("browser", "Stale page cleanup failed: %s.", formatError(error));
1099
+ LOG.debug("browser:lifecycle", "Stale page cleanup failed: %s.", formatError(error));
1038
1100
  }
1039
1101
  }
1040
1102
  /**
@@ -1065,7 +1127,7 @@ export function stopStalePageCleanup() {
1065
1127
  */
1066
1128
  function checkBrowserRestart() {
1067
1129
  // Skip if the server is shutting down, login mode is active, or the browser is not connected.
1068
- if (gracefulShutdownInProgress || loginModeActive || !currentBrowser || !currentBrowser.connected || !browserLaunchTime) {
1130
+ if (gracefulShutdownInProgress || isLoginModeActive() || !currentBrowser || !currentBrowser.connected || !browserLaunchTime) {
1069
1131
  return;
1070
1132
  }
1071
1133
  // Skip if the browser has not exceeded the maximum age.
@@ -1076,7 +1138,7 @@ function checkBrowserRestart() {
1076
1138
  // If there are active streams, cancel any pending quiet timer and return. Streams that start during the quiet period reset the countdown.
1077
1139
  if (getStreamCount() > 0) {
1078
1140
  if (restartQuietTimer) {
1079
- LOG.debug("browser", "Browser restart quiet period cancelled — streams are active.");
1141
+ LOG.debug("browser:lifecycle", "Browser restart quiet period cancelled — streams are active.");
1080
1142
  clearTimeout(restartQuietTimer);
1081
1143
  restartQuietTimer = null;
1082
1144
  }
@@ -1084,7 +1146,7 @@ function checkBrowserRestart() {
1084
1146
  }
1085
1147
  // No active streams and the browser is old enough. Start the quiet timer if one is not already running.
1086
1148
  if (!restartQuietTimer) {
1087
- LOG.debug("browser", "Browser uptime exceeds threshold. Quiet period started — restart will proceed if no streams start within %s minutes.", Math.round(BROWSER_RESTART_QUIET_PERIOD / 60000));
1149
+ LOG.debug("browser:lifecycle", "Browser uptime exceeds threshold. Quiet period started — restart will proceed if no streams start within %s minutes.", Math.round(BROWSER_RESTART_QUIET_PERIOD / 60000));
1088
1150
  restartQuietTimer = setTimeout(() => {
1089
1151
  void executeBrowserRestart();
1090
1152
  }, BROWSER_RESTART_QUIET_PERIOD);
@@ -1099,8 +1161,8 @@ async function executeBrowserRestart() {
1099
1161
  restartQuietTimer = null;
1100
1162
  // 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
1101
1163
  // mode was activated, or the browser disconnected on its own).
1102
- if (gracefulShutdownInProgress || loginModeActive || (getStreamCount() > 0) || !currentBrowser || !currentBrowser.connected || !browserLaunchTime) {
1103
- LOG.debug("browser", "Browser restart aborted — preconditions no longer met.");
1164
+ if (gracefulShutdownInProgress || isLoginModeActive() || (getStreamCount() > 0) || !currentBrowser || !currentBrowser.connected || !browserLaunchTime) {
1165
+ LOG.debug("browser:lifecycle", "Browser restart aborted — preconditions no longer met.");
1104
1166
  return;
1105
1167
  }
1106
1168
  const age = Date.now() - browserLaunchTime;
@@ -1108,7 +1170,7 @@ async function executeBrowserRestart() {
1108
1170
  const minutes = Math.floor((age % 3600000) / 60000);
1109
1171
  LOG.info("Restarting browser for scheduled maintenance (uptime: %sh %sm).", hours, minutes);
1110
1172
  try {
1111
- // closeBrowser() sets gracefulShutdownInProgress = true internally and performs multi-stage graceful close.
1173
+ // closeBrowser() sets gracefulShutdownInProgress = true internally and performs SIGTERM-based Chrome termination.
1112
1174
  await closeBrowser();
1113
1175
  // Reset the flag since the server is NOT shutting down — only the browser is restarting.
1114
1176
  setGracefulShutdown(false);
@@ -1193,7 +1255,7 @@ export async function prepareExtension() {
1193
1255
  throw error;
1194
1256
  }
1195
1257
  }
1196
- LOG.debug("browser", "Extension files prepared successfully.");
1258
+ LOG.debug("browser:lifecycle", "Extension files prepared successfully.");
1197
1259
  }
1198
1260
  catch (error) {
1199
1261
  LOG.error("Extension preparation failed: %s.", formatError(error));