Files
Shutong Wu 777e8a9c7a fix: trust the pipeline flag when a domain reload is deferred (#1276)
EditorApplication.isCompiling conflates three states: actually compiling,
compilation queued, and finished-but-reload-deferred. A project holding
EditorApplication.LockReloadAssemblies sits in the third state for as long
as the lock is held, with no compilation running, and isCompiling stays
true the whole time.

Eight call sites gated on that raw flag, so they refused work indefinitely:
the stdio bridge would not start, unity_reflect and manage_scriptable_object
returned "Unity is compiling", refresh_unity reported the wrong resulting
state and never completed its wait, the stdio reload handler deferred its
resume, and TestJobManager both mis-attributed its init timeout and reported
a bogus "compiling" block reason.

Route all eight through EditorStateCache.GetActualIsCompiling(), which falls
back to the event-tracked CompilationPipeline flag, and drop the isPlaying
gate that previously limited the workaround to play mode.

Verified live: with LockReloadAssemblies held after RequestScriptCompilation,
EditorApplication.isCompiling is true while the pipeline flag is false, so
CompilationPipeline.compilationFinished does fire while the reload is held.
2026-07-28 12:05:30 -04:00

204 lines
7.9 KiB
C#

using System;
using System.Threading;
using System.Threading.Tasks;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services.Transport;
using MCPForUnity.Editor.Services.Transport.Transports;
using MCPForUnity.Editor.Windows;
using UnityEditor;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Ensures the legacy stdio bridge resumes after domain reloads, mirroring the HTTP handler.
/// </summary>
[InitializeOnLoad]
internal static class StdioBridgeReloadHandler
{
private static readonly TimeSpan[] ResumeRetrySchedule =
{
TimeSpan.Zero,
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(3),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(10),
TimeSpan.FromSeconds(30)
};
private static CancellationTokenSource _retryCts;
static StdioBridgeReloadHandler()
{
AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload;
AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload;
EditorApplication.quitting += CancelRetries;
}
private static void CancelRetries()
{
try { _retryCts?.Cancel(); } catch { }
}
private static void OnBeforeAssemblyReload()
{
// Cancel any in-flight retry loop before the next reload.
CancelRetries();
try
{
// Only persist resume intent when stdio is the active transport and the bridge is running.
bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport;
// Check both TransportManager AND StdioBridgeHost directly, because CI starts via StdioBridgeHost
// bypassing TransportManager state.
bool tmRunning = MCPServiceLocator.TransportManager.IsRunning(TransportMode.Stdio);
bool hostRunning = StdioBridgeHost.IsRunning;
bool isRunning = tmRunning || hostRunning;
bool shouldResume = !useHttp && isRunning;
if (shouldResume)
{
EditorPrefs.SetBool(EditorPrefKeys.ResumeStdioAfterReload, true);
}
else
{
EditorPrefs.DeleteKey(EditorPrefKeys.ResumeStdioAfterReload);
}
if (isRunning)
{
// Stop only stdio before reload. This is centralized here so resume-flag updates
// and teardown cannot race each other via separate beforeAssemblyReload handlers.
var stopTask = MCPServiceLocator.TransportManager.StopAsync(TransportMode.Stdio);
try { stopTask.Wait(500); } catch { }
// Legacy safety: stdio may have been started outside TransportManager state.
try { StdioBridgeHost.Stop(); } catch { }
}
if (shouldResume)
{
// Write reloading status so clients don't think we vanished.
StdioBridgeHost.WriteHeartbeat(true, "reloading");
}
}
catch (Exception ex)
{
McpLog.Warn($"Failed to persist stdio reload flag: {ex.Message}");
}
}
private static void OnAfterAssemblyReload()
{
bool resume = false;
try
{
bool resumeFlag = EditorPrefs.GetBool(EditorPrefKeys.ResumeStdioAfterReload, false);
bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport;
resume = resumeFlag && !useHttp;
// If we're not going to resume, clear the flag immediately to avoid stuck "Resuming..." state
if (!resume)
{
EditorPrefs.DeleteKey(EditorPrefKeys.ResumeStdioAfterReload);
}
}
catch (Exception ex)
{
McpLog.Warn($"Failed to read stdio reload flag: {ex.Message}");
}
if (!resume)
{
return;
}
// If the editor is not compiling, attempt an immediate restart without relying on editor focus.
// Routed through EditorStateCache so a deferred reload (issue #1276) does not block resume.
bool isCompiling = EditorStateCache.GetActualIsCompiling();
if (!isCompiling)
{
_ = ResumeStdioWithRetriesAsync();
return;
}
// Fallback when compiling: schedule on the editor loop
EditorApplication.delayCall += () =>
{
_ = ResumeStdioWithRetriesAsync();
};
}
private static async Task ResumeStdioWithRetriesAsync()
{
// Cancel any previous retry loop and create a fresh token.
CancelRetries();
var cts = _retryCts = new CancellationTokenSource();
var token = cts.Token;
Exception lastException = null;
for (int i = 0; i < ResumeRetrySchedule.Length; i++)
{
if (token.IsCancellationRequested) return;
int attempt = i + 1;
McpLog.Debug($"[Stdio Reload] Resume attempt {attempt}/{ResumeRetrySchedule.Length}");
TimeSpan delay = ResumeRetrySchedule[i];
if (delay > TimeSpan.Zero)
{
McpLog.Debug($"[Stdio Reload] Waiting {delay.TotalSeconds:0.#}s before resume attempt {attempt}");
try { await Task.Delay(delay, token); }
catch (OperationCanceledException) { return; }
}
// Abort retries if the user switched transports while we were waiting.
if (EditorConfigurationCache.Instance.UseHttpTransport)
{
try { EditorPrefs.DeleteKey(EditorPrefKeys.ResumeStdioAfterReload); } catch { }
return;
}
try
{
bool started = await MCPServiceLocator.TransportManager.StartAsync(TransportMode.Stdio);
if (started)
{
McpLog.Debug($"[Stdio Reload] Resume succeeded on attempt {attempt}");
try { EditorPrefs.DeleteKey(EditorPrefKeys.ResumeStdioAfterReload); } catch { }
MCPForUnityEditorWindow.RequestHealthVerification();
return;
}
var state = MCPServiceLocator.TransportManager.GetState(TransportMode.Stdio);
string reason = string.IsNullOrWhiteSpace(state?.Error) ? "no error detail" : state.Error;
McpLog.Debug($"[Stdio Reload] Resume attempt {attempt} failed: {reason}");
}
catch (Exception ex)
{
lastException = ex;
McpLog.Debug($"[Stdio Reload] Resume attempt {attempt} threw: {ex.Message}");
}
}
try { EditorPrefs.DeleteKey(EditorPrefKeys.ResumeStdioAfterReload); } catch { }
// Clear the stale "reloading" heartbeat so clients stop seeing reloading=true.
// The bridge isn't running, so clients will get connection-refused (recoverable)
// instead of hanging on a zombie socket or being rejected by the preflight check.
try { StdioBridgeHost.WriteHeartbeat(false, "stopped"); } catch { }
if (lastException != null)
{
McpLog.Warn($"Failed to resume stdio bridge after domain reload: {lastException.Message}");
}
else
{
McpLog.Warn("Failed to resume stdio bridge after domain reload");
}
}
}
}