Skip to content

Commit 7adb379

Browse files
committed
Slim comments and docstrings across the stdio surface
Adopt a uniform comment standard for the stdio transport and its tests: PEP 257 one-line summaries, docstring bodies only for contracts or for warning-of-consequences notes (why the obvious alternative is wrong), inline comments at most two lines, no Args/Returns boilerplate on private helpers, ASCII punctuation only. Facts guarding non-obvious OS behavior (killpg/EPERM semantics, the pipe-gated process.wait(), the Windows stdin-pipe interpreter freeze, CPython gh-106749) are kept in compressed form; pure narration is deleted. No code changes outside comments and docstrings.
1 parent 0f2c132 commit 7adb379

12 files changed

Lines changed: 399 additions & 739 deletions

File tree

src/mcp/client/stdio.py

Lines changed: 53 additions & 134 deletions
Large diffs are not rendered by default.

src/mcp/os/posix/utilities.py

Lines changed: 19 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,67 +10,54 @@
1010

1111
logger = logging.getLogger(__name__)
1212

13-
# How often to probe for surviving process-group members between SIGTERM and SIGKILL.
13+
# How often to probe for surviving group members between SIGTERM and SIGKILL.
1414
_GROUP_POLL_INTERVAL = 0.01
1515

1616

1717
async def terminate_posix_process_tree(process: Process, timeout_seconds: float = 2.0) -> None:
18-
"""Terminate a process and all its descendants on POSIX systems.
19-
20-
The process was spawned with `start_new_session=True`, so it leads its own
21-
process group and `os.killpg` reaches every descendant in one atomic call,
22-
even those whose parent (including the leader itself) has already exited.
23-
Sends SIGTERM to the group, waits up to `timeout_seconds` for the group to
24-
disappear, then SIGKILLs whatever remains.
25-
26-
Descendants that move themselves into a new session or process group
27-
(daemonizers) escape a group kill by design. A group only disappears once
28-
every member is dead *and reaped*: a client running as PID 1 without reaping
29-
orphans keeps zombie descendants in the group and makes the wait below run
30-
its full timeout (run such clients under an init shim, e.g. `docker run
31-
--init`).
18+
"""SIGTERM the process group, wait up to timeout_seconds for it to
19+
disappear, then SIGKILL whatever remains.
20+
21+
killpg reaches every descendant atomically, even ones whose parent already
22+
exited; daemonizers that left the group escape by design. A group only
23+
disappears once every member is dead and reaped, so a client running as
24+
PID 1 should reap orphans (e.g. docker run --init) or the wait below runs
25+
its full timeout.
3226
"""
33-
# start_new_session=True makes the leader's pid the pgid. Never ask via
34-
# getpgid(): it fails once the leader is reaped, even with live members left.
27+
# The leader's pid is the pgid (start_new_session). Never use getpgid():
28+
# it fails once the leader is reaped, even with live members left.
3529
pgid = process.pid
3630

3731
try:
3832
os.killpg(pgid, signal.SIGTERM)
3933
except ProcessLookupError:
40-
return # the entire group is already gone
34+
return # the whole group is already gone
4135
except PermissionError:
42-
# EPERM never proves the group is gone (Linux: every member denied but
43-
# alive; macOS: one foreign-euid or zombie member is enough, the rest
44-
# were signalled), so keep waiting and escalating — both tolerate it.
36+
# EPERM never proves the group is gone (macOS raises it for zombie or
37+
# foreign-euid members), so keep waiting and escalating.
4538
logger.warning(
4639
"No permission to signal some of process group %d; waiting for it to exit anyway", pgid, exc_info=True
4740
)
4841

4942
with anyio.move_on_after(timeout_seconds):
5043
while _group_alive(pgid):
51-
# Reading `returncode` reaps the leader on trio (the property calls
52-
# Popen.poll()); without it the leader's zombie would keep the group
53-
# alive for the full timeout. On asyncio it is a cheap attribute read.
44+
# Reading returncode reaps the leader on trio; a zombie leader would
45+
# otherwise keep the group alive for the full timeout.
5446
_ = process.returncode
5547
await anyio.sleep(_GROUP_POLL_INTERVAL)
5648
return
5749

50+
# ESRCH: died since the last probe. EPERM: we killed what we were allowed to.
5851
with suppress(ProcessLookupError, PermissionError):
59-
# ESRCH: the group died between the last probe and now. EPERM: whatever
60-
# the platform let us signal has been KILLed; the rest is not ours to touch.
6152
os.killpg(pgid, signal.SIGKILL)
6253

6354

6455
def _group_alive(pgid: int) -> bool:
65-
"""Probe the group with signal 0. Only ESRCH proves it is gone: the probe
66-
keeps succeeding while live members or unreaped zombies remain, and EPERM
67-
is ambiguous on every platform."""
56+
"""Probe the group with signal 0; only ESRCH proves it is gone."""
6857
try:
6958
os.killpg(pgid, 0)
7059
except ProcessLookupError:
7160
return False
7261
except PermissionError:
73-
# Unsignalable survivors or zombies; keep waiting — reaping turns an
74-
# all-zombie group into ESRCH, and survivors may yet exit on their own.
75-
pass
62+
pass # unsignalable survivors or unreaped zombies; EPERM is ambiguous
7663
return True

src/mcp/os/win32/utilities.py

Lines changed: 34 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -28,59 +28,36 @@
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

4541
def 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

7658
class 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

228175
def _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

261203
def 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

279215
async 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

Comments
 (0)