Skip to content

Commit b3bdd59

Browse files
phernandezclaude
andcommitted
fix(cli): write config.json atomically and isolate auto-update in mcp routing tests
Root cause of the test_mcp_sse_forces_local KeyError('FORCE_LOCAL') flake (#940, Python 3.14 leg): the stdio variants of the mcp routing tests invoke `bm mcp`, which starts a real background auto-update daemon thread before mcp_server.run (the tests only mock run). That thread hits PyPI and then rewrites config.json (auto_update_last_checked_at) with an in-place Path.write_text, which truncates the file before writing. If that write lands while a later test's CLI invocation is reading config.json (the app callback's CliContainer.create()), load_config() sees empty/partial JSON and raises SystemExit. CliRunner.invoke swallows it, the mocked mcp_server.run never executes, and the test dies with KeyError on env_at_run['FORCE_LOCAL'] — exactly the observed CI failure shape. Nothing is 3.14-specific; that leg only shifted the timing. The same torn write is user-visible in production: the MCP stdio server re-reads config.json on mtime change and load_config() exits the process on invalid JSON if it races a CLI save. Fix: save_basic_memory_config writes a per-process/per-thread sibling temp file and publishes it with os.replace, so readers always observe either the old or the new complete document. The regression test injects an interrupted write and asserts the published config stays untouched; it fails against the old in-place write. Test hardening: the stdio routing tests stub run_auto_update so no PyPI call or config write leaks across tests, and all four transport tests now assert result.exit_code == 0 so a future pre-run failure surfaces its real error instead of a KeyError. Refs #940 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 85c701b commit b3bdd59

3 files changed

Lines changed: 106 additions & 6 deletions

File tree

src/basic_memory/config.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import json
55
import os
66
import shutil
7+
import threading
78
from dataclasses import dataclass
89
from datetime import datetime
910
from enum import Enum
@@ -1099,8 +1100,21 @@ def save_basic_memory_config(file_path: Path, config: BasicMemoryConfig) -> None
10991100
_secure_config_dir(file_path.parent)
11001101
# Use model_dump with mode='json' to serialize datetime objects properly
11011102
config_dict = config.model_dump(mode="json")
1102-
file_path.write_text(json.dumps(config_dict, indent=2))
1103-
_secure_config_file(file_path)
1103+
# Trigger: long-lived readers (MCP stdio server config reload, background
1104+
# auto-update threads) re-read config.json whenever its mtime changes,
1105+
# concurrently with CLI commands saving it.
1106+
# Why: writing the destination in place truncates it first, so a concurrent
1107+
# reader can observe empty/partial JSON and load_config() exits the process.
1108+
# Outcome: write a sibling temp file (unique per process/thread so parallel
1109+
# savers cannot interleave) and publish atomically via os.replace — readers
1110+
# always see either the old or the new complete document. (#940)
1111+
tmp_path = file_path.parent / f"{file_path.name}.{os.getpid()}.{threading.get_ident()}.tmp"
1112+
try:
1113+
tmp_path.write_text(json.dumps(config_dict, indent=2))
1114+
_secure_config_file(tmp_path)
1115+
os.replace(tmp_path, file_path)
1116+
finally:
1117+
tmp_path.unlink(missing_ok=True)
11041118
except Exception as e: # pragma: no cover
11051119
logger.error(f"Failed to save config: {e}")
11061120

test-int/cli/test_routing_integration.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,27 @@ def test_tool_edit_note_both_flags_error(self):
8989
assert "Cannot specify both --local and --cloud" in result.output
9090

9191

92+
def _stub_auto_update(monkeypatch, mcp_mod) -> None:
93+
"""Keep `bm mcp` stdio tests from running the real background auto-update.
94+
95+
The command starts a daemon thread before mcp_server.run; unstubbed it hits
96+
PyPI and rewrites config.json from a background thread, leaking into later
97+
tests in the same process (#940's KeyError flake on test_mcp_sse_forces_local).
98+
"""
99+
from basic_memory.cli.auto_update import AutoUpdateResult, AutoUpdateStatus, InstallSource
100+
101+
def skipped_auto_update(**kwargs) -> AutoUpdateResult:
102+
return AutoUpdateResult(
103+
status=AutoUpdateStatus.SKIPPED,
104+
source=InstallSource.UNKNOWN,
105+
checked=False,
106+
update_available=False,
107+
updated=False,
108+
)
109+
110+
monkeypatch.setattr(mcp_mod, "run_auto_update", skipped_auto_update)
111+
112+
92113
class TestMcpCommandRouting:
93114
"""Tests that MCP routing varies by transport."""
94115

@@ -109,8 +130,10 @@ def mock_run(*args, **kwargs):
109130

110131
monkeypatch.setattr(mcp_mod.mcp_server, "run", mock_run)
111132
monkeypatch.setattr(mcp_mod, "init_mcp_logging", lambda: None)
133+
_stub_auto_update(monkeypatch, mcp_mod)
112134

113-
runner.invoke(cli_app, ["mcp"]) # default transport is stdio
135+
result = runner.invoke(cli_app, ["mcp"]) # default transport is stdio
136+
assert result.exit_code == 0, result.output
114137

115138
# Command should not have set these vars
116139
assert env_at_run["FORCE_LOCAL"] is None
@@ -132,8 +155,10 @@ def mock_run(*args, **kwargs):
132155

133156
monkeypatch.setattr(mcp_mod.mcp_server, "run", mock_run)
134157
monkeypatch.setattr(mcp_mod, "init_mcp_logging", lambda: None)
158+
_stub_auto_update(monkeypatch, mcp_mod)
135159

136-
runner.invoke(cli_app, ["mcp"])
160+
result = runner.invoke(cli_app, ["mcp"])
161+
assert result.exit_code == 0, result.output
137162

138163
# Externally-set vars should be preserved
139164
assert env_at_run["FORCE_CLOUD"] == "true"
@@ -153,7 +178,8 @@ def mock_run(*args, **kwargs):
153178
monkeypatch.setattr(mcp_mod.mcp_server, "run", mock_run)
154179
monkeypatch.setattr(mcp_mod, "init_mcp_logging", lambda: None)
155180

156-
runner.invoke(cli_app, ["mcp", "--transport", "streamable-http"])
181+
result = runner.invoke(cli_app, ["mcp", "--transport", "streamable-http"])
182+
assert result.exit_code == 0, result.output
157183

158184
assert env_at_run["FORCE_LOCAL"] == "true"
159185
assert env_at_run["EXPLICIT"] == "true"
@@ -172,7 +198,8 @@ def mock_run(*args, **kwargs):
172198
monkeypatch.setattr(mcp_mod.mcp_server, "run", mock_run)
173199
monkeypatch.setattr(mcp_mod, "init_mcp_logging", lambda: None)
174200

175-
runner.invoke(cli_app, ["mcp", "--transport", "sse"])
201+
result = runner.invoke(cli_app, ["mcp", "--transport", "sse"])
202+
assert result.exit_code == 0, result.output
176203

177204
assert env_at_run["FORCE_LOCAL"] == "true"
178205
assert env_at_run["EXPLICIT"] == "true"

tests/test_config.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1600,3 +1600,62 @@ def test_auto_update_round_trip_persistence(self):
16001600
assert loaded.auto_update is False
16011601
assert loaded.update_check_interval == 7200
16021602
assert loaded.auto_update_last_checked_at == checked_at
1603+
1604+
1605+
class TestAtomicConfigSave:
1606+
"""Regression tests for #940: saving config must never tear the published file.
1607+
1608+
Long-lived readers (the MCP stdio server's mtime-based config reload, the CLI
1609+
background auto-update thread) re-read config.json while other code saves it.
1610+
An in-place write truncates the file first, so a concurrent reader can observe
1611+
empty/partial JSON — and load_config() raises SystemExit on invalid JSON.
1612+
"""
1613+
1614+
def test_interrupted_save_preserves_published_config(self, config_home, monkeypatch):
1615+
"""A write that dies mid-stream must leave the existing config untouched."""
1616+
import json
1617+
1618+
from basic_memory.config import save_basic_memory_config
1619+
1620+
with tempfile.TemporaryDirectory() as temp_dir:
1621+
temp_path = Path(temp_dir)
1622+
config_file = temp_path / "config.json"
1623+
config = BasicMemoryConfig(
1624+
projects={"main": {"path": str(temp_path / "main")}},
1625+
default_project="main",
1626+
)
1627+
save_basic_memory_config(config_file, config)
1628+
published = config_file.read_text(encoding="utf-8")
1629+
json.loads(published) # sanity: complete, valid document
1630+
1631+
def torn_write_text(self, content, *args, **kwargs):
1632+
# Fault injection: the write dies halfway through. For an in-place
1633+
# write this is exactly the truncated state a concurrent reader
1634+
# observes mid-save; an atomic save must confine it to a temp file.
1635+
with open(self, "w", encoding="utf-8") as fh:
1636+
fh.write(content[: len(content) // 2])
1637+
raise OSError("simulated interrupted write")
1638+
1639+
monkeypatch.setattr(Path, "write_text", torn_write_text)
1640+
# save_basic_memory_config logs write failures instead of raising
1641+
save_basic_memory_config(config_file, config)
1642+
monkeypatch.undo()
1643+
1644+
assert config_file.read_text(encoding="utf-8") == published
1645+
1646+
def test_save_leaves_no_temp_files(self, config_home):
1647+
"""The atomic-write temp file must not survive a successful save."""
1648+
with tempfile.TemporaryDirectory() as temp_dir:
1649+
temp_path = Path(temp_dir)
1650+
config_file = temp_path / "config.json"
1651+
config = BasicMemoryConfig(
1652+
projects={"main": {"path": str(temp_path / "main")}},
1653+
default_project="main",
1654+
)
1655+
1656+
from basic_memory.config import save_basic_memory_config
1657+
1658+
save_basic_memory_config(config_file, config)
1659+
1660+
assert config_file.exists()
1661+
assert not list(temp_path.glob("*.tmp"))

0 commit comments

Comments
 (0)