using System; using System.Threading.Tasks; using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Services.Transport; using MCPForUnity.Editor.Windows; using UnityEditor; using UnityEngine; namespace MCPForUnity.Editor.Services { /// /// Automatically starts the HTTP MCP bridge on editor load when the user has opted in /// via the "Auto-Start on Editor Load" toggle in Advanced Settings. /// This complements HttpBridgeReloadHandler (which only resumes after domain reloads). /// [InitializeOnLoad] internal static class HttpAutoStartHandler { internal const string SessionInitKey = "HttpAutoStartHandler.SessionInitialized"; // Set while AutoStartAsync is in flight. A domain reload kills the in-flight task but // leaves this set, so the next domain load can finish the connect phase without // re-spawning the server (StartLocalHttpServer would first stop a still-booting process). internal const string ConnectPendingKey = "HttpAutoStartHandler.ConnectPending"; // Bounds the per-frame retry when editor services keep throwing on a fresh launch. // Plain static, so every domain reload grants a fresh budget. private const int MaxServiceNotReadyRetries = 300; private static int _serviceNotReadyRetries; internal enum TickDecision { DeferBusy, DeferToResume, Skip, ShouldStart, ShouldReconnect, } static HttpAutoStartHandler() { if (Application.isBatchMode && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH"))) { return; } bool latched = SessionState.GetBool(SessionInitKey, false); bool connectPending = SessionState.GetBool(ConnectPendingKey, false); // Cheap pre-check so the common case (auto-start off, nothing pending) costs one // EditorPrefs read per domain load instead of an update subscription. The pref is // re-read every domain load, so enabling it takes effect at the next reload. if (!latched && !connectPending && !EditorPrefs.GetBool(EditorPrefKeys.AutoStartOnLoad, false)) { return; } // Latched with nothing pending: this session already auto-started. if (latched && !connectPending) { return; } // Pending EditorApplication.delayCall/update delegates are wiped by domain reloads, // so the deferred work must NOT latch up front — latching eagerly killed auto-start // for the whole session whenever startup included a compile (#1229). An update tick // is reload-safe because this ctor re-arms it on every domain load. EditorApplication.update += WaitForEditorReady; } /// /// Drops a reload-interrupted auto-start connect. Called when the user takes manual /// control of the bridge lifecycle, so no later domain load revives the connect. /// internal static void CancelPendingReconnect() => SessionState.EraseBool(ConnectPendingKey); private static void WaitForEditorReady() { switch (TickCore(HttpBridgeReloadHandler.IsEditorBusy())) { case TickDecision.DeferBusy: case TickDecision.DeferToResume: return; // stay registered, try again next tick case TickDecision.Skip: EditorApplication.update -= WaitForEditorReady; return; case TickDecision.ShouldStart: if (!TryBeginAutoStart()) { DeferOrGiveUp(); return; } SessionState.SetBool(SessionInitKey, true); EditorApplication.update -= WaitForEditorReady; return; case TickDecision.ShouldReconnect: if (!TryBeginReconnect()) { DeferOrGiveUp(); return; } EditorApplication.update -= WaitForEditorReady; return; } } // Services may not be initialized on the first frames of a fresh launch; retry next // tick, but not forever — a persistently broken environment shouldn't churn exceptions // every frame for the whole session. A later domain reload retries with a fresh budget. private static void DeferOrGiveUp() { if (++_serviceNotReadyRetries < MaxServiceNotReadyRetries) return; EditorApplication.update -= WaitForEditorReady; McpLog.Warn("[HTTP Auto-Start] Editor services unavailable; giving up until the next domain reload"); } /// /// Decision core for the editor-ready tick, separated so EditMode tests can drive it /// without spawning servers. Never writes the session latch — the caller latches only /// after the start work actually dispatches. /// internal static TickDecision TickCore(bool editorBusy) { if (editorBusy) return TickDecision.DeferBusy; bool connectPending = SessionState.GetBool(ConnectPendingKey, false); if (!connectPending) { if (SessionState.GetBool(SessionInitKey, false)) return TickDecision.Skip; // Only check lightweight EditorPrefs here — heavier services are touched in // TryBeginAutoStart once the editor is idle. No latch when disabled: the pref // is re-read on the next domain load. if (!EditorPrefs.GetBool(EditorPrefKeys.AutoStartOnLoad, false)) return TickDecision.Skip; } // A pending reload-resume owns bridge revival — checked only when we would // otherwise act, so a plain Skip never waits out the resume window. if (HttpBridgeReloadHandler.IsResumePending) return TickDecision.DeferToResume; return connectPending ? TickDecision.ShouldReconnect : TickDecision.ShouldStart; } /// /// Returns true when the auto-start decision was actually made (including deliberate /// early-outs). Returns false when services were not ready yet, so the caller leaves /// the session latch unset and retries instead of consuming it. /// private static bool TryBeginAutoStart() { try { if (!EditorConfigurationCache.Instance.UseHttpTransport) return true; // Don't auto-start if bridge is already running. if (MCPServiceLocator.TransportManager.IsRunning(TransportMode.Http)) return true; _ = AutoStartAsync(); return true; } catch (Exception ex) { McpLog.Debug($"[HTTP Auto-Start] Services not ready: {ex.Message}"); return false; } } /// /// Returns false when services were not ready yet (caller retries). On true the /// pending reconnect was either dispatched or deliberately dropped (auto-start /// disabled, transport switched, bridge already running). /// internal static bool TryBeginReconnect() { bool proceed; try { proceed = EditorPrefs.GetBool(EditorPrefKeys.AutoStartOnLoad, false) && EditorConfigurationCache.Instance.UseHttpTransport && !MCPServiceLocator.TransportManager.IsRunning(TransportMode.Http); } catch (Exception ex) { McpLog.Debug($"[HTTP Auto-Start] Services not ready: {ex.Message}"); return false; } if (!proceed) { SessionState.EraseBool(ConnectPendingKey); return true; } _ = ReconnectAsync(); return true; } private static async Task AutoStartAsync() { SessionState.SetBool(ConnectPendingKey, true); try { bool isLocal = !HttpEndpointUtility.IsRemoteScope(); if (isLocal) { // For HTTP Local: launch the server process first, then connect the bridge. // This mirrors what the UI "Start Server" button does. if (!HttpEndpointUtility.IsHttpLocalUrlAllowedForLaunch( HttpEndpointUtility.GetLocalBaseUrl(), out string policyError)) { McpLog.Debug($"[HTTP Auto-Start] Local URL blocked by security policy: {policyError}"); return; } // Check if server is already reachable (e.g. user started it externally). if (!MCPServiceLocator.Server.IsLocalHttpServerReachable()) { bool serverStarted = MCPServiceLocator.Server.StartLocalHttpServer(quiet: true); if (!serverStarted) { McpLog.Warn("[HTTP Auto-Start] Failed to start local HTTP server"); return; } } // Wait for the server to become reachable, then connect. await WaitForServerAndConnectAsync(); } else { // For HTTP Remote: server is external, just connect the bridge. await ConnectBridgeAsync(); } } catch (Exception ex) { McpLog.Warn($"[HTTP Auto-Start] Failed: {ex.Message}"); } finally { // Reached on every terminal outcome. A domain reload that kills the task // mid-flight skips this, leaving the key set for the reconnect path. SessionState.EraseBool(ConnectPendingKey); } } /// /// Finishes an auto-start whose connect phase was killed by a domain reload. /// Connect-only: never spawns a server — the previous domain already did. /// private static async Task ReconnectAsync() { try { if (HttpEndpointUtility.IsRemoteScope()) { await ConnectBridgeAsync(); return; } await WaitForServerAndConnectAsync(); } catch (Exception ex) { McpLog.Warn($"[HTTP Auto-Start] Post-reload reconnect failed: {ex.Message}"); } finally { SessionState.EraseBool(ConnectPendingKey); } } /// /// Waits for the local HTTP server to accept connections, then connects the bridge. /// Mirrors TryAutoStartSessionAsync in McpConnectionSection: while a managed launch /// process is alive, keep polling reachability and declare failure only when it exits /// without the port coming up. Without a launch handle (post-reload reconnect, or a /// server started externally) there is nothing to watch die, so poll to the hard cap. /// private static async Task WaitForServerAndConnectAsync() { var server = MCPServiceLocator.Server; string url = HttpEndpointUtility.GetLocalBaseUrl(); var pollDelay = TimeSpan.FromMilliseconds(500); var hardCap = TimeSpan.FromMinutes(5); double startTime = EditorApplication.timeSinceStartup; while (true) { // Abort if user changed settings while we were waiting. if (!EditorPrefs.GetBool(EditorPrefKeys.AutoStartOnLoad, false)) return; if (!EditorConfigurationCache.Instance.UseHttpTransport) return; if (MCPServiceLocator.TransportManager.IsRunning(TransportMode.Http)) return; if (server.IsLocalHttpServerReachable()) { McpLog.Info($"Server ready on {url}"); bool started = await MCPServiceLocator.Bridge.StartAsync(); if (started) { McpLog.Info("Session connected"); MCPForUnityEditorWindow.RequestHealthVerification(); return; } } double elapsed = EditorApplication.timeSinceStartup - startTime; bool launchProcessDied = server.HasManagedServerLaunchHandle && !server.IsManagedServerLaunchProcessAlive() && elapsed > 1.0; if (launchProcessDied || elapsed > hardCap.TotalSeconds) { // Last-resort connect attempt in case reachability detection missed a live server. if (await MCPServiceLocator.Bridge.StartAsync()) { McpLog.Info("Session connected"); MCPForUnityEditorWindow.RequestHealthVerification(); return; } server.LogLocalHttpServerLaunchFailure(); return; } try { await Task.Delay(pollDelay); } catch { return; } } } /// /// Connects the bridge directly (for remote HTTP where the server is already running). /// private static async Task ConnectBridgeAsync() { string url = HttpEndpointUtility.GetRemoteBaseUrl(); McpLog.Info($"Connecting to {url}…"); bool started = await MCPServiceLocator.Bridge.StartAsync(); if (started) { McpLog.Info("Connected"); MCPForUnityEditorWindow.RequestHealthVerification(); } else { McpLog.Warn("Connection failed: could not connect to remote HTTP server"); } } } }