diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index 2fb47abd..5f526ed5 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -11,6 +11,7 @@ from loguru import logger from pydantic import BaseModel from nanobot.config.schema import Config, _resolve_tool_config_refs +from nanobot.utils.helpers import _write_text_atomic # Global variable to store current config path (for multi-instance support) _current_config_path: Path | None = None @@ -85,8 +86,8 @@ def save_config(config: Config, config_path: Path | None = None) -> None: "proxy": config.providers.openai_codex.proxy, } - with open(path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, ensure_ascii=False) + # Temp + replace so a crash mid-write cannot leave a truncated config.json. + _write_text_atomic(path, json.dumps(data, indent=2, ensure_ascii=False)) def merge_missing_defaults(existing: Any, defaults: Any) -> Any: diff --git a/tests/config/test_config_atomic_save.py b/tests/config/test_config_atomic_save.py new file mode 100644 index 00000000..f2ac2367 --- /dev/null +++ b/tests/config/test_config_atomic_save.py @@ -0,0 +1,36 @@ +"""Tests for atomic config.json writes.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from nanobot.config.loader import load_config, save_config +from nanobot.config.schema import Config + + +def test_save_config_round_trips(tmp_path: Path) -> None: + path = tmp_path / "config.json" + save_config(Config(), path) + loaded = load_config(path) + assert loaded.agents.defaults.model + + +def test_save_config_preserves_existing_file_when_write_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path = tmp_path / "config.json" + save_config(Config(), path) + before = path.read_text(encoding="utf-8") + + def boom(self: Path, target: Path) -> Path: # noqa: ARG001 + raise OSError("simulated crash before replace") + + monkeypatch.setattr(Path, "replace", boom) + with pytest.raises(OSError, match="simulated crash"): + save_config(Config(), path) + + assert path.read_text(encoding="utf-8") == before + assert json.loads(before) # still valid JSON