From 173610027148dc72388627c33b31605603168e10 Mon Sep 17 00:00:00 2001 From: wushaobin <407570769@qq.com> Date: Fri, 23 Oct 2026 17:24:21 +0800 Subject: [PATCH] fix: cooperatively stop local servers and verify process exit --- .../Services/IServerManagementService.cs | 5 + .../Services/Server/IProcessDetector.cs | 2 +- .../Services/Server/IProcessTerminator.cs | 3 +- .../Editor/Services/Server/ProcessDetector.cs | 15 +- .../Services/Server/ProcessTerminator.cs | 40 +++-- .../Services/ServerManagementService.cs | 64 ++++++- .../Connection/McpConnectionSection.cs | 2 +- Server/src/main.py | 32 ++++ Server/tests/test_server_shutdown.py | 112 ++++++++++++ .../Server/ProcessTerminatorWindowsTests.cs | 111 ++++++++++++ .../ProcessTerminatorWindowsTests.cs.meta | 11 ++ .../Services/Server/ServerShutdownTests.cs | 160 ++++++++++++++++++ .../Server/ServerShutdownTests.cs.meta | 11 ++ 13 files changed, 547 insertions(+), 21 deletions(-) create mode 100644 Server/tests/test_server_shutdown.py create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/ProcessTerminatorWindowsTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/ProcessTerminatorWindowsTests.cs.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/ServerShutdownTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/ServerShutdownTests.cs.meta diff --git a/MCPForUnity/Editor/Services/IServerManagementService.cs b/MCPForUnity/Editor/Services/IServerManagementService.cs index 60f90b04d8..440402980f 100644 --- a/MCPForUnity/Editor/Services/IServerManagementService.cs +++ b/MCPForUnity/Editor/Services/IServerManagementService.cs @@ -48,6 +48,11 @@ public interface IServerManagementService /// bool StopLocalHttpServer(); + /// + /// Requests cooperative shutdown before falling back to guarded process termination. + /// + System.Threading.Tasks.Task StopLocalHttpServerAsync(); + /// /// Stop the Unity-managed local HTTP server if a handshake/pidfile exists, /// even if the current transport selection has changed. diff --git a/MCPForUnity/Editor/Services/Server/IProcessDetector.cs b/MCPForUnity/Editor/Services/Server/IProcessDetector.cs index 886e29dcc3..c60115850a 100644 --- a/MCPForUnity/Editor/Services/Server/IProcessDetector.cs +++ b/MCPForUnity/Editor/Services/Server/IProcessDetector.cs @@ -39,7 +39,7 @@ public interface IProcessDetector int GetCurrentProcessId(); /// - /// Checks if a process exists on Unix systems. + /// Checks whether a process exists on the current platform. /// /// The process ID to check /// True if the process exists diff --git a/MCPForUnity/Editor/Services/Server/IProcessTerminator.cs b/MCPForUnity/Editor/Services/Server/IProcessTerminator.cs index 0f6e9f881a..6e8dd1ee2c 100644 --- a/MCPForUnity/Editor/Services/Server/IProcessTerminator.cs +++ b/MCPForUnity/Editor/Services/Server/IProcessTerminator.cs @@ -9,7 +9,8 @@ public interface IProcessTerminator /// /// Terminates a process using platform-appropriate methods. /// On Unix: Tries SIGTERM first with grace period, then SIGKILL. - /// On Windows: Tries taskkill, then taskkill /F. + /// On Windows: Uses taskkill /F /T and briefly waits for confirmed exit. + /// Cooperative HTTP shutdown is handled separately by the server management service. /// /// The process ID to terminate /// True if the process was terminated successfully diff --git a/MCPForUnity/Editor/Services/Server/ProcessDetector.cs b/MCPForUnity/Editor/Services/Server/ProcessDetector.cs index b553cb3c7d..35813a6358 100644 --- a/MCPForUnity/Editor/Services/Server/ProcessDetector.cs +++ b/MCPForUnity/Editor/Services/Server/ProcessDetector.cs @@ -40,10 +40,17 @@ public bool ProcessExists(int pid) { if (Application.platform == RuntimePlatform.WindowsEditor) { - // On Windows, use tasklist to check if process exists - bool ok = ExecPath.TryRun("tasklist", $"/FI \"PID eq {pid}\"", Application.dataPath, out var stdout, out var stderr, 5000); - string combined = ((stdout ?? string.Empty) + "\n" + (stderr ?? string.Empty)).ToLowerInvariant(); - return ok && combined.Contains(pid.ToString()); + try + { + using (var process = System.Diagnostics.Process.GetProcessById(pid)) + { + return !process.HasExited; + } + } + catch (System.ArgumentException) + { + return false; + } } // Unix: ps exits non-zero when PID is not found. diff --git a/MCPForUnity/Editor/Services/Server/ProcessTerminator.cs b/MCPForUnity/Editor/Services/Server/ProcessTerminator.cs index 7e803b11bf..108cf5ec39 100644 --- a/MCPForUnity/Editor/Services/Server/ProcessTerminator.cs +++ b/MCPForUnity/Editor/Services/Server/ProcessTerminator.cs @@ -46,13 +46,13 @@ public bool Terminate(int pid) string stdout, stderr; if (Application.platform == RuntimePlatform.WindowsEditor) { - // taskkill without /F first; fall back to /F if needed. - bool ok = ExecPath.TryRun("taskkill", $"/PID {pid} /T", Application.dataPath, out stdout, out stderr); - if (!ok) + if (!_processDetector.ProcessExists(pid)) { - ok = ExecPath.TryRun("taskkill", $"/F /PID {pid} /T", Application.dataPath, out stdout, out stderr); + return false; } - return ok; + // The service handles cooperative HTTP shutdown; this is its Windows process fallback. + RunTaskKill(pid, force: true); + return WaitForProcessExit(pid, timeoutMilliseconds: 1000); } else { @@ -63,15 +63,9 @@ public bool Terminate(int pid) if (!File.Exists(killPath)) killPath = "kill"; ExecPath.TryRun(killPath, $"-15 {pid}", Application.dataPath, out stdout, out stderr); - // Wait briefly for graceful shutdown. - var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(8); - while (DateTime.UtcNow < deadline) + if (WaitForProcessExit(pid)) { - if (!_processDetector.ProcessExists(pid)) - { - return true; - } - System.Threading.Thread.Sleep(100); + return true; } // Escalate. @@ -85,5 +79,25 @@ public bool Terminate(int pid) return false; } } + + internal virtual bool WaitForProcessExit(int pid, int timeoutMilliseconds = 8000) + { + var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMilliseconds); + while (DateTime.UtcNow < deadline) + { + if (!_processDetector.ProcessExists(pid)) + { + return true; + } + System.Threading.Thread.Sleep(100); + } + return false; + } + + internal virtual bool RunTaskKill(int pid, bool force) + { + string arguments = force ? $"/F /PID {pid} /T" : $"/PID {pid} /T"; + return ExecPath.TryRun("taskkill", arguments, Application.dataPath, out _, out _); + } } } diff --git a/MCPForUnity/Editor/Services/ServerManagementService.cs b/MCPForUnity/Editor/Services/ServerManagementService.cs index 7a2c546cc3..50f0b65c10 100644 --- a/MCPForUnity/Editor/Services/ServerManagementService.cs +++ b/MCPForUnity/Editor/Services/ServerManagementService.cs @@ -3,6 +3,9 @@ using System.IO; using System.Linq; using System.Net.Sockets; +using System.Net.Http; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Services.Server; @@ -86,7 +89,7 @@ private bool TryReadPidFromPidFile(string pidFilePath, out int pid) return _pidFileManager.TryReadPid(pidFilePath, out pid); } - private bool TryProcessCommandLineContainsInstanceToken(int pid, string instanceToken, out bool containsToken) + internal virtual bool TryProcessCommandLineContainsInstanceToken(int pid, string instanceToken, out bool containsToken) { containsToken = false; if (pid <= 0 || string.IsNullOrEmpty(instanceToken)) @@ -388,6 +391,65 @@ public bool StopLocalHttpServer() return StopLocalHttpServerInternal(quiet: false); } + /// + public async Task StopLocalHttpServerAsync() + { + string url = HttpEndpointUtility.GetLocalBaseUrl(); + if (!IsLocalUrl(url)) return false; + if (TryGetLocalHttpServerHandshake(out var pidFile, out var token) + && TryReadPidFromPidFile(pidFile, out int pid) + && pid > 1 && pid != GetCurrentProcessIdSafe() + && await RequestShutdownAsync(new Uri(url), pid, token)) + { + if (!await WaitForServerExitAsync(pid)) + { + // The listener may already be closed; revalidate identity before escalation. + if (!TryProcessCommandLineContainsInstanceToken(pid, token, out bool matches) + || !matches || !TerminateProcess(pid)) return false; + } + if (TryGetLocalHttpServerHandshake(out var currentFile, out var currentToken) + && currentFile == pidFile && currentToken == token + && TryReadPidFromPidFile(currentFile, out int currentPid) && currentPid == pid) + { + DeletePidFile(pidFile); + ClearLocalServerPidTracking(); + } + return true; + } + return StopLocalHttpServerInternal(quiet: false); + } + + internal virtual async Task WaitForServerExitAsync(int pid) + { + var timer = System.Diagnostics.Stopwatch.StartNew(); + while (_processDetector.ProcessExists(pid) && timer.Elapsed < TimeSpan.FromSeconds(8)) + await Task.Delay(100); + return !_processDetector.ProcessExists(pid); + } + + internal virtual async Task RequestShutdownAsync(Uri url, int pid, string token) + { + try + { + using (var handler = new HttpClientHandler { UseProxy = false, AllowAutoRedirect = false }) + using (var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(1) }) + using (var request = new HttpRequestMessage(HttpMethod.Post, new Uri(url, "/_unity/shutdown"))) + { + request.Headers.Add("X-Unity-Instance-Token", token); + using (var response = await client.SendAsync(request)) + { + return response.StatusCode == System.Net.HttpStatusCode.Accepted + && (int?)JObject.Parse(await response.Content.ReadAsStringAsync())["pid"] == pid; + } + } + } + catch (Exception ex) + { + McpLog.Debug($"Cooperative server shutdown unavailable: {ex.GetType().Name}"); + return false; + } + } + public bool StopManagedLocalHttpServer() { if (!TryGetLocalHttpServerHandshake(out var pidFilePath, out _)) diff --git a/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs b/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs index 0ff5c2fd27..af6138fb8b 100644 --- a/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs +++ b/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs @@ -702,7 +702,7 @@ private async void OnHttpServerToggleClicked() { await bridgeService.StopAsync(); } - bool stopped = MCPServiceLocator.Server.StopLocalHttpServer(); + bool stopped = await MCPServiceLocator.Server.StopLocalHttpServerAsync(); if (stopped) { McpLog.Info("Server stopped"); diff --git a/Server/src/main.py b/Server/src/main.py index b057f3d96c..65f29ce2f0 100644 --- a/Server/src/main.py +++ b/Server/src/main.py @@ -29,6 +29,9 @@ import logging from contextlib import asynccontextmanager import os +import ipaddress +import secrets +import signal import threading import time from typing import AsyncIterator, Any @@ -62,6 +65,7 @@ pass from fastmcp import FastMCP +from starlette.background import BackgroundTask from logging.handlers import RotatingFileHandler @@ -384,6 +388,34 @@ def create_mcp_server(project_scoped_tools: bool) -> FastMCP: custom_tool_service = CustomToolService( mcp, project_scoped_tools=project_scoped_tools) + instance_token = os.environ.get("UNITY_MCP_INSTANCE_TOKEN", "") + shutdown_requested = False + + @mcp.custom_route("/_unity/shutdown", methods=["POST"]) + async def shutdown_http(request: Request) -> JSONResponse: + try: + local_client = request.client is not None and ipaddress.ip_address(request.client.host).is_loopback + except ValueError: + local_client = False + provided_token = request.headers.get("X-Unity-Instance-Token", "") + if (config.http_remote_hosted or not instance_token or not local_client + or not secrets.compare_digest(provided_token.encode(), instance_token.encode())): + return JSONResponse({"error": "Forbidden"}, status_code=403) + handler = signal.getsignal(signal.SIGINT) + if not callable(handler) or handler is signal.default_int_handler: + return JSONResponse({"error": "Shutdown handler unavailable"}, status_code=503) + + async def request_exit() -> None: + nonlocal shutdown_requested + if shutdown_requested: + return + shutdown_requested = True + # In-process Ctrl+C also unwinds FastMCP's outer lifespan after Uvicorn exits. + signal.raise_signal(signal.SIGINT) + + return JSONResponse({"pid": os.getpid()}, status_code=202, + background=BackgroundTask(request_exit)) + @mcp.custom_route("/health", methods=["GET"]) async def health_http(_: Request) -> JSONResponse: return JSONResponse({ diff --git a/Server/tests/test_server_shutdown.py b/Server/tests/test_server_shutdown.py new file mode 100644 index 0000000000..453162e8eb --- /dev/null +++ b/Server/tests/test_server_shutdown.py @@ -0,0 +1,112 @@ +"""Only authenticated local requests may trigger cooperative shutdown.""" +import os +import signal +from unittest.mock import Mock + +import pytest +from starlette.requests import Request +from starlette.routing import Route + +from core.config import config + + +@pytest.fixture +def shutdown_endpoint_factory(monkeypatch): + import main + + # Integration collection installs a FastMCP stub; collect the real handlers independently. + server = Mock() + server._additional_http_routes = [] + server._get_additional_http_routes.side_effect = lambda: list(server._additional_http_routes) + + def custom_route(path, methods): + def register(handler): + server._additional_http_routes.append(Route(path, handler, methods=methods)) + return handler + return register + + server.custom_route.side_effect = custom_route + monkeypatch.setattr(main, "FastMCP", Mock(return_value=server)) + monkeypatch.setattr(main, "CustomToolService", Mock()) + monkeypatch.setattr(main, "custom_tool_service", None) + monkeypatch.setattr(main, "get_unity_instance_middleware", Mock()) + monkeypatch.setattr(main, "register_all_tools", Mock()) + monkeypatch.setattr(main, "register_all_resources", Mock()) + + def create_endpoint(): + app = main.create_mcp_server(False) + return next(r.endpoint for r in app._get_additional_http_routes() if r.path == "/_unity/shutdown") + + return create_endpoint + + +@pytest.fixture +def shutdown(monkeypatch, shutdown_endpoint_factory): + monkeypatch.setenv("UNITY_MCP_INSTANCE_TOKEN", "launch-token") + monkeypatch.setattr(config, "http_remote_hosted", False) + raised = Mock() + monkeypatch.setattr(signal, "getsignal", lambda _: Mock()) + monkeypatch.setattr(signal, "raise_signal", raised) + return shutdown_endpoint_factory(), raised + + +def request(host="127.0.0.1", token="launch-token"): + return Request({"type": "http", "method": "POST", "path": "/_unity/shutdown", + "client": (host, 1234), "headers": [(b"x-unity-instance-token", token.encode())]}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("host", ["127.0.0.1", "::1"]) +async def test_shutdown_defers_signal_until_after_response(shutdown, host): + endpoint, raised = shutdown + response = await endpoint(request(host)) + assert response.status_code == 202 + assert str(os.getpid()).encode() in response.body + raised.assert_not_called() + await response.background() + raised.assert_called_once_with(signal.SIGINT) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("host,token", [ + ("127.0.0.1", ""), ("127.0.0.1", "wrong"), ("127.0.0.1", "\u00e9"), + ("192.0.2.1", "launch-token"), ("unknown", "launch-token"), +]) +async def test_shutdown_rejects_untrusted_requests(shutdown, host, token): + endpoint, raised = shutdown + assert (await endpoint(request(host, token))).status_code == 403 + raised.assert_not_called() + + +@pytest.mark.asyncio +async def test_shutdown_rejects_remote_hosted_server(shutdown, monkeypatch): + endpoint, raised = shutdown + monkeypatch.setattr(config, "http_remote_hosted", True) + assert (await endpoint(request())).status_code == 403 + raised.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("handler", [signal.SIG_DFL, signal.default_int_handler]) +async def test_shutdown_requires_installed_handler(shutdown, monkeypatch, handler): + endpoint, raised = shutdown + monkeypatch.setattr(signal, "getsignal", lambda _: handler) + assert (await endpoint(request())).status_code == 503 + raised.assert_not_called() + + +@pytest.mark.asyncio +async def test_repeated_shutdown_does_not_force_uvicorn_exit(shutdown): + endpoint, raised = shutdown + first = await endpoint(request()) + second = await endpoint(request()) + await first.background() + await second.background() + raised.assert_called_once_with(signal.SIGINT) + + +@pytest.mark.asyncio +async def test_shutdown_disabled_without_launch_token(monkeypatch, shutdown_endpoint_factory): + monkeypatch.delenv("UNITY_MCP_INSTANCE_TOKEN", raising=False) + endpoint = shutdown_endpoint_factory() + assert (await endpoint(request())).status_code == 403 diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/ProcessTerminatorWindowsTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/ProcessTerminatorWindowsTests.cs new file mode 100644 index 0000000000..680835d9f7 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/ProcessTerminatorWindowsTests.cs @@ -0,0 +1,111 @@ +using System.Collections.Generic; +using MCPForUnity.Editor.Services.Server; +using NUnit.Framework; + +namespace MCPForUnityTests.Editor.Services.Server +{ + [Platform("Win")] + public class ProcessTerminatorWindowsTests + { + [TestCase(-1)] + [TestCase(0)] + [TestCase(1)] + [TestCase(42)] + public void Terminate_ProtectedPid_DoesNotRunTaskKill(int pid) + { + var terminator = new StubTerminator(new StubDetector()); + + Assert.IsFalse(terminator.Terminate(pid)); + Assert.IsEmpty(terminator.ForcedCalls); + } + + [TestCase(true, true, true)] + [TestCase(true, false, false)] + [TestCase(false, true, true)] + [TestCase(false, false, false)] + public void Terminate_FallbackReportsActualExit(bool commandSucceeded, bool exits, bool expected) + { + var terminator = new StubTerminator(new StubDetector()) + { ForcedSucceeds = commandSucceeded, ExitsOnForce = exits }; + Assert.AreEqual(expected, terminator.Terminate(12345)); + CollectionAssert.AreEqual(new[] { true }, terminator.ForcedCalls); + Assert.IsTrue(terminator.Waited); + Assert.AreEqual(1000, terminator.WaitTimeoutMilliseconds); + } + + [Test] + public void Terminate_WhenForcedExitCompletesLater_ReportsSuccess() + { + var detector = new StubDetector { ChecksUntilExit = 3 }; + var terminator = new StubTerminator(detector); + Assert.IsTrue(terminator.Terminate(12345)); + Assert.IsFalse(detector.Alive); + Assert.AreEqual(1000, terminator.WaitTimeoutMilliseconds); + } + + [Test] + public void Terminate_WhenProcessIsAlreadyAbsent_DoesNotRunTaskKill() + { + var terminator = new StubTerminator(new StubDetector { Alive = false }); + + Assert.IsFalse(terminator.Terminate(12345)); + Assert.IsEmpty(terminator.ForcedCalls); + } + + [Test] + public void ProcessExists_CurrentEditor_ReturnsTrue() + { + var detector = new ProcessDetector(); + Assert.IsTrue(detector.ProcessExists(detector.GetCurrentProcessId())); + } + + [Test] + public void ProcessExists_MissingPid_ReturnsFalse() + { + Assert.IsFalse(new ProcessDetector().ProcessExists(int.MaxValue)); + } + + private class StubTerminator : ProcessTerminator + { + readonly StubDetector detector; + internal readonly List ForcedCalls = new List(); + internal bool ExitsOnForce; + internal bool ForcedSucceeds = true; + internal bool Waited; + internal int WaitTimeoutMilliseconds; + internal StubTerminator(StubDetector detector) : base(detector) { this.detector = detector; } + internal override bool RunTaskKill(int pid, bool force) + { + ForcedCalls.Add(force); + if (ExitsOnForce) detector.Alive = false; + return ForcedSucceeds; + } + internal override bool WaitForProcessExit(int pid, int timeoutMilliseconds = 8000) + { + Waited = true; + WaitTimeoutMilliseconds = timeoutMilliseconds; + return base.WaitForProcessExit(pid, timeoutMilliseconds); + } + } + + private class StubDetector : IProcessDetector + { + internal bool Alive = true; + internal int ChecksUntilExit; + public bool ProcessExists(int pid) + { + if (ChecksUntilExit > 0 && --ChecksUntilExit == 0) Alive = false; + return Alive; + } + public int GetCurrentProcessId() => 42; + public bool LooksLikeMcpServerProcess(int pid) => true; + public List GetListeningProcessIdsForPort(int port) => new List(); + public string NormalizeForMatch(string input) => input; + public bool TryGetProcessCommandLine(int pid, out string argsLower) + { + argsLower = string.Empty; + return false; + } + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/ProcessTerminatorWindowsTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/ProcessTerminatorWindowsTests.cs.meta new file mode 100644 index 0000000000..e2e84c674b --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/ProcessTerminatorWindowsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b2c88dc6782443d0b873f4e5a8a94e61 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/ServerShutdownTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/ServerShutdownTests.cs new file mode 100644 index 0000000000..b98e2f3b26 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/ServerShutdownTests.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Threading.Tasks; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Services; +using MCPForUnity.Editor.Services.Server; +using NUnit.Framework; +using UnityEditor; +using UnityEngine.TestTools; + +namespace MCPForUnityTests.Editor.Services.Server +{ + public class ServerShutdownTests + { + bool hadUrl; + string savedUrl; + + [SetUp] + public void SetUp() + { + hadUrl = EditorPrefs.HasKey(EditorPrefKeys.HttpBaseUrl); + savedUrl = EditorPrefs.GetString(EditorPrefKeys.HttpBaseUrl); + EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://127.0.0.1:59997"); + } + + [TearDown] + public void TearDown() + { + if (hadUrl) EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, savedUrl); + else EditorPrefs.DeleteKey(EditorPrefKeys.HttpBaseUrl); + } + + [UnityTest] + public IEnumerator CooperativeExit_DoesNotTerminateProcess() + { + var fixture = new Fixture { ExitDuringWait = true }; + var task = fixture.Service.StopLocalHttpServerAsync(); + Assert.IsFalse(task.IsCompleted, "Shutdown must yield to the Editor"); + while (!task.IsCompleted) yield return null; + Assert.IsTrue(task.GetAwaiter().GetResult()); + Assert.AreEqual(0, fixture.Terminations); + Assert.IsTrue(fixture.Deleted && fixture.Cleared); + } + + [UnityTest] + public IEnumerator ReplacedPidFile_DoesNotDeleteNewServerTracking() + { + var fixture = new Fixture { ExitDuringWait = true, ReplacePidFileDuringWait = true }; + var task = fixture.Service.StopLocalHttpServerAsync(); + while (!task.IsCompleted) yield return null; + Assert.IsTrue(task.GetAwaiter().GetResult()); + Assert.IsFalse(fixture.Deleted || fixture.Cleared); + Assert.AreEqual(0, fixture.Terminations); + } + + [UnityTest] + public IEnumerator AcknowledgedButStuck_RevalidatesAndTerminates() + { + var fixture = new Fixture(); + var task = fixture.Service.StopLocalHttpServerAsync(); + while (!task.IsCompleted) yield return null; + Assert.IsTrue(task.GetAwaiter().GetResult()); + Assert.AreEqual(1, fixture.Terminations); + Assert.IsTrue(fixture.IdentityChecked); + } + + [UnityTest] + public IEnumerator ChangedIdentity_RefusesToTerminateOrClearTracking() + { + var fixture = new Fixture { IdentityMatches = false }; + var task = fixture.Service.StopLocalHttpServerAsync(); + while (!task.IsCompleted) yield return null; + Assert.IsFalse(task.GetAwaiter().GetResult()); + Assert.AreEqual(0, fixture.Terminations); + Assert.IsFalse(fixture.Deleted || fixture.Cleared); + } + + [UnityTest] + public IEnumerator OlderServer_UsesGuardedFallbackWithoutGracePeriod() + { + var fixture = new Fixture { Accepted = false }; + var task = fixture.Service.StopLocalHttpServerAsync(); + while (!task.IsCompleted) yield return null; + Assert.IsTrue(task.GetAwaiter().GetResult()); + Assert.AreEqual(1, fixture.Terminations); + Assert.IsFalse(fixture.Waited); + } + + [UnityTest] + public IEnumerator ProtectedPid_DoesNotSendShutdownOrTerminate() + { + var fixture = new Fixture { Pid = 42 }; + var task = fixture.Service.StopLocalHttpServerAsync(); + while (!task.IsCompleted) yield return null; + Assert.IsFalse(task.GetAwaiter().GetResult()); + Assert.IsFalse(fixture.Requested); + Assert.AreEqual(0, fixture.Terminations); + } + + private class Service : ServerManagementService + { + readonly Fixture fixture; + internal Service(Fixture fixture) : base(fixture, fixture, fixture) { this.fixture = fixture; } + internal override async Task RequestShutdownAsync(Uri url, int pid, string token) + { + fixture.Requested = true; + Assert.AreEqual(fixture.Pid, pid); + Assert.AreEqual("launch-token", token); + await Task.Yield(); + return fixture.Accepted; + } + internal override async Task WaitForServerExitAsync(int pid) + { + fixture.Waited = true; + await Task.Yield(); + if (fixture.ExitDuringWait) fixture.Alive = false; + if (fixture.ReplacePidFileDuringWait) fixture.CurrentFilePid = 67890; + return !fixture.Alive; + } + internal override bool TryProcessCommandLineContainsInstanceToken(int pid, string token, out bool matches) + { + fixture.IdentityChecked = true; + matches = fixture.IdentityMatches; + return true; + } + } + + private class Fixture : IProcessDetector, IPidFileManager, IProcessTerminator + { + internal readonly Service Service; + internal bool Accepted = true, IdentityMatches = true, Alive = true; + internal bool ExitDuringWait, Requested, Waited, IdentityChecked, Deleted, Cleared; + internal bool ReplacePidFileDuringWait; + internal int? CurrentFilePid; + internal int Terminations, Pid = 12345; + internal Fixture() { Service = new Service(this); } + public bool Terminate(int pid) { Terminations++; Alive = false; return true; } + public bool ProcessExists(int pid) => Alive; + public int GetCurrentProcessId() => 42; + public bool LooksLikeMcpServerProcess(int pid) => false; + public List GetListeningProcessIdsForPort(int port) => new List { Pid }; + public string NormalizeForMatch(string value) => value; + public bool TryGetProcessCommandLine(int pid, out string args) { args = ""; return false; } + public string GetPidFilePath(int port) => "fixture.pid"; + public string GetPidDirectory() => "fixture"; + public bool TryReadPid(string file, out int pid) { pid = CurrentFilePid ?? Pid; return true; } + public bool TryGetPortFromPidFilePath(string file, out int port) { port = 59997; return true; } + public void DeletePidFile(string file) { Deleted = true; } + public void StoreHandshake(string file, string token) { } + public bool TryGetHandshake(out string file, out string token) + { file = "fixture.pid"; token = "launch-token"; return true; } + public void StoreTracking(int pid, int port, string hash = null) { } + public bool TryGetStoredPid(int port, out int pid) { pid = 0; return false; } + public string GetStoredArgsHash() => ""; + public void ClearTracking() { Cleared = true; } + public string ComputeShortHash(string value) => value; + } + } +} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/ServerShutdownTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/ServerShutdownTests.cs.meta new file mode 100644 index 0000000000..7cb34a7c10 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/ServerShutdownTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 98c156a43779434d9daa9c45e261c7c3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: