diff --git a/unity-connector/Editor/CommandRouter.cs b/unity-connector/Editor/CommandRouter.cs index d56ad0f..159e764 100644 --- a/unity-connector/Editor/CommandRouter.cs +++ b/unity-connector/Editor/CommandRouter.cs @@ -13,21 +13,34 @@ namespace UnityCliConnector /// public static class CommandRouter { - static readonly SemaphoreSlim s_Lock = new(1, 1); + static SemaphoreSlim s_Lock = new(1, 1); public static async Task Dispatch(string command, JObject parameters) { - await s_Lock.WaitAsync(); + // Capture locally so a concurrent ResetLock() swap doesn't make us release a + // semaphore we never acquired. A still-running orphaned call releases the old + // semaphore (now unreferenced) instead of double-releasing the new one. + var sem = s_Lock; + await sem.WaitAsync(); try { return await DispatchInternal(command, parameters); } finally { - s_Lock.Release(); + sem.Release(); } } + /// + /// Replaces the dispatch semaphore with a fresh one so new commands can run + /// even if a previous handler is still hung holding the old semaphore. + /// + public static void ResetLock() + { + s_Lock = new SemaphoreSlim(1, 1); + } + static async Task DispatchInternal(string command, JObject parameters) { if (command == "list") diff --git a/unity-connector/Editor/ConnectorStatusWindow.cs b/unity-connector/Editor/ConnectorStatusWindow.cs new file mode 100644 index 0000000..b326b6f --- /dev/null +++ b/unity-connector/Editor/ConnectorStatusWindow.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace UnityCliConnector +{ + /// + /// Editor window that surfaces the connector's runtime state and recovery + /// levers (Start / Stop / Restart / Purge) without needing the CLI. + /// + public class ConnectorStatusWindow : EditorWindow + { + // Mirrors RunTests.StatusDir. TestRunner is in a separate asmdef that depends + // on this one, so we can't reference it back here — duplicate the path. + static readonly string s_StatusDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-cli", "status"); + + struct PendingTest + { + public int Port; + public string Filter; + public string Path; + } + + [MenuItem("Tools/Unity CLI/Server Status")] + static void Open() => GetWindow("Unity CLI Server"); + + void OnEnable() => EditorApplication.update += Repaint; + void OnDisable() => EditorApplication.update -= Repaint; + + void OnGUI() + { + var running = HttpServer.IsRunning; + var state = Heartbeat.CurrentState; + + EditorGUILayout.Space(6); + + EditorGUILayout.BeginHorizontal(); + GUILayout.Label("Status", GUILayout.Width(60)); + var prev = GUI.color; + GUI.color = running ? Color.green : Color.gray; + GUILayout.Label(running ? "●" : "○", GUILayout.Width(18)); + GUI.color = prev; + GUILayout.Label(running ? "Running" : "Stopped"); + EditorGUILayout.EndHorizontal(); + + if (running) + { + EditorGUILayout.BeginHorizontal(); + GUILayout.Label("Port", GUILayout.Width(60)); + GUILayout.Label(HttpServer.Port.ToString()); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.BeginHorizontal(); + GUILayout.Label("State", GUILayout.Width(60)); + GUILayout.Label(state); + EditorGUILayout.EndHorizontal(); + } + + var pendingCommands = HttpServer.PendingCount; + var pendingTests = ListPendingTests(); + + EditorGUILayout.Space(8); + EditorGUILayout.BeginHorizontal(); + GUILayout.Label("Queued", GUILayout.Width(60)); + GUILayout.Label(pendingCommands.ToString()); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.BeginHorizontal(); + GUILayout.Label("Pending tests", GUILayout.Width(90)); + GUILayout.Label(pendingTests.Count.ToString()); + EditorGUILayout.EndHorizontal(); + + foreach (var entry in pendingTests) + { + var detail = string.IsNullOrEmpty(entry.Filter) + ? $"port {entry.Port}" + : $"port {entry.Port} — {entry.Filter}"; + EditorGUILayout.LabelField(" " + detail); + } + + EditorGUILayout.Space(8); + EditorGUILayout.BeginHorizontal(); + + if (running) + { + if (GUILayout.Button("Stop")) + HttpServer.ManualStop(); + if (GUILayout.Button("Restart")) + { + HttpServer.ManualStop(); + HttpServer.ManualStart(); + } + } + else + { + if (GUILayout.Button("Start")) + HttpServer.ManualStart(); + } + + using (new EditorGUI.DisabledScope(pendingCommands == 0 && pendingTests.Count == 0)) + { + if (GUILayout.Button("Purge")) + { + var confirmed = EditorUtility.DisplayDialog( + "Purge pending work", + $"Fault {pendingCommands} queued command(s) and delete {pendingTests.Count} pending test file(s)?\n\n" + + "Any CLI client currently waiting on a response will see an error.", + "Purge", + "Cancel"); + if (confirmed) + { + var faulted = HttpServer.PurgePending(); + var deleted = PurgePendingTests(pendingTests); + Debug.Log($"[UnityCliConnector] Purged {faulted} pending command(s), {deleted} pending test file(s)"); + } + } + } + + EditorGUILayout.EndHorizontal(); + EditorGUILayout.Space(4); + } + + static List ListPendingTests() + { + var result = new List(); + try + { + if (!Directory.Exists(s_StatusDir)) return result; + foreach (var file in Directory.GetFiles(s_StatusDir, "test-pending-*.json")) + { + try + { + var json = JObject.Parse(File.ReadAllText(file)); + result.Add(new PendingTest + { + Port = json["port"]?.Value() ?? 0, + Filter = json["filter"]?.Value() ?? "", + Path = file, + }); + } + catch { } + } + } + catch { } + return result; + } + + static int PurgePendingTests(IEnumerable entries) + { + var deleted = 0; + foreach (var e in entries) + { + try { File.Delete(e.Path); deleted++; } catch { } + } + return deleted; + } + } +} diff --git a/unity-connector/Editor/ConnectorStatusWindow.cs.meta b/unity-connector/Editor/ConnectorStatusWindow.cs.meta new file mode 100644 index 0000000..ebc3d2f --- /dev/null +++ b/unity-connector/Editor/ConnectorStatusWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 39dd38e7f0214d51a0131281a4dfcb55 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity-connector/Editor/Heartbeat.cs b/unity-connector/Editor/Heartbeat.cs index f4c5400..19cb0b3 100644 --- a/unity-connector/Editor/Heartbeat.cs +++ b/unity-connector/Editor/Heartbeat.cs @@ -58,9 +58,14 @@ public static void MarkCompileRequested() WriteState("compiling"); } + public static string CurrentState => + s_ForcedState ?? (HttpServer.IsRunning ? GetState() : "stopped"); + static void Tick() { - if (HttpServer.Port == 0) return; + // Stop writing once the listener is down so MarkStopped's "stopped" state + // isn't immediately overwritten by a fresh live snapshot. + if (!HttpServer.IsRunning) return; var now = EditorApplication.timeSinceStartup; if (now - s_LastWrite < INTERVAL) return; @@ -133,7 +138,9 @@ static string GetState() return "ready"; } - public static void Cleanup() + public static void Cleanup() => MarkStopped(); + + public static void MarkStopped() { if (HttpServer.Port == 0) return; s_ForcedState = "stopped"; diff --git a/unity-connector/Editor/HttpServer.cs b/unity-connector/Editor/HttpServer.cs index 4463b6d..36217e8 100644 --- a/unity-connector/Editor/HttpServer.cs +++ b/unity-connector/Editor/HttpServer.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.IO; +using System.Linq; using System.Net; using System.Text; using System.Threading; @@ -24,12 +25,19 @@ public static class HttpServer { const int DEFAULT_PORT = 8090; const int MAX_PORT_ATTEMPTS = 10; + const double AUTO_RESTART_INTERVAL = 1.0; + const double FAILURE_LOG_INTERVAL = 5.0; static HttpListener s_Listener; static CancellationTokenSource s_Cts; static int s_Port; + static double s_NextStartAttemptTime; + static string s_LastFailureMessage; + static double s_LastFailureLogTime; + static bool s_ManuallyStopped; static readonly ConcurrentQueue s_Queue = new(); + static readonly ConcurrentDictionary, byte> s_Pending = new(); struct WorkItem { @@ -43,45 +51,130 @@ static HttpServer() Start(); EditorApplication.quitting += Stop; AssemblyReloadEvents.beforeAssemblyReload += StopListener; - AssemblyReloadEvents.afterAssemblyReload += Start; + AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; EditorApplication.update += ProcessQueue; } public static int Port => s_Port; + public static bool IsRunning => s_Listener != null && s_Listener.IsListening; + public static int PendingCount => s_Pending.Count; + + public static void ManualStart() + { + s_ManuallyStopped = false; + Start(); + } + + public static void ManualStop() + { + s_ManuallyStopped = true; + Stop(); + } + + /// + /// Drains the queue, faults every in-flight TaskCompletionSource so waiting + /// clients return immediately, and resets the dispatch lock so new commands + /// can run even if a previous handler is still hung. + /// + public static int PurgePending() + { + while (s_Queue.TryDequeue(out _)) { } + + var snapshot = s_Pending.Keys.ToArray(); + var purged = 0; + foreach (var tcs in snapshot) + { + if (tcs.TrySetResult(new ErrorResponse("Purged by user"))) + purged++; + } + s_Pending.Clear(); + + CommandRouter.ResetLock(); + return purged; + } + + static void OnAfterAssemblyReload() + { + // Domain reload clears any prior manual-stop intent; user must re-stop after reload. + s_ManuallyStopped = false; + Start(); + } static void Start() { - if (s_Listener != null) return; + if (IsRunning) return; + // Stale listener left behind by a crashed ListenLoop — tear it down before rebinding. + if (s_Listener != null) StopListener(); for (var attempt = 0; attempt < MAX_PORT_ATTEMPTS; attempt++) { var port = DEFAULT_PORT + attempt; - try - { - var listener = new HttpListener(); - listener.Prefixes.Add($"http://127.0.0.1:{port}/"); - listener.Start(); + if (TryStartOnPort(port)) + return; + } - s_Listener = listener; - s_Port = port; - s_Cts = new CancellationTokenSource(); + Heartbeat.MarkStopped(); + ScheduleRetry(); + LogStartFailure("[UnityCliConnector] Failed to start HTTP server — no available port", true); + } - _ = ListenLoop(s_Cts.Token); + static bool TryStartOnPort(int port) + { + try + { + var listener = new HttpListener(); + listener.Prefixes.Add($"http://127.0.0.1:{port}/"); + listener.Start(); - Debug.Log($"[UnityCliConnector] HTTP server started on port {port}"); - return; - } - catch (HttpListenerException) - { - // Port in use, try next - } - catch (System.Net.Sockets.SocketException) - { - // Windows/Mono throws SocketException instead of HttpListenerException - } + s_Listener = listener; + s_Port = port; + s_Cts = new CancellationTokenSource(); + s_NextStartAttemptTime = 0; + ClearStartFailure(); + + _ = ListenLoop(s_Cts.Token); + + Debug.Log($"[UnityCliConnector] HTTP server started on port {port}"); + return true; + } + catch (HttpListenerException) + { + return false; + } + catch (System.Net.Sockets.SocketException) + { + // Windows/Mono throws SocketException instead of HttpListenerException + return false; } + catch (Exception ex) + { + ScheduleRetry(); + LogStartFailure($"[UnityCliConnector] Failed to start HTTP server on port {port}: {ex.Message}", true); + return false; + } + } - Debug.LogError("[UnityCliConnector] Failed to start HTTP server — no available port"); + static void ScheduleRetry() + { + s_NextStartAttemptTime = EditorApplication.timeSinceStartup + AUTO_RESTART_INTERVAL; + } + + static void LogStartFailure(string message, bool error = false) + { + var now = EditorApplication.timeSinceStartup; + if (s_LastFailureMessage == message && now - s_LastFailureLogTime < FAILURE_LOG_INTERVAL) + return; + + s_LastFailureMessage = message; + s_LastFailureLogTime = now; + if (error) Debug.LogError(message); + else Debug.LogWarning(message); + } + + static void ClearStartFailure() + { + s_LastFailureMessage = null; + s_LastFailureLogTime = 0; } static void StopListener() @@ -108,6 +201,7 @@ static void Stop() { var port = s_Port; StopListener(); + Heartbeat.MarkStopped(); Debug.Log($"[UnityCliConnector] HTTP server stopped (was port {port})"); } @@ -119,6 +213,12 @@ static void ForceEditorUpdate() static void ProcessQueue() { + // Watchdog: recover if the listener died unexpectedly. Skip when the user + // explicitly stopped via ManualStop — otherwise the watchdog would instantly + // revive the listener and the Stop button would be useless. + if (!IsRunning && !s_ManuallyStopped && EditorApplication.timeSinceStartup >= s_NextStartAttemptTime) + Start(); + while (s_Queue.TryDequeue(out var item)) ProcessItem(item); } @@ -128,30 +228,55 @@ static async void ProcessItem(WorkItem item) try { var r = await CommandRouter.Dispatch(item.Command, item.Parameters); - item.Tcs.SetResult(r); + item.Tcs.TrySetResult(r); } catch (Exception ex) { - item.Tcs.SetResult(new ErrorResponse(ex.Message)); + item.Tcs.TrySetResult(new ErrorResponse(ex.Message)); + } + finally + { + s_Pending.TryRemove(item.Tcs, out _); } } static async Task ListenLoop(CancellationToken ct) { - while (ct.IsCancellationRequested == false && s_Listener?.IsListening == true) + try { - try - { - var context = await s_Listener.GetContextAsync(); - _ = HandleRequest(context); - } - catch (ObjectDisposedException) + while (!ct.IsCancellationRequested) { - break; + var listener = s_Listener; + if (listener == null || !listener.IsListening) break; + + try + { + var context = await listener.GetContextAsync(); + _ = HandleRequest(context); + } + catch (ObjectDisposedException) + { + break; + } + catch (HttpListenerException) + { + break; + } } - catch (HttpListenerException) + } + catch (Exception ex) + { + Debug.LogError($"[UnityCliConnector] ListenLoop crashed: {ex.Message}"); + } + finally + { + // If we exited without an explicit cancel, clean up so the watchdog can restart. + if (!ct.IsCancellationRequested && s_Listener != null) { - break; + Debug.LogWarning("[UnityCliConnector] ListenLoop exited unexpectedly — cleaning up for auto-recovery"); + try { s_Listener.Stop(); s_Listener.Close(); } catch { } + s_Listener = null; + Heartbeat.MarkStopped(); } } } @@ -208,6 +333,7 @@ static async Task HandleRequest(HttpListenerContext context) else { var tcs = new TaskCompletionSource(); + s_Pending.TryAdd(tcs, 0); s_Queue.Enqueue(new WorkItem { Command = command,