Skip to content

Commit db60942

Browse files
groksrcclaude
andauthored
fix(core): invalidate config cache when file is modified by another process (#662)
Signed-off-by: Drew Cain <groksrc@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7bfac15 commit db60942

12 files changed

+231
-5
lines changed

src/basic_memory/config.py

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,12 @@ def data_dir_path(self) -> Path:
645645

646646
# Module-level cache for configuration
647647
_CONFIG_CACHE: Optional[BasicMemoryConfig] = None
648+
# Track config file mtime+size so cross-process changes (e.g. `bm project set-cloud`
649+
# in a separate terminal) invalidate the cache in long-lived processes like the
650+
# MCP stdio server. Using both mtime and size guards against coarse-granularity
651+
# filesystems where two writes within the same second share the same mtime.
652+
_CONFIG_MTIME: Optional[float] = None
653+
_CONFIG_SIZE: Optional[int] = None
648654

649655

650656
class ConfigManager:
@@ -678,13 +684,38 @@ def load_config(self) -> BasicMemoryConfig:
678684
Environment variables take precedence over file config values,
679685
following Pydantic Settings best practices.
680686
681-
Uses module-level cache for performance across ConfigManager instances.
687+
Uses module-level cache with file mtime validation so that
688+
cross-process config changes (e.g. `bm project set-cloud` in a
689+
separate terminal) are picked up by long-lived processes like
690+
the MCP stdio server.
682691
"""
683-
global _CONFIG_CACHE
692+
global _CONFIG_CACHE, _CONFIG_MTIME, _CONFIG_SIZE
684693

685-
# Return cached config if available
694+
# Trigger: cached config exists but the on-disk file may have been
695+
# modified by another process (CLI command in a different terminal).
696+
# Why: the MCP server is long-lived; without this check it would
697+
# serve stale project routing forever.
698+
# Outcome: cheap os.stat() per access; re-read only when mtime or size differs.
686699
if _CONFIG_CACHE is not None:
687-
return _CONFIG_CACHE
700+
try:
701+
st = self.config_file.stat()
702+
current_mtime = st.st_mtime
703+
current_size = st.st_size
704+
except OSError:
705+
current_mtime = None
706+
current_size = None
707+
708+
if (
709+
current_mtime is not None
710+
and current_mtime == _CONFIG_MTIME
711+
and current_size == _CONFIG_SIZE
712+
):
713+
return _CONFIG_CACHE
714+
715+
# mtime/size changed or file gone — invalidate and fall through to re-read
716+
_CONFIG_CACHE = None
717+
_CONFIG_MTIME = None
718+
_CONFIG_SIZE = None
688719

689720
if self.config_file.exists():
690721
try:
@@ -739,6 +770,15 @@ def load_config(self) -> BasicMemoryConfig:
739770

740771
_CONFIG_CACHE = BasicMemoryConfig(**merged_data)
741772

773+
# Record mtime+size so subsequent calls detect cross-process changes
774+
try:
775+
st = self.config_file.stat()
776+
_CONFIG_MTIME = st.st_mtime
777+
_CONFIG_SIZE = st.st_size
778+
except OSError:
779+
_CONFIG_MTIME = None
780+
_CONFIG_SIZE = None
781+
742782
# Re-save to normalize legacy config into current format
743783
if needs_resave:
744784
# Create backup before overwriting so users can revert if needed
@@ -769,10 +809,12 @@ def load_config(self) -> BasicMemoryConfig:
769809

770810
def save_config(self, config: BasicMemoryConfig) -> None:
771811
"""Save configuration to file and invalidate cache."""
772-
global _CONFIG_CACHE
812+
global _CONFIG_CACHE, _CONFIG_MTIME, _CONFIG_SIZE
773813
save_basic_memory_config(self.config_file, config)
774814
# Invalidate cache so next load_config() reads fresh data
775815
_CONFIG_CACHE = None
816+
_CONFIG_MTIME = None
817+
_CONFIG_SIZE = None
776818

777819
@property
778820
def projects(self) -> Dict[str, str]:

test-int/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,8 @@ def config_manager(app_config: BasicMemoryConfig, config_home) -> ConfigManager:
258258
from basic_memory import config as config_module
259259

260260
config_module._CONFIG_CACHE = None
261+
config_module._CONFIG_MTIME = None
262+
config_module._CONFIG_SIZE = None
261263

262264
config_manager = ConfigManager()
263265
# Update its paths to use the test directory

tests/cli/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ def isolated_home(tmp_path, monkeypatch) -> Path:
2525
from basic_memory import config as config_module
2626

2727
config_module._CONFIG_CACHE = None
28+
config_module._CONFIG_MTIME = None
29+
config_module._CONFIG_SIZE = None
2830

2931
monkeypatch.setenv("HOME", str(tmp_path))
3032
if os.name == "nt":

tests/cli/test_json_output.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,8 @@ def _write(config_data: dict):
350350
from basic_memory import config as config_module
351351

352352
config_module._CONFIG_CACHE = None
353+
config_module._CONFIG_MTIME = None
354+
config_module._CONFIG_SIZE = None
353355

354356
config_dir = tmp_path / ".basic-memory"
355357
config_dir.mkdir(parents=True, exist_ok=True)

tests/cli/test_project_add_with_local_path.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ def mock_config(tmp_path, monkeypatch):
2727
from basic_memory import config as config_module
2828

2929
config_module._CONFIG_CACHE = None
30+
config_module._CONFIG_MTIME = None
31+
config_module._CONFIG_SIZE = None
3032

3133
config_dir = tmp_path / ".basic-memory"
3234
config_dir.mkdir(parents=True, exist_ok=True)

tests/cli/test_project_list_and_ls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ def _write(config_data: dict) -> Path:
2929
from basic_memory import config as config_module
3030

3131
config_module._CONFIG_CACHE = None
32+
config_module._CONFIG_MTIME = None
33+
config_module._CONFIG_SIZE = None
3234

3335
config_dir = tmp_path / ".basic-memory"
3436
config_dir.mkdir(parents=True, exist_ok=True)

tests/cli/test_project_set_cloud_local.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ def mock_config(tmp_path, monkeypatch):
2222
from basic_memory import config as config_module
2323

2424
config_module._CONFIG_CACHE = None
25+
config_module._CONFIG_MTIME = None
26+
config_module._CONFIG_SIZE = None
2527

2628
config_dir = tmp_path / ".basic-memory"
2729
config_dir.mkdir(parents=True, exist_ok=True)
@@ -68,6 +70,8 @@ def test_set_cloud_no_credentials(self, runner, tmp_path, monkeypatch):
6870
from basic_memory import config as config_module
6971

7072
config_module._CONFIG_CACHE = None
73+
config_module._CONFIG_MTIME = None
74+
config_module._CONFIG_SIZE = None
7175

7276
config_dir = tmp_path / ".basic-memory"
7377
config_dir.mkdir(parents=True, exist_ok=True)
@@ -91,6 +95,8 @@ def test_set_cloud_with_oauth_session(self, runner, tmp_path, monkeypatch):
9195
from basic_memory import config as config_module
9296

9397
config_module._CONFIG_CACHE = None
98+
config_module._CONFIG_MTIME = None
99+
config_module._CONFIG_SIZE = None
94100

95101
config_dir = tmp_path / ".basic-memory"
96102
config_dir.mkdir(parents=True, exist_ok=True)
@@ -161,18 +167,24 @@ def test_set_local_clears_workspace_id(self, runner, mock_config):
161167

162168
# Manually set workspace_id on the project
163169
config_module._CONFIG_CACHE = None
170+
config_module._CONFIG_MTIME = None
171+
config_module._CONFIG_SIZE = None
164172
config_data = json.loads(mock_config.read_text())
165173
config_data["projects"]["research"]["mode"] = "cloud"
166174
config_data["projects"]["research"]["workspace_id"] = "11111111-1111-1111-1111-111111111111"
167175
mock_config.write_text(json.dumps(config_data, indent=2))
168176
config_module._CONFIG_CACHE = None
177+
config_module._CONFIG_MTIME = None
178+
config_module._CONFIG_SIZE = None
169179

170180
# Set back to local
171181
result = runner.invoke(app, ["project", "set-local", "research"])
172182
assert result.exit_code == 0
173183

174184
# Verify workspace_id was cleared
175185
config_module._CONFIG_CACHE = None
186+
config_module._CONFIG_MTIME = None
187+
config_module._CONFIG_SIZE = None
176188
updated_data = json.loads(mock_config.read_text())
177189
assert updated_data["projects"]["research"]["workspace_id"] is None
178190
assert updated_data["projects"]["research"]["mode"] == "local"
@@ -187,6 +199,8 @@ def test_set_cloud_with_workspace_stores_workspace_id(self, runner, mock_config,
187199
from basic_memory.schemas.cloud import WorkspaceInfo
188200

189201
config_module._CONFIG_CACHE = None
202+
config_module._CONFIG_MTIME = None
203+
config_module._CONFIG_SIZE = None
190204

191205
async def fake_get_available_workspaces():
192206
return [
@@ -210,6 +224,8 @@ async def fake_get_available_workspaces():
210224

211225
# Verify workspace_id was persisted
212226
config_module._CONFIG_CACHE = None
227+
config_module._CONFIG_MTIME = None
228+
config_module._CONFIG_SIZE = None
213229
updated_data = json.loads(mock_config.read_text())
214230
assert (
215231
updated_data["projects"]["research"]["workspace_id"]
@@ -222,6 +238,8 @@ def test_set_cloud_with_workspace_not_found(self, runner, mock_config, monkeypat
222238
from basic_memory.schemas.cloud import WorkspaceInfo
223239

224240
config_module._CONFIG_CACHE = None
241+
config_module._CONFIG_MTIME = None
242+
config_module._CONFIG_SIZE = None
225243

226244
async def fake_get_available_workspaces():
227245
return [
@@ -249,17 +267,23 @@ def test_set_cloud_uses_default_workspace_when_no_flag(self, runner, mock_config
249267
from basic_memory import config as config_module
250268

251269
config_module._CONFIG_CACHE = None
270+
config_module._CONFIG_MTIME = None
271+
config_module._CONFIG_SIZE = None
252272

253273
# Set default_workspace in config
254274
config_data = json.loads(mock_config.read_text())
255275
config_data["default_workspace"] = "global-default-tenant-id"
256276
mock_config.write_text(json.dumps(config_data, indent=2))
257277
config_module._CONFIG_CACHE = None
278+
config_module._CONFIG_MTIME = None
279+
config_module._CONFIG_SIZE = None
258280

259281
result = runner.invoke(app, ["project", "set-cloud", "research"])
260282
assert result.exit_code == 0
261283

262284
# Verify workspace_id was set from default
263285
config_module._CONFIG_CACHE = None
286+
config_module._CONFIG_MTIME = None
287+
config_module._CONFIG_SIZE = None
264288
updated_data = json.loads(mock_config.read_text())
265289
assert updated_data["projects"]["research"]["workspace_id"] == "global-default-tenant-id"

tests/cli/test_workspace_commands.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ def _setup_config(self, monkeypatch):
7676
monkeypatch.setenv("HOME", str(temp_path))
7777
monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(config_dir))
7878
basic_memory.config._CONFIG_CACHE = None
79+
basic_memory.config._CONFIG_MTIME = None
80+
basic_memory.config._CONFIG_SIZE = None
7981

8082
config_manager = ConfigManager()
8183
test_config = BasicMemoryConfig(
@@ -106,6 +108,8 @@ async def fake_get_available_workspaces(context=None):
106108

107109
# Verify config was updated
108110
basic_memory.config._CONFIG_CACHE = None
111+
basic_memory.config._CONFIG_MTIME = None
112+
basic_memory.config._CONFIG_SIZE = None
109113
config = ConfigManager().config
110114
assert config.default_workspace == "11111111-1111-1111-1111-111111111111"
111115

tests/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,8 @@ def config_manager(app_config: BasicMemoryConfig, config_home: Path, monkeypatch
138138
from basic_memory import config as config_module
139139

140140
config_module._CONFIG_CACHE = None
141+
config_module._CONFIG_MTIME = None
142+
config_module._CONFIG_SIZE = None
141143

142144
# Create a new ConfigManager that uses the test home directory
143145
config_manager = ConfigManager()

tests/mcp/test_tool_write_note.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1257,6 +1257,11 @@ async def test_write_note_config_overwrite_default_true(
12571257
# Set config to allow overwrites by default
12581258
app_config.write_note_overwrite_default = True
12591259
config_module._CONFIG_CACHE = app_config
1260+
# Pin mtime+size to the on-disk file so the cache guard sees a match
1261+
# and keeps our injected config instead of re-reading from disk.
1262+
_st = config_manager.config_file.stat()
1263+
config_module._CONFIG_MTIME = _st.st_mtime
1264+
config_module._CONFIG_SIZE = _st.st_size
12601265

12611266
try:
12621267
await write_note(
@@ -1281,6 +1286,9 @@ async def test_write_note_config_overwrite_default_true(
12811286
# Restore config
12821287
app_config.write_note_overwrite_default = False
12831288
config_module._CONFIG_CACHE = app_config
1289+
_st = config_manager.config_file.stat()
1290+
config_module._CONFIG_MTIME = _st.st_mtime
1291+
config_module._CONFIG_SIZE = _st.st_size
12841292

12851293
@pytest.mark.asyncio
12861294
async def test_write_note_new_note_unaffected(self, app, test_project):

0 commit comments

Comments
 (0)