diff --git a/pyproject.toml b/pyproject.toml index 5c7cf1ad..5d864edf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ dependencies = [ "sqlite-vec>=0.1.6", "openai>=1.100.2", "logfire>=4.19.0", + "psutil>=5.9.0", ] [project.urls] diff --git a/src/basic_memory/cli/commands/db.py b/src/basic_memory/cli/commands/db.py index fea4f88d..793e548f 100644 --- a/src/basic_memory/cli/commands/db.py +++ b/src/basic_memory/cli/commands/db.py @@ -1,8 +1,10 @@ """Database management commands.""" +import os from dataclasses import dataclass -from pathlib import Path +from pathlib import Path, PurePosixPath, PureWindowsPath +import psutil import typer from loguru import logger from rich.console import Console @@ -21,6 +23,107 @@ console = Console() +def _is_basic_memory_mcp(cmdline: list[str]) -> bool: + """Heuristic: does this argv represent a `basic-memory mcp` server? + + The MCP server can be launched any of: + basic-memory mcp + bm mcp # entrypoint alias from pyproject.toml + python -m basic_memory.cli.main mcp # module form + uv run basic-memory mcp / uv run bm mcp # uv wrappers + /abs/path/to/{bm,basic-memory}[.exe] mcp + + A reliable match needs both signals: + 1. "mcp" appears as an exact argv token (not "mcp-foo"). + 2. Some argv token names the basic-memory entrypoint — either by + hyphen/underscore form, or as a `bm` script (covers `/usr/local/bin/bm`, + `bm.exe`, etc. via Path.stem). + """ + if "mcp" not in cmdline: + return False + for arg in cmdline: + if "basic-memory" in arg or "basic_memory" in arg: + return True + # Try both POSIX and Windows path interpretations so a test on + # macOS still recognizes `C:\\...\\bm.exe`, and a real Windows + # run still recognizes `/usr/local/bin/bm`. Path() alone uses + # the host OS, which gives wrong stems for foreign separators. + if PurePosixPath(arg).stem == "bm" or PureWindowsPath(arg).stem == "bm": + return True + return False + + +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 + if _is_basic_memory_mcp(cmdline): + matches.append((pid, " ".join(cmdline))) + 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 +189,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 +207,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 diff --git a/tests/cli/test_db_reset_exit.py b/tests/cli/test_db_reset_exit.py index f9fdae9d..c3975b4c 100644 --- a/tests/cli/test_db_reset_exit.py +++ b/tests/cli/test_db_reset_exit.py @@ -26,9 +26,14 @@ def _isolated_env(tmp_path: Path) -> dict[str, str]: @skip_on_windows def test_bm_reset_exits_cleanly(tmp_path: Path): - """bm reset should finish and exit cleanly with non-interactive confirmation.""" + """bm reset should finish and exit cleanly with non-interactive confirmation. + + Uses --force to skip the live-MCP pre-flight (#765); we're verifying + process exit semantics here, not the pre-flight, which has dedicated + coverage in test_db_reset_zombie_check.py. + """ result = subprocess.run( - ["uv", "run", "bm", "reset"], + ["uv", "run", "bm", "reset", "--force"], input="y\n", capture_output=True, text=True, @@ -44,7 +49,7 @@ def test_bm_reset_exits_cleanly(tmp_path: Path): def test_bm_reset_reindex_exits_cleanly(tmp_path: Path): """bm reset --reindex should finish and exit cleanly with non-interactive confirmation.""" result = subprocess.run( - ["uv", "run", "bm", "reset", "--reindex"], + ["uv", "run", "bm", "reset", "--reindex", "--force"], input="y\n", capture_output=True, text=True, diff --git a/tests/cli/test_db_reset_zombie_check.py b/tests/cli/test_db_reset_zombie_check.py new file mode 100644 index 00000000..ed969847 --- /dev/null +++ b/tests/cli/test_db_reset_zombie_check.py @@ -0,0 +1,157 @@ +"""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) -> None: + """Replace psutil.process_iter with a fixed iterator. + + Procs is intentionally untyped: tests pass a mix of _FakeProc and + error-raising stand-ins to exercise the per-process exception path. + """ + 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, + [ + # Direct `basic-memory mcp`. + _FakeProc(pid=101, cmdline=["/usr/bin/python", "basic-memory", "mcp"]), + # `bm mcp` alias entrypoint — must also match (#765 P1). + _FakeProc(pid=202, cmdline=["bm", "mcp"]), + # Python module form, underscore name. + _FakeProc( + pid=303, + cmdline=["python", "-m", "basic_memory.cli.main", "mcp"], + ), + # Absolute path to the bm script. + _FakeProc(pid=404, cmdline=["/usr/local/bin/bm", "mcp"]), + # Windows-style bm.exe. + _FakeProc(pid=505, cmdline=["C:\\Users\\me\\.venv\\Scripts\\bm.exe", "mcp"]), + # Should NOT match — `mcp` is a substring of another arg, not a token. + _FakeProc(pid=606, cmdline=["python", "basic-memory", "mcp-helper"]), + # Should NOT match — has `mcp` but no basic-memory/bm signature. + _FakeProc(pid=707, cmdline=["python", "/some/other/server.py", "mcp"]), + ], + ) + result = db_cmd._find_live_mcp_processes() + pids = sorted(pid for pid, _ in result) + assert pids == [101, 202, 303, 404, 505] + + 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 diff --git a/uv.lock b/uv.lock index c949a35c..dd6502d2 100644 --- a/uv.lock +++ b/uv.lock @@ -177,6 +177,7 @@ dependencies = [ { name = "nest-asyncio" }, { name = "openai" }, { name = "pillow" }, + { name = "psutil" }, { name = "psycopg" }, { name = "pybars3" }, { name = "pydantic", extra = ["email", "timezone"] }, @@ -241,6 +242,7 @@ requires-dist = [ { name = "nest-asyncio", specifier = ">=1.6.0" }, { name = "openai", specifier = ">=1.100.2" }, { name = "pillow", specifier = ">=11.1.0" }, + { name = "psutil", specifier = ">=5.9.0" }, { name = "psycopg", specifier = "==3.3.1" }, { name = "pybars3", specifier = ">=0.9.7" }, { name = "pydantic", extras = ["email", "timezone"], specifier = ">=2.12.0" }, @@ -2136,6 +2138,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, ] +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + [[package]] name = "psycopg" version = "3.3.1"