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;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Concurrent;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
@ -25,6 +26,14 @@ namespace MCPForUnity.Editor
private static readonly object startStopLock = new(); private static readonly object startStopLock = new();
private static readonly object clientsLock = new(); private static readonly object clientsLock = new();
private static readonly System.Collections.Generic.HashSet<TcpClient> activeClients = 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 CancellationTokenSource cts;
private static Task listenerTask; private static Task listenerTask;
private static int processingCommands = 0; 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 ulong MaxFrameBytes = 64UL * 1024 * 1024; // 64 MiB hard cap for framed payloads
private const int FrameIOTimeoutMs = 30000; // Per-read timeout to avoid stalled clients 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 // Debug helpers
private static bool IsDebugEnabled() private static bool IsDebugEnabled()
{ {
@ -112,6 +125,35 @@ namespace MCPForUnity.Editor
{ {
// Record the main thread ID for safe thread checks // Record the main thread ID for safe thread checks
try { mainThreadId = Thread.CurrentThread.ManagedThreadId; } catch { mainThreadId = 0; } 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 // 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 // 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"))) 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 { } try { MCPForUnity.Editor.Helpers.McpLog.Info("[MCP] sending framed response", always: false); } catch { }
} }
byte[] responseBytes = System.Text.Encoding.UTF8.GetBytes(response); // 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); 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) catch (Exception ex)
{ {

View File

@ -16,16 +16,46 @@ namespace MCPForUnity.Editor.Tools
/// </summary> /// </summary>
public static class ManageScene 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> /// <summary>
/// Main handler for scene management actions. /// Main handler for scene management actions.
/// </summary> /// </summary>
public static object HandleCommand(JObject @params) public static object HandleCommand(JObject @params)
{ {
try { McpLog.Info("[ManageScene] HandleCommand: start", always: false); } catch { } try { McpLog.Info("[ManageScene] HandleCommand: start", always: false); } catch { }
string action = @params["action"]?.ToString().ToLower(); var cmd = ToSceneCommand(@params);
string name = @params["name"]?.ToString(); string action = cmd.action;
string path = @params["path"]?.ToString(); // Relative to Assets/ string name = string.IsNullOrEmpty(cmd.name) ? null : cmd.name;
int? buildIndex = @params["buildIndex"]?.ToObject<int?>(); 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 // bool loadAdditive = @params["loadAdditive"]?.ToObject<bool>() ?? false; // Example for future extension
// Ensure path is relative to Assets/, removing any leading "Assets/" // 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( def manage_scene(
ctx: Context, ctx: Context,
action: str, action: str,
name: str, name: str = "",
path: str, path: str = "",
build_index: Any, build_index: Any = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Manages Unity scenes (load, save, create, get hierarchy, etc.). """Manages Unity scenes (load, save, create, get hierarchy, etc.).
@ -47,15 +47,15 @@ def register_manage_scene_tools(mcp: FastMCP):
except Exception: except Exception:
return default return default
build_index = _coerce_int(build_index, default=0) coerced_build_index = _coerce_int(build_index, default=None)
params = { params = {"action": action}
"action": action, if name:
"name": name, params["name"] = name
"path": path, if path:
"buildIndex": build_index params["path"] = path
} if coerced_build_index is not None:
params = {k: v for k, v in params.items() if v is not None} params["buildIndex"] = coerced_build_index
# Use centralized retry helper # Use centralized retry helper
response = send_command_with_retry("manage_scene", params) response = send_command_with_retry("manage_scene", params)