Files
2026-03-09 17:50:20 +08:00

1060 lines
47 KiB
C#

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
{
/// <summary>
/// Command structure for JSON deserialization
/// </summary>
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<TcpClient> activeClients = new();
// Thread-safe mapping of clients to their platform types
private static readonly ConcurrentDictionary<TcpClient, string> 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<Outbound> _outbox = new(new ConcurrentQueue<Outbound>());
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<string> 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;
/// <summary>
/// Get all connected clients with their platform types
/// </summary>
public static Dictionary<string, string> GetConnectedClients()
{
var result = new Dictionary<string, string>();
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;
}
/// <summary>
/// Start with Auto-Connect mode - discovers new port and saves it
/// </summary>
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();
}
/// <summary>
/// Initialize the bridge after Unity is fully loaded and compilation is complete.
/// This prevents repeated restarts during script compilation that cause port hopping.
/// </summary>
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($"<b><color=#2EA3FF>Codely-Bridge</color></b>: 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($"<b><color=#2EA3FF>Codely Bridge</color></b>: 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($"<b><color=#2EA3FF>Codely Bridge</color></b>: 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($"<b><color=#2EA3FF>Codely Bridge</color></b>: 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($"<b><color=#2EA3FF>Codely Bridge</color></b>: 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($"<b><color=#2EA3FF>Codely Bridge</color></b>: 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($"<b><color=#2EA3FF>Codely-Bridge</color></b>: 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);
}
/// <summary>
/// Stop the TCP bridge server
/// </summary>
/// <param name="isManualStop">If true, prevents auto-start after compilation until Unity quits</param>
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("<b><color=#2EA3FF>Codely-Bridge</color></b>: 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($"<b><color=#2EA3FF>Codely-Bridge</color></b>: 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<string>(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<string> 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<string> 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<Command>(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();
}
}
}