187 lines
6.3 KiB
Python
187 lines
6.3 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Stress test for EditorStateCache.GetSnapshot() to reproduce GC allocation spikes.
|
|
|
|
This script rapidly polls the editor state to simulate an MCP client that frequently
|
|
checks Unity's readiness state. Run this while profiling in Unity to see if it
|
|
causes the GC spikes reported in GitHub issue #577.
|
|
|
|
Usage:
|
|
python tools/stress_editor_state.py --duration 30 --interval 0.05
|
|
|
|
While this runs, open Unity Profiler and look for:
|
|
- EditorStateCache.OnUpdate
|
|
- EditorStateCache.GetSnapshot
|
|
- GC.Alloc spikes
|
|
"""
|
|
import asyncio
|
|
import argparse
|
|
import json
|
|
import os
|
|
import struct
|
|
import time
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
|
|
TIMEOUT = 5.0
|
|
|
|
|
|
def find_status_files() -> list[Path]:
|
|
home = Path.home()
|
|
status_dir = Path(os.environ.get("UNITY_MCP_STATUS_DIR", home / ".unity-mcp"))
|
|
if not status_dir.exists():
|
|
return []
|
|
return sorted(status_dir.glob("unity-mcp-status-*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
|
|
|
|
def discover_port(project_path: str | None) -> int:
|
|
default_port = 6400
|
|
files = find_status_files()
|
|
for f in files:
|
|
try:
|
|
data = json.loads(f.read_text())
|
|
port = int(data.get("unity_port", 0) or 0)
|
|
if 0 < port < 65536:
|
|
return port
|
|
except Exception:
|
|
pass
|
|
return default_port
|
|
|
|
|
|
async def read_exact(reader: asyncio.StreamReader, n: int) -> bytes:
|
|
buf = b""
|
|
while len(buf) < n:
|
|
chunk = await reader.read(n - len(buf))
|
|
if not chunk:
|
|
raise ConnectionError("Connection closed while reading")
|
|
buf += chunk
|
|
return buf
|
|
|
|
|
|
async def read_frame(reader: asyncio.StreamReader) -> bytes:
|
|
header = await read_exact(reader, 8)
|
|
(length,) = struct.unpack(">Q", header)
|
|
if length <= 0 or length > (64 * 1024 * 1024):
|
|
raise ValueError(f"Invalid frame length: {length}")
|
|
return await read_exact(reader, length)
|
|
|
|
|
|
async def write_frame(writer: asyncio.StreamWriter, payload: bytes) -> None:
|
|
header = struct.pack(">Q", len(payload))
|
|
writer.write(header)
|
|
writer.write(payload)
|
|
await asyncio.wait_for(writer.drain(), timeout=TIMEOUT)
|
|
|
|
|
|
async def do_handshake(reader: asyncio.StreamReader) -> None:
|
|
line = await reader.readline()
|
|
if not line or b"WELCOME UNITY-MCP" not in line:
|
|
raise ConnectionError(f"Unexpected handshake from server: {line!r}")
|
|
|
|
|
|
def make_get_editor_state_frame() -> bytes:
|
|
payload = {"type": "get_editor_state", "params": {}}
|
|
return json.dumps(payload).encode("utf-8")
|
|
|
|
|
|
async def stress_loop(host: str, port: int, duration: float, interval: float, verbose: bool):
|
|
stop_time = time.time() + duration
|
|
stats = {"requests": 0, "errors": 0, "reconnects": 0}
|
|
|
|
print(f"Starting editor state stress test...")
|
|
print(f" Target: {host}:{port}")
|
|
print(f" Duration: {duration}s")
|
|
print(f" Interval: {interval}s ({1/interval:.1f} requests/sec)")
|
|
print(f" Press Ctrl+C to stop early")
|
|
print()
|
|
|
|
writer = None
|
|
reader = None
|
|
|
|
try:
|
|
while time.time() < stop_time:
|
|
try:
|
|
# Connect if needed
|
|
if writer is None:
|
|
reader, writer = await asyncio.wait_for(
|
|
asyncio.open_connection(host, port), timeout=TIMEOUT
|
|
)
|
|
await asyncio.wait_for(do_handshake(reader), timeout=TIMEOUT)
|
|
if verbose:
|
|
print(f"[{time.time():.2f}] Connected")
|
|
|
|
# Send get_editor_state request
|
|
await write_frame(writer, make_get_editor_state_frame())
|
|
response = await asyncio.wait_for(read_frame(reader), timeout=TIMEOUT)
|
|
stats["requests"] += 1
|
|
|
|
if verbose and stats["requests"] % 20 == 0:
|
|
try:
|
|
data = json.loads(response.decode("utf-8", errors="ignore"))
|
|
seq = data.get("data", {}).get("sequence", "?")
|
|
print(f"[{time.time():.2f}] Request #{stats['requests']}, sequence={seq}")
|
|
except Exception:
|
|
print(f"[{time.time():.2f}] Request #{stats['requests']}")
|
|
|
|
await asyncio.sleep(interval)
|
|
|
|
except (ConnectionError, OSError, asyncio.TimeoutError) as e:
|
|
stats["errors"] += 1
|
|
stats["reconnects"] += 1
|
|
if verbose:
|
|
print(f"[{time.time():.2f}] Connection error: {e}, reconnecting...")
|
|
if writer:
|
|
try:
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
except Exception:
|
|
pass
|
|
writer = None
|
|
reader = None
|
|
await asyncio.sleep(0.5)
|
|
|
|
except KeyboardInterrupt:
|
|
print("\nStopped by user")
|
|
finally:
|
|
if writer:
|
|
try:
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
except Exception:
|
|
pass
|
|
|
|
elapsed = duration - max(0, stop_time - time.time())
|
|
print()
|
|
print("=" * 50)
|
|
print("Results:")
|
|
print(f" Total requests: {stats['requests']}")
|
|
print(f" Errors: {stats['errors']}")
|
|
print(f" Reconnects: {stats['reconnects']}")
|
|
print(f" Elapsed: {elapsed:.1f}s")
|
|
print(f" Rate: {stats['requests']/elapsed:.1f} requests/sec")
|
|
print("=" * 50)
|
|
|
|
|
|
async def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Stress test EditorStateCache.GetSnapshot() to reproduce GC spikes"
|
|
)
|
|
parser.add_argument("--host", default="127.0.0.1", help="Unity bridge host")
|
|
parser.add_argument("--port", type=int, default=0, help="Unity bridge port (0=auto-discover)")
|
|
parser.add_argument("--duration", type=float, default=30.0, help="Test duration in seconds")
|
|
parser.add_argument("--interval", type=float, default=0.05, help="Interval between requests (0.05 = 20/sec)")
|
|
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|
|
args = parser.parse_args()
|
|
|
|
port = args.port if args.port > 0 else discover_port(None)
|
|
|
|
await stress_loop(args.host, port, args.duration, args.interval, args.verbose)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
asyncio.run(main())
|
|
except KeyboardInterrupt:
|
|
pass
|