-
Notifications
You must be signed in to change notification settings - Fork 196
feat(cli): refuse db reset while basic-memory mcp processes run #776
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,10 @@ | ||
| """Database management commands.""" | ||
|
|
||
| import os | ||
| from dataclasses import dataclass | ||
| from pathlib import Path | ||
|
|
||
| import psutil | ||
| import typer | ||
| from loguru import logger | ||
| from rich.console import Console | ||
|
|
@@ -21,6 +23,80 @@ | |
| console = Console() | ||
|
|
||
|
|
||
| def _find_live_mcp_processes() -> list[tuple[int, str]]: | ||
| """Return (pid, joined_cmdline) for live `basic-memory mcp` processes. | ||
|
|
||
| Why this exists (issue #765): | ||
| On POSIX, `Path.unlink()` removes the directory entry but the inode | ||
| survives as long as any process holds the file open. A `bm reset` | ||
| run while Claude Desktop (or another MCP client) is alive will | ||
| therefore "succeed" — but the still-running MCP keeps reading the | ||
| old, now-invisible memory.db inode and returns phantom rows. On | ||
| Windows the OS naturally raises PermissionError on `unlink()`, so | ||
| the bug is POSIX-specific. We detect proactively to give the same | ||
| error experience on every platform before doing damage. | ||
|
|
||
| The current process is excluded so this can be called from inside a | ||
| `bm reset` invocation. NoSuchProcess / AccessDenied are swallowed | ||
| because process tables race with the scan and we don't want a | ||
| transient permission error to mask a real zombie. | ||
| """ | ||
| me = os.getpid() | ||
| matches: list[tuple[int, str]] = [] | ||
| for proc in psutil.process_iter(["pid", "cmdline"]): | ||
| try: | ||
| pid = proc.info.get("pid") | ||
| if pid is None or pid == me: | ||
| continue | ||
| cmdline = proc.info.get("cmdline") or [] | ||
| if not cmdline: | ||
| continue | ||
| # Match the full `basic-memory mcp` invocation, not just any | ||
| # process whose argv mentions either word individually. | ||
| joined = " ".join(cmdline) | ||
| if "basic-memory" in joined and "mcp" in cmdline: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This guard aborts Useful? React with 👍 / 👎. |
||
| matches.append((pid, joined)) | ||
| except (psutil.NoSuchProcess, psutil.AccessDenied): | ||
| continue | ||
| return matches | ||
|
|
||
|
|
||
| def _abort_if_mcp_processes_alive() -> None: | ||
| """Refuse `bm reset` while basic-memory MCP processes are still running. | ||
|
|
||
| See _find_live_mcp_processes for the underlying POSIX-vs-Windows | ||
| rationale. Prints a per-PID list and platform-appropriate cleanup | ||
| instructions, then exits non-zero so destructive work never starts. | ||
| """ | ||
| zombies = _find_live_mcp_processes() | ||
| if not zombies: | ||
| return | ||
|
|
||
| console.print( | ||
| "[red]Refusing to reset:[/red] basic-memory MCP processes are still running." | ||
| ) | ||
| console.print( | ||
| "[yellow]On macOS/Linux these would keep reading the deleted memory.db inode " | ||
| "and return phantom search results (see #765).[/yellow]" | ||
| ) | ||
| for pid, cmd in zombies: | ||
| console.print(f" PID {pid}: {cmd}") | ||
| console.print("\n[bold]How to clean up:[/bold]") | ||
| console.print(" 1. Quit Claude Desktop and any other MCP clients.") | ||
| if os.name == "nt": | ||
| console.print( | ||
| " 2. Verify nothing remains: " | ||
| "[green]Get-CimInstance Win32_Process | " | ||
| "Where-Object {$_.CommandLine -like '*basic-memory*mcp*'}[/green]" | ||
| ) | ||
| else: | ||
| console.print( | ||
| " 2. Verify nothing remains: [green]pgrep -fa 'basic-memory mcp'[/green]" | ||
| ) | ||
| console.print(" 3. Re-run [green]bm reset[/green].") | ||
| raise typer.Exit(1) | ||
|
|
||
|
|
||
| @dataclass(slots=True) | ||
| class EmbeddingProgress: | ||
| """Typed CLI progress payload for embedding backfills.""" | ||
|
|
@@ -86,6 +162,16 @@ async def _reindex_projects(app_config): | |
| @app.command() | ||
| def reset( | ||
| reindex: bool = typer.Option(False, "--reindex", help="Rebuild db index from filesystem"), | ||
| force: bool = typer.Option( | ||
| False, | ||
| "--force", | ||
| help=( | ||
| "Skip the pre-flight check that refuses to reset while " | ||
| "basic-memory MCP processes are running. Use only in " | ||
| "automated workflows where you've already ensured no MCP " | ||
| "clients are attached to the database." | ||
| ), | ||
| ), | ||
| ): # pragma: no cover | ||
| """Reset database (drop all tables and recreate).""" | ||
| console.print( | ||
|
|
@@ -94,6 +180,14 @@ def reset( | |
| "Use [green]bm reset --reindex[/green] to automatically rebuild the index afterward." | ||
| ) | ||
| if typer.confirm("Reset the database index?"): | ||
| # Pre-flight: refuse to proceed if MCP processes still hold the DB | ||
| # file open. POSIX would silently let us unlink the inode while | ||
| # they keep reading it; Windows would error here anyway. See | ||
| # _find_live_mcp_processes for the full story. --force is the | ||
| # documented escape hatch for scripted/CI runs. | ||
| if not force: | ||
| _abort_if_mcp_processes_alive() | ||
|
|
||
| logger.info("Resetting database...") | ||
| config_manager = ConfigManager() | ||
| app_config = config_manager.config | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| """Regression tests for the live-MCP-process pre-flight in `bm reset` (#765).""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import os | ||
|
|
||
| import psutil | ||
| import pytest | ||
| import typer | ||
|
|
||
| from basic_memory.cli.commands import db as db_cmd | ||
|
|
||
|
|
||
| class _FakeProc: | ||
| """Minimal stand-in for psutil.Process; only exposes .info.""" | ||
|
|
||
| def __init__(self, pid: int, cmdline: list[str] | None): | ||
| self.info = {"pid": pid, "cmdline": cmdline} | ||
|
|
||
|
|
||
| def _patch_iter(monkeypatch: pytest.MonkeyPatch, procs: list[_FakeProc]) -> None: | ||
| monkeypatch.setattr( | ||
| psutil, | ||
| "process_iter", | ||
| lambda attrs=None: iter(procs), | ||
| ) | ||
|
|
||
|
|
||
| class TestFindLiveMcpProcesses: | ||
| def test_returns_empty_when_no_mcp_processes(self, monkeypatch): | ||
| _patch_iter( | ||
| monkeypatch, | ||
| [ | ||
| _FakeProc(pid=11, cmdline=["python", "-m", "http.server"]), | ||
| _FakeProc(pid=22, cmdline=["bm", "sync"]), | ||
| ], | ||
| ) | ||
| assert db_cmd._find_live_mcp_processes() == [] | ||
|
|
||
| def test_matches_basic_memory_mcp_invocations(self, monkeypatch): | ||
| _patch_iter( | ||
| monkeypatch, | ||
| [ | ||
| _FakeProc(pid=101, cmdline=["/usr/bin/python", "basic-memory", "mcp"]), | ||
| _FakeProc(pid=202, cmdline=["bm", "mcp"]), # alt entrypoint, no match | ||
| _FakeProc( | ||
| pid=303, | ||
| cmdline=["python", "-m", "basic_memory.cli.main", "basic-memory", "mcp"], | ||
| ), | ||
| ], | ||
| ) | ||
| result = db_cmd._find_live_mcp_processes() | ||
| # 101 and 303 match (cmdline contains both "basic-memory" and exact "mcp" arg). | ||
| # 202 is intentionally excluded because the joined cmdline has no "basic-memory". | ||
| pids = sorted(pid for pid, _ in result) | ||
| assert pids == [101, 303] | ||
|
|
||
| def test_skips_current_process(self, monkeypatch): | ||
| me = os.getpid() | ||
| _patch_iter( | ||
| monkeypatch, | ||
| [ | ||
| _FakeProc(pid=me, cmdline=["python", "basic-memory", "mcp"]), | ||
| ], | ||
| ) | ||
| # Self-match is suppressed so the helper can be called from inside | ||
| # `bm reset` without flagging the running process. | ||
| assert db_cmd._find_live_mcp_processes() == [] | ||
|
|
||
| def test_skips_processes_with_no_cmdline(self, monkeypatch): | ||
| _patch_iter( | ||
| monkeypatch, | ||
| [ | ||
| _FakeProc(pid=1, cmdline=None), # kernel-style process | ||
| _FakeProc(pid=2, cmdline=[]), | ||
| ], | ||
| ) | ||
| assert db_cmd._find_live_mcp_processes() == [] | ||
|
|
||
| def test_swallows_per_process_errors(self, monkeypatch): | ||
| """A NoSuchProcess race during iteration must not abort the scan.""" | ||
|
|
||
| class _Raising: | ||
| @property | ||
| def info(self): | ||
| raise psutil.NoSuchProcess(pid=999) | ||
|
|
||
| _patch_iter( | ||
| monkeypatch, | ||
| [ | ||
| _Raising(), | ||
| _FakeProc(pid=42, cmdline=["python", "basic-memory", "mcp"]), | ||
| ], | ||
| ) | ||
| result = db_cmd._find_live_mcp_processes() | ||
| assert [pid for pid, _ in result] == [42] | ||
|
|
||
|
|
||
| class TestAbortIfMcpProcessesAlive: | ||
| def test_no_op_when_no_processes(self, monkeypatch): | ||
| monkeypatch.setattr(db_cmd, "_find_live_mcp_processes", lambda: []) | ||
| # Must not raise — destructive work should proceed. | ||
| db_cmd._abort_if_mcp_processes_alive() | ||
|
|
||
| def test_exits_with_pids_when_processes_alive(self, monkeypatch, capsys): | ||
| monkeypatch.setattr( | ||
| db_cmd, | ||
| "_find_live_mcp_processes", | ||
| lambda: [(123, "python basic-memory mcp"), (456, "uv run bm mcp wrapper")], | ||
| ) | ||
| with pytest.raises(typer.Exit) as exc_info: | ||
| db_cmd._abort_if_mcp_processes_alive() | ||
| assert exc_info.value.exit_code == 1 | ||
|
|
||
| captured = capsys.readouterr() | ||
| # PIDs surface so the user can target the cleanup themselves. | ||
| assert "123" in captured.out | ||
| assert "456" in captured.out | ||
| assert "MCP processes" in captured.out | ||
|
|
||
| def test_prints_platform_specific_cleanup_hint_posix(self, monkeypatch, capsys): | ||
| monkeypatch.setattr(os, "name", "posix") | ||
| monkeypatch.setattr( | ||
| db_cmd, | ||
| "_find_live_mcp_processes", | ||
| lambda: [(7, "python basic-memory mcp")], | ||
| ) | ||
| with pytest.raises(typer.Exit): | ||
| db_cmd._abort_if_mcp_processes_alive() | ||
| out = capsys.readouterr().out | ||
| assert "pgrep -fa 'basic-memory mcp'" in out | ||
|
|
||
| def test_prints_platform_specific_cleanup_hint_windows(self, monkeypatch, capsys): | ||
| monkeypatch.setattr(os, "name", "nt") | ||
| monkeypatch.setattr( | ||
| db_cmd, | ||
| "_find_live_mcp_processes", | ||
| lambda: [(7, "python basic-memory mcp")], | ||
| ) | ||
| with pytest.raises(typer.Exit): | ||
| db_cmd._abort_if_mcp_processes_alive() | ||
| out = capsys.readouterr().out | ||
| assert "Get-CimInstance" in out |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
bm mcpprocesses before allowing resetThe new pre-flight only matches processes whose command line contains the literal
basic-memory, so an MCP server started via the supportedbm mcpentrypoint is not detected. In that scenariobm resetstill proceeds on POSIX while the live MCP process keeps the old inode open, reproducing the phantom-results problem this change is meant to block. The matcher should include thebmalias (or detect by subcommand structure) to avoid this false negative.Useful? React with 👍 / 👎.