using System; using System.Collections.Generic; using System.Collections.Concurrent; using System.IO; using System.Linq; using System.Net; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using Codely.Newtonsoft.Json; using Codely.Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; using UnityTcp.Editor.Helpers; using UnityTcp.Editor.Tools; namespace UnityTcp.Editor { /// /// Command structure for JSON deserialization /// public class Command { public string type { get; set; } public JObject @params { get; set; } } [InitializeOnLoad] public static partial class UnityTcpBridge { private static TcpListener listener; private static bool isRunning = false; private static readonly object lockObj = new(); private static readonly object startStopLock = new(); private static readonly object clientsLock = new(); private static readonly HashSet activeClients = new(); // Thread-safe mapping of clients to their platform types private static readonly ConcurrentDictionary clientPlatforms = new(); // Callback to notify about client platform changes public static event System.Action OnClientPlatformsChanged; // Single-writer outbox for framed responses private class Outbound { public byte[] Payload; public string Tag; public int? ReqId; } private static readonly BlockingCollection _outbox = new(new ConcurrentQueue()); private static CancellationTokenSource cts; private static Task listenerTask; private static int processingCommands = 0; private static bool initScheduled = false; private static bool ensureUpdateHooked = false; private static bool isStarting = false; private static double nextStartAt = 0.0f; private static double nextHeartbeatAt = 0.0f; private static string serverVer = "1.0.0-beta.1"; private static int heartbeatSeq = 0; private const string ManualStopSessionKey = "UnityTcp.ManuallyStoppedThisSession"; private static Dictionary< string, (string commandJson, TaskCompletionSource tcs) > commandQueue = new(); private static int currentUnityPort = 25916; // Dynamic port, starts with default private static bool isAutoConnectMode = false; 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) { TcpLog.Info(s, always: false); } // Debug helpers private static bool IsDebugEnabled() { try { return EditorPrefs.GetBool("UnityTcp.DebugLogs", false); } catch { return false; } } private static void LogBreadcrumb(string stage) { if (IsDebugEnabled()) { TcpLog.Info($"[{stage}]", always: false); } } public static bool IsRunning => isRunning; public static int GetCurrentPort() => currentUnityPort; public static bool IsAutoConnectMode() => isAutoConnectMode; /// /// Get all connected clients with their platform types /// public static Dictionary GetConnectedClients() { var result = new Dictionary(); lock (clientsLock) { foreach (var client in activeClients) { var endpoint = client.Client?.RemoteEndPoint?.ToString() ?? "unknown"; var platform = clientPlatforms.TryGetValue(client, out var p) ? p : "unknown"; result[endpoint] = platform; } } return result; } /// /// Start with Auto-Connect mode - discovers new port and saves it /// public static void StartAutoConnect() { Stop(); // Stop current connection try { // Auto-connect mode also gets a fresh port - no reuse // Note: Start() will call DiscoverNewPort() internally, so we don't need to set currentUnityPort here Start(); isAutoConnectMode = true; // Record telemetry for bridge startup TelemetryHelper.RecordBridgeStartup(); } catch (Exception ex) { Debug.LogError($"Auto-connect failed: {ex.Message}"); // Record telemetry for connection failure TelemetryHelper.RecordBridgeConnection(false, ex.Message); throw; } } public static bool FolderExists(string path) { if (string.IsNullOrEmpty(path)) { return false; } if (path.Equals("Assets", StringComparison.OrdinalIgnoreCase)) { return true; } string fullPath = Path.Combine( Application.dataPath, path.StartsWith("Assets/") ? path[7..] : path ); return Directory.Exists(fullPath); } static UnityTcpBridge() { // Initialize main thread ID for safe thread checks MainThreadHelper.InitializeMainThreadId(); // Register the FindObjectByInstruction delegate for UnityEngineObjectConverter UnityTcp.Editor.Serialization.UnityEngineObjectConverter.FindObjectByInstruction = ManageGameObject.FindObjectByInstruction; // 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 = "TCP-Writer" }; writerThread.Start(); } catch { } // Skip bridge in headless/batch environments (CI/builds) unless explicitly allowed via env // CI override: set UNITY_TCP_ALLOW_BATCH=1 to allow the bridge in batch mode if (Application.isBatchMode && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_TCP_ALLOW_BATCH"))) { return; } // Defer start until the editor is idle and not compiling ScheduleInitRetry(); // Add a safety net update hook in case delayCall is missed during reload churn if (!ensureUpdateHooked) { ensureUpdateHooked = true; EditorApplication.update += EnsureStartedOnEditorIdle; } EditorApplication.quitting += Stop; AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; // Also coalesce play mode transitions into a deferred init EditorApplication.playModeStateChanged += _ => ScheduleInitRetry(); } /// /// Initialize the bridge after Unity is fully loaded and compilation is complete. /// This prevents repeated restarts during script compilation that cause port hopping. /// private static void InitializeAfterCompilation() { Debug.Log($"InitializeAfterCompilation()"); // Don't auto-start if manually stopped this session (persists across domain reloads) if (SessionState.GetBool(ManualStopSessionKey, false)) { return; } initScheduled = false; // Play-mode friendly: allow starting in play mode; only defer while compiling if (CompilationHelper.IsCompiling()) { ScheduleInitRetry(); return; } if (!isRunning) { Start(); if (!isRunning) { // If a race prevented start, retry later ScheduleInitRetry(); } } } private static void ScheduleInitRetry() { if (SessionState.GetBool(ManualStopSessionKey, false)) { return; } if (initScheduled) { return; } initScheduled = true; // Debounce: start ~200ms after the last trigger nextStartAt = EditorApplication.timeSinceStartup + 0.20f; // Ensure the update pump is active if (!ensureUpdateHooked) { ensureUpdateHooked = true; EditorApplication.update += EnsureStartedOnEditorIdle; } // Keep the original delayCall as a secondary path EditorApplication.delayCall += InitializeAfterCompilation; } // Safety net: ensure the bridge starts shortly after domain reload when editor is idle private static void EnsureStartedOnEditorIdle() { if (SessionState.GetBool(ManualStopSessionKey, false)) { EditorApplication.update -= EnsureStartedOnEditorIdle; ensureUpdateHooked = false; return; } // Do nothing while compiling if (CompilationHelper.IsCompiling()) { return; } // If already running, remove the hook if (isRunning) { EditorApplication.update -= EnsureStartedOnEditorIdle; ensureUpdateHooked = false; return; } // Debounced start: wait until the scheduled time if (nextStartAt > 0 && EditorApplication.timeSinceStartup < nextStartAt) { return; } if (isStarting) { return; } isStarting = true; try { // Attempt start; if it succeeds, remove the hook to avoid overhead Start(); } finally { isStarting = false; } if (isRunning) { EditorApplication.update -= EnsureStartedOnEditorIdle; ensureUpdateHooked = false; } } public static void Start() { // Reset manual stop flag when manually starting (persists across domain reloads) SessionState.SetBool(ManualStopSessionKey, false); lock (startStopLock) { // Don't restart if already running on a working port if (isRunning && listener != null) { if (IsDebugEnabled()) { Debug.Log($"Codely-Bridge: UnityTcpBridge already running on port {currentUnityPort}"); } return; } Stop(); // Always discover a completely new port - no reuse to ensure instance isolation try { // Discover fresh port for this instance - no reuse of stored ports currentUnityPort = PortManager.DiscoverNewPort(); if (IsDebugEnabled()) Debug.Log($"Codely Bridge: Using fresh port {currentUnityPort} for this instance"); // Breadcrumb: Start LogBreadcrumb("Start"); const int maxImmediateRetries = 5; const int retrySleepMs = 150; int attempt = 0; for (; ; ) { try { listener = new TcpListener(IPAddress.IPv6Any, currentUnityPort); // Configure socket options for port isolation / fast reuse behavior #if UNITY_EDITOR_WIN try { listener.Server.SetSocketOption( SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, false // Disable port reuse on Windows to ensure complete isolation between instances ); } catch { } try { // On Windows: Force exclusive access listener.ExclusiveAddressUse = true; } catch { } #else try { listener.Server.SetSocketOption( SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, false // Disable port reuse ); } catch { } #endif // Minimize TIME_WAIT by sending RST on close try { listener.Server.LingerState = new LingerOption(true, 0); } catch (Exception) { // Ignore if not supported on platform } listener.Start(); break; } catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt < maxImmediateRetries) { attempt++; if (IsDebugEnabled()) { var (inUse, description) = PortManager.CheckPortConflict(currentUnityPort); Debug.LogWarning($"Codely Bridge: Port {currentUnityPort} bind failed (attempt {attempt}/{maxImmediateRetries}): {description}"); } Thread.Sleep(retrySleepMs); continue; } catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse && attempt >= maxImmediateRetries) { // Report the port conflict before switching var (inUse, description) = PortManager.CheckPortConflict(currentUnityPort); if (IsDebugEnabled()) Debug.LogWarning($"Codely Bridge: Port {currentUnityPort} persistently busy: {description}"); // Force discovery of a new port since the current one is persistently busy try { currentUnityPort = PortManager.DiscoverNewPort(); if (IsDebugEnabled()) Debug.Log($"Codely Bridge: Switched to new port {currentUnityPort} after retries failed"); } catch (Exception ex) { Debug.LogError($"Failed to discover new port after retries: {ex.Message}"); throw new Exception($"Unable to find any available ports: {ex.Message}"); } // Try to bind to the newly discovered port with a final retry attempt bool newPortBound = false; for (int finalAttempt = 0; finalAttempt < 2; finalAttempt++) { try { listener = new TcpListener(IPAddress.IPv6Any, currentUnityPort); // Configure socket options for port isolation / fast reuse behavior #if UNITY_EDITOR_WIN try { listener.Server.SetSocketOption( SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, false // Disable port reuse on Windows to ensure complete isolation between instances ); } catch { } try { // On Windows: Force exclusive access listener.ExclusiveAddressUse = true; } catch { } #else try { listener.Server.SetSocketOption( SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, false // Disable port reuse ); } catch { } #endif try { listener.Server.LingerState = new LingerOption(true, 0); } catch (Exception) { } listener.Start(); newPortBound = true; break; } catch (SocketException bindEx) when (bindEx.SocketErrorCode == SocketError.AddressAlreadyInUse && finalAttempt == 0) { // Race condition: newly discovered port became busy, try one more new port if (IsDebugEnabled()) Debug.LogWarning($"Codely Bridge: Newly discovered port {currentUnityPort} also became busy, trying another..."); currentUnityPort = PortManager.DiscoverNewPort(); continue; } } if (newPortBound) { break; } else { throw new Exception($"Failed to bind to any discovered ports, including fallback attempts"); } } } isRunning = true; isAutoConnectMode = false; string platform = Application.platform.ToString(); Debug.Log($"Codely-Bridge: Codely Bridge started on port {currentUnityPort}. (OS={platform}, server={serverVer})"); // Start background listener with cooperative cancellation cts = new CancellationTokenSource(); listenerTask = Task.Run(() => ListenerLoopAsync(cts.Token)); 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 heartbeatSeq++; StatusHelper.WriteHeartbeat(currentUnityPort, false, heartbeatSeq, "ready"); nextHeartbeatAt = EditorApplication.timeSinceStartup + 0.5f; } catch (SocketException ex) { Debug.LogError($"Failed to start TCP listener: {ex.Message}"); } } } public static void Stop() { Stop(false); } /// /// Stop the TCP bridge server /// /// If true, prevents auto-start after compilation until Unity quits public static void Stop(bool isManualStop) { if (isManualStop) { // Set flag in SessionState to survive domain reloads SessionState.SetBool(ManualStopSessionKey, true); } Task toWait = null; lock (startStopLock) { if (!isRunning) { return; } try { // Mark as stopping early to avoid accept logging during disposal isRunning = false; // Quiesce background listener quickly var cancel = cts; cts = null; try { cancel?.Cancel(); } catch { } try { listener?.Stop(); } catch { } listener = null; // Capture background task to wait briefly outside the lock toWait = listenerTask; listenerTask = null; } catch (Exception ex) { Debug.LogError($"Error stopping UnityTcpBridge: {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 { } } // Clear all client platform mappings (thread-safe) clientPlatforms.Clear(); // Give the background loop a short window to exit without blocking the editor // Wait longer on Windows to allow TCP port to fully release (typical TIME_WAIT is 200-500ms) if (toWait != null) { try { try { toWait.Wait(500); } catch { } } 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("Codely-Bridge: UnityTcpBridge stopped."); } private static async Task ListenerLoopAsync(CancellationToken token) { while (isRunning && !token.IsCancellationRequested) { try { TcpClient client = await listener.AcceptTcpClientAsync(); // Enable basic socket keepalive client.Client.SetSocketOption( SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true ); // Set longer receive timeout to prevent quick disconnections client.ReceiveTimeout = 60000; // 60 seconds // Fire and forget each client connection _ = Task.Run(() => HandleClientAsync(client, token), token); } catch (ObjectDisposedException) { // Listener was disposed during stop/reload; exit quietly if (!isRunning || token.IsCancellationRequested) { break; } } catch (OperationCanceledException) { break; } catch (Exception ex) { if (isRunning && !token.IsCancellationRequested) { if (IsDebugEnabled()) Debug.LogError($"Listener error: {ex.Message}"); } } } } private static async Task HandleClientAsync(TcpClient client, CancellationToken token) { using (client) using (NetworkStream stream = client.GetStream()) { lock (clientsLock) { activeClients.Add(client); } try { // Framed I/O only; legacy mode removed try { if (IsDebugEnabled()) { var ep = client.Client?.RemoteEndPoint?.ToString() ?? "unknown"; Debug.Log($"Codely-Bridge: Client connected {ep}"); } } catch { } // Strict framing: always require FRAMING=1 and frame all I/O try { client.NoDelay = true; } catch { } try { string handshake = "WELCOME UNITY-TCP 1 FRAMING=1\n"; byte[] handshakeBytes = System.Text.Encoding.ASCII.GetBytes(handshake); using var cts = new CancellationTokenSource(FrameIOTimeoutMs); #if NETSTANDARD2_1 || NET6_0_OR_GREATER await stream.WriteAsync(handshakeBytes.AsMemory(0, handshakeBytes.Length), cts.Token).ConfigureAwait(false); #else await stream.WriteAsync(handshakeBytes, 0, handshakeBytes.Length, cts.Token).ConfigureAwait(false); #endif if (IsDebugEnabled()) TcpLog.Info("Sent handshake FRAMING=1 (strict)", always: false); } catch (Exception ex) { if (IsDebugEnabled()) TcpLog.Warn($"Handshake failed: {ex.Message}"); return; // abort this client } while (isRunning && !token.IsCancellationRequested) { try { // Strict framed mode only: enforced framed I/O for this connection string commandText = await BinaryFrameHelper.ReadFrameAsUtf8Async(stream, FrameIOTimeoutMs, token).ConfigureAwait(false); try { if (IsDebugEnabled()) { var preview = commandText.Length > 120 ? commandText.Substring(0, 120) + "…" : commandText; TcpLog.Info($"recv framed: {preview}", always: false); } } catch { } string commandId = Guid.NewGuid().ToString(); var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); // Special handling for ping command to avoid JSON parsing if (commandText.Trim() == "ping") { // Direct response to ping without going through JSON parsing byte[] pingResponseBytes = System.Text.Encoding.UTF8.GetBytes( /*lang=json,strict*/ "{\"success\":true,\"message\":\"pong\"}" ); await BinaryFrameHelper.WriteFrameAsync(stream, pingResponseBytes); continue; } // Handle platform identification message if (commandText.StartsWith("PLATFORM=")) { string clientPlatform = commandText.Substring(9).Trim(); // Extract platform after "PLATFORM=" // Associate platform with this client (thread-safe) clientPlatforms[client] = clientPlatform; if (IsDebugEnabled()) TcpLog.Info($"Client platform: {clientPlatform}", always: false); // Notify listeners about client platform changes OnClientPlatformsChanged?.Invoke(); continue; } lock (lockObj) { commandQueue[commandId] = (commandText, tcs); } // Wait for the handler to produce a response, but do not block indefinitely string response; try { using var respCts = new CancellationTokenSource(FrameIOTimeoutMs); var completed = await Task.WhenAny(tcs.Task, Task.Delay(FrameIOTimeoutMs, respCts.Token)).ConfigureAwait(false); if (completed == tcs.Task) { // Got a result from the handler respCts.Cancel(); response = tcs.Task.Result; } else { // Timeout: return a structured error so the client can recover var timeoutResponse = new { success = false, error = $"Command processing timed out after {FrameIOTimeoutMs} ms", }; response = JsonConvert.SerializeObject(timeoutResponse); } } catch (Exception ex) { var errorResponse = new { success = false, error = ex.Message, }; response = JsonConvert.SerializeObject(errorResponse); } if (IsDebugEnabled()) { try { TcpLog.Info("[TCP] sending framed response", always: false); } catch { } } // 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 BinaryFrameHelper.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) { // Treat common disconnects/timeouts as benign; only surface hard errors string msg = ex.Message ?? string.Empty; bool isBenign = msg.IndexOf("Connection closed before reading expected bytes", StringComparison.OrdinalIgnoreCase) >= 0 || msg.IndexOf("Read timed out", StringComparison.OrdinalIgnoreCase) >= 0 || ex is System.IO.IOException; if (isBenign) { if (IsDebugEnabled()) TcpLog.Info($"Client handler: {msg}", always: false); } else { TcpLog.Error($"Client handler error: {msg}"); } break; } } } finally { lock (clientsLock) { activeClients.Remove(client); } // Remove client platform mapping when client disconnects (thread-safe) clientPlatforms.TryRemove(client, out _); // Notify listeners about client platform changes OnClientPlatformsChanged?.Invoke(); } } } private static void ProcessCommands() { if (!isRunning) return; if (Interlocked.Exchange(ref processingCommands, 1) == 1) return; // reentrancy guard try { // Heartbeat without holding the queue lock double now = EditorApplication.timeSinceStartup; if (now >= nextHeartbeatAt) { StatusHelper.WriteHeartbeat(currentUnityPort, false, heartbeatSeq); nextHeartbeatAt = now + 0.5f; } // Snapshot under lock, then process outside to reduce contention List<(string id, string text, TaskCompletionSource tcs)> work; lock (lockObj) { work = commandQueue .Select(kvp => (kvp.Key, kvp.Value.commandJson, kvp.Value.tcs)) .ToList(); } foreach (var item in work) { string id = item.id; string commandText = item.text; TaskCompletionSource tcs = item.tcs; try { // Special case handling if (string.IsNullOrEmpty(commandText)) { var emptyResponse = Response.Error("Empty command received"); tcs.SetResult(JsonConvert.SerializeObject(emptyResponse)); // Remove quickly under lock lock (lockObj) { commandQueue.Remove(id); } continue; } // Trim the command text to remove any whitespace commandText = commandText.Trim(); // Non-JSON direct commands handling (like ping) if (commandText == "ping") { var pingResponse = Response.Success("pong"); tcs.SetResult(JsonConvert.SerializeObject(pingResponse)); lock (lockObj) { commandQueue.Remove(id); } continue; } // Check if the command is valid JSON before attempting to deserialize if (!JsonCommandHelper.IsValidJson(commandText)) { var invalidJsonResponse = Response.Error("Invalid JSON format", new { receivedText = commandText.Length > 50 ? commandText[..50] + "..." : commandText }); tcs.SetResult(JsonConvert.SerializeObject(invalidJsonResponse)); lock (lockObj) { commandQueue.Remove(id); } continue; } // Normal JSON command processing Command command = JsonConvert.DeserializeObject(commandText); if (command == null) { var nullCommandResponse = Response.Error("Command deserialized to null", "The command was valid JSON but could not be deserialized to a Command object"); tcs.SetResult(JsonConvert.SerializeObject(nullCommandResponse)); } else { string responseJson = ExecuteCommand(command); tcs.SetResult(responseJson); } } catch (Exception ex) { Debug.LogError($"Error processing command: {ex.Message}\n{ex.StackTrace}"); var response = Response.Error(ex.Message, new { commandType = "Unknown (error during processing)", receivedText = commandText?.Length > 50 ? commandText[..50] + "..." : commandText, stackTrace = ex.StackTrace }); string responseJson = JsonConvert.SerializeObject(response); tcs.SetResult(responseJson); } // Remove quickly under lock lock (lockObj) { commandQueue.Remove(id); } } } finally { Interlocked.Exchange(ref processingCommands, 0); } } private static string ExecuteCommand(Command command) { try { if (string.IsNullOrEmpty(command.type)) { var errorResponse = Response.Error("Command type cannot be empty", "A valid command type is required for processing"); return JsonConvert.SerializeObject(errorResponse); } // Handle ping command for connection verification if (command.type.Equals("ping", StringComparison.OrdinalIgnoreCase)) { var pingResponse = Response.Success("pong"); return JsonConvert.SerializeObject(pingResponse); } // Use JObject for parameters as the new handlers likely expect this JObject paramsObject = command.@params ?? new JObject(); // Route command based on the tool structure from the existing project object result = command.type switch { // Maps the command type (tool name) to the corresponding handler's static HandleCommand method "manage_script" => ManageScript.HandleCommand(paramsObject), // High-level workflows (init_session, compile_and_validate, checkpoint) "manage_workflow" => ManageWorkflow.HandleCommand(paramsObject), // Run scene operations on the main thread to avoid deadlocks/hangs (with diagnostics under debug flag) "manage_scene" => HandleManageScene(paramsObject) ?? throw new TimeoutException($"manage_scene timed out after {FrameIOTimeoutMs} ms on main thread"), "manage_editor" => ManageEditor.HandleCommand(paramsObject), "manage_gameobject" => ManageGameObject.HandleCommand(paramsObject), "manage_asset" => ManageAsset.HandleCommand(paramsObject), "manage_shader" => ManageShader.HandleCommand(paramsObject), "read_console" => ReadConsole.HandleCommand(paramsObject), "execute_menu_item" => ExecuteMenuItem.HandleCommand(paramsObject), "manage_screenshot" => ManageScreenshot.HandleCommand(paramsObject), "execute_csharp_script" => ExecuteCSharpScript.HandleCommand(paramsObject), // [EXPERIMENTAL] Phase 3 tools "manage_package" => ManagePackage.HandleCommand(paramsObject), "manage_bake" => ManageBake.HandleCommand(paramsObject), "manage_ui_toolkit" => ManageUIToolkit.HandleCommand(paramsObject), // Custom tool execution "execute_custom_tool" => ExecuteCustomTool.HandleCommand(paramsObject), // Internal tools (NOT exposed to LLM) "_internal_state_dirty" => _InternalStateDirtyNotifier.HandleCommand(paramsObject), _ => throw new ArgumentException( $"Unknown or unsupported command type: {command.type}" ), }; // Convert result to success response format compatible with Response helper return JsonConvert.SerializeObject(Response.Success("Command executed successfully", result)); } catch (Exception ex) { // Log the detailed error in Unity for debugging Debug.LogError( $"Error executing command '{command?.type ?? "Unknown"}': {ex.Message}\n{ex.StackTrace}" ); // Use Response helper for consistent error format var response = Response.Error(ex.Message, new { command = command?.type ?? "Unknown", stackTrace = ex.StackTrace, paramsSummary = command?.@params != null ? JsonCommandHelper.GetParamsSummary(command.@params) : "No parameters" }); return JsonConvert.SerializeObject(response); } } private static object HandleManageScene(JObject paramsObject) { try { if (IsDebugEnabled()) Debug.Log("[TCP] manage_scene: dispatching to main thread"); var sw = System.Diagnostics.Stopwatch.StartNew(); var r = MainThreadHelper.InvokeOnMainThreadWithTimeout(() => ManageScene.HandleCommand(paramsObject), FrameIOTimeoutMs); sw.Stop(); if (IsDebugEnabled()) Debug.Log($"[TCP] manage_scene: completed in {sw.ElapsedMilliseconds} ms"); return r ?? Response.Error("manage_scene returned null (timeout or error)"); } catch (Exception ex) { return Response.Error($"manage_scene dispatch error: {ex.Message}"); } } // Heartbeat/status helpers private static void OnBeforeAssemblyReload() { // Stop cleanly before reload so sockets close and clients see 'reloading' try { Stop(); } catch { } // Avoid file I/O or heavy work here } private static void OnAfterAssemblyReload() { // Will be overwritten by Start(), but mark as alive quickly StatusHelper.WriteHeartbeat(currentUnityPort, false, heartbeatSeq, "idle"); LogBreadcrumb("Idle"); // Schedule a safe restart after reload to avoid races during compilation ScheduleInitRetry(); } } }