3131
3232_AUDIT_LOGGER = logging .getLogger ("trpc_agent_sdk.tools.safety.audit" )
3333
34- # Per-path locks for thread-safe concurrent writes (threading.Lock is NOT
35- # process-safe; multi-process deployments should use a dedicated audit daemon
36- # or file-locking via fcntl/msvcrt) .
34+ # Per-path locks for thread-safe AND process-safe concurrent writes via
35+ # fcntl.flock. On platforms without fcntl (Windows), falls back to
36+ # threading.Lock which is only thread-safe .
3737_FILE_LOCKS : dict [str , threading .Lock ] = {}
3838_FILE_LOCKS_LOCK = threading .Lock ()
39+ _MAX_FILE_LOCKS = 128
3940
4041
4142def _get_file_lock (path : str ) -> threading .Lock :
42- """Return (and cache) a threading.Lock for the given JSONL path."""
43+ """Return (and cache) a threading.Lock for the given JSONL path.
44+
45+ The cache is capped at *_MAX_FILE_LOCKS* entries to prevent unbounded
46+ growth when paths contain dynamic segments.
47+ """
48+ real = os .path .realpath (path )
4349 with _FILE_LOCKS_LOCK :
44- if path not in _FILE_LOCKS :
45- _FILE_LOCKS [path ] = threading .Lock ()
46- return _FILE_LOCKS [path ]
50+ if real not in _FILE_LOCKS :
51+ if len (_FILE_LOCKS ) >= _MAX_FILE_LOCKS :
52+ _FILE_LOCKS .pop (next (iter (_FILE_LOCKS )))
53+ _FILE_LOCKS [real ] = threading .Lock ()
54+ return _FILE_LOCKS [real ]
4755
4856
4957class AuditLogger :
@@ -83,14 +91,21 @@ def log_event(self, report: SafetyScanReport) -> SafetyAuditEvent:
8391 event = self ._build_event (report )
8492 line = json .dumps (event .to_dict (), ensure_ascii = False , default = str )
8593
86- # File output — thread-safe via per-path lock
94+ # File output — thread-safe + process-safe via fcntl.flock
8795 if self ._output_path :
8896 lock = _get_file_lock (str (self ._output_path ))
8997 with lock :
9098 try :
9199 with open (self ._output_path , "a" , encoding = "utf-8" ) as fh :
100+ # Acquire an OS-level advisory lock for cross-process safety
101+ try :
102+ import fcntl
103+ fcntl .flock (fh .fileno (), fcntl .LOCK_EX )
104+ except (ImportError , OSError ):
105+ pass # fcntl not available (e.g. Windows) — thread-lock only
92106 fh .write (line + "\n " )
93107 fh .flush ()
108+ os .fsync (fh .fileno ())
94109 except OSError as exc :
95110 _AUDIT_LOGGER .error ("Failed to write audit event: %s" , exc )
96111
@@ -107,23 +122,39 @@ def log_events(self, reports: list[SafetyScanReport]) -> list[SafetyAuditEvent]:
107122 def read_events (self , limit : int = 100 ) -> list [dict ]:
108123 """Read the most recent audit events from the JSONL file.
109124
125+ Reads from the tail of the file to avoid loading the entire file
126+ into memory. Falls back to a full scan for very small files.
127+
110128 Args:
111129 limit: Maximum number of events to return (most recent first).
112130
113131 Returns:
114- List of event dicts.
132+ List of event dicts, newest first .
115133 """
116134 if not self ._output_path or not os .path .exists (self ._output_path ):
117135 return []
136+ path = self ._output_path
137+ fsize = os .path .getsize (path )
138+ if fsize == 0 :
139+ return []
118140 events : list [dict ] = []
119- with open (self ._output_path , "r" , encoding = "utf-8" ) as fh :
120- for line in fh :
121- line = line .strip ()
122- if line :
123- try :
124- events .append (json .loads (line ))
125- except json .JSONDecodeError :
126- continue
141+ # Estimated bytes per line: average ~200, read 2× to be safe
142+ read_size = min (fsize , max (limit * 400 , 8192 ))
143+ try :
144+ with open (path , "rb" ) as fh :
145+ if fsize > read_size :
146+ fh .seek (fsize - read_size )
147+ # Skip partial first line (may be truncated)
148+ fh .readline ()
149+ for line in fh :
150+ line = line .decode ("utf-8" , errors = "replace" ).strip ()
151+ if line :
152+ try :
153+ events .append (json .loads (line ))
154+ except json .JSONDecodeError :
155+ continue
156+ except OSError :
157+ return []
127158 return events [- limit :][::- 1 ]
128159
129160 # ------------------------------------------------------------------
0 commit comments