66import os
77from pathlib import Path
88import shutil
9+ import signal
910import socket
1011import subprocess
1112import tempfile
@@ -112,7 +113,7 @@ def _used_ports(self) -> Iterator[Set[Tuple[str, int]]]:
112113 def get_hostname_and_port (self ) -> Tuple [str , int ]:
113114 with self ._used_ports () as used_ports :
114115 while True :
115- ( hostname , port ) = find_hostname_and_port ()
116+ hostname , port = find_hostname_and_port ()
116117 if (hostname , port ) not in used_ports :
117118 # double-checking in self._used_ports to prevent collisions
118119 # between controllers starting at the same time.
@@ -129,15 +130,41 @@ def check_is_alive(self) -> None:
129130 if self .proc .returncode is not None :
130131 raise ProcessStopped (f"process returned { self .proc .returncode } " )
131132
133+ def _terminate_process_group (self , sig : signal .Signals ) -> bool :
134+ """Try to send a signal to the entire process group.
135+
136+ Returns True if successful, False if we should fall back to single process.
137+ """
138+ assert self .proc
139+ try :
140+ os .killpg (os .getpgid (self .proc .pid ), sig )
141+ return True
142+ except (ProcessLookupError , PermissionError , OSError ):
143+ # Process group doesn't exist or we don't have permission
144+ return False
145+
132146 def kill_proc (self ) -> None :
133147 """Terminates the controlled process, waits for it to exit, and
134- eventually kills it."""
148+ eventually kills it.
149+
150+ This method kills the entire process group to ensure child processes
151+ (e.g., Solanum's authd and ssld helpers) are also terminated.
152+ """
135153 assert self .proc
136- self .proc .terminate ()
154+
155+ # Try to terminate the entire process group first
156+ if not self ._terminate_process_group (signal .SIGTERM ):
157+ # Fall back to terminating just this process
158+ self .proc .terminate ()
159+
137160 try :
138161 self .proc .wait (5 )
139162 except subprocess .TimeoutExpired :
140- self .proc .kill ()
163+ # If still running, send SIGKILL to the process group
164+ if not self ._terminate_process_group (signal .SIGKILL ):
165+ # Fall back to killing just this process
166+ self .proc .kill ()
167+ self .proc .wait (timeout = 10 ) # Wait for it to actually die
141168 self .proc = None
142169
143170 def kill (self ) -> None :
@@ -154,6 +181,10 @@ def execute(
154181 self , command : Sequence [Union [str , Path ]], ** kwargs : Any
155182 ) -> subprocess .Popen :
156183 output_to = None if self .debug_mode else subprocess .DEVNULL
184+ # Start in a new process group so we can kill all children together
185+ # Note: start_new_session and preexec_fn cannot be used together
186+ if "start_new_session" not in kwargs and "preexec_fn" not in kwargs :
187+ kwargs ["start_new_session" ] = True
157188 return subprocess .Popen (command , stderr = output_to , stdout = output_to , ** kwargs )
158189
159190
@@ -176,7 +207,9 @@ def kill(self) -> None:
176207 def terminate (self ) -> None :
177208 """Stops the process gracefully, and does not clean its config."""
178209 assert self .proc
179- self .proc .terminate ()
210+ # Terminate the entire process group to kill child processes too
211+ if not self ._terminate_process_group (signal .SIGTERM ):
212+ self .proc .terminate ()
180213 self .proc .wait ()
181214 self .proc = None
182215
0 commit comments