diff --git a/Editor/DI/ServiceContainer.cs b/Editor/DI/ServiceContainer.cs index 21522e7..d9aac16 100644 --- a/Editor/DI/ServiceContainer.cs +++ b/Editor/DI/ServiceContainer.cs @@ -208,8 +208,9 @@ public void Dispose() { lock (_lock) { - foreach (var disposable in _disposables) + for (var i = _disposables.Count - 1; i >= 0; i--) { + var disposable = _disposables[i]; try { disposable.Dispose(); } catch { } } _disposables.Clear(); @@ -319,8 +320,9 @@ public void Dispose() { lock (_lock) { - foreach (var disposable in _disposables) + for (var i = _disposables.Count - 1; i >= 0; i--) { + var disposable = _disposables[i]; try { disposable.Dispose(); } catch { } } _disposables.Clear(); diff --git a/Editor/MCP/Server/HttpMCPTransport.cs b/Editor/MCP/Server/HttpMCPTransport.cs index 39457e7..aca2e36 100644 --- a/Editor/MCP/Server/HttpMCPTransport.cs +++ b/Editor/MCP/Server/HttpMCPTransport.cs @@ -4,7 +4,7 @@ using System.Collections.Generic; using System.IO; using System.Net; -using System.Net.Http; +using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -14,31 +14,26 @@ namespace Funplay.Editor.MCP.Server { /// - /// HTTP transport implementation for MCP using System.Net.HttpListener. + /// HTTP transport implementation for MCP using a loopback TCP listener. /// Listens for JSON-RPC requests over HTTP. /// internal class HttpMCPTransport : IMCPTransport { - private HttpListener _listener; + private TcpListener _listener; private CancellationTokenSource _cts; private readonly int _port; - private readonly string _expectedServerName; - private readonly string _expectedProjectIdentity; private bool _isRunning; - private bool _ownsListener; private const int StartRetryAttempts = 40; private const int StartRetryDelayMs = 250; - private const int ExistingServerProbeTimeoutMs = 500; + private const int MaxHeaderBytes = 64 * 1024; public bool IsRunning => _isRunning; - public bool IsAttachedToExistingServer => _isRunning && !_ownsListener; + public bool IsAttachedToExistingServer => false; public event Action> OnRequestReceived; public HttpMCPTransport(int port, string expectedServerName = null, string expectedProjectIdentity = null) { _port = port; - _expectedServerName = expectedServerName; - _expectedProjectIdentity = expectedProjectIdentity; } public async Task StartAsync(CancellationToken ct = default) @@ -51,14 +46,12 @@ public async Task StartAsync(CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - _listener = new HttpListener(); - _listener.Prefixes.Add($"http://127.0.0.1:{_port}/"); - _listener.Prefixes.Add($"http://localhost:{_port}/"); + _listener = new TcpListener(IPAddress.Loopback, _port); + _listener.Server.NoDelay = true; _listener.Start(); _cts = new CancellationTokenSource(); _isRunning = true; - _ownsListener = true; _ = Task.Run(() => ListenLoopAsync(_cts.Token), _cts.Token); @@ -74,9 +67,6 @@ public async Task StartAsync(CancellationToken ct = default) catch (Exception ex) when (IsAddressInUse(ex)) { CleanupFailedStart(); - if (await TryAttachToExistingFunplayServerAsync(ct)) - return true; - if (attempt >= StartRetryAttempts) { Debug.LogError($"[Funplay MCP Server] Failed to start HTTP transport: {ex.Message}"); @@ -103,7 +93,6 @@ public async Task StartAsync(CancellationToken ct = default) } _isRunning = false; - _ownsListener = false; return false; } @@ -120,17 +109,10 @@ public void Stop() try { - if (_ownsListener) - { - _cts?.Cancel(); - _listener?.Stop(); - _listener?.Close(); - PluginDebugLogger.Log("[Funplay MCP Server] HTTP transport stopped"); - } - else if (_isRunning) - { - PluginDebugLogger.Log("[Funplay MCP Server] Detached from existing HTTP transport"); - } + _isRunning = false; + _cts?.Cancel(); + CloseListener(); + PluginDebugLogger.Log("[Funplay MCP Server] HTTP transport stopped"); } catch (ObjectDisposedException) { @@ -142,8 +124,6 @@ public void Stop() } finally { - _isRunning = false; - _ownsListener = false; _listener = null; _cts?.Dispose(); _cts = null; @@ -154,7 +134,8 @@ private void CleanupFailedStart() { try { - _listener?.Close(); + _isRunning = false; + CloseListener(); } catch { @@ -163,7 +144,6 @@ private void CleanupFailedStart() finally { _listener = null; - _ownsListener = false; } } @@ -180,228 +160,232 @@ private static async Task DelayBeforeRetryAsync(CancellationToken ct) } } - private async Task TryAttachToExistingFunplayServerAsync(CancellationToken ct) - { - if (string.IsNullOrEmpty(_expectedServerName) || string.IsNullOrEmpty(_expectedProjectIdentity)) - return false; - - try - { - var responseText = await SendInitializeProbeAsync(ct); - if (!IsExpectedFunplayServer(responseText, _expectedServerName, _expectedProjectIdentity)) - return false; - - _isRunning = true; - _ownsListener = false; - PluginDebugLogger.Log( - $"[Funplay MCP Server] Reusing existing HTTP transport on http://127.0.0.1:{_port}/"); - return true; - } - catch (OperationCanceledException) - { - return false; - } - catch - { - return false; - } - } - - private async Task SendInitializeProbeAsync(CancellationToken ct) - { - const string body = "{\"jsonrpc\":\"2.0\",\"id\":\"funplay-existing-server-probe\",\"method\":\"initialize\",\"params\":{}}"; - try - { - using (var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct)) - using (var client = new HttpClient { Timeout = TimeSpan.FromMilliseconds(ExistingServerProbeTimeoutMs) }) - using (var content = new StringContent(body, Encoding.UTF8, "application/json")) - { - timeoutCts.CancelAfter(ExistingServerProbeTimeoutMs); - var response = await client.PostAsync($"http://127.0.0.1:{_port}/", content, timeoutCts.Token); - if (response.StatusCode != HttpStatusCode.OK) - return string.Empty; - - return await response.Content.ReadAsStringAsync(); - } - } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) - { - return string.Empty; - } - catch (HttpRequestException) - { - return string.Empty; - } - } - - private static bool IsExpectedFunplayServer( - string responseText, - string expectedServerName, - string expectedProjectIdentity) - { - if (string.IsNullOrEmpty(responseText) || - string.IsNullOrEmpty(expectedServerName) || - string.IsNullOrEmpty(expectedProjectIdentity)) - { - return false; - } - - var response = SimpleJsonHelper.Deserialize(responseText) as Dictionary; - var result = GetDictionary(response, "result"); - var serverInfo = GetDictionary(result, "serverInfo"); - var funplayInfo = GetDictionary(result, "funplay"); - var serverName = GetString(serverInfo, "name"); - var projectIdentity = GetString(funplayInfo, "projectIdentity"); - - return string.Equals(serverName, expectedServerName, StringComparison.Ordinal) && - string.Equals(projectIdentity, expectedProjectIdentity, StringComparison.Ordinal); - } - - private static Dictionary GetDictionary(Dictionary source, string key) - { - if (source != null && - source.TryGetValue(key, out var value) && - value is Dictionary dictionary) - { - return dictionary; - } - - return null; - } - - private static string GetString(Dictionary source, string key) - { - return source != null && source.TryGetValue(key, out var value) - ? value?.ToString() - : null; - } - private static bool IsAddressInUse(Exception ex) { var message = ex?.Message ?? string.Empty; if (message.IndexOf("Only one usage", StringComparison.OrdinalIgnoreCase) >= 0 || - message.IndexOf("Address already in use", StringComparison.OrdinalIgnoreCase) >= 0 || - message.IndexOf("another listener", StringComparison.OrdinalIgnoreCase) >= 0 || - message.IndexOf("prefix is already registered", StringComparison.OrdinalIgnoreCase) >= 0) + message.IndexOf("Address already in use", StringComparison.OrdinalIgnoreCase) >= 0) { return true; } - return ex is HttpListenerException listenerException && - (listenerException.ErrorCode == 48 || - listenerException.ErrorCode == 98 || - listenerException.ErrorCode == 183 || - listenerException.ErrorCode == 10048); + return ex is SocketException socketException && + (socketException.ErrorCode == 48 || + socketException.ErrorCode == 98 || + socketException.ErrorCode == 183 || + socketException.ErrorCode == 10048); } private async Task ListenLoopAsync(CancellationToken ct) { - while (!ct.IsCancellationRequested && _isRunning) + var stoppedUnexpectedly = false; + try { - try - { - var context = await _listener.GetContextAsync(); - _ = Task.Run(() => HandleRequestAsync(context, ct), ct); - } - catch (HttpListenerException ex) when (ex.ErrorCode == 995) - { - break; - } - catch (ObjectDisposedException) + while (!ct.IsCancellationRequested && _isRunning) { - break; + try + { + var client = await _listener.AcceptTcpClientAsync(); + _ = Task.Run(() => HandleClientAsync(client, ct), ct); + } + catch (SocketException ex) when (ex.SocketErrorCode == SocketError.Interrupted || + ex.SocketErrorCode == SocketError.OperationAborted) + { + stoppedUnexpectedly = !ct.IsCancellationRequested && _isRunning; + break; + } + catch (ObjectDisposedException) + { + stoppedUnexpectedly = !ct.IsCancellationRequested && _isRunning; + break; + } + catch (Exception ex) + { + if (!ct.IsCancellationRequested && _isRunning) + { + Debug.LogError($"[Funplay MCP Server] Error in listen loop: {ex.Message}"); + stoppedUnexpectedly = true; + } + break; + } } - catch (Exception ex) + } + finally + { + if (stoppedUnexpectedly) { - if (!ct.IsCancellationRequested) - Debug.LogError($"[Funplay MCP Server] Error in listen loop: {ex.Message}"); - break; + _isRunning = false; + CloseListener(); } } } - private async Task HandleRequestAsync(HttpListenerContext context, CancellationToken ct) + private async Task HandleClientAsync(TcpClient client, CancellationToken ct) { MCPRequest request = null; + NetworkStream stream = null; try { - if (context.Request.HttpMethod == "OPTIONS") + using (client) { - await SendOptionsResponseAsync(context.Response); - return; - } + stream = client.GetStream(); + var httpRequest = await ReadHttpRequestAsync(stream, ct); + if (httpRequest == null) + return; - if (context.Request.HttpMethod != "POST") - { - await SendMethodNotAllowedAsync(context.Response, "POST, OPTIONS"); - return; - } + if (httpRequest.Method == "OPTIONS") + { + await SendOptionsResponseAsync(stream, ct); + return; + } - request = await ParseRequestAsync(context.Request); - if (request == null) - { - await SendErrorResponseAsync(context.Response, null, -32700, "Parse error"); - return; - } + if (httpRequest.Method != "POST") + { + await SendMethodNotAllowedAsync(stream, "POST, OPTIONS", ct); + return; + } - var responseTcs = new TaskCompletionSource(); - OnRequestReceived?.Invoke(request, r => responseTcs.TrySetResult(r)); + request = ParseJsonRequest(httpRequest.Body); + if (request == null) + { + await SendErrorResponseAsync(stream, null, -32700, "Parse error", ct); + return; + } - using (var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(60))) - using (var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token)) - { - try + var requestReceived = OnRequestReceived; + if (requestReceived == null) { - var responseTask = responseTcs.Task; - var completedTask = await Task.WhenAny(responseTask, Task.Delay(-1, linkedCts.Token)); - if (completedTask == responseTask) + await SendErrorResponseAsync(stream, request.Id, -32000, "MCP server is stopping or not ready.", ct); + return; + } + + var responseTcs = new TaskCompletionSource(); + requestReceived.Invoke(request, r => responseTcs.TrySetResult(r)); + + using (var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(60))) + using (var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token)) + { + try { - var response = await responseTask; - if (response == null) + var responseTask = responseTcs.Task; + var completedTask = await Task.WhenAny(responseTask, Task.Delay(-1, linkedCts.Token)); + if (completedTask == responseTask) { - await SendAcceptedAsync(context.Response); + var response = await responseTask; + if (response == null) + { + await SendAcceptedAsync(stream, ct); + } + else + { + await SendResponseAsync(stream, response, ct); + } } else { - await SendResponseAsync(context.Response, response); + throw new OperationCanceledException(); } } - else + catch (OperationCanceledException) { - throw new OperationCanceledException(); + var errResponse = timeoutCts.IsCancellationRequested + ? CreateErrorResponse(request.Id, -32000, "Request timeout") + : CreateErrorResponse(request.Id, -32000, "Request cancelled"); + await SendResponseAsync(stream, errResponse, CancellationToken.None); } } - catch (OperationCanceledException) - { - var errResponse = timeoutCts.IsCancellationRequested - ? CreateErrorResponse(request.Id, -32000, "Request timeout") - : CreateErrorResponse(request.Id, -32000, "Request cancelled"); - await SendResponseAsync(context.Response, errResponse); - } } } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + } catch (Exception ex) { Debug.LogError($"[Funplay MCP Server] Error handling request: {ex.Message}"); - await SendErrorResponseAsync(context.Response, request?.Id, -32603, $"Internal error: {ex.Message}"); + if (stream != null) + await SendErrorResponseAsync(stream, request?.Id, -32603, $"Internal error: {ex.Message}", CancellationToken.None); } } - private async Task ParseRequestAsync(HttpListenerRequest request) + private async Task ReadHttpRequestAsync(NetworkStream stream, CancellationToken ct) { - try + var buffer = new byte[8192]; + var rawRequest = new MemoryStream(); + var headerEnd = -1; + + while (headerEnd < 0) { - using (var reader = new StreamReader(request.InputStream, Encoding.UTF8)) - { - var json = await reader.ReadToEndAsync(); - return ParseJsonRequest(json); - } + var read = await stream.ReadAsync(buffer, 0, buffer.Length, ct); + if (read == 0) + return null; + + rawRequest.Write(buffer, 0, read); + if (rawRequest.Length > MaxHeaderBytes) + throw new InvalidOperationException("HTTP header is too large."); + + headerEnd = FindHeaderEnd(rawRequest.GetBuffer(), (int)rawRequest.Length); } - catch (Exception ex) - { - Debug.LogError($"[Funplay MCP Server] Failed to parse request: {ex.Message}"); + + var requestBytes = rawRequest.ToArray(); + var headerText = Encoding.ASCII.GetString(requestBytes, 0, headerEnd); + var lines = headerText.Split(new[] { "\r\n" }, StringSplitOptions.None); + if (lines.Length == 0) + return null; + + var requestLineParts = lines[0].Split(' '); + if (requestLineParts.Length < 1) return null; + + var contentLength = 0; + for (var i = 1; i < lines.Length; i++) + { + var separator = lines[i].IndexOf(':'); + if (separator <= 0) + continue; + + var name = lines[i].Substring(0, separator).Trim(); + if (!string.Equals(name, "Content-Length", StringComparison.OrdinalIgnoreCase)) + continue; + + int.TryParse(lines[i].Substring(separator + 1).Trim(), out contentLength); + break; + } + + var bodyStart = headerEnd + 4; + var bodyBytes = new byte[contentLength]; + var copied = Math.Min(contentLength, requestBytes.Length - bodyStart); + if (copied > 0) + Buffer.BlockCopy(requestBytes, bodyStart, bodyBytes, 0, copied); + + while (copied < contentLength) + { + var read = await stream.ReadAsync(bodyBytes, copied, contentLength - copied, ct); + if (read == 0) + break; + copied += read; } + + return new HttpRequestData + { + Method = requestLineParts[0], + Body = Encoding.UTF8.GetString(bodyBytes, 0, copied) + }; + } + + private static int FindHeaderEnd(byte[] buffer, int length) + { + for (var i = 3; i < length; i++) + { + if (buffer[i - 3] == '\r' && + buffer[i - 2] == '\n' && + buffer[i - 1] == '\r' && + buffer[i] == '\n') + { + return i - 3; + } + } + + return -1; } private MCPRequest ParseJsonRequest(string json) @@ -426,24 +410,12 @@ private MCPRequest ParseJsonRequest(string json) } } - private async Task SendResponseAsync(HttpListenerResponse response, MCPResponse mcpResponse) + private async Task SendResponseAsync(NetworkStream stream, MCPResponse mcpResponse, CancellationToken ct) { try { var json = SerializeResponse(mcpResponse); - var bytes = Encoding.UTF8.GetBytes(json); - - response.ContentType = "application/json"; - response.ContentEncoding = Encoding.UTF8; - response.ContentLength64 = bytes.Length; - response.StatusCode = 200; - - response.Headers.Add("Access-Control-Allow-Origin", "*"); - response.Headers.Add("Access-Control-Allow-Methods", "POST, OPTIONS"); - response.Headers.Add("Access-Control-Allow-Headers", "Content-Type"); - - await response.OutputStream.WriteAsync(bytes, 0, bytes.Length); - response.OutputStream.Close(); + await SendRawResponseAsync(stream, 200, "OK", "application/json; charset=utf-8", json, ct); } catch (Exception ex) { @@ -451,43 +423,50 @@ private async Task SendResponseAsync(HttpListenerResponse response, MCPResponse } } - private Task SendOptionsResponseAsync(HttpListenerResponse response) + private Task SendOptionsResponseAsync(NetworkStream stream, CancellationToken ct) { - response.StatusCode = (int)HttpStatusCode.NoContent; - response.ContentLength64 = 0; - response.Headers.Add("Access-Control-Allow-Origin", "*"); - response.Headers.Add("Access-Control-Allow-Methods", "POST, OPTIONS"); - response.Headers.Add("Access-Control-Allow-Headers", "Content-Type"); - response.OutputStream.Close(); - return Task.CompletedTask; + return SendRawResponseAsync(stream, (int)HttpStatusCode.NoContent, "No Content", "text/plain", string.Empty, ct); } - private Task SendMethodNotAllowedAsync(HttpListenerResponse response, string allowHeader) + private Task SendMethodNotAllowedAsync(NetworkStream stream, string allowHeader, CancellationToken ct) { - response.StatusCode = (int)HttpStatusCode.MethodNotAllowed; - response.ContentLength64 = 0; - response.Headers.Add("Allow", allowHeader); - response.Headers.Add("Access-Control-Allow-Origin", "*"); - response.Headers.Add("Access-Control-Allow-Methods", "POST, OPTIONS"); - response.Headers.Add("Access-Control-Allow-Headers", "Content-Type"); - response.OutputStream.Close(); - return Task.CompletedTask; + return SendRawResponseAsync(stream, (int)HttpStatusCode.MethodNotAllowed, "Method Not Allowed", "text/plain", string.Empty, ct, "Allow: " + allowHeader + "\r\n"); } - private Task SendAcceptedAsync(HttpListenerResponse response) + private Task SendAcceptedAsync(NetworkStream stream, CancellationToken ct) { - response.StatusCode = (int)HttpStatusCode.Accepted; - response.ContentLength64 = 0; - response.Headers.Add("Access-Control-Allow-Origin", "*"); - response.Headers.Add("Access-Control-Allow-Methods", "POST, OPTIONS"); - response.Headers.Add("Access-Control-Allow-Headers", "Content-Type"); - response.OutputStream.Close(); - return Task.CompletedTask; + return SendRawResponseAsync(stream, (int)HttpStatusCode.Accepted, "Accepted", "text/plain", string.Empty, ct); + } + + private async Task SendErrorResponseAsync(NetworkStream stream, object requestId, int code, string message, CancellationToken ct) + { + await SendResponseAsync(stream, CreateErrorResponse(requestId, code, message), ct); } - private async Task SendErrorResponseAsync(HttpListenerResponse response, object requestId, int code, string message) + private static async Task SendRawResponseAsync( + NetworkStream stream, + int statusCode, + string reasonPhrase, + string contentType, + string body, + CancellationToken ct, + string extraHeaders = "") { - await SendResponseAsync(response, CreateErrorResponse(requestId, code, message)); + var bodyBytes = Encoding.UTF8.GetBytes(body ?? string.Empty); + var header = + $"HTTP/1.1 {statusCode} {reasonPhrase}\r\n" + + $"Content-Type: {contentType}\r\n" + + $"Content-Length: {bodyBytes.Length}\r\n" + + "Connection: close\r\n" + + "Access-Control-Allow-Origin: *\r\n" + + "Access-Control-Allow-Methods: POST, OPTIONS\r\n" + + "Access-Control-Allow-Headers: Content-Type\r\n" + + extraHeaders + + "\r\n"; + var headerBytes = Encoding.ASCII.GetBytes(header); + await stream.WriteAsync(headerBytes, 0, headerBytes.Length, ct); + if (bodyBytes.Length > 0) + await stream.WriteAsync(bodyBytes, 0, bodyBytes.Length, ct); } private MCPResponse CreateErrorResponse(object requestId, int code, string message) @@ -529,8 +508,20 @@ private string SerializeResponse(MCPResponse response) public void Dispose() { Stop(); - _cts?.Dispose(); - _listener = null; + } + + private void CloseListener() + { + if (_listener == null) + return; + + try { _listener.Stop(); } catch { } + } + + private sealed class HttpRequestData + { + public string Method { get; set; } + public string Body { get; set; } } } } diff --git a/Editor/MCP/Server/MCPServerDomainReloadHandler.cs b/Editor/MCP/Server/MCPServerDomainReloadHandler.cs index 12df87a..41d1b64 100644 --- a/Editor/MCP/Server/MCPServerDomainReloadHandler.cs +++ b/Editor/MCP/Server/MCPServerDomainReloadHandler.cs @@ -17,6 +17,8 @@ internal static class MCPServerDomainReloadHandler { private const string WasRunningKey = "Funplay_MCPServer_WasRunning"; private const string PortKey = "Funplay_MCPServer_Port"; + private const string RestartDeadlineTicksKey = "Funplay_MCPServer_RestartDeadlineTicks"; + private static readonly TimeSpan RestartRetryWindow = TimeSpan.FromMinutes(5); private static bool _restartScheduled; private static bool _restartInProgress; @@ -43,6 +45,7 @@ internal static void PrepareForReload(IServiceProvider services) PluginDebugLogger.Log("[Funplay MCP Server] Saving state before domain reload"); SessionState.SetBool(WasRunningKey, true); SessionState.SetInt(PortKey, mcpServer.Port); + SessionState.SetString(RestartDeadlineTicksKey, DateTime.UtcNow.Add(RestartRetryWindow).Ticks.ToString()); } catch (Exception ex) { @@ -116,14 +119,21 @@ private static async void RestartWhenEditorIsReady() if (!SessionState.GetBool(WasRunningKey, false)) { - EditorApplication.update -= RestartWhenEditorIsReady; - _restartScheduled = false; + ClearScheduledRestart(); return; } if (EditorApplication.isCompiling) return; + if (RestartDeadlineExpired()) + { + Debug.LogError("[Funplay MCP Server] Timed out restarting after domain reload."); + ClearPendingRestart(); + ClearScheduledRestart(); + return; + } + var services = RootScopeServices.Services; if (services == null) return; @@ -133,8 +143,6 @@ private static async void RestartWhenEditorIsReady() if (mcpServer == null) return; - EditorApplication.update -= RestartWhenEditorIsReady; - _restartScheduled = false; _restartInProgress = true; try @@ -146,8 +154,22 @@ private static async void RestartWhenEditorIsReady() if (!mcpServer.IsRunning) { var started = await mcpServer.StartAsync(); - if (!started) + if (started) + { + ClearPendingRestart(); + ClearScheduledRestart(); + } + else if (RestartDeadlineExpired()) + { Debug.LogError("[Funplay MCP Server] Failed to restart after domain reload."); + ClearPendingRestart(); + ClearScheduledRestart(); + } + } + else + { + ClearPendingRestart(); + ClearScheduledRestart(); } } catch (Exception ex) @@ -156,10 +178,34 @@ private static async void RestartWhenEditorIsReady() } finally { - SessionState.EraseBool(WasRunningKey); - SessionState.EraseInt(PortKey); _restartInProgress = false; } } + + private static bool RestartDeadlineExpired() + { + var deadlineText = SessionState.GetString(RestartDeadlineTicksKey, string.Empty); + if (!long.TryParse(deadlineText, out var deadlineTicks)) + { + SessionState.SetString(RestartDeadlineTicksKey, DateTime.UtcNow.Add(RestartRetryWindow).Ticks.ToString()); + return false; + } + + return DateTime.UtcNow.Ticks >= deadlineTicks; + } + + private static void ClearPendingRestart() + { + SessionState.EraseBool(WasRunningKey); + SessionState.EraseInt(PortKey); + SessionState.EraseString(RestartDeadlineTicksKey); + } + + private static void ClearScheduledRestart() + { + EditorApplication.delayCall -= RestartWhenEditorIsReady; + EditorApplication.update -= RestartWhenEditorIsReady; + _restartScheduled = false; + } } } diff --git a/Editor/Threading/EditorThreadHelper.cs b/Editor/Threading/EditorThreadHelper.cs index 81f80a8..6293398 100644 --- a/Editor/Threading/EditorThreadHelper.cs +++ b/Editor/Threading/EditorThreadHelper.cs @@ -32,6 +32,9 @@ public EditorThreadHelper(IEditorStateService editorStateService) public Task ExecuteOnEditorThreadAsync(Action action) { + if (_disposed) + return CreateCanceledTask(); + if (IsMainThread) { try @@ -52,6 +55,9 @@ public Task ExecuteOnEditorThreadAsync(Action action) public Task ExecuteOnEditorThreadAsync(Func func) { + if (_disposed) + return CreateCanceledTask(); + if (IsMainThread) { try @@ -76,6 +82,9 @@ public Task ExecuteOnEditorThreadAsync(Func func) public Task ExecuteAsyncOnEditorThreadAsync(Func> asyncFunc) { + if (_disposed) + return CreateCanceledTask(); + if (IsMainThread) { return asyncFunc(); @@ -83,16 +92,28 @@ public Task ExecuteAsyncOnEditorThreadAsync(Func> asyncFunc) var outerTcs = new TaskCompletionSource(); var tcs = new TaskCompletionSource(); + tcs.Task.ContinueWith( + task => + { + if (task.IsCanceled) + outerTcs.TrySetCanceled(); + else if (task.IsFaulted) + outerTcs.TrySetException(task.Exception?.InnerException ?? task.Exception ?? new Exception("Unknown error")); + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + _funcQueue.Enqueue((() => { asyncFunc().ContinueWith(task => { if (task.IsFaulted) - outerTcs.SetException(task.Exception?.InnerException ?? task.Exception ?? new Exception("Unknown error")); + outerTcs.TrySetException(task.Exception?.InnerException ?? task.Exception ?? new Exception("Unknown error")); else if (task.IsCanceled) - outerTcs.SetCanceled(); + outerTcs.TrySetCanceled(); else - outerTcs.SetResult(task.Result); + outerTcs.TrySetResult(task.Result); }); return (object)null; }, tcs)); @@ -147,5 +168,19 @@ public void Dispose() while (_funcQueue.TryDequeue(out var item)) item.tcs.TrySetCanceled(); } + + private static Task CreateCanceledTask() + { + var tcs = new TaskCompletionSource(); + tcs.SetCanceled(); + return tcs.Task; + } + + private static Task CreateCanceledTask() + { + var tcs = new TaskCompletionSource(); + tcs.SetCanceled(); + return tcs.Task; + } } } diff --git a/Tests/Editor/EditorThreadHelperLifecycleTests.cs b/Tests/Editor/EditorThreadHelperLifecycleTests.cs new file mode 100644 index 0000000..c4d7eee --- /dev/null +++ b/Tests/Editor/EditorThreadHelperLifecycleTests.cs @@ -0,0 +1,43 @@ +// Copyright (C) Funplay. Licensed under MIT. + +using System.Threading.Tasks; +using Funplay.Editor.Threading; +using NUnit.Framework; + +namespace Funplay.Editor +{ + public sealed class EditorThreadHelperLifecycleTests + { + [Test] + public void ExecuteAsyncOnEditorThreadAsync_CancelsQueuedOuterTaskWhenDisposed() + { + var helper = new EditorThreadHelper(null); + Task queuedTask = null; + + Task.Run(() => + { + queuedTask = helper.ExecuteAsyncOnEditorThreadAsync(async () => + { + await Task.Yield(); + return 42; + }); + }).Wait(); + + helper.Dispose(); + + Assert.IsNotNull(queuedTask); + Assert.IsTrue(queuedTask.IsCanceled); + } + + [Test] + public void ExecuteAsyncOnEditorThreadAsync_RejectsNewWorkAfterDispose() + { + var helper = new EditorThreadHelper(null); + helper.Dispose(); + + var rejectedTask = helper.ExecuteAsyncOnEditorThreadAsync(() => Task.FromResult(42)); + + Assert.IsTrue(rejectedTask.IsCanceled); + } + } +} diff --git a/Tests/Editor/EditorThreadHelperLifecycleTests.cs.meta b/Tests/Editor/EditorThreadHelperLifecycleTests.cs.meta new file mode 100644 index 0000000..0c48c5f --- /dev/null +++ b/Tests/Editor/EditorThreadHelperLifecycleTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4f2b8c72822b4f5b98c9d3ffbaadf2b1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/HttpMCPTransportLifecycleTests.cs b/Tests/Editor/HttpMCPTransportLifecycleTests.cs index a193ee0..7d97d54 100644 --- a/Tests/Editor/HttpMCPTransportLifecycleTests.cs +++ b/Tests/Editor/HttpMCPTransportLifecycleTests.cs @@ -21,10 +21,9 @@ public sealed class HttpMCPTransportLifecycleTests { private const string ServerName = "Funplay MCP Server - Test Project"; private const string ProjectIdentityA = "project-a"; - private const string ProjectIdentityB = "project-b"; [UnityTest] - public IEnumerator StartAsync_AttachesToExistingSameProjectFunplayServer() + public IEnumerator StartAsync_WhenPortIsAlreadyOwned_ReturnsFalseWithoutStoppingOwner() { var port = GetFreeTcpPort(); var firstTransport = new HttpMCPTransport(port, ServerName, ProjectIdentityA); @@ -40,12 +39,15 @@ public IEnumerator StartAsync_AttachesToExistingSameProjectFunplayServer() Assert.IsTrue(firstStart.Result, "The first transport should bind a free port."); var stopwatch = Stopwatch.StartNew(); - var secondStart = secondTransport.StartAsync(); - yield return WaitForTask(secondStart); - Assert.IsTrue(secondStart.Result, "A second transport for the same project should attach to the existing Funplay server instead of timing out."); + using (var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(900))) + { + var secondStart = secondTransport.StartAsync(cts.Token); + yield return WaitForTask(secondStart); + Assert.IsFalse(secondStart.Result, "A second transport must not report running when it does not own the listener."); + } stopwatch.Stop(); - Assert.IsTrue(secondTransport.IsAttachedToExistingServer); + Assert.IsFalse(secondTransport.IsAttachedToExistingServer); Assert.Less(stopwatch.Elapsed, TimeSpan.FromSeconds(2)); secondTransport.Stop(); @@ -55,7 +57,7 @@ public IEnumerator StartAsync_AttachesToExistingSameProjectFunplayServer() Assert.That( probeTask.Result, Does.Contain(ProjectIdentityA), - "Stopping an attached transport must not stop the owning listener."); + "Stopping a failed second transport must not stop the owning listener."); } finally { @@ -65,11 +67,11 @@ public IEnumerator StartAsync_AttachesToExistingSameProjectFunplayServer() } [UnityTest] - public IEnumerator StartAsync_DoesNotAttachToSameNameDifferentProject() + public IEnumerator Stop_ReleasesOwnedPortForRestart() { var port = GetFreeTcpPort(); var firstTransport = new HttpMCPTransport(port, ServerName, ProjectIdentityA); - var secondTransport = new HttpMCPTransport(port, ServerName, ProjectIdentityB); + var secondTransport = new HttpMCPTransport(port, ServerName, ProjectIdentityA); firstTransport.OnRequestReceived += (request, sendResponse) => HandleInitializeRequest(request, sendResponse, ProjectIdentityA); @@ -80,17 +82,11 @@ public IEnumerator StartAsync_DoesNotAttachToSameNameDifferentProject() yield return WaitForTask(firstStart); Assert.IsTrue(firstStart.Result); - using (var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(900))) - { - var secondStart = secondTransport.StartAsync(cts.Token); - yield return WaitForTask(secondStart); - Assert.IsFalse(secondStart.Result); - } + firstTransport.Stop(); - Assert.IsFalse(secondTransport.IsAttachedToExistingServer); - var probeTask = SendInitializeRequestAsync(port); - yield return WaitForTask(probeTask); - Assert.That(probeTask.Result, Does.Contain(ProjectIdentityA)); + var secondStart = secondTransport.StartAsync(); + yield return WaitForTask(secondStart); + Assert.IsTrue(secondStart.Result, "Stopping the owner should release the port for a fresh transport."); } finally { @@ -100,7 +96,7 @@ public IEnumerator StartAsync_DoesNotAttachToSameNameDifferentProject() } [UnityTest] - public IEnumerator StartAsync_ProbeTimeoutIsNotTreatedAsExternalCancellation() + public IEnumerator StartAsync_UnresponsivePortOwnerFailsWithoutReportingRunning() { var port = GetFreeTcpPort(); using (var listener = CreateHttpListener(port)) @@ -131,6 +127,32 @@ public IEnumerator StartAsync_ProbeTimeoutIsNotTreatedAsExternalCancellation() } } + [UnityTest] + public IEnumerator RequestWithoutSubscriber_ReturnsServerNotReadyErrorWithoutWaitingForTimeout() + { + var port = GetFreeTcpPort(); + var transport = new HttpMCPTransport(port, ServerName, ProjectIdentityA); + + try + { + var startTask = transport.StartAsync(); + yield return WaitForTask(startTask); + Assert.IsTrue(startTask.Result); + + var stopwatch = Stopwatch.StartNew(); + var probeTask = SendInitializeRequestAsync(port); + yield return WaitForTask(probeTask, 2f); + stopwatch.Stop(); + + Assert.That(probeTask.Result, Does.Contain("MCP server is stopping or not ready.")); + Assert.Less(stopwatch.Elapsed, TimeSpan.FromSeconds(2)); + } + finally + { + transport.Dispose(); + } + } + private static IEnumerator WaitForTask(Task task, float timeoutSeconds = 5f) { var start = Time.realtimeSinceStartup; diff --git a/Tests/Editor/ServiceProviderLifecycleTests.cs b/Tests/Editor/ServiceProviderLifecycleTests.cs new file mode 100644 index 0000000..833d26e --- /dev/null +++ b/Tests/Editor/ServiceProviderLifecycleTests.cs @@ -0,0 +1,59 @@ +// Copyright (C) Funplay. Licensed under MIT. + +using System; +using System.Collections.Generic; +using Funplay.Editor.DI; +using NUnit.Framework; + +namespace Funplay.Editor +{ + public sealed class ServiceProviderLifecycleTests + { + [Test] + public void Dispose_DisposesServicesInReverseCreationOrder() + { + var disposeEvents = new List(); + var services = new ServiceCollection(); + services.AddSingleton(_ => new FirstDisposable(disposeEvents)); + services.AddSingleton(_ => new SecondDisposable(disposeEvents)); + + using (var provider = services.BuildServiceProvider()) + { + provider.GetService(typeof(FirstDisposable)); + provider.GetService(typeof(SecondDisposable)); + } + + CollectionAssert.AreEqual(new[] { "second", "first" }, disposeEvents); + } + + private sealed class FirstDisposable : IDisposable + { + private readonly List _events; + + public FirstDisposable(List events) + { + _events = events; + } + + public void Dispose() + { + _events.Add("first"); + } + } + + private sealed class SecondDisposable : IDisposable + { + private readonly List _events; + + public SecondDisposable(List events) + { + _events = events; + } + + public void Dispose() + { + _events.Add("second"); + } + } + } +} diff --git a/Tests/Editor/ServiceProviderLifecycleTests.cs.meta b/Tests/Editor/ServiceProviderLifecycleTests.cs.meta new file mode 100644 index 0000000..d1a6250 --- /dev/null +++ b/Tests/Editor/ServiceProviderLifecycleTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6456f442d8f442a7a73088f14df053ac +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: