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/Heartbeat.cs b/unity-connector/Editor/Heartbeat.cs index f4c5400..32585fa 100644 --- a/unity-connector/Editor/Heartbeat.cs +++ b/unity-connector/Editor/Heartbeat.cs @@ -60,7 +60,9 @@ public static void MarkCompileRequested() 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 +135,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..5eef879 100644 --- a/unity-connector/Editor/HttpServer.cs +++ b/unity-connector/Editor/HttpServer.cs @@ -24,10 +24,15 @@ 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 readonly ConcurrentQueue s_Queue = new(); @@ -48,40 +53,83 @@ static HttpServer() } public static int Port => s_Port; + public static bool IsRunning => s_Listener != null && s_Listener.IsListening; 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; } + } + + 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); + } - Debug.LogError("[UnityCliConnector] Failed to start HTTP server — no available port"); + static void ClearStartFailure() + { + s_LastFailureMessage = null; + s_LastFailureLogTime = 0; } static void StopListener() @@ -108,6 +156,7 @@ static void Stop() { var port = s_Port; StopListener(); + Heartbeat.MarkStopped(); Debug.Log($"[UnityCliConnector] HTTP server stopped (was port {port})"); } @@ -119,6 +168,10 @@ static void ForceEditorUpdate() static void ProcessQueue() { + // Watchdog: recover if the listener died unexpectedly between assembly reloads. + if (!IsRunning && EditorApplication.timeSinceStartup >= s_NextStartAttemptTime) + Start(); + while (s_Queue.TryDequeue(out var item)) ProcessItem(item); } @@ -138,20 +191,41 @@ static async void ProcessItem(WorkItem item) 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(); } } }