diff --git a/Assets/Tests/Editor/StaticFacadeStateGuardTests.cs b/Assets/Tests/Editor/StaticFacadeStateGuardTests.cs index 88a9d9860b..68f46a0190 100644 --- a/Assets/Tests/Editor/StaticFacadeStateGuardTests.cs +++ b/Assets/Tests/Editor/StaticFacadeStateGuardTests.cs @@ -68,7 +68,8 @@ public sealed class StaticFacadeStateGuardTests "Packages/src/Editor/FirstPartyTools/SimulateKeyboard/SimulateKeyboardUseCase.cs", "Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputMainThreadCleanup.cs", "Packages/src/Editor/FirstPartyTools/SimulateKeyboard/KeyboardInputActionExecutor.cs", - "Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeMissingReturnRetryPolicy.cs" + "Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/DynamicCodeMissingReturnRetryPolicy.cs", + "Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerRecoveryExecutor.cs" }; private static readonly Dictionary OverloadGuardMethodsByPath = new Dictionary diff --git a/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs b/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs index 6041a59791..29b1a983b8 100644 --- a/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs +++ b/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs @@ -1,9 +1,7 @@ using System; using System.Threading; using System.Threading.Tasks; -using System.Net.Sockets; using UnityEditor; -using UnityEngine; using io.github.hatayama.UnityCliLoop.Application; using io.github.hatayama.UnityCliLoop.Domain; @@ -38,7 +36,7 @@ private enum ServerStopIntent private readonly UnityCliLoopServerRecoveryTrackingService _recoveryTrackingService; private readonly IUnityCliLoopServerDomainReloadLifecycle _domainReloadLifecycle; private IUnityCliLoopServerInstance _bridgeServer; - private readonly SemaphoreSlim _startupSemaphore = new SemaphoreSlim(1, 1); + private readonly UnityCliLoopServerRecoveryExecutor _recoveryExecutor; internal UnityCliLoopServerControllerService( IUnityCliLoopServerInstanceFactory serverInstanceFactory, @@ -82,9 +80,18 @@ internal UnityCliLoopServerControllerService( _startupProtectionService = startupProtectionService ?? throw new ArgumentNullException(nameof(startupProtectionService)); _recoveryTrackingService = recoveryTrackingService ?? throw new ArgumentNullException(nameof(recoveryTrackingService)); _domainReloadLifecycle = domainReloadLifecycle ?? throw new ArgumentNullException(nameof(domainReloadLifecycle)); + + _recoveryExecutor = new UnityCliLoopServerRecoveryExecutor( + serverInstanceFactory, + readinessService, + startupProtectionService, + sessionFlagsRepository, + toolRegistrarService, + () => _bridgeServer, + server => _bridgeServer = server); } - private bool IsBackgroundUnityProcess() + internal static bool IsBackgroundUnityProcess() { bool isAssetImportWorker = AssetDatabase.IsAssetImportWorkerProcess(); return isAssetImportWorker; @@ -363,158 +370,9 @@ private void OnServerLoopUnexpectedlyExited() /// Centralized, coalesced recovery start. /// Attempts recovery on the project IPC endpoint for up to 5 seconds. /// - public async Task StartRecoveryIfNeededAsync(bool isAfterCompile, CancellationToken cancellationToken) - { - if (IsBackgroundUnityProcess()) - { - VibeLogger.LogInfo("server_start_ignored", "background_process"); - return; - } - - if (_startupProtectionService.IsStartupProtectionActive()) - { - VibeLogger.LogInfo("server_start_ignored", "startup_protection_active"); - if (_bridgeServer?.IsRunning == true) - { - await _readinessService.MarkServerReadyAsync("startup-protection-active", cancellationToken); - return; - } - - return; - } - - VibeLogger.LogInfo("startup_request", "transport=project_ipc"); - - await _startupSemaphore.WaitAsync(cancellationToken); - try - { - // If any server is already running, ignore this request to prevent double-binding - if (_bridgeServer != null && _bridgeServer.IsRunning) - { - VibeLogger.LogInfo("server_start_ignored", $"already_running endpoint={_bridgeServer.Endpoint}"); - await _readinessService.MarkServerReadyAsync("already-running", cancellationToken); - return; - } - - // Ensure previous instance is fully disposed before trying to bind a new one - if (_bridgeServer != null) - { - try - { - _bridgeServer.Dispose(); - VibeLogger.LogInfo("server_disposed_before_bind", "disposed previous server instance"); - } - catch (Exception ex) - { - VibeLogger.LogWarning("server_dispose_failed", ex.Message); - } - finally - { - _bridgeServer = null; - } - } - - bool started = await TryBindWithWaitAsync( - 5000, - 250, - cancellationToken); - - if (!started) - { - // Ensure session reflects stopped state on failure - _sessionFlagsRepository.ClearServerSession(); - _sessionFlagsRepository.ClearReconnectingFlags(); - string message = "Unity CLI Loop server recovery failed because the project IPC endpoint could not be bound within 5000ms."; - Debug.LogError($"[{UnityCliLoopConstants.PROJECT_NAME}] {message}"); - throw new InvalidOperationException(message); - } - - // Mark running and update settings - SaveRunningServerState(); - - // Clear reconnection-related flags on successful recovery - _sessionFlagsRepository.ClearReconnectingFlags(); - _sessionFlagsRepository.ClearPostCompileReconnectingUI(); - _toolRegistrarService.WarmupRegistry(); - await _readinessService.MarkServerReadyAsync("server-recovery", cancellationToken); - - _startupProtectionService.ActivateStartupProtection(5000); - } - finally - { - _startupSemaphore.Release(); - } - } - - private async Task TryBindWithWaitAsync( - int maxWaitMs, - int stepMs, - CancellationToken cancellationToken) + public Task StartRecoveryIfNeededAsync(bool isAfterCompile, CancellationToken cancellationToken) { - int remainingMs = maxWaitMs; - while (true) - { - VibeLogger.LogInfo("binding_attempt", "transport=project_ipc"); - IUnityCliLoopServerInstance server = null; - try - { - // Defensive: dispose any non-running stale instance before creating a new one - if (_bridgeServer != null && !_bridgeServer.IsRunning) - { - try - { - _bridgeServer.Dispose(); - VibeLogger.LogInfo("server_disposed_before_bind", "disposed stale instance"); - } - catch (Exception ex) - { - VibeLogger.LogWarning("server_dispose_failed", ex.Message); - } - finally - { - _bridgeServer = null; - } - } - - server = _serverInstanceFactory.Create(); - server.StartServer(); - _bridgeServer = server; - VibeLogger.LogInfo( - "binding_success", - "Unity CLI Loop server bound the project IPC endpoint.", - new { endpoint = server.Endpoint }); - return true; - } - catch (Exception ex) - { - // Ensure partially created server is cleaned up on failure - server?.Dispose(); - // 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", $"target=project_ipc code={sockEx.SocketErrorCode} hresult={sockEx.HResult} native={sockEx.ErrorCode}"); - } - else - { - VibeLogger.LogWarning("binding_failed", $"target=project_ipc 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; - } - } + return _recoveryExecutor.StartRecoveryIfNeededAsync(isAfterCompile, cancellationToken); } private void SaveRunningServerState() diff --git a/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerRecoveryExecutor.cs b/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerRecoveryExecutor.cs new file mode 100644 index 0000000000..83042a3d7c --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerRecoveryExecutor.cs @@ -0,0 +1,210 @@ +using System; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +using io.github.hatayama.UnityCliLoop.Application; +using io.github.hatayama.UnityCliLoop.Domain; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Executes the coalesced, bind-retry recovery flow that (re)binds the project IPC endpoint. + /// + internal sealed class UnityCliLoopServerRecoveryExecutor + { + private readonly IUnityCliLoopServerInstanceFactory _serverInstanceFactory; + private readonly UnityCliLoopServerReadinessService _readinessService; + private readonly UnityCliLoopServerStartupProtectionService _startupProtectionService; + private readonly ISessionFlagsRepository _sessionFlagsRepository; + private readonly UnityCliLoopToolRegistrarService _toolRegistrarService; + private readonly Func _getBridgeServer; + private readonly Action _setBridgeServer; + private readonly SemaphoreSlim _startupSemaphore = new SemaphoreSlim(1, 1); + + internal UnityCliLoopServerRecoveryExecutor( + IUnityCliLoopServerInstanceFactory serverInstanceFactory, + UnityCliLoopServerReadinessService readinessService, + UnityCliLoopServerStartupProtectionService startupProtectionService, + ISessionFlagsRepository sessionFlagsRepository, + UnityCliLoopToolRegistrarService toolRegistrarService, + Func getBridgeServer, + Action setBridgeServer) + { + System.Diagnostics.Debug.Assert(serverInstanceFactory != null, "serverInstanceFactory must not be null"); + System.Diagnostics.Debug.Assert(readinessService != null, "readinessService must not be null"); + System.Diagnostics.Debug.Assert(startupProtectionService != null, "startupProtectionService must not be null"); + System.Diagnostics.Debug.Assert(sessionFlagsRepository != null, "sessionFlagsRepository must not be null"); + System.Diagnostics.Debug.Assert(toolRegistrarService != null, "toolRegistrarService must not be null"); + System.Diagnostics.Debug.Assert(getBridgeServer != null, "getBridgeServer must not be null"); + System.Diagnostics.Debug.Assert(setBridgeServer != null, "setBridgeServer must not be null"); + + _serverInstanceFactory = serverInstanceFactory; + _readinessService = readinessService; + _startupProtectionService = startupProtectionService; + _sessionFlagsRepository = sessionFlagsRepository; + _toolRegistrarService = toolRegistrarService; + _getBridgeServer = getBridgeServer; + _setBridgeServer = setBridgeServer; + } + + /// + /// Centralized, coalesced recovery start. + /// Attempts recovery on the project IPC endpoint for up to 5 seconds. + /// + internal async Task StartRecoveryIfNeededAsync(bool isAfterCompile, CancellationToken ct) + { + if (UnityCliLoopServerControllerService.IsBackgroundUnityProcess()) + { + VibeLogger.LogInfo("server_start_ignored", "background_process"); + return; + } + + if (_startupProtectionService.IsStartupProtectionActive()) + { + VibeLogger.LogInfo("server_start_ignored", "startup_protection_active"); + if (_getBridgeServer()?.IsRunning == true) + { + await _readinessService.MarkServerReadyAsync("startup-protection-active", ct); + return; + } + + return; + } + + VibeLogger.LogInfo("startup_request", "transport=project_ipc"); + + await _startupSemaphore.WaitAsync(ct); + try + { + // If any server is already running, ignore this request to prevent double-binding + if (_getBridgeServer() != null && _getBridgeServer().IsRunning) + { + VibeLogger.LogInfo("server_start_ignored", $"already_running endpoint={_getBridgeServer().Endpoint}"); + await _readinessService.MarkServerReadyAsync("already-running", ct); + return; + } + + // Ensure previous instance is fully disposed before trying to bind a new one + if (_getBridgeServer() != null) + { + try + { + _getBridgeServer().Dispose(); + VibeLogger.LogInfo("server_disposed_before_bind", "disposed previous server instance"); + } + catch (Exception ex) + { + VibeLogger.LogWarning("server_dispose_failed", ex.Message); + } + finally + { + _setBridgeServer(null); + } + } + + bool started = await TryBindWithWaitAsync( + 5000, + 250, + ct); + + if (!started) + { + // Ensure session reflects stopped state on failure + _sessionFlagsRepository.ClearServerSession(); + _sessionFlagsRepository.ClearReconnectingFlags(); + string message = "Unity CLI Loop server recovery failed because the project IPC endpoint could not be bound within 5000ms."; + UnityEngine.Debug.LogError($"[{UnityCliLoopConstants.PROJECT_NAME}] {message}"); + throw new InvalidOperationException(message); + } + + // Mark running and update settings + _sessionFlagsRepository.MarkServerStarted(); + + // Clear reconnection-related flags on successful recovery + _sessionFlagsRepository.ClearReconnectingFlags(); + _sessionFlagsRepository.ClearPostCompileReconnectingUI(); + _toolRegistrarService.WarmupRegistry(); + await _readinessService.MarkServerReadyAsync("server-recovery", ct); + + _startupProtectionService.ActivateStartupProtection(5000); + } + finally + { + _startupSemaphore.Release(); + } + } + + private async Task TryBindWithWaitAsync( + int maxWaitMs, + int stepMs, + CancellationToken ct) + { + int remainingMs = maxWaitMs; + while (true) + { + VibeLogger.LogInfo("binding_attempt", "transport=project_ipc"); + IUnityCliLoopServerInstance server = null; + try + { + // Defensive: dispose any non-running stale instance before creating a new one + if (_getBridgeServer() != null && !_getBridgeServer().IsRunning) + { + try + { + _getBridgeServer().Dispose(); + VibeLogger.LogInfo("server_disposed_before_bind", "disposed stale instance"); + } + catch (Exception ex) + { + VibeLogger.LogWarning("server_dispose_failed", ex.Message); + } + finally + { + _setBridgeServer(null); + } + } + + server = _serverInstanceFactory.Create(); + server.StartServer(); + _setBridgeServer(server); + VibeLogger.LogInfo( + "binding_success", + "Unity CLI Loop server bound the project IPC endpoint.", + new { endpoint = server.Endpoint }); + return true; + } + catch (Exception ex) + { + // Ensure partially created server is cleaned up on failure + server?.Dispose(); + // 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", $"target=project_ipc code={sockEx.SocketErrorCode} hresult={sockEx.HResult} native={sockEx.ErrorCode}"); + } + else + { + VibeLogger.LogWarning("binding_failed", $"target=project_ipc code=Unknown hresult={ex.HResult}"); + } + + if (remainingMs <= 0) + { + return false; + } + + int delay = stepMs <= 0 ? remainingMs : Math.Min(stepMs, remainingMs); + await TimerDelay.Wait(delay, ct); + remainingMs -= delay; + } + } + } + } +} diff --git a/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerRecoveryExecutor.cs.meta b/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerRecoveryExecutor.cs.meta new file mode 100644 index 0000000000..594360a644 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerRecoveryExecutor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ea3e506a5a0d482baa7f384c2d29c876 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: