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 crashesmain
parent
741b4f7671
commit
eaf14ef46f
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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'");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
||||||
// Attempt start; if it succeeds, remove the hook to avoid overhead
|
try
|
||||||
Start();
|
{
|
||||||
isStarting = false;
|
// Attempt start; if it succeeds, remove the hook to avoid overhead
|
||||||
|
Start();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
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 { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give the background loop a short window to exit without blocking the editor
|
||||||
|
if (toWait != null)
|
||||||
|
{
|
||||||
|
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 ListenerLoop()
|
private static async Task ListenerLoopAsync(CancellationToken token)
|
||||||
{
|
{
|
||||||
while (isRunning)
|
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,11 +455,14 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
@ -496,6 +556,11 @@ namespace MCPForUnity.Editor
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
lock (clientsLock) { activeClients.Remove(client); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -624,6 +689,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;
|
||||||
if (now >= nextHeartbeatAt)
|
if (now >= nextHeartbeatAt)
|
||||||
|
|
@ -734,6 +803,11 @@ namespace MCPForUnity.Editor
|
||||||
// Remove quickly under lock
|
// Remove quickly under lock
|
||||||
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
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
Loading…
Reference in New Issue