| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342 |
- import { join } from 'path';
- import { homedir } from 'os';
- import { createConnection } from 'net';
- import { spawn, execSync } from 'child_process';
- import fs from 'fs';
- const CDP_PORT = 9222;
- const CDP_HOST = '127.0.0.1';
- const CDP_HEALTH_TIMEOUT_MS = 15_000;
- const HEALTH_CHECK_INTERVAL_MS = 60_000;
- const HEALTH_CHECK_FAIL_THRESHOLD = 3;
- let chromeProcess = null;
- let _consecutiveHealthFailures = 0;
- function _ts() {
- return new Date().toISOString();
- }
- function _caller() {
- const stack = new Error().stack;
- const frames = stack?.split('\n').slice(2, 4).map(l => l.trim()).join(' <- ') ?? '';
- return frames;
- }
- const LOCK_FILE_NAME = 'chrome-cdp.lock';
- function getUserDataDir() {
- const configDir = process.env.PILOTDECK_CONFIG_DIR ?? join(homedir(), '.pilotdeck');
- return join(configDir, 'browser-use-profile');
- }
- function getLockFilePath() {
- return join(getUserDataDir(), LOCK_FILE_NAME);
- }
- function writeLock() {
- try {
- const dir = getUserDataDir();
- fs.mkdirSync(dir, { recursive: true });
- fs.writeFileSync(getLockFilePath(), JSON.stringify({ pid: process.pid, ts: Date.now() }));
- } catch { /* ignore */ }
- }
- function removeLock() {
- try {
- const lockPath = getLockFilePath();
- if (!fs.existsSync(lockPath)) return;
- const content = fs.readFileSync(lockPath, 'utf8').trim();
- const { pid } = JSON.parse(content);
- if (pid === process.pid) {
- fs.unlinkSync(lockPath);
- }
- } catch { /* ignore */ }
- }
- function findChromePath() {
- const platform = process.platform;
- const candidates =
- platform === 'darwin'
- ? [
- '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
- '/Applications/Chromium.app/Contents/MacOS/Chromium',
- '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
- ]
- : ['/usr/bin/google-chrome', '/usr/bin/chromium-browser', '/usr/bin/chromium'];
- for (const c of candidates) {
- if (fs.existsSync(c)) return c;
- }
- return null;
- }
- function isCDPPortOpen() {
- return new Promise((resolve) => {
- const socket = createConnection({ host: CDP_HOST, port: CDP_PORT });
- socket.setTimeout(1500);
- socket.on('connect', () => {
- socket.destroy();
- resolve(true);
- });
- socket.on('error', () => resolve(false));
- socket.on('timeout', () => {
- socket.destroy();
- resolve(false);
- });
- });
- }
- export async function isCDPHealthy() {
- try {
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), CDP_HEALTH_TIMEOUT_MS);
- const res = await fetch(`http://${CDP_HOST}:${CDP_PORT}/json/version`, {
- signal: controller.signal,
- });
- clearTimeout(timer);
- return res.ok;
- } catch {
- return false;
- }
- }
- function cleanSingletonLocks(dir) {
- for (const name of ['SingletonLock', 'SingletonCookie', 'SingletonSocket']) {
- const p = join(dir, name);
- try {
- if (fs.existsSync(p)) fs.unlinkSync(p);
- } catch { /* ignore */ }
- }
- }
- function launchChrome(executablePath, userDataDir) {
- cleanSingletonLocks(userDataDir);
- const proc = spawn(executablePath, [
- `--remote-debugging-port=${CDP_PORT}`,
- `--user-data-dir=${userDataDir}`,
- '--no-first-run',
- '--no-default-browser-check',
- '--disable-features=ProfilePicker',
- ], {
- stdio: 'ignore',
- detached: true,
- });
- proc.unref();
- proc.on('exit', () => {
- if (chromeProcess === proc) chromeProcess = null;
- });
- return proc;
- }
- async function waitForCDP(maxMs = 10_000) {
- const deadline = Date.now() + maxMs;
- while (Date.now() < deadline) {
- if (await isCDPHealthy()) return true;
- await new Promise((r) => setTimeout(r, 250));
- }
- return false;
- }
- const CHROME_STOP_TIMEOUT_MS = 2500;
- const CHROME_STOP_POLL_MS = 100;
- async function killCDPPort() {
- const caller = _caller();
- let pidList = [];
- try {
- const raw = execSync(`lsof -ti :${CDP_PORT} 2>/dev/null`, { encoding: 'utf8' }).trim();
- if (raw) pidList = raw.split('\n').map(Number).filter(Boolean);
- } catch { /* ignore */ }
- if (pidList.length === 0) {
- chromeProcess = null;
- return;
- }
- console.warn(`[BROWSER ${_ts()}] killCDPPort: sending SIGTERM to pids=${JSON.stringify(pidList)} | caller: ${caller}`);
- for (const pid of pidList) {
- try { process.kill(pid, 'SIGTERM'); } catch { /* ignore */ }
- }
- const deadline = Date.now() + CHROME_STOP_TIMEOUT_MS;
- while (Date.now() < deadline) {
- if (!(await isCDPHealthy())) {
- chromeProcess = null;
- return;
- }
- await new Promise((r) => setTimeout(r, CHROME_STOP_POLL_MS));
- }
- console.warn(`[BROWSER ${_ts()}] killCDPPort: SIGTERM timeout, sending SIGKILL to pids=${JSON.stringify(pidList)}`);
- for (const pid of pidList) {
- try { process.kill(pid, 'SIGKILL'); } catch { /* ignore */ }
- }
- await new Promise((r) => setTimeout(r, 300));
- chromeProcess = null;
- }
- export async function ensureGlobalChrome() {
- if (await isCDPHealthy()) {
- return `http://${CDP_HOST}:${CDP_PORT}`;
- }
- if (await isCDPPortOpen()) {
- console.warn(`[BROWSER ${_ts()}] ensureGlobalChrome: port open but unhealthy, killing stale Chrome`);
- await killCDPPort();
- }
- const executablePath = findChromePath();
- if (!executablePath) return null;
- const userDataDir = getUserDataDir();
- fs.mkdirSync(userDataDir, { recursive: true });
- chromeProcess = launchChrome(executablePath, userDataDir);
- console.log(`[BROWSER ${_ts()}] ensureGlobalChrome: launched Chrome pid=${chromeProcess.pid}`);
- writeLock();
- if (await waitForCDP()) {
- return `http://${CDP_HOST}:${CDP_PORT}`;
- }
- removeLock();
- return null;
- }
- export async function restartGlobalChrome() {
- console.warn(`[BROWSER ${_ts()}] restartGlobalChrome: killing and relaunching | caller: ${_caller()}`);
- await killCDPPort();
- const executablePath = findChromePath();
- if (!executablePath) return null;
- const userDataDir = getUserDataDir();
- fs.mkdirSync(userDataDir, { recursive: true });
- chromeProcess = launchChrome(executablePath, userDataDir);
- console.log(`[BROWSER ${_ts()}] restartGlobalChrome: launched Chrome pid=${chromeProcess.pid}`);
- writeLock();
- if (await waitForCDP()) {
- return `http://${CDP_HOST}:${CDP_PORT}`;
- }
- removeLock();
- return null;
- }
- let healthCheckTimer = null;
- export function startChromeHealthCheck(intervalMs = HEALTH_CHECK_INTERVAL_MS) {
- stopChromeHealthCheck();
- _consecutiveHealthFailures = 0;
- healthCheckTimer = setInterval(async () => {
- if (!(await isCDPHealthy())) {
- _consecutiveHealthFailures++;
- console.warn(`[BROWSER ${_ts()}] Health check failed (${_consecutiveHealthFailures}/${HEALTH_CHECK_FAIL_THRESHOLD})`);
- if (_consecutiveHealthFailures >= HEALTH_CHECK_FAIL_THRESHOLD) {
- console.warn(`[BROWSER ${_ts()}] ${HEALTH_CHECK_FAIL_THRESHOLD} consecutive failures, restarting Chrome...`);
- _consecutiveHealthFailures = 0;
- const url = await restartGlobalChrome();
- if (url) {
- process.env.CDP_URL = url;
- console.log(`[BROWSER ${_ts()}] Chrome restarted at ${url}`);
- } else {
- console.error(`[BROWSER ${_ts()}] Chrome restart failed`);
- }
- }
- } else {
- if (_consecutiveHealthFailures > 0) {
- console.log(`[BROWSER ${_ts()}] Health check recovered after ${_consecutiveHealthFailures} failures`);
- }
- _consecutiveHealthFailures = 0;
- }
- }, intervalMs);
- healthCheckTimer.unref();
- }
- export function stopChromeHealthCheck() {
- if (healthCheckTimer) {
- clearInterval(healthCheckTimer);
- healthCheckTimer = null;
- }
- }
- export function shutdownGlobalChrome() {
- console.warn(`[BROWSER ${_ts()}] shutdownGlobalChrome called | caller: ${_caller()}`);
- stopChromeHealthCheck();
- if (chromeProcess) {
- console.warn(`[BROWSER ${_ts()}] shutdownGlobalChrome: sending SIGTERM to pid=${chromeProcess.pid}`);
- try { chromeProcess.kill('SIGTERM'); } catch { /* ignore */ }
- chromeProcess = null;
- }
- removeLock();
- }
- let _cdpInitPromise = null;
- // Chrome 147+ breaks Playwright's connectOverCDP (setDownloadBehavior protocol
- // change). When the agent's session.ts detects this, it skips CDP and uses
- // chromium.launch() directly. We still start Chrome here so the health-check
- // infrastructure keeps working, but we tag the env so callers know CDP is
- // connect-incompatible.
- const CDP_INCOMPATIBLE_CHROME_MAJOR = 147;
- async function getChromeMajorFromCDP() {
- try {
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), 3_000);
- const res = await fetch(`http://${CDP_HOST}:${CDP_PORT}/json/version`, {
- signal: controller.signal,
- });
- clearTimeout(timer);
- if (!res.ok) return 0;
- const data = await res.json();
- const match = data.Browser?.match(/Chrome\/(\d+)/);
- return match ? parseInt(match[1], 10) : 0;
- } catch {
- return 0;
- }
- }
- /**
- * Lazy CDP initializer — starts Chrome only on first call, then caches the URL.
- * Subsequent calls return immediately if Chrome is already healthy.
- * Serializes concurrent callers so Chrome is launched at most once.
- *
- * On Chrome 147+, CDP_URL is intentionally NOT set so that the Agent's
- * browser-use session.ts falls through to Playwright-managed launch instead
- * of attempting connectOverCDP (which hangs on 147+).
- */
- export async function ensureCDPUrl() {
- if (process.env.CDP_URL && await isCDPHealthy()) {
- return process.env.CDP_URL;
- }
- if (_cdpInitPromise) return _cdpInitPromise;
- _cdpInitPromise = (async () => {
- try {
- const cdpUrl = await ensureGlobalChrome();
- if (!cdpUrl) return null;
- const major = await getChromeMajorFromCDP();
- if (major >= CDP_INCOMPATIBLE_CHROME_MAJOR) {
- console.log(
- `[BROWSER ${_ts()}] Chrome ${major} detected — skipping CDP_URL ` +
- `(connectOverCDP incompatible). Agent will use Playwright-managed launch.`
- );
- return null;
- }
- process.env.CDP_URL = cdpUrl;
- startChromeHealthCheck(HEALTH_CHECK_INTERVAL_MS);
- console.log(`[BROWSER ${_ts()}] Global Chrome ready (lazy) at ${cdpUrl}`);
- return cdpUrl;
- } finally {
- _cdpInitPromise = null;
- }
- })();
- return _cdpInitPromise;
- }
|