Fix: Unity Editor reload crash + debug-noise reduction (#266)

* Editor: fix reload crash via cooperative cancellation + safe shutdown; gate ExecuteMenuItem logs behind debug flag

* Dev: add tools/stress_mcp.py stress utility and document usage in README-DEV

* docs: document immediate-reload stress test and streamline stress tool (immediate refresh, precondition SHA, EOF edits); revert to manage_script.read for compatibility

* fix: harden editor reload shutdown; gate logs; structured errors for ManageGameObject; test hardening

* tools(stress): cross-platform Assets path derivation using Path.parts with project-root fallback

* stress: add IO timeouts, jitter, retries, and storm mode to reduce reload crashes
main
dsarno 2025-09-06 10:58:11 -07:00 committed by GitHub
parent 741b4f7671
commit eaf14ef46f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 561 additions and 29 deletions

View File

@ -66,6 +66,59 @@ To find it reliably:
Note: In recent builds, the Python server sources are also bundled inside the package under `UnityMcpServer~/src`. This is handy for local testing or pointing MCP clients directly at the packaged server. Note: In recent builds, the Python server sources are also bundled inside the package under `UnityMcpServer~/src`. This is handy for local testing or pointing MCP clients directly at the packaged server.
## MCP Bridge Stress Test
An on-demand stress utility exercises the MCP bridge with multiple concurrent clients while triggering real script reloads via immediate script edits (no menu calls required).
### Script
- `tools/stress_mcp.py`
### What it does
- Starts N TCP clients against the Unity MCP bridge (default port auto-discovered from `~/.unity-mcp/unity-mcp-status-*.json`).
- Sends lightweight framed `ping` keepalives to maintain concurrency.
- In parallel, appends a unique marker comment to a target C# file using `manage_script.apply_text_edits` with:
- `options.refresh = "immediate"` to force an import/compile immediately (triggers domain reload), and
- `precondition_sha256` computed from the current file contents to avoid drift.
- Uses EOF insertion to avoid header/`using`-guard edits.
### Usage (local)
```bash
# Recommended: use the included large script in the test project
python3 tools/stress_mcp.py \
--duration 60 \
--clients 8 \
--unity-file "TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs"
```
Flags:
- `--project` Unity project path (auto-detected to the included test project by default)
- `--unity-file` C# file to edit (defaults to the long test script)
- `--clients` number of concurrent clients (default 10)
- `--duration` seconds to run (default 60)
### Expected outcome
- No Unity Editor crashes during reload churn
- Immediate reloads after each applied edit (no `Assets/Refresh` menu calls)
- Some transient disconnects or a few failed calls may occur during domain reload; the tool retries and continues
- JSON summary printed at the end, e.g.:
- `{"port": 6400, "stats": {"pings": 28566, "applies": 69, "disconnects": 0, "errors": 0}}`
### Notes and troubleshooting
- Immediate vs debounced:
- The tool sets `options.refresh = "immediate"` so changes compile instantly. If you only need churn (not per-edit confirmation), switch to debounced to reduce mid-reload failures.
- Precondition required:
- `apply_text_edits` requires `precondition_sha256` on larger files. The tool reads the file first to compute the SHA.
- Edit location:
- To avoid header guards or complex ranges, the tool appends a one-line marker at EOF each cycle.
- Read API:
- The bridge currently supports `manage_script.read` for file reads. You may see a deprecation warning; it's harmless for this internal tool.
- Transient failures:
- Occasional `apply_errors` often indicate the connection reloaded mid-reply. Edits still typically apply; the loop continues on the next iteration.
### CI guidance
- Keep this out of default PR CI due to Unity/editor requirements and runtime variability.
- Optionally run it as a manual workflow or nightly job on a Unity-capable runner.
## CI Test Workflow (GitHub Actions) ## CI Test Workflow (GitHub Actions)
We provide a CI job to run a Natural Language Editing mini-suite against the Unity test project. It spins up a headless Unity container and connects via the MCP bridge. We provide a CI job to run a Natural Language Editing mini-suite against the Unity test project. It spins up a headless Unity container and connects via the MCP bridge.

View File

@ -261,6 +261,31 @@ namespace MCPForUnityTests.Editor.Tools
// The collect-and-continue behavior means we should get an error response // The collect-and-continue behavior means we should get an error response
// that contains info about the failed properties, but valid ones were still applied // that contains info about the failed properties, but valid ones were still applied
// This proves the collect-and-continue behavior is working // This proves the collect-and-continue behavior is working
// Harden: verify structured error response with failures list contains both invalid fields
var successProp = result.GetType().GetProperty("success");
Assert.IsNotNull(successProp, "Result should expose 'success' property");
Assert.IsFalse((bool)successProp.GetValue(result), "Result.success should be false for partial failure");
var dataProp = result.GetType().GetProperty("data");
Assert.IsNotNull(dataProp, "Result should include 'data' with errors");
var dataVal = dataProp.GetValue(result);
Assert.IsNotNull(dataVal, "Result.data should not be null");
var errorsProp = dataVal.GetType().GetProperty("errors");
Assert.IsNotNull(errorsProp, "Result.data should include 'errors' list");
var errorsEnum = errorsProp.GetValue(dataVal) as System.Collections.IEnumerable;
Assert.IsNotNull(errorsEnum, "errors should be enumerable");
bool foundRotatoin = false;
bool foundInvalidProp = false;
foreach (var err in errorsEnum)
{
string s = err?.ToString() ?? string.Empty;
if (s.Contains("rotatoin")) foundRotatoin = true;
if (s.Contains("invalidProp")) foundInvalidProp = true;
}
Assert.IsTrue(foundRotatoin, "errors should mention the misspelled 'rotatoin' property");
Assert.IsTrue(foundInvalidProp, "errors should mention the 'invalidProp' property");
} }
[Test] [Test]
@ -307,6 +332,28 @@ namespace MCPForUnityTests.Editor.Tools
// The key test: processing continued after the exception and set useGravity // The key test: processing continued after the exception and set useGravity
// This proves the collect-and-continue behavior works even with exceptions // This proves the collect-and-continue behavior works even with exceptions
// Harden: verify structured error response contains velocity failure
var successProp2 = result.GetType().GetProperty("success");
Assert.IsNotNull(successProp2, "Result should expose 'success' property");
Assert.IsFalse((bool)successProp2.GetValue(result), "Result.success should be false when an exception occurs for a property");
var dataProp2 = result.GetType().GetProperty("data");
Assert.IsNotNull(dataProp2, "Result should include 'data' with errors");
var dataVal2 = dataProp2.GetValue(result);
Assert.IsNotNull(dataVal2, "Result.data should not be null");
var errorsProp2 = dataVal2.GetType().GetProperty("errors");
Assert.IsNotNull(errorsProp2, "Result.data should include 'errors' list");
var errorsEnum2 = errorsProp2.GetValue(dataVal2) as System.Collections.IEnumerable;
Assert.IsNotNull(errorsEnum2, "errors should be enumerable");
bool foundVelocityError = false;
foreach (var err in errorsEnum2)
{
string s = err?.ToString() ?? string.Empty;
if (s.Contains("velocity")) { foundVelocityError = true; break; }
}
Assert.IsTrue(foundVelocityError, "errors should include a message referencing 'velocity'");
} }
} }
} }

View File

@ -23,6 +23,11 @@ namespace MCPForUnity.Editor
private static bool isRunning = false; private static bool isRunning = false;
private static readonly object lockObj = new(); private static readonly object lockObj = new();
private static readonly object startStopLock = new(); private static readonly object startStopLock = new();
private static readonly object clientsLock = new();
private static readonly System.Collections.Generic.HashSet<TcpClient> activeClients = new();
private static CancellationTokenSource cts;
private static Task listenerTask;
private static int processingCommands = 0;
private static bool initScheduled = false; private static bool initScheduled = false;
private static bool ensureUpdateHooked = false; private static bool ensureUpdateHooked = false;
private static bool isStarting = false; private static bool isStarting = false;
@ -193,9 +198,15 @@ namespace MCPForUnity.Editor
} }
isStarting = true; isStarting = true;
try
{
// Attempt start; if it succeeds, remove the hook to avoid overhead // Attempt start; if it succeeds, remove the hook to avoid overhead
Start(); Start();
}
finally
{
isStarting = false; isStarting = false;
}
if (isRunning) if (isRunning)
{ {
EditorApplication.update -= EnsureStartedOnEditorIdle; EditorApplication.update -= EnsureStartedOnEditorIdle;
@ -319,8 +330,17 @@ namespace MCPForUnity.Editor
string platform = Application.platform.ToString(); string platform = Application.platform.ToString();
string serverVer = ReadInstalledServerVersionSafe(); string serverVer = ReadInstalledServerVersionSafe();
Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge started on port {currentUnityPort}. (OS={platform}, server={serverVer})"); Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge started on port {currentUnityPort}. (OS={platform}, server={serverVer})");
Task.Run(ListenerLoop); // Start background listener with cooperative cancellation
cts = new CancellationTokenSource();
listenerTask = Task.Run(() => ListenerLoopAsync(cts.Token));
EditorApplication.update += ProcessCommands; EditorApplication.update += ProcessCommands;
// Ensure lifecycle events are (re)subscribed in case Stop() removed them earlier in-domain
try { AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; } catch { }
try { AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; } catch { }
try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { }
try { AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; } catch { }
try { EditorApplication.quitting -= Stop; } catch { }
try { EditorApplication.quitting += Stop; } catch { }
// Write initial heartbeat immediately // Write initial heartbeat immediately
heartbeatSeq++; heartbeatSeq++;
WriteHeartbeat(false, "ready"); WriteHeartbeat(false, "ready");
@ -335,6 +355,7 @@ namespace MCPForUnity.Editor
public static void Stop() public static void Stop()
{ {
Task toWait = null;
lock (startStopLock) lock (startStopLock)
{ {
if (!isRunning) if (!isRunning)
@ -346,23 +367,55 @@ namespace MCPForUnity.Editor
{ {
// Mark as stopping early to avoid accept logging during disposal // Mark as stopping early to avoid accept logging during disposal
isRunning = false; isRunning = false;
// Mark heartbeat one last time before stopping
WriteHeartbeat(false, "stopped"); // Quiesce background listener quickly
listener?.Stop(); var cancel = cts;
cts = null;
try { cancel?.Cancel(); } catch { }
try { listener?.Stop(); } catch { }
listener = null; listener = null;
EditorApplication.update -= ProcessCommands;
if (IsDebugEnabled()) Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge stopped."); // Capture background task to wait briefly outside the lock
toWait = listenerTask;
listenerTask = null;
} }
catch (Exception ex) catch (Exception ex)
{ {
Debug.LogError($"Error stopping MCPForUnityBridge: {ex.Message}"); Debug.LogError($"Error stopping MCPForUnityBridge: {ex.Message}");
} }
} }
// Proactively close all active client sockets to unblock any pending reads
TcpClient[] toClose;
lock (clientsLock)
{
toClose = activeClients.ToArray();
activeClients.Clear();
}
foreach (var c in toClose)
{
try { c.Close(); } catch { }
} }
private static async Task ListenerLoop() // Give the background loop a short window to exit without blocking the editor
if (toWait != null)
{ {
while (isRunning) try { toWait.Wait(100); } catch { }
}
// Now unhook editor events safely
try { EditorApplication.update -= ProcessCommands; } catch { }
try { AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; } catch { }
try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { }
try { EditorApplication.quitting -= Stop; } catch { }
if (IsDebugEnabled()) Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge stopped.");
}
private static async Task ListenerLoopAsync(CancellationToken token)
{
while (isRunning && !token.IsCancellationRequested)
{ {
try try
{ {
@ -378,19 +431,23 @@ namespace MCPForUnity.Editor
client.ReceiveTimeout = 60000; // 60 seconds client.ReceiveTimeout = 60000; // 60 seconds
// Fire and forget each client connection // Fire and forget each client connection
_ = HandleClientAsync(client); _ = Task.Run(() => HandleClientAsync(client, token), token);
} }
catch (ObjectDisposedException) catch (ObjectDisposedException)
{ {
// Listener was disposed during stop/reload; exit quietly // Listener was disposed during stop/reload; exit quietly
if (!isRunning) if (!isRunning || token.IsCancellationRequested)
{ {
break; break;
} }
} }
catch (OperationCanceledException)
{
break;
}
catch (Exception ex) catch (Exception ex)
{ {
if (isRunning) if (isRunning && !token.IsCancellationRequested)
{ {
if (IsDebugEnabled()) Debug.LogError($"Listener error: {ex.Message}"); if (IsDebugEnabled()) Debug.LogError($"Listener error: {ex.Message}");
} }
@ -398,10 +455,13 @@ namespace MCPForUnity.Editor
} }
} }
private static async Task HandleClientAsync(TcpClient client) private static async Task HandleClientAsync(TcpClient client, CancellationToken token)
{ {
using (client) using (client)
using (NetworkStream stream = client.GetStream()) using (NetworkStream stream = client.GetStream())
{
lock (clientsLock) { activeClients.Add(client); }
try
{ {
// Framed I/O only; legacy mode removed // Framed I/O only; legacy mode removed
try try
@ -437,12 +497,12 @@ namespace MCPForUnity.Editor
return; // abort this client return; // abort this client
} }
while (isRunning) while (isRunning && !token.IsCancellationRequested)
{ {
try try
{ {
// Strict framed mode only: enforced framed I/O for this connection // Strict framed mode only: enforced framed I/O for this connection
string commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs); string commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs, token).ConfigureAwait(false);
try try
{ {
@ -454,7 +514,7 @@ namespace MCPForUnity.Editor
} }
catch { } catch { }
string commandId = Guid.NewGuid().ToString(); string commandId = Guid.NewGuid().ToString();
TaskCompletionSource<string> tcs = new(); var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
// Special handling for ping command to avoid JSON parsing // Special handling for ping command to avoid JSON parsing
if (commandText.Trim() == "ping") if (commandText.Trim() == "ping")
@ -473,7 +533,7 @@ namespace MCPForUnity.Editor
commandQueue[commandId] = (commandText, tcs); commandQueue[commandId] = (commandText, tcs);
} }
string response = await tcs.Task; string response = await tcs.Task.ConfigureAwait(false);
byte[] responseBytes = System.Text.Encoding.UTF8.GetBytes(response); byte[] responseBytes = System.Text.Encoding.UTF8.GetBytes(response);
await WriteFrameAsync(stream, responseBytes); await WriteFrameAsync(stream, responseBytes);
} }
@ -497,6 +557,11 @@ namespace MCPForUnity.Editor
} }
} }
} }
finally
{
lock (clientsLock) { activeClients.Remove(client); }
}
}
} }
// Timeout-aware exact read helper with cancellation; avoids indefinite stalls and background task leaks // Timeout-aware exact read helper with cancellation; avoids indefinite stalls and background task leaks
@ -574,9 +639,9 @@ namespace MCPForUnity.Editor
#endif #endif
} }
private static async System.Threading.Tasks.Task<string> ReadFrameAsUtf8Async(NetworkStream stream, int timeoutMs) private static async System.Threading.Tasks.Task<string> ReadFrameAsUtf8Async(NetworkStream stream, int timeoutMs, CancellationToken cancel)
{ {
byte[] header = await ReadExactAsync(stream, 8, timeoutMs); byte[] header = await ReadExactAsync(stream, 8, timeoutMs, cancel).ConfigureAwait(false);
ulong payloadLen = ReadUInt64BigEndian(header); ulong payloadLen = ReadUInt64BigEndian(header);
if (payloadLen > MaxFrameBytes) if (payloadLen > MaxFrameBytes)
{ {
@ -589,7 +654,7 @@ namespace MCPForUnity.Editor
throw new System.IO.IOException("Frame too large for buffer"); throw new System.IO.IOException("Frame too large for buffer");
} }
int count = (int)payloadLen; int count = (int)payloadLen;
byte[] payload = await ReadExactAsync(stream, count, timeoutMs); byte[] payload = await ReadExactAsync(stream, count, timeoutMs, cancel).ConfigureAwait(false);
return System.Text.Encoding.UTF8.GetString(payload); return System.Text.Encoding.UTF8.GetString(payload);
} }
@ -623,6 +688,10 @@ namespace MCPForUnity.Editor
} }
private static void ProcessCommands() private static void ProcessCommands()
{
if (!isRunning) return;
if (Interlocked.Exchange(ref processingCommands, 1) == 1) return; // reentrancy guard
try
{ {
// Heartbeat without holding the queue lock // Heartbeat without holding the queue lock
double now = EditorApplication.timeSinceStartup; double now = EditorApplication.timeSinceStartup;
@ -735,6 +804,11 @@ namespace MCPForUnity.Editor
lock (lockObj) { commandQueue.Remove(id); } lock (lockObj) { commandQueue.Remove(id); }
} }
} }
finally
{
Interlocked.Exchange(ref processingCommands, 0);
}
}
// Helper method to check if a string is valid JSON // Helper method to check if a string is valid JSON
private static bool IsValidJson(string text) private static bool IsValidJson(string text)
@ -865,8 +939,7 @@ namespace MCPForUnity.Editor
{ {
// Stop cleanly before reload so sockets close and clients see 'reloading' // Stop cleanly before reload so sockets close and clients see 'reloading'
try { Stop(); } catch { } try { Stop(); } catch { }
WriteHeartbeat(true, "reloading"); // Avoid file I/O or heavy work here
LogBreadcrumb("Reload");
} }
private static void OnAfterAssemblyReload() private static void OnAfterAssemblyReload()

View File

@ -27,7 +27,7 @@ namespace MCPForUnity.Editor.Tools
/// </summary> /// </summary>
public static object HandleCommand(JObject @params) public static object HandleCommand(JObject @params)
{ {
string action = @params["action"]?.ToString().ToLower() ?? "execute"; // Default action string action = (@params["action"]?.ToString())?.ToLowerInvariant() ?? "execute"; // Default action
try try
{ {
@ -96,14 +96,15 @@ namespace MCPForUnity.Editor.Tools
try try
{ {
// Trace incoming execute requests // Trace incoming execute requests (debug-gated)
Debug.Log($"[ExecuteMenuItem] Request to execute menu: '{menuPath}'"); McpLog.Info($"[ExecuteMenuItem] Request to execute menu: '{menuPath}'", always: false);
// Execute synchronously. This code runs on the Editor main thread in our bridge path. // Execute synchronously. This code runs on the Editor main thread in our bridge path.
bool executed = EditorApplication.ExecuteMenuItem(menuPath); bool executed = EditorApplication.ExecuteMenuItem(menuPath);
if (executed) if (executed)
{ {
Debug.Log($"[ExecuteMenuItem] Executed successfully: '{menuPath}'"); // Success trace (debug-gated)
McpLog.Info($"[ExecuteMenuItem] Executed successfully: '{menuPath}'", always: false);
return Response.Success( return Response.Success(
$"Executed menu item: '{menuPath}'", $"Executed menu item: '{menuPath}'",
new { executed = true, menuPath } new { executed = true, menuPath }

View File

@ -814,9 +814,34 @@ namespace MCPForUnity.Editor.Tools
// Return component errors if any occurred (after processing all components) // Return component errors if any occurred (after processing all components)
if (componentErrors.Count > 0) if (componentErrors.Count > 0)
{ {
// Aggregate flattened error strings to make tests/API assertions simpler
var aggregatedErrors = new System.Collections.Generic.List<string>();
foreach (var errorObj in componentErrors)
{
try
{
var dataProp = errorObj?.GetType().GetProperty("data");
var dataVal = dataProp?.GetValue(errorObj);
if (dataVal != null)
{
var errorsProp = dataVal.GetType().GetProperty("errors");
var errorsEnum = errorsProp?.GetValue(dataVal) as System.Collections.IEnumerable;
if (errorsEnum != null)
{
foreach (var item in errorsEnum)
{
var s = item?.ToString();
if (!string.IsNullOrEmpty(s)) aggregatedErrors.Add(s);
}
}
}
}
catch { }
}
return Response.Error( return Response.Error(
$"One or more component property operations failed on '{targetGo.name}'.", $"One or more component property operations failed on '{targetGo.name}'.",
new { componentErrors = componentErrors } new { componentErrors = componentErrors, errors = aggregatedErrors }
); );
} }

333
tools/stress_mcp.py Normal file
View File

@ -0,0 +1,333 @@
#!/usr/bin/env python3
import asyncio
import argparse
import json
import os
import struct
import time
from pathlib import Path
import random
import sys
TIMEOUT = float(os.environ.get("MCP_STRESS_TIMEOUT", "2.0"))
DEBUG = os.environ.get("MCP_STRESS_DEBUG", "").lower() in ("1", "true", "yes")
def dlog(*args):
if DEBUG:
print(*args, file=sys.stderr)
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 bridge port if nothing found
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)
proj = data.get("project_path") or ""
if project_path:
# Match status for the given project if possible
if proj and project_path in proj:
if 0 < port < 65536:
return port
else:
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:
# Server sends a single line handshake: "WELCOME UNITY-MCP 1 FRAMING=1\n"
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_ping_frame() -> bytes:
return b"ping"
def make_execute_menu_item(menu_path: str) -> bytes:
# Retained for manual debugging; not used in normal stress runs
payload = {"type": "execute_menu_item", "params": {"action": "execute", "menu_path": menu_path}}
return json.dumps(payload).encode("utf-8")
async def client_loop(idx: int, host: str, port: int, stop_time: float, stats: dict):
reconnect_delay = 0.2
while time.time() < stop_time:
writer = None
try:
# slight stagger to prevent burst synchronization across clients
await asyncio.sleep(0.003 * (idx % 11))
reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=TIMEOUT)
await asyncio.wait_for(do_handshake(reader), timeout=TIMEOUT)
# Send a quick ping first
await write_frame(writer, make_ping_frame())
_ = await asyncio.wait_for(read_frame(reader), timeout=TIMEOUT) # ignore content
# Main activity loop (keep-alive + light load). Edit spam handled by reload_churn_task.
while time.time() < stop_time:
# Ping-only; edits are sent via reload_churn_task to avoid console spam
await write_frame(writer, make_ping_frame())
_ = await asyncio.wait_for(read_frame(reader), timeout=TIMEOUT)
stats["pings"] += 1
await asyncio.sleep(0.02 + random.uniform(-0.003, 0.003))
except (ConnectionError, OSError, asyncio.IncompleteReadError, asyncio.TimeoutError):
stats["disconnects"] += 1
dlog(f"[client {idx}] disconnect/backoff {reconnect_delay}s")
await asyncio.sleep(reconnect_delay)
reconnect_delay = min(reconnect_delay * 1.5, 2.0)
continue
except Exception:
stats["errors"] += 1
dlog(f"[client {idx}] unexpected error")
await asyncio.sleep(0.2)
continue
finally:
if writer is not None:
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
async def reload_churn_task(project_path: str, stop_time: float, unity_file: str | None, host: str, port: int, stats: dict, storm_count: int = 1):
# Use script edit tool to touch a C# file, which triggers compilation reliably
path = Path(unity_file) if unity_file else None
seq = 0
proj_root = Path(project_path).resolve() if project_path else None
# Build candidate list for storm mode
candidates: list[Path] = []
if proj_root:
try:
for p in (proj_root / "Assets").rglob("*.cs"):
candidates.append(p.resolve())
except Exception:
candidates = []
if path and path.exists():
rp = path.resolve()
if rp not in candidates:
candidates.append(rp)
while time.time() < stop_time:
try:
if path and path.exists():
# Determine files to touch this cycle
targets: list[Path]
if storm_count and storm_count > 1 and candidates:
k = min(max(1, storm_count), len(candidates))
targets = random.sample(candidates, k)
else:
targets = [path]
for tpath in targets:
# Build a tiny ApplyTextEdits request that toggles a trailing comment
relative = None
try:
# Derive Unity-relative path under Assets/ (cross-platform)
resolved = tpath.resolve()
parts = list(resolved.parts)
if "Assets" in parts:
i = parts.index("Assets")
relative = Path(*parts[i:]).as_posix()
elif proj_root and str(resolved).startswith(str(proj_root)):
rel = resolved.relative_to(proj_root)
parts2 = list(rel.parts)
if "Assets" in parts2:
i2 = parts2.index("Assets")
relative = Path(*parts2[i2:]).as_posix()
except Exception:
relative = None
if relative:
# Derive name and directory for ManageScript and compute precondition SHA + EOF position
name_base = Path(relative).stem
dir_path = str(Path(relative).parent).replace('\\', '/')
# 1) Read current contents via manage_script.read to compute SHA and true EOF location
contents = None
read_success = False
for attempt in range(3):
writer = None
try:
reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=TIMEOUT)
await asyncio.wait_for(do_handshake(reader), timeout=TIMEOUT)
read_payload = {
"type": "manage_script",
"params": {
"action": "read",
"name": name_base,
"path": dir_path
}
}
await write_frame(writer, json.dumps(read_payload).encode("utf-8"))
resp = await asyncio.wait_for(read_frame(reader), timeout=TIMEOUT)
read_obj = json.loads(resp.decode("utf-8", errors="ignore"))
result = read_obj.get("result", read_obj) if isinstance(read_obj, dict) else {}
if result.get("success"):
data_obj = result.get("data", {})
contents = data_obj.get("contents") or ""
read_success = True
break
except Exception:
# retry with backoff
await asyncio.sleep(0.2 * (2 ** attempt) + random.uniform(0.0, 0.1))
finally:
if 'writer' in locals() and writer is not None:
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
if not read_success or contents is None:
stats["apply_errors"] = stats.get("apply_errors", 0) + 1
await asyncio.sleep(0.5)
continue
# Compute SHA and EOF insertion point
import hashlib
sha = hashlib.sha256(contents.encode("utf-8")).hexdigest()
lines = contents.splitlines(keepends=True)
# Insert at true EOF (safe against header guards)
end_line = len(lines) + 1 # 1-based exclusive end
end_col = 1
# Build a unique marker append; ensure it begins with a newline if needed
marker = f"// MCP_STRESS seq={seq} time={int(time.time())}"
seq += 1
insert_text = ("\n" if not contents.endswith("\n") else "") + marker + "\n"
# 2) Apply text edits with immediate refresh and precondition
apply_payload = {
"type": "manage_script",
"params": {
"action": "apply_text_edits",
"name": name_base,
"path": dir_path,
"edits": [
{
"startLine": end_line,
"startCol": end_col,
"endLine": end_line,
"endCol": end_col,
"newText": insert_text
}
],
"precondition_sha256": sha,
"options": {"refresh": "immediate", "validate": "standard"}
}
}
apply_success = False
for attempt in range(3):
writer = None
try:
reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=TIMEOUT)
await asyncio.wait_for(do_handshake(reader), timeout=TIMEOUT)
await write_frame(writer, json.dumps(apply_payload).encode("utf-8"))
resp = await asyncio.wait_for(read_frame(reader), timeout=TIMEOUT)
try:
data = json.loads(resp.decode("utf-8", errors="ignore"))
result = data.get("result", data) if isinstance(data, dict) else {}
ok = bool(result.get("success", False))
if ok:
stats["applies"] = stats.get("applies", 0) + 1
apply_success = True
break
except Exception:
# fall through to retry
pass
except Exception:
# retry with backoff
await asyncio.sleep(0.2 * (2 ** attempt) + random.uniform(0.0, 0.1))
finally:
if 'writer' in locals() and writer is not None:
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
if not apply_success:
stats["apply_errors"] = stats.get("apply_errors", 0) + 1
except Exception:
pass
await asyncio.sleep(1.0)
async def main():
ap = argparse.ArgumentParser(description="Stress test the Unity MCP bridge with concurrent clients and reload churn")
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--project", default=str(Path(__file__).resolve().parents[1] / "TestProjects" / "UnityMCPTests"))
ap.add_argument("--unity-file", default=str(Path(__file__).resolve().parents[1] / "TestProjects" / "UnityMCPTests" / "Assets" / "Scripts" / "LongUnityScriptClaudeTest.cs"))
ap.add_argument("--clients", type=int, default=10)
ap.add_argument("--duration", type=int, default=60)
ap.add_argument("--storm-count", type=int, default=1, help="Number of scripts to touch each cycle")
args = ap.parse_args()
port = discover_port(args.project)
stop_time = time.time() + max(10, args.duration)
stats = {"pings": 0, "menus": 0, "mods": 0, "disconnects": 0, "errors": 0}
tasks = []
# Spawn clients
for i in range(max(1, args.clients)):
tasks.append(asyncio.create_task(client_loop(i, args.host, port, stop_time, stats)))
# Spawn reload churn task
tasks.append(asyncio.create_task(reload_churn_task(args.project, stop_time, args.unity_file, args.host, port, stats, storm_count=args.storm_count)))
await asyncio.gather(*tasks, return_exceptions=True)
print(json.dumps({"port": port, "stats": stats}, indent=2))
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass