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
13
14
from anyio .abc import Process
14
15
from anyio .streams .file import FileReadStream , FileWriteStream
15
16
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
+
16
34
17
35
def get_windows_executable_command (command : str ) -> str :
18
36
"""
@@ -103,6 +121,11 @@ def kill(self) -> None:
103
121
"""Kill the subprocess immediately (alias for terminate)."""
104
122
self .terminate ()
105
123
124
+ @property
125
+ def pid (self ) -> int :
126
+ """Return the process ID."""
127
+ return self .popen .pid
128
+
106
129
107
130
# ------------------------
108
131
# Updated function
@@ -117,13 +140,16 @@ async def create_windows_process(
117
140
cwd : Path | str | None = None ,
118
141
) -> Process | FallbackProcess :
119
142
"""
120
- Creates a subprocess in a Windows-compatible way.
143
+ Creates a subprocess in a Windows-compatible way with Job Object support .
121
144
122
145
Attempt to use anyio's open_process for async subprocess creation.
123
146
In some cases this will throw NotImplementedError on Windows, e.g.
124
147
when using the SelectorEventLoop which does not support async subprocesses.
125
148
In that case, we fall back to using subprocess.Popen.
126
149
150
+ The process is automatically added to a Job Object to ensure all child
151
+ processes are terminated when the parent is terminated.
152
+
127
153
Args:
128
154
command (str): The executable to run
129
155
args (list[str]): List of command line arguments
@@ -132,8 +158,11 @@ async def create_windows_process(
132
158
cwd (Path | str | None): Working directory for the subprocess
133
159
134
160
Returns:
135
- FallbackProcess: Async-compatible subprocess with stdin and stdout streams
161
+ Process | FallbackProcess: Async-compatible subprocess with stdin and stdout streams
136
162
"""
163
+ job = _create_job_object ()
164
+ process = None
165
+
137
166
try :
138
167
# First try using anyio with Windows-specific flags to hide console window
139
168
process = await anyio .open_process (
@@ -146,10 +175,9 @@ async def create_windows_process(
146
175
stderr = errlog ,
147
176
cwd = cwd ,
148
177
)
149
- return process
150
178
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 )
153
181
except Exception :
154
182
# Try again without creation flags
155
183
process = await anyio .open_process (
@@ -158,7 +186,9 @@ async def create_windows_process(
158
186
stderr = errlog ,
159
187
cwd = cwd ,
160
188
)
161
- return process
189
+
190
+ _maybe_assign_process_to_job (process , job )
191
+ return process
162
192
163
193
164
194
async def _create_windows_fallback_process (
@@ -185,8 +215,6 @@ async def _create_windows_fallback_process(
185
215
bufsize = 0 , # Unbuffered output
186
216
creationflags = getattr (subprocess , "CREATE_NO_WINDOW" , 0 ),
187
217
)
188
- return FallbackProcess (popen_obj )
189
-
190
218
except Exception :
191
219
# If creationflags failed, fallback without them
192
220
popen_obj = subprocess .Popen (
@@ -198,4 +226,84 @@ async def _create_windows_fallback_process(
198
226
cwd = cwd ,
199
227
bufsize = 0 ,
200
228
)
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