chore(unity): 固定Unity CLI Loop本地包并移除UnitySkills依赖
This commit is contained in:
@@ -0,0 +1,958 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Net.Sockets;
|
||||
using UnityEditor;
|
||||
using Newtonsoft.Json;
|
||||
using UnityEngine;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
// Related classes:
|
||||
// - McpBridgeServer: The TCP server instance that this class manages.
|
||||
// - McpEditorWindow: The UI for starting and stopping the server.
|
||||
// - AssemblyReloadEvents: Used to handle server state across domain reloads.
|
||||
/// <summary>
|
||||
/// Manages the state of the MCP Server with SessionState and automatically restores it on assembly reload.
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
public static class McpServerController
|
||||
{
|
||||
private static McpBridgeServer mcpServer;
|
||||
private static readonly SemaphoreSlim StartupSemaphore = new SemaphoreSlim(1, 1);
|
||||
private static long startupProtectionUntilTicks = 0; // UTC ticks
|
||||
private static Task _currentRecoveryTask;
|
||||
|
||||
private static bool IsBackgroundUnityProcess()
|
||||
{
|
||||
bool isBatchMode = Application.isBatchMode;
|
||||
bool isAssetImportWorker = AssetDatabase.IsAssetImportWorkerProcess();
|
||||
return isBatchMode || isAssetImportWorker;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The current MCP server instance.
|
||||
/// </summary>
|
||||
public static McpBridgeServer CurrentServer => mcpServer;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the server is running.
|
||||
/// </summary>
|
||||
public static bool IsServerRunning => mcpServer?.IsRunning ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// The server's port number.
|
||||
/// </summary>
|
||||
public static int ServerPort => mcpServer?.Port ?? McpEditorSettings.GetCustomPort();
|
||||
|
||||
/// <summary>
|
||||
/// Current recovery task. Can be awaited by other components to ensure recovery completes first.
|
||||
/// </summary>
|
||||
public static Task RecoveryTask => _currentRecoveryTask;
|
||||
|
||||
static McpServerController()
|
||||
{
|
||||
InitializeOnLoad();
|
||||
}
|
||||
|
||||
private static void InitializeOnLoad()
|
||||
{
|
||||
if (IsBackgroundUnityProcess())
|
||||
{
|
||||
VibeLogger.LogInfo("server_controller_background_skip", "Skipping MCP server controller initialization in background Unity process.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Register cleanup for when Unity exits.
|
||||
EditorApplication.quitting += OnEditorQuitting;
|
||||
|
||||
// Processing before assembly reload.
|
||||
AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload;
|
||||
|
||||
// Processing after assembly reload.
|
||||
AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload;
|
||||
|
||||
// Domain Reload disabled (Enter Play Mode Settings) causes static constructor re-entry
|
||||
McpBridgeServer.OnServerLoopExited -= OnServerLoopUnexpectedlyExited;
|
||||
McpBridgeServer.OnServerLoopExited += OnServerLoopUnexpectedlyExited;
|
||||
|
||||
// Initialize connected tools monitoring service
|
||||
// Note: ConnectedToolsMonitoringService has [InitializeOnLoad] so it's automatically initialized
|
||||
// This comment ensures the service initialization order is documented
|
||||
|
||||
// Recovery binds a TCP listener and may touch config files, so keep it off the
|
||||
// synchronous InitializeOnLoad path while preserving automatic startup.
|
||||
ScheduleStartupRecovery(
|
||||
action => EditorApplication.delayCall += () => action(),
|
||||
RestoreServerStateIfNeeded);
|
||||
}
|
||||
|
||||
internal static Task ScheduleStartupRecovery(
|
||||
Action<Action> scheduleDelayCall,
|
||||
Func<Task> restoreServerState)
|
||||
{
|
||||
Debug.Assert(scheduleDelayCall != null, "scheduleDelayCall must not be null");
|
||||
Debug.Assert(restoreServerState != null, "restoreServerState must not be null");
|
||||
|
||||
TaskCompletionSource<bool> scheduledRecoveryCompletionSource = new TaskCompletionSource<bool>();
|
||||
_currentRecoveryTask = scheduledRecoveryCompletionSource.Task;
|
||||
|
||||
scheduleDelayCall(() =>
|
||||
{
|
||||
Task restoreTask;
|
||||
try
|
||||
{
|
||||
restoreTask = restoreServerState();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CompleteScheduledStartupRecovery(Task.FromException(ex), scheduledRecoveryCompletionSource);
|
||||
return;
|
||||
}
|
||||
|
||||
if (restoreTask.IsCompleted)
|
||||
{
|
||||
CompleteScheduledStartupRecovery(restoreTask, scheduledRecoveryCompletionSource);
|
||||
return;
|
||||
}
|
||||
|
||||
_ = restoreTask.ContinueWith(task =>
|
||||
{
|
||||
CompleteScheduledStartupRecovery(task, scheduledRecoveryCompletionSource);
|
||||
}, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
});
|
||||
|
||||
return scheduledRecoveryCompletionSource.Task;
|
||||
}
|
||||
|
||||
private static void CompleteScheduledStartupRecovery(
|
||||
Task restoreTask,
|
||||
TaskCompletionSource<bool> scheduledRecoveryCompletionSource)
|
||||
{
|
||||
if (ReferenceEquals(_currentRecoveryTask, scheduledRecoveryCompletionSource.Task))
|
||||
{
|
||||
_currentRecoveryTask = null;
|
||||
}
|
||||
|
||||
if (restoreTask.IsCanceled)
|
||||
{
|
||||
scheduledRecoveryCompletionSource.SetCanceled();
|
||||
return;
|
||||
}
|
||||
|
||||
if (restoreTask.IsFaulted)
|
||||
{
|
||||
VibeLogger.LogError("server_startup_restore_failed",
|
||||
$"Failed to restore server: {restoreTask.Exception?.GetBaseException().Message}");
|
||||
scheduledRecoveryCompletionSource.SetException(restoreTask.Exception.GetBaseException());
|
||||
return;
|
||||
}
|
||||
|
||||
scheduledRecoveryCompletionSource.SetResult(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the server.
|
||||
/// </summary>
|
||||
/// <param name="port">
|
||||
/// The port number to bind to. Use -1 to fall back to the saved custom port
|
||||
/// from <see cref="McpEditorSettings.GetCustomPort"/>. Defaults to -1.
|
||||
/// </param>
|
||||
public static async void StartServer(int port = -1)
|
||||
{
|
||||
await StartServerWithUseCaseAsync(port);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the server using new UseCase implementation.
|
||||
/// </summary>
|
||||
private static async Task StartServerWithUseCaseAsync(int port)
|
||||
{
|
||||
if (IsBackgroundUnityProcess())
|
||||
{
|
||||
VibeLogger.LogInfo("server_start_ignored", "background_process");
|
||||
return;
|
||||
}
|
||||
|
||||
// Signal server is starting for CLI detection
|
||||
string serverStartingLockToken = CreateOptionalServerStartingLock();
|
||||
|
||||
bool startupLockReleasedByPrewarm = false;
|
||||
try
|
||||
{
|
||||
// Always stop the existing server first (to release the port)
|
||||
if (mcpServer != null)
|
||||
{
|
||||
await StopServerWithUseCaseAsync();
|
||||
}
|
||||
|
||||
DynamicCodeStartupTelemetry.Reset();
|
||||
DynamicCodeForegroundWarmupState.Reset();
|
||||
|
||||
// Execute initialization UseCase
|
||||
McpServerInitializationUseCase useCase = new();
|
||||
ServerInitializationSchema schema = new()
|
||||
{
|
||||
Port = port,
|
||||
PreserveStartupLockUntilExplicitRelease = true
|
||||
};
|
||||
System.Threading.CancellationToken cancellationToken = System.Threading.CancellationToken.None;
|
||||
|
||||
var result = await useCase.ExecuteAsync(schema, cancellationToken);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
// Error message already handled by UseCase
|
||||
UnityEngine.Debug.LogError($"Server startup failed: {result.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
// UseCase creates a new server instance, so we keep a reference here
|
||||
// for compatibility with existing code
|
||||
mcpServer = result.ServerInstance;
|
||||
|
||||
DynamicCodeStartupTelemetry.MarkServerReady();
|
||||
CustomToolManager.WarmupRegistry();
|
||||
DynamicCodeServices.ResetServerScopedServices();
|
||||
IPrewarmDynamicCodeUseCase prewarmDynamicCodeUseCase =
|
||||
await DynamicCodeServices.GetPrewarmDynamicCodeUseCaseAsync(serverStartingLockToken);
|
||||
prewarmDynamicCodeUseCase.Request();
|
||||
startupLockReleasedByPrewarm = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!startupLockReleasedByPrewarm)
|
||||
{
|
||||
ServerStartingLockService.DeleteOwnedLockFile(serverStartingLockToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the server.
|
||||
/// </summary>
|
||||
public static async void StopServer()
|
||||
{
|
||||
await StopServerWithUseCaseAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the server using new UseCase implementation.
|
||||
/// </summary>
|
||||
private static async Task StopServerWithUseCaseAsync()
|
||||
{
|
||||
if (IsBackgroundUnityProcess())
|
||||
{
|
||||
VibeLogger.LogInfo("server_stop_ignored", "background_process");
|
||||
return;
|
||||
}
|
||||
|
||||
ClearStartupProtection();
|
||||
|
||||
// Execute shutdown UseCase
|
||||
McpServerShutdownUseCase useCase = new(new McpServerStartupService());
|
||||
ServerShutdownSchema schema = new() { ForceShutdown = false };
|
||||
System.Threading.CancellationToken cancellationToken = System.Threading.CancellationToken.None;
|
||||
|
||||
var result = await useCase.ExecuteAsync(schema, cancellationToken);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
// Server stopped by UseCase, so clear the reference
|
||||
mcpServer = null;
|
||||
|
||||
// Clear session state to reflect server stopped
|
||||
McpEditorSettings.ClearServerSession();
|
||||
DynamicCodeStartupTelemetry.Reset();
|
||||
DynamicCodeForegroundWarmupState.Reset();
|
||||
DynamicCodeServices.ResetServerScopedServices();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Error message already handled by UseCase
|
||||
UnityEngine.Debug.LogError($"Server shutdown failed: {result.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processing before assembly reload.
|
||||
/// </summary>
|
||||
private static void OnBeforeAssemblyReload()
|
||||
{
|
||||
ClearStartupProtection();
|
||||
|
||||
// Create and execute DomainReloadRecoveryUseCase instance
|
||||
DomainReloadRecoveryUseCase useCase = new();
|
||||
ServiceResult<string> result = useCase.ExecuteBeforeDomainReload(mcpServer);
|
||||
|
||||
// Clear instance if server shutdown succeeded
|
||||
if (result.Success)
|
||||
{
|
||||
mcpServer = null;
|
||||
}
|
||||
|
||||
DynamicCodeStartupTelemetry.Reset();
|
||||
DynamicCodeForegroundWarmupState.Reset();
|
||||
DynamicCodeServices.ResetServerScopedServicesBeforeDomainReload();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processing after assembly reload.
|
||||
/// </summary>
|
||||
private static void OnAfterAssemblyReload()
|
||||
{
|
||||
// Create and execute DomainReloadRecoveryUseCase instance
|
||||
DomainReloadRecoveryUseCase useCase = new();
|
||||
_ = useCase.ExecuteAfterDomainReloadAsync(System.Threading.CancellationToken.None).ContinueWith(task =>
|
||||
{
|
||||
if (task.IsFaulted)
|
||||
{
|
||||
UnityEngine.Debug.LogError($"Domain reload recovery failed: {task.Exception}");
|
||||
}
|
||||
}, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
|
||||
// Synchronize MCP editor configurations with current debug symbol state after domain reload
|
||||
// Use delayCall to ensure the editor is fully initialized before file I/O
|
||||
EditorApplication.delayCall += () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
McpDebugStateUpdater.UpdateAllConfigurationsForDebugState();
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
UnityEngine.Debug.LogWarning($"MCP debug-state configuration sync failed: {ex.Message}");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores the server state if necessary.
|
||||
/// </summary>
|
||||
private static Task RestoreServerStateIfNeeded()
|
||||
{
|
||||
if (IsBackgroundUnityProcess())
|
||||
{
|
||||
VibeLogger.LogInfo("server_restore_skipped", "background_process");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
int savedPort = McpEditorSettings.GetCustomPort();
|
||||
bool isAfterCompile = McpEditorSettings.GetIsAfterCompile();
|
||||
|
||||
if (mcpServer?.IsRunning == true)
|
||||
{
|
||||
if (isAfterCompile)
|
||||
{
|
||||
McpEditorSettings.ClearAfterCompileFlag();
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (isAfterCompile)
|
||||
{
|
||||
McpEditorSettings.ClearAfterCompileFlag();
|
||||
}
|
||||
|
||||
int portToUse = savedPort;
|
||||
|
||||
// Centralized, coalesced startup request
|
||||
// Store the task so McpEditorWindow can await it to prevent race conditions
|
||||
return StartRecoveryIfNeededAsync(portToUse, isAfterCompile, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes server recovery with retries on the original port.
|
||||
/// Does not change the port number; only attempts recovery on the specified port.
|
||||
/// </summary>
|
||||
private static void TryRestoreServerWithRetry(int port, int retryCount)
|
||||
{
|
||||
const int maxRetries = 3;
|
||||
|
||||
try
|
||||
{
|
||||
// If there is an existing server instance, ensure it is stopped.
|
||||
if (mcpServer != null)
|
||||
{
|
||||
mcpServer.Dispose();
|
||||
mcpServer = null;
|
||||
}
|
||||
|
||||
// Try to start server on the requested port only
|
||||
mcpServer = new McpBridgeServer();
|
||||
mcpServer.StartServer(port);
|
||||
|
||||
// Update settings with the actual port used (same as requested)
|
||||
if (McpEditorSettings.GetCustomPort() != port)
|
||||
{
|
||||
McpEditorSettings.SetCustomPort(port);
|
||||
}
|
||||
|
||||
// Clear server-side reconnecting flag on successful restoration
|
||||
// NOTE: Do NOT clear UI display flag here - let it be cleared by timeout or client connection
|
||||
McpEditorSettings.SetIsReconnecting(false);
|
||||
|
||||
// Tools changed notification will be sent by OnAfterAssemblyReload
|
||||
}
|
||||
catch (System.Exception)
|
||||
{
|
||||
// If the maximum number of retries has not been reached, try again.
|
||||
if (retryCount < maxRetries)
|
||||
{
|
||||
// Wait for port release before retry
|
||||
RetryServerRestoreAsync(port, retryCount).Forget();
|
||||
}
|
||||
else
|
||||
{
|
||||
// If it ultimately fails, clear the SessionState.
|
||||
McpEditorSettings.ClearServerSession();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prevent CLI from misdetecting a busy state when server startup is intentionally skipped.
|
||||
/// </summary>
|
||||
private static void DeleteAllLockFiles()
|
||||
{
|
||||
CompilationLockService.DeleteLockFile();
|
||||
DomainReloadDetectionService.DeleteLockFile();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleanup on Unity exit.
|
||||
/// Disposes the TCP listener and marks the server as stopped so the CLI
|
||||
/// does not attempt to connect to a stale port after the editor closes.
|
||||
/// </summary>
|
||||
private static void OnEditorQuitting()
|
||||
{
|
||||
if (mcpServer != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
mcpServer.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
mcpServer = null;
|
||||
}
|
||||
}
|
||||
DynamicCodeForegroundWarmupState.Reset();
|
||||
DynamicCodeServices.ResetServerScopedServices();
|
||||
McpEditorSettings.ClearServerSession();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OnServerLoopExited fires from the thread pool, but Unity APIs (EditorSettings,
|
||||
/// VibeLogger with SerializedObject, etc.) are main-thread-only.
|
||||
/// EditorApplication.delayCall marshals the recovery to the next editor tick.
|
||||
/// </summary>
|
||||
private static void OnServerLoopUnexpectedlyExited()
|
||||
{
|
||||
// OnServerLoopExited fires from thread pool — marshal to main thread for Unity API safety
|
||||
EditorApplication.delayCall += () =>
|
||||
{
|
||||
ClearStartupProtection();
|
||||
|
||||
VibeLogger.LogWarning(
|
||||
"server_loop_exit_detected",
|
||||
"Detected unexpected server loop exit. Initiating automatic recovery.",
|
||||
new { port = mcpServer?.Port }
|
||||
);
|
||||
|
||||
int portToRecover = mcpServer?.Port ?? McpEditorSettings.GetCustomPort();
|
||||
|
||||
// Resources already cleaned up by CleanupAfterUnexpectedLoopExit — just clear the reference
|
||||
mcpServer = null;
|
||||
|
||||
// The server just crashed — startup protection blocks recovery if the crash happens
|
||||
// within the 5-second protection window after a successful start
|
||||
System.Threading.Volatile.Write(ref startupProtectionUntilTicks, 0L);
|
||||
|
||||
_currentRecoveryTask = StartRecoveryIfNeededAsync(portToRecover, false, CancellationToken.None);
|
||||
_ = _currentRecoveryTask.ContinueWith(task =>
|
||||
{
|
||||
if (ReferenceEquals(_currentRecoveryTask, task))
|
||||
{
|
||||
_currentRecoveryTask = null;
|
||||
}
|
||||
if (task.IsFaulted)
|
||||
{
|
||||
VibeLogger.LogError(
|
||||
"server_auto_recovery_failed",
|
||||
$"Automatic recovery after unexpected exit failed: {task.Exception?.GetBaseException().Message}"
|
||||
);
|
||||
McpEditorSettings.ClearServerSession();
|
||||
}
|
||||
}, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes pending compile requests.
|
||||
/// </summary>
|
||||
private static void ProcessPendingCompileRequests()
|
||||
{
|
||||
// Temporarily disabled to avoid main thread errors due to SessionState operations.
|
||||
// TODO: Re-enable after resolving the main thread issue.
|
||||
// CompileSessionState.StartForcedRecompile();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets server status information.
|
||||
/// </summary>
|
||||
public static (bool isRunning, int port, bool wasRestoredFromSession) GetServerStatus()
|
||||
{
|
||||
bool wasRestored = McpEditorSettings.GetIsServerRunning();
|
||||
return (IsServerRunning, ServerPort, wasRestored);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send tools changed notification to TypeScript side
|
||||
/// </summary>
|
||||
private static void SendToolsChangedNotification()
|
||||
{
|
||||
// Log with stack trace to identify caller
|
||||
System.Diagnostics.StackTrace stackTrace = new System.Diagnostics.StackTrace(true);
|
||||
string callerInfo = stackTrace.GetFrame(1)?.GetMethod()?.Name ?? "Unknown";
|
||||
|
||||
if (mcpServer == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Send MCP standard notification only
|
||||
var notificationParams = new
|
||||
{
|
||||
timestamp = System.DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"),
|
||||
message = "Unity tools have been updated"
|
||||
};
|
||||
|
||||
var mcpNotification = new
|
||||
{
|
||||
jsonrpc = McpServerConfig.JSONRPC_VERSION,
|
||||
method = "notifications/tools/list_changed",
|
||||
@params = notificationParams
|
||||
};
|
||||
|
||||
string mcpNotificationJson = JsonConvert.SerializeObject(mcpNotification);
|
||||
mcpServer.SendNotificationToClients(mcpNotificationJson);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manually trigger tool change notification
|
||||
/// Public method for external calls (e.g., from UnityToolRegistry)
|
||||
/// </summary>
|
||||
public static void TriggerToolChangeNotification()
|
||||
{
|
||||
if (IsServerRunning)
|
||||
{
|
||||
SendToolsChangedNotification();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send tool notification after compilation with frame delay
|
||||
/// </summary>
|
||||
private static async Task SendToolNotificationAfterCompilationAsync()
|
||||
{
|
||||
// Use frame delay for timing adjustment after domain reload
|
||||
// This ensures Unity Editor is in a stable state before sending notifications
|
||||
await EditorDelay.DelayFrame(1);
|
||||
|
||||
CustomToolManager.NotifyToolChanges();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restore server after compilation with frame delay.
|
||||
/// Currently kept as a helper; recovery logic is unified in StartRecoveryIfNeededAsync.
|
||||
/// </summary>
|
||||
private static async Task RestoreServerAfterCompileAsync(int port)
|
||||
{
|
||||
// Wait a short while for timing adjustment (TCP port release)
|
||||
await EditorDelay.DelayFrame(1);
|
||||
|
||||
TryRestoreServerWithRetry(port, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restore server on startup with frame delay
|
||||
/// </summary>
|
||||
private static async Task RestoreServerOnStartupAsync(int port)
|
||||
{
|
||||
// Wait for Unity Editor to be ready before auto-starting
|
||||
await EditorDelay.DelayFrame(1);
|
||||
_ = StartRecoveryIfNeededAsync(port, false, CancellationToken.None).ContinueWith(task =>
|
||||
{
|
||||
if (task.IsFaulted)
|
||||
{
|
||||
VibeLogger.LogError("server_startup_restore_failed",
|
||||
$"Failed to restore server: {task.Exception?.GetBaseException().Message}");
|
||||
}
|
||||
}, TaskScheduler.FromCurrentSynchronizationContext());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retry server restore with frame delay on the same port.
|
||||
/// </summary>
|
||||
private static async Task RetryServerRestoreAsync(int port, int retryCount)
|
||||
{
|
||||
// Wait longer for port release before retry
|
||||
await EditorDelay.DelayFrame(5);
|
||||
TryRestoreServerWithRetry(port, retryCount + 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start UI display timeout timer for reconnecting message
|
||||
/// </summary>
|
||||
private static async Task StartReconnectionUITimeoutAsync()
|
||||
{
|
||||
// Wait for the timeout period (convert seconds to frames at ~60fps)
|
||||
int timeoutFrames = McpConstants.RECONNECTION_TIMEOUT_SECONDS * 60;
|
||||
await EditorDelay.DelayFrame(timeoutFrames);
|
||||
|
||||
// Check if UI flag is still set after timeout
|
||||
bool isStillShowingUI = McpEditorSettings.GetShowReconnectingUI();
|
||||
if (isStillShowingUI)
|
||||
{
|
||||
McpEditorSettings.ClearReconnectingFlags();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear reconnecting flags when client connects
|
||||
/// Called by UI or bridge server when client connection is detected
|
||||
/// </summary>
|
||||
public static void ClearReconnectingFlag()
|
||||
{
|
||||
bool wasReconnecting = McpEditorSettings.GetIsReconnecting();
|
||||
bool wasShowingUI = McpEditorSettings.GetShowReconnectingUI();
|
||||
|
||||
if (wasReconnecting || wasShowingUI)
|
||||
{
|
||||
McpEditorSettings.ClearReconnectingFlags();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds an available port starting from the given port number
|
||||
/// Delegates to NetworkUtility for consistent port finding behavior.
|
||||
/// </summary>
|
||||
/// <param name="startPort">The starting port number to check</param>
|
||||
/// <returns>The first available port number</returns>
|
||||
private static int FindAvailablePort(int startPort)
|
||||
{
|
||||
return NetworkUtility.FindAvailablePort(startPort);
|
||||
}
|
||||
|
||||
private static bool TryFindFallbackPort(int currentPort, out int fallbackPort)
|
||||
{
|
||||
const int maxAttempts = 10;
|
||||
|
||||
for (int offset = 1; offset <= maxAttempts; offset++)
|
||||
{
|
||||
int candidatePort = currentPort + offset;
|
||||
if (!McpPortValidator.ValidatePort(candidatePort, "for recovery fallback"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (NetworkUtility.IsPortInUse(candidatePort))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
fallbackPort = candidatePort;
|
||||
return true;
|
||||
}
|
||||
|
||||
fallbackPort = currentPort;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void LogRecoveryFallback(int sourcePort, int fallbackPort, string reason)
|
||||
{
|
||||
string message = $"Recovery fallback activated: {sourcePort} -> {fallbackPort} ({reason})";
|
||||
VibeLogger.LogWarning("recovery_port_fallback", message);
|
||||
Debug.LogWarning($"[{McpConstants.PROJECT_NAME}] {message}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates server configuration before starting
|
||||
/// Implements fail-fast behavior for invalid configurations
|
||||
/// </summary>
|
||||
private static void ValidateServerConfiguration(int port)
|
||||
{
|
||||
// Validate port number using shared validator
|
||||
if (!McpPortValidator.ValidatePort(port, "for MCP server"))
|
||||
{
|
||||
throw new System.ArgumentOutOfRangeException(nameof(port),
|
||||
$"Port number must be between 1 and 65535. Received: {port}");
|
||||
}
|
||||
|
||||
// Validate Unity Editor state
|
||||
if (EditorApplication.isCompiling)
|
||||
{
|
||||
throw new System.InvalidOperationException(
|
||||
"Cannot start MCP server while Unity is compiling. Please wait for compilation to complete.");
|
||||
}
|
||||
|
||||
// Server configuration validation passed
|
||||
// Note: Port availability and system port conflicts are handled by FindAvailablePort
|
||||
}
|
||||
|
||||
public static bool IsStartupProtectionActive()
|
||||
{
|
||||
long nowTicks = DateTime.UtcNow.Ticks;
|
||||
return nowTicks < System.Threading.Volatile.Read(ref startupProtectionUntilTicks);
|
||||
}
|
||||
|
||||
private static void ActivateStartupProtection(int milliseconds)
|
||||
{
|
||||
long untilTicks = DateTime.UtcNow.AddMilliseconds(milliseconds).Ticks;
|
||||
System.Threading.Volatile.Write(ref startupProtectionUntilTicks, untilTicks);
|
||||
VibeLogger.LogInfo("startup_protection_active", $"window={milliseconds}ms");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears startup protection so recovery paths can restart the server immediately.
|
||||
/// </summary>
|
||||
private static void ClearStartupProtection()
|
||||
{
|
||||
System.Threading.Volatile.Write(ref startupProtectionUntilTicks, 0L);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Centralized, coalesced recovery start.
|
||||
/// Attempts recovery on the specified port for up to 5 seconds without changing the port number.
|
||||
/// </summary>
|
||||
public static async Task StartRecoveryIfNeededAsync(int savedPort, bool isAfterCompile, CancellationToken cancellationToken)
|
||||
{
|
||||
if (IsBackgroundUnityProcess())
|
||||
{
|
||||
VibeLogger.LogInfo("server_start_ignored", "background_process");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure stale reload locks are cleaned up before recovery.
|
||||
// Why not clear serverstarting.lock here: a previous generation may still be finishing
|
||||
// and ownership is now tracked per startup token below.
|
||||
DomainReloadDetectionService.DeleteLockFile();
|
||||
CompilationLockService.DeleteLockFile();
|
||||
|
||||
VibeLogger.LogInfo("startup_request", $"port={savedPort}");
|
||||
|
||||
if (IsStartupProtectionActive())
|
||||
{
|
||||
VibeLogger.LogInfo("server_start_ignored", "startup_protection_active");
|
||||
return;
|
||||
}
|
||||
|
||||
await StartupSemaphore.WaitAsync(cancellationToken);
|
||||
string serverStartingLockToken = null;
|
||||
try
|
||||
{
|
||||
// If any server is already running, ignore this request to prevent double-binding
|
||||
if (mcpServer != null && mcpServer.IsRunning)
|
||||
{
|
||||
VibeLogger.LogInfo("server_start_ignored", $"already_running port={mcpServer.Port}");
|
||||
return;
|
||||
}
|
||||
|
||||
serverStartingLockToken = CreateOptionalServerStartingLock();
|
||||
|
||||
// Ensure previous instance is fully disposed before trying to bind a new one
|
||||
if (mcpServer != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
mcpServer.Dispose();
|
||||
VibeLogger.LogInfo("server_disposed_before_bind", "disposed previous server instance");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
VibeLogger.LogWarning("server_dispose_failed", ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
mcpServer = null;
|
||||
}
|
||||
}
|
||||
|
||||
int chosenPort = savedPort;
|
||||
bool savedPortInUse = NetworkUtility.IsPortInUse(savedPort);
|
||||
if (savedPortInUse && TryFindFallbackPort(savedPort, out int preBindFallbackPort))
|
||||
{
|
||||
chosenPort = preBindFallbackPort;
|
||||
LogRecoveryFallback(savedPort, chosenPort, "saved_port_in_use_before_bind");
|
||||
}
|
||||
|
||||
bool started = await TryBindWithWaitAsync(
|
||||
chosenPort,
|
||||
5000,
|
||||
250,
|
||||
cancellationToken,
|
||||
clearServerStartingLockWhenReady: false);
|
||||
|
||||
if (!started)
|
||||
{
|
||||
if (TryFindFallbackPort(chosenPort, out int fallbackPort) && fallbackPort != chosenPort)
|
||||
{
|
||||
int previousAttemptPort = chosenPort;
|
||||
chosenPort = fallbackPort;
|
||||
LogRecoveryFallback(previousAttemptPort, chosenPort, "bind_retry_timeout");
|
||||
started = await TryBindWithWaitAsync(
|
||||
chosenPort,
|
||||
5000,
|
||||
250,
|
||||
cancellationToken,
|
||||
clearServerStartingLockWhenReady: false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!started)
|
||||
{
|
||||
// Ensure session reflects stopped state on failure
|
||||
McpEditorSettings.ClearServerSession();
|
||||
McpEditorSettings.ClearReconnectingFlags();
|
||||
Debug.LogError($"[{McpConstants.PROJECT_NAME}] Recovery failed: no available port to bind. SavedPort={savedPort}, LastAttemptPort={chosenPort}");
|
||||
throw new InvalidOperationException($"Failed to bind any recovery port. SavedPort={savedPort}, LastAttemptPort={chosenPort}.");
|
||||
}
|
||||
|
||||
// Auto-update configuration files after startup.
|
||||
// This keeps external editor settings aligned with path updates and recovery port fallback.
|
||||
try
|
||||
{
|
||||
McpConfigAutoUpdater.UpdateAllConfiguredEditors(chosenPort);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
VibeLogger.LogWarning("config_auto_update_failed", $"Failed to auto-update configurations: {ex.Message}");
|
||||
Debug.LogWarning($"[{McpConstants.PROJECT_NAME}] Failed to auto-update configurations: {ex.Message}");
|
||||
// Continue with running server even if config update fails
|
||||
}
|
||||
|
||||
// Mark running and update settings
|
||||
SaveRunningServerSession(chosenPort);
|
||||
|
||||
// Clear reconnection-related flags on successful recovery
|
||||
McpEditorSettings.ClearReconnectingFlags();
|
||||
McpEditorSettings.ClearPostCompileReconnectingUI();
|
||||
DynamicCodeStartupTelemetry.MarkServerReady();
|
||||
CustomToolManager.WarmupRegistry();
|
||||
DynamicCodeServices.ResetServerScopedServices();
|
||||
IPrewarmDynamicCodeUseCase prewarmDynamicCodeUseCase =
|
||||
await DynamicCodeServices.GetPrewarmDynamicCodeUseCaseAsync(serverStartingLockToken);
|
||||
prewarmDynamicCodeUseCase.Request();
|
||||
|
||||
ActivateStartupProtection(5000);
|
||||
}
|
||||
catch
|
||||
{
|
||||
ServerStartingLockService.DeleteOwnedLockFile(serverStartingLockToken);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
StartupSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> TryBindWithWaitAsync(
|
||||
int port,
|
||||
int maxWaitMs,
|
||||
int stepMs,
|
||||
CancellationToken cancellationToken,
|
||||
bool clearServerStartingLockWhenReady = true)
|
||||
{
|
||||
int remainingMs = maxWaitMs;
|
||||
while (true)
|
||||
{
|
||||
VibeLogger.LogInfo("binding_attempt", $"port={port}");
|
||||
McpBridgeServer server = null;
|
||||
try
|
||||
{
|
||||
// Defensive: dispose any non-running stale instance before creating a new one
|
||||
if (mcpServer != null && !mcpServer.IsRunning)
|
||||
{
|
||||
try
|
||||
{
|
||||
mcpServer.Dispose();
|
||||
VibeLogger.LogInfo("server_disposed_before_bind", "disposed stale instance");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
VibeLogger.LogWarning("server_dispose_failed", ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
mcpServer = null;
|
||||
}
|
||||
}
|
||||
|
||||
server = new McpBridgeServer();
|
||||
server.StartServer(port, clearServerStartingLockWhenReady);
|
||||
mcpServer = server;
|
||||
VibeLogger.LogInfo("binding_success", $"port={port}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Ensure partially created server is cleaned up on failure
|
||||
try { server?.Dispose(); } catch { }
|
||||
// Unwrap SocketException details if present
|
||||
SocketException sockEx = ex as SocketException;
|
||||
if (ex is InvalidOperationException && ex.InnerException is SocketException innerSock)
|
||||
{
|
||||
sockEx = innerSock;
|
||||
}
|
||||
|
||||
if (sockEx != null)
|
||||
{
|
||||
VibeLogger.LogWarning("binding_failed", $"port={port} code={sockEx.SocketErrorCode} hresult={sockEx.HResult} native={sockEx.ErrorCode}");
|
||||
}
|
||||
else
|
||||
{
|
||||
VibeLogger.LogWarning("binding_failed", $"port={port} code=Unknown hresult={ex.HResult}");
|
||||
}
|
||||
|
||||
if (remainingMs <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int delay = stepMs <= 0 ? remainingMs : Math.Min(stepMs, remainingMs);
|
||||
await TimerDelay.Wait(delay, cancellationToken);
|
||||
remainingMs -= delay;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void SaveRunningServerSession(int port)
|
||||
{
|
||||
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
|
||||
string serverSessionId = Guid.NewGuid().ToString("N");
|
||||
McpEditorSettings.SetRunningServerSession(port, projectRoot, serverSessionId);
|
||||
}
|
||||
|
||||
internal static string CreateOptionalServerStartingLock(Func<string> createLockFile = null)
|
||||
{
|
||||
Func<string> createLockFileCore = createLockFile ?? ServerStartingLockService.CreateLockFile;
|
||||
string serverStartingLockToken = createLockFileCore();
|
||||
if (!string.IsNullOrEmpty(serverStartingLockToken))
|
||||
{
|
||||
return serverStartingLockToken;
|
||||
}
|
||||
|
||||
// Why: serverstarting.lock only improves busy diagnostics for external callers; the
|
||||
// listener itself can still start and recover safely without it.
|
||||
// Why not fail fast here: a transient file lock would otherwise turn an optional
|
||||
// readiness hint into a full startup outage for launch and recovery paths.
|
||||
VibeLogger.LogWarning(
|
||||
"server_starting_lock_optional",
|
||||
"Proceeding without serverstarting.lock because the readiness hint could not be created.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user