6
6
import subprocess
7
7
import sys
8
8
from pathlib import Path
9
- from typing import BinaryIO , TextIO , cast
9
+ from typing import BinaryIO , TextIO , Union , cast
10
10
11
11
import anyio
12
12
from anyio import to_thread
13
13
from anyio .abc import Process
14
14
from anyio .streams .file import FileReadStream , FileWriteStream
15
15
16
+ # Windows-specific imports for Job Objects
17
+ if sys .platform == "win32" :
18
+ import pywintypes
19
+ import win32api
20
+ import win32con
21
+ import win32job
22
+ else :
23
+ # Type stubs for non-Windows platforms
24
+ win32api = None
25
+ win32con = None
26
+ win32job = None
27
+ pywintypes = None
28
+
29
+ JobHandle = int
30
+
16
31
17
32
def get_windows_executable_command (command : str ) -> str :
18
33
"""
@@ -103,6 +118,11 @@ def kill(self) -> None:
103
118
"""Kill the subprocess immediately (alias for terminate)."""
104
119
self .terminate ()
105
120
121
+ @property
122
+ def pid (self ) -> int :
123
+ """Return the process ID."""
124
+ return self .popen .pid
125
+
106
126
107
127
# ------------------------
108
128
# Updated function
@@ -117,13 +137,16 @@ async def create_windows_process(
117
137
cwd : Path | str | None = None ,
118
138
) -> Process | FallbackProcess :
119
139
"""
120
- Creates a subprocess in a Windows-compatible way.
140
+ Creates a subprocess in a Windows-compatible way with Job Object support .
121
141
122
142
Attempt to use anyio's open_process for async subprocess creation.
123
143
In some cases this will throw NotImplementedError on Windows, e.g.
124
144
when using the SelectorEventLoop which does not support async subprocesses.
125
145
In that case, we fall back to using subprocess.Popen.
126
146
147
+ The process is automatically added to a Job Object to ensure all child
148
+ processes are terminated when the parent is terminated.
149
+
127
150
Args:
128
151
command (str): The executable to run
129
152
args (list[str]): List of command line arguments
@@ -132,8 +155,12 @@ async def create_windows_process(
132
155
cwd (Path | str | None): Working directory for the subprocess
133
156
134
157
Returns:
135
- FallbackProcess: Async-compatible subprocess with stdin and stdout streams
158
+ Process | FallbackProcess: Async-compatible subprocess with stdin and stdout streams
136
159
"""
160
+ # Create a job object for this process
161
+ job = _create_job_object ()
162
+ process = None
163
+
137
164
try :
138
165
# First try using anyio with Windows-specific flags to hide console window
139
166
process = await anyio .open_process (
@@ -146,10 +173,9 @@ async def create_windows_process(
146
173
stderr = errlog ,
147
174
cwd = cwd ,
148
175
)
149
- return process
150
176
except NotImplementedError :
151
177
# Windows often doesn't support async subprocess creation, use fallback
152
- return await _create_windows_fallback_process (command , args , env , errlog , cwd )
178
+ process = await _create_windows_fallback_process (command , args , env , errlog , cwd )
153
179
except Exception :
154
180
# Try again without creation flags
155
181
process = await anyio .open_process (
@@ -158,7 +184,9 @@ async def create_windows_process(
158
184
stderr = errlog ,
159
185
cwd = cwd ,
160
186
)
161
- return process
187
+
188
+ _maybe_assign_process_to_job (process , job )
189
+ return process
162
190
163
191
164
192
async def _create_windows_fallback_process (
@@ -171,7 +199,8 @@ async def _create_windows_fallback_process(
171
199
"""
172
200
Create a subprocess using subprocess.Popen as a fallback when anyio fails.
173
201
174
- This function wraps the sync subprocess.Popen in an async-compatible interface.
202
+ This function wraps the sync subprocess.Popen in an async-compatible interface
203
+ and optionally assigns it to a job object.
175
204
"""
176
205
try :
177
206
# Try launching with creationflags to avoid opening a new console window
@@ -185,8 +214,6 @@ async def _create_windows_fallback_process(
185
214
bufsize = 0 , # Unbuffered output
186
215
creationflags = getattr (subprocess , "CREATE_NO_WINDOW" , 0 ),
187
216
)
188
- return FallbackProcess (popen_obj )
189
-
190
217
except Exception :
191
218
# If creationflags failed, fallback without them
192
219
popen_obj = subprocess .Popen (
@@ -198,4 +225,108 @@ async def _create_windows_fallback_process(
198
225
cwd = cwd ,
199
226
bufsize = 0 ,
200
227
)
201
- return FallbackProcess (popen_obj )
228
+ process = FallbackProcess (popen_obj )
229
+ return process
230
+
231
+
232
+ def _create_job_object () -> int | None :
233
+ """
234
+ Create a Windows Job Object configured to terminate all processes when closed.
235
+
236
+ Returns:
237
+ The job object handle, or None if creation failed.
238
+ """
239
+ if sys .platform != "win32" or not win32job :
240
+ return None
241
+
242
+ try :
243
+ # Create an unnamed job object
244
+ job = win32job .CreateJobObject (None , "" )
245
+
246
+ # Query current job information
247
+ extended_info = win32job .QueryInformationJobObject (job , win32job .JobObjectExtendedLimitInformation )
248
+
249
+ # Set the job to terminate all processes when the handle is closed
250
+ extended_info ["BasicLimitInformation" ]["LimitFlags" ] |= win32job .JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
251
+
252
+ # Apply the updated job information
253
+ win32job .SetInformationJobObject (job , win32job .JobObjectExtendedLimitInformation , extended_info )
254
+
255
+ return job
256
+ except Exception :
257
+ # If job creation fails, return None
258
+ return None
259
+
260
+
261
+ def _maybe_assign_process_to_job (process : Union [Process , "FallbackProcess" ], job : JobHandle | None ) -> None :
262
+ """
263
+ Try to assign a process to a job object. If assignment fails
264
+ for any reason, the job handle is closed.
265
+
266
+ Args:
267
+ process: The process to assign to the job
268
+ job: The job object handle (may be None)
269
+ """
270
+ if not job :
271
+ return
272
+
273
+ if sys .platform != "win32" or not win32api or not win32con or not win32job :
274
+ return
275
+
276
+ try :
277
+ # Open the process with required permissions
278
+ process_handle = win32api .OpenProcess (
279
+ win32con .PROCESS_SET_QUOTA | win32con .PROCESS_TERMINATE , False , process .pid
280
+ )
281
+ if not process_handle :
282
+ raise Exception ("Failed to open process handle" )
283
+
284
+ try :
285
+ # Assign process to job
286
+ win32job .AssignProcessToJobObject (job , process_handle )
287
+ # Store job on process for later cleanup
288
+ process ._job_object = job # type: ignore
289
+ finally :
290
+ # Always close the process handle
291
+ win32api .CloseHandle (process_handle )
292
+ except Exception :
293
+ # If we can't assign to job, close it
294
+ if win32api :
295
+ win32api .CloseHandle (job )
296
+
297
+
298
+ async def terminate_windows_process_tree (process : Union [Process , "FallbackProcess" ]) -> None :
299
+ """
300
+ Terminate a process and all its children on Windows.
301
+
302
+ If the process has an associated job object, it will be terminated.
303
+ Otherwise, falls back to basic process termination.
304
+
305
+ Args:
306
+ process: The process to terminate
307
+ """
308
+ if sys .platform != "win32" :
309
+ return
310
+
311
+ # Check if process has a job object
312
+ job = getattr (process , "_job_object" , None )
313
+ if job and win32job :
314
+ try :
315
+ # Terminate all processes in the job (exit code 1)
316
+ win32job .TerminateJobObject (job , 1 )
317
+ except Exception :
318
+ # Job might already be terminated
319
+ pass
320
+ finally :
321
+ if win32api :
322
+ try :
323
+ # Close the job handle
324
+ win32api .CloseHandle (job )
325
+ except Exception :
326
+ pass
327
+
328
+ # Always try to terminate the process itself as well
329
+ try :
330
+ process .terminate ()
331
+ except Exception :
332
+ pass
0 commit comments