globalChrome.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. import { join } from 'path';
  2. import { homedir } from 'os';
  3. import { createConnection } from 'net';
  4. import { spawn, execSync } from 'child_process';
  5. import fs from 'fs';
  6. const CDP_PORT = 9222;
  7. const CDP_HOST = '127.0.0.1';
  8. const CDP_HEALTH_TIMEOUT_MS = 15_000;
  9. const HEALTH_CHECK_INTERVAL_MS = 60_000;
  10. const HEALTH_CHECK_FAIL_THRESHOLD = 3;
  11. let chromeProcess = null;
  12. let _consecutiveHealthFailures = 0;
  13. function _ts() {
  14. return new Date().toISOString();
  15. }
  16. function _caller() {
  17. const stack = new Error().stack;
  18. const frames = stack?.split('\n').slice(2, 4).map(l => l.trim()).join(' <- ') ?? '';
  19. return frames;
  20. }
  21. const LOCK_FILE_NAME = 'chrome-cdp.lock';
  22. function getUserDataDir() {
  23. const configDir = process.env.PILOTDECK_CONFIG_DIR ?? join(homedir(), '.pilotdeck');
  24. return join(configDir, 'browser-use-profile');
  25. }
  26. function getLockFilePath() {
  27. return join(getUserDataDir(), LOCK_FILE_NAME);
  28. }
  29. function writeLock() {
  30. try {
  31. const dir = getUserDataDir();
  32. fs.mkdirSync(dir, { recursive: true });
  33. fs.writeFileSync(getLockFilePath(), JSON.stringify({ pid: process.pid, ts: Date.now() }));
  34. } catch { /* ignore */ }
  35. }
  36. function removeLock() {
  37. try {
  38. const lockPath = getLockFilePath();
  39. if (!fs.existsSync(lockPath)) return;
  40. const content = fs.readFileSync(lockPath, 'utf8').trim();
  41. const { pid } = JSON.parse(content);
  42. if (pid === process.pid) {
  43. fs.unlinkSync(lockPath);
  44. }
  45. } catch { /* ignore */ }
  46. }
  47. function findChromePath() {
  48. const platform = process.platform;
  49. const candidates =
  50. platform === 'darwin'
  51. ? [
  52. '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
  53. '/Applications/Chromium.app/Contents/MacOS/Chromium',
  54. '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
  55. ]
  56. : ['/usr/bin/google-chrome', '/usr/bin/chromium-browser', '/usr/bin/chromium'];
  57. for (const c of candidates) {
  58. if (fs.existsSync(c)) return c;
  59. }
  60. return null;
  61. }
  62. function isCDPPortOpen() {
  63. return new Promise((resolve) => {
  64. const socket = createConnection({ host: CDP_HOST, port: CDP_PORT });
  65. socket.setTimeout(1500);
  66. socket.on('connect', () => {
  67. socket.destroy();
  68. resolve(true);
  69. });
  70. socket.on('error', () => resolve(false));
  71. socket.on('timeout', () => {
  72. socket.destroy();
  73. resolve(false);
  74. });
  75. });
  76. }
  77. export async function isCDPHealthy() {
  78. try {
  79. const controller = new AbortController();
  80. const timer = setTimeout(() => controller.abort(), CDP_HEALTH_TIMEOUT_MS);
  81. const res = await fetch(`http://${CDP_HOST}:${CDP_PORT}/json/version`, {
  82. signal: controller.signal,
  83. });
  84. clearTimeout(timer);
  85. return res.ok;
  86. } catch {
  87. return false;
  88. }
  89. }
  90. function cleanSingletonLocks(dir) {
  91. for (const name of ['SingletonLock', 'SingletonCookie', 'SingletonSocket']) {
  92. const p = join(dir, name);
  93. try {
  94. if (fs.existsSync(p)) fs.unlinkSync(p);
  95. } catch { /* ignore */ }
  96. }
  97. }
  98. function launchChrome(executablePath, userDataDir) {
  99. cleanSingletonLocks(userDataDir);
  100. const proc = spawn(executablePath, [
  101. `--remote-debugging-port=${CDP_PORT}`,
  102. `--user-data-dir=${userDataDir}`,
  103. '--no-first-run',
  104. '--no-default-browser-check',
  105. '--disable-features=ProfilePicker',
  106. ], {
  107. stdio: 'ignore',
  108. detached: true,
  109. });
  110. proc.unref();
  111. proc.on('exit', () => {
  112. if (chromeProcess === proc) chromeProcess = null;
  113. });
  114. return proc;
  115. }
  116. async function waitForCDP(maxMs = 10_000) {
  117. const deadline = Date.now() + maxMs;
  118. while (Date.now() < deadline) {
  119. if (await isCDPHealthy()) return true;
  120. await new Promise((r) => setTimeout(r, 250));
  121. }
  122. return false;
  123. }
  124. const CHROME_STOP_TIMEOUT_MS = 2500;
  125. const CHROME_STOP_POLL_MS = 100;
  126. async function killCDPPort() {
  127. const caller = _caller();
  128. let pidList = [];
  129. try {
  130. const raw = execSync(`lsof -ti :${CDP_PORT} 2>/dev/null`, { encoding: 'utf8' }).trim();
  131. if (raw) pidList = raw.split('\n').map(Number).filter(Boolean);
  132. } catch { /* ignore */ }
  133. if (pidList.length === 0) {
  134. chromeProcess = null;
  135. return;
  136. }
  137. console.warn(`[BROWSER ${_ts()}] killCDPPort: sending SIGTERM to pids=${JSON.stringify(pidList)} | caller: ${caller}`);
  138. for (const pid of pidList) {
  139. try { process.kill(pid, 'SIGTERM'); } catch { /* ignore */ }
  140. }
  141. const deadline = Date.now() + CHROME_STOP_TIMEOUT_MS;
  142. while (Date.now() < deadline) {
  143. if (!(await isCDPHealthy())) {
  144. chromeProcess = null;
  145. return;
  146. }
  147. await new Promise((r) => setTimeout(r, CHROME_STOP_POLL_MS));
  148. }
  149. console.warn(`[BROWSER ${_ts()}] killCDPPort: SIGTERM timeout, sending SIGKILL to pids=${JSON.stringify(pidList)}`);
  150. for (const pid of pidList) {
  151. try { process.kill(pid, 'SIGKILL'); } catch { /* ignore */ }
  152. }
  153. await new Promise((r) => setTimeout(r, 300));
  154. chromeProcess = null;
  155. }
  156. export async function ensureGlobalChrome() {
  157. if (await isCDPHealthy()) {
  158. return `http://${CDP_HOST}:${CDP_PORT}`;
  159. }
  160. if (await isCDPPortOpen()) {
  161. console.warn(`[BROWSER ${_ts()}] ensureGlobalChrome: port open but unhealthy, killing stale Chrome`);
  162. await killCDPPort();
  163. }
  164. const executablePath = findChromePath();
  165. if (!executablePath) return null;
  166. const userDataDir = getUserDataDir();
  167. fs.mkdirSync(userDataDir, { recursive: true });
  168. chromeProcess = launchChrome(executablePath, userDataDir);
  169. console.log(`[BROWSER ${_ts()}] ensureGlobalChrome: launched Chrome pid=${chromeProcess.pid}`);
  170. writeLock();
  171. if (await waitForCDP()) {
  172. return `http://${CDP_HOST}:${CDP_PORT}`;
  173. }
  174. removeLock();
  175. return null;
  176. }
  177. export async function restartGlobalChrome() {
  178. console.warn(`[BROWSER ${_ts()}] restartGlobalChrome: killing and relaunching | caller: ${_caller()}`);
  179. await killCDPPort();
  180. const executablePath = findChromePath();
  181. if (!executablePath) return null;
  182. const userDataDir = getUserDataDir();
  183. fs.mkdirSync(userDataDir, { recursive: true });
  184. chromeProcess = launchChrome(executablePath, userDataDir);
  185. console.log(`[BROWSER ${_ts()}] restartGlobalChrome: launched Chrome pid=${chromeProcess.pid}`);
  186. writeLock();
  187. if (await waitForCDP()) {
  188. return `http://${CDP_HOST}:${CDP_PORT}`;
  189. }
  190. removeLock();
  191. return null;
  192. }
  193. let healthCheckTimer = null;
  194. export function startChromeHealthCheck(intervalMs = HEALTH_CHECK_INTERVAL_MS) {
  195. stopChromeHealthCheck();
  196. _consecutiveHealthFailures = 0;
  197. healthCheckTimer = setInterval(async () => {
  198. if (!(await isCDPHealthy())) {
  199. _consecutiveHealthFailures++;
  200. console.warn(`[BROWSER ${_ts()}] Health check failed (${_consecutiveHealthFailures}/${HEALTH_CHECK_FAIL_THRESHOLD})`);
  201. if (_consecutiveHealthFailures >= HEALTH_CHECK_FAIL_THRESHOLD) {
  202. console.warn(`[BROWSER ${_ts()}] ${HEALTH_CHECK_FAIL_THRESHOLD} consecutive failures, restarting Chrome...`);
  203. _consecutiveHealthFailures = 0;
  204. const url = await restartGlobalChrome();
  205. if (url) {
  206. process.env.CDP_URL = url;
  207. console.log(`[BROWSER ${_ts()}] Chrome restarted at ${url}`);
  208. } else {
  209. console.error(`[BROWSER ${_ts()}] Chrome restart failed`);
  210. }
  211. }
  212. } else {
  213. if (_consecutiveHealthFailures > 0) {
  214. console.log(`[BROWSER ${_ts()}] Health check recovered after ${_consecutiveHealthFailures} failures`);
  215. }
  216. _consecutiveHealthFailures = 0;
  217. }
  218. }, intervalMs);
  219. healthCheckTimer.unref();
  220. }
  221. export function stopChromeHealthCheck() {
  222. if (healthCheckTimer) {
  223. clearInterval(healthCheckTimer);
  224. healthCheckTimer = null;
  225. }
  226. }
  227. export function shutdownGlobalChrome() {
  228. console.warn(`[BROWSER ${_ts()}] shutdownGlobalChrome called | caller: ${_caller()}`);
  229. stopChromeHealthCheck();
  230. if (chromeProcess) {
  231. console.warn(`[BROWSER ${_ts()}] shutdownGlobalChrome: sending SIGTERM to pid=${chromeProcess.pid}`);
  232. try { chromeProcess.kill('SIGTERM'); } catch { /* ignore */ }
  233. chromeProcess = null;
  234. }
  235. removeLock();
  236. }
  237. let _cdpInitPromise = null;
  238. // Chrome 147+ breaks Playwright's connectOverCDP (setDownloadBehavior protocol
  239. // change). When the agent's session.ts detects this, it skips CDP and uses
  240. // chromium.launch() directly. We still start Chrome here so the health-check
  241. // infrastructure keeps working, but we tag the env so callers know CDP is
  242. // connect-incompatible.
  243. const CDP_INCOMPATIBLE_CHROME_MAJOR = 147;
  244. async function getChromeMajorFromCDP() {
  245. try {
  246. const controller = new AbortController();
  247. const timer = setTimeout(() => controller.abort(), 3_000);
  248. const res = await fetch(`http://${CDP_HOST}:${CDP_PORT}/json/version`, {
  249. signal: controller.signal,
  250. });
  251. clearTimeout(timer);
  252. if (!res.ok) return 0;
  253. const data = await res.json();
  254. const match = data.Browser?.match(/Chrome\/(\d+)/);
  255. return match ? parseInt(match[1], 10) : 0;
  256. } catch {
  257. return 0;
  258. }
  259. }
  260. /**
  261. * Lazy CDP initializer — starts Chrome only on first call, then caches the URL.
  262. * Subsequent calls return immediately if Chrome is already healthy.
  263. * Serializes concurrent callers so Chrome is launched at most once.
  264. *
  265. * On Chrome 147+, CDP_URL is intentionally NOT set so that the Agent's
  266. * browser-use session.ts falls through to Playwright-managed launch instead
  267. * of attempting connectOverCDP (which hangs on 147+).
  268. */
  269. export async function ensureCDPUrl() {
  270. if (process.env.CDP_URL && await isCDPHealthy()) {
  271. return process.env.CDP_URL;
  272. }
  273. if (_cdpInitPromise) return _cdpInitPromise;
  274. _cdpInitPromise = (async () => {
  275. try {
  276. const cdpUrl = await ensureGlobalChrome();
  277. if (!cdpUrl) return null;
  278. const major = await getChromeMajorFromCDP();
  279. if (major >= CDP_INCOMPATIBLE_CHROME_MAJOR) {
  280. console.log(
  281. `[BROWSER ${_ts()}] Chrome ${major} detected — skipping CDP_URL ` +
  282. `(connectOverCDP incompatible). Agent will use Playwright-managed launch.`
  283. );
  284. return null;
  285. }
  286. process.env.CDP_URL = cdpUrl;
  287. startChromeHealthCheck(HEALTH_CHECK_INTERVAL_MS);
  288. console.log(`[BROWSER ${_ts()}] Global Chrome ready (lazy) at ${cdpUrl}`);
  289. return cdpUrl;
  290. } finally {
  291. _cdpInitPromise = null;
  292. }
  293. })();
  294. return _cdpInitPromise;
  295. }