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. /// /// Manages the state of the MCP Server with SessionState and automatically restores it on assembly reload. /// [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; } /// /// The current MCP server instance. /// public static McpBridgeServer CurrentServer => mcpServer; /// /// Whether the server is running. /// public static bool IsServerRunning => mcpServer?.IsRunning ?? false; /// /// The server's port number. /// public static int ServerPort => mcpServer?.Port ?? McpEditorSettings.GetCustomPort(); /// /// Current recovery task. Can be awaited by other components to ensure recovery completes first. /// 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 scheduleDelayCall, Func restoreServerState) { Debug.Assert(scheduleDelayCall != null, "scheduleDelayCall must not be null"); Debug.Assert(restoreServerState != null, "restoreServerState must not be null"); TaskCompletionSource scheduledRecoveryCompletionSource = new TaskCompletionSource(); _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 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); } /// /// Starts the server. /// /// /// The port number to bind to. Use -1 to fall back to the saved custom port /// from . Defaults to -1. /// public static async void StartServer(int port = -1) { await StartServerWithUseCaseAsync(port); } /// /// Starts the server using new UseCase implementation. /// 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); } } } /// /// Stops the server. /// public static async void StopServer() { await StopServerWithUseCaseAsync(); } /// /// Stops the server using new UseCase implementation. /// 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}"); } } /// /// Processing before assembly reload. /// private static void OnBeforeAssemblyReload() { ClearStartupProtection(); // Create and execute DomainReloadRecoveryUseCase instance DomainReloadRecoveryUseCase useCase = new(); ServiceResult result = useCase.ExecuteBeforeDomainReload(mcpServer); // Clear instance if server shutdown succeeded if (result.Success) { mcpServer = null; } DynamicCodeStartupTelemetry.Reset(); DynamicCodeForegroundWarmupState.Reset(); DynamicCodeServices.ResetServerScopedServicesBeforeDomainReload(); } /// /// Processing after assembly reload. /// 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}"); } }; } /// /// Restores the server state if necessary. /// 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); } /// /// Executes server recovery with retries on the original port. /// Does not change the port number; only attempts recovery on the specified port. /// 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(); } } } /// /// Prevent CLI from misdetecting a busy state when server startup is intentionally skipped. /// private static void DeleteAllLockFiles() { CompilationLockService.DeleteLockFile(); DomainReloadDetectionService.DeleteLockFile(); } /// /// 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. /// private static void OnEditorQuitting() { if (mcpServer != null) { try { mcpServer.Dispose(); } finally { mcpServer = null; } } DynamicCodeForegroundWarmupState.Reset(); DynamicCodeServices.ResetServerScopedServices(); McpEditorSettings.ClearServerSession(); } /// /// 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. /// 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()); }; } /// /// Processes pending compile requests. /// 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(); } /// /// Gets server status information. /// public static (bool isRunning, int port, bool wasRestoredFromSession) GetServerStatus() { bool wasRestored = McpEditorSettings.GetIsServerRunning(); return (IsServerRunning, ServerPort, wasRestored); } /// /// Send tools changed notification to TypeScript side /// 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); } /// /// Manually trigger tool change notification /// Public method for external calls (e.g., from UnityToolRegistry) /// public static void TriggerToolChangeNotification() { if (IsServerRunning) { SendToolsChangedNotification(); } } /// /// Send tool notification after compilation with frame delay /// 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(); } /// /// Restore server after compilation with frame delay. /// Currently kept as a helper; recovery logic is unified in StartRecoveryIfNeededAsync. /// private static async Task RestoreServerAfterCompileAsync(int port) { // Wait a short while for timing adjustment (TCP port release) await EditorDelay.DelayFrame(1); TryRestoreServerWithRetry(port, 0); } /// /// Restore server on startup with frame delay /// 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()); } /// /// Retry server restore with frame delay on the same port. /// private static async Task RetryServerRestoreAsync(int port, int retryCount) { // Wait longer for port release before retry await EditorDelay.DelayFrame(5); TryRestoreServerWithRetry(port, retryCount + 1); } /// /// Start UI display timeout timer for reconnecting message /// 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(); } } /// /// Clear reconnecting flags when client connects /// Called by UI or bridge server when client connection is detected /// public static void ClearReconnectingFlag() { bool wasReconnecting = McpEditorSettings.GetIsReconnecting(); bool wasShowingUI = McpEditorSettings.GetShowReconnectingUI(); if (wasReconnecting || wasShowingUI) { McpEditorSettings.ClearReconnectingFlags(); } } /// /// Finds an available port starting from the given port number /// Delegates to NetworkUtility for consistent port finding behavior. /// /// The starting port number to check /// The first available port number 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}"); } /// /// Validates server configuration before starting /// Implements fail-fast behavior for invalid configurations /// 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"); } /// /// Clears startup protection so recovery paths can restart the server immediately. /// private static void ClearStartupProtection() { System.Threading.Volatile.Write(ref startupProtectionUntilTicks, 0L); } /// /// Centralized, coalesced recovery start. /// Attempts recovery on the specified port for up to 5 seconds without changing the port number. /// 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 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 createLockFile = null) { Func 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; } } }