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..53a0a924e --- /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 Codex and 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.