Files
coplaydev--unity-mcp/MCPForUnity/Editor/Services/HttpAutoStartHandler.cs
T
2026-07-13 12:49:17 +08:00

358 lines
14 KiB
C#

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
{
/// <summary>
/// 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).
/// </summary>
[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;
}
/// <summary>
/// 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.
/// </summary>
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");
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>
/// 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).
/// </summary>
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);
}
}
/// <summary>
/// Finishes an auto-start whose connect phase was killed by a domain reload.
/// Connect-only: never spawns a server — the previous domain already did.
/// </summary>
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);
}
}
/// <summary>
/// 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.
/// </summary>
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; }
}
}
/// <summary>
/// Connects the bridge directly (for remote HTTP where the server is already running).
/// </summary>
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");
}
}
}
}