unity-mcp/MCPForUnity/Editor/Services/EditorStateCache.cs

235 lines
8.5 KiB
C#
Raw Normal View History

Async Test Infrastructure & Editor Readiness Status + new refresh_unity tool (#507) * Add editor readiness v2, refresh tool, and preflight guards * Detect external package changes and harden refresh retry * feat: add TestRunnerNoThrottle and async test running with background stall prevention - Add TestRunnerNoThrottle.cs: Sets editor to 'No Throttling' mode during test runs with SessionState persistence across domain reload - Add run_tests_async and get_test_job tools for non-blocking test execution - Add TestJobManager for async test job tracking with progress monitoring - Add ForceSynchronousImport to all AssetDatabase.Refresh() calls to prevent stalls - Mark DomainReloadResilienceTests as [Explicit] with documentation explaining the test infrastructure limitation (internal coroutine waits vs MCP socket polling) - MCP workflow is unaffected - socket messages provide external stimulus that keeps Unity responsive even when backgrounded * refactor: simplify and clean up code - Remove unused Newtonsoft.Json.Linq import from TestJobManager - Add throttling to SessionState persistence (once per second) to reduce overhead - Critical job state changes (start/finish) still persist immediately - Fix duplicate XML summary tag in DomainReloadResilienceTests * docs: add async test tools to README, document domain reload limitation - Add run_tests_async and get_test_job to main README tools list - Document background stall limitation for domain reload tests in DEV readme * ci: add separate job for domain reload tests Run [Explicit] domain_reload tests in their own job using -testCategory * ci: run domain reload tests in same job as regular tests Combines into single job with two test steps to reuse cached Library * fix: address coderabbit review issues - Fix TOCTOU race in TestJobManager.StartJob (single lock scope for check-and-set) - Store TestRunnerApi reference with HideAndDontSave to prevent GC/serialization issues * docs: update tool descriptions to prefer run_tests_async - run_tests_async is now marked as preferred for long-running suites - run_tests description notes it blocks and suggests async alternative * docs: update README screenshot to v8.6 UI * docs: add v8.6 UI screenshot * Update README for MCP version and instructions for v8.7 * fix: handle preflight busy signals and derive job status from test results - manage_asset, manage_gameobject, manage_scene now check preflight return value and propagate busy/retry signals to clients (fixes Sourcery #1) - TestJobManager.FinalizeCurrentJobFromRunFinished now sets job status to Failed when resultPayload.Failed > 0, not always Succeeded (fixes Sourcery #2) * fix: increase HTTP server startup timeout for dev mode When 'Force fresh server install' is enabled, uvx uses --no-cache --refresh which rebuilds the package and takes significantly longer to start. - Increase timeout from 10s to 45s when dev mode is enabled - Add informative log message explaining the longer startup time - Show actual timeout value in warning message * fix: derive job status from test results in FinalizeFromTask fallback Apply same logic as FinalizeCurrentJobFromRunFinished: check result.Failed > 0 to correctly mark jobs as Failed when tests fail, even in the fallback path when RunFinished callback is not delivered.
2026-01-04 04:42:32 +08:00
using System;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditorInternal;
using UnityEditor.SceneManagement;
using UnityEngine;
namespace MCPForUnity.Editor.Services
{
/// <summary>
/// Maintains a cached readiness snapshot (v2) so status reads remain fast even when Unity is busy.
/// Updated on the main thread via Editor callbacks and periodic update ticks.
/// </summary>
[InitializeOnLoad]
internal static class EditorStateCache
{
private static readonly object LockObj = new();
private static long _sequence;
private static long _observedUnixMs;
private static bool _lastIsCompiling;
private static long? _lastCompileStartedUnixMs;
private static long? _lastCompileFinishedUnixMs;
private static bool _domainReloadPending;
private static long? _domainReloadBeforeUnixMs;
private static long? _domainReloadAfterUnixMs;
private static double _lastUpdateTimeSinceStartup;
private const double MinUpdateIntervalSeconds = 0.25;
private static JObject _cached;
static EditorStateCache()
{
try
{
_sequence = 0;
_observedUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
_cached = BuildSnapshot("init");
EditorApplication.update += OnUpdate;
EditorApplication.playModeStateChanged += _ => ForceUpdate("playmode");
AssemblyReloadEvents.beforeAssemblyReload += () =>
{
_domainReloadPending = true;
_domainReloadBeforeUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
ForceUpdate("before_domain_reload");
};
AssemblyReloadEvents.afterAssemblyReload += () =>
{
_domainReloadPending = false;
_domainReloadAfterUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
ForceUpdate("after_domain_reload");
};
}
catch (Exception ex)
{
McpLog.Error($"[EditorStateCache] Failed to initialise: {ex.Message}\n{ex.StackTrace}");
}
}
private static void OnUpdate()
{
// Throttle to reduce overhead while keeping the snapshot fresh enough for polling clients.
double now = EditorApplication.timeSinceStartup;
if (now - _lastUpdateTimeSinceStartup < MinUpdateIntervalSeconds)
{
// Still update on compilation edge transitions to keep timestamps meaningful.
bool isCompiling = EditorApplication.isCompiling;
if (isCompiling == _lastIsCompiling)
{
return;
}
}
_lastUpdateTimeSinceStartup = now;
ForceUpdate("tick");
}
private static void ForceUpdate(string reason)
{
lock (LockObj)
{
_cached = BuildSnapshot(reason);
}
}
private static JObject BuildSnapshot(string reason)
{
_sequence++;
_observedUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
bool isCompiling = EditorApplication.isCompiling;
if (isCompiling && !_lastIsCompiling)
{
_lastCompileStartedUnixMs = _observedUnixMs;
}
else if (!isCompiling && _lastIsCompiling)
{
_lastCompileFinishedUnixMs = _observedUnixMs;
}
_lastIsCompiling = isCompiling;
var scene = EditorSceneManager.GetActiveScene();
string scenePath = string.IsNullOrEmpty(scene.path) ? null : scene.path;
string sceneGuid = !string.IsNullOrEmpty(scenePath) ? AssetDatabase.AssetPathToGUID(scenePath) : null;
bool testsRunning = TestRunStatus.IsRunning;
var testsMode = TestRunStatus.Mode?.ToString();
string currentJobId = TestJobManager.CurrentJobId;
bool isFocused = InternalEditorUtility.isApplicationActive;
var activityPhase = "idle";
if (testsRunning)
{
activityPhase = "running_tests";
}
else if (isCompiling)
{
activityPhase = "compiling";
}
else if (_domainReloadPending)
{
activityPhase = "domain_reload";
}
else if (EditorApplication.isUpdating)
{
activityPhase = "asset_import";
}
else if (EditorApplication.isPlayingOrWillChangePlaymode)
{
activityPhase = "playmode_transition";
}
// Keep this as a plain JSON object for minimal friction with transports.
return JObject.FromObject(new
{
schema_version = "unity-mcp/editor_state@2",
observed_at_unix_ms = _observedUnixMs,
sequence = _sequence,
unity = new
{
instance_id = (string)null,
unity_version = Application.unityVersion,
project_id = (string)null,
platform = Application.platform.ToString(),
is_batch_mode = Application.isBatchMode
},
editor = new
{
is_focused = isFocused,
play_mode = new
{
is_playing = EditorApplication.isPlaying,
is_paused = EditorApplication.isPaused,
is_changing = EditorApplication.isPlayingOrWillChangePlaymode
},
active_scene = new
{
path = scenePath,
guid = sceneGuid,
name = scene.name ?? string.Empty
}
},
activity = new
{
phase = activityPhase,
since_unix_ms = _observedUnixMs,
reasons = new[] { reason }
},
compilation = new
{
is_compiling = isCompiling,
is_domain_reload_pending = _domainReloadPending,
last_compile_started_unix_ms = _lastCompileStartedUnixMs,
last_compile_finished_unix_ms = _lastCompileFinishedUnixMs,
last_domain_reload_before_unix_ms = _domainReloadBeforeUnixMs,
last_domain_reload_after_unix_ms = _domainReloadAfterUnixMs
},
assets = new
{
is_updating = EditorApplication.isUpdating,
external_changes_dirty = false,
external_changes_last_seen_unix_ms = (long?)null,
refresh = new
{
is_refresh_in_progress = false,
last_refresh_requested_unix_ms = (long?)null,
last_refresh_finished_unix_ms = (long?)null
}
},
tests = new
{
is_running = testsRunning,
mode = testsMode,
current_job_id = string.IsNullOrEmpty(currentJobId) ? null : currentJobId,
started_unix_ms = TestRunStatus.StartedUnixMs,
started_by = "unknown",
last_run = TestRunStatus.FinishedUnixMs.HasValue
? new
{
finished_unix_ms = TestRunStatus.FinishedUnixMs,
result = "unknown",
counts = (object)null
}
: null
},
transport = new
{
unity_bridge_connected = (bool?)null,
last_message_unix_ms = (long?)null
}
});
}
public static JObject GetSnapshot()
{
lock (LockObj)
{
// Defensive: if something went wrong early, rebuild once.
if (_cached == null)
{
_cached = BuildSnapshot("rebuild");
}
return (JObject)_cached.DeepClone();
}
}
}
}