11#!/usr/bin/env python3
22
33import argparse
4+ import atexit
45import csv
56import json
67import math
@@ -147,6 +148,46 @@ class ResourceMonitor(threading.Thread):
147148 last_wall = current_wall
148149
149150
151+ class ManagedProcessCleanup :
152+ """Best-effort process-group cleanup for normal and exceptional exits."""
153+
154+ def __init__ (self , timeout_sec : float ) -> None :
155+ self ._timeout_sec = timeout_sec
156+ self ._processes : List [ManagedProcess ] = []
157+ self ._monitor : Optional [ResourceMonitor ] = None
158+ self ._lock = threading .Lock ()
159+ self ._cleaned = False
160+
161+ def add (self , managed : ManagedProcess ) -> ManagedProcess :
162+ with self ._lock :
163+ self ._processes .append (managed )
164+ return managed
165+
166+ def set_monitor (self , monitor : ResourceMonitor ) -> None :
167+ with self ._lock :
168+ self ._monitor = monitor
169+
170+ def stop_all (self ) -> None :
171+ with self ._lock :
172+ if self ._cleaned :
173+ return
174+ self ._cleaned = True
175+ processes = list (reversed (self ._processes ))
176+ monitor = self ._monitor
177+ if monitor is not None :
178+ monitor .stop ()
179+ monitor .join (timeout = 2.0 )
180+ for managed in processes :
181+ try :
182+ stop_process (managed , self ._timeout_sec )
183+ except (OSError , subprocess .SubprocessError ):
184+ try :
185+ managed .stdout_stream .close ()
186+ managed .stderr_stream .close ()
187+ except OSError :
188+ pass
189+
190+
150191def build_stats (samples : List [Dict [str , float ]]) -> Dict [str , Optional [float ]]:
151192 cpu_values = [sample ["cpu_percent" ] for sample in samples ]
152193 rss_values = [sample ["rss_mb" ] for sample in samples ]
@@ -177,11 +218,17 @@ def launch_process(
177218 env = os .environ .copy ()
178219 if extra_env :
179220 env .update (extra_env )
221+
222+ def prepare_child_process () -> None :
223+ os .setsid ()
224+ signal .pthread_sigmask (
225+ signal .SIG_UNBLOCK , {signal .SIGINT , signal .SIGTERM })
226+
180227 process = subprocess .Popen (
181228 ["bash" , "-lc" , command ],
182229 stdout = stdout_stream ,
183230 stderr = stderr_stream ,
184- preexec_fn = os . setsid ,
231+ preexec_fn = prepare_child_process ,
185232 env = env ,
186233 )
187234 return ManagedProcess (
@@ -195,22 +242,66 @@ def launch_process(
195242 )
196243
197244
245+ def launch_registered_process (
246+ cleanup : ManagedProcessCleanup ,
247+ name : str ,
248+ command : str ,
249+ output_dir : str ,
250+ extra_env : Optional [Dict [str , str ]] = None ,
251+ ) -> ManagedProcess :
252+ """Launch a process group and immediately register it for cleanup."""
253+ termination_signals = {signal .SIGINT , signal .SIGTERM }
254+ previous_mask = signal .pthread_sigmask (signal .SIG_BLOCK , termination_signals )
255+ try :
256+ managed = launch_process (name , command , output_dir , extra_env )
257+ return cleanup .add (managed )
258+ finally :
259+ signal .pthread_sigmask (signal .SIG_SETMASK , previous_mask )
260+
261+
262+ def process_group_exists (process_group_id : int ) -> bool :
263+ try :
264+ os .killpg (process_group_id , 0 )
265+ except ProcessLookupError :
266+ return False
267+ except PermissionError :
268+ return True
269+ return True
270+
271+
272+ def wait_for_process_group_exit (
273+ managed : ManagedProcess , process_group_id : int , timeout_sec : float
274+ ) -> bool :
275+ deadline = time .monotonic () + timeout_sec
276+ while process_group_exists (process_group_id ) and time .monotonic () < deadline :
277+ managed .process .poll ()
278+ time .sleep (0.02 )
279+ managed .process .poll ()
280+ return not process_group_exists (process_group_id )
281+
282+
198283def stop_process (managed : ManagedProcess , timeout_sec : float ) -> int :
284+ process_group_id = managed .process .pid
285+ for shutdown_signal in (signal .SIGINT , signal .SIGTERM , signal .SIGKILL ):
286+ if not process_group_exists (process_group_id ):
287+ break
288+ try :
289+ os .killpg (process_group_id , shutdown_signal )
290+ except ProcessLookupError :
291+ break
292+ if wait_for_process_group_exit (managed , process_group_id , timeout_sec ):
293+ break
294+
199295 if managed .process .poll () is None :
200- os .killpg (os .getpgid (managed .process .pid ), signal .SIGINT )
201296 try :
202297 managed .process .wait (timeout = timeout_sec )
203298 except subprocess .TimeoutExpired :
204- os .killpg (os .getpgid (managed .process .pid ), signal .SIGTERM )
205- try :
206- managed .process .wait (timeout = timeout_sec )
207- except subprocess .TimeoutExpired :
208- os .killpg (os .getpgid (managed .process .pid ), signal .SIGKILL )
209- managed .process .wait (timeout = timeout_sec )
299+ managed .process .kill ()
300+ managed .process .wait (timeout = timeout_sec )
210301
211302 managed .stdout_stream .close ()
212303 managed .stderr_stream .close ()
213- return int (managed .process .returncode )
304+ return int (managed .process .returncode if managed . process . returncode is not None else - 1 )
214305
215306
216307def detect_process_died_positions (log_path : str , target_process_pattern : str ) -> Dict [str , int ]:
@@ -369,9 +460,17 @@ def wait_for_pid(pattern: str, timeout_sec: float) -> Optional[int]:
369460 return None
370461
371462
463+ def exit_on_termination_signal (signum : int , _frame : object ) -> None :
464+ """Convert SIGTERM into normal interpreter unwinding so atexit cleanup runs."""
465+ raise SystemExit (128 + signum )
466+
467+
372468def main () -> int :
373469 args = parse_args ()
374470 os .makedirs (args .output_dir , exist_ok = True )
471+ cleanup = ManagedProcessCleanup (args .shutdown_timeout )
472+ atexit .register (cleanup .stop_all )
473+ signal .signal (signal .SIGTERM , exit_on_termination_signal )
375474
376475 pose_csv_path = os .path .join (args .output_dir , "pose_trace.csv" )
377476 diagnostic_csv_path = os .path .join (args .output_dir , "alignment_status.csv" )
@@ -400,10 +499,14 @@ def main() -> int:
400499
401500 started_at = now_iso ()
402501 settle_start_monotonic = time .monotonic ()
403- system = launch_process ("system" , system_command , args .output_dir , launch_env )
404- recorder = launch_process ("pose_recorder" , recorder_command , args .output_dir , launch_env )
502+ system = launch_registered_process (
503+ cleanup , "system" , system_command , args .output_dir , launch_env )
504+ recorder = launch_registered_process (
505+ cleanup , "pose_recorder" , recorder_command , args .output_dir , launch_env )
405506 diagnostic_recorder = (
406- launch_process ("diagnostic_recorder" , diagnostic_recorder_command , args .output_dir , launch_env )
507+ launch_registered_process (
508+ cleanup , "diagnostic_recorder" , diagnostic_recorder_command ,
509+ args .output_dir , launch_env )
407510 if args .diagnostic_topic
408511 else None
409512 )
@@ -412,13 +515,15 @@ def main() -> int:
412515 monitored_pid = wait_for_pid (args .target_process_pattern , args .settle_seconds )
413516 if monitored_pid is not None :
414517 monitor = ResourceMonitor (monitored_pid , resource_csv_path , args .monitor_interval )
518+ cleanup .set_monitor (monitor )
415519 monitor .start ()
416520
417521 settle_elapsed = time .monotonic () - settle_start_monotonic
418522 time .sleep (max (0.0 , args .settle_seconds - settle_elapsed ))
419523
420524 bag_started_monotonic = time .monotonic ()
421- bag = launch_process ("bag_play" , bag_command , args .output_dir , launch_env )
525+ bag = launch_registered_process (
526+ cleanup , "bag_play" , bag_command , args .output_dir , launch_env )
422527 bag_stopped_by_runner = False
423528 if args .bag_duration > 0.0 :
424529 deadline = bag_started_monotonic + args .bag_duration
@@ -524,4 +629,8 @@ def main() -> int:
524629
525630
526631if __name__ == "__main__" :
527- sys .exit (main ())
632+ try :
633+ exit_code = main ()
634+ except KeyboardInterrupt :
635+ exit_code = 130
636+ sys .exit (exit_code )
0 commit comments