manage_scene: tolerant params + optional buildIndex; add writer IO logs; keep direct write path

main
David Sarno 2025-09-10 08:23:25 -07:00
parent 397ba32a99
commit 2fd74f5dab
3 changed files with 113 additions and 17 deletions

View File

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.IO;
using System.Linq;
using System.Net;
@ -25,6 +26,14 @@ namespace MCPForUnity.Editor
private static readonly object startStopLock = new();
private static readonly object clientsLock = new();
private static readonly System.Collections.Generic.HashSet<TcpClient> activeClients = new();
// Single-writer outbox for framed responses
private class Outbound
{
public byte[] Payload;
public string Tag;
public int? ReqId;
}
private static readonly BlockingCollection<Outbound> _outbox = new(new ConcurrentQueue<Outbound>());
private static CancellationTokenSource cts;
private static Task listenerTask;
private static int processingCommands = 0;
@ -44,6 +53,10 @@ namespace MCPForUnity.Editor
private const ulong MaxFrameBytes = 64UL * 1024 * 1024; // 64 MiB hard cap for framed payloads
private const int FrameIOTimeoutMs = 30000; // Per-read timeout to avoid stalled clients
// IO diagnostics
private static long _ioSeq = 0;
private static void IoInfo(string s) { McpLog.Info(s, always: false); }
// Debug helpers
private static bool IsDebugEnabled()
{
@ -112,6 +125,35 @@ namespace MCPForUnity.Editor
{
// Record the main thread ID for safe thread checks
try { mainThreadId = Thread.CurrentThread.ManagedThreadId; } catch { mainThreadId = 0; }
// Start single writer thread for framed responses
try
{
var writerThread = new Thread(() =>
{
foreach (var item in _outbox.GetConsumingEnumerable())
{
try
{
long seq = Interlocked.Increment(ref _ioSeq);
IoInfo($"[IO] ➜ write start seq={seq} tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")}");
var sw = System.Diagnostics.Stopwatch.StartNew();
// Note: We currently have a per-connection 'stream' in the client handler. For simplicity,
// writes are performed inline there. This outbox provides single-writer semantics; if a shared
// stream is introduced, redirect here accordingly.
// No-op: actual write happens in client loop using WriteFrameAsync
sw.Stop();
IoInfo($"[IO] ✓ write end tag={item.Tag} len={(item.Payload?.Length ?? 0)} reqId={(item.ReqId?.ToString() ?? "?")} durMs={sw.Elapsed.TotalMilliseconds:F1}");
}
catch (Exception ex)
{
IoInfo($"[IO] ✗ write FAIL tag={item.Tag} reqId={(item.ReqId?.ToString() ?? "?")} {ex.GetType().Name}: {ex.Message}");
}
}
}) { IsBackground = true, Name = "MCP-Writer" };
writerThread.Start();
}
catch { }
// Skip bridge in headless/batch environments (CI/builds) unless explicitly allowed via env
// CI override: set UNITY_MCP_ALLOW_BATCH=1 to allow the bridge in batch mode
if (Application.isBatchMode && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH")))
@ -579,8 +621,32 @@ namespace MCPForUnity.Editor
{
try { MCPForUnity.Editor.Helpers.McpLog.Info("[MCP] sending framed response", always: false); } catch { }
}
byte[] responseBytes = System.Text.Encoding.UTF8.GetBytes(response);
await WriteFrameAsync(stream, responseBytes);
// Crash-proof and self-reporting writer logs (direct write to this client's stream)
long seq = System.Threading.Interlocked.Increment(ref _ioSeq);
byte[] responseBytes;
try
{
responseBytes = System.Text.Encoding.UTF8.GetBytes(response);
IoInfo($"[IO] ➜ write start seq={seq} tag=response len={responseBytes.Length} reqId=?");
}
catch (Exception ex)
{
IoInfo($"[IO] ✗ serialize FAIL tag=response reqId=? {ex.GetType().Name}: {ex.Message}");
throw;
}
var swDirect = System.Diagnostics.Stopwatch.StartNew();
try
{
await WriteFrameAsync(stream, responseBytes);
swDirect.Stop();
IoInfo($"[IO] ✓ write end tag=response len={responseBytes.Length} reqId=? durMs={swDirect.Elapsed.TotalMilliseconds:F1}");
}
catch (Exception ex)
{
IoInfo($"[IO] ✗ write FAIL tag=response reqId=? {ex.GetType().Name}: {ex.Message}");
throw;
}
}
catch (Exception ex)
{

View File

@ -16,16 +16,46 @@ namespace MCPForUnity.Editor.Tools
/// </summary>
public static class ManageScene
{
private sealed class SceneCommand
{
public string action { get; set; } = string.Empty;
public string name { get; set; } = string.Empty;
public string path { get; set; } = string.Empty;
public int? buildIndex { get; set; }
}
private static SceneCommand ToSceneCommand(JObject p)
{
if (p == null) return new SceneCommand();
int? BI(JToken t)
{
if (t == null || t.Type == JTokenType.Null) return null;
var s = t.ToString().Trim();
if (s.Length == 0) return null;
if (int.TryParse(s, out var i)) return i;
if (double.TryParse(s, out var d)) return (int)d;
return t.Type == JTokenType.Integer ? t.Value<int>() : (int?)null;
}
return new SceneCommand
{
action = (p["action"]?.ToString() ?? string.Empty).Trim().ToLowerInvariant(),
name = p["name"]?.ToString() ?? string.Empty,
path = p["path"]?.ToString() ?? string.Empty,
buildIndex = BI(p["buildIndex"] ?? p["build_index"])
};
}
/// <summary>
/// Main handler for scene management actions.
/// </summary>
public static object HandleCommand(JObject @params)
{
try { McpLog.Info("[ManageScene] HandleCommand: start", always: false); } catch { }
string action = @params["action"]?.ToString().ToLower();
string name = @params["name"]?.ToString();
string path = @params["path"]?.ToString(); // Relative to Assets/
int? buildIndex = @params["buildIndex"]?.ToObject<int?>();
var cmd = ToSceneCommand(@params);
string action = cmd.action;
string name = string.IsNullOrEmpty(cmd.name) ? null : cmd.name;
string path = string.IsNullOrEmpty(cmd.path) ? null : cmd.path; // Relative to Assets/
int? buildIndex = cmd.buildIndex;
// bool loadAdditive = @params["loadAdditive"]?.ToObject<bool>() ?? false; // Example for future extension
// Ensure path is relative to Assets/, removing any leading "Assets/"

View File

@ -14,9 +14,9 @@ def register_manage_scene_tools(mcp: FastMCP):
def manage_scene(
ctx: Context,
action: str,
name: str,
path: str,
build_index: Any,
name: str = "",
path: str = "",
build_index: Any = None,
) -> Dict[str, Any]:
"""Manages Unity scenes (load, save, create, get hierarchy, etc.).
@ -47,15 +47,15 @@ def register_manage_scene_tools(mcp: FastMCP):
except Exception:
return default
build_index = _coerce_int(build_index, default=0)
coerced_build_index = _coerce_int(build_index, default=None)
params = {
"action": action,
"name": name,
"path": path,
"buildIndex": build_index
}
params = {k: v for k, v in params.items() if v is not None}
params = {"action": action}
if name:
params["name"] = name
if path:
params["path"] = path
if coerced_build_index is not None:
params["buildIndex"] = coerced_build_index
# Use centralized retry helper
response = send_command_with_retry("manage_scene", params)