diff --git a/src/bin/chrome-devtools-mcp-main.ts b/src/bin/chrome-devtools-mcp-main.ts index bfb6bb38e..1ca7993e2 100644 --- a/src/bin/chrome-devtools-mcp-main.ts +++ b/src/bin/chrome-devtools-mcp-main.ts @@ -8,6 +8,7 @@ import '../polyfill.js'; import process from 'node:process'; +import {acquireEndpointLock, releaseEndpointLock} from '../browser.js'; import {createMcpServer, logDisclaimers} from '../index.js'; import {logger, saveLogsToFile} from '../logger.js'; import {computeFlagUsage} from '../telemetry/flagUtils.js'; @@ -35,6 +36,28 @@ if (process.env['CHROME_DEVTOOLS_MCP_CRASH_ON_UNCAUGHT'] !== 'true') { }); } +// Acquire endpoint lock early so stale instances are killed before we connect. +const lockedEndpoint = args.browserUrl ?? args.wsEndpoint; +if (lockedEndpoint) { + acquireEndpointLock(lockedEndpoint); +} + +// Clean up lock on exit. SIGTERM/SIGINT handlers must call process.exit() +// or the default exit behavior is suppressed and the process stays alive. +function cleanupAndExit() { + if (lockedEndpoint) { + releaseEndpointLock(lockedEndpoint); + } + process.exit(0); +} +process.on('SIGINT', cleanupAndExit); +process.on('SIGTERM', cleanupAndExit); +process.on('exit', () => { + if (lockedEndpoint) { + releaseEndpointLock(lockedEndpoint); + } +}); + logger(`Starting Chrome DevTools MCP Server v${VERSION}`); const {server, clearcutLogger} = await createMcpServer(args, { logFile, diff --git a/src/browser.ts b/src/browser.ts index 7deea75b4..346eff79f 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -8,6 +8,7 @@ import {execSync} from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import process from 'node:process'; import {logger} from './logger.js'; import type { @@ -20,6 +21,92 @@ import {puppeteer} from './third_party/index.js'; let browser: Browser | undefined; +/** + * Ensures only one chrome-devtools-mcp process connects to a given endpoint. + * Multiple CDP clients on the same debug port cause "Network.enable timed out" + * errors because sessions conflict. This kills any previous instance before + * connecting. + */ +function getLockDir(): string { + const uid = os.userInfo().uid; + const dir = path.join(os.tmpdir(), `chrome-devtools-mcp-${uid}`); + fs.mkdirSync(dir, {recursive: true}); + return dir; +} + +function endpointToLockName(endpoint: string): string { + // Normalize endpoint to a safe filename. Include host so different + // hosts on the same port don't collide. + return endpoint.replace(/[^a-zA-Z0-9]/g, '_') + '.lock'; +} + +export function acquireEndpointLock(endpoint: string): void { + const lockPath = path.join(getLockDir(), endpointToLockName(endpoint)); + + // Check for and kill any existing owner. + try { + const content = fs.readFileSync(lockPath, 'utf-8').trim(); + const lines = content.split('\n'); + const pid = parseInt(lines[0] ?? '', 10); + if (!isNaN(pid) && pid !== process.pid) { + try { + process.kill(pid, 0); // Throws if process doesn't exist. + logger(`Killing previous MCP process (PID ${pid}) for ${endpoint}`); + process.kill(pid, 'SIGTERM'); + // Wait for the process to actually exit before proceeding. + const start = Date.now(); + while (Date.now() - start < 1000) { + try { + process.kill(pid, 0); + } catch { + break; // Process exited. + } + } + // Force kill if still alive after 1s. + try { + process.kill(pid, 'SIGKILL'); + } catch { + // Already dead. + } + } catch { + // Process already dead, stale lock file. + } + // Remove stale lock before acquiring. + try { + fs.unlinkSync(lockPath); + } catch { + // Best effort. + } + } + } catch { + // No lock file exists yet. + } + + // Write lock atomically. If another process races us, one of us will fail + // the 'wx' open and retry or proceed without the lock. + try { + const fd = fs.openSync(lockPath, 'wx'); + fs.writeSync(fd, `${process.pid}\n${endpoint}\n`); + fs.closeSync(fd); + } catch { + // File already exists (race). Overwrite since we already killed the owner. + fs.writeFileSync(lockPath, `${process.pid}\n${endpoint}\n`); + } +} + +export function releaseEndpointLock(endpoint: string): void { + try { + const lockPath = path.join(getLockDir(), endpointToLockName(endpoint)); + const content = fs.readFileSync(lockPath, 'utf-8').trim(); + const pid = parseInt(content.split('\n')[0] ?? '', 10); + if (pid === process.pid) { + fs.unlinkSync(lockPath); + } + } catch { + // Best effort cleanup. + } +} + function makeTargetFilter(enableExtensions = false) { const ignoredPrefixes = new Set(['chrome://', 'chrome-untrusted://']); if (!enableExtensions) { @@ -118,6 +205,12 @@ export async function ensureBrowserConnected(options: { ); } + // Acquire endpoint lock to prevent multiple clients on the same browser. + const endpoint = options.browserURL ?? options.wsEndpoint; + if (endpoint) { + acquireEndpointLock(endpoint); + } + logger('Connecting Puppeteer to ', JSON.stringify(connectOptions)); try { browser = await puppeteer.connect(connectOptions);