@@ -68,6 +68,58 @@ def _read_stdout_from_offset(self, stdout_file: Path, start_offset: int) -> str:
6868 return ""
6969 return content [start_offset :]
7070
71+ def _read_app_logs (self , app_log_dir : Path ) -> str :
72+ if not app_log_dir .exists ():
73+ return ""
74+
75+ segments : list [str ] = []
76+ for log_file in sorted (path for path in app_log_dir .rglob ("*.log" ) if path .is_file ()):
77+ try :
78+ segments .append (log_file .read_text (encoding = "utf-8" , errors = "replace" ))
79+ except OSError :
80+ continue
81+ return "\n " .join (segments )
82+
83+ def _read_app_logs_from_offset (self , app_log_dir : Path , start_offset : int ) -> str :
84+ content = self ._read_app_logs (app_log_dir )
85+ if start_offset <= 0 :
86+ return content
87+ if start_offset >= len (content ):
88+ return ""
89+ return content [start_offset :]
90+
91+ def _monitor_app_log_dir_for_stdout (self , stdout_file : Path ) -> Path :
92+ return stdout_file .parent / "app-logs"
93+
94+ def _remember_monitor_app_log_offset (self , stdout_file : Path , offset : int ) -> None :
95+ if not hasattr (self , "_monitor_app_log_start_offsets" ):
96+ self ._monitor_app_log_start_offsets = {}
97+ self ._monitor_app_log_start_offsets [str (stdout_file )] = offset
98+
99+ def _monitor_app_log_start_offset (self , stdout_file : Path ) -> int :
100+ offsets = getattr (self , "_monitor_app_log_start_offsets" , {})
101+ return int (offsets .get (str (stdout_file ), 0 ))
102+
103+ def _read_monitor_output_from_offsets (self , stdout_file : Path , stdout_start_offset : int ) -> str :
104+ """Read post-mutation monitor evidence from stdout and the app log.
105+
106+ Local monitor event processing does not always emit another global
107+ sync-complete marker after an inotify wake. The stable evidence is the
108+ per-event upload/delete/move output, and that may be present in stdout,
109+ the configured application log, or both depending on verbosity and CI
110+ buffering. Offset both streams so assertions only inspect activity that
111+ happened after the test mutation.
112+ """
113+ stdout_segment = self ._read_stdout_from_offset (stdout_file , stdout_start_offset )
114+ app_log_dir = self ._monitor_app_log_dir_for_stdout (stdout_file )
115+ app_log_segment = self ._read_app_logs_from_offset (
116+ app_log_dir ,
117+ self ._monitor_app_log_start_offset (stdout_file ),
118+ )
119+ if stdout_segment and app_log_segment :
120+ return stdout_segment + "\n " + app_log_segment
121+ return stdout_segment or app_log_segment
122+
71123 def _wait_for_initial_sync_complete (
72124 self ,
73125 stdout_file : Path ,
@@ -130,17 +182,21 @@ def _prepare_monitor_for_local_mutation(
130182 quiet_seconds : float = 3.0 ,
131183 timeout_seconds : int = 30 ,
132184 ) -> int :
133- """Wait for monitor readiness and return the post-mutation log offset."""
185+ """Wait for monitor readiness and return the post-mutation stdout offset."""
134186 ready = self ._wait_for_monitor_stdout_quiet (
135187 process ,
136188 stdout_file ,
137189 quiet_seconds = quiet_seconds ,
138190 timeout_seconds = timeout_seconds ,
139191 )
140192 content = self ._read_stdout (stdout_file )
193+ app_log_content = self ._read_app_logs (self ._monitor_app_log_dir_for_stdout (stdout_file ))
194+ self ._remember_monitor_app_log_offset (stdout_file , len (app_log_content ))
141195 details ["monitor_ready_after_initial_sync" ] = ready
142196 details ["initial_sync_complete_count_before_mutation" ] = content .count (self .SYNC_COMPLETE_PATTERN )
197+ details ["app_log_sync_complete_count_before_mutation" ] = app_log_content .count (self .SYNC_COMPLETE_PATTERN )
143198 details ["mutation_log_start_offset" ] = len (content )
199+ details ["mutation_app_log_start_offset" ] = len (app_log_content )
144200 return len (content )
145201
146202 def _wait_for_monitor_patterns (
@@ -154,7 +210,7 @@ def _wait_for_monitor_patterns(
154210 deadline = time .time () + timeout_seconds
155211
156212 while time .time () < deadline :
157- content = self ._read_stdout_from_offset (stdout_file , start_offset )
213+ content = self ._read_monitor_output_from_offsets (stdout_file , start_offset )
158214 if all (pattern in content for pattern in required_patterns ):
159215 return True
160216 time .sleep (poll_interval )
@@ -174,7 +230,7 @@ def _wait_for_stdout_growth_patterns(
174230 latest_segment = ""
175231
176232 while time .time () < deadline :
177- latest_segment = self ._read_stdout_from_offset (stdout_file , start_offset )
233+ latest_segment = self ._read_monitor_output_from_offsets (stdout_file , start_offset )
178234 if all (pattern in latest_segment for pattern in required_patterns ):
179235 return True , latest_segment
180236 time .sleep (poll_interval )
@@ -192,7 +248,7 @@ def _wait_for_any_monitor_pattern_group(
192248 deadline = time .time () + timeout_seconds
193249
194250 while time .time () < deadline :
195- content = self ._read_stdout_from_offset (stdout_file , start_offset )
251+ content = self ._read_monitor_output_from_offsets (stdout_file , start_offset )
196252 for idx , group in enumerate (alternative_pattern_groups ):
197253 if all (pattern in content for pattern in group ):
198254 return True , idx
@@ -213,14 +269,50 @@ def _wait_for_any_stdout_growth_pattern_group(
213269 latest_segment = ""
214270
215271 while time .time () < deadline :
216- latest_segment = self ._read_stdout_from_offset (stdout_file , start_offset )
272+ latest_segment = self ._read_monitor_output_from_offsets (stdout_file , start_offset )
217273 for idx , group in enumerate (alternative_pattern_groups ):
218274 if all (pattern in latest_segment for pattern in group ):
219275 return True , idx , latest_segment
220276 time .sleep (poll_interval )
221277
222278 return False , - 1 , latest_segment
223279
280+ def _wait_for_required_patterns_and_any_group (
281+ self ,
282+ stdout_file : Path ,
283+ * ,
284+ start_offset : int ,
285+ required_patterns : list [str ],
286+ alternative_pattern_groups : list [list [str ]],
287+ timeout_seconds : int = 120 ,
288+ poll_interval : float = 0.5 ,
289+ ) -> tuple [bool , bool , int , str ]:
290+ """Wait until all fixed patterns and one alternative group are observed."""
291+ deadline = time .time () + timeout_seconds
292+ latest_segment = ""
293+ matched_group = - 1
294+
295+ while time .time () < deadline :
296+ latest_segment = self ._read_monitor_output_from_offsets (stdout_file , start_offset )
297+ fixed_ok = all (pattern in latest_segment for pattern in required_patterns )
298+ matched_group = - 1
299+ for idx , group in enumerate (alternative_pattern_groups ):
300+ if all (pattern in latest_segment for pattern in group ):
301+ matched_group = idx
302+ break
303+ group_ok = matched_group >= 0
304+ if fixed_ok and group_ok :
305+ return True , True , matched_group , latest_segment
306+ time .sleep (poll_interval )
307+
308+ fixed_ok = all (pattern in latest_segment for pattern in required_patterns )
309+ matched_group = - 1
310+ for idx , group in enumerate (alternative_pattern_groups ):
311+ if all (pattern in latest_segment for pattern in group ):
312+ matched_group = idx
313+ break
314+ return fixed_ok , matched_group >= 0 , matched_group , latest_segment
315+
224316 def _wait_for_post_mutation_sync_complete (
225317 self ,
226318 stdout_file : Path ,
@@ -230,14 +322,20 @@ def _wait_for_post_mutation_sync_complete(
230322 poll_interval : float = 0.5 ,
231323 quiet_seconds_after_marker : float = 3.0 ,
232324 ) -> tuple [bool , str ]:
325+ """Wait for a post-mutation global sync-complete marker when a test needs it.
326+
327+ Most local inotify tests should not use this helper. Local event handling
328+ can complete successfully without emitting another global sync-complete
329+ line, so those tests should wait for their event-specific patterns instead.
330+ """
233331 deadline = time .time () + timeout_seconds
234332 latest_segment = ""
235333 marker_seen = False
236334 last_length = - 1
237335 quiet_started_at : float | None = None
238336
239337 while time .time () < deadline :
240- latest_segment = self ._read_stdout_from_offset (stdout_file , start_offset )
338+ latest_segment = self ._read_monitor_output_from_offsets (stdout_file , start_offset )
241339 now = time .time ()
242340
243341 if self .SYNC_COMPLETE_PATTERN in latest_segment :
0 commit comments