@@ -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 :
0 commit comments