Skip to content

Commit a5db77f

Browse files
committed
Serialize expensive editor operations with leases
1 parent 2d2bdc5 commit a5db77f

5 files changed

Lines changed: 607 additions & 39 deletions

File tree

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import logging
5+
import os
6+
import re
7+
import time
8+
import uuid
9+
from dataclasses import dataclass
10+
from pathlib import Path
11+
from typing import Any
12+
13+
from models import MCPResponse
14+
15+
logger = logging.getLogger(__name__)
16+
17+
LEASE_DIR_ENV = "UNITY_MCP_OPERATION_LEASE_DIR"
18+
LEASE_TTL_ENV = "UNITY_MCP_OPERATION_LEASE_TTL_S"
19+
DEFAULT_LEASE_TTL_S = 120.0
20+
_SAFE_KEY_RE = re.compile(r"[^A-Za-z0-9_.@-]+")
21+
22+
23+
@dataclass(frozen=True)
24+
class EditorOperationLeaseInfo:
25+
instance_id: str
26+
operation: str
27+
owner: str
28+
started_unix_ms: int
29+
expires_unix_ms: int
30+
pid: int | None = None
31+
path: str | None = None
32+
33+
34+
@dataclass
35+
class EditorOperationLease:
36+
path: Path
37+
token: str
38+
info: EditorOperationLeaseInfo
39+
reentrant: bool = False
40+
_released: bool = False
41+
42+
@property
43+
def instance_id(self) -> str:
44+
return self.info.instance_id
45+
46+
@property
47+
def operation(self) -> str:
48+
return self.info.operation
49+
50+
@property
51+
def owner(self) -> str:
52+
return self.info.owner
53+
54+
def release(self) -> None:
55+
if self._released:
56+
return
57+
if self.reentrant:
58+
self._released = True
59+
return
60+
try:
61+
payload = _read_payload(self.path)
62+
if payload and payload.get("token") == self.token:
63+
self.path.unlink(missing_ok=True)
64+
except Exception as exc: # pragma: no cover - defensive cleanup path
65+
logger.debug("Failed to release editor operation lease %s: %r", self.path, exc)
66+
finally:
67+
self._released = True
68+
69+
70+
def operation_owner_from_context(ctx: Any) -> str:
71+
return f"pid:{os.getpid()}:ctx:{id(ctx)}"
72+
73+
74+
def operation_busy_response(
75+
lease_info: EditorOperationLeaseInfo,
76+
*,
77+
retry_after_ms: int = 2000,
78+
) -> MCPResponse:
79+
return MCPResponse(
80+
success=False,
81+
error="operation_busy",
82+
message=f"Unity editor operation already in progress: {lease_info.operation}",
83+
hint="retry",
84+
data={
85+
"reason": "operation_busy",
86+
"retry_after_ms": int(retry_after_ms),
87+
"instance_id": lease_info.instance_id,
88+
"operation": lease_info.operation,
89+
"owner": lease_info.owner,
90+
"pid": lease_info.pid,
91+
"expires_unix_ms": lease_info.expires_unix_ms,
92+
},
93+
)
94+
95+
96+
def try_acquire_editor_operation_lease(
97+
unity_instance: str | None,
98+
operation: str,
99+
*,
100+
owner: str | None = None,
101+
ttl_s: float | None = None,
102+
) -> tuple[EditorOperationLease | None, EditorOperationLeaseInfo | None]:
103+
instance_id = unity_instance or "default"
104+
lease_dir = _operation_lease_dir()
105+
lease_dir.mkdir(parents=True, exist_ok=True)
106+
path = lease_dir / f"{_safe_lease_key(instance_id)}.json"
107+
owner = owner or f"pid:{os.getpid()}"
108+
ttl_ms = int(_lease_ttl_s(ttl_s) * 1000)
109+
110+
while True:
111+
now_ms = _now_ms()
112+
token = uuid.uuid4().hex
113+
payload = {
114+
"instance_id": instance_id,
115+
"operation": operation,
116+
"owner": owner,
117+
"pid": os.getpid(),
118+
"token": token,
119+
"started_unix_ms": now_ms,
120+
"expires_unix_ms": now_ms + ttl_ms,
121+
}
122+
123+
try:
124+
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL)
125+
except FileExistsError:
126+
existing = _read_payload(path)
127+
if _is_lease_expired(existing, path, now_ms, ttl_ms):
128+
try:
129+
path.unlink()
130+
except FileNotFoundError:
131+
continue
132+
except OSError:
133+
return None, _payload_to_info(existing, path, instance_id)
134+
continue
135+
if existing and existing.get("owner") == owner:
136+
return (
137+
EditorOperationLease(
138+
path,
139+
str(existing.get("token") or ""),
140+
_payload_to_info(existing, path, instance_id),
141+
reentrant=True,
142+
),
143+
None,
144+
)
145+
return None, _payload_to_info(existing, path, instance_id)
146+
147+
with os.fdopen(fd, "w", encoding="utf-8") as lease_file:
148+
json.dump(payload, lease_file, separators=(",", ":"), sort_keys=True)
149+
return EditorOperationLease(path, token, _payload_to_info(payload, path, instance_id)), None
150+
151+
152+
def _operation_lease_dir() -> Path:
153+
configured = os.environ.get(LEASE_DIR_ENV)
154+
if configured:
155+
return Path(configured)
156+
return Path.home() / ".unity-mcp" / "operation-leases"
157+
158+
159+
def _lease_ttl_s(ttl_s: float | None) -> float:
160+
if ttl_s is None:
161+
raw = os.environ.get(LEASE_TTL_ENV)
162+
if raw is None:
163+
return DEFAULT_LEASE_TTL_S
164+
try:
165+
ttl_s = float(raw)
166+
except ValueError:
167+
return DEFAULT_LEASE_TTL_S
168+
169+
try:
170+
value = float(ttl_s)
171+
except (TypeError, ValueError):
172+
return DEFAULT_LEASE_TTL_S
173+
return max(0.001, min(value, 3600.0))
174+
175+
176+
def _safe_lease_key(instance_id: str) -> str:
177+
key = _SAFE_KEY_RE.sub("_", instance_id).strip("._-")
178+
return key or "default"
179+
180+
181+
def _now_ms() -> int:
182+
return int(time.time() * 1000)
183+
184+
185+
def _read_payload(path: Path) -> dict[str, Any] | None:
186+
try:
187+
raw = path.read_text(encoding="utf-8")
188+
payload = json.loads(raw)
189+
return payload if isinstance(payload, dict) else None
190+
except FileNotFoundError:
191+
return None
192+
except Exception:
193+
return None
194+
195+
196+
def _payload_to_info(
197+
payload: dict[str, Any] | None,
198+
path: Path,
199+
fallback_instance_id: str,
200+
) -> EditorOperationLeaseInfo:
201+
payload = payload or {}
202+
started = _int_or_default(payload.get("started_unix_ms"), _mtime_ms(path))
203+
expires = _int_or_default(
204+
payload.get("expires_unix_ms"),
205+
started + int(DEFAULT_LEASE_TTL_S * 1000),
206+
)
207+
pid = payload.get("pid")
208+
return EditorOperationLeaseInfo(
209+
instance_id=str(payload.get("instance_id") or fallback_instance_id),
210+
operation=str(payload.get("operation") or "unknown"),
211+
owner=str(payload.get("owner") or "unknown"),
212+
started_unix_ms=started,
213+
expires_unix_ms=expires,
214+
pid=pid if isinstance(pid, int) else None,
215+
path=str(path),
216+
)
217+
218+
219+
def _is_lease_expired(
220+
payload: dict[str, Any] | None,
221+
path: Path,
222+
now_ms: int,
223+
ttl_ms: int,
224+
) -> bool:
225+
if payload and isinstance(payload.get("expires_unix_ms"), int):
226+
return payload["expires_unix_ms"] <= now_ms
227+
try:
228+
return _mtime_ms(path) + ttl_ms <= now_ms
229+
except OSError:
230+
return True
231+
232+
233+
def _mtime_ms(path: Path) -> int:
234+
try:
235+
return int(path.stat().st_mtime * 1000)
236+
except OSError:
237+
return _now_ms()
238+
239+
240+
def _int_or_default(value: Any, default: int) -> int:
241+
return value if isinstance(value, int) else default

Server/src/services/tools/refresh_unity.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@
1313
from models import MCPResponse
1414
from services.registry import mcp_for_unity_tool
1515
from services.tools import get_unity_instance_from_context
16+
from services.tools.editor_operation_lease import (
17+
operation_busy_response,
18+
operation_owner_from_context,
19+
try_acquire_editor_operation_lease,
20+
)
1621
import transport.unity_transport as unity_transport
1722
import transport.legacy.unity_connection as _legacy_conn
1823
from transport.legacy.unity_connection import _extract_response_reason
@@ -183,7 +188,29 @@ async def refresh_unity(
183188
"If true, wait until editor_state.advice.ready_for_tools is true"] = True,
184189
) -> MCPResponse | dict[str, Any]:
185190
unity_instance = await get_unity_instance_from_context(ctx)
191+
lease, busy_lease = try_acquire_editor_operation_lease(
192+
unity_instance,
193+
"refresh_unity",
194+
owner=operation_owner_from_context(ctx),
195+
)
196+
if busy_lease is not None:
197+
return operation_busy_response(busy_lease)
198+
199+
try:
200+
return await _refresh_unity_locked(ctx, unity_instance, mode, scope, compile, wait_for_ready)
201+
finally:
202+
if lease is not None:
203+
lease.release()
204+
186205

206+
async def _refresh_unity_locked(
207+
ctx: Context,
208+
unity_instance: str | None,
209+
mode: str,
210+
scope: str,
211+
compile: str,
212+
wait_for_ready: bool,
213+
) -> MCPResponse | dict[str, Any]:
187214
params: dict[str, Any] = {
188215
"mode": mode,
189216
"scope": scope,

Server/src/services/tools/run_tests.py

Lines changed: 55 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@
1313
from models import MCPResponse
1414
from services.registry import mcp_for_unity_tool
1515
from services.tools import get_unity_instance_from_context
16+
from services.tools.editor_operation_lease import (
17+
operation_busy_response,
18+
operation_owner_from_context,
19+
try_acquire_editor_operation_lease,
20+
)
1621
from services.tools.preflight import preflight
1722
import transport.unity_transport as unity_transport
1823
from transport.legacy.unity_connection import async_send_command_with_retry
@@ -175,49 +180,60 @@ async def run_tests(
175180
return MCPResponse(success=False, error="init_timeout must be a positive integer (milliseconds) or None")
176181

177182
unity_instance = await get_unity_instance_from_context(ctx)
178-
179-
gate = await preflight(ctx, requires_no_tests=True, wait_for_no_compile=True, refresh_if_dirty=True)
180-
if isinstance(gate, MCPResponse):
181-
return gate
182-
183-
def _coerce_string_list(value) -> list[str] | None:
184-
if value is None:
185-
return None
186-
if isinstance(value, str):
187-
return [value] if value.strip() else None
188-
if isinstance(value, list):
189-
result = [str(v).strip() for v in value if v and str(v).strip()]
190-
return result if result else None
191-
return None
192-
193-
params: dict[str, Any] = {"mode": mode}
194-
if (t := _coerce_string_list(test_names)):
195-
params["testNames"] = t
196-
if (g := _coerce_string_list(group_names)):
197-
params["groupNames"] = g
198-
if (c := _coerce_string_list(category_names)):
199-
params["categoryNames"] = c
200-
if (a := _coerce_string_list(assembly_names)):
201-
params["assemblyNames"] = a
202-
if include_failed_tests:
203-
params["includeFailedTests"] = True
204-
if include_details:
205-
params["includeDetails"] = True
206-
if init_timeout is not None and init_timeout > 0:
207-
params["initTimeout"] = init_timeout
208-
209-
response = await unity_transport.send_with_unity_instance(
210-
async_send_command_with_retry,
183+
lease, busy_lease = try_acquire_editor_operation_lease(
211184
unity_instance,
212185
"run_tests",
213-
params,
186+
owner=operation_owner_from_context(ctx),
214187
)
188+
if busy_lease is not None:
189+
return operation_busy_response(busy_lease)
215190

216-
if isinstance(response, dict):
217-
if not response.get("success", True):
218-
return MCPResponse(**response)
219-
return RunTestsStartResponse(**response)
220-
return MCPResponse(success=False, error=str(response))
191+
try:
192+
gate = await preflight(ctx, requires_no_tests=True, wait_for_no_compile=True, refresh_if_dirty=True)
193+
if isinstance(gate, MCPResponse):
194+
return gate
195+
196+
def _coerce_string_list(value) -> list[str] | None:
197+
if value is None:
198+
return None
199+
if isinstance(value, str):
200+
return [value] if value.strip() else None
201+
if isinstance(value, list):
202+
result = [str(v).strip() for v in value if v and str(v).strip()]
203+
return result if result else None
204+
return None
205+
206+
params: dict[str, Any] = {"mode": mode}
207+
if (t := _coerce_string_list(test_names)):
208+
params["testNames"] = t
209+
if (g := _coerce_string_list(group_names)):
210+
params["groupNames"] = g
211+
if (c := _coerce_string_list(category_names)):
212+
params["categoryNames"] = c
213+
if (a := _coerce_string_list(assembly_names)):
214+
params["assemblyNames"] = a
215+
if include_failed_tests:
216+
params["includeFailedTests"] = True
217+
if include_details:
218+
params["includeDetails"] = True
219+
if init_timeout is not None and init_timeout > 0:
220+
params["initTimeout"] = init_timeout
221+
222+
response = await unity_transport.send_with_unity_instance(
223+
async_send_command_with_retry,
224+
unity_instance,
225+
"run_tests",
226+
params,
227+
)
228+
229+
if isinstance(response, dict):
230+
if not response.get("success", True):
231+
return MCPResponse(**response)
232+
return RunTestsStartResponse(**response)
233+
return MCPResponse(success=False, error=str(response))
234+
finally:
235+
if lease is not None:
236+
lease.release()
221237

222238

223239
@mcp_for_unity_tool(

0 commit comments

Comments
 (0)