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: