Skip to content
Open
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
109 changes: 104 additions & 5 deletions Server/src/transport/legacy/unity_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -715,20 +721,25 @@ 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

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)


# -----------------------------
Expand Down Expand Up @@ -777,6 +788,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, force_refresh=attempt > 0)
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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def send_command_with_retry(
command_type: str,
params: dict[str, Any],
Expand Down Expand Up @@ -806,7 +902,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)
Expand Down
151 changes: 151 additions & 0 deletions Server/tests/test_editor_lifecycle_recovery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
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, *, force_refresh=False):
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, *, force_refresh=False):
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


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",
[
"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
38 changes: 38 additions & 0 deletions docs/plans/2026-05-31-editor-lifecycle-recovery-spec.md
Original file line number Diff line number Diff line change
@@ -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.