From 638cb8d8ec68ec3a4706b602282c6f3a61a48343 Mon Sep 17 00:00:00 2001 From: rkdrnf Date: Sat, 2 May 2026 12:36:18 +0900 Subject: [PATCH 1/2] fix(connector): auto-recover when HTTP listener dies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The listener could enter several dead states that required an Editor restart to recover from: 1. Zombie listener: Start() early-returned when s_Listener != null, so if the listener crashed without nulling the reference, every later call became a no-op. 2. ListenLoop exited via exception without clearing s_Listener, leaving the connector permanently dead until the next assembly reload. 3. Initial port-in-use was fatal — once all 10 ports failed at startup, nothing ever retried. 4. A hung command handler held CommandRouter's static semaphore forever; restarting the HTTP server didn't help because the lock lives in CommandRouter. Changes: - HttpServer.IsRunning checks the listener actually accepts traffic, and Start() tears down a stale reference before rebinding. - ListenLoop has a finally block that nulls the listener and marks the heartbeat stopped if it exits unexpectedly. - ProcessQueue acts as a watchdog: if the listener is down it calls Start() (rate-limited via AUTO_RESTART_INTERVAL), so transient port conflicts and silent crashes recover automatically. Failure logging is rate-limited to avoid console spam. - CommandRouter.Dispatch captures the semaphore locally so a ResetLock() swap can't make an in-flight call double-release the new semaphore. ResetLock() exposes a recovery hook for future UI/CLI surfaces that need to clear a hung handler without restarting Unity. - Heartbeat.MarkStopped() lets failure paths flag the heartbeat file as dead so the CLI sees an honest "not responding" state. --- unity-connector/Editor/CommandRouter.cs | 19 +++- unity-connector/Editor/Heartbeat.cs | 4 +- unity-connector/Editor/HttpServer.cs | 138 ++++++++++++++++++------ 3 files changed, 125 insertions(+), 36 deletions(-) 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..679518a 100644 --- a/unity-connector/Editor/Heartbeat.cs +++ b/unity-connector/Editor/Heartbeat.cs @@ -133,7 +133,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(); } } } From 24a4e4b8c69992fddb4e02974bd8cfaf2f39f85d Mon Sep 17 00:00:00 2001 From: rkdrnf Date: Sat, 2 May 2026 14:15:04 +0900 Subject: [PATCH 2/2] fix(connector): stop heartbeat writes when listener is down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MarkStopped() set s_ForcedState = "stopped" and wrote, but the next Tick (~500ms later) cleared s_ForcedState and wrote a live snapshot because Tick only guarded on Port == 0 — a port set during the last successful bind survives a listener crash. Result: the "stopped" state existed for one heartbeat interval, then got overwritten with state="ready" while the listener was actually dead. Switch the guard to !HttpServer.IsRunning so the heartbeat goes silent when the listener is down, letting MarkStopped's write persist until the watchdog restarts it. --- unity-connector/Editor/Heartbeat.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/unity-connector/Editor/Heartbeat.cs b/unity-connector/Editor/Heartbeat.cs index 679518a..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;