Skip to content
Merged
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
29 changes: 6 additions & 23 deletions MCPForUnity/Editor/Helpers/PortManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,15 @@ private static int FindAvailablePort()
/// <returns>True if port is available</returns>
public static bool IsPortAvailable(int port)
{
// Start with quick loopback check
try
{
var testListener = new TcpListener(IPAddress.Loopback, port);
#if UNITY_EDITOR_OSX
// On macOS, SO_REUSEADDR (the default) lets multiple processes bind the same
// port — including AssetImportWorkers. ExclusiveAddressUse prevents this so
// the test bind fails when another process already holds the port.
try { testListener.Server.ExclusiveAddressUse = true; } catch { }
#endif
testListener.Start();
testListener.Stop();
}
Expand All @@ -131,28 +136,6 @@ public static bool IsPortAvailable(int port)
return false;
}

#if UNITY_EDITOR_OSX
// On macOS, the OS might report the port as available (SO_REUSEADDR) even if another process
// is using it, unless we also check active connections or try a stricter bind.
// Double check by trying to Connect to it. If we CAN connect, it's NOT available.
try
{
using var client = new TcpClient();
var connectTask = client.ConnectAsync(IPAddress.Loopback, port);
// If we connect successfully, someone is listening -> Not available
if (connectTask.Wait(50) && client.Connected)
{
if (IsDebugEnabled()) McpLog.Info($"[PortManager] Port {port} bind succeeded but connection also succeeded -> Not available (Conflict).");
return false;
}
}
catch
{
// Connection failed -> likely available (or firewall blocked, but we assume available)
if (IsDebugEnabled()) McpLog.Info($"[PortManager] Port {port} connection failed -> likely available.");
}
#endif

return true;
}

Expand Down
120 changes: 100 additions & 20 deletions MCPForUnity/Editor/Services/StdioBridgeReloadHandler.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services.Transport;
using MCPForUnity.Editor.Services.Transport.Transports;
using MCPForUnity.Editor.Windows;
using UnityEditor;

namespace MCPForUnity.Editor.Services
Expand All @@ -13,14 +16,35 @@ namespace MCPForUnity.Editor.Services
[InitializeOnLoad]
internal static class StdioBridgeReloadHandler
{
private static readonly TimeSpan[] ResumeRetrySchedule =
{
TimeSpan.Zero,
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(3),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(10),
TimeSpan.FromSeconds(30)
};

private static CancellationTokenSource _retryCts;

static StdioBridgeReloadHandler()
{
AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload;
AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload;
EditorApplication.quitting += CancelRetries;
}

private static void CancelRetries()
{
try { _retryCts?.Cancel(); } catch { }
}

private static void OnBeforeAssemblyReload()
{
// Cancel any in-flight retry loop before the next reload.
CancelRetries();

try
{
// Only persist resume intent when stdio is the active transport and the bridge is running.
Expand Down Expand Up @@ -89,36 +113,92 @@ private static void OnAfterAssemblyReload()
return;
}

// Restart via TransportManager so state stays in sync; if it fails (port busy), rely on UI to retry.
TryStartBridgeImmediate();
// If the editor is not compiling, attempt an immediate restart without relying on editor focus.
bool isCompiling = EditorApplication.isCompiling;
try
{
var pipeline = Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor");
var prop = pipeline?.GetProperty("isCompiling", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
if (prop != null) isCompiling |= (bool)prop.GetValue(null);
}
catch { }

if (!isCompiling)
{
_ = ResumeStdioWithRetriesAsync();
return;
}

// Fallback when compiling: schedule on the editor loop
EditorApplication.delayCall += () =>
{
_ = ResumeStdioWithRetriesAsync();
};
}

private static void TryStartBridgeImmediate()
private static async Task ResumeStdioWithRetriesAsync()
{
var startTask = MCPServiceLocator.TransportManager.StartAsync(TransportMode.Stdio);
startTask.ContinueWith(t =>
// Cancel any previous retry loop and create a fresh token.
CancelRetries();
var cts = _retryCts = new CancellationTokenSource();
var token = cts.Token;

Exception lastException = null;

for (int i = 0; i < ResumeRetrySchedule.Length; i++)
{
// Clear the flag after attempting to start (success or failure).
// This prevents getting stuck in "Resuming..." state.
// We do this synchronously on the continuation thread - it's safe because
// EditorPrefs operations are thread-safe and any new reload will set the flag
// fresh in OnBeforeAssemblyReload before we get here.
try { EditorPrefs.DeleteKey(EditorPrefKeys.ResumeStdioAfterReload); } catch { }

if (t.IsFaulted)
if (token.IsCancellationRequested) return;

int attempt = i + 1;
McpLog.Debug($"[Stdio Reload] Resume attempt {attempt}/{ResumeRetrySchedule.Length}");

TimeSpan delay = ResumeRetrySchedule[i];
if (delay > TimeSpan.Zero)
{
var baseEx = t.Exception?.GetBaseException();
McpLog.Warn($"Failed to resume stdio bridge after reload: {baseEx?.Message}");
return;
McpLog.Debug($"[Stdio Reload] Waiting {delay.TotalSeconds:0.#}s before resume attempt {attempt}");
try { await Task.Delay(delay, token); }
catch (OperationCanceledException) { return; }
}
if (!t.Result)

// Abort retries if the user switched transports while we were waiting.
if (EditorConfigurationCache.Instance.UseHttpTransport)
{
McpLog.Warn("Failed to resume stdio bridge after domain reload");
try { EditorPrefs.DeleteKey(EditorPrefKeys.ResumeStdioAfterReload); } catch { }
return;
}

MCPForUnity.Editor.Windows.MCPForUnityEditorWindow.RequestHealthVerification();
}, System.Threading.Tasks.TaskScheduler.Default);
try
{
bool started = await MCPServiceLocator.TransportManager.StartAsync(TransportMode.Stdio);
if (started)
{
McpLog.Debug($"[Stdio Reload] Resume succeeded on attempt {attempt}");
try { EditorPrefs.DeleteKey(EditorPrefKeys.ResumeStdioAfterReload); } catch { }
MCPForUnityEditorWindow.RequestHealthVerification();
return;
}

var state = MCPServiceLocator.TransportManager.GetState(TransportMode.Stdio);
string reason = string.IsNullOrWhiteSpace(state?.Error) ? "no error detail" : state.Error;
McpLog.Debug($"[Stdio Reload] Resume attempt {attempt} failed: {reason}");
}
catch (Exception ex)
{
lastException = ex;
McpLog.Debug($"[Stdio Reload] Resume attempt {attempt} threw: {ex.Message}");
}
}

try { EditorPrefs.DeleteKey(EditorPrefKeys.ResumeStdioAfterReload); } catch { }

if (lastException != null)
{
McpLog.Warn($"Failed to resume stdio bridge after domain reload: {lastException.Message}");
}
else
{
McpLog.Warn("Failed to resume stdio bridge after domain reload");
}
}
}
}
Loading