diff --git a/MCPForUnity/Editor/Services/EditorStateCache.cs b/MCPForUnity/Editor/Services/EditorStateCache.cs
index d02b26528..1efb8b9a6 100644
--- a/MCPForUnity/Editor/Services/EditorStateCache.cs
+++ b/MCPForUnity/Editor/Services/EditorStateCache.cs
@@ -1,4 +1,5 @@
using System;
+using System.Globalization;
using System.Reflection;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json;
@@ -22,8 +23,24 @@ internal static class EditorStateCache
private static long _observedUnixMs;
private static bool _lastIsCompiling;
- private static long? _lastCompileStartedUnixMs;
- private static long? _lastCompileFinishedUnixMs;
+
+ // Compile edges live in SessionState, recorded from the CompilationPipeline
+ // events, rather than in statics sampled off the update tick. Two reasons,
+ // both load-bearing:
+ //
+ // - A successful compile ends in a domain reload that wipes every static in
+ // this class, including the "was compiling" flag the falling edge was
+ // derived from. The finish of the very compile a client is waiting on was
+ // therefore unobservable: both timestamps read null afterwards, so nothing
+ // downstream could tell "finished" from "never started" (issue #814).
+ // - The events fire at the true edges. Sampling quantised them to the 1s
+ // update throttle and dropped any compile shorter than one tick entirely.
+ //
+ // SessionState survives domain reloads and dies with the editor session,
+ // which is exactly the lifetime these values describe.
+ private const string CompileStartedKey = "MCPForUnity.EditorState.CompileStartedUnixMs";
+ private const string CompileFinishedKey = "MCPForUnity.EditorState.CompileFinishedUnixMs";
+ private const string CompileCountKey = "MCPForUnity.EditorState.CompileCount";
private static bool _domainReloadPending;
private static long? _domainReloadBeforeUnixMs;
@@ -262,8 +279,24 @@ static EditorStateCache()
// Tracks whether an assembly compilation is actually running, for
// GetActualIsCompiling. 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;
+ // The timestamps beside it are not per-domain — see the SessionState
+ // note on CompileStartedKey.
+ UnityEditor.Compilation.CompilationPipeline.compilationStarted += _ =>
+ {
+ _pipelineCompilationRunning = true;
+ SetSessionUnixMs(CompileStartedKey, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
+ SessionState.SetInt(CompileCountKey, SessionState.GetInt(CompileCountKey, 0) + 1);
+ ForceUpdate("compilation_started");
+ };
+ UnityEditor.Compilation.CompilationPipeline.compilationFinished += _ =>
+ {
+ _pipelineCompilationRunning = false;
+ // Fires before the domain reload, which is what makes the finish
+ // observable at all: the write lands while this domain is alive and
+ // is read back by the next one.
+ SetSessionUnixMs(CompileFinishedKey, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
+ ForceUpdate("compilation_finished");
+ };
AssemblyReloadEvents.beforeAssemblyReload += () =>
{
@@ -378,14 +411,6 @@ private static JObject BuildSnapshot(string reason)
_observedUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
bool isCompiling = GetActualIsCompiling();
- if (isCompiling && !_lastIsCompiling)
- {
- _lastCompileStartedUnixMs = _observedUnixMs;
- }
- else if (!isCompiling && _lastIsCompiling)
- {
- _lastCompileFinishedUnixMs = _observedUnixMs;
- }
_lastIsCompiling = isCompiling;
var scene = EditorSceneManager.GetActiveScene();
@@ -458,8 +483,8 @@ private static JObject BuildSnapshot(string reason)
{
IsCompiling = isCompiling,
IsDomainReloadPending = _domainReloadPending,
- LastCompileStartedUnixMs = _lastCompileStartedUnixMs,
- LastCompileFinishedUnixMs = _lastCompileFinishedUnixMs,
+ LastCompileStartedUnixMs = GetSessionUnixMs(CompileStartedKey),
+ LastCompileFinishedUnixMs = GetSessionUnixMs(CompileFinishedKey),
LastDomainReloadBeforeUnixMs = _domainReloadBeforeUnixMs,
LastDomainReloadAfterUnixMs = _domainReloadAfterUnixMs
},
@@ -535,6 +560,27 @@ public static JObject GetSnapshot()
}
}
+ ///
+ /// Compilations begun this editor session, surviving domain reloads. Callers
+ /// that trigger a compile snapshot this first, then wait for it to move — the
+ /// only signal that separates "a compile ran" from "one never started", which
+ /// reads as idle either way.
+ ///
+ internal static int CompileCount => SessionState.GetInt(CompileCountKey, 0);
+
+ internal static long? GetSessionUnixMs(string key)
+ {
+ string raw = SessionState.GetString(key, string.Empty);
+ return long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out long value)
+ ? value
+ : (long?)null;
+ }
+
+ // SessionState has no long overload, so these round-trip through an
+ // invariant string rather than losing precision through int or float.
+ internal static void SetSessionUnixMs(string key, long value)
+ => SessionState.SetString(key, value.ToString(CultureInfo.InvariantCulture));
+
// 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
diff --git a/MCPForUnity/Editor/Tools/RefreshUnity.cs b/MCPForUnity/Editor/Tools/RefreshUnity.cs
index e35237d80..01fbff001 100644
--- a/MCPForUnity/Editor/Tools/RefreshUnity.cs
+++ b/MCPForUnity/Editor/Tools/RefreshUnity.cs
@@ -18,6 +18,18 @@ public static class RefreshUnity
{
private const int DefaultWaitTimeoutSeconds = 60;
+ /// Backstop on the wait for compilation to begin. Not the normal
+ /// exit: RequestScriptCompilation records a pending request that the
+ /// editor drains into a pipeline run on a later tick whether or not any
+ /// source changed — "recompiles those scripts which require it" in the docs
+ /// describes per-assembly skipping inside that run, not a run that is skipped.
+ /// With nothing changed, 6000.3 still raises compilationStarted/Finished
+ /// (~100 ms, cached) and reloads the domain. The grace only bounds the cases
+ /// where the run never begins: the pipeline refusing to start on a setup
+ /// error, or play mode with "Recompile After Finished Playing" deferring it
+ /// until exit. Both are reported as compile_started = false.
+ private const int CompileStartGraceSeconds = 10;
+
public static async Task