2828 win32job = None
2929 pywintypes = None
3030
31- # How often FallbackProcess polls the underlying Popen for exit. Polling keeps the
32- # wait cancellable: a thread blocked in Popen.wait() cannot be cancelled by anyio,
33- # which would make every timeout around it ineffective.
31+ # How often FallbackProcess polls the underlying Popen for exit.
3432_EXIT_POLL_INTERVAL = 0.01
3533
36- # The Job Object each spawned process was assigned to, so its process tree can be
37- # terminated through it later. Values must stay the pywin32 `PyHANDLE` returned by
38- # `CreateJobObject`, never a detached int: if neither pop site runs (an abandoned
39- # shutdown), the dying weak entry drops the last reference and the `PyHANDLE`
40- # destructor closes the OS handle, which is what makes `KILL_ON_JOB_CLOSE` reap
41- # the orphaned tree.
34+ # Job Object handle per spawned process, for tree termination at shutdown.
35+ # Values stay pywin32 PyHANDLEs: if no pop site ever runs, the dying weak entry
36+ # drops the last reference and the PyHANDLE destructor closes the handle, which
37+ # is what makes KILL_ON_JOB_CLOSE reap an abandoned tree.
4238_process_jobs : "weakref.WeakKeyDictionary[Process | FallbackProcess, object]" = weakref .WeakKeyDictionary ()
4339
4440
4541def get_windows_executable_command (command : str ) -> str :
46- """Get the correct executable command normalized for Windows.
47-
48- On Windows, commands might exist with specific extensions (.exe, .cmd, etc.)
49- that need to be located for proper execution.
50-
51- Args:
52- command: Base command (e.g., 'uvx', 'npx')
53-
54- Returns:
55- str: Windows-appropriate command path
56- """
42+ """Resolve the command to a Windows executable path, trying the bare name
43+ first and then the common script extensions (.cmd, .bat, .exe, .ps1)."""
5744 try :
58- # First check if command exists in PATH as-is
5945 if command_path := shutil .which (command ):
6046 return command_path
6147
62- # Check for Windows-specific extensions
6348 for ext in [".cmd" , ".bat" , ".exe" , ".ps1" ]:
6449 ext_version = f"{ command } { ext } "
6550 if ext_path := shutil .which (ext_version ):
6651 return ext_path
6752
68- # For regular commands or if we couldn't find special versions
6953 return command
7054 except OSError :
71- # Handle file system errors during path resolution
72- # (permissions, broken symlinks, etc.)
73- return command
55+ return command # path probing failed (permissions, broken symlinks)
7456
7557
7658class FallbackProcess :
77- """A fallback process wrapper for Windows to handle async I/O
78- when using subprocess.Popen, which provides sync-only FileIO objects.
79-
80- This wraps stdin and stdout into async-compatible
81- streams (FileReadStream, FileWriteStream),
82- so that MCP clients expecting async streams can work properly.
83- """
59+ """Async wrapper around subprocess.Popen for Windows event loops without
60+ async subprocess support (SelectorEventLoop)."""
8461
8562 def __init__ (self , popen_obj : subprocess .Popen [bytes ]) -> None :
8663 self .popen : subprocess .Popen [bytes ] = popen_obj
@@ -91,11 +68,8 @@ def __init__(self, popen_obj: subprocess.Popen[bytes]) -> None:
9168 self .stdout = FileReadStream (cast (BinaryIO , stdout )) if stdout else None
9269
9370 async def wait (self ) -> int :
94- """Wait for process exit by polling.
95-
96- `Popen.wait()` in a worker thread cannot be cancelled by anyio, which would
97- defeat every timeout placed around this call; polling keeps it cancellable.
98- """
71+ """Wait for exit by polling; a thread blocked in Popen.wait() cannot be
72+ cancelled by anyio, which would defeat every timeout around this call."""
9973 while (returncode := self .popen .poll ()) is None :
10074 await anyio .sleep (_EXIT_POLL_INTERVAL )
10175 return returncode
@@ -105,7 +79,7 @@ def terminate(self) -> None:
10579 self .popen .terminate ()
10680
10781 def kill (self ) -> None :
108- """Kill the subprocess (on Windows this is the same hard kill as terminate)."""
82+ """Kill the subprocess (same hard kill as terminate on Windows )."""
10983 self .popen .kill ()
11084
11185 @property
@@ -115,11 +89,8 @@ def pid(self) -> int:
11589
11690 @property
11791 def returncode (self ) -> int | None :
118- """Return the exit code, or `None` if the process has not yet terminated.
119-
120- Polls the underlying `Popen` so the value updates as soon as the process
121- dies, without anyone having to call `wait()`.
122- """
92+ """Exit code, or None while running; polls Popen so death is observable
93+ without anyone calling wait()."""
12394 return self .popen .poll ()
12495
12596
@@ -135,27 +106,9 @@ async def create_windows_process(
135106 errlog : TextIO | None = sys .stderr ,
136107 cwd : Path | str | None = None ,
137108) -> Process | FallbackProcess :
138- """Creates a subprocess in a Windows-compatible way with Job Object support.
139-
140- Attempts to use anyio's open_process for async subprocess creation.
141- In some cases this will throw NotImplementedError on Windows, e.g.,
142- when using the SelectorEventLoop, which does not support async subprocesses.
143- In that case, we fall back to using subprocess.Popen.
144-
145- The process is added to a Job Object so that child processes are terminated
146- with it; children spawned before the assignment completes are not captured
147- (see the inline note below).
148-
149- Args:
150- command (str): The executable to run
151- args (list[str]): List of command line arguments
152- env (dict[str, str] | None): Environment variables
153- errlog (TextIO | None): Where to send stderr output (defaults to sys.stderr)
154- cwd (Path | str | None): Working directory for the subprocess
155-
156- Returns:
157- Process | FallbackProcess: Async-compatible subprocess with stdin and stdout streams
158- """
109+ """Spawn the server inside a Job Object so its children can be terminated
110+ with it; falls back to subprocess.Popen on event loops without async
111+ subprocess support."""
159112 try :
160113 process = await anyio .open_process (
161114 [command , * args ],
@@ -169,11 +122,9 @@ async def create_windows_process(
169122 # Windows event loops without async subprocess support (SelectorEventLoop)
170123 process = await _create_windows_fallback_process (command , args , env , errlog , cwd )
171124
172- # Created only after a successful spawn (a failed spawn raises before any job
173- # exists, so no handle can leak). Children the server spawns before
174- # AssignProcessToJobObject completes land outside the job — membership is
175- # inherited at CreateProcess, never acquired retroactively. If that window
176- # ever bites, the fix is a CREATE_SUSPENDED spawn -> assign -> resume.
125+ # Children spawned before the assignment completes land outside the job
126+ # (membership is inherited at CreateProcess, never acquired retroactively);
127+ # if that ever bites, the fix is a CREATE_SUSPENDED spawn -> assign -> resume.
177128 job = _create_job_object ()
178129 _maybe_assign_process_to_job (process , job )
179130 return process
@@ -186,10 +137,7 @@ async def _create_windows_fallback_process(
186137 errlog : TextIO | None = sys .stderr ,
187138 cwd : Path | str | None = None ,
188139) -> FallbackProcess :
189- """Create a subprocess using subprocess.Popen as a fallback when anyio fails.
190-
191- This function wraps the sync subprocess.Popen in an async-compatible interface.
192- """
140+ """Spawn via subprocess.Popen and wrap it in FallbackProcess."""
193141 popen_obj = subprocess .Popen (
194142 [command , * args ],
195143 stdin = subprocess .PIPE ,
@@ -218,20 +166,15 @@ def _create_job_object() -> object | None:
218166 return job
219167 except pywintypes .error :
220168 logger .warning ("Failed to create Job Object for process tree management" , exc_info = True )
221- # If creation succeeded but configuration failed, close the handle rather
222- # than leaving it to be reclaimed whenever the GC gets to it.
169+ # If creation succeeded but configuration failed, close the handle now.
223170 if job is not None :
224171 _close_job_handle (job )
225172 return None
226173
227174
228175def _maybe_assign_process_to_job (process : Process | FallbackProcess , job : object | None ) -> None :
229- """Try to assign a process to a job object.
230-
231- On success the job is recorded for the process so that
232- `terminate_windows_process_tree` can terminate the whole tree through it.
233- If assignment fails for any reason, the job handle is closed.
234- """
176+ """Assign the process to the job and record it for tree termination; on
177+ any failure the job handle is closed instead."""
235178 if job is None :
236179 return
237180
@@ -249,25 +192,18 @@ def _maybe_assign_process_to_job(process: Process | FallbackProcess, job: object
249192 win32job .AssignProcessToJobObject (job , process_handle )
250193 finally :
251194 win32api .CloseHandle (process_handle )
252- # Recorded only after the CloseHandle above succeeded: had it failed, the
253- # except below would close the job and KILL_ON_JOB_CLOSE would take the
254- # server with it.
195+ # Record only after the CloseHandle above succeeded: had it failed, the
196+ # except below would close the job and KILL_ON_JOB_CLOSE takes the server.
255197 _process_jobs [process ] = job
256198 except pywintypes .error :
257199 logger .warning ("Failed to assign process %d to Job Object" , process .pid , exc_info = True )
258200 _close_job_handle (job )
259201
260202
261203def close_process_job (process : Process | FallbackProcess ) -> None :
262- """Close the process's Job Object handle, if it still has one.
263-
264- The job is created with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`, so closing the
265- handle also kills any job members still alive — deterministically, instead of
266- whenever the GC would have collected the handle. This is a deliberate
267- divergence from POSIX, where a well-behaved server's surviving children are
268- left alive. No-op on POSIX and when no job was assigned (or tree termination
269- already closed it).
270- """
204+ """Close the process's Job Object handle, killing any members still alive
205+ (KILL_ON_JOB_CLOSE) deterministically rather than at GC time; a deliberate
206+ divergence from POSIX, where a graceful server's children are left alive."""
271207 if sys .platform != "win32" :
272208 return
273209
@@ -277,14 +213,9 @@ def close_process_job(process: Process | FallbackProcess) -> None:
277213
278214
279215async def terminate_windows_process_tree (process : Process | FallbackProcess ) -> None :
280- """Terminate a process and all its children on Windows.
281-
282- If the process was assigned to a Job Object at spawn, the job is terminated,
283- which kills every process in it immediately. Otherwise only the process itself
284- is terminated. Both are immediate hard kills: Windows offers no portable
285- equivalent of SIGTERM for a whole tree, so the stdin-close grace period in the
286- client shutdown is the server's opportunity to exit cleanly.
287- """
216+ """Terminate the job (an immediate hard kill of every member), or just the
217+ process if it has no job. Windows has no tree-wide SIGTERM; the stdin-close
218+ grace period is the server's chance to exit cleanly."""
288219 if sys .platform != "win32" :
289220 return
290221
@@ -296,8 +227,7 @@ async def terminate_windows_process_tree(process: Process | FallbackProcess) ->
296227 finally :
297228 _close_job_handle (job )
298229
299- # Terminate the process directly too: it may have no job (creation or
300- # assignment failed), in which case the job path above did nothing.
230+ # The process may have no job (creation or assignment failed); kill it directly too.
301231 try :
302232 process .terminate ()
303233 except OSError :
0 commit comments