Skip to content

Commit 0ce4071

Browse files
committed
fix: harden editor reload shutdown; gate logs; structured errors for ManageGameObject; test hardening
1 parent ab957fd commit 0ce4071

File tree

4 files changed

+111
-11
lines changed

4 files changed

+111
-11
lines changed

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: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ 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();
2628
private static CancellationTokenSource cts;
2729
private static Task listenerTask;
2830
private static int processingCommands = 0;
@@ -196,9 +198,15 @@ private static void EnsureStartedOnEditorIdle()
196198
}
197199

198200
isStarting = true;
199-
// Attempt start; if it succeeds, remove the hook to avoid overhead
200-
Start();
201-
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+
}
202210
if (isRunning)
203211
{
204212
EditorApplication.update -= EnsureStartedOnEditorIdle;
@@ -378,6 +386,18 @@ public static void Stop()
378386
}
379387
}
380388

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+
381401
// Give the background loop a short window to exit without blocking the editor
382402
if (toWait != null)
383403
{
@@ -440,6 +460,9 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken
440460
using (client)
441461
using (NetworkStream stream = client.GetStream())
442462
{
463+
lock (clientsLock) { activeClients.Add(client); }
464+
try
465+
{
443466
// Framed I/O only; legacy mode removed
444467
try
445468
{
@@ -479,7 +502,7 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken
479502
try
480503
{
481504
// Strict framed mode only: enforced framed I/O for this connection
482-
string commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs);
505+
string commandText = await ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs, token).ConfigureAwait(false);
483506

484507
try
485508
{
@@ -491,7 +514,7 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken
491514
}
492515
catch { }
493516
string commandId = Guid.NewGuid().ToString();
494-
TaskCompletionSource<string> tcs = new();
517+
var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
495518

496519
// Special handling for ping command to avoid JSON parsing
497520
if (commandText.Trim() == "ping")
@@ -510,7 +533,7 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken
510533
commandQueue[commandId] = (commandText, tcs);
511534
}
512535

513-
string response = await tcs.Task;
536+
string response = await tcs.Task.ConfigureAwait(false);
514537
byte[] responseBytes = System.Text.Encoding.UTF8.GetBytes(response);
515538
await WriteFrameAsync(stream, responseBytes);
516539
}
@@ -533,6 +556,11 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken
533556
break;
534557
}
535558
}
559+
}
560+
finally
561+
{
562+
lock (clientsLock) { activeClients.Remove(client); }
563+
}
536564
}
537565
}
538566

@@ -611,9 +639,9 @@ private static async System.Threading.Tasks.Task WriteFrameAsync(NetworkStream s
611639
#endif
612640
}
613641

614-
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)
615643
{
616-
byte[] header = await ReadExactAsync(stream, 8, timeoutMs);
644+
byte[] header = await ReadExactAsync(stream, 8, timeoutMs, cancel).ConfigureAwait(false);
617645
ulong payloadLen = ReadUInt64BigEndian(header);
618646
if (payloadLen > MaxFrameBytes)
619647
{
@@ -626,7 +654,7 @@ private static async System.Threading.Tasks.Task<string> ReadFrameAsUtf8Async(Ne
626654
throw new System.IO.IOException("Frame too large for buffer");
627655
}
628656
int count = (int)payloadLen;
629-
byte[] payload = await ReadExactAsync(stream, count, timeoutMs);
657+
byte[] payload = await ReadExactAsync(stream, count, timeoutMs, cancel).ConfigureAwait(false);
630658
return System.Text.Encoding.UTF8.GetString(payload);
631659
}
632660

UnityMcpBridge/Editor/Tools/ExecuteMenuItem.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public static class ExecuteMenuItem
2727
/// </summary>
2828
public static object HandleCommand(JObject @params)
2929
{
30-
string action = @params["action"]?.ToString().ToLower() ?? "execute"; // Default action
30+
string action = (@params["action"]?.ToString())?.ToLowerInvariant() ?? "execute"; // Default action
3131

3232
try
3333
{

UnityMcpBridge/Editor/Tools/ManageGameObject.cs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -814,9 +814,34 @@ string searchMethod
814814
// Return component errors if any occurred (after processing all components)
815815
if (componentErrors.Count > 0)
816816
{
817+
// Aggregate flattened error strings to make tests/API assertions simpler
818+
var aggregatedErrors = new System.Collections.Generic.List<string>();
819+
foreach (var errorObj in componentErrors)
820+
{
821+
try
822+
{
823+
var dataProp = errorObj?.GetType().GetProperty("data");
824+
var dataVal = dataProp?.GetValue(errorObj);
825+
if (dataVal != null)
826+
{
827+
var errorsProp = dataVal.GetType().GetProperty("errors");
828+
var errorsEnum = errorsProp?.GetValue(dataVal) as System.Collections.IEnumerable;
829+
if (errorsEnum != null)
830+
{
831+
foreach (var item in errorsEnum)
832+
{
833+
var s = item?.ToString();
834+
if (!string.IsNullOrEmpty(s)) aggregatedErrors.Add(s);
835+
}
836+
}
837+
}
838+
}
839+
catch { }
840+
}
841+
817842
return Response.Error(
818843
$"One or more component property operations failed on '{targetGo.name}'.",
819-
new { componentErrors = componentErrors }
844+
new { componentErrors = componentErrors, errors = aggregatedErrors }
820845
);
821846
}
822847

0 commit comments

Comments
 (0)