From 4f91fdaab982483769e72ac925cc76f46aae0fc5 Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Mon, 30 Mar 2026 16:44:43 +0530 Subject: [PATCH] fix: kill stale MCP processes on reconnect to same endpoint When MCP clients reconnect (e.g. via /mcp in Claude Code), each reconnect spawns a new chrome-devtools-mcp process. Multiple CDP clients on the same debug port cause 'Network.enable timed out' errors because sessions conflict. This adds endpoint-based PID lock files. On startup, the server checks if another instance is already connected to the same endpoint, sends SIGTERM (with SIGKILL fallback after 1s), waits for it to die, then acquires the lock. On exit, the lock is released. Lock files are keyed by normalized endpoint URL (not just port) so different hosts on the same port don't collide. Both browserUrl and wsEndpoint connections are covered. --- src/bin/chrome-devtools-mcp-main.ts | 23 +++++++ src/browser.ts | 93 +++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) 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);