Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions unity-connector/Editor/CommandRouter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,34 @@ namespace UnityCliConnector
/// </summary>
public static class CommandRouter
{
static readonly SemaphoreSlim s_Lock = new(1, 1);
static SemaphoreSlim s_Lock = new(1, 1);

public static async Task<object> 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();
}
}

/// <summary>
/// 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.
/// </summary>
public static void ResetLock()
{
s_Lock = new SemaphoreSlim(1, 1);
}

static async Task<object> DispatchInternal(string command, JObject parameters)
{
if (command == "list")
Expand Down
8 changes: 6 additions & 2 deletions unity-connector/Editor/Heartbeat.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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";
Expand Down
138 changes: 106 additions & 32 deletions unity-connector/Editor/HttpServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<WorkItem> s_Queue = new();

Expand All @@ -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()
Expand All @@ -108,6 +156,7 @@ static void Stop()
{
var port = s_Port;
StopListener();
Heartbeat.MarkStopped();
Debug.Log($"[UnityCliConnector] HTTP server stopped (was port {port})");
}

Expand All @@ -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);
}
Expand All @@ -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();
}
}
}
Expand Down