Skip to content

Commit 2f04ac8

Browse files
committed
Abandon canceled Windows command resolution
1 parent b66cfad commit 2f04ac8

2 files changed

Lines changed: 45 additions & 2 deletions

File tree

src/mcp/client/stdio.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -320,8 +320,8 @@ def _close_subprocess_transport(process: ServerProcess) -> None:
320320

321321
async def _get_executable_command(command: str) -> str:
322322
"""Normalizes the command for the current platform."""
323-
if sys.platform == "win32": # pragma: no cover
324-
return await anyio.to_thread.run_sync(get_windows_executable_command, command)
323+
if sys.platform == "win32":
324+
return await anyio.to_thread.run_sync(get_windows_executable_command, command, abandon_on_cancel=True)
325325
else: # pragma: lax no cover
326326
return command
327327

tests/client/test_stdio.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,18 @@
1414
import os
1515
import signal
1616
import sys
17+
import threading
1718
from collections.abc import Callable
1819
from contextlib import AsyncExitStack, suppress
1920
from pathlib import Path
21+
from types import SimpleNamespace
2022
from typing import TextIO, cast
2123

2224
import anyio
2325
import anyio.abc
26+
import anyio.from_thread
2427
import anyio.lowlevel
28+
import anyio.to_thread
2529
import pytest
2630
import trio
2731
import trio.testing
@@ -572,6 +576,45 @@ async def test_a_command_that_cannot_be_execed_raises_enoent() -> None:
572576
assert exc_info.value.errno == errno.ENOENT
573577

574578

579+
@pytest.mark.anyio
580+
async def test_cancellation_during_windows_command_resolution_returns_before_resolution_finishes(
581+
monkeypatch: pytest.MonkeyPatch,
582+
) -> None:
583+
"""Cancelling `stdio_client` does not wait for blocked Windows command resolution."""
584+
resolution_started = anyio.Event()
585+
resolution_release = threading.Event()
586+
resolution_finished = threading.Event()
587+
588+
def blocking_resolver(command: str) -> str:
589+
anyio.from_thread.run_sync(resolution_started.set)
590+
resolution_release.wait()
591+
resolution_finished.set()
592+
return command
593+
594+
monkeypatch.setattr(stdio, "sys", SimpleNamespace(platform="win32"))
595+
monkeypatch.setattr(stdio, "get_windows_executable_command", blocking_resolver)
596+
597+
cancel_scope = anyio.CancelScope()
598+
client_stopped = anyio.Event()
599+
600+
async def run_client() -> None:
601+
with cancel_scope:
602+
async with AsyncExitStack() as stack:
603+
await stack.enter_async_context(stdio_client(FAKE_PARAMS))
604+
client_stopped.set()
605+
606+
with anyio.fail_after(5):
607+
async with anyio.create_task_group() as tg:
608+
tg.start_soon(run_client)
609+
await resolution_started.wait()
610+
cancel_scope.cancel()
611+
try:
612+
await client_stopped.wait()
613+
finally:
614+
resolution_release.set()
615+
await anyio.to_thread.run_sync(resolution_finished.wait)
616+
617+
575618
@pytest.mark.anyio
576619
async def test_cancellation_during_spawn_leaks_no_streams(monkeypatch: pytest.MonkeyPatch) -> None:
577620
"""Cancellation while the spawn is still in flight must not leak the internal streams.

0 commit comments

Comments
 (0)