Files
ft/Client/Packages/io.github.hatayama.uloopmcp/Editor/Server/McpServerController.cs

959 lines
38 KiB
C#

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;
}
}
}