feat(cli): refuse db reset while basic-memory mcp processes run (#776)

Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
Paul Hernandez
2026-04-29 15:48:19 -05:00
committed by GitHub
parent c4956cac16
commit 2fccc74a20
5 changed files with 318 additions and 4 deletions
+1
View File
@@ -48,6 +48,7 @@ dependencies = [
"sqlite-vec>=0.1.6",
"openai>=1.100.2",
"logfire>=4.19.0",
"psutil>=5.9.0",
]
[project.urls]
+122 -1
View File
@@ -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 @@ from basic_memory.sync.sync_service import get_sync_service
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
+8 -3
View File
@@ -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,
+157
View File
@@ -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
Generated
+30
View File
@@ -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"