Skip to content

Commit a735d4e

Browse files
dsarnoclaude
andauthored
Fix script mutation tools retrying and failing after domain reload (#796)
* Fix script edit tools retrying non-idempotent commands during reload When Unity enters domain reload after a script edit, the retry loop in send_command_with_retry would re-send the identical edit command up to 40 times, duplicating insert_method/anchor_insert edits. Pass retry_on_reload=False on all script-mutating send calls since the edit lands on disk before the reload triggers. Also fix _flip_async coroutine in apply_text_edits that was passed as a threading.Thread target — the coroutine was never awaited. Replace with asyncio.create_task so the sentinel reload flip actually executes. Fixes #790 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add post-mutation wait-for-ready and reloading-rejection retry Script-mutating tools (create_script, delete_script, apply_text_edits, script_apply_edits) use retry_on_reload=False to avoid re-sending non-idempotent commands during domain reload. But this meant they returned before Unity finished reloading, causing the next tool call to timeout. This commit: - Extracts wait_for_editor_ready() helper from refresh_unity.py that polls editor_state until Unity reports ready_for_tools - Adds is_reloading_rejection() to detect when Unity rejected a command due to stale reloading state (safe to retry since command never ran) - Calls both at all 9 mutation sites (4 in manage_script, 5 in script_apply_edits): retry on rejection, then wait for readiness - Adds 9 unit tests for the new helpers Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix stale socket causing mutations to silently fail after domain reload UnityConnection reuses TCP sockets across commands. After domain reload kills Unity TCP listener, the Python side still holds the dead socket. With retry_on_reload=False (used for non-idempotent mutations), there are no retries to recover -- the command is never delivered to Unity. - Add _ensure_live_connection() to detect dead sockets via non-blocking MSG_PEEK before each send, allowing reconnection - Add is_connection_lost_after_send() helper and post-mutation verification at all 9 mutation sites (create/delete/apply_text_edits/script_apply_edits) to recover when domain reload drops the TCP response - Fix stale reloading heartbeat in C# bridge: write reloading=false at Start() entry and after retry exhaustion in StdioBridgeReloadHandler Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Extract send_mutation() and verify_edit_by_sha() shared helpers Consolidates the repeated retry+wait+verify pattern from 9 inline sites across manage_script.py and script_apply_edits.py into a single send_mutation() helper in refresh_unity.py. Addresses Sourcery code review feedback on PR #796. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 63bb2ef commit a735d4e

7 files changed

Lines changed: 533 additions & 107 deletions

File tree

‎MCPForUnity/Editor/Services/StdioBridgeReloadHandler.cs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,11 @@ private static async Task ResumeStdioWithRetriesAsync()
191191

192192
try { EditorPrefs.DeleteKey(EditorPrefKeys.ResumeStdioAfterReload); } catch { }
193193

194+
// Clear the stale "reloading" heartbeat so clients stop seeing reloading=true.
195+
// The bridge isn't running, so clients will get connection-refused (recoverable)
196+
// instead of hanging on a zombie socket or being rejected by the preflight check.
197+
try { StdioBridgeHost.WriteHeartbeat(false, "stopped"); } catch { }
198+
194199
if (lastException != null)
195200
{
196201
McpLog.Warn($"Failed to resume stdio bridge after domain reload: {lastException.Message}");

‎MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,12 @@ public static void Start()
269269
{
270270
currentUnityPort = PortManager.GetPortWithFallback();
271271

272+
// Clear any stale "reloading" heartbeat from a previous domain reload.
273+
// After reload, static fields reset (isRunning=false), so Stop() above
274+
// is a no-op and won't delete the status file. Writing now ensures clients
275+
// see reloading=false even if listener creation fails below.
276+
WriteHeartbeat(false, "starting");
277+
272278
LogBreadcrumb("Start");
273279

274280
try
@@ -320,6 +326,7 @@ public static void Start()
320326
catch (SocketException ex)
321327
{
322328
McpLog.Error($"Failed to start TCP listener: {ex.Message}");
329+
WriteHeartbeat(false, "start_failed");
323330
}
324331
}
325332
}

‎Server/src/services/tools/manage_script.py‎

Lines changed: 54 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from services.registry import mcp_for_unity_tool
1010
from services.tools import get_unity_instance_from_context
11+
from services.tools.refresh_unity import send_mutation, verify_edit_by_sha
1112
from transport.unity_transport import send_with_unity_instance
1213
import transport.legacy.unity_connection
1314

@@ -324,13 +325,13 @@ def _le(a: tuple[int, int], b: tuple[int, int]) -> bool:
324325
"options": opts,
325326
}
326327
params = {k: v for k, v in params.items() if v is not None}
327-
resp = await send_with_unity_instance(
328-
transport.legacy.unity_connection.async_send_command_with_retry,
329-
unity_instance,
330-
"manage_script",
331-
params,
332-
retry_on_reload=False,
333-
)
328+
329+
async def _verify_edit():
330+
if await verify_edit_by_sha(unity_instance, name, directory, precondition_sha256):
331+
return {"success": True, "message": "Edit applied (verified after domain reload).", "data": {"normalizedEdits": normalized_edits}}
332+
return None
333+
334+
resp = await send_mutation(ctx, unity_instance, "manage_script", params, verify_after_disconnect=_verify_edit)
334335
if isinstance(resp, dict):
335336
data = resp.setdefault("data", {})
336337
data.setdefault("normalizedEdits", normalized_edits)
@@ -423,13 +424,18 @@ async def create_script(
423424
contents.encode("utf-8")).decode("utf-8")
424425
params["contentsEncoded"] = True
425426
params = {k: v for k, v in params.items() if v is not None}
426-
resp = await send_with_unity_instance(
427-
transport.legacy.unity_connection.async_send_command_with_retry,
428-
unity_instance,
429-
"manage_script",
430-
params,
431-
retry_on_reload=False,
432-
)
427+
428+
async def _verify_create():
429+
verify = await send_with_unity_instance(
430+
transport.legacy.unity_connection.async_send_command_with_retry,
431+
unity_instance, "manage_script",
432+
{"action": "read", "name": name, "path": directory},
433+
)
434+
if isinstance(verify, dict) and verify.get("success"):
435+
return {"success": True, "message": "Script created (verified after domain reload).", "data": verify.get("data")}
436+
return None
437+
438+
resp = await send_mutation(ctx, unity_instance, "manage_script", params, verify_after_disconnect=_verify_create)
433439
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
434440

435441

@@ -453,13 +459,18 @@ async def delete_script(
453459
if not directory or directory.split("/")[0].lower() != "assets":
454460
return {"success": False, "code": "path_outside_assets", "message": "URI must resolve under 'Assets/'."}
455461
params = {"action": "delete", "name": name, "path": directory}
456-
resp = await send_with_unity_instance(
457-
transport.legacy.unity_connection.async_send_command_with_retry,
458-
unity_instance,
459-
"manage_script",
460-
params,
461-
retry_on_reload=False,
462-
)
462+
463+
async def _verify_delete():
464+
verify = await send_with_unity_instance(
465+
transport.legacy.unity_connection.async_send_command_with_retry,
466+
unity_instance, "manage_script",
467+
{"action": "read", "name": name, "path": directory},
468+
)
469+
if isinstance(verify, dict) and not verify.get("success"):
470+
return {"success": True, "message": "Script deleted (verified after domain reload)."}
471+
return None
472+
473+
resp = await send_mutation(ctx, unity_instance, "manage_script", params, verify_after_disconnect=_verify_delete)
463474
return resp if isinstance(resp, dict) else {"success": False, "message": str(resp)}
464475

465476

@@ -553,13 +564,28 @@ async def manage_script(
553564

554565
params = {k: v for k, v in params.items() if v is not None}
555566

556-
response = await send_with_unity_instance(
557-
transport.legacy.unity_connection.async_send_command_with_retry,
558-
unity_instance,
559-
"manage_script",
560-
params,
561-
retry_on_reload=(action == "read"),
562-
)
567+
if action == "read":
568+
response = await send_with_unity_instance(
569+
transport.legacy.unity_connection.async_send_command_with_retry,
570+
unity_instance,
571+
"manage_script",
572+
params,
573+
retry_on_reload=True,
574+
)
575+
else:
576+
async def _verify_mutation():
577+
verify = await send_with_unity_instance(
578+
transport.legacy.unity_connection.async_send_command_with_retry,
579+
unity_instance, "manage_script",
580+
{"action": "read", "name": name, "path": path},
581+
)
582+
if action == "create" and isinstance(verify, dict) and verify.get("success"):
583+
return {"success": True, "message": "Script created (verified after domain reload).", "data": verify.get("data")}
584+
elif action == "delete" and isinstance(verify, dict) and not verify.get("success"):
585+
return {"success": True, "message": "Script deleted (verified after domain reload)."}
586+
return None
587+
588+
response = await send_mutation(ctx, unity_instance, "manage_script", params, verify_after_disconnect=_verify_mutation)
563589

564590
if isinstance(response, dict):
565591
if response.get("success"):

‎Server/src/services/tools/refresh_unity.py‎

Lines changed: 152 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
import asyncio
44
import logging
5+
import os
56
import time
7+
from collections.abc import Awaitable, Callable
68
from typing import Annotated, Any, Literal
79

810
from fastmcp import Context
@@ -12,12 +14,156 @@
1214
from services.registry import mcp_for_unity_tool
1315
from services.tools import get_unity_instance_from_context
1416
import transport.unity_transport as unity_transport
15-
from transport.legacy.unity_connection import async_send_command_with_retry, _extract_response_reason
17+
import transport.legacy.unity_connection as _legacy_conn
18+
from transport.legacy.unity_connection import _extract_response_reason
1619
from services.state.external_changes_scanner import external_changes_scanner
1720
import services.resources.editor_state as editor_state
1821

1922
logger = logging.getLogger(__name__)
2023

24+
# Blocking reasons that indicate Unity is actually busy (not just stale status).
25+
# Must match activityPhase values from EditorStateCache.cs
26+
_REAL_BLOCKING_REASONS = {"compiling", "domain_reload", "running_tests", "asset_import"}
27+
28+
29+
def _in_pytest() -> bool:
30+
"""Return True when running inside pytest to avoid polling unmocked resources."""
31+
return "PYTEST_CURRENT_TEST" in os.environ
32+
33+
34+
async def wait_for_editor_ready(ctx: Context, timeout_s: float = 30.0) -> tuple[bool, float]:
35+
"""Poll editor_state until Unity is ready for tool calls.
36+
37+
Returns (ready, elapsed_seconds). Treats exceptions from
38+
get_editor_state as "not ready yet" so the loop survives transient
39+
connection errors during domain reload.
40+
"""
41+
if _in_pytest():
42+
return (True, 0.0)
43+
44+
start = time.monotonic()
45+
while time.monotonic() - start < timeout_s:
46+
try:
47+
state_resp = await editor_state.get_editor_state(ctx)
48+
state = state_resp.model_dump() if hasattr(state_resp, "model_dump") else state_resp
49+
data = (state or {}).get("data") if isinstance(state, dict) else None
50+
advice = (data or {}).get("advice") if isinstance(data, dict) else None
51+
if isinstance(advice, dict):
52+
if advice.get("ready_for_tools") is True:
53+
return (True, time.monotonic() - start)
54+
blocking = set(advice.get("blocking_reasons") or [])
55+
if not (blocking & _REAL_BLOCKING_REASONS):
56+
return (True, time.monotonic() - start)
57+
except Exception:
58+
pass # not ready yet — keep polling
59+
await asyncio.sleep(0.25)
60+
61+
return (False, time.monotonic() - start)
62+
63+
64+
def is_reloading_rejection(resp: Any) -> bool:
65+
"""True when Unity rejected a command because it thinks it is reloading.
66+
67+
The command was never executed, so retrying is safe.
68+
"""
69+
if not isinstance(resp, dict) or resp.get("success"):
70+
return False
71+
data = resp.get("data") or {}
72+
return data.get("reason") == "reloading" and resp.get("hint") == "retry"
73+
74+
75+
def is_connection_lost_after_send(resp: Any) -> bool:
76+
"""True when a mutation's response indicates TCP was lost after command was sent.
77+
78+
Script mutations trigger domain reload which kills the TCP connection.
79+
The mutation was likely executed but the response was lost.
80+
"""
81+
if isinstance(resp, dict):
82+
if resp.get("success"):
83+
return False
84+
err = (resp.get("error") or resp.get("message") or "").lower()
85+
else:
86+
if getattr(resp, "success", None):
87+
return False
88+
err = (getattr(resp, "error", "") or "").lower()
89+
return "connection closed" in err or "disconnected" in err or "aborted" in err
90+
91+
92+
async def send_mutation(
93+
ctx: Context,
94+
unity_instance: str | None,
95+
command: str,
96+
params: dict[str, Any],
97+
*,
98+
verify_after_disconnect: Callable[[], Awaitable[dict | None]] | None = None,
99+
) -> dict | Any:
100+
"""Send a non-idempotent mutation with reload recovery.
101+
102+
Handles the full retry/recovery pattern for script mutations:
103+
1. Send with retry_on_reload=False (don't re-send if Unity is reloading)
104+
2. If reloading rejection (command never executed) → wait + retry once
105+
3. If connection lost after send → wait + verify via callback
106+
4. Wait for editor readiness before returning
107+
108+
Args:
109+
verify_after_disconnect: async callable returning a replacement response
110+
dict if the mutation was verified after connection loss, or None to
111+
keep the original error response.
112+
"""
113+
resp = await unity_transport.send_with_unity_instance(
114+
_legacy_conn.async_send_command_with_retry,
115+
unity_instance,
116+
command,
117+
params,
118+
retry_on_reload=False,
119+
)
120+
if is_reloading_rejection(resp):
121+
await wait_for_editor_ready(ctx)
122+
resp = await unity_transport.send_with_unity_instance(
123+
_legacy_conn.async_send_command_with_retry,
124+
unity_instance,
125+
command,
126+
params,
127+
retry_on_reload=False,
128+
)
129+
if is_connection_lost_after_send(resp) and verify_after_disconnect:
130+
await wait_for_editor_ready(ctx)
131+
verified = await verify_after_disconnect()
132+
if verified is not None:
133+
resp = verified
134+
await wait_for_editor_ready(ctx)
135+
return resp
136+
137+
138+
async def verify_edit_by_sha(
139+
unity_instance: str | None,
140+
name: str,
141+
path: str,
142+
pre_sha: str | None,
143+
) -> bool:
144+
"""Verify a script edit was applied by comparing SHA before and after.
145+
146+
Returns True if the file's SHA changed (edit likely applied).
147+
"""
148+
if not pre_sha:
149+
return False
150+
try:
151+
verify = await unity_transport.send_with_unity_instance(
152+
_legacy_conn.async_send_command_with_retry,
153+
unity_instance,
154+
"manage_script",
155+
{"action": "get_sha", "name": name, "path": path},
156+
)
157+
if isinstance(verify, dict) and verify.get("success"):
158+
new_sha = (verify.get("data") or {}).get("sha256")
159+
return bool(new_sha and new_sha != pre_sha)
160+
except Exception as exc:
161+
logger.debug(
162+
"Failed to verify edit after disconnect for %s at %s: %r",
163+
name, path, exc,
164+
)
165+
return False
166+
21167

22168
@mcp_for_unity_tool(
23169
description="Request a Unity asset database refresh and optionally a script compilation. Can optionally wait for readiness.",
@@ -49,7 +195,7 @@ async def refresh_unity(
49195
# Don't retry on reload - refresh_unity triggers compilation/reload,
50196
# so retrying would cause multiple reloads (issue #577)
51197
response = await unity_transport.send_with_unity_instance(
52-
async_send_command_with_retry,
198+
_legacy_conn.async_send_command_with_retry,
53199
unity_instance,
54200
"refresh_unity",
55201
params,
@@ -98,41 +244,15 @@ async def refresh_unity(
98244
# poll the canonical editor_state resource until ready or timeout.
99245
ready_confirmed = False
100246
if wait_for_ready:
101-
timeout_s = 60.0
102-
start = time.monotonic()
103-
104-
# Blocking reasons that indicate Unity is actually busy (not just stale status)
105-
# Must match activityPhase values from EditorStateCache.cs
106-
real_blocking_reasons = {"compiling", "domain_reload", "running_tests", "asset_import"}
107-
108-
while time.monotonic() - start < timeout_s:
109-
state_resp = await editor_state.get_editor_state(ctx)
110-
state = state_resp.model_dump() if hasattr(
111-
state_resp, "model_dump") else state_resp
112-
data = (state or {}).get("data") if isinstance(
113-
state, dict) else None
114-
advice = (data or {}).get(
115-
"advice") if isinstance(data, dict) else None
116-
if isinstance(advice, dict):
117-
# Exit if ready_for_tools is True
118-
if advice.get("ready_for_tools") is True:
119-
ready_confirmed = True
120-
break
121-
# Also exit if the only blocking reason is "stale_status" (Unity in background)
122-
# Staleness means we can't confirm status, not that Unity is actually busy
123-
blocking = set(advice.get("blocking_reasons") or [])
124-
if not (blocking & real_blocking_reasons):
125-
ready_confirmed = True # No real blocking reasons, consider ready
126-
break
127-
await asyncio.sleep(0.25)
247+
ready_confirmed, _ = await wait_for_editor_ready(ctx, timeout_s=60.0)
128248

129249
# If we timed out without confirming readiness, log and return failure
130250
if not ready_confirmed:
131-
logger.warning(f"refresh_unity: Timed out after {timeout_s}s waiting for editor to become ready")
251+
logger.warning("refresh_unity: Timed out after 60s waiting for editor to become ready")
132252
return MCPResponse(
133253
success=False,
134-
message=f"Refresh triggered but timed out after {timeout_s}s waiting for editor readiness.",
135-
data={"timeout": True, "wait_seconds": timeout_s},
254+
message="Refresh triggered but timed out after 60s waiting for editor readiness.",
255+
data={"timeout": True, "wait_seconds": 60.0},
136256
)
137257

138258
# After readiness is restored, clear any external-dirty flag for this instance so future tools can proceed cleanly.

0 commit comments

Comments
 (0)