11"""Local compute provider: runs tasks in a uv venv synced with the base environment."""
22
3+ import contextlib
34import os
5+ import signal
46import subprocess
57import sys
68from pathlib import Path
@@ -85,6 +87,48 @@ def _get_uv_pip_install_flags() -> str:
8587 return ""
8688
8789
90+ def _terminate_process_tree (pid : int , sig : int = signal .SIGTERM ) -> None :
91+ """
92+ Best-effort termination of a process and all of its descendants.
93+
94+ Uses psutil when available to walk the full process tree and then force-kill
95+ any survivors; otherwise falls back to killing the process group (if possible)
96+ and then the single pid.
97+ """
98+ try :
99+ import psutil # type: ignore[import-not-found]
100+ except Exception :
101+ psutil = None # type: ignore[assignment]
102+
103+ if psutil is not None :
104+ try :
105+ parent = psutil .Process (pid )
106+ except psutil .NoSuchProcess :
107+ parent = None
108+
109+ if parent is not None :
110+ procs = [parent ] + parent .children (recursive = True )
111+
112+ # First try graceful termination
113+ for proc in procs :
114+ with contextlib .suppress (psutil .NoSuchProcess , psutil .AccessDenied , psutil .Error ):
115+ proc .send_signal (sig )
116+
117+ # Give processes a short window to exit, then force kill survivors
118+ gone , alive = psutil .wait_procs (procs , timeout = 3 ) # type: ignore[assignment]
119+ for proc in alive :
120+ with contextlib .suppress (psutil .NoSuchProcess , psutil .AccessDenied , psutil .Error ):
121+ proc .kill ()
122+ return
123+
124+ # Fallback path when psutil is unavailable or parent no longer exists
125+ try :
126+ pgid = os .getpgid (pid )
127+ os .killpg (pgid , sig )
128+ except Exception :
129+ os .kill (pid , sig )
130+
131+
88132def _is_process_zombie (pid : int ) -> bool :
89133 """
90134 Return True if the process is a zombie/defunct process.
@@ -172,18 +216,19 @@ def launch_cluster(self, cluster_name: str, config: ClusterConfig) -> Dict[str,
172216 if not job_dir or not os .path .isdir (job_dir ):
173217 raise ValueError ("Local provider requires workspace_dir (job directory) in provider_config" )
174218 job_dir = Path (job_dir )
175- venv_path = job_dir / "venv"
176- venv_path .mkdir (parents = True , exist_ok = True )
177-
178- self ._ensure_venv_and_sync (venv_path )
179-
180219 # Use a per-job workspace directory as HOME for local runs so tools that
181220 # rely on ~ and $HOME resolve inside the job workspace instead of the
182221 # user's real home directory. This makes it easier to clone and run
183222 # code in an isolated workspace for each job.
184223 workspace_home = job_dir / "workspace"
185224 workspace_home .mkdir (parents = True , exist_ok = True )
186225
226+ # Create the venv inside the per-job workspace HOME directory so that all
227+ # environment state (including Python packages) lives under HOME.
228+ venv_path = workspace_home / "venv"
229+
230+ self ._ensure_venv_and_sync (venv_path )
231+
187232 venv_bin = venv_path / "bin"
188233 env = os .environ .copy ()
189234 env .update (config .env_vars or {})
@@ -226,7 +271,7 @@ def launch_cluster(self, cluster_name: str, config: ClusterConfig) -> Dict[str,
226271 }
227272
228273 def stop_cluster (self , cluster_name : str ) -> Dict [str , Any ]:
229- """Stop the local process for this cluster (SIGTERM)."""
274+ """Stop the local process tree for this cluster (SIGTERM)."""
230275 job_dir = self .extra_config .get ("workspace_dir" )
231276 if not job_dir :
232277 return {
@@ -243,8 +288,8 @@ def stop_cluster(self, cluster_name: str) -> Dict[str, Any]:
243288 }
244289 try :
245290 pid = int (pid_file .read_text ().strip ())
246- os . kill (pid , 15 )
247- return {"cluster_name" : cluster_name , "status" : "stopped" , "message" : "Sent SIGTERM" }
291+ _terminate_process_tree (pid , signal . SIGTERM )
292+ return {"cluster_name" : cluster_name , "status" : "stopped" , "message" : "Sent SIGTERM to process tree " }
248293 except (ValueError , ProcessLookupError , OSError ) as e :
249294 return {"cluster_name" : cluster_name , "status" : "stopped" , "message" : str (e )}
250295
0 commit comments