Skip to content

Watchdog SIGKILLs any Chrome sharing the profile directory, with no ownership check #131

Description

@lunetics

Summary

LocalBrowserWatchdog._kill_stale_chrome_for_profile() scans all running
processes and sends SIGKILL to every Chrome/Chromium/Brave whose
--user-data-dir resolves to the profile it is about to use. There is no check
for whether that process belongs to this instance, whether it is still alive and
healthy, or whether it is currently writing to the profile.

With a single instance this is harmless housekeeping. With two or more
instances sharing a profile — which is the default for the MCP server, since
every server defaults to ~/.config/openbrowser/profiles/default
(openbrowser/mcp/server.py:314) — each instance's first browser call kills the
other's live browser.

Where

  • Kill loop, no ownership/liveness check: openbrowser/browser/watchdogs/local_browser_watchdog.py:542-573
  • Called before every launch: local_browser_watchdog.py:137
  • Called again from CDP recovery, with the default profile hardcoded as fallback: openbrowser/mcp/server.py:392-398
  • Called a third time from the launch-retry path after profile errors: local_browser_watchdog.py:233
  • Shared default profile + keep_alive: True: openbrowser/mcp/server.py:313-314

Why it matters beyond a lost browser

keep_alive: True means the browser outlives the call and keeps holding the
profile, so the kill lands on a browser that is alive but idle — including while
Chrome is flushing cookies, Local State and LevelDB right after finishing work.
A SIGKILL at that moment gives Chrome no opportunity to complete those writes.

On our workstation this is not hypothetical: 13 MCP server processes were running
concurrently, all pointing at the same profile — one whose months-old browsing
state (cookies, site reputation) is expensive to recreate.

Reproduction

  1. Start two MCP servers with the default configuration (both therefore use
    ~/.config/openbrowser/profiles/default).
  2. In server A, run any execute_code that navigates — a browser launches and
    stays alive (keep_alive).
  3. In server B, run any execute_code. Its _ensure_namespace()
    _launch_browser()_kill_stale_chrome_for_profile() SIGKILLs A's browser
    before starting its own.
  4. Server A's next call hits a dead CDP connection, recovers, and kills B's
    browser in turn.

Evidence that SIGKILL skips Chrome's shutdown path

Chrome writes profile.exit_type into <profile>/Default/Preferences:
"Crashed" while running, rewritten on shutdown. Measured on throwaway profiles
(Chrome 150.0.7871.186):

Signal exit_type after the process is gone
SIGKILL (current behaviour) Crashed
SIGTERM, then SIGKILL after a 5s grace window SessionEnded

That the value was rewritten in the second case is the point: Chrome ran its
shutdown path and flushed.

Proposed fix

Send SIGTERM first and escalate to SIGKILL only for the processes we signalled,
and only if the already existing 5-second wait loop
(local_browser_watchdog.py:581-601) still finds them. This keeps the function's
contract intact — it still returns only once the profile lock is free — while
giving a healthy browser the chance to shut down cleanly. It also preserves the
behaviour the kill exists for (reaping browsers left behind by a crashed server),
because an unresponsive process is still killed after the grace window.

I have this running locally; a PR with the fix and regression tests is ready and
will follow this issue.

Environment

openbrowser-ai 0.1.50 (current release), Python 3.13, Chrome 150.0.7871.186,
Linux. The same code is present in main at the time of writing.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    Status
    In progress

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions