browser-mcp.js leaks orphan processes when the parent agent exits abnormally — root cause + fix
Filing this as an issue (rather than a PR) because I can't open PRs against this repo. Branch + diff + tests are on my fork: sauravvarma#1
Related: #98, #106.
Why I dug into this
My laptop was thrashing yesterday — 12.7 GB of swap used, load average 13, the fans were loud enough that I went looking for the cause. ps aux | grep browser-mcp found 66 orphan expect-cli/dist/browser-mcp.js processes holding ~2.1 GB of RSS between them. All reparented to launchd (PPID 1), spawned from claude CLI sessions that had long since exited.
Killing them with pkill -9 got the laptop usable again. Same pattern matches #98 (Codex parents) and #106 (stale Chrome on close), so this isn't agent-specific.
Root cause
packages/browser/src/mcp/start.ts only registers handlers for SIGINT, SIGTERM, and beforeExit:
process.once("SIGINT", handleShutdown);
process.once("SIGTERM", handleShutdown);
process.once("beforeExit", () => { void McpRuntime.runPromise(closeSession); });
When the parent agent dies gracefully (sending SIGTERM), the child shuts down. When the parent dies abnormally — crash, force-quit, SIGKILL, or the host being put to sleep mid-session — no signal reaches the child. Its stdin pipe closes, but StdioServerTransport (in the MCP SDK) doesn't propagate stdin EOF as a shutdown. It subscribes to 'data' and 'error' on stdin, not 'end' or 'close'. So the node process keeps running, with Playwright Chromium still attached.
Every abnormal parent exit leaks one browser-mcp.js. They build up across sessions.
Reproduction
Spawn the binary, send an MCP initialize, close stdin to simulate parent death, see if the child exits:
import { spawn } from "node:child_process";
const child = spawn("node", ["apps/cli/dist/browser-mcp.js"], { stdio: ["pipe", "pipe", "inherit"] });
const init = JSON.stringify({
jsonrpc: "2.0", id: 1, method: "initialize",
params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "t", version: "0" } },
}) + "\n";
child.stdout.on("data", (c) => { if (c.toString().includes('"id":1')) child.stdin.end(); });
child.stdin.write(init);
child.on("exit", (code) => { console.log("exited", code); process.exit(0); });
setTimeout(() => { console.log("LEAK"); child.kill("SIGKILL"); process.exit(1); }, 8000);
| build |
result |
main @ 0.1.3 |
hangs past 8s — process would live forever |
| with fix |
exits cleanly in 540 ms, code 0 |
Fix
Three lines added to start.ts — wire stdin EOF and SIGHUP into the existing handleShutdown path:
process.once("SIGHUP", handleShutdown);
process.stdin.once("end", handleShutdown);
process.stdin.once("close", handleShutdown);
Diff: main...sauravvarma:expect:fix/mcp-stdin-eof-shutdown
Plus two spawn-based regression tests added to apps/cli/tests/mcp-subcommand.test.ts that would have caught the leak — they follow the existing inline pattern in that file, no new helpers introduced.
Notes
- All 215 tests in
@expect/browser pass with the fix in place (after pnpm exec playwright install).
pnpm check / pnpm lint fail on unmodified main in my environment because vp fmt / vp lint can't load vite.config.ts. Pre-existing, didn't touch.
- Same gap likely exists in
start-http.ts for the HTTP transport — happy to send a follow-up if useful.
- If you'd rather receive this as a proper PR against
main, let me know and how I can work out the permissions issue. The fork branch is ready to merge as-is.
browser-mcp.js leaks orphan processes when the parent agent exits abnormally — root cause + fix
Filing this as an issue (rather than a PR) because I can't open PRs against this repo. Branch + diff + tests are on my fork: sauravvarma#1
Related: #98, #106.
Why I dug into this
My laptop was thrashing yesterday — 12.7 GB of swap used, load average 13, the fans were loud enough that I went looking for the cause.
ps aux | grep browser-mcpfound 66 orphanexpect-cli/dist/browser-mcp.jsprocesses holding ~2.1 GB of RSS between them. All reparented to launchd (PPID 1), spawned fromclaudeCLI sessions that had long since exited.Killing them with
pkill -9got the laptop usable again. Same pattern matches #98 (Codex parents) and #106 (stale Chrome on close), so this isn't agent-specific.Root cause
packages/browser/src/mcp/start.tsonly registers handlers forSIGINT,SIGTERM, andbeforeExit:When the parent agent dies gracefully (sending SIGTERM), the child shuts down. When the parent dies abnormally — crash, force-quit, SIGKILL, or the host being put to sleep mid-session — no signal reaches the child. Its stdin pipe closes, but
StdioServerTransport(in the MCP SDK) doesn't propagate stdin EOF as a shutdown. It subscribes to'data'and'error'on stdin, not'end'or'close'. So the node process keeps running, with Playwright Chromium still attached.Every abnormal parent exit leaks one
browser-mcp.js. They build up across sessions.Reproduction
Spawn the binary, send an MCP
initialize, close stdin to simulate parent death, see if the child exits:main@0.1.3Fix
Three lines added to
start.ts— wire stdin EOF and SIGHUP into the existinghandleShutdownpath:Diff: main...sauravvarma:expect:fix/mcp-stdin-eof-shutdown
Plus two spawn-based regression tests added to
apps/cli/tests/mcp-subcommand.test.tsthat would have caught the leak — they follow the existing inline pattern in that file, no new helpers introduced.Notes
@expect/browserpass with the fix in place (afterpnpm exec playwright install).pnpm check/pnpm lintfail on unmodifiedmainin my environment becausevp fmt/vp lintcan't loadvite.config.ts. Pre-existing, didn't touch.start-http.tsfor the HTTP transport — happy to send a follow-up if useful.main, let me know and how I can work out the permissions issue. The fork branch is ready to merge as-is.