Skip to content

Commit 4f91fda

Browse files
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.
1 parent cea963b commit 4f91fda

2 files changed

Lines changed: 116 additions & 0 deletions

File tree

src/bin/chrome-devtools-mcp-main.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import '../polyfill.js';
88

99
import process from 'node:process';
1010

11+
import {acquireEndpointLock, releaseEndpointLock} from '../browser.js';
1112
import {createMcpServer, logDisclaimers} from '../index.js';
1213
import {logger, saveLogsToFile} from '../logger.js';
1314
import {computeFlagUsage} from '../telemetry/flagUtils.js';
@@ -35,6 +36,28 @@ if (process.env['CHROME_DEVTOOLS_MCP_CRASH_ON_UNCAUGHT'] !== 'true') {
3536
});
3637
}
3738

39+
// Acquire endpoint lock early so stale instances are killed before we connect.
40+
const lockedEndpoint = args.browserUrl ?? args.wsEndpoint;
41+
if (lockedEndpoint) {
42+
acquireEndpointLock(lockedEndpoint);
43+
}
44+
45+
// Clean up lock on exit. SIGTERM/SIGINT handlers must call process.exit()
46+
// or the default exit behavior is suppressed and the process stays alive.
47+
function cleanupAndExit() {
48+
if (lockedEndpoint) {
49+
releaseEndpointLock(lockedEndpoint);
50+
}
51+
process.exit(0);
52+
}
53+
process.on('SIGINT', cleanupAndExit);
54+
process.on('SIGTERM', cleanupAndExit);
55+
process.on('exit', () => {
56+
if (lockedEndpoint) {
57+
releaseEndpointLock(lockedEndpoint);
58+
}
59+
});
60+
3861
logger(`Starting Chrome DevTools MCP Server v${VERSION}`);
3962
const {server, clearcutLogger} = await createMcpServer(args, {
4063
logFile,

src/browser.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {execSync} from 'node:child_process';
88
import fs from 'node:fs';
99
import os from 'node:os';
1010
import path from 'node:path';
11+
import process from 'node:process';
1112

1213
import {logger} from './logger.js';
1314
import type {
@@ -20,6 +21,92 @@ import {puppeteer} from './third_party/index.js';
2021

2122
let browser: Browser | undefined;
2223

24+
/**
25+
* Ensures only one chrome-devtools-mcp process connects to a given endpoint.
26+
* Multiple CDP clients on the same debug port cause "Network.enable timed out"
27+
* errors because sessions conflict. This kills any previous instance before
28+
* connecting.
29+
*/
30+
function getLockDir(): string {
31+
const uid = os.userInfo().uid;
32+
const dir = path.join(os.tmpdir(), `chrome-devtools-mcp-${uid}`);
33+
fs.mkdirSync(dir, {recursive: true});
34+
return dir;
35+
}
36+
37+
function endpointToLockName(endpoint: string): string {
38+
// Normalize endpoint to a safe filename. Include host so different
39+
// hosts on the same port don't collide.
40+
return endpoint.replace(/[^a-zA-Z0-9]/g, '_') + '.lock';
41+
}
42+
43+
export function acquireEndpointLock(endpoint: string): void {
44+
const lockPath = path.join(getLockDir(), endpointToLockName(endpoint));
45+
46+
// Check for and kill any existing owner.
47+
try {
48+
const content = fs.readFileSync(lockPath, 'utf-8').trim();
49+
const lines = content.split('\n');
50+
const pid = parseInt(lines[0] ?? '', 10);
51+
if (!isNaN(pid) && pid !== process.pid) {
52+
try {
53+
process.kill(pid, 0); // Throws if process doesn't exist.
54+
logger(`Killing previous MCP process (PID ${pid}) for ${endpoint}`);
55+
process.kill(pid, 'SIGTERM');
56+
// Wait for the process to actually exit before proceeding.
57+
const start = Date.now();
58+
while (Date.now() - start < 1000) {
59+
try {
60+
process.kill(pid, 0);
61+
} catch {
62+
break; // Process exited.
63+
}
64+
}
65+
// Force kill if still alive after 1s.
66+
try {
67+
process.kill(pid, 'SIGKILL');
68+
} catch {
69+
// Already dead.
70+
}
71+
} catch {
72+
// Process already dead, stale lock file.
73+
}
74+
// Remove stale lock before acquiring.
75+
try {
76+
fs.unlinkSync(lockPath);
77+
} catch {
78+
// Best effort.
79+
}
80+
}
81+
} catch {
82+
// No lock file exists yet.
83+
}
84+
85+
// Write lock atomically. If another process races us, one of us will fail
86+
// the 'wx' open and retry or proceed without the lock.
87+
try {
88+
const fd = fs.openSync(lockPath, 'wx');
89+
fs.writeSync(fd, `${process.pid}\n${endpoint}\n`);
90+
fs.closeSync(fd);
91+
} catch {
92+
// File already exists (race). Overwrite since we already killed the owner.
93+
fs.writeFileSync(lockPath, `${process.pid}\n${endpoint}\n`);
94+
}
95+
}
96+
97+
export function releaseEndpointLock(endpoint: string): void {
98+
try {
99+
const lockPath = path.join(getLockDir(), endpointToLockName(endpoint));
100+
const content = fs.readFileSync(lockPath, 'utf-8').trim();
101+
const pid = parseInt(content.split('\n')[0] ?? '', 10);
102+
if (pid === process.pid) {
103+
fs.unlinkSync(lockPath);
104+
}
105+
} catch {
106+
// Best effort cleanup.
107+
}
108+
}
109+
23110
function makeTargetFilter(enableExtensions = false) {
24111
const ignoredPrefixes = new Set(['chrome://', 'chrome-untrusted://']);
25112
if (!enableExtensions) {
@@ -118,6 +205,12 @@ export async function ensureBrowserConnected(options: {
118205
);
119206
}
120207

208+
// Acquire endpoint lock to prevent multiple clients on the same browser.
209+
const endpoint = options.browserURL ?? options.wsEndpoint;
210+
if (endpoint) {
211+
acquireEndpointLock(endpoint);
212+
}
213+
121214
logger('Connecting Puppeteer to ', JSON.stringify(connectOptions));
122215
try {
123216
browser = await puppeteer.connect(connectOptions);

0 commit comments

Comments
 (0)