Skip to content

Commit e85d111

Browse files
committed
fix(hooks): atomic read-modify-write in SessionStats.flush() (#1493)
Replace separate _locked_read() + _locked_write() in flush() with a single _locked_modify() helper that holds LOCK_EX for the entire read-modify-write window. This prevents concurrent PostToolUse processes from clobbering each other's updates. Add TestConcurrentFlush regression test (8 procs × 100 calls = 800 expected) that fails without the fix.
1 parent 2ad1f8c commit e85d111

2 files changed

Lines changed: 120 additions & 16 deletions

File tree

packages/claude-code-plugin/hooks/lib/stats.py

Lines changed: 71 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -148,24 +148,38 @@ def record_tool_call(self, tool_name: str, success: bool = True) -> None:
148148
self.flush()
149149

150150
def flush(self) -> None:
151-
"""Flush accumulated in-memory stats to disk."""
151+
"""Flush accumulated in-memory stats to disk.
152+
153+
Uses _locked_modify to perform atomic read-modify-write inside a
154+
single LOCK_EX window, preventing lost updates from concurrent
155+
processes (#1493).
156+
"""
152157
if self._pending_count == 0:
153158
return
154-
data = self._locked_read()
155-
data["tool_count"] = data.get("tool_count", 0) + self._mem_tool_count
156-
data["error_count"] = data.get("error_count", 0) + self._mem_error_count
157-
tool_names = data.get("tool_names", {})
158-
for name, count in self._mem_tool_names.items():
159-
tool_names[name] = tool_names.get(name, 0) + count
160-
data["tool_names"] = tool_names
161-
# Merge hook timings
162-
hook_timings = data.get("hook_timings", {})
163-
for name, times in self._mem_hook_timings.items():
164-
if name not in hook_timings:
165-
hook_timings[name] = []
166-
hook_timings[name].extend(times)
167-
data["hook_timings"] = hook_timings
168-
self._locked_write(data)
159+
160+
# Capture deltas before entering critical section
161+
delta_tool_count = self._mem_tool_count
162+
delta_error_count = self._mem_error_count
163+
delta_tool_names = dict(self._mem_tool_names)
164+
delta_hook_timings = {k: list(v) for k, v in self._mem_hook_timings.items()}
165+
166+
def apply_deltas(data: Dict[str, Any]) -> Dict[str, Any]:
167+
data["tool_count"] = data.get("tool_count", 0) + delta_tool_count
168+
data["error_count"] = data.get("error_count", 0) + delta_error_count
169+
tool_names = data.get("tool_names", {})
170+
for name, count in delta_tool_names.items():
171+
tool_names[name] = tool_names.get(name, 0) + count
172+
data["tool_names"] = tool_names
173+
hook_timings = data.get("hook_timings", {})
174+
for name, times in delta_hook_timings.items():
175+
if name not in hook_timings:
176+
hook_timings[name] = []
177+
hook_timings[name].extend(times)
178+
data["hook_timings"] = hook_timings
179+
return data
180+
181+
self._locked_modify(apply_deltas)
182+
169183
# Reset in-memory accumulators
170184
self._mem_tool_count = 0
171185
self._mem_error_count = 0
@@ -295,6 +309,47 @@ def cleanup_stale(data_dir: str, max_age_hours: int = 24) -> None:
295309
except OSError:
296310
pass
297311

312+
def _locked_modify(self, mutator: Any) -> None:
313+
"""Atomic read-modify-write inside a single LOCK_EX window (#1493).
314+
315+
Opens the stats file with exclusive lock, reads current data,
316+
applies *mutator(data) -> data*, then writes back — all without
317+
releasing the lock. This prevents the lost-update race where
318+
concurrent processes each read the same baseline.
319+
320+
Args:
321+
mutator: Callable (Dict -> Dict) that transforms the data
322+
dict in place or returns the updated dict.
323+
324+
Note: When HAS_FCNTL is False (non-Unix platforms), locking is
325+
skipped entirely. Concurrent flushes on such platforms may lose
326+
updates — this is a known limitation documented here for
327+
visibility.
328+
"""
329+
seed: Dict[str, Any] = {
330+
"session_id": self.session_id,
331+
"started_at": time.time(),
332+
"tool_count": 0,
333+
"error_count": 0,
334+
"tool_names": {},
335+
"hook_timings": {},
336+
}
337+
try:
338+
fd = os.open(self.stats_file, os.O_RDWR | os.O_CREAT)
339+
with os.fdopen(fd, "r+", encoding="utf-8") as f:
340+
if HAS_FCNTL:
341+
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
342+
raw = f.read()
343+
data = json.loads(raw) if raw else dict(seed)
344+
data = mutator(data)
345+
f.seek(0)
346+
f.truncate()
347+
json.dump(data, f)
348+
except (json.JSONDecodeError, OSError):
349+
# File corrupted or missing — write seed with deltas applied
350+
data = mutator(dict(seed))
351+
self._locked_write(data)
352+
298353
def _locked_read(self) -> Dict[str, Any]:
299354
"""Read stats file with file locking."""
300355
try:

packages/claude-code-plugin/tests/test_stats.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,55 @@ def test_format_summary_no_timing_when_empty(self, stats):
337337
assert "⏱" not in summary
338338

339339

340+
class TestConcurrentFlush:
341+
"""Regression test for race condition in flush() (#1493).
342+
343+
Multiple processes calling record_tool_call() + flush() against the
344+
same session/data_dir must not lose updates.
345+
"""
346+
347+
@staticmethod
348+
def _worker(data_dir: str, session_id: str, n: int) -> None:
349+
"""Worker that records n tool calls and flushes each one."""
350+
import sys as _sys
351+
_lib = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "hooks", "lib")
352+
if _lib not in _sys.path:
353+
_sys.path.insert(0, _lib)
354+
from stats import SessionStats as _SS
355+
s = _SS(session_id=session_id, data_dir=data_dir, flush_interval=10)
356+
for _ in range(n):
357+
s.record_tool_call("Bash")
358+
s.flush()
359+
360+
def test_concurrent_flush_no_lost_updates(self, data_dir):
361+
"""8 processes x 100 calls = 800 total. Final disk count must be 800."""
362+
import multiprocessing as mp
363+
364+
session_id = "race-test"
365+
num_workers = 8
366+
calls_per_worker = 100
367+
expected = num_workers * calls_per_worker
368+
369+
# Seed the stats file
370+
SessionStats(session_id=session_id, data_dir=data_dir)
371+
372+
procs = [
373+
mp.Process(target=self._worker, args=(data_dir, session_id, calls_per_worker))
374+
for _ in range(num_workers)
375+
]
376+
for p in procs:
377+
p.start()
378+
for p in procs:
379+
p.join()
380+
381+
s = SessionStats(session_id=session_id, data_dir=data_dir)
382+
on_disk = s._locked_read()
383+
assert on_disk["tool_count"] == expected, (
384+
f"Expected {expected}, got {on_disk['tool_count']} — lost updates detected"
385+
)
386+
assert on_disk["tool_names"]["Bash"] == expected
387+
388+
340389
class TestCleanup:
341390
def test_cleanup_stale_removes_old_files(self, data_dir):
342391
# Create a stale file

0 commit comments

Comments
 (0)