22Windows-specific functionality for stdio client operations.
33"""
44
5+ import logging
56import shutil
67import subprocess
78import sys
1314from anyio .abc import Process
1415from anyio .streams .file import FileReadStream , FileWriteStream
1516
17+ logger = logging .getLogger ("client.stdio.win32" )
18+
19+ # Windows-specific imports for Job Objects
20+ if sys .platform == "win32" :
21+ import pywintypes
22+ import win32api
23+ import win32con
24+ import win32job
25+ else :
26+ # Type stubs for non-Windows platforms
27+ win32api = None
28+ win32con = None
29+ win32job = None
30+ pywintypes = None
31+
32+ JobHandle = int
33+
1634
1735def get_windows_executable_command (command : str ) -> str :
1836 """
@@ -103,6 +121,11 @@ def kill(self) -> None:
103121 """Kill the subprocess immediately (alias for terminate)."""
104122 self .terminate ()
105123
124+ @property
125+ def pid (self ) -> int :
126+ """Return the process ID."""
127+ return self .popen .pid
128+
106129
107130# ------------------------
108131# Updated function
@@ -117,13 +140,16 @@ async def create_windows_process(
117140 cwd : Path | str | None = None ,
118141) -> Process | FallbackProcess :
119142 """
120- Creates a subprocess in a Windows-compatible way.
143+ Creates a subprocess in a Windows-compatible way with Job Object support .
121144
122145 Attempt to use anyio's open_process for async subprocess creation.
123146 In some cases this will throw NotImplementedError on Windows, e.g.
124147 when using the SelectorEventLoop which does not support async subprocesses.
125148 In that case, we fall back to using subprocess.Popen.
126149
150+ The process is automatically added to a Job Object to ensure all child
151+ processes are terminated when the parent is terminated.
152+
127153 Args:
128154 command (str): The executable to run
129155 args (list[str]): List of command line arguments
@@ -132,8 +158,11 @@ async def create_windows_process(
132158 cwd (Path | str | None): Working directory for the subprocess
133159
134160 Returns:
135- FallbackProcess: Async-compatible subprocess with stdin and stdout streams
161+ Process | FallbackProcess: Async-compatible subprocess with stdin and stdout streams
136162 """
163+ job = _create_job_object ()
164+ process = None
165+
137166 try :
138167 # First try using anyio with Windows-specific flags to hide console window
139168 process = await anyio .open_process (
@@ -146,10 +175,9 @@ async def create_windows_process(
146175 stderr = errlog ,
147176 cwd = cwd ,
148177 )
149- return process
150178 except NotImplementedError :
151- # Windows often doesn't support async subprocess creation, use fallback
152- return await _create_windows_fallback_process (command , args , env , errlog , cwd )
179+ # If Windows doesn't support async subprocess creation, use fallback
180+ process = await _create_windows_fallback_process (command , args , env , errlog , cwd )
153181 except Exception :
154182 # Try again without creation flags
155183 process = await anyio .open_process (
@@ -158,7 +186,9 @@ async def create_windows_process(
158186 stderr = errlog ,
159187 cwd = cwd ,
160188 )
161- return process
189+
190+ _maybe_assign_process_to_job (process , job )
191+ return process
162192
163193
164194async def _create_windows_fallback_process (
@@ -185,8 +215,6 @@ async def _create_windows_fallback_process(
185215 bufsize = 0 , # Unbuffered output
186216 creationflags = getattr (subprocess , "CREATE_NO_WINDOW" , 0 ),
187217 )
188- return FallbackProcess (popen_obj )
189-
190218 except Exception :
191219 # If creationflags failed, fallback without them
192220 popen_obj = subprocess .Popen (
@@ -198,4 +226,84 @@ async def _create_windows_fallback_process(
198226 cwd = cwd ,
199227 bufsize = 0 ,
200228 )
201- return FallbackProcess (popen_obj )
229+ process = FallbackProcess (popen_obj )
230+ return process
231+
232+
233+ def _create_job_object () -> int | None :
234+ """
235+ Create a Windows Job Object configured to terminate all processes when closed.
236+ """
237+ if sys .platform != "win32" or not win32job :
238+ return None
239+
240+ try :
241+ job = win32job .CreateJobObject (None , "" )
242+ extended_info = win32job .QueryInformationJobObject (job , win32job .JobObjectExtendedLimitInformation )
243+
244+ extended_info ["BasicLimitInformation" ]["LimitFlags" ] |= win32job .JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
245+ win32job .SetInformationJobObject (job , win32job .JobObjectExtendedLimitInformation , extended_info )
246+ return job
247+ except Exception as e :
248+ logger .warning (f"Failed to create Job Object for process tree management: { e } " )
249+ return None
250+
251+
252+ def _maybe_assign_process_to_job (process : Process | FallbackProcess , job : JobHandle | None ) -> None :
253+ """
254+ Try to assign a process to a job object. If assignment fails
255+ for any reason, the job handle is closed.
256+ """
257+ if not job :
258+ return
259+
260+ if sys .platform != "win32" or not win32api or not win32con or not win32job :
261+ return
262+
263+ try :
264+ process_handle = win32api .OpenProcess (
265+ win32con .PROCESS_SET_QUOTA | win32con .PROCESS_TERMINATE , False , process .pid
266+ )
267+ if not process_handle :
268+ raise Exception ("Failed to open process handle" )
269+
270+ try :
271+ win32job .AssignProcessToJobObject (job , process_handle )
272+ process ._job_object = job
273+ finally :
274+ win32api .CloseHandle (process_handle )
275+ except Exception as e :
276+ logger .warning (f"Failed to assign process { process .pid } to Job Object: { e } " )
277+ if win32api :
278+ win32api .CloseHandle (job )
279+
280+
281+ async def terminate_windows_process_tree (process : Process | FallbackProcess , timeout : float = 2.0 ) -> None :
282+ """
283+ Terminate a process and all its children on Windows.
284+
285+ If the process has an associated job object, it will be terminated.
286+ Otherwise, falls back to basic process termination.
287+ """
288+ if sys .platform != "win32" :
289+ return
290+
291+ job = getattr (process , "_job_object" , None )
292+ if job and win32job :
293+ try :
294+ win32job .TerminateJobObject (job , 1 )
295+ except Exception :
296+ # Job might already be terminated
297+ pass
298+ finally :
299+ if win32api :
300+ try :
301+ win32api .CloseHandle (job )
302+ except Exception :
303+ pass
304+
305+ # Always try to terminate the process itself as well
306+ try :
307+ process .terminate ()
308+ except Exception :
309+ pass
0 commit comments