Skip to content

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

@sauravvarma

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions