22Windows-specific functionality for stdio client operations.
33"""
44
5+ import logging
56import shutil
67import subprocess
78import sys
1415from anyio .streams .file import FileReadStream , FileWriteStream
1516from typing_extensions import deprecated
1617
18+ logger = logging .getLogger ("client.stdio.win32" )
19+
20+ # Windows-specific imports for Job Objects
21+ if sys .platform == "win32" :
22+ import pywintypes
23+ import win32api
24+ import win32con
25+ import win32job
26+ else :
27+ # Type stubs for non-Windows platforms
28+ win32api = None
29+ win32con = None
30+ win32job = None
31+ pywintypes = None
32+
33+ JobHandle = int
34+
1735
1836def get_windows_executable_command (command : str ) -> str :
1937 """
@@ -104,6 +122,11 @@ def kill(self) -> None:
104122 """Kill the subprocess immediately (alias for terminate)."""
105123 self .terminate ()
106124
125+ @property
126+ def pid (self ) -> int :
127+ """Return the process ID."""
128+ return self .popen .pid
129+
107130
108131# ------------------------
109132# Updated function
@@ -118,13 +141,16 @@ async def create_windows_process(
118141 cwd : Path | str | None = None ,
119142) -> Process | FallbackProcess :
120143 """
121- Creates a subprocess in a Windows-compatible way.
144+ Creates a subprocess in a Windows-compatible way with Job Object support .
122145
123146 Attempt to use anyio's open_process for async subprocess creation.
124147 In some cases this will throw NotImplementedError on Windows, e.g.
125148 when using the SelectorEventLoop which does not support async subprocesses.
126149 In that case, we fall back to using subprocess.Popen.
127150
151+ The process is automatically added to a Job Object to ensure all child
152+ processes are terminated when the parent is terminated.
153+
128154 Args:
129155 command (str): The executable to run
130156 args (list[str]): List of command line arguments
@@ -133,8 +159,11 @@ async def create_windows_process(
133159 cwd (Path | str | None): Working directory for the subprocess
134160
135161 Returns:
136- FallbackProcess: Async-compatible subprocess with stdin and stdout streams
162+ Process | FallbackProcess: Async-compatible subprocess with stdin and stdout streams
137163 """
164+ job = _create_job_object ()
165+ process = None
166+
138167 try :
139168 # First try using anyio with Windows-specific flags to hide console window
140169 process = await anyio .open_process (
@@ -147,10 +176,9 @@ async def create_windows_process(
147176 stderr = errlog ,
148177 cwd = cwd ,
149178 )
150- return process
151179 except NotImplementedError :
152- # Windows often doesn't support async subprocess creation, use fallback
153- return await _create_windows_fallback_process (command , args , env , errlog , cwd )
180+ # If Windows doesn't support async subprocess creation, use fallback
181+ process = await _create_windows_fallback_process (command , args , env , errlog , cwd )
154182 except Exception :
155183 # Try again without creation flags
156184 process = await anyio .open_process (
@@ -159,7 +187,9 @@ async def create_windows_process(
159187 stderr = errlog ,
160188 cwd = cwd ,
161189 )
162- return process
190+
191+ _maybe_assign_process_to_job (process , job )
192+ return process
163193
164194
165195async def _create_windows_fallback_process (
@@ -186,8 +216,6 @@ async def _create_windows_fallback_process(
186216 bufsize = 0 , # Unbuffered output
187217 creationflags = getattr (subprocess , "CREATE_NO_WINDOW" , 0 ),
188218 )
189- return FallbackProcess (popen_obj )
190-
191219 except Exception :
192220 # If creationflags failed, fallback without them
193221 popen_obj = subprocess .Popen (
@@ -199,7 +227,90 @@ async def _create_windows_fallback_process(
199227 cwd = cwd ,
200228 bufsize = 0 ,
201229 )
202- return FallbackProcess (popen_obj )
230+ return FallbackProcess (popen_obj )
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_seconds : 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+ Args:
289+ process: The process to terminate
290+ timeout_seconds: Timeout in seconds before force killing (default: 2.0)
291+ """
292+ if sys .platform != "win32" :
293+ return
294+
295+ job = getattr (process , "_job_object" , None )
296+ if job and win32job :
297+ try :
298+ win32job .TerminateJobObject (job , 1 )
299+ except Exception :
300+ # Job might already be terminated
301+ pass
302+ finally :
303+ if win32api :
304+ try :
305+ win32api .CloseHandle (job )
306+ except Exception :
307+ pass
308+
309+ # Always try to terminate the process itself as well
310+ try :
311+ process .terminate ()
312+ except Exception :
313+ pass
203314
204315
205316@deprecated (
0 commit comments