diff --git a/Assets/Tests/Editor/DomainReloadDetectionServiceTests.cs b/Assets/Tests/Editor/DomainReloadDetectionServiceTests.cs index 5307cb2a85..f1fb737aba 100644 --- a/Assets/Tests/Editor/DomainReloadDetectionServiceTests.cs +++ b/Assets/Tests/Editor/DomainReloadDetectionServiceTests.cs @@ -112,9 +112,9 @@ public void CompleteDomainReload_WhenLegacyReloadStateExists_MigratesRecoveryFla } [Test] - public void CompleteDomainReload_WhenLegacyStateOnlySaysRunning_IgnoresStaleRunningFlag() + public void CompleteDomainReload_WhenLegacyStateOnlySaysRunning_DoesNotRestoreRunningSession() { - // Verifies that stale running-only JSON does not opt into recovery after the migration. + // Verifies that stale running-only JSON is not restored into SessionState. UnityCliLoopEditorLegacySessionState legacySessionState = new( isServerRunning: true, isAfterCompile: false, @@ -131,7 +131,7 @@ public void CompleteDomainReload_WhenLegacyStateOnlySaysRunning_IgnoresStaleRunn Assert.That(_sessionStateService.GetIsServerRunning(), Is.False); ServerReadinessState state = _stateStore.Read(); - Assert.That(state.Phase, Is.EqualTo("stopped")); + Assert.That(state.Phase, Is.EqualTo("recovering")); } [Test] @@ -162,7 +162,7 @@ public void CompleteDomainReload_WhenLegacyReloadStateWasMigrated_DoesNotReapply Assert.That(_sessionStateService.GetIsAfterCompile(), Is.False); Assert.That(_sessionStateService.GetIsReconnecting(), Is.False); ServerReadinessState state = _stateStore.Read(); - Assert.That(state.Phase, Is.EqualTo("stopped")); + Assert.That(state.Phase, Is.EqualTo("recovering")); } private static ServerReadinessStateStore CreateTestStateStore() diff --git a/Assets/Tests/Editor/DomainReloadRecoveryUseCaseTests.cs b/Assets/Tests/Editor/DomainReloadRecoveryUseCaseTests.cs index 0c13806572..64776ceae5 100644 --- a/Assets/Tests/Editor/DomainReloadRecoveryUseCaseTests.cs +++ b/Assets/Tests/Editor/DomainReloadRecoveryUseCaseTests.cs @@ -98,14 +98,27 @@ public void ExecuteBeforeDomainReload_ShouldPreferInstanceState_WhenInstanceIsRu } [Test] - public void CompleteDomainReload_WhenServerWasNotRunning_ShouldPublishStoppedState() + public void CompleteDomainReload_WhenServerWasNotRunning_ShouldPublishRecoveringState() { - // Verifies that a domain reload with no server to recover does not leave CLI waiters in recovering state. + // Verifies that no-server domain reload completion matches the automatic recovery that follows it. _sessionStateService.SetIsServerRunning(false); _domainReloadDetectionService.StartDomainReload("test-correlation", serverIsRunning: false); _domainReloadDetectionService.CompleteDomainReload("test-correlation"); + ServerReadinessState state = _stateStore.Read(); + Assert.That(state.Phase, Is.EqualTo("recovering")); + } + + [Test] + public void CompleteDomainReload_WhenServerWasManuallyStopped_ShouldPublishStoppedState() + { + // Verifies that explicit Stop Server remains terminal across Domain Reload completion. + _sessionStateService.MarkServerManuallyStopped(); + _domainReloadDetectionService.StartDomainReload("test-correlation", serverIsRunning: false); + + _domainReloadDetectionService.CompleteDomainReload("test-correlation"); + ServerReadinessState state = _stateStore.Read(); Assert.That(state.Phase, Is.EqualTo("stopped")); } @@ -130,6 +143,41 @@ public async Task RestoreServerStateIfNeededAsync_WhenRecoveryDoesNotStartServer Is.EqualTo("Unity CLI Loop server recovery finished, but no running server instance is available.")); } + [Test] + public async Task RestoreServerStateIfNeededAsync_WhenNoServerWasRunning_ShouldStillStartRecovery() + { + // Verifies launch-time reload recovery starts the server even when no previous bridge session existed. + _sessionStateService.SetIsServerRunning(false); + _sessionStateService.SetIsAfterCompile(false); + TestRecoveryCoordinator recoveryCoordinator = new(recoverServer: true); + SessionRecoveryService service = new( + recoveryCoordinator, + _domainReloadDetectionService, + _sessionStateService); + + ValidationResult result = await service.RestoreServerStateIfNeededAsync(CancellationToken.None); + + Assert.That(result.IsValid, Is.True); + Assert.That(recoveryCoordinator.StartRecoveryCallCount, Is.EqualTo(1)); + } + + [Test] + public async Task RestoreServerStateIfNeededAsync_WhenServerWasManuallyStopped_ShouldSkipRecovery() + { + // Verifies explicit Stop Server is preserved across Domain Reload. + _sessionStateService.MarkServerManuallyStopped(); + TestRecoveryCoordinator recoveryCoordinator = new(recoverServer: true); + SessionRecoveryService service = new( + recoveryCoordinator, + _domainReloadDetectionService, + _sessionStateService); + + ValidationResult result = await service.RestoreServerStateIfNeededAsync(CancellationToken.None); + + Assert.That(result.IsValid, Is.True); + Assert.That(recoveryCoordinator.StartRecoveryCallCount, Is.EqualTo(0)); + } + private static ServerReadinessStateStore CreateTestStateStore() { string projectRoot = System.IO.Path.Combine( @@ -160,10 +208,26 @@ private static DomainReloadRecoveryUseCase CreateUseCase( /// private sealed class TestRecoveryCoordinator : IUnityCliLoopServerRecoveryCoordinator { - public IUnityCliLoopServerInstance CurrentServer => null; + private readonly bool _recoverServer; + private readonly TestServerInstance _server = new(); + + public TestRecoveryCoordinator(bool recoverServer = false) + { + _recoverServer = recoverServer; + } + + public int StartRecoveryCallCount { get; private set; } + + public IUnityCliLoopServerInstance CurrentServer => _server.IsRunning ? _server : null; public Task StartRecoveryIfNeededAsync(bool isAfterCompile, CancellationToken cancellationToken) { + StartRecoveryCallCount++; + if (_recoverServer) + { + _server.StartServer(); + } + return Task.CompletedTask; } } diff --git a/Assets/Tests/Editor/UnityCliLoopEditorSessionStateRepositoryTests.cs b/Assets/Tests/Editor/UnityCliLoopEditorSessionStateRepositoryTests.cs index 57bd7bdbf3..879951721e 100644 --- a/Assets/Tests/Editor/UnityCliLoopEditorSessionStateRepositoryTests.cs +++ b/Assets/Tests/Editor/UnityCliLoopEditorSessionStateRepositoryTests.cs @@ -32,6 +32,7 @@ public void GetFlags_WhenSessionStateIsEmpty_ReturnsFalseDefaults() { // Verifies that transient runtime flags do not opt into stale recovery by default. Assert.That(_sessionStateService.GetIsServerRunning(), Is.False); + Assert.That(_sessionStateService.GetIsServerManuallyStopped(), Is.False); Assert.That(_sessionStateService.GetIsAfterCompile(), Is.False); Assert.That(_sessionStateService.GetIsDomainReloadInProgress(), Is.False); Assert.That(_sessionStateService.GetIsReconnecting(), Is.False); @@ -50,6 +51,7 @@ public void GetFlags_WhenServiceAndRepositoryAreRecreated_ReadsExistingSessionVa UnityCliLoopEditorSessionStateTestFactory.CreateService(); Assert.That(recreatedService.GetIsServerRunning(), Is.True); + Assert.That(recreatedService.GetIsServerManuallyStopped(), Is.False); Assert.That(recreatedService.GetIsAfterCompile(), Is.True); Assert.That(recreatedService.GetIsDomainReloadInProgress(), Is.True); Assert.That(recreatedService.GetIsReconnecting(), Is.True); @@ -89,6 +91,7 @@ public void ClearAll_WhenFlagsAreSet_ClearsEveryTransientFlag() // Verifies that test and shutdown cleanup can reset all runtime SessionState flags together. _sessionStateService.MarkDomainReloadStarted(serverIsRunning: true); _sessionStateService.SetShouldAutoScanThirdPartyToolMigration(true); + _sessionStateService.SetIsServerManuallyStopped(true); _sessionStateService.ClearAll(); @@ -99,6 +102,20 @@ public void ClearAll_WhenFlagsAreSet_ClearsEveryTransientFlag() Assert.That(_sessionStateService.GetShowReconnectingUI(), Is.False); Assert.That(_sessionStateService.GetShowPostCompileReconnectingUI(), Is.False); Assert.That(_sessionStateService.GetShouldAutoScanThirdPartyToolMigration(), Is.False); + Assert.That(_sessionStateService.GetIsServerManuallyStopped(), Is.False); + } + + [Test] + public void MarkServerManuallyStopped_WhenServiceIsRecreated_PreservesManualStop() + { + // Verifies that explicit Stop Server survives Domain Reload service recreation. + _sessionStateService.MarkServerManuallyStopped(); + + UnityCliLoopEditorSessionStateService recreatedService = + UnityCliLoopEditorSessionStateTestFactory.CreateService(); + + Assert.That(recreatedService.GetIsServerRunning(), Is.False); + Assert.That(recreatedService.GetIsServerManuallyStopped(), Is.True); } } } diff --git a/Assets/Tests/Editor/UnityCliLoopEditorSessionStateTestFactory.cs b/Assets/Tests/Editor/UnityCliLoopEditorSessionStateTestFactory.cs index f09820bccc..52bcc6d3d4 100644 --- a/Assets/Tests/Editor/UnityCliLoopEditorSessionStateTestFactory.cs +++ b/Assets/Tests/Editor/UnityCliLoopEditorSessionStateTestFactory.cs @@ -26,6 +26,7 @@ internal static UnityCliLoopEditorSessionStateSnapshot CaptureSnapshot( internal readonly struct UnityCliLoopEditorSessionStateSnapshot { private readonly bool _isServerRunning; + private readonly bool _isServerManuallyStopped; private readonly bool _isAfterCompile; private readonly bool _isDomainReloadInProgress; private readonly bool _isReconnecting; @@ -36,6 +37,7 @@ internal readonly struct UnityCliLoopEditorSessionStateSnapshot private UnityCliLoopEditorSessionStateSnapshot(UnityCliLoopEditorSessionStateService service) { _isServerRunning = service.GetIsServerRunning(); + _isServerManuallyStopped = service.GetIsServerManuallyStopped(); _isAfterCompile = service.GetIsAfterCompile(); _isDomainReloadInProgress = service.GetIsDomainReloadInProgress(); _isReconnecting = service.GetIsReconnecting(); @@ -53,6 +55,7 @@ internal static UnityCliLoopEditorSessionStateSnapshot Capture( internal void Restore(UnityCliLoopEditorSessionStateService service) { service.SetIsServerRunning(_isServerRunning); + service.SetIsServerManuallyStopped(_isServerManuallyStopped); service.SetIsAfterCompile(_isAfterCompile); service.SetIsDomainReloadInProgress(_isDomainReloadInProgress); service.SetIsReconnecting(_isReconnecting); diff --git a/Assets/Tests/Editor/UnityCliLoopServerControllerStartupLockTests.cs b/Assets/Tests/Editor/UnityCliLoopServerControllerStartupLockTests.cs index cbb660c993..20427290b7 100644 --- a/Assets/Tests/Editor/UnityCliLoopServerControllerStartupLockTests.cs +++ b/Assets/Tests/Editor/UnityCliLoopServerControllerStartupLockTests.cs @@ -152,6 +152,103 @@ public async Task ProbeReadinessWithTimeoutAsync_WhenProbeDoesNotComplete_Throws Assert.That(readinessProbe.CallCount, Is.EqualTo(1)); } + [Test] + public async Task RestoreServerStateIfNeeded_WhenServerWasManuallyStopped_ShouldSkipStartupRecovery() + { + // Tests that explicit Stop Server is preserved when startup recovery runs after Domain Reload. + _sessionStateService.MarkServerManuallyStopped(); + TestServerInstanceFactory serverInstanceFactory = new(); + UnityCliLoopServerLifecycleRegistryService lifecycleRegistry = + new UnityCliLoopServerLifecycleRegistryService(); + ServerReadinessStateStore stateStore = CreateTestStateStore(); + UnityCliLoopServerControllerService service = new( + serverInstanceFactory, + lifecycleRegistry, + new DomainReloadDetectionFileService(_sessionStateService, stateStore), + _sessionStateService, + stateStore, + new TestReadinessProbe(), + new TestDomainReloadLifecycle()); + + await service.RestoreServerStateIfNeeded(); + + Assert.That(serverInstanceFactory.LastCreated, Is.Null); + } + + [Test] + public async Task StopServerWithUseCaseAsync_WhenStoppedByUser_ShouldMarkManualStop() + { + // Tests that the manual Stop Server path records explicit user stop intent. + UnityCliLoopServerControllerService service = CreateControllerService(); + TestServerInstance runningServer = new(); + runningServer.StartServer(); + service.RegisterRecoveredServer(runningServer); + + await service.StopServerWithUseCaseAsync(); + + Assert.That(_sessionStateService.GetIsServerRunning(), Is.False); + Assert.That(_sessionStateService.GetIsServerManuallyStopped(), Is.True); + } + + [Test] + public async Task StartServerWithUseCaseAsync_WhenRestartCleanupStartFails_ShouldNotMarkManualStop() + { + // Tests that internal restart cleanup is not mistaken for explicit user stop intent. + UnityEngine.TestTools.LogAssert.Expect( + UnityEngine.LogType.Error, + "Server startup failed: Failed to start server: start failed"); + TestServerInstanceFactory serverInstanceFactory = new(throwOnCreate: true); + UnityCliLoopServerLifecycleRegistryService lifecycleRegistry = + new UnityCliLoopServerLifecycleRegistryService(); + ServerReadinessStateStore stateStore = CreateTestStateStore(); + UnityCliLoopServerControllerService service = new( + serverInstanceFactory, + lifecycleRegistry, + new DomainReloadDetectionFileService(_sessionStateService, stateStore), + _sessionStateService, + stateStore, + new TestReadinessProbe(), + new TestDomainReloadLifecycle()); + TestServerInstance runningServer = new(); + runningServer.StartServer(); + service.RegisterRecoveredServer(runningServer); + + await service.StartServerWithUseCaseAsync(); + + Assert.That(_sessionStateService.GetIsServerRunning(), Is.False); + Assert.That(_sessionStateService.GetIsServerManuallyStopped(), Is.False); + } + + [Test] + public async Task StartServerWithUseCaseAsync_WhenRestartCleanupStopFails_ShouldNotStartNewServer() + { + // Tests that restart cleanup failure does not get hidden behind a second startup attempt. + UnityEngine.TestTools.LogAssert.Expect( + UnityEngine.LogType.Error, + "Server shutdown failed: Failed to stop server: dispose failed"); + TestServerInstanceFactory serverInstanceFactory = new(); + UnityCliLoopServerLifecycleRegistryService lifecycleRegistry = + new UnityCliLoopServerLifecycleRegistryService(); + ServerReadinessStateStore stateStore = CreateTestStateStore(); + UnityCliLoopServerControllerService service = new( + serverInstanceFactory, + lifecycleRegistry, + new DomainReloadDetectionFileService(_sessionStateService, stateStore), + _sessionStateService, + stateStore, + new TestReadinessProbe(), + new TestDomainReloadLifecycle()); + TestServerInstance runningServer = new(throwOnDispose: true); + runningServer.StartServer(); + service.RegisterRecoveredServer(runningServer); + + await service.StartServerWithUseCaseAsync(); + + Assert.That(serverInstanceFactory.LastCreated, Is.Null); + Assert.That(_sessionStateService.GetIsServerRunning(), Is.True); + Assert.That(_sessionStateService.GetIsServerManuallyStopped(), Is.False); + } + private UnityCliLoopServerControllerService CreateControllerService() { return CreateControllerService(new TestReadinessProbe()); @@ -223,10 +320,22 @@ public void PrepareForDomainReload() /// private sealed class TestServerInstanceFactory : IUnityCliLoopServerInstanceFactory { + private readonly bool _throwOnCreate; + + public TestServerInstanceFactory(bool throwOnCreate = false) + { + _throwOnCreate = throwOnCreate; + } + public TestServerInstance LastCreated { get; private set; } public IUnityCliLoopServerInstance Create() { + if (_throwOnCreate) + { + throw new System.InvalidOperationException("start failed"); + } + LastCreated = new TestServerInstance(); return LastCreated; } @@ -237,6 +346,13 @@ public IUnityCliLoopServerInstance Create() /// private sealed class TestServerInstance : IUnityCliLoopServerInstance { + private readonly bool _throwOnDispose; + + public TestServerInstance(bool throwOnDispose = false) + { + _throwOnDispose = throwOnDispose; + } + public bool IsRunning { get; private set; } public string Endpoint => "test"; @@ -253,6 +369,11 @@ public void StopServer() public void Dispose() { + if (_throwOnDispose) + { + throw new System.InvalidOperationException("dispose failed"); + } + IsRunning = false; } } diff --git a/Packages/src/Cli~/dist/darwin-amd64/uloop b/Packages/src/Cli~/dist/darwin-amd64/uloop index 9ca7ba476c..96ea8a52bf 100755 Binary files a/Packages/src/Cli~/dist/darwin-amd64/uloop and b/Packages/src/Cli~/dist/darwin-amd64/uloop differ diff --git a/Packages/src/Cli~/dist/darwin-arm64/uloop b/Packages/src/Cli~/dist/darwin-arm64/uloop index 15d7b2df21..fdaeba7909 100755 Binary files a/Packages/src/Cli~/dist/darwin-arm64/uloop and b/Packages/src/Cli~/dist/darwin-arm64/uloop differ diff --git a/Packages/src/Cli~/dist/windows-amd64/uloop.exe b/Packages/src/Cli~/dist/windows-amd64/uloop.exe index b1e4968771..9a1932fa12 100755 Binary files a/Packages/src/Cli~/dist/windows-amd64/uloop.exe and b/Packages/src/Cli~/dist/windows-amd64/uloop.exe differ diff --git a/Packages/src/Cli~/internal/cli/tool_readiness.go b/Packages/src/Cli~/internal/cli/tool_readiness.go index 6b4ac7c260..d8c848950a 100644 --- a/Packages/src/Cli~/internal/cli/tool_readiness.go +++ b/Packages/src/Cli~/internal/cli/tool_readiness.go @@ -21,7 +21,11 @@ const executeDynamicCodeReadinessProbe = `return "Unity CLI Loop dynamic code pr type toolReadinessWaitMode int -var findRunningUnityProcessForReadiness = findRunningUnityProcess +var ( + findRunningUnityProcessForReadiness = findRunningUnityProcess + stoppedServerStateGrace = 2 * time.Second + stoppedServerStatePoll = 100 * time.Millisecond +) const ( toolReadinessWaitThroughStopped toolReadinessWaitMode = iota @@ -54,6 +58,27 @@ func waitForToolReadinessWithMode(ctx context.Context, projectRoot string, mode if mode == toolReadinessStopWhenServerStops && isServerStateStopped(state) { return serverStoppedError{state: state} } + if mode == toolReadinessWaitThroughStopped && isServerStateStopped(state) { + changed, err := waitForStoppedServerStateChange(timeoutContext, ctx, projectRoot, state) + if err != nil { + return err + } + if !changed { + stale, err := isStoppedServerStateStale(timeoutContext, projectRoot) + if err != nil { + return err + } + if stale { + return serverStoppedError{state: state} + } + select { + case <-timeoutContext.Done(): + return toolReadinessDoneError(ctx) + case <-time.After(toolReadinessPoll): + } + } + continue + } if isServerStateBusy(state) { stale, err := isBusyServerStateStale(timeoutContext, projectRoot) if err != nil { @@ -83,6 +108,50 @@ func waitForToolReadinessWithMode(ctx context.Context, projectRoot string, mode } } +func waitForStoppedServerStateChange(timeoutCtx context.Context, parentCtx context.Context, projectRoot string, initialState serverState) (bool, error) { + if stoppedServerStateGrace <= 0 { + return false, nil + } + + graceTimer := time.NewTimer(stoppedServerStateGrace) + defer graceTimer.Stop() + + ticker := time.NewTicker(stoppedServerStatePoll) + defer ticker.Stop() + + for { + select { + case <-timeoutCtx.Done(): + return false, toolReadinessDoneError(parentCtx) + case <-graceTimer.C: + return false, nil + case <-ticker.C: + state, ok, err := readServerState(projectRoot) + if err != nil { + return false, err + } + if !ok || !isSameStoppedServerState(state, initialState) { + return true, nil + } + } + } +} + +func isSameStoppedServerState(state serverState, expected serverState) bool { + return state.Phase == "stopped" && + expected.Phase == "stopped" && + state.GenerationID == expected.GenerationID && + state.Reason == expected.Reason +} + +func isStoppedServerStateStale(ctx context.Context, projectRoot string) (bool, error) { + runningProcess, err := findRunningUnityProcessForReadiness(ctx, projectRoot) + if err != nil { + return false, err + } + return runningProcess == nil, nil +} + func isBusyServerStateStale(ctx context.Context, projectRoot string) (bool, error) { runningProcess, err := findRunningUnityProcessForReadiness(ctx, projectRoot) if err != nil { diff --git a/Packages/src/Cli~/internal/cli/tool_readiness_test.go b/Packages/src/Cli~/internal/cli/tool_readiness_test.go index 0f7d1be4d7..b528d5d0c8 100644 --- a/Packages/src/Cli~/internal/cli/tool_readiness_test.go +++ b/Packages/src/Cli~/internal/cli/tool_readiness_test.go @@ -3,7 +3,10 @@ package cli import ( "context" "errors" + "os" + "path/filepath" "testing" + "time" ) // Verifies that parent cancellation is preserved instead of being reported as a timeout. @@ -43,6 +46,115 @@ func TestWaitForRecoveringToolReadinessStopsWhenServerStateStopped(t *testing.T) } } +// Verifies that launch readiness fails quickly when Unity leaves the server stopped. +func TestWaitForToolReadinessStopsWhenServerStateStaysStopped(t *testing.T) { + originalGrace := stoppedServerStateGrace + originalFinder := findRunningUnityProcessForReadiness + stoppedServerStateGrace = 0 + findRunningUnityProcessForReadiness = func(context.Context, string) (*unityProcess, error) { + return nil, nil + } + t.Cleanup(func() { + stoppedServerStateGrace = originalGrace + findRunningUnityProcessForReadiness = originalFinder + }) + + projectRoot := t.TempDir() + createUnityProjectForReadinessTest(t, projectRoot) + writeReadinessServerStateForTest(t, projectRoot, `{"phase":"stopped","reason":"domain-reload-after-no-server"}`) + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + err := waitForToolReadiness(ctx, projectRoot) + + var stoppedErr serverStoppedError + if !errors.As(err, &stoppedErr) { + t.Fatalf("expected stopped error, got %v", err) + } + if stoppedErr.state.Reason != "domain-reload-after-no-server" { + t.Fatalf("stopped reason mismatch: %#v", stoppedErr.state) + } +} + +// Verifies that launch readiness does not fail early while Unity is still starting. +func TestWaitForToolReadinessContinuesWhenStoppedStateIsStaleButUnityRuns(t *testing.T) { + originalGrace := stoppedServerStateGrace + originalFinder := findRunningUnityProcessForReadiness + stoppedServerStateGrace = 0 + findRunningUnityProcessForReadiness = func(context.Context, string) (*unityProcess, error) { + return &unityProcess{pid: 123}, nil + } + t.Cleanup(func() { + stoppedServerStateGrace = originalGrace + findRunningUnityProcessForReadiness = originalFinder + }) + + projectRoot := t.TempDir() + createUnityProjectForReadinessTest(t, projectRoot) + writeReadinessServerStateForTest(t, projectRoot, `{"phase":"stopped","reason":"editor-quitting"}`) + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + err := waitForToolReadiness(ctx, projectRoot) + + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected readiness wait to keep polling until context deadline, got %v", err) + } +} + +// Verifies that launch readiness gives startup recovery a chance to replace stopped state. +func TestWaitForStoppedServerStateChangeDetectsRecoveryState(t *testing.T) { + originalGrace := stoppedServerStateGrace + originalPoll := stoppedServerStatePoll + stoppedServerStateGrace = 100 * time.Millisecond + stoppedServerStatePoll = time.Millisecond + t.Cleanup(func() { + stoppedServerStateGrace = originalGrace + stoppedServerStatePoll = originalPoll + }) + + projectRoot := t.TempDir() + initialState := serverState{Phase: "stopped", Reason: "domain-reload-after-no-server"} + writeReadinessServerStateForTest(t, projectRoot, `{"phase":"stopped","reason":"domain-reload-after-no-server"}`) + go func() { + time.Sleep(10 * time.Millisecond) + writeReadinessServerStateForTest(t, projectRoot, `{"phase":"recovering","reason":"server-recovery"}`) + }() + + changed, err := waitForStoppedServerStateChange(context.Background(), context.Background(), projectRoot, initialState) + if err != nil { + t.Fatalf("waitForStoppedServerStateChange failed: %v", err) + } + if !changed { + t.Fatal("stopped server state change was not detected") + } +} + +// Verifies that stopped-state grace timeout uses the public readiness timeout message. +func TestWaitForStoppedServerStateChangeWhenInternalTimeoutExpiresReportsReadinessTimeout(t *testing.T) { + originalGrace := stoppedServerStateGrace + originalPoll := stoppedServerStatePoll + stoppedServerStateGrace = 100 * time.Millisecond + stoppedServerStatePoll = time.Millisecond + t.Cleanup(func() { + stoppedServerStateGrace = originalGrace + stoppedServerStatePoll = originalPoll + }) + + projectRoot := t.TempDir() + initialState := serverState{Phase: "stopped", Reason: "domain-reload-after-no-server"} + writeReadinessServerStateForTest(t, projectRoot, `{"phase":"stopped","reason":"domain-reload-after-no-server"}`) + timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Nanosecond) + defer cancel() + <-timeoutCtx.Done() + + _, err := waitForStoppedServerStateChange(timeoutCtx, context.Background(), projectRoot, initialState) + + if err == nil || err.Error() != "timed out waiting for Unity tool readiness" { + t.Fatalf("timeout error mismatch: %v", err) + } +} + // Verifies that stale busy state returns immediately when the Unity process is gone. func TestWaitForRecoveringToolReadinessReportsStaleBusyStateWhenUnityIsGone(t *testing.T) { originalFinder := findRunningUnityProcessForReadiness @@ -92,3 +204,12 @@ func TestWaitForRecoveringToolReadinessPassesTimeoutContextToStaleCheck(t *testi t.Fatal("stale-state process lookup did not receive a timeout context") } } + +func createUnityProjectForReadinessTest(t *testing.T, projectRoot string) { + t.Helper() + for _, relativePath := range []string{"Assets", "ProjectSettings"} { + if err := os.MkdirAll(filepath.Join(projectRoot, relativePath), 0o755); err != nil { + t.Fatalf("failed to create Unity project directory: %v", err) + } + } +} diff --git a/Packages/src/Editor/Application/SessionRecoveryService.cs b/Packages/src/Editor/Application/SessionRecoveryService.cs index a0e0d8f7a4..39a2c48de9 100644 --- a/Packages/src/Editor/Application/SessionRecoveryService.cs +++ b/Packages/src/Editor/Application/SessionRecoveryService.cs @@ -46,7 +46,6 @@ public async Task RestoreServerStateIfNeededAsync(Cancellation { ct.ThrowIfCancellationRequested(); - bool wasRunning = _sessionStateService.GetIsServerRunning(); bool isAfterCompile = _sessionStateService.GetIsAfterCompile(); IUnityCliLoopServerInstance currentServer = _recoveryCoordinator.CurrentServer; @@ -64,15 +63,17 @@ public async Task RestoreServerStateIfNeededAsync(Cancellation _sessionStateService.ClearAfterCompileFlag(); } - if (wasRunning && (currentServer == null || !currentServer.IsRunning)) + if (_sessionStateService.GetIsServerManuallyStopped()) { - await _recoveryCoordinator.StartRecoveryIfNeededAsync(isAfterCompile, ct); - IUnityCliLoopServerInstance recoveredServer = _recoveryCoordinator.CurrentServer; - if (recoveredServer?.IsRunning != true) - { - return ValidationResult.Failure( - "Unity CLI Loop server recovery finished, but no running server instance is available."); - } + return ValidationResult.Success(); + } + + await _recoveryCoordinator.StartRecoveryIfNeededAsync(isAfterCompile, ct); + IUnityCliLoopServerInstance recoveredServer = _recoveryCoordinator.CurrentServer; + if (recoveredServer?.IsRunning != true) + { + return ValidationResult.Failure( + "Unity CLI Loop server recovery finished, but no running server instance is available."); } return ValidationResult.Success(); diff --git a/Packages/src/Editor/Application/UnityCliLoopServerStartupService.cs b/Packages/src/Editor/Application/UnityCliLoopServerStartupService.cs index 940c7c9a89..42d53b794c 100644 --- a/Packages/src/Editor/Application/UnityCliLoopServerStartupService.cs +++ b/Packages/src/Editor/Application/UnityCliLoopServerStartupService.cs @@ -64,7 +64,7 @@ public ServiceResult UpdateSessionState(bool isRunning) return ServiceResult.SuccessResult(true); } - _sessionStateService.SetIsServerRunning(true); + _sessionStateService.MarkServerStarted(); return ServiceResult.SuccessResult(true); } } diff --git a/Packages/src/Editor/Domain/UnityCliLoopEditorSessionStateService.cs b/Packages/src/Editor/Domain/UnityCliLoopEditorSessionStateService.cs index cef04fc3c5..aa8c6c3458 100644 --- a/Packages/src/Editor/Domain/UnityCliLoopEditorSessionStateService.cs +++ b/Packages/src/Editor/Domain/UnityCliLoopEditorSessionStateService.cs @@ -7,6 +7,8 @@ public interface IUnityCliLoopEditorSessionStatePort { bool GetIsServerRunning(); void SetIsServerRunning(bool isServerRunning); + bool GetIsServerManuallyStopped(); + void SetIsServerManuallyStopped(bool isServerManuallyStopped); bool GetIsAfterCompile(); void SetIsAfterCompile(bool isAfterCompile); bool GetIsDomainReloadInProgress(); @@ -45,6 +47,16 @@ public void SetIsServerRunning(bool isServerRunning) _sessionStatePort.SetIsServerRunning(isServerRunning); } + public bool GetIsServerManuallyStopped() + { + return _sessionStatePort.GetIsServerManuallyStopped(); + } + + public void SetIsServerManuallyStopped(bool isServerManuallyStopped) + { + _sessionStatePort.SetIsServerManuallyStopped(isServerManuallyStopped); + } + public bool GetIsAfterCompile() { return _sessionStatePort.GetIsAfterCompile(); @@ -125,12 +137,25 @@ public void MarkDomainReloadStarted(bool serverIsRunning) } SetIsServerRunning(true); + SetIsServerManuallyStopped(false); SetIsAfterCompile(true); SetIsReconnecting(true); SetShowReconnectingUI(true); SetShowPostCompileReconnectingUI(true); } + public void MarkServerStarted() + { + SetIsServerRunning(true); + SetIsServerManuallyStopped(false); + } + + public void MarkServerManuallyStopped() + { + ClearServerSession(); + SetIsServerManuallyStopped(true); + } + public void ClearServerSession() { SetIsServerRunning(false); @@ -171,6 +196,7 @@ public void ClearAll() ClearServerSession(); ClearDomainReloadRecoveryFlags(); SetShouldAutoScanThirdPartyToolMigration(false); + SetIsServerManuallyStopped(false); } } } diff --git a/Packages/src/Editor/Infrastructure/Server/DomainReloadDetectionFileService.cs b/Packages/src/Editor/Infrastructure/Server/DomainReloadDetectionFileService.cs index 849a34311e..b6901d34f5 100644 --- a/Packages/src/Editor/Infrastructure/Server/DomainReloadDetectionFileService.cs +++ b/Packages/src/Editor/Infrastructure/Server/DomainReloadDetectionFileService.cs @@ -116,12 +116,12 @@ public void CompleteDomainReload(string correlationId) } MigrateLegacySessionStateIfNeeded(); - bool serverWillRecover = _sessionStateService.GetIsServerRunning(); + bool serverWillRecover = !_sessionStateService.GetIsServerManuallyStopped(); _stateStore.Write( serverWillRecover ? ServerReadinessPhase.Recovering : ServerReadinessPhase.Stopped, correlationId, - serverWillRecover ? "domain-reload-after" : "domain-reload-after-no-server", + serverWillRecover ? "domain-reload-after" : "manual-stop", null, null); @@ -132,7 +132,9 @@ public void CompleteDomainReload(string correlationId) // Log recording VibeLogger.LogInfo( "domain_reload_complete", - "Domain reload completed - starting server recovery process", + serverWillRecover + ? "Domain reload completed - starting server recovery process" + : "Domain reload completed - server was manually stopped before recovery", new { transport = "project_ipc" }, correlationId ); @@ -181,7 +183,7 @@ private void MigrateLegacySessionStateIfNeeded() if (legacySessionState.IsServerRunning) { - _sessionStateService.SetIsServerRunning(true); + _sessionStateService.MarkServerStarted(); } if (legacySessionState.IsAfterCompile) diff --git a/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs b/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs index d192961101..7ea1128438 100644 --- a/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs +++ b/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs @@ -19,6 +19,15 @@ public sealed class UnityCliLoopServerControllerService : IUnityCliLoopServerRecoveryCoordinator, IUnityCliLoopServerStateReader { + private const string MANUAL_STOP_REASON = "manual-stop"; + private const string MANUAL_START_CLEANUP_REASON = "manual-start-cleanup"; + + private enum ServerStopIntent + { + ManualStop, + RestartCleanup, + } + private readonly IUnityCliLoopServerInstanceFactory _serverInstanceFactory; private readonly UnityCliLoopServerLifecycleRegistryService _serverLifecycleRegistry; private readonly IDomainReloadDetectionService _domainReloadDetectionService; @@ -194,7 +203,7 @@ public async void StartServer() /// /// Starts the server using new UseCase implementation. /// - private async Task StartServerWithUseCaseAsync() + internal async Task StartServerWithUseCaseAsync() { if (IsBackgroundUnityProcess()) { @@ -208,7 +217,11 @@ private async Task StartServerWithUseCaseAsync() // Always stop the existing server first so the project IPC endpoint is released. if (_bridgeServer != null) { - await StopServerWithUseCaseAsync(); + bool cleanupSucceeded = await StopServerForRestartAsync(CancellationToken.None); + if (!cleanupSucceeded) + { + return; + } } UnityCliLoopServerStartupService startupService = @@ -251,14 +264,27 @@ public async void StopServer() /// internal async Task StopServerWithUseCaseAsync() { + await StopServerForIntentAsync(ServerStopIntent.ManualStop, CancellationToken.None); + } + + private async Task StopServerForRestartAsync(CancellationToken ct) + { + return await StopServerForIntentAsync(ServerStopIntent.RestartCleanup, ct); + } + + private async Task StopServerForIntentAsync(ServerStopIntent stopIntent, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (IsBackgroundUnityProcess()) { VibeLogger.LogInfo("server_stop_ignored", "background_process"); - return; + return true; } string generationId = ServerReadinessStateStore.CreateGenerationId(); - WriteServerState(ServerReadinessPhase.Stopping, generationId, "manual-stop", null); + string reason = stopIntent == ServerStopIntent.ManualStop ? MANUAL_STOP_REASON : MANUAL_START_CLEANUP_REASON; + WriteServerState(ServerReadinessPhase.Stopping, generationId, reason, null); _serverLifecycleRegistry.PublishServerStopping(); PrepareForServerShutdown(); @@ -266,24 +292,32 @@ internal async Task StopServerWithUseCaseAsync() new UnityCliLoopServerStartupService(_serverInstanceFactory, _sessionStateService); UnityCliLoopServerShutdownUseCase useCase = new UnityCliLoopServerShutdownUseCase(startupService, this); - System.Threading.CancellationToken cancellationToken = System.Threading.CancellationToken.None; - ServerShutdownResult result = await useCase.ExecuteAsync(cancellationToken); + ServerShutdownResult result = await useCase.ExecuteAsync(ct); if (result.Success) { // Server stopped by UseCase, so clear the reference _bridgeServer = null; - // Clear session state to reflect server stopped - _sessionStateService.ClearServerSession(); - WriteServerState(ServerReadinessPhase.Stopped, generationId, "manual-stop", null); + if (stopIntent == ServerStopIntent.ManualStop) + { + _sessionStateService.MarkServerManuallyStopped(); + } + else + { + _sessionStateService.ClearServerSession(); + } + + WriteServerState(ServerReadinessPhase.Stopped, generationId, reason, null); + return true; } else { // Error message already handled by UseCase - WriteServerState(ServerReadinessPhase.Failed, generationId, "manual-stop", result.Message); + WriteServerState(ServerReadinessPhase.Failed, generationId, reason, result.Message); UnityEngine.Debug.LogError($"Server shutdown failed: {result.Message}"); + return false; } } @@ -341,7 +375,7 @@ private async Task ExecuteAfterDomainReloadRecoveryAsync(CancellationToken cance /// /// Restores the server state if necessary. /// - private async Task RestoreServerStateIfNeeded() + internal async Task RestoreServerStateIfNeeded() { if (IsBackgroundUnityProcess()) { @@ -366,6 +400,11 @@ private async Task RestoreServerStateIfNeeded() _sessionStateService.ClearAfterCompileFlag(); } + if (_sessionStateService.GetIsServerManuallyStopped()) + { + return; + } + await StartRecoveryIfNeededAsync(isAfterCompile, CancellationToken.None); } @@ -664,7 +703,7 @@ private async Task TryBindWithWaitAsync( private void SaveRunningServerState() { - _sessionStateService.SetIsServerRunning(true); + _sessionStateService.MarkServerStarted(); } private async Task MarkServerReadyAsync( diff --git a/Packages/src/Editor/Infrastructure/Settings/UnityCliLoopEditorSessionStateRepository.cs b/Packages/src/Editor/Infrastructure/Settings/UnityCliLoopEditorSessionStateRepository.cs index 8b3aa8a672..c5fe267dd1 100644 --- a/Packages/src/Editor/Infrastructure/Settings/UnityCliLoopEditorSessionStateRepository.cs +++ b/Packages/src/Editor/Infrastructure/Settings/UnityCliLoopEditorSessionStateRepository.cs @@ -11,6 +11,7 @@ public sealed class UnityCliLoopEditorSessionStateRepository : IUnityCliLoopEdit { private const string KeyPrefix = "io.github.hatayama.uloopmcp.editorSession."; private const string IsServerRunningKey = KeyPrefix + "isServerRunning"; + private const string IsServerManuallyStoppedKey = KeyPrefix + "isServerManuallyStopped"; private const string IsAfterCompileKey = KeyPrefix + "isAfterCompile"; private const string IsDomainReloadInProgressKey = KeyPrefix + "isDomainReloadInProgress"; private const string IsReconnectingKey = KeyPrefix + "isReconnecting"; @@ -29,6 +30,16 @@ public void SetIsServerRunning(bool isServerRunning) SetBool(IsServerRunningKey, isServerRunning); } + public bool GetIsServerManuallyStopped() + { + return GetBool(IsServerManuallyStoppedKey); + } + + public void SetIsServerManuallyStopped(bool isServerManuallyStopped) + { + SetBool(IsServerManuallyStoppedKey, isServerManuallyStopped); + } + public bool GetIsAfterCompile() { return GetBool(IsAfterCompileKey);