From 907800f26ffe326dedb4e8fa16bdd55432a9b026 Mon Sep 17 00:00:00 2001 From: Kai Gao Date: Fri, 8 May 2026 22:17:29 +0800 Subject: [PATCH] fix: enable ExclusiveAddressUse on Linux for reliable multi-instance connections - Remove #if UNITY_EDITOR_OSX guard from ExclusiveAddressUse in CreateConfiguredListener and IsPortAvailable - Add process ID to status heartbeat payload for multi-instance discovery - Write instance-specific status file (hash-pid.json) alongside project file - GetPortWithFallback now validates stored port availability before returning - Stop() cleans up both project and instance-specific status files - PortManager.SavePort includes PID and writes instance-scoped file --- MCPForUnity/Editor/Helpers/PortManager.cs | 45 ++++++++++---- .../Transport/Transports/StdioBridgeHost.cs | 59 +++++++++++++++---- 2 files changed, 81 insertions(+), 23 deletions(-) diff --git a/MCPForUnity/Editor/Helpers/PortManager.cs b/MCPForUnity/Editor/Helpers/PortManager.cs index f9a2915c3..7f0dc224b 100644 --- a/MCPForUnity/Editor/Helpers/PortManager.cs +++ b/MCPForUnity/Editor/Helpers/PortManager.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.IO; using System.Net; using System.Net.Sockets; @@ -33,10 +34,13 @@ public class PortConfig public int unity_port; public string created_date; public string project_path; + public int pid; } /// /// Get the port to use from storage, or return the default if none has been saved yet. + /// When the stored port is in use by another process (multi-instance scenario), + /// falls back to the default port instead of returning an occupied port. /// /// Port number to use public static int GetPortWithFallback() @@ -46,7 +50,15 @@ public static int GetPortWithFallback() storedConfig.unity_port > 0 && string.Equals(storedConfig.project_path ?? string.Empty, Application.dataPath ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { - return storedConfig.unity_port; + // Only return the stored port if it's actually available. + // When another instance of the same project is running, the stored + // port will be occupied; falling back to DefaultPort lets Start()'s + // SocketException handler pick a truly free port via DiscoverNewPort(). + if (IsPortAvailable(storedConfig.unity_port)) + { + return storedConfig.unity_port; + } + if (IsDebugEnabled()) McpLog.Info($"Stored port {storedConfig.unity_port} is occupied, falling back to default"); } return DefaultPort; @@ -141,12 +153,10 @@ public static bool IsPortAvailable(int port) 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. + // ExclusiveAddressUse prevents SO_REUSEADDR from allowing multiple + // processes (including AssetImportWorkers) to bind the same port. + // The test bind fails when another process already holds the port. try { testListener.Server.ExclusiveAddressUse = true; } catch { } -#endif testListener.Start(); testListener.Stop(); } @@ -221,23 +231,38 @@ private static void SavePort(int port) { try { + int pid = 0; + try { pid = System.Diagnostics.Process.GetCurrentProcess().Id; } catch { } + var portConfig = new PortConfig { unity_port = port, created_date = DateTime.UtcNow.ToString("O"), - project_path = Application.dataPath + project_path = Application.dataPath, + pid = pid }; string registryDir = GetRegistryDirectory(); Directory.CreateDirectory(registryDir); - string registryFile = GetRegistryFilePath(); string json = JsonConvert.SerializeObject(portConfig, Formatting.Indented); + byte[] utf8Bytes = new System.Text.UTF8Encoding(false).GetBytes(json); + // Write to hashed, project-scoped file - File.WriteAllText(registryFile, json, new System.Text.UTF8Encoding(false)); + string registryFile = GetRegistryFilePath(); + File.WriteAllBytes(registryFile, utf8Bytes); // Also write to legacy stable filename to avoid hash/case drift across reloads string legacy = Path.Combine(GetRegistryDirectory(), RegistryFileName); - File.WriteAllText(legacy, json, new System.Text.UTF8Encoding(false)); + File.WriteAllBytes(legacy, utf8Bytes); + + // Write instance-scoped file so multiple instances of the same project + // can be tracked independently. + if (pid > 0) + { + string hash = ComputeProjectHash(Application.dataPath); + string instanceFile = Path.Combine(registryDir, $"unity-mcp-port-{hash}-{pid}.json"); + File.WriteAllBytes(instanceFile, utf8Bytes); + } if (IsDebugEnabled()) McpLog.Info($"Saved port {port} to storage"); } diff --git a/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs index 4c0ff0b87..a9fab9db5 100644 --- a/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs +++ b/MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs @@ -381,14 +381,12 @@ public static void Start() private static TcpListener CreateConfiguredListener(int port) { var newListener = new TcpListener(IPAddress.Loopback, port); -#if UNITY_EDITOR_OSX - // SO_REUSEADDR is intentionally NOT set. On macOS it allows multiple - // processes (including AssetImportWorkers) to bind the same port, - // causing connections to land on a worker that can't process commands. - // The ExclusiveAddressUse flag prevents this; port-busy conflicts are - // handled by the retry/fallback logic in Start() and the reload handler. + // SO_REUSEADDR allows multiple processes to bind the same port on + // Linux/macOS, causing connections to land on a worker/old instance + // that can't process commands. ExclusiveAddressUse prevents this; + // port-busy conflicts are handled by the retry/fallback logic in + // Start() and the reload handler. try { newListener.Server.ExclusiveAddressUse = true; } catch { } -#endif try { newListener.Server.LingerState = new LingerOption(true, 0); @@ -459,11 +457,25 @@ public static void Stop() { dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); } - string statusFile = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json"); - if (File.Exists(statusFile)) + string projectHash = ComputeProjectHash(Application.dataPath); + + string projectFile = Path.Combine(dir, $"unity-mcp-status-{projectHash}.json"); + if (File.Exists(projectFile)) + { + File.Delete(projectFile); + if (IsDebugEnabled()) McpLog.Info($"Deleted project status file: {projectFile}"); + } + + // Clean up instance-specific file so stale entries don't accumulate. + int pid = s_CachedProcessId; + if (pid > 0) { - File.Delete(statusFile); - if (IsDebugEnabled()) McpLog.Info($"Deleted status file: {statusFile}"); + string instanceFile = Path.Combine(dir, $"unity-mcp-status-{projectHash}-{pid}.json"); + if (File.Exists(instanceFile)) + { + File.Delete(instanceFile); + if (IsDebugEnabled()) McpLog.Info($"Deleted instance status file: {instanceFile}"); + } } } catch (Exception ex) @@ -1065,6 +1077,13 @@ private static bool IsValidJson(string text) } + private static readonly int s_CachedProcessId = GetProcessId(); + + private static int GetProcessId() + { + try { return Process.GetCurrentProcess().Id; } catch { return 0; } + } + public static void WriteHeartbeat(bool reloading, string reason = null) { try @@ -1075,7 +1094,6 @@ public static void WriteHeartbeat(bool reloading, string reason = null) dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-mcp"); } Directory.CreateDirectory(dir); - string filePath = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json"); string projectName = "Unknown"; try @@ -1105,6 +1123,7 @@ public static void WriteHeartbeat(bool reloading, string reason = null) var payload = new { unity_port = currentUnityPort, + pid = s_CachedProcessId, reloading, reason = reason ?? (reloading ? "reloading" : "ready"), seq = heartbeatSeq, @@ -1114,7 +1133,21 @@ public static void WriteHeartbeat(bool reloading, string reason = null) last_heartbeat = DateTime.UtcNow.ToString("O"), project_scoped_tools = projectScopedTools }; - File.WriteAllText(filePath, JsonConvert.SerializeObject(payload), new System.Text.UTF8Encoding(false)); + string json = JsonConvert.SerializeObject(payload); + byte[] utf8Bytes = new System.Text.UTF8Encoding(false).GetBytes(json); + + // Project-scoped file: used by clients that look for any instance of this project. + string projectFile = Path.Combine(dir, $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}.json"); + File.WriteAllBytes(projectFile, utf8Bytes); + + // Instance-scoped file: allows clients to discover and select specific + // instances when multiple copies of the same project are running. + if (s_CachedProcessId > 0) + { + string instanceFile = Path.Combine(dir, + $"unity-mcp-status-{ComputeProjectHash(Application.dataPath)}-{s_CachedProcessId}.json"); + File.WriteAllBytes(instanceFile, utf8Bytes); + } } catch (Exception) {