From 2f500437f8591e068e4b5ea9513de221ff1f0fb1 Mon Sep 17 00:00:00 2001 From: ZeroGameStudio <837757433@qq.com> Date: Sun, 31 May 2026 13:08:13 +0800 Subject: [PATCH 1/2] Handle Unity editor offline reconnects --- .../src/transport/legacy/unity_connection.py | 90 ++++++++++++++++++- .../tests/test_editor_lifecycle_recovery.py | 81 +++++++++++++++++ ...26-05-31-editor-lifecycle-recovery-spec.md | 38 ++++++++ 3 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 Server/tests/test_editor_lifecycle_recovery.py create mode 100644 docs/plans/2026-05-31-editor-lifecycle-recovery-spec.md diff --git a/Server/src/transport/legacy/unity_connection.py b/Server/src/transport/legacy/unity_connection.py index 1d703a862..95184116c 100644 --- a/Server/src/transport/legacy/unity_connection.py +++ b/Server/src/transport/legacy/unity_connection.py @@ -777,6 +777,91 @@ def _is_reloading_response(resp: object) -> bool: return _extract_response_reason(resp) == "reloading" +def _is_editor_offline_error(exc: BaseException) -> bool: + """Return True for connection failures that mean the Unity Editor is offline.""" + text = str(exc).lower() + if not text: + return False + + offline_markers = ( + "no unity editor instances found", + "failed to connect to unity instance", + "could not connect to unity", + "connection refused", + "actively refused", + "no connection could be made", + ) + return any(marker in text for marker in offline_markers) + + +def _editor_offline_response(detail: str, retry_after_ms: int = 1000) -> MCPResponse: + return MCPResponse( + success=False, + error="editor_offline", + message="Unity Editor is offline or the MCP bridge is not ready.", + hint="retry", + data={ + "reason": "editor_offline", + "retry_after_ms": int(retry_after_ms), + "detail": detail, + }, + ) + + +def _get_editor_reconnect_max_wait_s() -> float: + raw_value = os.environ.get("UNITY_MCP_EDITOR_RECONNECT_MAX_WAIT_S", "2.0") + try: + wait_s = float(raw_value) + except ValueError: + logger.warning( + "Invalid UNITY_MCP_EDITOR_RECONNECT_MAX_WAIT_S=%r, using default 2.0", + raw_value, + ) + wait_s = 2.0 + return max(0.0, min(wait_s, 30.0)) + + +def _get_connection_with_editor_reconnect( + instance_id: str | None, + command_type: str, +) -> UnityConnection | MCPResponse: + """Resolve a Unity connection, waiting briefly for editor restart recovery.""" + max_wait_s = _get_editor_reconnect_max_wait_s() + deadline = time.monotonic() + max_wait_s + last_error: BaseException | None = None + attempt = 0 + + while True: + try: + return get_unity_connection(instance_id) + except Exception as exc: + if not _is_editor_offline_error(exc): + raise + + last_error = exc + now = time.monotonic() + if max_wait_s <= 0 or now >= deadline: + logger.info( + "Unity editor offline for command=%s instance=%s: %s", + command_type, + instance_id or "default", + exc, + ) + return _editor_offline_response(str(last_error)) + + attempt += 1 + remaining_s = max(0.0, deadline - now) + sleep_s = min(remaining_s, 0.25 * (2 ** min(attempt - 1, 2))) + logger.debug( + "Unity editor offline; waiting %.3fs before reconnect attempt %d for command=%s instance=%s", + sleep_s, + attempt + 1, + command_type, + instance_id or "default", + ) + time.sleep(sleep_s) + + def send_command_with_retry( command_type: str, params: dict[str, Any], @@ -806,7 +891,10 @@ def send_command_with_retry( t_retry_start = time.time() logger.info("[TIMING-STDIO] send_command_with_retry START command=%s", command_type) t_get_conn = time.time() - conn = get_unity_connection(instance_id) + conn_or_response = _get_connection_with_editor_reconnect(instance_id, command_type) + if isinstance(conn_or_response, MCPResponse): + return conn_or_response + conn = conn_or_response logger.info("[TIMING-STDIO] get_unity_connection took %.3fs command=%s", time.time() - t_get_conn, command_type) if max_retries is None: max_retries = getattr(config, "reload_max_retries", 40) diff --git a/Server/tests/test_editor_lifecycle_recovery.py b/Server/tests/test_editor_lifecycle_recovery.py new file mode 100644 index 000000000..d336a0dc8 --- /dev/null +++ b/Server/tests/test_editor_lifecycle_recovery.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import pytest + + +def test_send_command_returns_structured_offline_when_no_editor(monkeypatch): + from transport.legacy import unity_connection as mod + + def missing_connection(_instance_id=None): + raise ConnectionError( + "No Unity Editor instances found. Please ensure Unity is running with MCP for Unity bridge." + ) + + monkeypatch.setenv("UNITY_MCP_EDITOR_RECONNECT_MAX_WAIT_S", "0") + monkeypatch.setattr(mod, "get_unity_connection", missing_connection) + + result = mod.send_command_with_retry("read_console", {}, instance_id="POB@abc") + + assert result.success is False + assert result.error == "editor_offline" + assert result.hint == "retry" + assert result.data["reason"] == "editor_offline" + assert result.data["retry_after_ms"] == 1000 + + +def test_send_command_retries_until_editor_returns(monkeypatch): + from transport.legacy import unity_connection as mod + + calls = {"count": 0, "sleep": 0} + + class FakeConnection: + def send_command(self, command_type, params, max_attempts=None): + return { + "success": True, + "data": { + "command_type": command_type, + "params": params, + "max_attempts": max_attempts, + }, + } + + def reconnecting_connection(_instance_id=None): + calls["count"] += 1 + if calls["count"] == 1: + raise ConnectionError("Failed to connect to Unity instance 'POB@abc' on port 6400.") + return FakeConnection() + + def fake_sleep(seconds): + calls["sleep"] += 1 + + now = {"value": 0.0} + + def fake_monotonic(): + now["value"] += 0.1 + return now["value"] + + monkeypatch.setenv("UNITY_MCP_EDITOR_RECONNECT_MAX_WAIT_S", "5") + monkeypatch.setattr(mod, "get_unity_connection", reconnecting_connection) + monkeypatch.setattr(mod.time, "sleep", fake_sleep) + monkeypatch.setattr(mod.time, "monotonic", fake_monotonic) + + result = mod.send_command_with_retry("read_console", {"action": "get"}, instance_id="POB@abc") + + assert result["success"] is True + assert calls["count"] == 2 + assert calls["sleep"] == 1 + + +@pytest.mark.parametrize( + "message", + [ + "No Unity Editor instances found.", + "Failed to connect to Unity instance 'POB@abc' on port 6400.", + "Could not connect to Unity", + "[WinError 10061] No connection could be made because the target machine actively refused it", + ], +) +def test_editor_offline_error_detection(message): + from transport.legacy.unity_connection import _is_editor_offline_error + + assert _is_editor_offline_error(ConnectionError(message)) is True diff --git a/docs/plans/2026-05-31-editor-lifecycle-recovery-spec.md b/docs/plans/2026-05-31-editor-lifecycle-recovery-spec.md new file mode 100644 index 000000000..567393df4 --- /dev/null +++ b/docs/plans/2026-05-31-editor-lifecycle-recovery-spec.md @@ -0,0 +1,38 @@ +# Editor Lifecycle Recovery Spec + +## Goal + +Keep the MCP server usable when the Unity Editor closes, reloads, or restarts. +Tool calls should return structured retryable state or recover when the editor +comes back quickly, instead of surfacing opaque connection failures. + +## Scope + +This first PR targets stdio/legacy Unity socket transport because that is the +path used by common desktop clients. HTTP keep-running behavior already exists +separately and should not be rewritten here. + +## Behavior + +- If no Unity Editor instance can be discovered, return `success=false`, + `error=editor_offline`, `hint=retry`, and `data.reason=editor_offline`. +- If a Unity instance is discovered but the socket connection is refused, + return the same structured offline response. +- Before returning offline, wait for a short bounded reconnect window so a tool + call made during editor restart can complete if the bridge comes back. +- The reconnect window is configurable with + `UNITY_MCP_EDITOR_RECONNECT_MAX_WAIT_S`. +- Existing domain-reload `reloading` handling remains unchanged. + +## Non-goals + +- Do not keep a Unity-side bridge alive after the Unity Editor process exits. +- Do not redesign the HTTP plugin hub. +- Do not add a full command queue or multi-agent gateway in this PR. + +## Verification + +- Add Python tests for offline response classification. +- Add Python tests proving `send_command_with_retry` retries connection lookup + during the reconnect window. +- Run the focused Python test file. From 7b7231d929db91ed3a02d21cc509ea4409171e48 Mon Sep 17 00:00:00 2001 From: ZeroGameStudio <837757433@qq.com> Date: Mon, 1 Jun 2026 14:36:36 +0800 Subject: [PATCH 2/2] Refresh Unity discovery during editor reconnect --- .../src/transport/legacy/unity_connection.py | 21 ++++-- .../tests/test_editor_lifecycle_recovery.py | 74 ++++++++++++++++++- 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/Server/src/transport/legacy/unity_connection.py b/Server/src/transport/legacy/unity_connection.py index 95184116c..7368c2938 100644 --- a/Server/src/transport/legacy/unity_connection.py +++ b/Server/src/transport/legacy/unity_connection.py @@ -636,13 +636,19 @@ def _resolve_instance_id(self, instance_identifier: str | None, instances: list[ f"Check mcpforunity://instances resource for all instances." ) - def get_connection(self, instance_identifier: str | None = None) -> UnityConnection: + def get_connection( + self, + instance_identifier: str | None = None, + *, + force_refresh: bool = False, + ) -> UnityConnection: """ Get or create a connection to a Unity instance. Args: instance_identifier: Optional identifier (name, hash, name@hash, etc.) If None, uses default or most recent instance + force_refresh: If True, bypasses the cached discovery result Returns: UnityConnection to the specified instance @@ -651,7 +657,7 @@ def get_connection(self, instance_identifier: str | None = None) -> UnityConnect ConnectionError: If instance cannot be found or connected """ # Refresh instance list if cache expired - instances = self.discover_all_instances() + instances = self.discover_all_instances(force_refresh=force_refresh) # Resolve identifier to specific instance target = self._resolve_instance_id(instance_identifier, instances) @@ -715,12 +721,17 @@ def get_unity_connection_pool() -> UnityConnectionPool: # Backwards compatibility: keep old single-connection function -def get_unity_connection(instance_identifier: str | None = None) -> UnityConnection: +def get_unity_connection( + instance_identifier: str | None = None, + *, + force_refresh: bool = False, +) -> UnityConnection: """Retrieve or establish a Unity connection. Args: instance_identifier: Optional identifier for specific Unity instance. If None, uses default or most recent instance. + force_refresh: If True, bypasses the cached discovery result. Returns: UnityConnection to the specified or default Unity instance @@ -728,7 +739,7 @@ def get_unity_connection(instance_identifier: str | None = None) -> UnityConnect Note: This function now uses the connection pool internally. """ pool = get_unity_connection_pool() - return pool.get_connection(instance_identifier) + return pool.get_connection(instance_identifier, force_refresh=force_refresh) # ----------------------------- @@ -833,7 +844,7 @@ def _get_connection_with_editor_reconnect( while True: try: - return get_unity_connection(instance_id) + return get_unity_connection(instance_id, force_refresh=attempt > 0) except Exception as exc: if not _is_editor_offline_error(exc): raise diff --git a/Server/tests/test_editor_lifecycle_recovery.py b/Server/tests/test_editor_lifecycle_recovery.py index d336a0dc8..02a45af8f 100644 --- a/Server/tests/test_editor_lifecycle_recovery.py +++ b/Server/tests/test_editor_lifecycle_recovery.py @@ -1,12 +1,14 @@ from __future__ import annotations +from datetime import datetime, timezone + import pytest def test_send_command_returns_structured_offline_when_no_editor(monkeypatch): from transport.legacy import unity_connection as mod - def missing_connection(_instance_id=None): + def missing_connection(_instance_id=None, *, force_refresh=False): raise ConnectionError( "No Unity Editor instances found. Please ensure Unity is running with MCP for Unity bridge." ) @@ -39,7 +41,7 @@ def send_command(self, command_type, params, max_attempts=None): }, } - def reconnecting_connection(_instance_id=None): + def reconnecting_connection(_instance_id=None, *, force_refresh=False): calls["count"] += 1 if calls["count"] == 1: raise ConnectionError("Failed to connect to Unity instance 'POB@abc' on port 6400.") @@ -66,6 +68,74 @@ def fake_monotonic(): assert calls["sleep"] == 1 +def test_reconnect_refreshes_discovery_cache_when_editor_returns(monkeypatch): + from models.models import UnityInstanceInfo + from transport.legacy import unity_connection as mod + + pool = mod.UnityConnectionPool() + pool._known_instances = {} + pool._last_full_scan = mod.time.time() + + instance = UnityInstanceInfo( + id="POB@abc", + name="POB", + path="D:/unity/projects/POB", + hash="abc", + port=6400, + status="running", + last_heartbeat=datetime.now(timezone.utc), + ) + + calls = {"discover": 0, "connect": 0, "sleep": 0} + + def fake_discover_all(): + calls["discover"] += 1 + return [instance] + + def fake_connect(self): + calls["connect"] += 1 + return True + + def fake_send_command(self, command_type, params, max_attempts=None): + return { + "success": True, + "data": { + "command_type": command_type, + "instance_id": self.instance_id, + "params": params, + "max_attempts": max_attempts, + }, + } + + now = {"value": -0.1} + + def fake_monotonic(): + now["value"] += 0.1 + return now["value"] + + def fake_sleep(_seconds): + calls["sleep"] += 1 + + monkeypatch.setattr(mod, "_unity_connection_pool", pool) + monkeypatch.setattr( + mod.PortDiscovery, "discover_all_unity_instances", staticmethod(fake_discover_all) + ) + monkeypatch.setattr(mod.UnityConnection, "connect", fake_connect) + monkeypatch.setattr(mod.UnityConnection, "send_command", fake_send_command) + monkeypatch.setattr(mod.time, "monotonic", fake_monotonic) + monkeypatch.setattr(mod.time, "sleep", fake_sleep) + monkeypatch.setenv("UNITY_MCP_EDITOR_RECONNECT_MAX_WAIT_S", "0.5") + + result = mod.send_command_with_retry( + "read_console", {"action": "get"}, instance_id="POB@abc" + ) + + assert isinstance(result, dict) + assert result["success"] is True + assert result["data"]["instance_id"] == "POB@abc" + assert calls == {"discover": 1, "connect": 1, "sleep": 1} + + @pytest.mark.parametrize( "message", [