Skip to content

Commit eaf14ef

Browse files
authored
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
1 parent 741b4f7 commit eaf14ef

File tree

6 files changed

+561
-29
lines changed

6 files changed

+561
-29
lines changed

README-DEV.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,59 @@ To find it reliably:
6666

6767
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.
6868

69+
## MCP Bridge Stress Test
70+
71+
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).
72+
73+
### Script
74+
- `tools/stress_mcp.py`
75+
76+
### What it does
77+
- Starts N TCP clients against the Unity MCP bridge (default port auto-discovered from `~/.unity-mcp/unity-mcp-status-*.json`).
78+
- Sends lightweight framed `ping` keepalives to maintain concurrency.
79+
- In parallel, appends a unique marker comment to a target C# file using `manage_script.apply_text_edits` with:
80+
- `options.refresh = "immediate"` to force an import/compile immediately (triggers domain reload), and
81+
- `precondition_sha256` computed from the current file contents to avoid drift.
82+
- Uses EOF insertion to avoid header/`using`-guard edits.
83+
84+
### Usage (local)
85+
```bash
86+
# Recommended: use the included large script in the test project
87+
python3 tools/stress_mcp.py \
88+
--duration 60 \
89+
--clients 8 \
90+
--unity-file "TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs"
91+
```
92+
93+
Flags:
94+
- `--project` Unity project path (auto-detected to the included test project by default)
95+
- `--unity-file` C# file to edit (defaults to the long test script)
96+
- `--clients` number of concurrent clients (default 10)
97+
- `--duration` seconds to run (default 60)
98+
99+
### Expected outcome
100+
- No Unity Editor crashes during reload churn
101+
- Immediate reloads after each applied edit (no `Assets/Refresh` menu calls)
102+
- Some transient disconnects or a few failed calls may occur during domain reload; the tool retries and continues
103+
- JSON summary printed at the end, e.g.:
104+
- `{"port": 6400, "stats": {"pings": 28566, "applies": 69, "disconnects": 0, "errors": 0}}`
105+
106+
### Notes and troubleshooting
107+
- Immediate vs debounced:
108+
- 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.
109+
- Precondition required:
110+
- `apply_text_edits` requires `precondition_sha256` on larger files. The tool reads the file first to compute the SHA.
111+
- Edit location:
112+
- To avoid header guards or complex ranges, the tool appends a one-line marker at EOF each cycle.
113+
- Read API:
114+
- The bridge currently supports `manage_script.read` for file reads. You may see a deprecation warning; it's harmless for this internal tool.
115+
- Transient failures:
116+
- Occasional `apply_errors` often indicate the connection reloaded mid-reply. Edits still typically apply; the loop continues on the next iteration.
117+
118+
### CI guidance
119+
- Keep this out of default PR CI due to Unity/editor requirements and runtime variability.
120+
- Optionally run it as a manual workflow or nightly job on a Unity-capable runner.
121+
69122
## CI Test Workflow (GitHub Actions)
70123

71124
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.

TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,31 @@ public void SetComponentProperties_CollectsAllFailuresAndAppliesValidOnes()
261261
// The collect-and-continue behavior means we should get an error response
262262
// that contains info about the failed properties, but valid ones were still applied
263263
// This proves the collect-and-continue behavior is working
264+
265+
// Harden: verify structured error response with failures list contains both invalid fields
266+
var successProp = result.GetType().GetProperty("success");
267+
Assert.IsNotNull(successProp, "Result should expose 'success' property");
268+
Assert.IsFalse((bool)successProp.GetValue(result), "Result.success should be false for partial failure");
269+
270+
var dataProp = result.GetType().GetProperty("data");
271+
Assert.IsNotNull(dataProp, "Result should include 'data' with errors");
272+
var dataVal = dataProp.GetValue(result);
273+
Assert.IsNotNull(dataVal, "Result.data should not be null");
274+
var errorsProp = dataVal.GetType().GetProperty("errors");
275+
Assert.IsNotNull(errorsProp, "Result.data should include 'errors' list");
276+
var errorsEnum = errorsProp.GetValue(dataVal) as System.Collections.IEnumerable;
277+
Assert.IsNotNull(errorsEnum, "errors should be enumerable");
278+
279+
bool foundRotatoin = false;
280+
bool foundInvalidProp = false;
281+
foreach (var err in errorsEnum)
282+
{
283+
string s = err?.ToString() ?? string.Empty;
284+
if (s.Contains("rotatoin")) foundRotatoin = true;
285+
if (s.Contains("invalidProp")) foundInvalidProp = true;
286+
}
287+
Assert.IsTrue(foundRotatoin, "errors should mention the misspelled 'rotatoin' property");
288+
Assert.IsTrue(foundInvalidProp, "errors should mention the 'invalidProp' property");
264289
}
265290

266291
[Test]
@@ -307,6 +332,28 @@ public void SetComponentProperties_ContinuesAfterException()
307332

308333
// The key test: processing continued after the exception and set useGravity
309334
// This proves the collect-and-continue behavior works even with exceptions
335+
336+
// Harden: verify structured error response contains velocity failure
337+
var successProp2 = result.GetType().GetProperty("success");
338+
Assert.IsNotNull(successProp2, "Result should expose 'success' property");
339+
Assert.IsFalse((bool)successProp2.GetValue(result), "Result.success should be false when an exception occurs for a property");
340+
341+
var dataProp2 = result.GetType().GetProperty("data");
342+
Assert.IsNotNull(dataProp2, "Result should include 'data' with errors");
343+
var dataVal2 = dataProp2.GetValue(result);
344+
Assert.IsNotNull(dataVal2, "Result.data should not be null");
345+
var errorsProp2 = dataVal2.GetType().GetProperty("errors");
346+
Assert.IsNotNull(errorsProp2, "Result.data should include 'errors' list");
347+
var errorsEnum2 = errorsProp2.GetValue(dataVal2) as System.Collections.IEnumerable;
348+
Assert.IsNotNull(errorsEnum2, "errors should be enumerable");
349+
350+
bool foundVelocityError = false;
351+
foreach (var err in errorsEnum2)
352+
{
353+
string s = err?.ToString() ?? string.Empty;
354+
if (s.Contains("velocity")) { foundVelocityError = true; break; }
355+
}
356+
Assert.IsTrue(foundVelocityError, "errors should include a message referencing 'velocity'");
310357
}
311358
}
312359
}

UnityMcpBridge/Editor/MCPForUnityBridge.cs

Lines changed: 97 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ public static partial class MCPForUnityBridge
2323
private static bool isRunning = false;
2424
private static readonly object lockObj = new();
2525
private static readonly object startStopLock = new();
26+
private static readonly object clientsLock = new();
27+
private static readonly System.Collections.Generic.HashSet<TcpClient> activeClients = new();
28+
private static CancellationTokenSource cts;
29+
private static Task listenerTask;
30+
private static int processingCommands = 0;
2631
private static bool initScheduled = false;
2732
private static bool ensureUpdateHooked = false;
2833
private static bool isStarting = false;
@@ -193,9 +198,15 @@ private static void EnsureStartedOnEditorIdle()
193198
}
194199

195200
isStarting = true;
196-
// Attempt start; if it succeeds, remove the hook to avoid overhead
197-
Start();
198-
isStarting = false;
201+
try
202+
{
203+
// Attempt start; if it succeeds, remove the hook to avoid overhead
204+
Start();
205+
}
206+
finally
207+
{
208+
isStarting = false;
209+
}
199210
if (isRunning)
200211
{
201212
EditorApplication.update -= EnsureStartedOnEditorIdle;
@@ -319,8 +330,17 @@ public static void Start()
319330
string platform = Application.platform.ToString();
320331
string serverVer = ReadInstalledServerVersionSafe();
321332
Debug.Log($"<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge started on port {currentUnityPort}. (OS={platform}, server={serverVer})");
322-
Task.Run(ListenerLoop);
333+
// Start background listener with cooperative cancellation
334+
cts = new CancellationTokenSource();
335+
listenerTask = Task.Run(() => ListenerLoopAsync(cts.Token));
323336
EditorApplication.update += ProcessCommands;
337+
// Ensure lifecycle events are (re)subscribed in case Stop() removed them earlier in-domain
338+
try { AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; } catch { }
339+
try { AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; } catch { }
340+
try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { }
341+
try { AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; } catch { }
342+
try { EditorApplication.quitting -= Stop; } catch { }
343+
try { EditorApplication.quitting += Stop; } catch { }
324344
// Write initial heartbeat immediately
325345
heartbeatSeq++;
326346
WriteHeartbeat(false, "ready");
@@ -335,6 +355,7 @@ public static void Start()
335355

336356
public static void Stop()
337357
{
358+
Task toWait = null;
338359
lock (startStopLock)
339360
{
340361
if (!isRunning)
@@ -346,23 +367,55 @@ public static void Stop()
346367
{
347368
// Mark as stopping early to avoid accept logging during disposal
348369
isRunning = false;
349-
// Mark heartbeat one last time before stopping
350-
WriteHeartbeat(false, "stopped");
351-
listener?.Stop();
370+
371+
// Quiesce background listener quickly
372+
var cancel = cts;
373+
cts = null;
374+
try { cancel?.Cancel(); } catch { }
375+
376+
try { listener?.Stop(); } catch { }
352377
listener = null;
353-
EditorApplication.update -= ProcessCommands;
354-
if (IsDebugEnabled()) Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge stopped.");
378+
379+
// Capture background task to wait briefly outside the lock
380+
toWait = listenerTask;
381+
listenerTask = null;
355382
}
356383
catch (Exception ex)
357384
{
358385
Debug.LogError($"Error stopping MCPForUnityBridge: {ex.Message}");
359386
}
360387
}
388+
389+
// Proactively close all active client sockets to unblock any pending reads
390+
TcpClient[] toClose;
391+
lock (clientsLock)
392+
{
393+
toClose = activeClients.ToArray();
394+
activeClients.Clear();
395+
}
396+
foreach (var c in toClose)
397+
{
398+
try { c.Close(); } catch { }
399+
}
400+
401+
// Give the background loop a short window to exit without blocking the editor
402+
if (toWait != null)
403+
{
404+
try { toWait.Wait(100); } catch { }
405+
}
406+
407+
// Now unhook editor events safely
408+
try { EditorApplication.update -= ProcessCommands; } catch { }
409+
try { AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; } catch { }
410+
try { AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; } catch { }
411+
try { EditorApplication.quitting -= Stop; } catch { }
412+
413+
if (IsDebugEnabled()) Debug.Log("<b><color=#2EA3FF>MCP-FOR-UNITY</color></b>: MCPForUnityBridge stopped.");
361414
}
362415

363-
private static async Task ListenerLoop()
416+
private static async Task ListenerLoopAsync(CancellationToken token)
364417
{
365-
while (isRunning)
418+
while (isRunning && !token.IsCancellationRequested)
366419
{
367420
try
368421
{
@@ -378,31 +431,38 @@ private static async Task ListenerLoop()
378431
client.ReceiveTimeout = 60000; // 60 seconds
379432

380433
// Fire and forget each client connection
381-
_ = HandleClientAsync(client);
434+
_ = Task.Run(() => HandleClientAsync(client, token), token);
382435
}
383436
catch (ObjectDisposedException)
384437
{
385438
// Listener was disposed during stop/reload; exit quietly
386-
if (!isRunning)
439+
if (!isRunning || token.IsCancellationRequested)
387440
{
388441
break;
389442
}
390443
}
444+
catch (OperationCanceledException)
445+
{
446+
break;
447+
}
391448
catch (Exception ex)
392449
{
393-
if (isRunning)
450+
if (isRunning && !token.IsCancellationRequested)
394451
{
395452
if (IsDebugEnabled()) Debug.LogError($"Listener error: {ex.Message}");
396453
}
397454
}
398455
}
399456
}
400457

401-
private static async Task HandleClientAsync(TcpClient client)
458+
private static async Task HandleClientAsync(TcpClient client, CancellationToken token)
402459
{
403460
using (client)
404461
using (NetworkStream stream = client.GetStream())
405462
{
463+
lock (clientsLock) { activeClients.Add(client); }
464+
try
465+
{
406466
// Framed I/O only; legacy mode removed
407467
try
408468
{
@@ -437,12 +497,12 @@ private static async Task HandleClientAsync(TcpClient client)
437497
return; // abort this client
438498
}
439499

440-
while (isRunning)
500+
while (isRunning && !token.IsCancellationRequested)
441501
{
442502
try
443503
{
444504
// Strict framed mode only: enforced framed I/O for this connection
445-
string commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs);
505+
string commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs, token).ConfigureAwait(false);
446506

447507
try
448508
{
@@ -454,7 +514,7 @@ private static async Task HandleClientAsync(TcpClient client)
454514
}
455515
catch { }
456516
string commandId = Guid.NewGuid().ToString();
457-
TaskCompletionSource<string> tcs = new();
517+
var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
458518

459519
// Special handling for ping command to avoid JSON parsing
460520
if (commandText.Trim() == "ping")
@@ -473,7 +533,7 @@ private static async Task HandleClientAsync(TcpClient client)
473533
commandQueue[commandId] = (commandText, tcs);
474534
}
475535

476-
string response = await tcs.Task;
536+
string response = await tcs.Task.ConfigureAwait(false);
477537
byte[] responseBytes = System.Text.Encoding.UTF8.GetBytes(response);
478538
await WriteFrameAsync(stream, responseBytes);
479539
}
@@ -496,6 +556,11 @@ private static async Task HandleClientAsync(TcpClient client)
496556
break;
497557
}
498558
}
559+
}
560+
finally
561+
{
562+
lock (clientsLock) { activeClients.Remove(client); }
563+
}
499564
}
500565
}
501566

@@ -574,9 +639,9 @@ private static async System.Threading.Tasks.Task WriteFrameAsync(NetworkStream s
574639
#endif
575640
}
576641

577-
private static async System.Threading.Tasks.Task<string> ReadFrameAsUtf8Async(NetworkStream stream, int timeoutMs)
642+
private static async System.Threading.Tasks.Task<string> ReadFrameAsUtf8Async(NetworkStream stream, int timeoutMs, CancellationToken cancel)
578643
{
579-
byte[] header = await ReadExactAsync(stream, 8, timeoutMs);
644+
byte[] header = await ReadExactAsync(stream, 8, timeoutMs, cancel).ConfigureAwait(false);
580645
ulong payloadLen = ReadUInt64BigEndian(header);
581646
if (payloadLen > MaxFrameBytes)
582647
{
@@ -589,7 +654,7 @@ private static async System.Threading.Tasks.Task<string> ReadFrameAsUtf8Async(Ne
589654
throw new System.IO.IOException("Frame too large for buffer");
590655
}
591656
int count = (int)payloadLen;
592-
byte[] payload = await ReadExactAsync(stream, count, timeoutMs);
657+
byte[] payload = await ReadExactAsync(stream, count, timeoutMs, cancel).ConfigureAwait(false);
593658
return System.Text.Encoding.UTF8.GetString(payload);
594659
}
595660

@@ -624,6 +689,10 @@ private static void WriteUInt64BigEndian(byte[] dest, ulong value)
624689

625690
private static void ProcessCommands()
626691
{
692+
if (!isRunning) return;
693+
if (Interlocked.Exchange(ref processingCommands, 1) == 1) return; // reentrancy guard
694+
try
695+
{
627696
// Heartbeat without holding the queue lock
628697
double now = EditorApplication.timeSinceStartup;
629698
if (now >= nextHeartbeatAt)
@@ -734,6 +803,11 @@ private static void ProcessCommands()
734803
// Remove quickly under lock
735804
lock (lockObj) { commandQueue.Remove(id); }
736805
}
806+
}
807+
finally
808+
{
809+
Interlocked.Exchange(ref processingCommands, 0);
810+
}
737811
}
738812

739813
// Helper method to check if a string is valid JSON
@@ -865,8 +939,7 @@ private static void OnBeforeAssemblyReload()
865939
{
866940
// Stop cleanly before reload so sockets close and clients see 'reloading'
867941
try { Stop(); } catch { }
868-
WriteHeartbeat(true, "reloading");
869-
LogBreadcrumb("Reload");
942+
// Avoid file I/O or heavy work here
870943
}
871944

872945
private static void OnAfterAssemblyReload()

0 commit comments

Comments
 (0)