Skip to content

[Bug]: @playwright/mcp orphans Chrome process trees when the MCP host dies without SIGINT/SIGTERM #41013

@alexkahndev

Description

@alexkahndev

Version

@playwright/mcp@0.0.75 (which bundles playwright-core@1.60.0). The relevant code is in playwright-core, not in the @playwright/mcp wrapper, which is why I'm filing here.

Steps to reproduce

Save as /tmp/host.mjs:

import { spawn } from 'node:child_process';
const child = spawn('npx', ['-y', '@playwright/mcp@latest'], {
  stdio: ['pipe', 'pipe', 'inherit'],
});
const send = (msg) => child.stdin.write(JSON.stringify(msg) + '\n');
let id = 0;
send({ jsonrpc: '2.0', id: ++id, method: 'initialize',
  params: { protocolVersion: '2024-11-05', capabilities: {},
    clientInfo: { name: 'repro', version: '1' } } });
setTimeout(() => send({ jsonrpc: '2.0',
  method: 'notifications/initialized' }), 500);
setTimeout(() => send({ jsonrpc: '2.0', id: ++id, method: 'tools/call',
  params: { name: 'browser_navigate',
    arguments: { url: 'about:blank' } } }), 1500);
console.error('host pid:', process.pid, 'child pid:', child.pid);

Then in a shell:

node /tmp/host.mjs &
HOST_PID=$!
sleep 10                                # let chrome boot
echo "=== before kill ==="
ps -eo pid,ppid,etime,cmd | grep -E 'mcp-chrome|chrome.*--user-data-dir' | grep -v grep
kill -9 $HOST_PID                       # simulates IDE hard-reload / crash
sleep 3
echo "=== after host SIGKILL ==="
ps -eo pid,ppid,etime,cmd | grep -E 'mcp-chrome|chrome.*--user-data-dir' | grep -v grep

Real-world equivalents that hit the same path: the user reloads their IDE
window (VS Code "Developer: Reload Window"), the OS OOM-kills the IDE, or
the host process exits abnormally for any reason that doesn't propagate
SIGINT/SIGTERM to the MCP server child.

Expected behavior

After kill -9 $HOST_PID, the Chrome process tree spawned by
@playwright/mcp should exit. No chrome processes should remain re-parented
to PID 1, and the per-session mcp-chrome-* directory under
~/.cache/ms-playwright/ should be eligible for cleanup.

Actual behavior

After kill -9 $HOST_PID, the Chrome process tree is still running and has
been re-parented to PID 1. Sample from the repro on my machine:

=== after host SIGKILL ===
  pid  ppid    etime cmd
12847     1    00:14 /home/alexkahn/.cache/ms-playwright/chromium_headless_shell-1207/chrome-linux/headless_shell --user-data-dir=/home/alexkahn/.cache/ms-playwright/mcp-chrome-... --remote-debugging-pipe ...
12851     1    00:14 [chrome zygote]
12858 12847    00:14 [chrome gpu-process]
...

The chrome tree stays alive indefinitely; etime keeps growing. Over a
typical multi-day developer session this accumulates to multiple orphan
trees, each holding 200–500 MB resident. On WSL with a fixed memory cap
this drives the VM toward swap. A pgrep -f 'ms-playwright/mcp-chrome' | xargs kill -9 reliably frees hundreds of MB at a time on real machines
that have only been doing normal browser-automation work.

Additional context

The watchdog
(packages/playwright-core/src/server/launchApp.ts and the bundled MCP watchdog
in coreBundle) currently registers handlers for:

  • process.on('SIGINT', …)
  • process.on('SIGTERM', …)
  • process.stdin.on('close', …)

It also schedules a 15-second hard exit:

setTimeout(() => process.exit(0), 15_000);

The exit paths this misses:

  1. Parent SIGKILL — no signal reaches the MCP server child, so neither
    SIGINT nor SIGTERM fires. The classic detection is PPID polling (the
    process is re-parented to PID 1, i.e. process.ppid === 1).
  2. npx / npm exec intermediary — the standard MCP host config is
    { "command": "npx", "args": ["@playwright/mcp@latest"] }. The npx
    wrapper sometimes swallows the stdin-close propagation, so even a
    graceful host shutdown doesn't trigger stdin('close') in the
    grandchild.
  3. Hard-exit timeout doesn't kill chrome. The 15s setTimeout calls
    process.exit(0) without invoking the kill set first. So even when the
    watchdog does run, a slow gracefullyCloseAll() (which is what holds
    it open past 15s in the first place) results in chrome being abandoned.

There are two prior reports of essentially the same bug, both closed as
"no repro":

I think the "no repro" closures happened because the bug doesn't appear on
Playwright's own CI matrix (clean spawn, clean SIGTERM on teardown). It
only appears with a real-world MCP host setup where (a) the host can die
without sending SIGTERM and (b) there's typically an npx/npm exec
intermediary in the spawn chain. The script above produces it
deterministically.

Environment

  System:
    OS: Linux 6.18 Ubuntu 24.04.4 LTS 24.04.4 LTS (Noble Numbat)
    CPU: (10) x64 Intel(R) Core(TM) Ultra 7 155U
    Memory: 5.34 GB / 11.68 GB
    Container: Yes
  Binaries:
    Node: 22.14.0 - /home/alexkahn/.nvm/versions/node/v22.14.0/bin/node
    Yarn: 1.22.22 - /home/alexkahn/.nvm/versions/node/v22.14.0/bin/yarn
    npm: 10.9.2 - /home/alexkahn/.nvm/versions/node/v22.14.0/bin/npm
    pnpm: 10.32.1 - /home/alexkahn/.bun/bin/pnpm
    bun: 1.3.14 - /home/alexkahn/.bun/bin/bun
  npmPackages:
    @playwright/mcp: 0.0.75
    playwright-core: 1.60.0

(WSL2 on Windows 11; the bug also reproduces on plain Linux per the prior
reports linked above.)

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