diff --git a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs index 6f7ca9f04..5f7a3bb9d 100644 --- a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs +++ b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs @@ -8,12 +8,14 @@ internal static class EditorPrefKeys { internal const string UseHttpTransport = "MCPForUnity.UseHttpTransport"; internal const string HttpTransportScope = "MCPForUnity.HttpTransportScope"; // "local" | "remote" - internal const string LastLocalHttpServerPid = "MCPForUnity.LocalHttpServer.LastPid"; - internal const string LastLocalHttpServerPort = "MCPForUnity.LocalHttpServer.LastPort"; - internal const string LastLocalHttpServerStartedUtc = "MCPForUnity.LocalHttpServer.LastStartedUtc"; - internal const string LastLocalHttpServerPidArgsHash = "MCPForUnity.LocalHttpServer.LastPidArgsHash"; - internal const string LastLocalHttpServerPidFilePath = "MCPForUnity.LocalHttpServer.LastPidFilePath"; - internal const string LastLocalHttpServerInstanceToken = "MCPForUnity.LocalHttpServer.LastInstanceToken"; + // Unity-managed local HTTP server state. EditorPrefs are shared by every editor the user runs, + // so PidFileManager appends ".{projectHash}.{port}" to each of these: two editors, or two ports, + // never read or clear each other's slot. + internal const string LocalHttpServerPid = "MCPForUnity.LocalHttpServer.Pid"; + internal const string LocalHttpServerStartedUtc = "MCPForUnity.LocalHttpServer.StartedUtc"; + internal const string LocalHttpServerPidArgsHash = "MCPForUnity.LocalHttpServer.PidArgsHash"; + internal const string LocalHttpServerPidFilePath = "MCPForUnity.LocalHttpServer.PidFilePath"; + internal const string LocalHttpServerInstanceToken = "MCPForUnity.LocalHttpServer.InstanceToken"; internal const string DebugLogs = "MCPForUnity.DebugLogs"; internal const string ValidationLevel = "MCPForUnity.ValidationLevel"; internal const string UnitySocketPort = "MCPForUnity.UnitySocketPort"; diff --git a/MCPForUnity/Editor/Services/IServerManagementService.cs b/MCPForUnity/Editor/Services/IServerManagementService.cs index 60f90b04d..4581371b7 100644 --- a/MCPForUnity/Editor/Services/IServerManagementService.cs +++ b/MCPForUnity/Editor/Services/IServerManagementService.cs @@ -49,11 +49,25 @@ public interface IServerManagementService bool StopLocalHttpServer(); /// - /// Stop the Unity-managed local HTTP server if a handshake/pidfile exists, - /// even if the current transport selection has changed. + /// Port of the local HTTP server this editor process launched, from the SessionState launch + /// marker. False for servers launched by another editor, by an earlier run of this editor, + /// or externally. + /// + bool TryGetLaunchedLocalHttpServerPort(out int port); + + /// + /// Stop the local HTTP server this editor process launched, even if the current transport + /// selection has changed. No-op when this process did not launch one. /// bool StopManagedLocalHttpServer(); + /// + /// Asks the server on how many Unity instances other than this + /// project are connected (GET /api/instances). Bounded by ; + /// returns false when the server did not answer with a well-formed response in time. + /// + bool TryCountOtherConnectedUnityInstances(int port, int timeoutMs, out int otherInstances); + /// /// Best-effort detection: returns true if a local MCP HTTP server appears to be running /// on the configured local URL/port (used to drive UI state even if the session is not active). diff --git a/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs b/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs index e757b86ce..f97ccbcfc 100644 --- a/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs +++ b/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs @@ -10,12 +10,19 @@ namespace MCPForUnity.Editor.Services /// /// Best-effort cleanup when the Unity Editor is quitting. /// - Stops active transports so clients don't see a "hung" session longer than necessary. - /// - Stops the local HTTP server this Unity instance launched (handshake/pidfile-based), so a - /// headless server doesn't become an invisible orphan. This runs on quit only, never on domain reload. + /// - Stops the local HTTP server this editor process launched, but only when no other Unity + /// instance is still connected to it (last one out turns the lights off). A headless server + /// has no terminal window, so an unstopped one would be an invisible orphan; a stopped one + /// that other editors still use disconnects all of them. This runs on quit only, never on + /// domain reload. /// [InitializeOnLoad] internal static class McpEditorShutdownCleanup { + // Upper bound for asking the server how many Unity instances are still connected. The quit + // handler already waits up to 750 ms on transport stops; keep the whole thing near a second. + internal const int InstanceProbeTimeoutMs = 500; + static McpEditorShutdownCleanup() { // Guard against duplicate subscriptions across domain reloads. @@ -23,15 +30,23 @@ static McpEditorShutdownCleanup() EditorApplication.quitting += OnEditorQuitting; } - // A -batchmode/CI instance resolves the interactive editor's server via the global - // pidfile+port handshake, so cleanup there would stop another user's server. Mirror the - // sibling guards (HttpAutoStartHandler, StdioBridgeHost): skip in batch unless opted in. + // A -batchmode/CI instance never auto-starts the server (HttpAutoStartHandler has the same + // guard), so it has nothing of its own to stop. Mirror the sibling guards (HttpAutoStartHandler, + // StdioBridgeHost): skip in batch unless opted in. internal static bool ShouldRunCleanup() => ShouldRunCleanup(Application.isBatchMode, Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH")); internal static bool ShouldRunCleanup(bool isBatchMode, string allowBatchEnv) => !isBatchMode || !string.IsNullOrWhiteSpace(allowBatchEnv); + /// + /// Last-one-out decision for a server this editor process launched. + /// is null when the server did not answer the instance probe in time; that fails toward leaving + /// it running, because killing a server other editors depend on is worse than a stray process. + /// + internal static bool ShouldStopManagedServer(int? otherConnectedInstances) => + otherConnectedInstances == 0; + private static void OnEditorQuitting() { if (!ShouldRunCleanup()) return; @@ -52,14 +67,32 @@ private static void OnEditorQuitting() McpLog.Warn($"Shutdown cleanup: failed to stop transports: {ex.Message}"); } - // 2) Stop the local HTTP server this Unity instance launched (best-effort). - // Headless servers have no terminal window, so an unstopped one is an invisible orphan. - // StopManagedLocalHttpServer only stops the server matching our pidfile+instance-token handshake, - // so it never touches servers launched by other Unity instances. This runs on quit only; - // domain reloads must NOT stop the server (and don't — this handler is gated on EditorApplication.quitting). + // 2) Stop the local HTTP server this editor process launched (best-effort). + // The launch marker lives in SessionState, which is per editor process, so a server launched + // by another editor on this machine (or started externally) is never resolved here. Even for + // our own launch, other editors may share it: ask the server first and only stop it when we + // are the last Unity instance connected. try { - MCPServiceLocator.Server.StopManagedLocalHttpServer(); + var server = MCPServiceLocator.Server; + if (!server.TryGetLaunchedLocalHttpServerPort(out int port)) + { + return; + } + + int? otherInstances = server.TryCountOtherConnectedUnityInstances(port, InstanceProbeTimeoutMs, out int count) + ? count + : (int?)null; + + if (!ShouldStopManagedServer(otherInstances)) + { + McpLog.Debug(otherInstances.HasValue + ? $"Shutdown cleanup: leaving local HTTP server on port {port} running; {otherInstances.Value} other Unity instance(s) still connected." + : $"Shutdown cleanup: leaving local HTTP server on port {port} running; it did not report connected instances within {InstanceProbeTimeoutMs} ms."); + return; + } + + server.StopManagedLocalHttpServer(); } catch (Exception ex) { @@ -68,4 +101,3 @@ private static void OnEditorQuitting() } } } - diff --git a/MCPForUnity/Editor/Services/Server/IPidFileManager.cs b/MCPForUnity/Editor/Services/Server/IPidFileManager.cs index b9bd74be9..7be5234bf 100644 --- a/MCPForUnity/Editor/Services/Server/IPidFileManager.cs +++ b/MCPForUnity/Editor/Services/Server/IPidFileManager.cs @@ -2,7 +2,9 @@ namespace MCPForUnity.Editor.Services.Server { /// /// Interface for managing PID files and handshake state for the local HTTP server. - /// Handles persistence of server process information across Unity domain reloads. + /// Handshake and tracking state is keyed per project and per port, and the launch + /// marker is per editor process, so concurrent editors sharing one server (or running + /// servers on different ports) never act on each other's state. /// public interface IPidFileManager { @@ -27,14 +29,6 @@ public interface IPidFileManager /// True if a valid PID was read bool TryReadPid(string pidFilePath, out int pid); - /// - /// Attempts to extract the port number from a PID file path. - /// - /// Path to the PID file - /// Output: the port number - /// True if the port was extracted successfully - bool TryGetPortFromPidFilePath(string pidFilePath, out int port); - /// /// Deletes a PID file. /// @@ -42,22 +36,35 @@ public interface IPidFileManager void DeletePidFile(string pidFilePath); /// - /// Stores the handshake information (PID file path and instance token) in EditorPrefs. + /// Stores the handshake for a server this editor process just launched: the PID file path and + /// instance token go to EditorPrefs (per project, per port; survives editor restarts so the + /// server can still be stopped deterministically later), and the port is recorded in + /// SessionState as the launch marker (survives domain reloads, dies with this editor process). /// + /// Port the server was launched on /// Path to the PID file /// Unique instance token for the server - void StoreHandshake(string pidFilePath, string instanceToken); + void StoreHandshake(int port, string pidFilePath, string instanceToken); /// - /// Attempts to retrieve stored handshake information from EditorPrefs. + /// Attempts to retrieve the stored handshake for a port (this project's slot only). /// + /// Port to look up /// Output: stored PID file path /// Output: stored instance token /// True if valid handshake information was found - bool TryGetHandshake(out string pidFilePath, out string instanceToken); + bool TryGetHandshake(int port, out string pidFilePath, out string instanceToken); /// - /// Stores PID tracking information in EditorPrefs. + /// Returns the port of the server this editor process launched, if any. False for servers + /// launched by other editors, by an earlier run of this editor, or externally. + /// + /// Output: the launched port + /// True if this editor process launched a server + bool TryGetLaunchedPort(out int port); + + /// + /// Stores PID tracking information in EditorPrefs (per project, per port). /// /// The process ID /// The port number @@ -74,15 +81,18 @@ public interface IPidFileManager bool TryGetStoredPid(int expectedPort, out int pid); /// - /// Gets the stored args hash for the tracked server. + /// Gets the stored args hash for the tracked server on a port. /// + /// The port number /// The stored args hash, or empty string if not found - string GetStoredArgsHash(); + string GetStoredArgsHash(int port); /// - /// Clears all PID tracking information from EditorPrefs. + /// Clears handshake and tracking information for a port, and the launch marker if it + /// points at that port. Other ports and other projects are untouched. /// - void ClearTracking(); + /// The port number + void ClearTracking(int port); /// /// Computes a short hash of the input string for fingerprinting. diff --git a/MCPForUnity/Editor/Services/Server/PidFileManager.cs b/MCPForUnity/Editor/Services/Server/PidFileManager.cs index eca60ee27..7fc0b2dc0 100644 --- a/MCPForUnity/Editor/Services/Server/PidFileManager.cs +++ b/MCPForUnity/Editor/Services/Server/PidFileManager.cs @@ -5,6 +5,7 @@ using System.Security.Cryptography; using System.Text; using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; using UnityEditor; using UnityEngine; @@ -12,10 +13,14 @@ namespace MCPForUnity.Editor.Services.Server { /// /// Manages PID files and handshake state for the local HTTP server. - /// Handles persistence of server process information across Unity domain reloads. + /// EditorPrefs are per user, not per editor, so every key is suffixed with the project hash and + /// port; the "this editor process launched it" marker lives in SessionState, which survives + /// domain reloads and dies with the process. /// public class PidFileManager : IPidFileManager { + internal const string LaunchedPortSessionKey = "MCPForUnity.LocalHttpServer.LaunchedPort"; + /// public string GetPidDirectory() { @@ -64,39 +69,6 @@ public bool TryReadPid(string pidFilePath, out int pid) } } - /// - public bool TryGetPortFromPidFilePath(string pidFilePath, out int port) - { - port = 0; - if (string.IsNullOrEmpty(pidFilePath)) - { - return false; - } - - try - { - string fileName = Path.GetFileNameWithoutExtension(pidFilePath); - if (string.IsNullOrEmpty(fileName)) - { - return false; - } - - const string prefix = "mcp_http_"; - if (!fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - string portText = fileName.Substring(prefix.Length); - return int.TryParse(portText, out port) && port > 0; - } - catch - { - port = 0; - return false; - } - } - /// public void DeletePidFile(string pidFilePath) { @@ -111,13 +83,18 @@ public void DeletePidFile(string pidFilePath) } /// - public void StoreHandshake(string pidFilePath, string instanceToken) + public void StoreHandshake(int port, string pidFilePath, string instanceToken) { + if (port <= 0) + { + return; + } + try { if (!string.IsNullOrEmpty(pidFilePath)) { - EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerPidFilePath, pidFilePath); + EditorPrefs.SetString(Key(EditorPrefKeys.LocalHttpServerPidFilePath, port), pidFilePath); } } catch { } @@ -126,21 +103,28 @@ public void StoreHandshake(string pidFilePath, string instanceToken) { if (!string.IsNullOrEmpty(instanceToken)) { - EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerInstanceToken, instanceToken); + EditorPrefs.SetString(Key(EditorPrefKeys.LocalHttpServerInstanceToken, port), instanceToken); } } catch { } + + try { SessionState.SetInt(LaunchedPortSessionKey, port); } catch { } } /// - public bool TryGetHandshake(out string pidFilePath, out string instanceToken) + public bool TryGetHandshake(int port, out string pidFilePath, out string instanceToken) { pidFilePath = null; instanceToken = null; + if (port <= 0) + { + return false; + } + try { - pidFilePath = EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerPidFilePath, string.Empty); - instanceToken = EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerInstanceToken, string.Empty); + pidFilePath = EditorPrefs.GetString(Key(EditorPrefKeys.LocalHttpServerPidFilePath, port), string.Empty); + instanceToken = EditorPrefs.GetString(Key(EditorPrefKeys.LocalHttpServerInstanceToken, port), string.Empty); if (string.IsNullOrEmpty(pidFilePath) || string.IsNullOrEmpty(instanceToken)) { pidFilePath = null; @@ -157,21 +141,33 @@ public bool TryGetHandshake(out string pidFilePath, out string instanceToken) } } + /// + public bool TryGetLaunchedPort(out int port) + { + port = 0; + try { port = SessionState.GetInt(LaunchedPortSessionKey, 0); } catch { port = 0; } + return port > 0; + } + /// public void StoreTracking(int pid, int port, string argsHash = null) { - try { EditorPrefs.SetInt(EditorPrefKeys.LastLocalHttpServerPid, pid); } catch { } - try { EditorPrefs.SetInt(EditorPrefKeys.LastLocalHttpServerPort, port); } catch { } - try { EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerStartedUtc, DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)); } catch { } + if (port <= 0) + { + return; + } + + try { EditorPrefs.SetInt(Key(EditorPrefKeys.LocalHttpServerPid, port), pid); } catch { } + try { EditorPrefs.SetString(Key(EditorPrefKeys.LocalHttpServerStartedUtc, port), DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)); } catch { } try { if (!string.IsNullOrEmpty(argsHash)) { - EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerPidArgsHash, argsHash); + EditorPrefs.SetString(Key(EditorPrefKeys.LocalHttpServerPidArgsHash, port), argsHash); } else { - EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPidArgsHash); + EditorPrefs.DeleteKey(Key(EditorPrefKeys.LocalHttpServerPidArgsHash, port)); } } catch { } @@ -181,13 +177,17 @@ public void StoreTracking(int pid, int port, string argsHash = null) public bool TryGetStoredPid(int expectedPort, out int pid) { pid = 0; + if (expectedPort <= 0) + { + return false; + } + try { - int storedPid = EditorPrefs.GetInt(EditorPrefKeys.LastLocalHttpServerPid, 0); - int storedPort = EditorPrefs.GetInt(EditorPrefKeys.LastLocalHttpServerPort, 0); - string storedUtc = EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerStartedUtc, string.Empty); + int storedPid = EditorPrefs.GetInt(Key(EditorPrefKeys.LocalHttpServerPid, expectedPort), 0); + string storedUtc = EditorPrefs.GetString(Key(EditorPrefKeys.LocalHttpServerStartedUtc, expectedPort), string.Empty); - if (storedPid <= 0 || storedPort != expectedPort) + if (storedPid <= 0) { return false; } @@ -213,11 +213,11 @@ public bool TryGetStoredPid(int expectedPort, out int pid) } /// - public string GetStoredArgsHash() + public string GetStoredArgsHash(int port) { try { - return EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerPidArgsHash, string.Empty); + return EditorPrefs.GetString(Key(EditorPrefKeys.LocalHttpServerPidArgsHash, port), string.Empty); } catch { @@ -226,14 +226,31 @@ public string GetStoredArgsHash() } /// - public void ClearTracking() + public void ClearTracking(int port) + { + if (port <= 0) + { + return; + } + + try { EditorPrefs.DeleteKey(Key(EditorPrefKeys.LocalHttpServerPid, port)); } catch { } + try { EditorPrefs.DeleteKey(Key(EditorPrefKeys.LocalHttpServerStartedUtc, port)); } catch { } + try { EditorPrefs.DeleteKey(Key(EditorPrefKeys.LocalHttpServerPidArgsHash, port)); } catch { } + try { EditorPrefs.DeleteKey(Key(EditorPrefKeys.LocalHttpServerPidFilePath, port)); } catch { } + try { EditorPrefs.DeleteKey(Key(EditorPrefKeys.LocalHttpServerInstanceToken, port)); } catch { } + try + { + if (SessionState.GetInt(LaunchedPortSessionKey, 0) == port) + { + SessionState.EraseInt(LaunchedPortSessionKey); + } + } + catch { } + } + + private static string Key(string baseKey, int port) { - try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPid); } catch { } - try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPort); } catch { } - try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerStartedUtc); } catch { } - try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPidArgsHash); } catch { } - try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPidFilePath); } catch { } - try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerInstanceToken); } catch { } + return $"{baseKey}.{ProjectIdentityUtility.GetProjectHash()}.{port}"; } /// diff --git a/MCPForUnity/Editor/Services/ServerManagementService.cs b/MCPForUnity/Editor/Services/ServerManagementService.cs index 7a2c546cc..8ea74f2b3 100644 --- a/MCPForUnity/Editor/Services/ServerManagementService.cs +++ b/MCPForUnity/Editor/Services/ServerManagementService.cs @@ -61,19 +61,19 @@ private string NormalizeForMatch(string s) return _processDetector.NormalizeForMatch(s); } - private void ClearLocalServerPidTracking() + private void ClearLocalServerPidTracking(int port) { - _pidFileManager.ClearTracking(); + _pidFileManager.ClearTracking(port); } - private void StoreLocalHttpServerHandshake(string pidFilePath, string instanceToken) + private void StoreLocalHttpServerHandshake(int port, string pidFilePath, string instanceToken) { - _pidFileManager.StoreHandshake(pidFilePath, instanceToken); + _pidFileManager.StoreHandshake(port, pidFilePath, instanceToken); } - private bool TryGetLocalHttpServerHandshake(out string pidFilePath, out string instanceToken) + private bool TryGetLocalHttpServerHandshake(int port, out string pidFilePath, out string instanceToken) { - return _pidFileManager.TryGetHandshake(out pidFilePath, out instanceToken); + return _pidFileManager.TryGetHandshake(port, out pidFilePath, out instanceToken); } private string GetLocalHttpServerPidFilePath(int port) @@ -130,9 +130,9 @@ private bool TryGetStoredLocalServerPid(int expectedPort, out int pid) return _pidFileManager.TryGetStoredPid(expectedPort, out pid); } - private string GetStoredArgsHash() + private string GetStoredArgsHash(int port) { - return _pidFileManager.GetStoredArgsHash(); + return _pidFileManager.GetStoredArgsHash(port); } /// @@ -315,8 +315,11 @@ public bool StartLocalHttpServer(bool quiet = false) try { - // Clear any stale handshake state from prior launches. - ClearLocalServerPidTracking(); + // Clear any stale handshake state from prior launches on this port. + if (portForPid > 0) + { + ClearLocalServerPidTracking(portForPid); + } _lastLaunchedProcess = null; // Best-effort: delete stale pidfile if it exists. @@ -362,7 +365,7 @@ public bool StartLocalHttpServer(bool quiet = false) _lastLaunchedProcess = System.Diagnostics.Process.Start(startInfo); if (!string.IsNullOrEmpty(pidFilePath)) { - StoreLocalHttpServerHandshake(pidFilePath, instanceToken); + StoreLocalHttpServerHandshake(portForPid, pidFilePath, instanceToken); } return true; } @@ -388,31 +391,129 @@ public bool StopLocalHttpServer() return StopLocalHttpServerInternal(quiet: false); } + public bool TryGetLaunchedLocalHttpServerPort(out int port) + { + return _pidFileManager.TryGetLaunchedPort(out port); + } + public bool StopManagedLocalHttpServer() { - if (!TryGetLocalHttpServerHandshake(out var pidFilePath, out _)) + // The SessionState launch marker is per editor process, so a server launched by another + // editor (or externally) has no marker here and is left alone. + if (!TryGetLaunchedLocalHttpServerPort(out int port)) { return false; } - int port = 0; - if (!TryGetPortFromPidFilePath(pidFilePath, out port) || port <= 0) + return StopLocalHttpServerInternal(quiet: true, portOverride: port, allowNonLocalUrl: true); + } + + public bool TryCountOtherConnectedUnityInstances(int port, int timeoutMs, out int otherInstances) + { + otherInstances = 0; + if (port <= 0) + { + return false; + } + + try { string baseUrl = HttpEndpointUtility.GetLocalBaseUrl(); - if (IsLocalUrl(baseUrl) - && Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri) - && uri.Port > 0) + if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri)) { - port = uri.Port; + return false; + } + + string ownProjectHash = ProjectIdentityUtility.GetProjectHash(); + + // Same shared budget across candidate hosts as TryConnectToLocalPort: this runs on quit. + var elapsed = System.Diagnostics.Stopwatch.StartNew(); + foreach (string host in BuildLocalProbeHosts(uri.Host)) + { + int remainingMs = timeoutMs - (int)elapsed.ElapsedMilliseconds; + if (remainingMs <= 0) + { + break; + } + + string url = new UriBuilder(uri.Scheme, host, port, "/api/instances").Uri.ToString(); + string json = TryHttpGet(url, remainingMs); + if (json != null) + { + return TryCountOtherInstances(json, ownProjectHash, out otherInstances); + } } } + catch { } - if (port <= 0) + return false; + } + + /// + /// Counts the instances in a GET /api/instances response whose project hash is not ours. + /// Our own session may still be listed while the hub processes our WebSocket close, so it is + /// filtered by hash rather than assumed gone. Returns false for anything that is not a + /// well-formed success response, so callers fail toward leaving the server running. + /// + internal static bool TryCountOtherInstances(string json, string ownProjectHash, out int otherInstances) + { + otherInstances = 0; + try { + var root = Newtonsoft.Json.Linq.JObject.Parse(json); + if (root.Value("success") != true) + { + return false; + } + + if (!(root["instances"] is Newtonsoft.Json.Linq.JArray instances)) + { + return false; + } + + foreach (var instance in instances) + { + string hash = (instance as Newtonsoft.Json.Linq.JObject)?.Value("hash"); + if (!string.Equals(hash, ownProjectHash, StringComparison.OrdinalIgnoreCase)) + { + otherInstances++; + } + } + + return true; + } + catch + { + otherInstances = 0; return false; } + } - return StopLocalHttpServerInternal(quiet: true, portOverride: port, allowNonLocalUrl: true); + private static string TryHttpGet(string url, int timeoutMs) + { + try + { + using (var client = new System.Net.Http.HttpClient()) + { + client.Timeout = TimeSpan.FromMilliseconds(timeoutMs); + var task = System.Threading.Tasks.Task.Run(async () => + { + using (var response = await client.GetAsync(url).ConfigureAwait(false)) + { + if (!response.IsSuccessStatusCode) + { + return null; + } + return await response.Content.ReadAsStringAsync().ConfigureAwait(false); + } + }); + return task.Wait(timeoutMs) ? task.Result : null; + } + } + catch + { + return null; + } } public bool IsLocalHttpServerRunning() @@ -433,7 +534,7 @@ public bool IsLocalHttpServerRunning() int port = uri.Port; // Handshake path: if we have a pidfile+token and the PID is still the listener, treat as running. - if (TryGetLocalHttpServerHandshake(out var pidFilePath, out var instanceToken) + if (TryGetLocalHttpServerHandshake(port, out var pidFilePath, out var instanceToken) && TryReadPidFromPidFile(pidFilePath, out var pidFromFile) && pidFromFile > 0) { @@ -636,7 +737,7 @@ private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, b // Preferred deterministic stop path: if we have a pidfile+token from a Unity-managed launch, // validate and terminate exactly that PID. - if (TryGetLocalHttpServerHandshake(out var pidFilePath, out var instanceToken)) + if (TryGetLocalHttpServerHandshake(port, out var pidFilePath, out var instanceToken)) { // Prefer deterministic stop when Unity started the server (pidfile+token). // If the pidfile isn't available yet (fast quit after start), we can optionally fall back @@ -675,7 +776,7 @@ private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, b { // Nothing is listening anymore; clear stale handshake state. try { DeletePidFile(pidFilePath); } catch { } - ClearLocalServerPidTracking(); + ClearLocalServerPidTracking(port); if (!quiet) { McpLog.Info($"No process found listening on port {port}"); @@ -702,7 +803,7 @@ private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, b { stoppedAny = true; try { DeletePidFile(pidFilePath); } catch { } - ClearLocalServerPidTracking(); + ClearLocalServerPidTracking(port); if (!quiet) { McpLog.Info($"Stopped local HTTP server on port {port} (PID: {pidFromFile})"); @@ -727,7 +828,7 @@ private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, b $"(tokenMatch={tokenMatches}, tokenQueryOk={tokenQueryOk}). Falling back to guarded port heuristics."); } try { DeletePidFile(pidFilePath); } catch { } - ClearLocalServerPidTracking(); + ClearLocalServerPidTracking(port); } else { @@ -755,7 +856,7 @@ private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, b { McpLog.Info($"Stopped local HTTP server on port {port}"); } - ClearLocalServerPidTracking(); + ClearLocalServerPidTracking(port); return true; } @@ -763,7 +864,7 @@ private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, b { McpLog.Info($"No process found listening on port {port}"); } - ClearLocalServerPidTracking(); + ClearLocalServerPidTracking(port); return false; } @@ -773,7 +874,7 @@ private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, b if (pids.Contains(storedPid)) { string expectedHash = string.Empty; - expectedHash = GetStoredArgsHash(); + expectedHash = GetStoredArgsHash(port); // Prefer a fingerprint match (reduces PID reuse risk). If missing (older installs), // fall back to a looser check to avoid leaving orphaned servers after domain reload. @@ -819,7 +920,7 @@ private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, b McpLog.Info($"Stopped local HTTP server on port {port} (PID: {storedPid})"); } stoppedAny = true; - ClearLocalServerPidTracking(); + ClearLocalServerPidTracking(port); // Refresh the PID list to avoid double-work. pids = GetListeningProcessIdsForPort(port); } @@ -833,7 +934,7 @@ private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, b else { // Stale PID (no longer listening). Clear. - ClearLocalServerPidTracking(); + ClearLocalServerPidTracking(port); } } @@ -874,7 +975,7 @@ private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, b if (stoppedAny) { - ClearLocalServerPidTracking(); + ClearLocalServerPidTracking(port); } return stoppedAny; } @@ -893,11 +994,6 @@ private bool TryGetUnixProcessArgs(int pid, out string argsLower) return _processDetector.TryGetProcessCommandLine(pid, out argsLower); } - private bool TryGetPortFromPidFilePath(string pidFilePath, out int port) - { - return _pidFileManager.TryGetPortFromPidFilePath(pidFilePath, out port); - } - private void DeletePidFile(string pidFilePath) { _pidFileManager.DeletePidFile(pidFilePath); diff --git a/MCPForUnity/Editor/Windows/EditorPrefs/EditorPrefsWindow.cs b/MCPForUnity/Editor/Windows/EditorPrefs/EditorPrefsWindow.cs index 3d011362a..0ea0a1400 100644 --- a/MCPForUnity/Editor/Windows/EditorPrefs/EditorPrefsWindow.cs +++ b/MCPForUnity/Editor/Windows/EditorPrefs/EditorPrefsWindow.cs @@ -50,8 +50,6 @@ public class EditorPrefsWindow : EditorWindow { EditorPrefKeys.ValidationLevel, EditorPrefType.Int }, { EditorPrefKeys.LastUpdateCheck, EditorPrefType.String }, { EditorPrefKeys.LastStdIoUpgradeVersion, EditorPrefType.Int }, - { EditorPrefKeys.LastLocalHttpServerPid, EditorPrefType.Int }, - { EditorPrefKeys.LastLocalHttpServerPort, EditorPrefType.Int }, // String prefs { EditorPrefKeys.EditorWindowActivePanel, EditorPrefType.String }, @@ -72,10 +70,6 @@ public class EditorPrefsWindow : EditorWindow { EditorPrefKeys.LatestKnownVersion, EditorPrefType.String }, { EditorPrefKeys.LastAssetStoreUpdateCheck, EditorPrefType.String }, { EditorPrefKeys.LatestKnownAssetStoreVersion, EditorPrefType.String }, - { EditorPrefKeys.LastLocalHttpServerStartedUtc, EditorPrefType.String }, - { EditorPrefKeys.LastLocalHttpServerPidArgsHash, EditorPrefType.String }, - { EditorPrefKeys.LastLocalHttpServerPidFilePath, EditorPrefType.String }, - { EditorPrefKeys.LastLocalHttpServerInstanceToken, EditorPrefType.String }, }; // Templates diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/McpEditorShutdownCleanupTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/McpEditorShutdownCleanupTests.cs index 74bf851bd..0ded819b1 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/McpEditorShutdownCleanupTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/McpEditorShutdownCleanupTests.cs @@ -45,5 +45,28 @@ public void ShouldRunCleanup_Parameterless_MatchesEnvironment() || !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH")); Assert.AreEqual(expected, McpEditorShutdownCleanup.ShouldRunCleanup()); } + + [Test] + public void ShouldStopManagedServer_NoOtherInstances_Stops() + { + // Last one out: the launching editor is the only Unity instance left on the server. + Assert.IsTrue(McpEditorShutdownCleanup.ShouldStopManagedServer(otherConnectedInstances: 0)); + } + + [Test] + public void ShouldStopManagedServer_OtherInstancesConnected_LeavesServerRunning() + { + // Two editors share one HTTP-local server; the launcher quitting must not disconnect the other. + Assert.IsFalse(McpEditorShutdownCleanup.ShouldStopManagedServer(otherConnectedInstances: 1)); + Assert.IsFalse(McpEditorShutdownCleanup.ShouldStopManagedServer(otherConnectedInstances: 3)); + } + + [Test] + public void ShouldStopManagedServer_ProbeFailed_LeavesServerRunning() + { + // null = the server did not answer within the quit-time budget. Fail toward not killing: + // a stray headless process is recoverable, a torn-down shared server is not. + Assert.IsFalse(McpEditorShutdownCleanup.ShouldStopManagedServer(otherConnectedInstances: null)); + } } } diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/PidFileManagerTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/PidFileManagerTests.cs index 8b0d53508..99d3b898d 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/PidFileManagerTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/PidFileManagerTests.cs @@ -1,7 +1,6 @@ using System.IO; using NUnit.Framework; using MCPForUnity.Editor.Services.Server; -using MCPForUnity.Editor.Constants; using UnityEditor; using UnityEngine; @@ -13,15 +12,24 @@ namespace MCPForUnityTests.Editor.Services.Server [TestFixture] public class PidFileManagerTests { + // Ports used by handshake/tracking tests. Kept away from the default 8080 so a server the + // host editor actually launched is never touched, and cleared per port because the slots + // are per project+port now. + private const int PortA = 58080; + private const int PortB = 58081; + private PidFileManager _manager; private string _testPidFilePath; + private int _savedLaunchedPort; [SetUp] public void SetUp() { _manager = new PidFileManager(); - // Clear any test state - ClearTestEditorPrefs(); + // The launch marker is one SessionState slot per editor process; preserve whatever the + // host editor recorded so running these tests never drops its own server ownership. + _savedLaunchedPort = SessionState.GetInt(PidFileManager.LaunchedPortSessionKey, 0); + ClearTestState(); } [TearDown] @@ -32,18 +40,18 @@ public void TearDown() { try { File.Delete(_testPidFilePath); } catch { } } - // Clear test state - ClearTestEditorPrefs(); + ClearTestState(); + if (_savedLaunchedPort > 0) + { + SessionState.SetInt(PidFileManager.LaunchedPortSessionKey, _savedLaunchedPort); + } } - private void ClearTestEditorPrefs() + private void ClearTestState() { - try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPid); } catch { } - try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPort); } catch { } - try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerStartedUtc); } catch { } - try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPidArgsHash); } catch { } - try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPidFilePath); } catch { } - try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerInstanceToken); } catch { } + _manager.ClearTracking(PortA); + _manager.ClearTracking(PortB); + SessionState.EraseInt(PidFileManager.LaunchedPortSessionKey); } #region GetPidFilePath Tests @@ -195,101 +203,119 @@ public void TryReadPid_NegativePid_ReturnsFalse() #endregion - #region TryGetPortFromPidFilePath Tests + #region Handshake Tests [Test] - public void TryGetPortFromPidFilePath_ValidPath_ReturnsTrue() + public void StoreHandshake_ValidData_StoresInEditorPrefs() { // Arrange - string path = "/some/path/mcp_http_8080.pid"; + string pidFilePath = "/test/path.pid"; + string instanceToken = "test-token-123"; // Act - bool result = _manager.TryGetPortFromPidFilePath(path, out int port); + _manager.StoreHandshake(PortA, pidFilePath, instanceToken); + bool result = _manager.TryGetHandshake(PortA, out var storedPath, out var storedToken); // Assert Assert.IsTrue(result); - Assert.AreEqual(8080, port); + Assert.AreEqual(pidFilePath, storedPath); + Assert.AreEqual(instanceToken, storedToken); } [Test] - public void TryGetPortFromPidFilePath_DifferentPort_ParsesCorrectly() + public void TryGetHandshake_NoHandshake_ReturnsFalse() { - // Arrange - string path = "/path/to/mcp_http_9999.pid"; - // Act - bool result = _manager.TryGetPortFromPidFilePath(path, out int port); + bool result = _manager.TryGetHandshake(PortA, out var pidFilePath, out var instanceToken); // Assert - Assert.IsTrue(result); - Assert.AreEqual(9999, port); + Assert.IsFalse(result); + Assert.IsNull(pidFilePath); + Assert.IsNull(instanceToken); } [Test] - public void TryGetPortFromPidFilePath_NullPath_ReturnsFalse() + public void TryGetHandshake_OtherPort_ReturnsFalse() { - // Act - bool result = _manager.TryGetPortFromPidFilePath(null, out int port); + // Two Unity-managed servers on different ports must not clobber each other's handshake. + _manager.StoreHandshake(PortA, "/a.pid", "token-a"); - // Assert - Assert.IsFalse(result); - Assert.AreEqual(0, port); + Assert.IsFalse(_manager.TryGetHandshake(PortB, out _, out _)); } [Test] - public void TryGetPortFromPidFilePath_InvalidPrefix_ReturnsFalse() + public void StoreHandshake_TwoPorts_KeepBothSlots() { - // Arrange - string path = "/some/path/wrong_prefix_8080.pid"; + _manager.StoreHandshake(PortA, "/a.pid", "token-a"); + _manager.StoreHandshake(PortB, "/b.pid", "token-b"); - // Act - bool result = _manager.TryGetPortFromPidFilePath(path, out int port); + Assert.IsTrue(_manager.TryGetHandshake(PortA, out var pathA, out var tokenA)); + Assert.IsTrue(_manager.TryGetHandshake(PortB, out var pathB, out var tokenB)); + Assert.AreEqual("/a.pid", pathA); + Assert.AreEqual("token-a", tokenA); + Assert.AreEqual("/b.pid", pathB); + Assert.AreEqual("token-b", tokenB); + } - // Assert - Assert.IsFalse(result); + [Test] + public void StoreHandshake_NullValues_DoesNotThrow() + { + // Act & Assert + Assert.DoesNotThrow(() => + { + _manager.StoreHandshake(PortA, null, null); + }); + } + + [Test] + public void StoreHandshake_InvalidPort_StoresNothing() + { + _manager.StoreHandshake(0, "/a.pid", "token-a"); + + Assert.IsFalse(_manager.TryGetLaunchedPort(out _)); } #endregion - #region Handshake Tests + #region Launch Marker Tests [Test] - public void StoreHandshake_ValidData_StoresInEditorPrefs() + public void TryGetLaunchedPort_NothingLaunched_ReturnsFalse() { - // Arrange - string pidFilePath = "/test/path.pid"; - string instanceToken = "test-token-123"; + // Regression for the multi-editor shutdown bug: an editor that never launched the server + // (it only connected to one another editor started) must not resolve a launch to stop. + Assert.IsFalse(_manager.TryGetLaunchedPort(out int port)); + Assert.AreEqual(0, port); + } - // Act - _manager.StoreHandshake(pidFilePath, instanceToken); - bool result = _manager.TryGetHandshake(out var storedPath, out var storedToken); + [Test] + public void StoreHandshake_RecordsLaunchedPort() + { + _manager.StoreHandshake(PortA, "/a.pid", "token-a"); - // Assert - Assert.IsTrue(result); - Assert.AreEqual(pidFilePath, storedPath); - Assert.AreEqual(instanceToken, storedToken); + Assert.IsTrue(_manager.TryGetLaunchedPort(out int port)); + Assert.AreEqual(PortA, port); } [Test] - public void TryGetHandshake_NoHandshake_ReturnsFalse() + public void ClearTracking_SamePort_ClearsLaunchedPort() { - // Act - bool result = _manager.TryGetHandshake(out var pidFilePath, out var instanceToken); + _manager.StoreHandshake(PortA, "/a.pid", "token-a"); - // Assert - Assert.IsFalse(result); - Assert.IsNull(pidFilePath); - Assert.IsNull(instanceToken); + _manager.ClearTracking(PortA); + + Assert.IsFalse(_manager.TryGetLaunchedPort(out _)); } [Test] - public void StoreHandshake_NullValues_DoesNotThrow() + public void ClearTracking_OtherPort_KeepsLaunchedPort() { - // Act & Assert - Assert.DoesNotThrow(() => - { - _manager.StoreHandshake(null, null); - }); + _manager.StoreHandshake(PortA, "/a.pid", "token-a"); + + _manager.ClearTracking(PortB); + + Assert.IsTrue(_manager.TryGetLaunchedPort(out int port)); + Assert.AreEqual(PortA, port); } #endregion @@ -301,11 +327,10 @@ public void StoreTracking_ValidData_CanBeRetrieved() { // Arrange int pid = 12345; - int port = 8080; // Act - _manager.StoreTracking(pid, port); - bool result = _manager.TryGetStoredPid(port, out int storedPid); + _manager.StoreTracking(pid, PortA); + bool result = _manager.TryGetStoredPid(PortA, out int storedPid); // Assert Assert.IsTrue(result); @@ -316,10 +341,10 @@ public void StoreTracking_ValidData_CanBeRetrieved() public void TryGetStoredPid_WrongPort_ReturnsFalse() { // Arrange - _manager.StoreTracking(12345, 8080); + _manager.StoreTracking(12345, PortA); // Act - bool result = _manager.TryGetStoredPid(9090, out int storedPid); + bool result = _manager.TryGetStoredPid(PortB, out int storedPid); // Assert Assert.IsFalse(result, "Should return false for wrong port"); @@ -329,7 +354,7 @@ public void TryGetStoredPid_WrongPort_ReturnsFalse() public void TryGetStoredPid_NoTracking_ReturnsFalse() { // Act - bool result = _manager.TryGetStoredPid(8080, out int storedPid); + bool result = _manager.TryGetStoredPid(PortA, out int storedPid); // Assert Assert.IsFalse(result); @@ -337,30 +362,44 @@ public void TryGetStoredPid_NoTracking_ReturnsFalse() } [Test] - public void ClearTracking_RemovesAllKeys() + public void ClearTracking_RemovesAllKeysForPort() { // Arrange - _manager.StoreTracking(12345, 8080, "somehash"); - _manager.StoreHandshake("/path.pid", "token"); + _manager.StoreTracking(12345, PortA, "somehash"); + _manager.StoreHandshake(PortA, "/path.pid", "token"); // Act - _manager.ClearTracking(); - bool hasTracking = _manager.TryGetStoredPid(8080, out _); - bool hasHandshake = _manager.TryGetHandshake(out _, out _); + _manager.ClearTracking(PortA); + bool hasTracking = _manager.TryGetStoredPid(PortA, out _); + bool hasHandshake = _manager.TryGetHandshake(PortA, out _, out _); // Assert Assert.IsFalse(hasTracking); Assert.IsFalse(hasHandshake); + Assert.AreEqual(string.Empty, _manager.GetStoredArgsHash(PortA)); + } + + [Test] + public void ClearTracking_OtherPort_LeavesSlotIntact() + { + _manager.StoreTracking(12345, PortA, "somehash"); + _manager.StoreHandshake(PortA, "/path.pid", "token"); + + _manager.ClearTracking(PortB); + + Assert.IsTrue(_manager.TryGetStoredPid(PortA, out int storedPid)); + Assert.AreEqual(12345, storedPid); + Assert.IsTrue(_manager.TryGetHandshake(PortA, out _, out _)); } [Test] public void GetStoredArgsHash_WithHash_ReturnsHash() { // Arrange - _manager.StoreTracking(12345, 8080, "testhash123"); + _manager.StoreTracking(12345, PortA, "testhash123"); // Act - string hash = _manager.GetStoredArgsHash(); + string hash = _manager.GetStoredArgsHash(PortA); // Assert Assert.AreEqual("testhash123", hash); @@ -370,7 +409,7 @@ public void GetStoredArgsHash_WithHash_ReturnsHash() public void GetStoredArgsHash_NoHash_ReturnsEmpty() { // Act - string hash = _manager.GetStoredArgsHash(); + string hash = _manager.GetStoredArgsHash(PortA); // Assert Assert.AreEqual(string.Empty, hash); diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ServerManagementServiceInstanceProbeTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ServerManagementServiceInstanceProbeTests.cs new file mode 100644 index 000000000..acac774c8 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ServerManagementServiceInstanceProbeTests.cs @@ -0,0 +1,88 @@ +using NUnit.Framework; +using MCPForUnity.Editor.Services; + +namespace MCPForUnityTests.Editor.Services +{ + /// + /// Parsing of the GET /api/instances response the quit handler uses for its last-one-out check. + /// The transport itself is not exercised here; the decision on top of the count is covered by + /// McpEditorShutdownCleanupTests.ShouldStopManagedServer. + /// + [TestFixture] + public class ServerManagementServiceInstanceProbeTests + { + private const string OwnHash = "aaaa111122223333"; + + [Test] + public void TryCountOtherInstances_OnlyOurOwnSession_CountsZero() + { + // Our own session can still be listed while the hub processes the WebSocket close that + // the quit handler issued a moment earlier; it must not count as "someone else". + string json = "{\"success\": true, \"instances\": [" + + "{\"session_id\": \"s1\", \"project\": \"Ours\", \"hash\": \"" + OwnHash + "\", \"unity_version\": \"6000.0.1f1\", \"connected_at\": 1.0}" + + "]}"; + + Assert.IsTrue(ServerManagementService.TryCountOtherInstances(json, OwnHash, out int others)); + Assert.AreEqual(0, others); + } + + [Test] + public void TryCountOtherInstances_AnotherProjectConnected_CountsIt() + { + string json = "{\"success\": true, \"instances\": [" + + "{\"session_id\": \"s1\", \"project\": \"Ours\", \"hash\": \"" + OwnHash + "\"}," + + "{\"session_id\": \"s2\", \"project\": \"Theirs\", \"hash\": \"bbbb444455556666\"}" + + "]}"; + + Assert.IsTrue(ServerManagementService.TryCountOtherInstances(json, OwnHash, out int others)); + Assert.AreEqual(1, others); + } + + [Test] + public void TryCountOtherInstances_OwnHashCaseDiffers_StillFiltered() + { + string json = "{\"success\": true, \"instances\": [{\"hash\": \"" + OwnHash.ToUpperInvariant() + "\"}]}"; + + Assert.IsTrue(ServerManagementService.TryCountOtherInstances(json, OwnHash, out int others)); + Assert.AreEqual(0, others); + } + + [Test] + public void TryCountOtherInstances_NoInstances_CountsZero() + { + Assert.IsTrue(ServerManagementService.TryCountOtherInstances("{\"success\": true, \"instances\": []}", OwnHash, out int others)); + Assert.AreEqual(0, others); + } + + [Test] + public void TryCountOtherInstances_InstanceWithoutHash_CountsAsOther() + { + // Unknown identity is treated as another editor: the caller then leaves the server running. + string json = "{\"success\": true, \"instances\": [{\"session_id\": \"s9\"}]}"; + + Assert.IsTrue(ServerManagementService.TryCountOtherInstances(json, OwnHash, out int others)); + Assert.AreEqual(1, others); + } + + [Test] + public void TryCountOtherInstances_ServerReportedFailure_ReturnsFalse() + { + Assert.IsFalse(ServerManagementService.TryCountOtherInstances("{\"success\": false, \"error\": \"boom\"}", OwnHash, out _)); + } + + [Test] + public void TryCountOtherInstances_MissingInstancesArray_ReturnsFalse() + { + Assert.IsFalse(ServerManagementService.TryCountOtherInstances("{\"success\": true}", OwnHash, out _)); + } + + [Test] + public void TryCountOtherInstances_MalformedJson_ReturnsFalse() + { + Assert.IsFalse(ServerManagementService.TryCountOtherInstances("not json", OwnHash, out int others)); + Assert.AreEqual(0, others); + Assert.IsFalse(ServerManagementService.TryCountOtherInstances(string.Empty, OwnHash, out _)); + Assert.IsFalse(ServerManagementService.TryCountOtherInstances(null, OwnHash, out _)); + } + } +}