Skip to content

Commit 591602f

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

5 files changed

Lines changed: 600 additions & 39 deletions

File tree

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
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+
session_id = getattr(ctx, "session_id", None)
72+
if session_id:
73+
return f"session:{session_id}"
74+
return f"pid:{os.getpid()}"
75+
76+
77+
def operation_busy_response(
78+
lease_info: EditorOperationLeaseInfo,
79+
*,
80+
retry_after_ms: int = 2000,
81+
) -> MCPResponse:
82+
return MCPResponse(
83+
success=False,
84+
error="operation_busy",
85+
message=f"Unity editor operation already in progress: {lease_info.operation}",
86+
hint="retry",
87+
data={
88+
"reason": "operation_busy",
89+
"retry_after_ms": int(retry_after_ms),
90+
"instance_id": lease_info.instance_id,
91+
"operation": lease_info.operation,
92+
"owner": lease_info.owner,
93+
"pid": lease_info.pid,
94+
"expires_unix_ms": lease_info.expires_unix_ms,
95+
},
96+
)
97+
98+
99+
def try_acquire_editor_operation_lease(
100+
unity_instance: str | None,
101+
operation: str,
102+
*,
103+
owner: str | None = None,
104+
ttl_s: float | None = None,
105+
) -> tuple[EditorOperationLease | None, EditorOperationLeaseInfo | None]:
106+
instance_id = unity_instance or "default"
107+
lease_dir = _operation_lease_dir()
108+
lease_dir.mkdir(parents=True, exist_ok=True)
109+
path = lease_dir / f"{_safe_lease_key(instance_id)}.json"
110+
owner = owner or f"pid:{os.getpid()}"
111+
ttl_ms = int(_lease_ttl_s(ttl_s) * 1000)
112+
113+
while True:
114+
now_ms = _now_ms()
115+
token = uuid.uuid4().hex
116+
payload = {
117+
"instance_id": instance_id,
118+
"operation": operation,
119+
"owner": owner,
120+
"pid": os.getpid(),
121+
"token": token,
122+
"started_unix_ms": now_ms,
123+
"expires_unix_ms": now_ms + ttl_ms,
124+
}
125+
126+
try:
127+
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL)
128+
except FileExistsError:
129+
existing = _read_payload(path)
130+
if _is_lease_expired(existing, path, now_ms, ttl_ms):
131+
try:
132+
path.unlink()
133+
except FileNotFoundError:
134+
continue
135+
except OSError:
136+
return None, _payload_to_info(existing, path, instance_id)
137+
continue
138+
if existing and existing.get("owner") == owner:
139+
return (
140+
EditorOperationLease(
141+
path,
142+
str(existing.get("token") or ""),
143+
_payload_to_info(existing, path, instance_id),
144+
reentrant=True,
145+
),
146+
None,
147+
)
148+
return None, _payload_to_info(existing, path, instance_id)
149+
150+
with os.fdopen(fd, "w", encoding="utf-8") as lease_file:
151+
json.dump(payload, lease_file, separators=(",", ":"), sort_keys=True)
152+
return EditorOperationLease(path, token, _payload_to_info(payload, path, instance_id)), None
153+
154+
155+
def _operation_lease_dir() -> Path:
156+
configured = os.environ.get(LEASE_DIR_ENV)
157+
if configured:
158+
return Path(configured)
159+
return Path.home() / ".unity-mcp" / "operation-leases"
160+
161+
162+
def _lease_ttl_s(ttl_s: float | None) -> float:
163+
if ttl_s is None:
164+
raw = os.environ.get(LEASE_TTL_ENV)
165+
if raw is None:
166+
return DEFAULT_LEASE_TTL_S
167+
try:
168+
ttl_s = float(raw)
169+
except ValueError:
170+
return DEFAULT_LEASE_TTL_S
171+
172+
try:
173+
value = float(ttl_s)
174+
except (TypeError, ValueError):
175+
return DEFAULT_LEASE_TTL_S
176+
return max(0.001, min(value, 3600.0))
177+
178+
179+
def _safe_lease_key(instance_id: str) -> str:
180+
key = _SAFE_KEY_RE.sub("_", instance_id).strip("._-")
181+
return key or "default"
182+
183+
184+
def _now_ms() -> int:
185+
return int(time.time() * 1000)
186+
187+
188+
def _read_payload(path: Path) -> dict[str, Any] | None:
189+
try:
190+
raw = path.read_text(encoding="utf-8")
191+
payload = json.loads(raw)
192+
return payload if isinstance(payload, dict) else None
193+
except FileNotFoundError:
194+
return None
195+
except Exception:
196+
return None
197+
198+
199+
def _payload_to_info(
200+
payload: dict[str, Any] | None,
201+
path: Path,
202+
fallback_instance_id: str,
203+
) -> EditorOperationLeaseInfo:
204+
payload = payload or {}
205+
started = _int_or_default(payload.get("started_unix_ms"), _mtime_ms(path))
206+
expires = _int_or_default(
207+
payload.get("expires_unix_ms"),
208+
started + int(DEFAULT_LEASE_TTL_S * 1000),
209+
)
210+
pid = payload.get("pid")
211+
return EditorOperationLeaseInfo(
212+
instance_id=str(payload.get("instance_id") or fallback_instance_id),
213+
operation=str(payload.get("operation") or "unknown"),
214+
owner=str(payload.get("owner") or "unknown"),
215+
started_unix_ms=started,
216+
expires_unix_ms=expires,
217+
pid=pid if isinstance(pid, int) else None,
218+
path=str(path),
219+
)
220+
221+
222+
def _is_lease_expired(
223+
payload: dict[str, Any] | None,
224+
path: Path,
225+
now_ms: int,
226+
ttl_ms: int,
227+
) -> bool:
228+
if payload and isinstance(payload.get("expires_unix_ms"), int):
229+
return payload["expires_unix_ms"] <= now_ms
230+
try:
231+
return _mtime_ms(path) + ttl_ms <= now_ms
232+
except OSError:
233+
return True
234+
235+
236+
def _mtime_ms(path: Path) -> int:
237+
try:
238+
return int(path.stat().st_mtime * 1000)
239+
except OSError:
240+
return _now_ms()
241+
242+
243+
def _int_or_default(value: Any, default: int) -> int:
244+
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)