959 lines
38 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|