Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion MCPForUnity/Editor/Constants/EditorPrefKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ internal static class EditorPrefKeys
internal const string DebugLogs = "MCPForUnity.DebugLogs";
internal const string ValidationLevel = "MCPForUnity.ValidationLevel";
internal const string UnitySocketPort = "MCPForUnity.UnitySocketPort";
internal const string ResumeHttpAfterReload = "MCPForUnity.ResumeHttpAfterReload";
internal const string ResumeStdioAfterReload = "MCPForUnity.ResumeStdioAfterReload";

internal const string UvxPathOverride = "MCPForUnity.UvxPath";
Expand Down
41 changes: 22 additions & 19 deletions MCPForUnity/Editor/Services/EditorStateCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,12 @@ static EditorStateCache()
EditorApplication.update += OnUpdate;
EditorApplication.playModeStateChanged += _ => ForceUpdate("playmode");

// Tracks whether an assembly compilation is actually running, for
// GetActualIsCompiling's Play-mode check. Statics reset on domain reload
// and this [InitializeOnLoad] ctor re-subscribes, so the flag is per-domain.
UnityEditor.Compilation.CompilationPipeline.compilationStarted += _ => _pipelineCompilationRunning = true;
UnityEditor.Compilation.CompilationPipeline.compilationFinished += _ => _pipelineCompilationRunning = false;

AssemblyReloadEvents.beforeAssemblyReload += () =>
{
_domainReloadPending = true;
Expand Down Expand Up @@ -529,39 +535,36 @@ public static JObject GetSnapshot()
}
}

// Set/cleared by the CompilationPipeline.compilationStarted/Finished events
// subscribed in the static ctor. NOTE: CompilationPipeline.isCompiling does not
// exist on the supported Unity range (verified by reflection probe on 2021.3 and
// 6000.4 — neither public nor non-public), so the reflection this replaced never
// resolved and always fell back to the raw signal.
private static bool _pipelineCompilationRunning;

/// <summary>
/// Returns the actual compilation state, working around a known Unity quirk where
/// EditorApplication.isCompiling can return false positives in Play mode.
/// See: https://github.com/CoplayDev/unity-mcp/issues/549
/// EditorApplication.isCompiling can return false positives in Play mode (e.g. a
/// recompile deferred by Recompile-After-Finished-Playing keeps it true for the
/// whole play session). See: https://github.com/CoplayDev/unity-mcp/issues/549
/// </summary>
private static bool GetActualIsCompiling()
internal static bool GetActualIsCompiling()
{
// If EditorApplication.isCompiling is false, Unity is definitely not compiling
if (!EditorApplication.isCompiling)
{
return false;
}

// In Play mode, EditorApplication.isCompiling can have false positives.
// Double-check with CompilationPipeline.isCompiling via reflection.
// In Play mode, trust the event-tracked pipeline state instead: a deferred
// recompile keeps EditorApplication.isCompiling true without any compilation
// actually running.
if (EditorApplication.isPlaying)
{
try
{
Type pipeline = Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor");
var prop = pipeline?.GetProperty("isCompiling", BindingFlags.Public | BindingFlags.Static);
if (prop != null)
{
return (bool)prop.GetValue(null);
}
}
catch
{
// If reflection fails, fall back to EditorApplication.isCompiling
}
return _pipelineCompilationRunning;
}

// Outside Play mode or if reflection failed, trust EditorApplication.isCompiling
// Outside Play mode the raw signal is reliable.
return true;
}
}
Expand Down
224 changes: 199 additions & 25 deletions MCPForUnity/Editor/Services/HttpAutoStartHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,54 +17,193 @@ namespace MCPForUnity.Editor.Services
[InitializeOnLoad]
internal static class HttpAutoStartHandler
{
private const string SessionInitKey = "HttpAutoStartHandler.SessionInitialized";
internal const string SessionInitKey = "HttpAutoStartHandler.SessionInitialized";

static HttpAutoStartHandler()
// 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
{
// SessionState resets on editor process start but persists across domain reloads.
// Only run once per session — let HttpBridgeReloadHandler handle reload-resume cases.
if (SessionState.GetBool(SessionInitKey, false)) return;
DeferBusy,
DeferToResume,
Skip,
ShouldStart,
ShouldReconnect,
}

static HttpAutoStartHandler()
{
if (Application.isBatchMode &&
string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH")))
{
return;
}

// Only check lightweight EditorPrefs here — services like EditorConfigurationCache
// and MCPServiceLocator may not be initialized yet on fresh editor launch.
bool autoStartEnabled = EditorPrefs.GetBool(EditorPrefKeys.AutoStartOnLoad, false);
if (!autoStartEnabled) return;
bool latched = SessionState.GetBool(SessionInitKey, false);
bool connectPending = SessionState.GetBool(ConnectPendingKey, false);

SessionState.SetBool(SessionInitKey, true);
// 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;
}

// Delay to let the editor and services finish initialization.
EditorApplication.delayCall += OnEditorReady;
// 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;
}

private static void OnEditorReady()
/// <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()
{
try
switch (TickCore(HttpBridgeReloadHandler.IsEditorBusy()))
{
bool autoStartEnabled = EditorPrefs.GetBool(EditorPrefKeys.AutoStartOnLoad, false);
if (!autoStartEnabled) return;
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;

bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport;
if (!useHttp) return;
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;
if (MCPServiceLocator.TransportManager.IsRunning(TransportMode.Http)) return true;

_ = AutoStartAsync();
return true;
}
catch (Exception ex)
{
McpLog.Debug($"[HTTP Auto-Start] Deferred check failed: {ex.Message}");
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();
Expand Down Expand Up @@ -104,13 +243,46 @@ private static async Task AutoStartAsync()
{
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: keep polling reachability while
/// the launched process is alive; declare failure only when it exits without the port coming
/// up, or a generous hard cap is reached.
/// 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()
{
Expand Down Expand Up @@ -139,10 +311,12 @@ private static async Task WaitForServerAndConnectAsync()
}
}

bool processAlive = server.IsManagedServerLaunchProcessAlive();
double elapsed = EditorApplication.timeSinceStartup - startTime;
bool launchProcessDied = server.HasManagedServerLaunchHandle
&& !server.IsManagedServerLaunchProcessAlive()
&& elapsed > 1.0;

if ((!processAlive && elapsed > 1.0) || elapsed > hardCap.TotalSeconds)
if (launchProcessDied || elapsed > hardCap.TotalSeconds)
{
// Last-resort connect attempt in case reachability detection missed a live server.
if (await MCPServiceLocator.Bridge.StartAsync())
Expand Down
Loading
Loading