diff --git a/rf4_research/community_cli.py b/rf4_research/community_cli.py index 1a67de9..aebae21 100644 --- a/rf4_research/community_cli.py +++ b/rf4_research/community_cli.py @@ -6,6 +6,7 @@ import json import os import sys import time +import tempfile from dataclasses import asdict from pathlib import Path from urllib.parse import urlsplit @@ -55,14 +56,22 @@ def _read_state(state_file: Path) -> dict: def _write_state(state_file: Path, state: dict) -> None: - """Write state file atomically with exclusive lock.""" + """A02: Atomic write with exclusive lock, flush before unlock. + + Writes to temp file first, then renames atomically. Lock is acquired + before any file operations to prevent race conditions. + """ state_file.parent.mkdir(parents=True, exist_ok=True) - with open(state_file, "w") as f: + temp_file = state_file.with_suffix(".tmp") + with open(temp_file, "w") as f: fcntl.flock(f, fcntl.LOCK_EX) try: f.write(json.dumps(state, sort_keys=True)) + f.flush() + os.fsync(f.fileno()) finally: fcntl.flock(f, fcntl.LOCK_UN) + temp_file.replace(state_file) def fetch_site_key(url: str) -> str: diff --git a/tests/test_community_cli.py b/tests/test_community_cli.py index 47b5198..07bf935 100644 --- a/tests/test_community_cli.py +++ b/tests/test_community_cli.py @@ -57,3 +57,20 @@ def test_validate_url_host_rejects_missing_hostname() -> None: _validate_url_host("not-a-valid-url") with pytest.raises(ValueError, match="valid hostname"): _validate_url_host("") + + +def test_write_state_is_atomic_with_flush(tmp_path: Path) -> None: + """A02: _write_state uses exclusive lock, flush, and atomic rename.""" + from rf4_research.community_cli import _read_state, _write_state + + state_file = tmp_path / "state.json" + _write_state(state_file, {"key1": "value1"}) + assert state_file.exists() + assert not (state_file.with_suffix(".tmp")).exists() + state = _read_state(state_file) + assert state == {"key1": "value1"} + + _write_state(state_file, {"key1": "value2", "key2": "value3"}) + state = _read_state(state_file) + assert state == {"key1": "value2", "key2": "value3"} + assert not (state_file.with_suffix(".tmp")).exists()