Skip to content

Commit 2fccc74

Browse files
authored
feat(cli): refuse db reset while basic-memory mcp processes run (#776)
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent c4956ca commit 2fccc74

5 files changed

Lines changed: 318 additions & 4 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ dependencies = [
4848
"sqlite-vec>=0.1.6",
4949
"openai>=1.100.2",
5050
"logfire>=4.19.0",
51+
"psutil>=5.9.0",
5152
]
5253

5354
[project.urls]

src/basic_memory/cli/commands/db.py

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
"""Database management commands."""
22

3+
import os
34
from dataclasses import dataclass
4-
from pathlib import Path
5+
from pathlib import Path, PurePosixPath, PureWindowsPath
56

7+
import psutil
68
import typer
79
from loguru import logger
810
from rich.console import Console
@@ -21,6 +23,107 @@
2123
console = Console()
2224

2325

26+
def _is_basic_memory_mcp(cmdline: list[str]) -> bool:
27+
"""Heuristic: does this argv represent a `basic-memory mcp` server?
28+
29+
The MCP server can be launched any of:
30+
basic-memory mcp
31+
bm mcp # entrypoint alias from pyproject.toml
32+
python -m basic_memory.cli.main mcp # module form
33+
uv run basic-memory mcp / uv run bm mcp # uv wrappers
34+
/abs/path/to/{bm,basic-memory}[.exe] mcp
35+
36+
A reliable match needs both signals:
37+
1. "mcp" appears as an exact argv token (not "mcp-foo").
38+
2. Some argv token names the basic-memory entrypoint — either by
39+
hyphen/underscore form, or as a `bm` script (covers `/usr/local/bin/bm`,
40+
`bm.exe`, etc. via Path.stem).
41+
"""
42+
if "mcp" not in cmdline:
43+
return False
44+
for arg in cmdline:
45+
if "basic-memory" in arg or "basic_memory" in arg:
46+
return True
47+
# Try both POSIX and Windows path interpretations so a test on
48+
# macOS still recognizes `C:\\...\\bm.exe`, and a real Windows
49+
# run still recognizes `/usr/local/bin/bm`. Path() alone uses
50+
# the host OS, which gives wrong stems for foreign separators.
51+
if PurePosixPath(arg).stem == "bm" or PureWindowsPath(arg).stem == "bm":
52+
return True
53+
return False
54+
55+
56+
def _find_live_mcp_processes() -> list[tuple[int, str]]:
57+
"""Return (pid, joined_cmdline) for live `basic-memory mcp` processes.
58+
59+
Why this exists (issue #765):
60+
On POSIX, `Path.unlink()` removes the directory entry but the inode
61+
survives as long as any process holds the file open. A `bm reset`
62+
run while Claude Desktop (or another MCP client) is alive will
63+
therefore "succeed" — but the still-running MCP keeps reading the
64+
old, now-invisible memory.db inode and returns phantom rows. On
65+
Windows the OS naturally raises PermissionError on `unlink()`, so
66+
the bug is POSIX-specific. We detect proactively to give the same
67+
error experience on every platform before doing damage.
68+
69+
The current process is excluded so this can be called from inside a
70+
`bm reset` invocation. NoSuchProcess / AccessDenied are swallowed
71+
because process tables race with the scan and we don't want a
72+
transient permission error to mask a real zombie.
73+
"""
74+
me = os.getpid()
75+
matches: list[tuple[int, str]] = []
76+
for proc in psutil.process_iter(["pid", "cmdline"]):
77+
try:
78+
pid = proc.info.get("pid")
79+
if pid is None or pid == me:
80+
continue
81+
cmdline = proc.info.get("cmdline") or []
82+
if not cmdline:
83+
continue
84+
if _is_basic_memory_mcp(cmdline):
85+
matches.append((pid, " ".join(cmdline)))
86+
except (psutil.NoSuchProcess, psutil.AccessDenied):
87+
continue
88+
return matches
89+
90+
91+
def _abort_if_mcp_processes_alive() -> None:
92+
"""Refuse `bm reset` while basic-memory MCP processes are still running.
93+
94+
See _find_live_mcp_processes for the underlying POSIX-vs-Windows
95+
rationale. Prints a per-PID list and platform-appropriate cleanup
96+
instructions, then exits non-zero so destructive work never starts.
97+
"""
98+
zombies = _find_live_mcp_processes()
99+
if not zombies:
100+
return
101+
102+
console.print(
103+
"[red]Refusing to reset:[/red] basic-memory MCP processes are still running."
104+
)
105+
console.print(
106+
"[yellow]On macOS/Linux these would keep reading the deleted memory.db inode "
107+
"and return phantom search results (see #765).[/yellow]"
108+
)
109+
for pid, cmd in zombies:
110+
console.print(f" PID {pid}: {cmd}")
111+
console.print("\n[bold]How to clean up:[/bold]")
112+
console.print(" 1. Quit Claude Desktop and any other MCP clients.")
113+
if os.name == "nt":
114+
console.print(
115+
" 2. Verify nothing remains: "
116+
"[green]Get-CimInstance Win32_Process | "
117+
"Where-Object {$_.CommandLine -like '*basic-memory*mcp*'}[/green]"
118+
)
119+
else:
120+
console.print(
121+
" 2. Verify nothing remains: [green]pgrep -fa 'basic-memory mcp'[/green]"
122+
)
123+
console.print(" 3. Re-run [green]bm reset[/green].")
124+
raise typer.Exit(1)
125+
126+
24127
@dataclass(slots=True)
25128
class EmbeddingProgress:
26129
"""Typed CLI progress payload for embedding backfills."""
@@ -86,6 +189,16 @@ async def _reindex_projects(app_config):
86189
@app.command()
87190
def reset(
88191
reindex: bool = typer.Option(False, "--reindex", help="Rebuild db index from filesystem"),
192+
force: bool = typer.Option(
193+
False,
194+
"--force",
195+
help=(
196+
"Skip the pre-flight check that refuses to reset while "
197+
"basic-memory MCP processes are running. Use only in "
198+
"automated workflows where you've already ensured no MCP "
199+
"clients are attached to the database."
200+
),
201+
),
89202
): # pragma: no cover
90203
"""Reset database (drop all tables and recreate)."""
91204
console.print(
@@ -94,6 +207,14 @@ def reset(
94207
"Use [green]bm reset --reindex[/green] to automatically rebuild the index afterward."
95208
)
96209
if typer.confirm("Reset the database index?"):
210+
# Pre-flight: refuse to proceed if MCP processes still hold the DB
211+
# file open. POSIX would silently let us unlink the inode while
212+
# they keep reading it; Windows would error here anyway. See
213+
# _find_live_mcp_processes for the full story. --force is the
214+
# documented escape hatch for scripted/CI runs.
215+
if not force:
216+
_abort_if_mcp_processes_alive()
217+
97218
logger.info("Resetting database...")
98219
config_manager = ConfigManager()
99220
app_config = config_manager.config

tests/cli/test_db_reset_exit.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,14 @@ def _isolated_env(tmp_path: Path) -> dict[str, str]:
2626

2727
@skip_on_windows
2828
def test_bm_reset_exits_cleanly(tmp_path: Path):
29-
"""bm reset should finish and exit cleanly with non-interactive confirmation."""
29+
"""bm reset should finish and exit cleanly with non-interactive confirmation.
30+
31+
Uses --force to skip the live-MCP pre-flight (#765); we're verifying
32+
process exit semantics here, not the pre-flight, which has dedicated
33+
coverage in test_db_reset_zombie_check.py.
34+
"""
3035
result = subprocess.run(
31-
["uv", "run", "bm", "reset"],
36+
["uv", "run", "bm", "reset", "--force"],
3237
input="y\n",
3338
capture_output=True,
3439
text=True,
@@ -44,7 +49,7 @@ def test_bm_reset_exits_cleanly(tmp_path: Path):
4449
def test_bm_reset_reindex_exits_cleanly(tmp_path: Path):
4550
"""bm reset --reindex should finish and exit cleanly with non-interactive confirmation."""
4651
result = subprocess.run(
47-
["uv", "run", "bm", "reset", "--reindex"],
52+
["uv", "run", "bm", "reset", "--reindex", "--force"],
4853
input="y\n",
4954
capture_output=True,
5055
text=True,
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""Regression tests for the live-MCP-process pre-flight in `bm reset` (#765)."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
7+
import psutil
8+
import pytest
9+
import typer
10+
11+
from basic_memory.cli.commands import db as db_cmd
12+
13+
14+
class _FakeProc:
15+
"""Minimal stand-in for psutil.Process; only exposes .info."""
16+
17+
def __init__(self, pid: int, cmdline: list[str] | None):
18+
self.info = {"pid": pid, "cmdline": cmdline}
19+
20+
21+
def _patch_iter(monkeypatch: pytest.MonkeyPatch, procs) -> None:
22+
"""Replace psutil.process_iter with a fixed iterator.
23+
24+
Procs is intentionally untyped: tests pass a mix of _FakeProc and
25+
error-raising stand-ins to exercise the per-process exception path.
26+
"""
27+
monkeypatch.setattr(
28+
psutil,
29+
"process_iter",
30+
lambda attrs=None: iter(procs),
31+
)
32+
33+
34+
class TestFindLiveMcpProcesses:
35+
def test_returns_empty_when_no_mcp_processes(self, monkeypatch):
36+
_patch_iter(
37+
monkeypatch,
38+
[
39+
_FakeProc(pid=11, cmdline=["python", "-m", "http.server"]),
40+
_FakeProc(pid=22, cmdline=["bm", "sync"]),
41+
],
42+
)
43+
assert db_cmd._find_live_mcp_processes() == []
44+
45+
def test_matches_basic_memory_mcp_invocations(self, monkeypatch):
46+
_patch_iter(
47+
monkeypatch,
48+
[
49+
# Direct `basic-memory mcp`.
50+
_FakeProc(pid=101, cmdline=["/usr/bin/python", "basic-memory", "mcp"]),
51+
# `bm mcp` alias entrypoint — must also match (#765 P1).
52+
_FakeProc(pid=202, cmdline=["bm", "mcp"]),
53+
# Python module form, underscore name.
54+
_FakeProc(
55+
pid=303,
56+
cmdline=["python", "-m", "basic_memory.cli.main", "mcp"],
57+
),
58+
# Absolute path to the bm script.
59+
_FakeProc(pid=404, cmdline=["/usr/local/bin/bm", "mcp"]),
60+
# Windows-style bm.exe.
61+
_FakeProc(pid=505, cmdline=["C:\\Users\\me\\.venv\\Scripts\\bm.exe", "mcp"]),
62+
# Should NOT match — `mcp` is a substring of another arg, not a token.
63+
_FakeProc(pid=606, cmdline=["python", "basic-memory", "mcp-helper"]),
64+
# Should NOT match — has `mcp` but no basic-memory/bm signature.
65+
_FakeProc(pid=707, cmdline=["python", "/some/other/server.py", "mcp"]),
66+
],
67+
)
68+
result = db_cmd._find_live_mcp_processes()
69+
pids = sorted(pid for pid, _ in result)
70+
assert pids == [101, 202, 303, 404, 505]
71+
72+
def test_skips_current_process(self, monkeypatch):
73+
me = os.getpid()
74+
_patch_iter(
75+
monkeypatch,
76+
[
77+
_FakeProc(pid=me, cmdline=["python", "basic-memory", "mcp"]),
78+
],
79+
)
80+
# Self-match is suppressed so the helper can be called from inside
81+
# `bm reset` without flagging the running process.
82+
assert db_cmd._find_live_mcp_processes() == []
83+
84+
def test_skips_processes_with_no_cmdline(self, monkeypatch):
85+
_patch_iter(
86+
monkeypatch,
87+
[
88+
_FakeProc(pid=1, cmdline=None), # kernel-style process
89+
_FakeProc(pid=2, cmdline=[]),
90+
],
91+
)
92+
assert db_cmd._find_live_mcp_processes() == []
93+
94+
def test_swallows_per_process_errors(self, monkeypatch):
95+
"""A NoSuchProcess race during iteration must not abort the scan."""
96+
97+
class _Raising:
98+
@property
99+
def info(self):
100+
raise psutil.NoSuchProcess(pid=999)
101+
102+
_patch_iter(
103+
monkeypatch,
104+
[
105+
_Raising(),
106+
_FakeProc(pid=42, cmdline=["python", "basic-memory", "mcp"]),
107+
],
108+
)
109+
result = db_cmd._find_live_mcp_processes()
110+
assert [pid for pid, _ in result] == [42]
111+
112+
113+
class TestAbortIfMcpProcessesAlive:
114+
def test_no_op_when_no_processes(self, monkeypatch):
115+
monkeypatch.setattr(db_cmd, "_find_live_mcp_processes", lambda: [])
116+
# Must not raise — destructive work should proceed.
117+
db_cmd._abort_if_mcp_processes_alive()
118+
119+
def test_exits_with_pids_when_processes_alive(self, monkeypatch, capsys):
120+
monkeypatch.setattr(
121+
db_cmd,
122+
"_find_live_mcp_processes",
123+
lambda: [(123, "python basic-memory mcp"), (456, "uv run bm mcp wrapper")],
124+
)
125+
with pytest.raises(typer.Exit) as exc_info:
126+
db_cmd._abort_if_mcp_processes_alive()
127+
assert exc_info.value.exit_code == 1
128+
129+
captured = capsys.readouterr()
130+
# PIDs surface so the user can target the cleanup themselves.
131+
assert "123" in captured.out
132+
assert "456" in captured.out
133+
assert "MCP processes" in captured.out
134+
135+
def test_prints_platform_specific_cleanup_hint_posix(self, monkeypatch, capsys):
136+
monkeypatch.setattr(os, "name", "posix")
137+
monkeypatch.setattr(
138+
db_cmd,
139+
"_find_live_mcp_processes",
140+
lambda: [(7, "python basic-memory mcp")],
141+
)
142+
with pytest.raises(typer.Exit):
143+
db_cmd._abort_if_mcp_processes_alive()
144+
out = capsys.readouterr().out
145+
assert "pgrep -fa 'basic-memory mcp'" in out
146+
147+
def test_prints_platform_specific_cleanup_hint_windows(self, monkeypatch, capsys):
148+
monkeypatch.setattr(os, "name", "nt")
149+
monkeypatch.setattr(
150+
db_cmd,
151+
"_find_live_mcp_processes",
152+
lambda: [(7, "python basic-memory mcp")],
153+
)
154+
with pytest.raises(typer.Exit):
155+
db_cmd._abort_if_mcp_processes_alive()
156+
out = capsys.readouterr().out
157+
assert "Get-CimInstance" in out

0 commit comments

Comments
 (0)