diff --git a/MCPForUnity/package.json b/MCPForUnity/package.json index 75275ccd1..515d2e02d 100644 --- a/MCPForUnity/package.json +++ b/MCPForUnity/package.json @@ -1,6 +1,6 @@ { "name": "com.coplaydev.unity-mcp", - "version": "9.4.5-beta.1", + "version": "9.4.5-beta.2", "displayName": "MCP for Unity", "description": "A bridge that connects AI assistants to Unity via the MCP (Model Context Protocol). Allows AI clients like Claude Code, Cursor, and VSCode to directly control your Unity Editor for enhanced development workflows.\n\nFeatures automated setup wizard, cross-platform support, and seamless integration with popular AI development tools.\n\nJoin Our Discord: https://discord.gg/y4p8KfzrN4", "unity": "2021.3", diff --git a/Server/src/main.py b/Server/src/main.py index 59855f29d..c476e7a07 100644 --- a/Server/src/main.py +++ b/Server/src/main.py @@ -21,12 +21,52 @@ import asyncio import logging from contextlib import asynccontextmanager +from functools import partial import os +import sys import threading import time -from typing import AsyncIterator, Any +from typing import AsyncIterator, Any, Callable, Literal from urllib.parse import urlparse - +import anyio + +# Keep this local to avoid importing FastMCP internal modules just for typing. +Transport = Literal["stdio", "http", "sse", "streamable-http"] + +# Windows asyncio fix: Use SelectorEventLoop instead of ProactorEventLoop +# This fixes "WinError 64: The specified network name is no longer available" +# which occurs with WebSocket connections under heavy client reconnect scenarios. +# +# Set UNITY_MCP_ASYNCIO_POLICY=proactor to override and use ProactorEventLoop. +def _get_asyncio_loop_factory() -> Callable[[], asyncio.AbstractEventLoop] | None: + if sys.platform != "win32": + return None + + policy = os.getenv("UNITY_MCP_ASYNCIO_POLICY", "selector").lower() + if policy == "selector": + return asyncio.SelectorEventLoop + + return None + + +def _run_mcp(mcp: "FastMCP", transport: Transport, **transport_kwargs: Any) -> None: + """Run FastMCP with an optional Windows-specific loop factory.""" + loop_factory = _get_asyncio_loop_factory() + if loop_factory is None: + mcp.run(transport=transport, **transport_kwargs) + return + + anyio.run( + partial( + mcp.run_async, + transport, + show_banner=True, + **transport_kwargs, + ), + backend_options={"loop_factory": loop_factory}, + ) + + # Workaround for environments where tool signature evaluation runs with a globals # dict that does not include common `typing` names (e.g. when annotations are strings # and evaluated via `eval()` during schema generation). @@ -825,7 +865,7 @@ def main(): # Determine transport mode if config.transport_mode == 'http': # Use HTTP transport for FastMCP - transport = 'http' + transport: Transport = "http" # Use the parsed host and port from URL/args http_url = os.environ.get("UNITY_MCP_HTTP_URL", args.http_url) parsed_url = urlparse(http_url) @@ -833,11 +873,12 @@ def main(): "UNITY_MCP_HTTP_HOST") or parsed_url.hostname or "localhost" port = args.http_port or _env_port or parsed_url.port or 8080 logger.info(f"Starting FastMCP with HTTP transport on {host}:{port}") - mcp.run(transport=transport, host=host, port=port) + _run_mcp(mcp, transport=transport, host=host, port=port) else: # Use stdio transport for traditional MCP logger.info("Starting FastMCP with stdio transport") - mcp.run(transport='stdio') + transport: Transport = "stdio" + _run_mcp(mcp, transport=transport) # Run the server diff --git a/Server/tests/test_event_loop_policy.py b/Server/tests/test_event_loop_policy.py new file mode 100644 index 000000000..b9d5e0fea --- /dev/null +++ b/Server/tests/test_event_loop_policy.py @@ -0,0 +1,161 @@ +""" +Test event loop configuration for Windows. + +This module verifies that the correct asyncio loop factory is selected +on different platforms to prevent WinError 64 on Windows. + +WinError 64 occurs when using ProactorEventLoop with concurrent +WebSocket and HTTP connections. The fix is to use SelectorEventLoop +on Windows. + +Related fix: Server/src/main.py +""" + +import sys +import asyncio +from functools import partial +import pytest + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific") +def test_windows_uses_selector_event_loop_factory(): + """ + Verify that Windows uses SelectorEventLoop via loop_factory. + + This prevents WinError 64 when handling concurrent WebSocket and HTTP connections. + + Regression test for Windows asyncio bug where ProactorEventLoop's IOCP + has race conditions with rapid connection changes. + + The fix is applied in Server/src/main.py + """ + import importlib + import main # type: ignore[import] - conftest.py adds src to sys.path + importlib.reload(main) + + loop_factory = main._get_asyncio_loop_factory() + assert loop_factory is asyncio.SelectorEventLoop, ( + "Expected SelectorEventLoop for Windows loop_factory" + ) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Non-Windows only") +def test_non_windows_uses_default_loop_factory(): + """ + Verify that non-Windows platforms keep their default event loop behavior. + + SelectorEventLoop should only be used on Windows to avoid the IOCP bug. + Other platforms should use their optimal default event loop. + """ + import importlib + import main # type: ignore[import] - conftest.py adds src to sys.path + importlib.reload(main) + + loop_factory = main._get_asyncio_loop_factory() + assert loop_factory is None, "Non-Windows platforms should not set a loop_factory" + + +@pytest.mark.asyncio +async def test_async_operations_use_correct_event_loop(): + """ + Smoke test to verify async operations work with the configured event loop. + + This test creates a simple async operation to ensure the event loop + is functional. It doesn't test WinError 64 directly (which is a + timing-dependent race condition), but confirms the basic async + infrastructure works with the policy configured in main.py. + """ + # Import main to ensure the event loop policy is configured + import importlib + import main # type: ignore[import] - conftest.py adds src to sys.path + importlib.reload(main) + + # Simple async operation + async def simple_task(): + await asyncio.sleep(0.01) + return "success" + + # Should complete without errors + result = await simple_task() + assert result == "success" + + # Verify we're using the expected event loop + # Use get_running_loop() as we're in an async context + loop = asyncio.get_running_loop() + assert loop is not None, "Event loop should be running" + assert loop.is_running(), "Event loop should be in running state" + + +def test_run_mcp_uses_fastmcp_run_when_no_loop_factory(monkeypatch: pytest.MonkeyPatch): + """When no loop factory is needed, _run_mcp should delegate to FastMCP.run.""" + import importlib + import main # type: ignore[import] - conftest.py adds src to sys.path + importlib.reload(main) + + class DummyMCP: + def __init__(self) -> None: + self.run_called = False + self.run_kwargs = {} + + def run(self, **kwargs): + self.run_called = True + self.run_kwargs = kwargs + + monkeypatch.setattr(main, "_get_asyncio_loop_factory", lambda: None) + + mcp = DummyMCP() + main._run_mcp(mcp, transport="stdio") + + assert mcp.run_called + assert mcp.run_kwargs["transport"] == "stdio" + + +def test_run_mcp_uses_anyio_with_loop_factory(monkeypatch: pytest.MonkeyPatch): + """When loop factory exists, _run_mcp should use anyio.run with backend options.""" + import importlib + import main # type: ignore[import] - conftest.py adds src to sys.path + importlib.reload(main) + + class DummyMCP: + async def run_async(self, transport, show_banner=True, **kwargs): + return None + + captured = {} + + def fake_anyio_run(func, *args, **kwargs): + captured["func"] = func + captured["args"] = args + captured["kwargs"] = kwargs + return None + + monkeypatch.setattr(main, "anyio", type("AnyIOStub", (), {"run": staticmethod(fake_anyio_run)})) + monkeypatch.setattr(main, "_get_asyncio_loop_factory", lambda: asyncio.SelectorEventLoop) + + mcp = DummyMCP() + main._run_mcp(mcp, transport="http", host="localhost", port=8080) + + assert isinstance(captured["func"], partial) + assert captured["func"].args[0] == "http" + assert captured["func"].keywords["show_banner"] is True + assert captured["func"].keywords["host"] == "localhost" + assert captured["func"].keywords["port"] == 8080 + assert captured["kwargs"]["backend_options"]["loop_factory"] is asyncio.SelectorEventLoop + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific") +def test_windows_loop_factory_prevents_proactor(): + """ + Verify that Windows loop_factory explicitly avoids ProactorEventLoop. + + ProactorEventLoop uses IOCP which has known issues with WinError 64 + when handling rapid connection changes. This test confirms we're + not using ProactorEventLoop. + """ + import importlib + import main # type: ignore[import] - conftest.py adds src to sys.path + importlib.reload(main) + + loop_factory = main._get_asyncio_loop_factory() + assert loop_factory is asyncio.SelectorEventLoop, ( + "SelectorEventLoop should be used on Windows (prevents WinError 64)" + )