Skip to content

browser-mcp.js leaks orphan processes when parent agent exits abnormally — root cause + fix #107

Description

@sauravvarma

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions