Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions Server/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import logging
from contextlib import asynccontextmanager
import os
import sys
import threading
import time
from typing import AsyncIterator, Any
Expand Down Expand Up @@ -159,6 +160,37 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[dict[str, Any]]:
loop = asyncio.get_running_loop()
PluginHub.configure(_plugin_registry, loop)

# Set up event loop exception handler for Windows socket errors
# On Windows, OSError [WinError 64] can occur in asyncio IOCP proactor
# when clients disconnect during WebSocket accept. We suppress the ERROR
# log spam but still allow the exception to propagate normally so the
# WebSocket connection fails cleanly and Unity can reconnect.
original_handler = loop.get_exception_handler()
def _asyncio_exception_handler(loop: asyncio.AbstractEventLoop, context: dict[str, Any]) -> None:
exception = context.get("exception")
if isinstance(exception, OSError):
winerror = getattr(exception, "winerror", None)
if sys.platform == 'win32' and winerror == 64:
# Suppress the ERROR log for this common Windows socket error
# The exception will still propagate, failing the WebSocket cleanly
logger.debug(
f"[DEBUG] Asyncio: OSError WinError 64 during {context.get('task', 'operation')} "
"(normal during Unity domain reloads, connection will fail cleanly)"
)
# Don't call default handler to avoid ERROR log
# Exception still propagates to the caller
return
# For all other exceptions, use the original handler
logger.info(f"[DEBUG] Asyncio exception handler: exception={type(exception).__name__ if exception else 'None'}, "
f"task={context.get('task', 'unknown')}")
if original_handler:
original_handler(loop, context)
else:
loop.default_exception_handler(context)

loop.set_exception_handler(_asyncio_exception_handler)
logger.info("[DEBUG] Registered asyncio exception handler for Windows socket errors")

# Record server startup telemetry
start_time = time.time()
start_clk = time.perf_counter()
Expand Down Expand Up @@ -700,11 +732,12 @@ def main():
host = args.http_host or os.environ.get(
"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}")
logger.info(f"[DEBUG] Starting FastMCP with HTTP transport on {host}:{port}")
logger.info(f"[DEBUG] WebSocket hub endpoint will be at ws://{host}:{port}/hub/plugin")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security (javascript.lang.security.detect-insecure-websocket): Insecure WebSocket Detected. WebSocket Secure (wss) should be used for all WebSocket connections.

Source: opengrep

mcp.run(transport=transport, host=host, port=port)
else:
# Use stdio transport for traditional MCP
logger.info("Starting FastMCP with stdio transport")
logger.info("[DEBUG] Starting FastMCP with stdio transport")
mcp.run(transport='stdio')


Expand Down
26 changes: 24 additions & 2 deletions Server/src/transport/plugin_hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import asyncio
import logging
import os
import sys
import time
import uuid
from typing import Any
Expand Down Expand Up @@ -77,12 +78,26 @@ def is_configured(cls) -> bool:
return cls._registry is not None and cls._lock is not None

async def on_connect(self, websocket: WebSocket) -> None:
"""Handle incoming WebSocket connection.

Note: On Windows, OSError [WinError 64] can occur when Unity disconnects
during domain reload while we're trying to accept the connection.
This is handled by the asyncio event loop exception handler in main.py
to prevent the error from crashing the server. The connection will fail
normally and Unity will attempt to reconnect.
"""
client_host = websocket.client.host if websocket.client else "unknown"
logger.info(f"[DEBUG] on_connect called: client={client_host}")

await websocket.accept()
logger.info(f"[DEBUG] WebSocket accept() successful for client={client_host}")

msg = WelcomeMessage(
serverTimeout=self.SERVER_TIMEOUT,
keepAliveInterval=self.KEEP_ALIVE_INTERVAL,
)
await websocket.send_json(msg.model_dump())
logger.info(f"[DEBUG] WelcomeMessage sent to client={client_host}")

async def on_receive(self, websocket: WebSocket, data: Any) -> None:
if not isinstance(data, dict):
Expand All @@ -107,12 +122,15 @@ async def on_receive(self, websocket: WebSocket, data: Any) -> None:
async def on_disconnect(self, websocket: WebSocket, close_code: int) -> None:
cls = type(self)
lock = cls._lock
logger.info(f"[DEBUG] on_disconnect called: close_code={close_code}")
if lock is None:
logger.warning("[DEBUG] on_disconnect: lock is None, returning")
return
async with lock:
session_id = next(
(sid for sid, ws in cls._connections.items() if ws is websocket), None)
if session_id:
logger.info(f"[DEBUG] on_disconnect: removing session {session_id}")
cls._connections.pop(session_id, None)
# Fail-fast any in-flight commands for this session to avoid waiting for COMMAND_TIMEOUT.
pending_ids = [
Expand All @@ -133,7 +151,8 @@ async def on_disconnect(self, websocket: WebSocket, close_code: int) -> None:
if cls._registry:
await cls._registry.unregister(session_id)
logger.info(
f"Plugin session {session_id} disconnected ({close_code})")
f"[DEBUG] Plugin session {session_id} disconnected ({close_code}), "
f"remaining sessions: {len(cls._connections)}")

# ------------------------------------------------------------------
# Public API
Expand Down Expand Up @@ -272,6 +291,7 @@ async def _handle_register(self, websocket: WebSocket, payload: RegisterMessage)
cls = type(self)
registry = cls._registry
lock = cls._lock
logger.info(f"[DEBUG] _handle_register called: project_name={payload.project_name}")
if registry is None or lock is None:
await websocket.close(code=1011)
raise RuntimeError("PluginHub not configured")
Expand All @@ -286,14 +306,16 @@ async def _handle_register(self, websocket: WebSocket, payload: RegisterMessage)
"Plugin registration missing project_hash")

session_id = str(uuid.uuid4())
logger.info(f"[DEBUG] Generated session_id={session_id} for {project_name}")
# Inform the plugin of its assigned session ID
response = RegisteredMessage(session_id=session_id)
await websocket.send_json(response.model_dump())

session = await registry.register(session_id, project_name, project_hash, unity_version)
async with lock:
cls._connections[session.session_id] = websocket
logger.info(f"Plugin registered: {project_name} ({project_hash})")
logger.info(f"[DEBUG] Plugin registered: {project_name} ({project_hash}), session={session_id}, "
f"total connections={len(cls._connections)}")

async def _handle_register_tools(self, websocket: WebSocket, payload: RegisterToolsMessage) -> None:
cls = type(self)
Expand Down