@@ -236,6 +236,65 @@ def _subprocess_env() -> dict:
236236 return env
237237
238238
239+ def _parse_ps_time_seconds (value : str ) -> float | None :
240+ """Parse ps TIME values like MM:SS.cc, HH:MM:SS.cc, or DD-HH:MM:SS.cc."""
241+ raw = value .strip ()
242+ if not raw :
243+ return None
244+ days = 0
245+ if "-" in raw :
246+ day_raw , raw = raw .split ("-" , 1 )
247+ try :
248+ days = int (day_raw )
249+ except ValueError :
250+ return None
251+ parts = raw .split (":" )
252+ try :
253+ if len (parts ) == 2 :
254+ hours = 0
255+ minutes = int (parts [0 ])
256+ seconds = float (parts [1 ])
257+ elif len (parts ) == 3 :
258+ hours = int (parts [0 ])
259+ minutes = int (parts [1 ])
260+ seconds = float (parts [2 ])
261+ else :
262+ return None
263+ except ValueError :
264+ return None
265+ return float (days * 86400 + hours * 3600 + minutes * 60 ) + seconds
266+
267+
268+ def _process_group_cpu_seconds (pgid : int ) -> float | None :
269+ """Return cumulative CPU seconds for a process group, when ps supports it."""
270+ try :
271+ output = subprocess .check_output (
272+ ["ps" , "-axo" , "pgid=,time=" ],
273+ text = True ,
274+ stderr = subprocess .DEVNULL ,
275+ )
276+ except (OSError , subprocess .SubprocessError ):
277+ return None
278+ total = 0.0
279+ seen = False
280+ for line in output .splitlines ():
281+ fields = line .split ()
282+ if len (fields ) < 2 :
283+ continue
284+ try :
285+ row_pgid = int (fields [0 ])
286+ except ValueError :
287+ continue
288+ if row_pgid != pgid :
289+ continue
290+ seconds = _parse_ps_time_seconds (fields [1 ])
291+ if seconds is None :
292+ continue
293+ total += seconds
294+ seen = True
295+ return total if seen else None
296+
297+
239298def run_checked (
240299 repo : str ,
241300 stage : str ,
@@ -289,6 +348,7 @@ def terminate_process(reason: str) -> None:
289348 deadline = time .monotonic () + timeout if timeout and timeout > 0 else None
290349 last_activity = time .monotonic ()
291350 last_signature : tuple [int , int ] | None = None
351+ last_cpu_seconds : float | None = None
292352 while proc .poll () is None :
293353 now = time .monotonic ()
294354 if deadline is not None and now > deadline :
@@ -304,12 +364,18 @@ def terminate_process(reason: str) -> None:
304364 if signature != last_signature :
305365 last_signature = signature
306366 last_activity = now
367+ cpu_seconds = _process_group_cpu_seconds (proc .pid )
368+ if cpu_seconds is not None and cpu_seconds != last_cpu_seconds :
369+ last_cpu_seconds = cpu_seconds
370+ last_activity = now
307371 if now - last_activity > stall_timeout :
308- terminate_process (f"no log progress for { stall_timeout } s" )
372+ terminate_process (
373+ f"no log/CPU progress for { stall_timeout } s"
374+ )
309375 raise RepoFailure (
310376 repo ,
311377 stage ,
312- f"stalled after { stall_timeout } s without log progress" ,
378+ f"stalled after { stall_timeout } s without log or CPU progress" ,
313379 )
314380 time .sleep (1.0 )
315381 stdout_data , _ = proc .communicate (timeout = 1 )
0 commit comments