Skip to content

Commit 1cbf322

Browse files
Nima21claude
andcommitted
fix: kill Chrome process on MCP server shutdown to prevent orphaned processes
Chrome (headless) was launched but never cleaned up when the MCP client disconnected or the server process was terminated. This caused orphaned Chrome processes to accumulate across containers (59 found on np23.dev, oldest 65+ hours, consuming >100% CPU combined). Added graceful shutdown that kills the Chrome process on: - SIGTERM/SIGINT/SIGHUP signals (container stop, Ctrl+C) - MCP client disconnect (server.onclose) - stdin close (parent process death) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8462ded commit 1cbf322

2 files changed

Lines changed: 70 additions & 1 deletion

File tree

src/browser.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,35 @@ puppeteerExtra.use(StealthPlugin());
2525

2626
let browser: Browser | undefined;
2727

28+
/**
29+
* Close the browser instance, killing the Chrome process if we launched it.
30+
* Safe to call multiple times or when no browser exists.
31+
*/
32+
export async function closeBrowser(): Promise<void> {
33+
if (!browser) {
34+
return;
35+
}
36+
const b = browser;
37+
browser = undefined;
38+
try {
39+
if (b.connected) {
40+
// browser.close() sends a Browser.close CDP command and kills the process
41+
// if it was launched by puppeteer. For connected browsers it just disconnects.
42+
await b.close();
43+
}
44+
} catch {
45+
// Best-effort: if close fails, try to kill the process directly
46+
try {
47+
const proc = b.process();
48+
if (proc && !proc.killed) {
49+
proc.kill('SIGKILL');
50+
}
51+
} catch {
52+
// Nothing more we can do
53+
}
54+
}
55+
}
56+
2857
function makeTargetFilter() {
2958
const ignoredPrefixes = new Set([
3059
'chrome://',

src/main.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import './polyfill.js';
99
import process from 'node:process';
1010

1111
import type {Channel} from './browser.js';
12-
import {ensureBrowserConnected, ensureBrowserLaunched} from './browser.js';
12+
import {closeBrowser, ensureBrowserConnected, ensureBrowserLaunched} from './browser.js';
1313
import {cliOptions, parseArguments} from './cli.js';
1414
import {loadIssueDescriptions} from './issue-descriptions.js';
1515
import {logger, saveLogsToFile} from './logger.js';
@@ -262,6 +262,46 @@ await loadIssueDescriptions();
262262
const transport = new StdioServerTransport();
263263
await server.connect(transport);
264264
logger('Chrome DevTools MCP Server connected');
265+
266+
// Graceful shutdown: kill Chrome when the MCP server exits for any reason.
267+
let shuttingDown = false;
268+
async function gracefulShutdown(reason: string) {
269+
if (shuttingDown) {
270+
return;
271+
}
272+
shuttingDown = true;
273+
logger(`Shutting down: ${reason}`);
274+
try {
275+
context?.dispose();
276+
} catch {
277+
// best-effort
278+
}
279+
try {
280+
await closeBrowser();
281+
} catch {
282+
// best-effort
283+
}
284+
logger('Shutdown complete');
285+
process.exit(0);
286+
}
287+
288+
// Handle OS signals (container stop, systemd, Ctrl+C)
289+
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP'] as const) {
290+
process.on(signal, () => {
291+
void gracefulShutdown(`received ${signal}`);
292+
});
293+
}
294+
295+
// Handle MCP client disconnect (transport/server close)
296+
server.server.onclose = () => {
297+
void gracefulShutdown('MCP client disconnected');
298+
};
299+
300+
// Handle stdin closing (parent process died)
301+
process.stdin.on('close', () => {
302+
void gracefulShutdown('stdin closed');
303+
});
304+
265305
logDisclaimers();
266306
void clearcutLogger?.logDailyActiveIfNeeded();
267307
void clearcutLogger?.logServerStart(computeFlagUsage(args, cliOptions));

0 commit comments

Comments
 (0)