55import json
66import os
77import select
8+ import signal
89import subprocess
910import threading
1011import time
3334logger = bec_logger .logger
3435
3536IGNORE_WIDGETS = ["LaunchWindow" ]
37+ PROCESS_TERMINATION_TIMEOUT = 10
38+ PROCESS_OUTPUT_THREAD_JOIN_TIMEOUT = 2
39+ GRACEFUL_SERVER_SHUTDOWN_TIMEOUT = 5
3640
3741RegistryState : TypeAlias = dict [
3842 Literal ["gui_id" , "name" , "widget_class" , "config" , "__rpc__" , "container_proxy" ],
@@ -75,6 +79,123 @@ def _get_output(process, logger) -> None:
7579 logger .error (f"Error reading process output: { str (e )} " )
7680
7781
82+ def _process_group_id (process ) -> int | None :
83+ pid = getattr (process , "pid" , None )
84+ if os .name != "posix" or not isinstance (pid , int ):
85+ return None
86+ try :
87+ return os .getpgid (pid )
88+ except ProcessLookupError :
89+ return None
90+
91+
92+ def _process_details (process ) -> str :
93+ args = getattr (process , "args" , None )
94+ if isinstance (args , list ):
95+ command = " " .join (str (arg ) for arg in args )
96+ else :
97+ command = str (args )
98+ return (
99+ f"pid={ getattr (process , 'pid' , None )} pgid={ _process_group_id (process )} command={ command } "
100+ )
101+
102+
103+ def _process_group_snapshot (process ) -> str :
104+ pgid = _process_group_id (process )
105+ if pgid is None :
106+ return "Process group snapshot unavailable: process group no longer exists"
107+ try :
108+ result = subprocess .run (
109+ ["ps" , "-o" , "pid,ppid,pgid,stat,command" , "-g" , str (pgid )],
110+ check = False ,
111+ capture_output = True ,
112+ text = True ,
113+ timeout = 2 ,
114+ )
115+ except Exception as exc :
116+ return f"Process group snapshot unavailable: { exc } "
117+ output = result .stdout .strip ()
118+ if not output :
119+ return f"Process group snapshot empty for pgid={ pgid } "
120+ return output
121+
122+
123+ def _terminate_plot_process (process , logger , timeout : float = PROCESS_TERMINATION_TIMEOUT ) -> None :
124+ if process .poll () is not None :
125+ return
126+
127+ process_details = _process_details (process )
128+ try :
129+ pgid = _process_group_id (process )
130+ if pgid is not None :
131+ logger .info (f"Terminating GUI process group { process_details } " )
132+ os .killpg (pgid , signal .SIGTERM )
133+ else :
134+ logger .info (f"Terminating GUI process { process_details } " )
135+ process .terminate ()
136+ except ProcessLookupError :
137+ process .wait (timeout = timeout )
138+ return
139+ except Exception as exc :
140+ logger .warning (
141+ f"Failed to terminate GUI process group: { exc } ; terminating process only. "
142+ f"{ process_details } "
143+ )
144+ process .terminate ()
145+
146+ try :
147+ process .wait (timeout = timeout )
148+ return
149+ except subprocess .TimeoutExpired :
150+ logger .warning (
151+ f"GUI process did not stop within { timeout } s; killing it. "
152+ f"{ process_details } \n { _process_group_snapshot (process )} "
153+ )
154+
155+ try :
156+ pgid = _process_group_id (process )
157+ if pgid is not None :
158+ os .killpg (pgid , signal .SIGKILL )
159+ else :
160+ process .kill ()
161+ except ProcessLookupError as e :
162+ logger .error (f"Failed to kill GUI process group: { e } " )
163+ process .wait (timeout = timeout )
164+ return
165+ process .wait (timeout = timeout )
166+
167+
168+ def _wait_for_process_exit (process , timeout : float ) -> bool :
169+ try :
170+ process .wait (timeout = timeout )
171+ except subprocess .TimeoutExpired :
172+ return False
173+ return True
174+
175+
176+ def _join_process_output_thread (process , thread : threading .Thread | None , logger ) -> None :
177+ if thread is None :
178+ return
179+ thread .join (timeout = PROCESS_OUTPUT_THREAD_JOIN_TIMEOUT )
180+ if not thread .is_alive ():
181+ return
182+
183+ for stream in (process .stdout , process .stderr ):
184+ if stream is None :
185+ continue
186+ try :
187+ stream .close ()
188+ except OSError as e :
189+ logger .error (f"Failed to close stream { str (e )} " )
190+ pass
191+ thread .join (timeout = PROCESS_OUTPUT_THREAD_JOIN_TIMEOUT )
192+ if thread .is_alive ():
193+ logger .warning (
194+ "GUI process output reader thread did not stop after process shutdown. "
195+ f"{ _process_details (process )} "
196+ )
197+
198+
78199def _start_plot_process (
79200 gui_id : str ,
80201 gui_class_id : str ,
@@ -465,11 +586,13 @@ def kill_server(self) -> None:
465586
466587 if self ._process :
467588 logger .success ("Stopping GUI..." )
468- self ._process .terminate ()
469- if self ._process_output_processing_thread :
470- self ._process_output_processing_thread .join ()
471- self ._process .wait ()
589+ if not self ._request_server_shutdown ():
590+ _terminate_plot_process (self ._process , logger )
591+ _join_process_output_thread (
592+ self ._process , self ._process_output_processing_thread , logger
593+ )
472594 self ._process = None
595+ self ._process_output_processing_thread = None
473596
474597 # Unregister the registry state
475598 self ._client .connector .unregister (
@@ -488,6 +611,30 @@ def close(self):
488611 #### Private methods ####
489612 #########################
490613
614+ def _request_server_shutdown (self ) -> bool :
615+ if self ._process is None or self ._process .poll () is not None :
616+ return True
617+ process_details = _process_details (self ._process )
618+ logger .info (f"Requesting graceful GUI shutdown { process_details } " )
619+ try :
620+ self .launcher ._run_rpc ( # pylint: disable=protected-access
621+ "system.shutdown" , wait_for_rpc_response = False
622+ )
623+ except Exception as exc :
624+ logger .warning (
625+ f"Could not request graceful GUI shutdown via RPC: { exc } . " f"{ process_details } "
626+ )
627+ return False
628+ if _wait_for_process_exit (self ._process , GRACEFUL_SERVER_SHUTDOWN_TIMEOUT ):
629+ logger .info (f"GUI server exited after graceful shutdown { process_details } " )
630+ return True
631+ logger .warning (
632+ "GUI server did not exit after graceful shutdown request; "
633+ f"falling back to process termination. { process_details } \n "
634+ f"{ _process_group_snapshot (self ._process )} "
635+ )
636+ return False
637+
491638 def _check_if_server_is_alive (self ):
492639 """Checks if the process is alive"""
493640 if self ._process is None :
0 commit comments