Skip to content

Commit b7da729

Browse files
committed
Handle Unity editor offline reconnects
1 parent 2d2bdc5 commit b7da729

3 files changed

Lines changed: 208 additions & 1 deletion

File tree

Server/src/transport/legacy/unity_connection.py

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -777,6 +777,91 @@ def _is_reloading_response(resp: object) -> bool:
777777
return _extract_response_reason(resp) == "reloading"
778778

779779

780+
def _is_editor_offline_error(exc: BaseException) -> bool:
781+
"""Return True for connection failures that mean the Unity Editor is offline."""
782+
text = str(exc).lower()
783+
if not text:
784+
return False
785+
786+
offline_markers = (
787+
"no unity editor instances found",
788+
"failed to connect to unity instance",
789+
"could not connect to unity",
790+
"connection refused",
791+
"actively refused",
792+
"no connection could be made",
793+
)
794+
return any(marker in text for marker in offline_markers)
795+
796+
797+
def _editor_offline_response(detail: str, retry_after_ms: int = 1000) -> MCPResponse:
798+
return MCPResponse(
799+
success=False,
800+
error="editor_offline",
801+
message="Unity Editor is offline or the MCP bridge is not ready.",
802+
hint="retry",
803+
data={
804+
"reason": "editor_offline",
805+
"retry_after_ms": int(retry_after_ms),
806+
"detail": detail,
807+
},
808+
)
809+
810+
811+
def _get_editor_reconnect_max_wait_s() -> float:
812+
raw_value = os.environ.get("UNITY_MCP_EDITOR_RECONNECT_MAX_WAIT_S", "2.0")
813+
try:
814+
wait_s = float(raw_value)
815+
except ValueError:
816+
logger.warning(
817+
"Invalid UNITY_MCP_EDITOR_RECONNECT_MAX_WAIT_S=%r, using default 2.0",
818+
raw_value,
819+
)
820+
wait_s = 2.0
821+
return max(0.0, min(wait_s, 30.0))
822+
823+
824+
def _get_connection_with_editor_reconnect(
825+
instance_id: str | None,
826+
command_type: str,
827+
) -> UnityConnection | MCPResponse:
828+
"""Resolve a Unity connection, waiting briefly for editor restart recovery."""
829+
max_wait_s = _get_editor_reconnect_max_wait_s()
830+
deadline = time.monotonic() + max_wait_s
831+
last_error: BaseException | None = None
832+
attempt = 0
833+
834+
while True:
835+
try:
836+
return get_unity_connection(instance_id)
837+
except Exception as exc:
838+
if not _is_editor_offline_error(exc):
839+
raise
840+
841+
last_error = exc
842+
now = time.monotonic()
843+
if max_wait_s <= 0 or now >= deadline:
844+
logger.info(
845+
"Unity editor offline for command=%s instance=%s: %s",
846+
command_type,
847+
instance_id or "default",
848+
exc,
849+
)
850+
return _editor_offline_response(str(last_error))
851+
852+
attempt += 1
853+
remaining_s = max(0.0, deadline - now)
854+
sleep_s = min(remaining_s, 0.25 * (2 ** min(attempt - 1, 2)))
855+
logger.debug(
856+
"Unity editor offline; waiting %.3fs before reconnect attempt %d for command=%s instance=%s",
857+
sleep_s,
858+
attempt + 1,
859+
command_type,
860+
instance_id or "default",
861+
)
862+
time.sleep(sleep_s)
863+
864+
780865
def send_command_with_retry(
781866
command_type: str,
782867
params: dict[str, Any],
@@ -806,7 +891,10 @@ def send_command_with_retry(
806891
t_retry_start = time.time()
807892
logger.info("[TIMING-STDIO] send_command_with_retry START command=%s", command_type)
808893
t_get_conn = time.time()
809-
conn = get_unity_connection(instance_id)
894+
conn_or_response = _get_connection_with_editor_reconnect(instance_id, command_type)
895+
if isinstance(conn_or_response, MCPResponse):
896+
return conn_or_response
897+
conn = conn_or_response
810898
logger.info("[TIMING-STDIO] get_unity_connection took %.3fs command=%s", time.time() - t_get_conn, command_type)
811899
if max_retries is None:
812900
max_retries = getattr(config, "reload_max_retries", 40)
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
5+
6+
def test_send_command_returns_structured_offline_when_no_editor(monkeypatch):
7+
from transport.legacy import unity_connection as mod
8+
9+
def missing_connection(_instance_id=None):
10+
raise ConnectionError(
11+
"No Unity Editor instances found. Please ensure Unity is running with MCP for Unity bridge."
12+
)
13+
14+
monkeypatch.setenv("UNITY_MCP_EDITOR_RECONNECT_MAX_WAIT_S", "0")
15+
monkeypatch.setattr(mod, "get_unity_connection", missing_connection)
16+
17+
result = mod.send_command_with_retry("read_console", {}, instance_id="POB@abc")
18+
19+
assert result.success is False
20+
assert result.error == "editor_offline"
21+
assert result.hint == "retry"
22+
assert result.data["reason"] == "editor_offline"
23+
assert result.data["retry_after_ms"] == 1000
24+
25+
26+
def test_send_command_retries_until_editor_returns(monkeypatch):
27+
from transport.legacy import unity_connection as mod
28+
29+
calls = {"count": 0, "sleep": 0}
30+
31+
class FakeConnection:
32+
def send_command(self, command_type, params, max_attempts=None):
33+
return {
34+
"success": True,
35+
"data": {
36+
"command_type": command_type,
37+
"params": params,
38+
"max_attempts": max_attempts,
39+
},
40+
}
41+
42+
def reconnecting_connection(_instance_id=None):
43+
calls["count"] += 1
44+
if calls["count"] == 1:
45+
raise ConnectionError("Failed to connect to Unity instance 'POB@abc' on port 6400.")
46+
return FakeConnection()
47+
48+
def fake_sleep(seconds):
49+
calls["sleep"] += 1
50+
51+
now = {"value": 0.0}
52+
53+
def fake_monotonic():
54+
now["value"] += 0.1
55+
return now["value"]
56+
57+
monkeypatch.setenv("UNITY_MCP_EDITOR_RECONNECT_MAX_WAIT_S", "5")
58+
monkeypatch.setattr(mod, "get_unity_connection", reconnecting_connection)
59+
monkeypatch.setattr(mod.time, "sleep", fake_sleep)
60+
monkeypatch.setattr(mod.time, "monotonic", fake_monotonic)
61+
62+
result = mod.send_command_with_retry("read_console", {"action": "get"}, instance_id="POB@abc")
63+
64+
assert result["success"] is True
65+
assert calls["count"] == 2
66+
assert calls["sleep"] == 1
67+
68+
69+
@pytest.mark.parametrize(
70+
"message",
71+
[
72+
"No Unity Editor instances found.",
73+
"Failed to connect to Unity instance 'POB@abc' on port 6400.",
74+
"Could not connect to Unity",
75+
"[WinError 10061] No connection could be made because the target machine actively refused it",
76+
],
77+
)
78+
def test_editor_offline_error_detection(message):
79+
from transport.legacy.unity_connection import _is_editor_offline_error
80+
81+
assert _is_editor_offline_error(ConnectionError(message)) is True
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Editor Lifecycle Recovery Spec
2+
3+
## Goal
4+
5+
Keep the MCP server usable when the Unity Editor closes, reloads, or restarts.
6+
Tool calls should return structured retryable state or recover when the editor
7+
comes back quickly, instead of surfacing opaque connection failures.
8+
9+
## Scope
10+
11+
This first PR targets stdio/legacy Unity socket transport because that is the
12+
path used by Codex and common desktop clients. HTTP keep-running behavior
13+
already exists separately and should not be rewritten here.
14+
15+
## Behavior
16+
17+
- If no Unity Editor instance can be discovered, return `success=false`,
18+
`error=editor_offline`, `hint=retry`, and `data.reason=editor_offline`.
19+
- If a Unity instance is discovered but the socket connection is refused,
20+
return the same structured offline response.
21+
- Before returning offline, wait for a short bounded reconnect window so a tool
22+
call made during editor restart can complete if the bridge comes back.
23+
- The reconnect window is configurable with
24+
`UNITY_MCP_EDITOR_RECONNECT_MAX_WAIT_S`.
25+
- Existing domain-reload `reloading` handling remains unchanged.
26+
27+
## Non-goals
28+
29+
- Do not keep a Unity-side bridge alive after the Unity Editor process exits.
30+
- Do not redesign the HTTP plugin hub.
31+
- Do not add a full command queue or multi-agent gateway in this PR.
32+
33+
## Verification
34+
35+
- Add Python tests for offline response classification.
36+
- Add Python tests proving `send_command_with_retry` retries connection lookup
37+
during the reconnect window.
38+
- Run the focused Python test file.

0 commit comments

Comments
 (0)