2
2
Windows-specific functionality for stdio client operations.
3
3
"""
4
4
5
+ import logging
5
6
import shutil
6
7
import subprocess
7
8
import sys
14
15
from anyio .streams .file import FileReadStream , FileWriteStream
15
16
from typing_extensions import deprecated
16
17
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
+
17
35
18
36
def get_windows_executable_command (command : str ) -> str :
19
37
"""
@@ -104,6 +122,11 @@ def kill(self) -> None:
104
122
"""Kill the subprocess immediately (alias for terminate)."""
105
123
self .terminate ()
106
124
125
+ @property
126
+ def pid (self ) -> int :
127
+ """Return the process ID."""
128
+ return self .popen .pid
129
+
107
130
108
131
# ------------------------
109
132
# Updated function
@@ -118,13 +141,16 @@ async def create_windows_process(
118
141
cwd : Path | str | None = None ,
119
142
) -> Process | FallbackProcess :
120
143
"""
121
- Creates a subprocess in a Windows-compatible way.
144
+ Creates a subprocess in a Windows-compatible way with Job Object support .
122
145
123
146
Attempt to use anyio's open_process for async subprocess creation.
124
147
In some cases this will throw NotImplementedError on Windows, e.g.
125
148
when using the SelectorEventLoop which does not support async subprocesses.
126
149
In that case, we fall back to using subprocess.Popen.
127
150
151
+ The process is automatically added to a Job Object to ensure all child
152
+ processes are terminated when the parent is terminated.
153
+
128
154
Args:
129
155
command (str): The executable to run
130
156
args (list[str]): List of command line arguments
@@ -133,8 +159,11 @@ async def create_windows_process(
133
159
cwd (Path | str | None): Working directory for the subprocess
134
160
135
161
Returns:
136
- FallbackProcess: Async-compatible subprocess with stdin and stdout streams
162
+ Process | FallbackProcess: Async-compatible subprocess with stdin and stdout streams
137
163
"""
164
+ job = _create_job_object ()
165
+ process = None
166
+
138
167
try :
139
168
# First try using anyio with Windows-specific flags to hide console window
140
169
process = await anyio .open_process (
@@ -147,10 +176,9 @@ async def create_windows_process(
147
176
stderr = errlog ,
148
177
cwd = cwd ,
149
178
)
150
- return process
151
179
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 )
154
182
except Exception :
155
183
# Try again without creation flags
156
184
process = await anyio .open_process (
@@ -159,7 +187,9 @@ async def create_windows_process(
159
187
stderr = errlog ,
160
188
cwd = cwd ,
161
189
)
162
- return process
190
+
191
+ _maybe_assign_process_to_job (process , job )
192
+ return process
163
193
164
194
165
195
async def _create_windows_fallback_process (
@@ -186,8 +216,6 @@ async def _create_windows_fallback_process(
186
216
bufsize = 0 , # Unbuffered output
187
217
creationflags = getattr (subprocess , "CREATE_NO_WINDOW" , 0 ),
188
218
)
189
- return FallbackProcess (popen_obj )
190
-
191
219
except Exception :
192
220
# If creationflags failed, fallback without them
193
221
popen_obj = subprocess .Popen (
@@ -199,7 +227,87 @@ async def _create_windows_fallback_process(
199
227
cwd = cwd ,
200
228
bufsize = 0 ,
201
229
)
202
- return FallbackProcess (popen_obj )
230
+ process = FallbackProcess (popen_obj )
231
+ return process
232
+
233
+
234
+ def _create_job_object () -> int | None :
235
+ """
236
+ Create a Windows Job Object configured to terminate all processes when closed.
237
+ """
238
+ if sys .platform != "win32" or not win32job :
239
+ return None
240
+
241
+ try :
242
+ job = win32job .CreateJobObject (None , "" )
243
+ extended_info = win32job .QueryInformationJobObject (job , win32job .JobObjectExtendedLimitInformation )
244
+
245
+ extended_info ["BasicLimitInformation" ]["LimitFlags" ] |= win32job .JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
246
+ win32job .SetInformationJobObject (job , win32job .JobObjectExtendedLimitInformation , extended_info )
247
+ return job
248
+ except Exception as e :
249
+ logger .warning (f"Failed to create Job Object for process tree management: { e } " )
250
+ return None
251
+
252
+
253
+ def _maybe_assign_process_to_job (process : Process | FallbackProcess , job : JobHandle | None ) -> None :
254
+ """
255
+ Try to assign a process to a job object. If assignment fails
256
+ for any reason, the job handle is closed.
257
+ """
258
+ if not job :
259
+ return
260
+
261
+ if sys .platform != "win32" or not win32api or not win32con or not win32job :
262
+ return
263
+
264
+ try :
265
+ process_handle = win32api .OpenProcess (
266
+ win32con .PROCESS_SET_QUOTA | win32con .PROCESS_TERMINATE , False , process .pid
267
+ )
268
+ if not process_handle :
269
+ raise Exception ("Failed to open process handle" )
270
+
271
+ try :
272
+ win32job .AssignProcessToJobObject (job , process_handle )
273
+ process ._job_object = job
274
+ finally :
275
+ win32api .CloseHandle (process_handle )
276
+ except Exception as e :
277
+ logger .warning (f"Failed to assign process { process .pid } to Job Object: { e } " )
278
+ if win32api :
279
+ win32api .CloseHandle (job )
280
+
281
+
282
+ async def terminate_windows_process_tree (process : Process | FallbackProcess , timeout : float = 2.0 ) -> None :
283
+ """
284
+ Terminate a process and all its children on Windows.
285
+
286
+ If the process has an associated job object, it will be terminated.
287
+ Otherwise, falls back to basic process termination.
288
+ """
289
+ if sys .platform != "win32" :
290
+ return
291
+
292
+ job = getattr (process , "_job_object" , None )
293
+ if job and win32job :
294
+ try :
295
+ win32job .TerminateJobObject (job , 1 )
296
+ except Exception :
297
+ # Job might already be terminated
298
+ pass
299
+ finally :
300
+ if win32api :
301
+ try :
302
+ win32api .CloseHandle (job )
303
+ except Exception :
304
+ pass
305
+
306
+ # Always try to terminate the process itself as well
307
+ try :
308
+ process .terminate ()
309
+ except Exception :
310
+ pass
203
311
204
312
205
313
@deprecated (
0 commit comments