From 64578f6d4c69015c88468b654bd7c07e698eb44f Mon Sep 17 00:00:00 2001 From: Pringled Date: Fri, 12 Jun 2026 08:09:06 +0200 Subject: [PATCH 1/7] Add lock for json writes --- src/semble/stats.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/semble/stats.py b/src/semble/stats.py index bebc9888..a0fec2d7 100644 --- a/src/semble/stats.py +++ b/src/semble/stats.py @@ -59,8 +59,15 @@ def save_search_stats( stats_file = _get_stats_file() stats_file.parent.mkdir(parents=True, exist_ok=True) with stats_file.open("a") as f: + try: + import fcntl + except ImportError: + pass # Windows has no fcntl, proceed without lock + else: + fcntl.flock(f, fcntl.LOCK_EX) f.write(json.dumps(record) + "\n") except OSError: + # If we can't write to the stats file, just skip the stats for this call pass From e57b97235a14029329c89d705adcdf87b127dd2d Mon Sep 17 00:00:00 2001 From: Pringled Date: Fri, 12 Jun 2026 08:13:03 +0200 Subject: [PATCH 2/7] Add no cover --- src/semble/stats.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/semble/stats.py b/src/semble/stats.py index a0fec2d7..0b796ac7 100644 --- a/src/semble/stats.py +++ b/src/semble/stats.py @@ -61,7 +61,7 @@ def save_search_stats( with stats_file.open("a") as f: try: import fcntl - except ImportError: + except ImportError: # pragma: no cover pass # Windows has no fcntl, proceed without lock else: fcntl.flock(f, fcntl.LOCK_EX) From 8921d7ed07a5ee977b0e394fa1d1ced08608b14d Mon Sep 17 00:00:00 2001 From: Pringled Date: Fri, 12 Jun 2026 08:20:07 +0200 Subject: [PATCH 3/7] Resolve comment --- src/semble/stats.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/semble/stats.py b/src/semble/stats.py index 0b796ac7..ac4aa42d 100644 --- a/src/semble/stats.py +++ b/src/semble/stats.py @@ -61,10 +61,10 @@ def save_search_stats( with stats_file.open("a") as f: try: import fcntl - except ImportError: # pragma: no cover - pass # Windows has no fcntl, proceed without lock - else: + fcntl.flock(f, fcntl.LOCK_EX) + except (ImportError, OSError): # pragma: no cover + pass # Locking unavailable or failed; proceed without it f.write(json.dumps(record) + "\n") except OSError: # If we can't write to the stats file, just skip the stats for this call From 532bf1caa28638180e74645a3d5e06aa46c2c64b Mon Sep 17 00:00:00 2001 From: Pringled Date: Fri, 12 Jun 2026 08:37:36 +0200 Subject: [PATCH 4/7] Add test --- src/semble/stats.py | 7 +++---- tests/test_stats.py | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/semble/stats.py b/src/semble/stats.py index ac4aa42d..b0836f31 100644 --- a/src/semble/stats.py +++ b/src/semble/stats.py @@ -62,12 +62,11 @@ def save_search_stats( try: import fcntl - fcntl.flock(f, fcntl.LOCK_EX) - except (ImportError, OSError): # pragma: no cover - pass # Locking unavailable or failed; proceed without it + fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB) + except (ImportError, OSError): + return # Cannot safely acquire lock; skip stats for this call f.write(json.dumps(record) + "\n") except OSError: - # If we can't write to the stats file, just skip the stats for this call pass diff --git a/tests/test_stats.py b/tests/test_stats.py index e3c1f321..3216781f 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -1,5 +1,6 @@ import json import sys +import types from datetime import datetime, timezone from pathlib import Path from unittest.mock import MagicMock, patch @@ -27,6 +28,25 @@ def sample_stats_file(tmp_path: Path) -> Path: return stats_file +def test_save_search_stats_skips_on_lock_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """save_search_stats silently skips the write when locking fails.""" + chunk = make_chunk("hello", "src/foo.py") + result = SearchResult(chunk=chunk, score=0.9) + stats_file = tmp_path / "stats.jsonl" + stats_file.touch() + monkeypatch.setattr("semble.stats._get_stats_file", lambda: stats_file) + + mock_fcntl = types.ModuleType("fcntl") + mock_fcntl.LOCK_EX = 2 # type: ignore[attr-defined] + mock_fcntl.LOCK_NB = 4 # type: ignore[attr-defined] + mock_fcntl.flock = MagicMock(side_effect=OSError) # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "fcntl", mock_fcntl) + + save_search_stats([result], CallType.SEARCH, {"src/foo.py": 42}) + + assert stats_file.read_text() == "" + + def test_save_search_stats(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """save_search_stats deduplicates file paths and silences write errors.""" chunk = make_chunk("hello", "src/foo.py") From 84cc24f5154d80c32eee8b01c4d060997fe0a809 Mon Sep 17 00:00:00 2001 From: Pringled Date: Fri, 12 Jun 2026 08:42:48 +0200 Subject: [PATCH 5/7] Update --- src/semble/stats.py | 2 +- tests/test_stats.py | 20 -------------------- 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/src/semble/stats.py b/src/semble/stats.py index b0836f31..69d09264 100644 --- a/src/semble/stats.py +++ b/src/semble/stats.py @@ -63,7 +63,7 @@ def save_search_stats( import fcntl fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB) - except (ImportError, OSError): + except (ImportError, OSError): # pragma: no cover return # Cannot safely acquire lock; skip stats for this call f.write(json.dumps(record) + "\n") except OSError: diff --git a/tests/test_stats.py b/tests/test_stats.py index 3216781f..e3c1f321 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -1,6 +1,5 @@ import json import sys -import types from datetime import datetime, timezone from pathlib import Path from unittest.mock import MagicMock, patch @@ -28,25 +27,6 @@ def sample_stats_file(tmp_path: Path) -> Path: return stats_file -def test_save_search_stats_skips_on_lock_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """save_search_stats silently skips the write when locking fails.""" - chunk = make_chunk("hello", "src/foo.py") - result = SearchResult(chunk=chunk, score=0.9) - stats_file = tmp_path / "stats.jsonl" - stats_file.touch() - monkeypatch.setattr("semble.stats._get_stats_file", lambda: stats_file) - - mock_fcntl = types.ModuleType("fcntl") - mock_fcntl.LOCK_EX = 2 # type: ignore[attr-defined] - mock_fcntl.LOCK_NB = 4 # type: ignore[attr-defined] - mock_fcntl.flock = MagicMock(side_effect=OSError) # type: ignore[attr-defined] - monkeypatch.setitem(sys.modules, "fcntl", mock_fcntl) - - save_search_stats([result], CallType.SEARCH, {"src/foo.py": 42}) - - assert stats_file.read_text() == "" - - def test_save_search_stats(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """save_search_stats deduplicates file paths and silences write errors.""" chunk = make_chunk("hello", "src/foo.py") From 2b4cc1744765589000709f7ff45eec29b5dc1db6 Mon Sep 17 00:00:00 2001 From: Pringled Date: Fri, 12 Jun 2026 08:46:02 +0200 Subject: [PATCH 6/7] Update --- src/semble/stats.py | 6 ++++-- uv.lock | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/semble/stats.py b/src/semble/stats.py index 69d09264..c89ef8af 100644 --- a/src/semble/stats.py +++ b/src/semble/stats.py @@ -63,8 +63,10 @@ def save_search_stats( import fcntl fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB) - except (ImportError, OSError): # pragma: no cover - return # Cannot safely acquire lock; skip stats for this call + except ImportError: # pragma: no cover + pass # Windows has no fcntl, write unlocked + except OSError: # pragma: no cover + return # lock contention or unsupported filesystem; skip f.write(json.dumps(record) + "\n") except OSError: pass diff --git a/uv.lock b/uv.lock index d0db5aa6..63f2277d 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ [options] exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. -exclude-newer-span = "P3D" +exclude-newer-span = "P1W" [[package]] name = "annotated-doc" From cf7d52a939f46e644ade47fd577618ca8388d24b Mon Sep 17 00:00:00 2001 From: Pringled Date: Fri, 12 Jun 2026 12:41:02 +0200 Subject: [PATCH 7/7] Add cache for import --- src/semble/stats.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/semble/stats.py b/src/semble/stats.py index c89ef8af..12f6c862 100644 --- a/src/semble/stats.py +++ b/src/semble/stats.py @@ -3,7 +3,10 @@ from collections import defaultdict from dataclasses import dataclass from datetime import datetime, timedelta, timezone +from functools import cache +from importlib import import_module from pathlib import Path +from types import ModuleType from semble.cache import resolve_cache_folder from semble.types import CallType, SearchResult @@ -37,6 +40,15 @@ class SavingsSummary: call_type_counts: dict[str, int] +@cache +def _import_fcntl() -> ModuleType | None: + """Return fcntl when available, otherwise None.""" + try: + return import_module("fcntl") + except ImportError: # pragma: no cover + return None + + def save_search_stats( results: list[SearchResult], call_type: CallType, @@ -59,12 +71,12 @@ def save_search_stats( stats_file = _get_stats_file() stats_file.parent.mkdir(parents=True, exist_ok=True) with stats_file.open("a") as f: + fcntl = _import_fcntl() try: - import fcntl - - fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB) - except ImportError: # pragma: no cover - pass # Windows has no fcntl, write unlocked + if fcntl is not None: + fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: # pragma: no cover + return # another process holds the lock; skip this record except OSError: # pragma: no cover return # lock contention or unsupported filesystem; skip f.write(json.dumps(record) + "\n")