diff --git a/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs b/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs index 7405142fab..e004f53ec6 100644 --- a/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs +++ b/Assets/Tests/Editor/CliSetupApplicationServiceTests.cs @@ -33,12 +33,12 @@ public async Task InstallGlobalCliAsync_UsesMinimumRequiredCliReleaseTag() [Test] public void GetMinimumRequiredCliVersion_RequiresCompileRecoveryCliRelease() { - // Verifies this package release requires the CLI that polls accepted compile result files. + // Verifies this package release requires the CLI that polls stored compile status. CliSetupApplicationService service = new( new FakeCliInstallationDetector(new string[] { null }), new FakeNativeCliInstaller()); - Assert.That(service.GetMinimumRequiredCliVersion(), Is.EqualTo("3.0.0-beta.20")); + Assert.That(service.GetMinimumRequiredCliVersion(), Is.EqualTo("3.0.0-beta.22")); } [Test] @@ -49,7 +49,7 @@ public void GetMinimumRequiredCliReleaseTag_UsesCliGitHubReleaseTag() new FakeCliInstallationDetector(new string[] { null }), new FakeNativeCliInstaller()); - Assert.That(service.GetMinimumRequiredCliReleaseTag(), Is.EqualTo("cli-v3.0.0-beta.20")); + Assert.That(service.GetMinimumRequiredCliReleaseTag(), Is.EqualTo("cli-v3.0.0-beta.22")); } [Test] diff --git a/Assets/Tests/Editor/CompileResultPersistenceServiceTests.cs b/Assets/Tests/Editor/CompileResultPersistenceServiceTests.cs deleted file mode 100644 index 3044d291d3..0000000000 --- a/Assets/Tests/Editor/CompileResultPersistenceServiceTests.cs +++ /dev/null @@ -1,153 +0,0 @@ -using System; -using System.IO; -using Newtonsoft.Json; -using NUnit.Framework; - -using io.github.hatayama.UnityCliLoop.FirstPartyTools; -using io.github.hatayama.UnityCliLoop.ToolContracts; - -namespace io.github.hatayama.UnityCliLoop.Tests.Editor -{ - /// - /// Verifies compile result file persistence used by CLI domain-reload waiters. - /// - public sealed class CompileResultPersistenceServiceTests - { - [Test] - public void SaveResult_WhenRequestIdContainsPathSeparator_ThrowsArgumentException() - { - // Verifies result persistence rejects request IDs that could escape the result directory. - UnityCliLoopCompileResult result = CreateResult(success: true); - - Assert.That( - () => CompileResultPersistenceService.SaveResult("../unsafe", result), - Throws.TypeOf()); - } - - [Test] - public void IsSafe_WhenRequestIdUsesAllowedCharacters_ReturnsTrue() - { - // Verifies compile request ID validation accepts the CLI-generated character set. - bool isSafe = CompileRequestIdRules.IsSafe("compile_safe-123"); - - Assert.That(isSafe, Is.True); - } - - [Test] - public void IsSafe_WhenRequestIdContainsPathSeparator_ReturnsFalse() - { - // Verifies shared request ID validation rejects path separators. - bool isSafe = CompileRequestIdRules.IsSafe("../unsafe"); - - Assert.That(isSafe, Is.False); - } - - [Test] - public void SaveResult_WhenTargetIsMissing_PublishesCompleteJsonWithoutSidecars() - { - // Verifies CLI pollers read a complete JSON file after result persistence publishes it. - string requestId = CreateRequestId(); - string filePath = CreateResultFilePath(requestId); - DeleteResultFileSet(filePath); - - try - { - CompileResultPersistenceService.SaveResult(requestId, CreateResult(success: true)); - - string json = File.ReadAllText(filePath); - UnityCliLoopCompileResult restored = - JsonConvert.DeserializeObject(json); - Assert.That(restored.Success, Is.True); - Assert.That(File.Exists(filePath + ".tmp.write"), Is.False); - Assert.That(File.Exists(filePath + ".tmp"), Is.False); - } - finally - { - DeleteResultFileSet(filePath); - } - } - - [Test] - public void SaveResult_WhenTargetAlreadyExists_ReplacesPreviousJson() - { - // Verifies a repeated request ID publishes the newest complete compile result. - string requestId = CreateRequestId(); - string filePath = CreateResultFilePath(requestId); - DeleteResultFileSet(filePath); - - try - { - CompileResultPersistenceService.SaveResult(requestId, CreateResult(success: false)); - CompileResultPersistenceService.SaveResult(requestId, CreateResult(success: true)); - - string json = File.ReadAllText(filePath); - UnityCliLoopCompileResult restored = - JsonConvert.DeserializeObject(json); - Assert.That(restored.Success, Is.True); - } - finally - { - DeleteResultFileSet(filePath); - } - } - - private static UnityCliLoopCompileResult CreateResult(bool success) - { - return new UnityCliLoopCompileResult - { - Success = success, - ErrorCount = success ? 0 : 1, - WarningCount = 0, - Errors = success ? Array.Empty() : new[] - { - new UnityCliLoopCompileIssue - { - Message = "compile failed", - File = "", - Line = 0 - } - }, - Warnings = Array.Empty(), - ProjectRoot = "" - }; - } - - private static string CreateRequestId() - { - return "compile_persistence_test_" + Guid.NewGuid().ToString("N"); - } - - private static string CreateResultFilePath(string requestId) - { - return Path.Combine(CreateResultDirectoryPath(), requestId + UnityCliLoopConstants.JSON_FILE_EXTENSION); - } - - private static string CreateResultDirectoryPath() - { - string projectRoot = Path.GetFullPath(Path.Combine(UnityEngine.Application.dataPath, "..")); - return Path.Combine( - projectRoot, - UnityCliLoopConstants.TEMP_DIR, - UnityCliLoopConstants.UNITYCLILOOP_DIR, - UnityCliLoopConstants.COMPILE_RESULTS_DIR); - } - - private static void DeleteResultFileSet(string filePath) - { - string[] paths = - { - filePath, - filePath + ".tmp.write", - filePath + ".tmp", - filePath + ".bak" - }; - foreach (string path in paths) - { - if (File.Exists(path)) - { - File.Delete(path); - } - } - } - } -} diff --git a/Assets/Tests/Editor/CompileSessionResultServiceTests.cs b/Assets/Tests/Editor/CompileSessionResultServiceTests.cs new file mode 100644 index 0000000000..17a8b1a613 --- /dev/null +++ b/Assets/Tests/Editor/CompileSessionResultServiceTests.cs @@ -0,0 +1,110 @@ +using System; +using NUnit.Framework; +using UnityEditor.Compilation; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Tests compile result conversion before results are stored for CLI polling. + /// + [TestFixture] + public sealed class CompileSessionResultServiceTests + { + [Test] + public void CreateCompileResult_WhenNormalCompileCompletes_MapsDetailedIssues() + { + // Verifies normal compile results keep detailed error and warning entries. + CompilerMessage error = new CompilerMessage + { + type = CompilerMessageType.Error, + message = "error", + file = "Assets/Test.cs", + line = 12 + }; + CompilerMessage warning = new CompilerMessage + { + type = CompilerMessageType.Warning, + message = "warning", + file = "Assets/Test.cs", + line = 15 + }; + CompileResult result = new CompileResult( + success: false, + errorCount: 1, + warningCount: 1, + completedAt: DateTime.Now, + messages: new[] { error, warning }, + errors: new[] { error }, + warnings: new[] { warning }); + + UnityCliLoopCompileResult response = + CompileSessionResultService.CreateCompileResult(result, forceRecompile: false); + + Assert.That(response.Success, Is.False); + Assert.That(response.ErrorCount, Is.EqualTo(1)); + Assert.That(response.WarningCount, Is.EqualTo(1)); + Assert.That(response.Errors, Has.Length.EqualTo(1)); + Assert.That(response.Warnings, Has.Length.EqualTo(1)); + Assert.That(response.Errors[0].Message, Is.EqualTo("error")); + } + + [Test] + public void CreateCompileResult_WhenForceCompileIsUnknown_DropsDetailedIssues() + { + // Verifies force compile results do not pretend Unity returned detailed issue content. + CompilerMessage error = new CompilerMessage + { + type = CompilerMessageType.Error, + message = "error", + file = "Assets/Test.cs", + line = 12 + }; + CompileResult result = new CompileResult( + success: null, + errorCount: 1, + warningCount: 0, + completedAt: DateTime.Now, + messages: new[] { error }, + errors: new[] { error }, + warnings: Array.Empty(), + isIndeterminate: true, + message: null); + + UnityCliLoopCompileResult response = + CompileSessionResultService.CreateCompileResult(result, forceRecompile: true); + + Assert.That(response.Success, Is.Null); + Assert.That(response.ErrorCount, Is.Null); + Assert.That(response.WarningCount, Is.Null); + Assert.That(response.Errors, Is.Null); + Assert.That(response.Warnings, Is.Null); + Assert.That(response.Message, Is.Null); + } + + [Test] + public void CreateCompileResult_WhenForceCompileHasOutcome_DropsCountsAndDetailedIssues() + { + // Verifies force compile does not report counts or issue lists even when a high-level outcome exists. + CompileResult result = new CompileResult( + success: true, + errorCount: 0, + warningCount: 2, + completedAt: DateTime.Now, + messages: Array.Empty(), + errors: Array.Empty(), + warnings: Array.Empty()); + + UnityCliLoopCompileResult response = + CompileSessionResultService.CreateCompileResult(result, forceRecompile: true); + + Assert.That(response.Success, Is.True); + Assert.That(response.ErrorCount, Is.Null); + Assert.That(response.WarningCount, Is.Null); + Assert.That(response.Errors, Is.Null); + Assert.That(response.Warnings, Is.Null); + Assert.That(response.Message, Is.Null); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileResultPersistenceService.cs.meta b/Assets/Tests/Editor/CompileSessionResultServiceTests.cs.meta similarity index 83% rename from Packages/src/Editor/FirstPartyTools/Compile/CompileResultPersistenceService.cs.meta rename to Assets/Tests/Editor/CompileSessionResultServiceTests.cs.meta index 96fdda49e6..7c7bf46812 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompileResultPersistenceService.cs.meta +++ b/Assets/Tests/Editor/CompileSessionResultServiceTests.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: baa3261503cd245b09d0570f38d02c63 +guid: 4a9bec276c5f8493d8e4a0f03fcf435c MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Assets/Tests/Editor/CompileStatusBridgeCommandTests.cs b/Assets/Tests/Editor/CompileStatusBridgeCommandTests.cs new file mode 100644 index 0000000000..5207ace587 --- /dev/null +++ b/Assets/Tests/Editor/CompileStatusBridgeCommandTests.cs @@ -0,0 +1,193 @@ +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Domain; +using io.github.hatayama.UnityCliLoop.Infrastructure; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Tests compile status bridge responses without invoking Unity's real compiler. + /// + [TestFixture] + public sealed class CompileStatusBridgeCommandTests + { + private UnityCliLoopEditorSessionStateService _sessionStateService; + private UnityCliLoopEditorSessionStateSnapshot _originalSnapshot; + + [SetUp] + public void SetUp() + { + _sessionStateService = UnityCliLoopEditorSessionStateTestFactory.CreateService(); + _originalSnapshot = UnityCliLoopEditorSessionStateTestFactory.CaptureSnapshot(_sessionStateService); + _sessionStateService.ClearAll(); + } + + [TearDown] + public void TearDown() + { + _originalSnapshot.Restore(_sessionStateService); + } + + [Test] + public void BuildResponse_WhenUnityIsIdleAndResultMatches_ReturnsReadyResult() + { + // Verifies the CLI can retrieve the compile result once Unity has stopped compiling and reloading. + _sessionStateService.StoreCompileResult( + "compile_test_request", + forceRecompile: false, + resultJson: "{\"Success\":true}", + completedAtUtc: System.DateTime.UtcNow); + + GetCompileStatusResponse response = CompileStatusBridgeCommand.BuildResponse( + "compile_test_request", + isCompiling: false, + isUpdating: false, + isDomainReloadInProgress: false, + _sessionStateService); + + Assert.That(response.Ready, Is.True); + Assert.That(response.HasResult, Is.True); + Assert.That(response.Result, Is.Not.Null); + Assert.That(response.Result["Success"]?.Value(), Is.True); + } + + [Test] + public void BuildResponse_WhenUnityIsStillCompiling_ReturnsNotReadyWithStoredResult() + { + // Verifies compile status does not release the result before Unity can accept follow-up commands. + _sessionStateService.StoreCompileResult( + "compile_test_request", + forceRecompile: false, + resultJson: "{\"Success\":true}", + completedAtUtc: System.DateTime.UtcNow); + + GetCompileStatusResponse response = CompileStatusBridgeCommand.BuildResponse( + "compile_test_request", + isCompiling: true, + isUpdating: false, + isDomainReloadInProgress: false, + _sessionStateService); + + Assert.That(response.Ready, Is.False); + Assert.That(response.HasResult, Is.True); + Assert.That(response.Result, Is.TypeOf()); + } + + [Test] + public void BuildResponse_WhenRequestIdDiffers_DoesNotReturnStaleResult() + { + // Verifies an older compile result cannot satisfy a newer CLI request. + _sessionStateService.StoreCompileResult( + "compile_old_request", + forceRecompile: false, + resultJson: "{\"Success\":true}", + completedAtUtc: System.DateTime.UtcNow); + + GetCompileStatusResponse response = CompileStatusBridgeCommand.BuildResponse( + "compile_new_request", + isCompiling: false, + isUpdating: false, + isDomainReloadInProgress: false, + _sessionStateService); + + Assert.That(response.Ready, Is.True); + Assert.That(response.HasResult, Is.False); + Assert.That(response.Result, Is.Null); + } + + [Test] + public void BuildResponse_WhenUnityIsIdleAndPendingRequestHasNoReloadSignal_WaitsForResult() + { + // Verifies idle polling cannot complete while the original compile operation is still running. + _sessionStateService.MarkPendingCompileRequest( + "compile_test_request", + forceRecompile: false, + markedAtUtc: System.DateTime.UtcNow); + + GetCompileStatusResponse response = CompileStatusBridgeCommand.BuildResponse( + "compile_test_request", + isCompiling: false, + isUpdating: false, + isDomainReloadInProgress: false, + _sessionStateService); + + Assert.That(response.Ready, Is.True); + Assert.That(response.HasResult, Is.False); + Assert.That(response.Result, Is.Null); + Assert.That(_sessionStateService.GetPendingCompileRequest().HasRequest, Is.True); + } + + [Test] + public void BuildResponse_WhenUnityIsIdleAndPendingRequestObservedReload_ReturnsRecoveredResult() + { + // Verifies reload recovery returns an indeterminate result after Unity actually started Domain Reload. + _sessionStateService.MarkPendingCompileRequest( + "compile_test_request", + forceRecompile: false, + markedAtUtc: System.DateTime.UtcNow); + _sessionStateService.MarkPendingCompileRequestReloadObserved(); + + GetCompileStatusResponse response = CompileStatusBridgeCommand.BuildResponse( + "compile_test_request", + isCompiling: false, + isUpdating: false, + isDomainReloadInProgress: false, + _sessionStateService); + + Assert.That(response.Ready, Is.True); + Assert.That(response.HasResult, Is.True); + Assert.That(response.Result["Success"]?.Type, Is.EqualTo(JTokenType.Null)); + Assert.That(response.Result["ErrorCount"]?.Type, Is.EqualTo(JTokenType.Null)); + Assert.That(response.Result["Warnings"]?.Type, Is.EqualTo(JTokenType.Null)); + Assert.That(response.Result["Message"]?.ToString(), Does.Contain("reloaded scripts")); + Assert.That(_sessionStateService.GetPendingCompileRequest().HasRequest, Is.False); + } + + [Test] + public void BuildResponse_WhenUnityIsBusyAndPendingRequestHasNoResult_WaitsForReadiness() + { + // Verifies pending recovery does not publish an indeterminate result before Unity is idle. + _sessionStateService.MarkPendingCompileRequest( + "compile_test_request", + forceRecompile: false, + markedAtUtc: System.DateTime.UtcNow); + + GetCompileStatusResponse response = CompileStatusBridgeCommand.BuildResponse( + "compile_test_request", + isCompiling: true, + isUpdating: false, + isDomainReloadInProgress: false, + _sessionStateService); + + Assert.That(response.Ready, Is.False); + Assert.That(response.HasResult, Is.False); + Assert.That(response.Result, Is.Null); + Assert.That(_sessionStateService.GetPendingCompileRequest().HasRequest, Is.True); + } + + [Test] + public void BuildResponse_WhenForceCompilePendingRequestHasNoResult_ReturnsNullMessage() + { + // Verifies forced compile recovery preserves the no-details response shape. + _sessionStateService.MarkPendingCompileRequest( + "compile_test_request", + forceRecompile: true, + markedAtUtc: System.DateTime.UtcNow); + _sessionStateService.MarkPendingCompileRequestReloadObserved(); + + GetCompileStatusResponse response = CompileStatusBridgeCommand.BuildResponse( + "compile_test_request", + isCompiling: false, + isUpdating: false, + isDomainReloadInProgress: false, + _sessionStateService); + + Assert.That(response.Ready, Is.True); + Assert.That(response.HasResult, Is.True); + Assert.That(response.Result["Success"]?.Type, Is.EqualTo(JTokenType.Null)); + Assert.That(response.Result["ErrorCount"]?.Type, Is.EqualTo(JTokenType.Null)); + Assert.That(response.Result["Message"]?.Type, Is.EqualTo(JTokenType.Null)); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileDomainReloadRecoveryStartup.cs.meta b/Assets/Tests/Editor/CompileStatusBridgeCommandTests.cs.meta similarity index 83% rename from Packages/src/Editor/FirstPartyTools/Compile/CompileDomainReloadRecoveryStartup.cs.meta rename to Assets/Tests/Editor/CompileStatusBridgeCommandTests.cs.meta index 0b964930d6..8303dfe3e6 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompileDomainReloadRecoveryStartup.cs.meta +++ b/Assets/Tests/Editor/CompileStatusBridgeCommandTests.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: fc95b66d9c2d249d2ac7869b1fac0a05 +guid: e3f124a363df44ba496335a0a3dfc6c9 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Assets/Tests/Editor/PendingCompileResultRecoveryServiceTests.cs b/Assets/Tests/Editor/PendingCompileResultRecoveryServiceTests.cs deleted file mode 100644 index 535111963b..0000000000 --- a/Assets/Tests/Editor/PendingCompileResultRecoveryServiceTests.cs +++ /dev/null @@ -1,299 +0,0 @@ -using NUnit.Framework; -using System; - -using io.github.hatayama.UnityCliLoop.Domain; -using io.github.hatayama.UnityCliLoop.FirstPartyTools; - -namespace io.github.hatayama.UnityCliLoop.Tests.Editor -{ - /// - /// Tests pending compile result recovery without invoking Unity's real compiler. - /// - [TestFixture] - public sealed class PendingCompileResultRecoveryServiceTests - { - private UnityCliLoopEditorSessionStateService _sessionStateService; - private UnityCliLoopEditorSessionStateSnapshot _originalSessionState; - private DateTime _testUtcNow; - - [SetUp] - public void SetUp() - { - _sessionStateService = UnityCliLoopEditorSessionStateTestFactory.CreateService(); - _originalSessionState = UnityCliLoopEditorSessionStateTestFactory.CaptureSnapshot(_sessionStateService); - _sessionStateService.ClearAll(); - _testUtcNow = DateTime.UtcNow; - } - - [TearDown] - public void TearDown() - { - _originalSessionState.Restore(_sessionStateService); - } - - [Test] - public void Recover_WhenPendingResultIsMissing_PersistsIndeterminateResultAndClearsSession() - { - // Verifies Domain Reload recovery creates the result file that the CLI is polling. - _sessionStateService.MarkPendingCompileRequest("compile_test_request", forceRecompile: false); - string savedRequestId = ""; - UnityCliLoopCompileResult savedResult = null; - PendingCompileResultRecoveryService recoveryService = new PendingCompileResultRecoveryService( - _sessionStateService, - () => false, - _ => false, - (requestId, result) => - { - savedRequestId = requestId; - savedResult = result; - }, - () => "", - () => _testUtcNow); - - PendingCompileRecoveryStatus status = recoveryService.Recover(recoverWhileEditorCompiling: false); - - Assert.That(status, Is.EqualTo(PendingCompileRecoveryStatus.Completed)); - Assert.That(savedRequestId, Is.EqualTo("compile_test_request")); - Assert.That(savedResult, Is.Not.Null); - Assert.That(savedResult.Success, Is.Null); - Assert.That(savedResult.ErrorCount, Is.Null); - Assert.That(savedResult.WarningCount, Is.Null); - Assert.That(savedResult.ProjectRoot, Is.EqualTo("")); - Assert.That(_sessionStateService.GetPendingCompileRequest().HasRequest, Is.False); - } - - [Test] - public void Recover_WhenResultAlreadyExists_ClearsSessionWithoutSaving() - { - // Verifies recovery does not overwrite a result that normal compile persistence already wrote. - _sessionStateService.MarkPendingCompileRequest("compile_test_request", forceRecompile: false); - int saveCallCount = 0; - PendingCompileResultRecoveryService recoveryService = new PendingCompileResultRecoveryService( - _sessionStateService, - () => false, - _ => true, - (_, _) => saveCallCount++, - () => "", - () => _testUtcNow); - - PendingCompileRecoveryStatus status = recoveryService.Recover(recoverWhileEditorCompiling: false); - - Assert.That(status, Is.EqualTo(PendingCompileRecoveryStatus.Completed)); - Assert.That(saveCallCount, Is.EqualTo(0)); - Assert.That(_sessionStateService.GetPendingCompileRequest().HasRequest, Is.False); - } - - [Test] - public void Recover_WhenPendingRequestIsExpired_ClearsSessionWithoutSaving() - { - // Verifies stale compile recovery data does not create a result for a canceled command later. - _sessionStateService.MarkPendingCompileRequestWithExpiration( - "compile_test_request", - forceRecompile: false, - expiresAtUtcTicks: _testUtcNow.AddSeconds(-1).Ticks); - int saveCallCount = 0; - PendingCompileResultRecoveryService recoveryService = new PendingCompileResultRecoveryService( - _sessionStateService, - () => false, - _ => false, - (_, _) => saveCallCount++, - () => "", - () => _testUtcNow); - - PendingCompileRecoveryStatus status = recoveryService.Recover(recoverWhileEditorCompiling: false); - - Assert.That(status, Is.EqualTo(PendingCompileRecoveryStatus.Completed)); - Assert.That(saveCallCount, Is.EqualTo(0)); - Assert.That(_sessionStateService.GetPendingCompileRequest().HasRequest, Is.False); - } - - [Test] - public void MarkPendingCompileRequest_WhenCreated_OutlivesAcceptedCompileWaitBudget() - { - // Verifies long accepted compiles keep recovery state until the CLI wait budget has elapsed. - DateTime markedAtUtc = DateTime.UtcNow; - - _sessionStateService.MarkPendingCompileRequest("compile_test_request", forceRecompile: true); - UnityCliLoopPendingCompileRequest pendingCompileRequest = - _sessionStateService.GetPendingCompileRequest(); - DateTime expiresAtUtc = new DateTime( - pendingCompileRequest.ExpiresAtUtcTicks, - DateTimeKind.Utc); - TimeSpan lifetime = expiresAtUtc - markedAtUtc; - - Assert.That(pendingCompileRequest.HasRequest, Is.True); - Assert.That(lifetime, Is.GreaterThanOrEqualTo(TimeSpan.FromMinutes(31))); - } - - [Test] - public void Recover_WhenEditorIsStillCompiling_KeepsPendingRequestForRetry() - { - // Verifies recovery waits briefly while Unity still reports an active compile. - _sessionStateService.MarkPendingCompileRequest("compile_test_request", forceRecompile: true); - int saveCallCount = 0; - PendingCompileResultRecoveryService recoveryService = new PendingCompileResultRecoveryService( - _sessionStateService, - () => true, - _ => false, - (_, _) => saveCallCount++, - () => "", - () => _testUtcNow); - - PendingCompileRecoveryStatus status = recoveryService.Recover(recoverWhileEditorCompiling: false); - - UnityCliLoopPendingCompileRequest pendingCompileRequest = - _sessionStateService.GetPendingCompileRequest(); - Assert.That(status, Is.EqualTo(PendingCompileRecoveryStatus.Retry)); - Assert.That(saveCallCount, Is.EqualTo(0)); - Assert.That(pendingCompileRequest.HasRequest, Is.True); - Assert.That(pendingCompileRequest.RequestId, Is.EqualTo("compile_test_request")); - } - - [Test] - public void Recover_WhenForcedAfterWait_PersistsForcedCompileMessage() - { - // Verifies timeout recovery still completes forced compile requests that never become idle. - _sessionStateService.MarkPendingCompileRequest("compile_test_request", forceRecompile: true); - UnityCliLoopCompileResult savedResult = null; - PendingCompileResultRecoveryService recoveryService = new PendingCompileResultRecoveryService( - _sessionStateService, - () => true, - _ => false, - (_, result) => savedResult = result, - () => "", - () => _testUtcNow); - - PendingCompileRecoveryStatus status = recoveryService.Recover(recoverWhileEditorCompiling: true); - - Assert.That(status, Is.EqualTo(PendingCompileRecoveryStatus.Completed)); - Assert.That(savedResult, Is.Not.Null); - Assert.That(savedResult.Message, Does.Contain("Force compilation")); - Assert.That(savedResult.ErrorCount, Is.Null); - Assert.That(savedResult.WarningCount, Is.Null); - Assert.That(_sessionStateService.GetPendingCompileRequest().HasRequest, Is.False); - } - - [Test] - public void ShouldClearPendingCompileRequestAfterCancellation_WhenCallerCancelsBeforeReload_ReturnsTrue() - { - // Verifies caller cancellation clears pending recovery before it can become stale. - UnityCliLoopCompileRequest request = CreateCompileRequest(waitForDomainReload: true); - - bool shouldClear = CompileUseCase.ShouldClearPendingCompileRequestAfterCancellation( - request, - isCancellationRequested: true, - isDomainReloadInProgress: false); - - Assert.That(shouldClear, Is.True); - } - - [Test] - public void ShouldClearPendingCompileRequestAfterCancellation_WhenDomainReloadCancels_ReturnsFalse() - { - // Verifies Domain Reload cancellation keeps pending recovery available after reload. - UnityCliLoopCompileRequest request = CreateCompileRequest(waitForDomainReload: true); - - bool shouldClear = CompileUseCase.ShouldClearPendingCompileRequestAfterCancellation( - request, - isCancellationRequested: true, - isDomainReloadInProgress: true); - - Assert.That(shouldClear, Is.False); - } - - [Test] - public void ShouldClearPendingCompileRequestAfterCancellation_WhenNoReloadWait_ReturnsFalse() - { - // Verifies fire-and-forget compile requests do not touch pending reload recovery state. - UnityCliLoopCompileRequest request = CreateCompileRequest(waitForDomainReload: false); - - bool shouldClear = CompileUseCase.ShouldClearPendingCompileRequestAfterCancellation( - request, - isCancellationRequested: true, - isDomainReloadInProgress: false); - - Assert.That(shouldClear, Is.False); - } - - [Test] - public void ShouldClearPendingCompileRequestAfterInterruptedCompile_WhenCompileFailsBeforeReload_ReturnsTrue() - { - // Verifies non-cancellation failures clear pending recovery before it can become stale. - UnityCliLoopCompileRequest request = CreateCompileRequest(waitForDomainReload: true); - - bool shouldClear = CompileUseCase.ShouldClearPendingCompileRequestAfterInterruptedCompile( - request, - resultPersistenceCompleted: false, - isCancellationRequested: false, - isDomainReloadInProgress: false); - - Assert.That(shouldClear, Is.True); - } - - [Test] - public void ShouldClearPendingCompileRequestAfterInterruptedCompile_WhenResultPersistenceCompleted_ReturnsFalse() - { - // Verifies successful result persistence owns pending recovery cleanup. - UnityCliLoopCompileRequest request = CreateCompileRequest(waitForDomainReload: true); - - bool shouldClear = CompileUseCase.ShouldClearPendingCompileRequestAfterInterruptedCompile( - request, - resultPersistenceCompleted: true, - isCancellationRequested: false, - isDomainReloadInProgress: false); - - Assert.That(shouldClear, Is.False); - } - - [Test] - public void ShouldClearPendingCompileRequestAfterInterruptedCompile_WhenDomainReloadStarted_ReturnsFalse() - { - // Verifies Domain Reload recovery keeps ownership once Unity has begun reloading scripts. - UnityCliLoopCompileRequest request = CreateCompileRequest(waitForDomainReload: true); - - bool shouldClear = CompileUseCase.ShouldClearPendingCompileRequestAfterInterruptedCompile( - request, - resultPersistenceCompleted: false, - isCancellationRequested: false, - isDomainReloadInProgress: true); - - Assert.That(shouldClear, Is.False); - } - - [Test] - public void ShouldRecoverWhileEditorCompiling_WhenElapsedTimeIsBelowLimit_ReturnsFalse() - { - // Verifies recovery polling uses real elapsed time instead of editor frame count. - DateTime startedAtUtc = new DateTime(2026, 5, 30, 0, 0, 0, DateTimeKind.Utc); - DateTime utcNow = startedAtUtc.AddMilliseconds(4999); - - bool shouldRecover = - CompileDomainReloadRecoveryStartup.ShouldRecoverWhileEditorCompiling(startedAtUtc, utcNow); - - Assert.That(shouldRecover, Is.False); - } - - [Test] - public void ShouldRecoverWhileEditorCompiling_WhenElapsedTimeReachesLimit_ReturnsTrue() - { - // Verifies recovery can synthesize an indeterminate result after the real timeout elapses. - DateTime startedAtUtc = new DateTime(2026, 5, 30, 0, 0, 0, DateTimeKind.Utc); - DateTime utcNow = startedAtUtc.AddMilliseconds(5000); - - bool shouldRecover = - CompileDomainReloadRecoveryStartup.ShouldRecoverWhileEditorCompiling(startedAtUtc, utcNow); - - Assert.That(shouldRecover, Is.True); - } - - private static UnityCliLoopCompileRequest CreateCompileRequest(bool waitForDomainReload) - { - return new UnityCliLoopCompileRequest - { - ForceRecompile = false, - WaitForDomainReload = waitForDomainReload, - RequestId = "compile_test_request" - }; - } - } -} diff --git a/Assets/Tests/Editor/UnityCliLoopEditorSessionStateRepositoryTests.cs b/Assets/Tests/Editor/UnityCliLoopEditorSessionStateRepositoryTests.cs index 2799d53398..047418825c 100644 --- a/Assets/Tests/Editor/UnityCliLoopEditorSessionStateRepositoryTests.cs +++ b/Assets/Tests/Editor/UnityCliLoopEditorSessionStateRepositoryTests.cs @@ -40,7 +40,6 @@ public void GetFlags_WhenSessionStateIsEmpty_ReturnsFalseDefaults() Assert.That(_sessionStateService.GetShowReconnectingUI(), Is.False); Assert.That(_sessionStateService.GetShowPostCompileReconnectingUI(), Is.False); Assert.That(_sessionStateService.GetShouldAutoScanThirdPartyToolMigration(), Is.False); - Assert.That(_sessionStateService.GetPendingCompileRequest().HasRequest, Is.False); } [Test] @@ -74,49 +73,187 @@ public void GetShouldAutoScanThirdPartyToolMigration_WhenServiceIsRecreated_Read } [Test] - public void GetPendingCompileRequest_WhenServiceIsRecreated_ReadsExistingSessionValue() + public void GetCompileResult_WhenRequestIdMatches_ReturnsStoredJson() { - // Verifies pending compile recovery data survives Domain Reload service recreation. - _sessionStateService.MarkPendingCompileRequest("compile_test_request", forceRecompile: true); + // Verifies compile results survive service recreation and are keyed by request id. + DateTime completedAtUtc = new DateTime(2026, 5, 30, 0, 0, 0, DateTimeKind.Utc); + _sessionStateService.StoreCompileResult( + "compile_test_request", + forceRecompile: true, + resultJson: "{\"Success\":null}", + completedAtUtc: completedAtUtc); UnityCliLoopEditorSessionStateService recreatedService = UnityCliLoopEditorSessionStateTestFactory.CreateService(); + UnityCliLoopStoredCompileResult storedResult = + recreatedService.GetCompileResult("compile_test_request"); + + Assert.That(storedResult.HasResult, Is.True); + Assert.That(storedResult.RequestId, Is.EqualTo("compile_test_request")); + Assert.That(storedResult.ForceRecompile, Is.True); + Assert.That(storedResult.ResultJson, Is.EqualTo("{\"Success\":null}")); + Assert.That(storedResult.CompletedAtUtcTicks, Is.EqualTo(completedAtUtc.Ticks)); + } - UnityCliLoopPendingCompileRequest pendingCompileRequest = - recreatedService.GetPendingCompileRequest(); - Assert.That(pendingCompileRequest.HasRequest, Is.True); - Assert.That(pendingCompileRequest.RequestId, Is.EqualTo("compile_test_request")); - Assert.That(pendingCompileRequest.ForceRecompile, Is.True); - Assert.That(pendingCompileRequest.ExpiresAtUtcTicks, Is.GreaterThan(DateTime.UtcNow.Ticks)); + [Test] + public void GetCompileResult_WhenRequestIdDiffers_ReturnsEmptyResult() + { + // Verifies stale results from older compile commands are never returned to a new request. + _sessionStateService.StoreCompileResult( + "compile_old_request", + forceRecompile: false, + resultJson: "{\"Success\":true}", + completedAtUtc: DateTime.UtcNow); + + UnityCliLoopStoredCompileResult storedResult = + _sessionStateService.GetCompileResult("compile_new_request"); + + Assert.That(storedResult.HasResult, Is.False); + } + + [Test] + public void GetCompileResult_WhenAnotherRequestStoresLater_ReturnsOriginalResult() + { + // Verifies compile results are keyed by request id instead of a single global slot. + _sessionStateService.StoreCompileResult( + "compile_first_request", + forceRecompile: false, + resultJson: "{\"Success\":true}", + completedAtUtc: DateTime.UtcNow); + _sessionStateService.StoreCompileResult( + "compile_second_request", + forceRecompile: true, + resultJson: "{\"Success\":null}", + completedAtUtc: DateTime.UtcNow); + + UnityCliLoopStoredCompileResult firstResult = + _sessionStateService.GetCompileResult("compile_first_request"); + UnityCliLoopStoredCompileResult secondResult = + _sessionStateService.GetCompileResult("compile_second_request"); + + Assert.That(firstResult.HasResult, Is.True); + Assert.That(firstResult.ResultJson, Is.EqualTo("{\"Success\":true}")); + Assert.That(secondResult.HasResult, Is.True); + Assert.That(secondResult.ResultJson, Is.EqualTo("{\"Success\":null}")); } [Test] - public void GetPendingCompileRequest_WhenExpirationTicksAreMalformed_ClearsSessionValue() + public void GetCompileResult_WhenLegacySingleSlotExists_ReturnsLegacyResult() { - // Verifies malformed compile recovery data self-heals instead of breaking startup recovery. + // Verifies an in-flight compile that started on the old storage key can finish after this assembly reloads. UnityCliLoopEditorSessionStateRepository repository = new UnityCliLoopEditorSessionStateRepository(); - repository.SetPendingCompileRequestId("compile_test_request"); - repository.SetPendingCompileForceRecompile(true); - repository.SetPendingCompileExpiresAtUtcTicks("not_ticks"); + DateTime completedAtUtc = new DateTime(2026, 5, 30, 0, 0, 0, DateTimeKind.Utc); + repository.SetLegacyCompileResultRequestId("compile_legacy_request"); + repository.SetLegacyCompileResultForceRecompile(false); + repository.SetLegacyCompileResultJson("{\"Success\":true}"); + repository.SetLegacyCompileResultCompletedAtUtcTicks(completedAtUtc.Ticks.ToString()); UnityCliLoopEditorSessionStateService recreatedService = new UnityCliLoopEditorSessionStateService(repository); - UnityCliLoopPendingCompileRequest pendingCompileRequest = + UnityCliLoopStoredCompileResult storedResult = + recreatedService.GetCompileResult("compile_legacy_request"); + + Assert.That(storedResult.HasResult, Is.True); + Assert.That(storedResult.ResultJson, Is.EqualTo("{\"Success\":true}")); + Assert.That(repository.GetLegacyCompileResultRequestId(), Is.Empty); + } + + [Test] + public void GetPendingCompileRequest_WhenServiceIsRecreated_ReturnsPendingRequest() + { + // Verifies pending compile recovery state survives Domain Reload service recreation. + DateTime markedAtUtc = new DateTime(2026, 5, 30, 0, 0, 0, DateTimeKind.Utc); + _sessionStateService.MarkPendingCompileRequest( + "compile_test_request", + forceRecompile: true, + markedAtUtc: markedAtUtc); + + UnityCliLoopEditorSessionStateService recreatedService = + UnityCliLoopEditorSessionStateTestFactory.CreateService(); + UnityCliLoopPendingCompileRequest pendingRequest = recreatedService.GetPendingCompileRequest(); - Assert.That(pendingCompileRequest.HasRequest, Is.False); - Assert.That(recreatedService.GetPendingCompileRequest().HasRequest, Is.False); + Assert.That(pendingRequest.HasRequest, Is.True); + Assert.That(pendingRequest.RequestId, Is.EqualTo("compile_test_request")); + Assert.That(pendingRequest.ForceRecompile, Is.True); + Assert.That(pendingRequest.ExpiresAtUtcTicks, Is.GreaterThan(markedAtUtc.Ticks)); + Assert.That(pendingRequest.ReloadObserved, Is.False); + } + + [Test] + public void GetPendingCompileRequestForRequestId_WhenAnotherRequestStoresLater_ReturnsOriginalRequest() + { + // Verifies pending compile requests are keyed by request id instead of a single global slot. + DateTime markedAtUtc = new DateTime(2026, 5, 30, 0, 0, 0, DateTimeKind.Utc); + _sessionStateService.MarkPendingCompileRequest( + "compile_first_request", + forceRecompile: false, + markedAtUtc: markedAtUtc); + _sessionStateService.MarkPendingCompileRequest( + "compile_second_request", + forceRecompile: true, + markedAtUtc: markedAtUtc); + + UnityCliLoopPendingCompileRequest firstRequest = + _sessionStateService.GetPendingCompileRequestForRequestId("compile_first_request"); + UnityCliLoopPendingCompileRequest secondRequest = + _sessionStateService.GetPendingCompileRequestForRequestId("compile_second_request"); + + Assert.That(firstRequest.HasRequest, Is.True); + Assert.That(firstRequest.ForceRecompile, Is.False); + Assert.That(secondRequest.HasRequest, Is.True); + Assert.That(secondRequest.ForceRecompile, Is.True); + } + + [Test] + public void GetPendingCompileRequestForRequestId_WhenLegacySingleSlotExists_ReturnsLegacyRequest() + { + // Verifies pending compile recovery can see a request marked before this assembly reloads. + UnityCliLoopEditorSessionStateRepository repository = new UnityCliLoopEditorSessionStateRepository(); + DateTime expiresAtUtc = new DateTime(2026, 5, 30, 0, 32, 0, DateTimeKind.Utc); + repository.SetLegacyPendingCompileRequestId("compile_legacy_request"); + repository.SetLegacyPendingCompileForceRecompile(true); + repository.SetLegacyPendingCompileExpiresAtUtcTicks(expiresAtUtc.Ticks.ToString()); + repository.SetLegacyPendingCompileReloadObserved(true); + UnityCliLoopEditorSessionStateService recreatedService = + new UnityCliLoopEditorSessionStateService(repository); + + UnityCliLoopPendingCompileRequest pendingRequest = + recreatedService.GetPendingCompileRequestForRequestId("compile_legacy_request"); + + Assert.That(pendingRequest.HasRequest, Is.True); + Assert.That(pendingRequest.ForceRecompile, Is.True); + Assert.That(pendingRequest.ReloadObserved, Is.True); + Assert.That(repository.GetLegacyPendingCompileRequestId(), Is.Empty); + } + + [Test] + public void MarkDomainReloadStarted_WhenPendingCompileRequestExists_MarksReloadObserved() + { + // Verifies pending compile recovery only becomes eligible after Unity starts Domain Reload. + _sessionStateService.MarkPendingCompileRequest( + "compile_test_request", + forceRecompile: false, + markedAtUtc: DateTime.UtcNow); + + _sessionStateService.MarkDomainReloadStarted(serverIsRunning: true); + + UnityCliLoopPendingCompileRequest pendingRequest = + _sessionStateService.GetPendingCompileRequest(); + Assert.That(pendingRequest.HasRequest, Is.True); + Assert.That(pendingRequest.ReloadObserved, Is.True); } [Test] - public void ClearExpiredPendingCompileRequest_WhenRequestIsExpired_ClearsSessionValue() + public void ClearExpiredPendingCompileRequest_WhenPendingRequestIsStale_ClearsSessionValue() { - // Verifies stale compile recovery data cannot survive indefinitely across commands. - DateTime now = new DateTime(2026, 5, 30, 0, 0, 0, DateTimeKind.Utc); - _sessionStateService.MarkPendingCompileRequestWithExpiration( + // Verifies stale pending compile recovery state cannot satisfy later CLI polling. + DateTime markedAtUtc = new DateTime(2026, 5, 30, 0, 0, 0, DateTimeKind.Utc); + DateTime now = new DateTime(2026, 5, 30, 0, 32, 1, DateTimeKind.Utc); + _sessionStateService.MarkPendingCompileRequest( "compile_test_request", forceRecompile: false, - expiresAtUtcTicks: now.AddSeconds(-1).Ticks); + markedAtUtc: markedAtUtc); bool cleared = _sessionStateService.ClearExpiredPendingCompileRequest(now); @@ -125,22 +262,73 @@ public void ClearExpiredPendingCompileRequest_WhenRequestIsExpired_ClearsSession } [Test] - public void ClearExpiredPendingCompileRequest_WhenRequestIsFresh_KeepsSessionValue() + public void ClearPendingCompileRequestIfMatches_WhenRequestIdMatches_ClearsSessionValue() { - // Verifies active compile recovery data is not cleared before its deadline. - DateTime now = new DateTime(2026, 5, 30, 0, 0, 0, DateTimeKind.Utc); - _sessionStateService.MarkPendingCompileRequestWithExpiration( + // Verifies stored compile results can clear their matching pending recovery state. + _sessionStateService.MarkPendingCompileRequest( "compile_test_request", forceRecompile: false, - expiresAtUtcTicks: now.AddSeconds(1).Ticks); + markedAtUtc: DateTime.UtcNow); - bool cleared = _sessionStateService.ClearExpiredPendingCompileRequest(now); + bool cleared = _sessionStateService.ClearPendingCompileRequestIfMatches("compile_test_request"); - UnityCliLoopPendingCompileRequest pendingCompileRequest = - _sessionStateService.GetPendingCompileRequest(); - Assert.That(cleared, Is.False); - Assert.That(pendingCompileRequest.HasRequest, Is.True); - Assert.That(pendingCompileRequest.RequestId, Is.EqualTo("compile_test_request")); + Assert.That(cleared, Is.True); + Assert.That(_sessionStateService.GetPendingCompileRequest().HasRequest, Is.False); + } + + [Test] + public void GetStoredCompileResult_WhenCompletedTicksAreMalformed_ClearsSessionValue() + { + // Verifies malformed stored compile results self-heal instead of breaking status polling. + UnityCliLoopEditorSessionStateRepository repository = new UnityCliLoopEditorSessionStateRepository(); + repository.SetCompileResultRequestIds("compile_test_request"); + repository.SetCompileResultForceRecompile("compile_test_request", false); + repository.SetCompileResultJson("compile_test_request", "{\"Success\":true}"); + repository.SetCompileResultCompletedAtUtcTicks("compile_test_request", "not_ticks"); + UnityCliLoopEditorSessionStateService recreatedService = + new UnityCliLoopEditorSessionStateService(repository); + + UnityCliLoopStoredCompileResult storedResult = + recreatedService.GetStoredCompileResult(); + + Assert.That(storedResult.HasResult, Is.False); + Assert.That(recreatedService.GetStoredCompileResult().HasResult, Is.False); + } + + [Test] + public void GetCompileResult_WhenLegacyCompletedTicksAreOutOfRange_ClearsLegacySessionValue() + { + // Verifies legacy compile result migration rejects ticks that cannot become a UTC DateTime. + UnityCliLoopEditorSessionStateRepository repository = new UnityCliLoopEditorSessionStateRepository(); + repository.SetLegacyCompileResultRequestId("compile_legacy_request"); + repository.SetLegacyCompileResultForceRecompile(false); + repository.SetLegacyCompileResultJson("{\"Success\":true}"); + repository.SetLegacyCompileResultCompletedAtUtcTicks(long.MaxValue.ToString()); + UnityCliLoopEditorSessionStateService recreatedService = + new UnityCliLoopEditorSessionStateService(repository); + + UnityCliLoopStoredCompileResult storedResult = + recreatedService.GetCompileResult("compile_legacy_request"); + + Assert.That(storedResult.HasResult, Is.False); + Assert.That(repository.GetLegacyCompileResultRequestId(), Is.Empty); + } + + [Test] + public void ClearExpiredCompileResult_WhenResultIsStale_ClearsSessionValue() + { + // Verifies stale compile results do not survive indefinitely across commands. + DateTime now = new DateTime(2026, 5, 30, 0, 32, 1, DateTimeKind.Utc); + _sessionStateService.StoreCompileResult( + "compile_test_request", + forceRecompile: false, + resultJson: "{\"Success\":true}", + completedAtUtc: new DateTime(2026, 5, 30, 0, 0, 0, DateTimeKind.Utc)); + + bool cleared = _sessionStateService.ClearExpiredCompileResult(now); + + Assert.That(cleared, Is.True); + Assert.That(_sessionStateService.GetStoredCompileResult().HasResult, Is.False); } [Test] @@ -164,7 +352,11 @@ public void ClearAll_WhenFlagsAreSet_ClearsEveryTransientFlag() _sessionStateService.MarkDomainReloadStarted(serverIsRunning: true); _sessionStateService.SetShouldAutoScanThirdPartyToolMigration(true); _sessionStateService.SetIsServerManuallyStopped(true); - _sessionStateService.MarkPendingCompileRequest("compile_test_request", forceRecompile: false); + _sessionStateService.StoreCompileResult( + "compile_test_request", + forceRecompile: false, + resultJson: "{\"Success\":true}", + completedAtUtc: DateTime.UtcNow); _sessionStateService.ClearAll(); @@ -176,6 +368,7 @@ public void ClearAll_WhenFlagsAreSet_ClearsEveryTransientFlag() Assert.That(_sessionStateService.GetShowPostCompileReconnectingUI(), Is.False); Assert.That(_sessionStateService.GetShouldAutoScanThirdPartyToolMigration(), Is.False); Assert.That(_sessionStateService.GetIsServerManuallyStopped(), Is.False); + Assert.That(_sessionStateService.GetStoredCompileResult().HasResult, Is.False); Assert.That(_sessionStateService.GetPendingCompileRequest().HasRequest, Is.False); } diff --git a/Assets/Tests/Editor/UnityCliLoopEditorSessionStateTestFactory.cs b/Assets/Tests/Editor/UnityCliLoopEditorSessionStateTestFactory.cs index 53313c0b7c..e43acd12fa 100644 --- a/Assets/Tests/Editor/UnityCliLoopEditorSessionStateTestFactory.cs +++ b/Assets/Tests/Editor/UnityCliLoopEditorSessionStateTestFactory.cs @@ -33,7 +33,8 @@ internal readonly struct UnityCliLoopEditorSessionStateSnapshot private readonly bool _showReconnectingUI; private readonly bool _showPostCompileReconnectingUI; private readonly bool _shouldAutoScanThirdPartyToolMigration; - private readonly UnityCliLoopPendingCompileRequest _pendingCompileRequest; + private readonly UnityCliLoopStoredCompileResult[] _compileResults; + private readonly UnityCliLoopPendingCompileRequest[] _pendingCompileRequests; private UnityCliLoopEditorSessionStateSnapshot(UnityCliLoopEditorSessionStateService service) { @@ -45,7 +46,8 @@ private UnityCliLoopEditorSessionStateSnapshot(UnityCliLoopEditorSessionStateSer _showReconnectingUI = service.GetShowReconnectingUI(); _showPostCompileReconnectingUI = service.GetShowPostCompileReconnectingUI(); _shouldAutoScanThirdPartyToolMigration = service.GetShouldAutoScanThirdPartyToolMigration(); - _pendingCompileRequest = service.GetPendingCompileRequest(); + _compileResults = service.GetStoredCompileResults(); + _pendingCompileRequests = service.GetPendingCompileRequests(); } internal static UnityCliLoopEditorSessionStateSnapshot Capture( @@ -64,16 +66,25 @@ internal void Restore(UnityCliLoopEditorSessionStateService service) service.SetShowReconnectingUI(_showReconnectingUI); service.SetShowPostCompileReconnectingUI(_showPostCompileReconnectingUI); service.SetShouldAutoScanThirdPartyToolMigration(_shouldAutoScanThirdPartyToolMigration); - if (_pendingCompileRequest.HasRequest) + service.ClearCompileResult(); + foreach (UnityCliLoopStoredCompileResult compileResult in _compileResults) { - service.MarkPendingCompileRequestWithExpiration( - _pendingCompileRequest.RequestId, - _pendingCompileRequest.ForceRecompile, - _pendingCompileRequest.ExpiresAtUtcTicks); - return; + service.StoreCompileResult( + compileResult.RequestId, + compileResult.ForceRecompile, + compileResult.ResultJson, + new System.DateTime(compileResult.CompletedAtUtcTicks, System.DateTimeKind.Utc)); } service.ClearPendingCompileRequest(); + foreach (UnityCliLoopPendingCompileRequest pendingCompileRequest in _pendingCompileRequests) + { + service.StorePendingCompileRequest( + pendingCompileRequest.RequestId, + pendingCompileRequest.ForceRecompile, + pendingCompileRequest.ExpiresAtUtcTicks, + pendingCompileRequest.ReloadObserved); + } } } } diff --git a/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs b/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs index 59444266d5..b273491233 100644 --- a/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs +++ b/Assets/Tests/Editor/UnityCliLoopToolRegistryTests.cs @@ -326,6 +326,19 @@ public async Task ExecuteCommandAsync_WhenCommandIsGetToolDetails_ReturnsCatalog Assert.That(toolNames, Does.Not.Contain("debug-sleep")); } + [Test] + public async Task ExecuteCommandAsync_WhenCommandIsGetCompileStatus_ReturnsBridgeStatusPayload() + { + // Tests that compile status polling routes as a CLI-only bridge command, not a public tool. + UnityCliLoopToolResponse response = await UnityApiHandler.ExecuteCommandAsync( + UnityCliLoopConstants.COMMAND_NAME_GET_COMPILE_STATUS, + new JObject(), + CancellationToken.None); + + GetCompileStatusResponse getCompileStatusResponse = response as GetCompileStatusResponse; + Assert.That(getCompileStatusResponse, Is.Not.Null); + } + [Test] public void Constructor_WhenLegacyDevelopmentToolsAreRemoved_DoesNotRegisterThem() { diff --git a/Packages/src/Cli~/contract.json b/Packages/src/Cli~/contract.json index 8b84728271..83282ee0e1 100644 --- a/Packages/src/Cli~/contract.json +++ b/Packages/src/Cli~/contract.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "cliVersion": "3.0.0-beta.21" + "cliVersion": "3.0.0-beta.22" } diff --git a/Packages/src/Cli~/dist/darwin-amd64/uloop b/Packages/src/Cli~/dist/darwin-amd64/uloop index 0367ea3d09..63ac15ab75 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 ccf112beb7..62d750e452 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 32e2c1452b..683948aa85 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/compile_wait.go b/Packages/src/Cli~/internal/cli/compile_wait.go index bf64d892c2..e613c64d77 100644 --- a/Packages/src/Cli~/internal/cli/compile_wait.go +++ b/Packages/src/Cli~/internal/cli/compile_wait.go @@ -1,14 +1,11 @@ package cli import ( - "bytes" "context" "crypto/rand" "encoding/hex" "encoding/json" "fmt" - "os" - "path/filepath" "strings" "time" @@ -16,23 +13,37 @@ import ( ) const ( - compileCommandName = "compile" - compileRequestIDParam = "RequestId" - compileWaitParam = domainReloadWaitParam - compileResultRelativeDir = "Temp/UnityCliLoop/compile-results" - compileWaitTimeout = 90 * time.Second - compileWaitPollInterval = 50 * time.Millisecond - compileLockGracePeriod = 500 * time.Millisecond + compileCommandName = "compile" + compileStatusCommandName = "get-compile-status" + compileRequestIDParam = "RequestId" + compileWaitParam = domainReloadWaitParam + compileForceParam = "ForceRecompile" + compileWaitTimeout = toolReadinessTimeout + compileWaitPollInterval = toolReadinessPoll + compileStatusProbeTimeout = toolReadinessProbeTimeout + compileResponseTimeout = 2 * time.Second ) type compileCompletionOptions struct { - projectRoot string - requestID string - timeout time.Duration - pollInterval time.Duration - lockGrace time.Duration + connection unityipc.Connection + requestID string + forceRecompile bool + timeout time.Duration + pollInterval time.Duration } +type compileStatusResponse struct { + Ready bool `json:"Ready"` + HasResult bool `json:"HasResult"` + IsCompiling bool `json:"IsCompiling"` + IsUpdating bool `json:"IsUpdating"` + IsDomainReloadInProgress bool `json:"IsDomainReloadInProgress"` + Result json.RawMessage `json:"Result"` + Message string `json:"Message"` +} + +var queryCompileStatus = queryCompileStatusFromUnity + func shouldWaitForCompileDomainReload(command string, params map[string]any) bool { if command != compileCommandName { return false @@ -92,58 +103,58 @@ func isSafeCompileRequestID(requestID string) bool { } func waitForCompileCompletion(ctx context.Context, options compileCompletionOptions) (json.RawMessage, bool, error) { - deadline := time.Now().Add(options.timeout) - var idleSince time.Time + startedAt := time.Now() + deadline := startedAt.Add(options.timeout) - for time.Now().Before(deadline) { - result, err := tryReadCompileResult(options.projectRoot, options.requestID) - if err != nil { - return nil, false, err + for { + now := time.Now() + if !now.Before(deadline) { + break } - if len(result) > 0 { - if idleSince.IsZero() { - idleSince = time.Now() - } - if time.Since(idleSince) >= options.lockGrace { - return result, true, nil - } - } else { - idleSince = time.Time{} + status, err := queryCompileStatus(ctx, options.connection, options.requestID) + if err == nil && status.Ready && status.HasResult && len(status.Result) > 0 { + return status.Result, true, nil } select { case <-ctx.Done(): + logCompileWaitCancelled(options, startedAt, ctx.Err()) return nil, false, ctx.Err() case <-time.After(options.pollInterval): } } + logCompileWaitTimedOut(options, startedAt) return nil, false, nil } -func tryReadCompileResult(projectRoot string, requestID string) (json.RawMessage, error) { - if !isSafeCompileRequestID(requestID) { - return nil, fmt.Errorf("requestId contains unsafe characters: %s", requestID) - } +func compileForceRecompileEnabled(params map[string]any) bool { + value, ok := params[compileForceParam].(bool) + return ok && value +} + +func queryCompileStatusFromUnity(ctx context.Context, connection unityipc.Connection, requestID string) (compileStatusResponse, error) { + probeContext, cancel := context.WithTimeout(ctx, compileStatusProbeTimeout) + defer cancel() - resultPath := filepath.Join(projectRoot, compileResultRelativeDir, requestID+".json") - content, err := os.ReadFile(resultPath) + response, err := unityipc.NewClient(connection, version).Send( + probeContext, + compileStatusCommandName, + map[string]any{compileRequestIDParam: requestID}, + ) if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err + return compileStatusResponse{}, err } - content = bytes.TrimPrefix(content, []byte{0xef, 0xbb, 0xbf}) - if !json.Valid(content) { - return nil, nil + var status compileStatusResponse + if err := json.Unmarshal(response, &status); err != nil { + return compileStatusResponse{}, err } - return json.RawMessage(content), nil + return status, nil } -func shouldWaitForCompileResult(err error, outcome unityipc.UnitySendOutcome) bool { +func shouldWaitForCompileStatus(err error, outcome unityipc.UnitySendOutcome) bool { if err == nil { return true } @@ -168,3 +179,45 @@ func isTransportDisconnectError(err error) bool { func isFinalResponseTimeoutError(err error) bool { return strings.Contains(err.Error(), "i/o timeout") } + +func logCompileWaitTimedOut(options compileCompletionOptions, startedAt time.Time) { + _ = writeCliVibeLog(options.connection.ProjectRoot, cliVibeLogEntry{ + Level: "WARNING", + Operation: "cli_compile_status_wait_timed_out", + Message: "Timed out while polling Unity compile status.", + Context: compileWaitLogContext(options, startedAt, nil), + CorrelationID: options.requestID, + }) +} + +func logCompileWaitCancelled(options compileCompletionOptions, startedAt time.Time, err error) { + _ = writeCliVibeLog(options.connection.ProjectRoot, cliVibeLogEntry{ + Level: "WARNING", + Operation: "cli_compile_status_wait_cancelled", + Message: "Compile status polling was cancelled.", + Context: compileWaitLogContext(options, startedAt, map[string]any{ + "error": errorMessage(err), + }), + CorrelationID: options.requestID, + }) +} + +func compileWaitLogContext( + options compileCompletionOptions, + startedAt time.Time, + extra map[string]any, +) map[string]any { + context := map[string]any{ + "command": compileCommandName, + "request_id": options.requestID, + "force_recompile": options.forceRecompile, + "endpoint": options.connection.Endpoint.Address, + "timeout_ms": options.timeout.Milliseconds(), + "poll_interval_ms": options.pollInterval.Milliseconds(), + "elapsed_ms": time.Since(startedAt).Milliseconds(), + } + for key, value := range extra { + context[key] = value + } + return context +} diff --git a/Packages/src/Cli~/internal/cli/compile_wait_test.go b/Packages/src/Cli~/internal/cli/compile_wait_test.go index 088a476018..fbabd9126d 100644 --- a/Packages/src/Cli~/internal/cli/compile_wait_test.go +++ b/Packages/src/Cli~/internal/cli/compile_wait_test.go @@ -3,9 +3,8 @@ package cli import ( "bytes" "context" + "encoding/json" "fmt" - "os" - "path/filepath" "strings" "testing" "time" @@ -146,35 +145,24 @@ func TestPrepareCompileWaitParamsForcesDomainReloadWait(t *testing.T) { } } -// Verifies that compile wait ignores stale server-state files and returns the persisted result. -func TestWaitForCompileCompletionIgnoresStaleServerState(t *testing.T) { - projectRoot := t.TempDir() +// Verifies that compile wait returns the result after Unity reports idle status. +func TestWaitForCompileCompletionReturnsReadyStatusResult(t *testing.T) { + connection := compileWaitTestConnection(t) requestID := "compile_test" - resultDir := filepath.Join(projectRoot, compileResultRelativeDir) - if err := os.MkdirAll(resultDir, 0o755); err != nil { - t.Fatalf("failed to create result dir: %v", err) - } - staleStatePath := filepath.Join(projectRoot, "Temp", "UnityCliLoop", "server-state.json") - if err := os.MkdirAll(filepath.Dir(staleStatePath), 0o755); err != nil { - t.Fatalf("failed to create stale state dir: %v", err) - } - if err := os.WriteFile(staleStatePath, []byte(`{"phase":"failed","lastError":"stale"}`), 0o644); err != nil { - t.Fatalf("failed to write stale state: %v", err) - } - if err := os.WriteFile( - filepath.Join(resultDir, requestID+".json"), - []byte("\xef\xbb\xbf{\"Success\":true}"), - 0o644, - ); err != nil { - t.Fatalf("failed to write result: %v", err) - } + callCount := 0 + replaceQueryCompileStatus(t, func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) { + callCount++ + if callCount == 1 { + return compileStatusResponse{Ready: false, HasResult: true, Result: json.RawMessage(`{"Success":true}`)}, nil + } + return compileStatusResponse{Ready: true, HasResult: true, Result: json.RawMessage(`{"Success":true}`)}, nil + }) result, completed, err := waitForCompileCompletion(context.Background(), compileCompletionOptions{ - projectRoot: projectRoot, + connection: connection, requestID: requestID, timeout: time.Second, pollInterval: 5 * time.Millisecond, - lockGrace: 10 * time.Millisecond, }) if err != nil { t.Fatalf("waitForCompileCompletion failed: %v", err) @@ -187,37 +175,143 @@ func TestWaitForCompileCompletionIgnoresStaleServerState(t *testing.T) { } } -func TestShouldWaitForCompileResultRequiresDispatchedTransportError(t *testing.T) { - if shouldWaitForCompileResult(os.ErrNotExist, unityipc.UnitySendOutcome{}) { +// Verifies force compile waits for Unity's stored result instead of fabricating one from idle status. +func TestWaitForCompileCompletionForceCompileWaitsForStoredResult(t *testing.T) { + connection := compileWaitTestConnection(t) + requestID := "compile_force_unknown" + callCount := 0 + replaceQueryCompileStatus(t, func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) { + callCount++ + if callCount == 1 { + return compileStatusResponse{Ready: true, HasResult: false}, nil + } + return compileStatusResponse{ + Ready: true, + HasResult: true, + Result: json.RawMessage(`{"Success":null,"ErrorCount":null,"Message":"Force compilation completed"}`), + }, nil + }) + + result, completed, err := waitForCompileCompletion(context.Background(), compileCompletionOptions{ + connection: connection, + requestID: requestID, + forceRecompile: true, + timeout: time.Second, + pollInterval: 5 * time.Millisecond, + }) + if err != nil { + t.Fatalf("waitForCompileCompletion failed: %v", err) + } + if !completed { + t.Fatal("force compile wait did not complete") + } + if callCount != 2 { + t.Fatalf("force compile should wait for stored result, got %d status calls", callCount) + } + + var payload map[string]any + if err := json.Unmarshal(result, &payload); err != nil { + t.Fatalf("force compile result is not JSON: %v", err) + } + if payload["Success"] != nil { + t.Fatalf("force compile success should be unknown: %#v", payload["Success"]) + } + if payload["ErrorCount"] != nil || payload["WarningCount"] != nil { + t.Fatalf("force compile counts should be unknown: %#v", payload) + } + message, ok := payload["Message"].(string) + if !ok || !strings.Contains(message, "Force compilation completed") { + t.Fatalf("force compile message mismatch: %#v", payload["Message"]) + } +} + +// Verifies force compile does not finish only because Unity reports an idle status without a result. +func TestWaitForCompileCompletionForceCompileTimesOutWithoutStoredResult(t *testing.T) { + connection := compileWaitTestConnection(t) + requestID := "compile_force_no_result" + replaceQueryCompileStatus(t, func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) { + return compileStatusResponse{Ready: true, HasResult: false}, nil + }) + + _, completed, err := waitForCompileCompletion(context.Background(), compileCompletionOptions{ + connection: connection, + requestID: requestID, + forceRecompile: true, + timeout: 20 * time.Millisecond, + pollInterval: 5 * time.Millisecond, + }) + if err != nil { + t.Fatalf("waitForCompileCompletion failed: %v", err) + } + if completed { + t.Fatal("force compile wait should not complete without a stored result") + } +} + +// Verifies that compile status wait timeouts are visible in CLI Vibe logs. +func TestWaitForCompileCompletionWritesTimeoutVibeLog(t *testing.T) { + enableCliVibeLog(t) + connection := compileWaitTestConnection(t) + requestID := "compile_timeout_log_test" + replaceQueryCompileStatus(t, func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) { + return compileStatusResponse{Ready: false}, nil + }) + + _, completed, err := waitForCompileCompletion(context.Background(), compileCompletionOptions{ + connection: connection, + requestID: requestID, + timeout: 20 * time.Millisecond, + pollInterval: 5 * time.Millisecond, + }) + if err != nil { + t.Fatalf("waitForCompileCompletion failed: %v", err) + } + if completed { + t.Fatal("compile wait should time out without a ready result") + } + + logContent := readOnlyCliVibeLog(t, connection.ProjectRoot) + for _, expected := range []string{ + `"operation":"cli_compile_status_wait_timed_out"`, + `"request_id":"compile_timeout_log_test"`, + } { + if !strings.Contains(logContent, expected) { + t.Fatalf("CLI Vibe log missing %q:\n%s", expected, logContent) + } + } +} + +func TestShouldWaitForCompileStatusRequiresDispatchedTransportError(t *testing.T) { + if shouldWaitForCompileStatus(fmt.Errorf("missing"), unityipc.UnitySendOutcome{}) { t.Fatal("undispatched error should not wait") } outcome := unityipc.UnitySendOutcome{RequestDispatched: true} - if !shouldWaitForCompileResult(fmt.Errorf("EOF"), outcome) { + if !shouldWaitForCompileStatus(fmt.Errorf("EOF"), outcome) { t.Fatal("dispatched transport error should wait") } } -// Verifies accepted final-response timeouts can fall back to result-file polling. -func TestShouldWaitForCompileResultAllowsAcceptedFinalResponseTimeout(t *testing.T) { +// Verifies accepted final-response timeouts can fall back to status polling. +func TestShouldWaitForCompileStatusAllowsAcceptedFinalResponseTimeout(t *testing.T) { outcome := unityipc.UnitySendOutcome{RequestDispatched: true, RequestAccepted: true} - if !shouldWaitForCompileResult(fmt.Errorf("read tcp 127.0.0.1:1: i/o timeout"), outcome) { + if !shouldWaitForCompileStatus(fmt.Errorf("read tcp 127.0.0.1:1: i/o timeout"), outcome) { t.Fatal("accepted final-response timeout should wait") } unacceptedOutcome := unityipc.UnitySendOutcome{RequestDispatched: true} - if shouldWaitForCompileResult(fmt.Errorf("read tcp 127.0.0.1:1: i/o timeout"), unacceptedOutcome) { + if shouldWaitForCompileStatus(fmt.Errorf("read tcp 127.0.0.1:1: i/o timeout"), unacceptedOutcome) { t.Fatal("unaccepted timeout should not wait") } } -// Verifies compile readiness wait decisions include indeterminate forced-compile results. +// Verifies compile readiness warmup only runs after confirmed successful results. func TestCompileResultReadinessWaitMode(t *testing.T) { cases := map[string]compileReadinessWaitMode{ `{"Success":true}`: compileReadinessWaitWarmup, `{"Success":false,"Errors":[{"Message":"boom"}]}`: compileReadinessWaitNone, - `{"Success":null,"Message":"indeterminate"}`: compileReadinessWaitRequired, - `{"Message":"indeterminate"}`: compileReadinessWaitRequired, + `{"Success":null,"Message":"indeterminate"}`: compileReadinessWaitNone, + `{"Message":"indeterminate"}`: compileReadinessWaitNone, } for result, expected := range cases { @@ -238,3 +332,26 @@ func TestWritePostCompileWarmupWarningReportsNonFatalFailure(t *testing.T) { t.Fatalf("warning mismatch: %s", stderr.String()) } } + +func compileWaitTestConnection(t *testing.T) unityipc.Connection { + t.Helper() + return unityipc.Connection{ + Endpoint: unityipc.Endpoint{ + Network: "tcp", + Address: "127.0.0.1:1", + }, + ProjectRoot: t.TempDir(), + } +} + +func replaceQueryCompileStatus( + t *testing.T, + replacement func(context.Context, unityipc.Connection, string) (compileStatusResponse, error), +) { + t.Helper() + original := queryCompileStatus + queryCompileStatus = replacement + t.Cleanup(func() { + queryCompileStatus = original + }) +} diff --git a/Packages/src/Cli~/internal/cli/connection_retry.go b/Packages/src/Cli~/internal/cli/connection_retry.go index 7cca689551..3e813c84fc 100644 --- a/Packages/src/Cli~/internal/cli/connection_retry.go +++ b/Packages/src/Cli~/internal/cli/connection_retry.go @@ -49,6 +49,24 @@ func sendWithTransientConnectionRetry( method string, params map[string]any, progress unityipc.ProgressFunc, +) (unityipc.UnitySendOutcome, error) { + return sendWithTransientConnectionRetryAndResponseTimeout( + ctx, + connection, + method, + params, + progress, + 0, + ) +} + +func sendWithTransientConnectionRetryAndResponseTimeout( + ctx context.Context, + connection unityipc.Connection, + method string, + params map[string]any, + progress unityipc.ProgressFunc, + responseTimeout time.Duration, ) (unityipc.UnitySendOutcome, error) { retryContext, cancel := context.WithTimeout(ctx, serverConnectionRetryTimeout) defer cancel() @@ -67,8 +85,11 @@ func sendWithTransientConnectionRetry( focusAttempted := false for { - outcome, err := unityipc.NewClient(connection, version). - SendWithProgressOutcomeAcceptContext(ctx, retryContext, method, params, progress) + client := unityipc.NewClient(connection, version) + if responseTimeout > 0 { + client = client.WithResponseTimeout(responseTimeout) + } + outcome, err := client.SendWithProgressOutcomeAcceptContext(ctx, retryContext, method, params, progress) if !shouldRetryUndispatchedConnection(err, outcome) { return outcome, err } diff --git a/Packages/src/Cli~/internal/cli/error_envelope.go b/Packages/src/Cli~/internal/cli/error_envelope.go index a560e97d45..963ca1081e 100644 --- a/Packages/src/Cli~/internal/cli/error_envelope.go +++ b/Packages/src/Cli~/internal/cli/error_envelope.go @@ -443,7 +443,7 @@ func compileWaitTimeoutError(projectRoot string) cliError { return cliError{ ErrorCode: errorCodeCompileWaitTimeout, Phase: errorPhaseCompileWaiting, - Message: "Compile wait timed out after 90000ms.", + Message: "Compile status wait timed out after 180000ms.", Retryable: true, SafeToRetry: true, ProjectRoot: projectRoot, diff --git a/Packages/src/Cli~/internal/cli/run.go b/Packages/src/Cli~/internal/cli/run.go index a663f4697d..7f50697382 100644 --- a/Packages/src/Cli~/internal/cli/run.go +++ b/Packages/src/Cli~/internal/cli/run.go @@ -228,7 +228,7 @@ func runCompileWithDomainReloadWait(ctx context.Context, connection unityipc.Con startedAt := time.Now() spinner := newToolSpinner(stderr, compileCommandName) - outcome, err := sendWithTransientConnectionRetry( + outcome, err := sendWithTransientConnectionRetryAndResponseTimeout( ctx, connection, compileCommandName, @@ -236,11 +236,12 @@ func runCompileWithDomainReloadWait(ctx context.Context, connection unityipc.Con func(string) { spinner.Update("Executing compile...") }, + compileResponseTimeout, ) - if err != nil && shouldWaitForCompileResult(err, outcome) { - spinner.Update("Connection lost during compile. Waiting for result file...") + if err != nil && shouldWaitForCompileStatus(err, outcome) { + spinner.Update("Connection changed during compile. Waiting for Unity status...") } - if !shouldWaitForCompileResult(err, outcome) { + if !shouldWaitForCompileStatus(err, outcome) { spinner.Stop() writeToolFailure(stderr, err, outcome, errorContext{ projectRoot: connection.ProjectRoot, @@ -251,11 +252,11 @@ func runCompileWithDomainReloadWait(ctx context.Context, connection unityipc.Con spinner.Update("Waiting for domain reload to complete...") result, completed, waitErr := waitForCompileCompletion(ctx, compileCompletionOptions{ - projectRoot: connection.ProjectRoot, - requestID: requestID, - timeout: compileWaitTimeout, - pollInterval: compileWaitPollInterval, - lockGrace: compileLockGracePeriod, + connection: connection, + requestID: requestID, + forceRecompile: compileForceRecompileEnabled(params), + timeout: compileWaitTimeout, + pollInterval: compileWaitPollInterval, }) if waitErr != nil { spinner.Stop() @@ -271,16 +272,6 @@ func runCompileWithDomainReloadWait(ctx context.Context, connection unityipc.Con return 1 } switch compileResultReadinessWaitMode(result) { - case compileReadinessWaitRequired: - spinner.Update("Waiting for Unity tools after compile...") - if err := waitForToolReadiness(ctx, connection.ProjectRoot); err != nil { - spinner.Stop() - writeClassifiedError(stderr, err, errorContext{ - projectRoot: connection.ProjectRoot, - command: compileCommandName, - }) - return 1 - } case compileReadinessWaitWarmup: spinner.Update("Warming execute-dynamic-code after compile...") if err := waitForToolReadiness(ctx, connection.ProjectRoot); err != nil { @@ -378,7 +369,6 @@ type compileReadinessWaitMode int const ( compileReadinessWaitNone compileReadinessWaitMode = iota - compileReadinessWaitRequired compileReadinessWaitWarmup ) @@ -388,7 +378,7 @@ func compileResultReadinessWaitMode(result json.RawMessage) compileReadinessWait return compileReadinessWaitNone } if status.Success == nil { - return compileReadinessWaitRequired + return compileReadinessWaitNone } if *status.Success { return compileReadinessWaitWarmup diff --git a/Packages/src/Cli~/internal/tools/default-tools.json b/Packages/src/Cli~/internal/tools/default-tools.json index 1e81e104e5..62bd834bf9 100644 --- a/Packages/src/Cli~/internal/tools/default-tools.json +++ b/Packages/src/Cli~/internal/tools/default-tools.json @@ -1,5 +1,5 @@ { - "version": "3.0.0-beta.21", + "version": "3.0.0-beta.22", "tools": [ { "name": "compile", diff --git a/Packages/src/Cli~/internal/unityipc/client.go b/Packages/src/Cli~/internal/unityipc/client.go index 398b9b0767..814cd815e9 100644 --- a/Packages/src/Cli~/internal/unityipc/client.go +++ b/Packages/src/Cli~/internal/unityipc/client.go @@ -85,6 +85,11 @@ func NewClient(connection Connection, clientVersion string) *Client { return &Client{connection: connection, clientVersion: clientVersion} } +func (client *Client) WithResponseTimeout(timeout time.Duration) *Client { + client.responseTimeout = timeout + return client +} + func (client *Client) Send(ctx context.Context, method string, params map[string]any) (json.RawMessage, error) { return client.SendWithProgress(ctx, method, params, nil) } diff --git a/Packages/src/Editor/Domain/CliConstants.cs b/Packages/src/Editor/Domain/CliConstants.cs index dab8fe8f45..306d51f5f6 100644 --- a/Packages/src/Editor/Domain/CliConstants.cs +++ b/Packages/src/Editor/Domain/CliConstants.cs @@ -6,7 +6,7 @@ namespace io.github.hatayama.UnityCliLoop.Domain public static class CliConstants { public const string EXECUTABLE_NAME = "uloop"; - public const string MINIMUM_REQUIRED_CLI_VERSION = "3.0.0-beta.20"; + public const string MINIMUM_REQUIRED_CLI_VERSION = "3.0.0-beta.22"; public const string MINIMUM_REQUIRED_CLI_RELEASE_TAG = CLI_RELEASE_TAG_PREFIX + MINIMUM_REQUIRED_CLI_VERSION; public const string VERSION_FLAG = "--version"; public const string SHORT_VERSION_FLAG = "-v"; diff --git a/Packages/src/Editor/Domain/UnityCliLoopEditorSessionStateService.cs b/Packages/src/Editor/Domain/UnityCliLoopEditorSessionStateService.cs index d3467d0a30..ae9217c904 100644 --- a/Packages/src/Editor/Domain/UnityCliLoopEditorSessionStateService.cs +++ b/Packages/src/Editor/Domain/UnityCliLoopEditorSessionStateService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics; namespace io.github.hatayama.UnityCliLoop.Domain @@ -21,16 +22,108 @@ public interface IUnityCliLoopEditorSessionStatePort void SetShowPostCompileReconnectingUI(bool showPostCompileReconnectingUI); bool GetShouldAutoScanThirdPartyToolMigration(); void SetShouldAutoScanThirdPartyToolMigration(bool shouldAutoScanThirdPartyToolMigration); - string GetPendingCompileRequestId(); - void SetPendingCompileRequestId(string pendingCompileRequestId); - bool GetPendingCompileForceRecompile(); - void SetPendingCompileForceRecompile(bool pendingCompileForceRecompile); - string GetPendingCompileExpiresAtUtcTicks(); - void SetPendingCompileExpiresAtUtcTicks(string pendingCompileExpiresAtUtcTicks); + string GetCompileResultRequestIds(); + void SetCompileResultRequestIds(string compileResultRequestIds); + string GetLegacyCompileResultRequestId(); + void SetLegacyCompileResultRequestId(string compileResultRequestId); + bool GetLegacyCompileResultForceRecompile(); + void SetLegacyCompileResultForceRecompile(bool compileResultForceRecompile); + string GetLegacyCompileResultJson(); + void SetLegacyCompileResultJson(string compileResultJson); + string GetLegacyCompileResultCompletedAtUtcTicks(); + void SetLegacyCompileResultCompletedAtUtcTicks(string compileResultCompletedAtUtcTicks); + bool GetCompileResultForceRecompile(string requestId); + void SetCompileResultForceRecompile(string requestId, bool compileResultForceRecompile); + string GetCompileResultJson(string requestId); + void SetCompileResultJson(string requestId, string compileResultJson); + string GetCompileResultCompletedAtUtcTicks(string requestId); + void SetCompileResultCompletedAtUtcTicks(string requestId, string compileResultCompletedAtUtcTicks); + string GetPendingCompileRequestIds(); + void SetPendingCompileRequestIds(string pendingCompileRequestIds); + string GetLegacyPendingCompileRequestId(); + void SetLegacyPendingCompileRequestId(string pendingCompileRequestId); + bool GetLegacyPendingCompileForceRecompile(); + void SetLegacyPendingCompileForceRecompile(bool pendingCompileForceRecompile); + string GetLegacyPendingCompileExpiresAtUtcTicks(); + void SetLegacyPendingCompileExpiresAtUtcTicks(string pendingCompileExpiresAtUtcTicks); + bool GetLegacyPendingCompileReloadObserved(); + void SetLegacyPendingCompileReloadObserved(bool pendingCompileReloadObserved); + bool GetPendingCompileForceRecompile(string requestId); + void SetPendingCompileForceRecompile(string requestId, bool pendingCompileForceRecompile); + string GetPendingCompileExpiresAtUtcTicks(string requestId); + void SetPendingCompileExpiresAtUtcTicks(string requestId, string pendingCompileExpiresAtUtcTicks); + bool GetPendingCompileReloadObserved(string requestId); + void SetPendingCompileReloadObserved(string requestId, bool pendingCompileReloadObserved); } /// - /// Records the compile request that must still produce a CLI result file after Domain Reload. + /// Records the compile result that the CLI reads after Unity finishes Domain Reload. + /// + public sealed class UnityCliLoopStoredCompileResult + { + private UnityCliLoopStoredCompileResult( + bool hasResult, + string requestId, + bool forceRecompile, + string resultJson, + long completedAtUtcTicks) + { + HasResult = hasResult; + RequestId = requestId; + ForceRecompile = forceRecompile; + ResultJson = resultJson; + CompletedAtUtcTicks = completedAtUtcTicks; + } + + public bool HasResult { get; } + public string RequestId { get; } + public bool ForceRecompile { get; } + public string ResultJson { get; } + public long CompletedAtUtcTicks { get; } + + public static UnityCliLoopStoredCompileResult None() + { + return new UnityCliLoopStoredCompileResult(false, "", false, "", 0); + } + + public static UnityCliLoopStoredCompileResult Create( + string requestId, + bool forceRecompile, + string resultJson, + long completedAtUtcTicks) + { + Debug.Assert(!string.IsNullOrWhiteSpace(requestId), "requestId must not be null or whitespace"); + Debug.Assert(!string.IsNullOrWhiteSpace(resultJson), "resultJson must not be null or whitespace"); + Debug.Assert(completedAtUtcTicks > 0, "completedAtUtcTicks must be positive"); + + if (string.IsNullOrWhiteSpace(requestId)) + { + throw new ArgumentException("requestId must not be null or whitespace.", nameof(requestId)); + } + + if (string.IsNullOrWhiteSpace(resultJson)) + { + throw new ArgumentException("resultJson must not be null or whitespace.", nameof(resultJson)); + } + + return new UnityCliLoopStoredCompileResult( + true, + requestId, + forceRecompile, + resultJson, + completedAtUtcTicks); + } + + public bool IsExpiredAt(DateTime utcNow, TimeSpan lifetime) + { + Debug.Assert(utcNow.Kind == DateTimeKind.Utc, "utcNow must be UTC"); + Debug.Assert(lifetime > TimeSpan.Zero, "lifetime must be positive"); + return HasResult && CompletedAtUtcTicks <= (utcNow - lifetime).Ticks; + } + } + + /// + /// Records a compile request that may need an indeterminate result after Domain Reload. /// public sealed class UnityCliLoopPendingCompileRequest { @@ -38,37 +131,47 @@ private UnityCliLoopPendingCompileRequest( bool hasRequest, string requestId, bool forceRecompile, - long expiresAtUtcTicks) + long expiresAtUtcTicks, + bool reloadObserved) { HasRequest = hasRequest; RequestId = requestId; ForceRecompile = forceRecompile; ExpiresAtUtcTicks = expiresAtUtcTicks; + ReloadObserved = reloadObserved; } public bool HasRequest { get; } public string RequestId { get; } public bool ForceRecompile { get; } public long ExpiresAtUtcTicks { get; } + public bool ReloadObserved { get; } public static UnityCliLoopPendingCompileRequest None() { - return new UnityCliLoopPendingCompileRequest(false, "", false, 0); + return new UnityCliLoopPendingCompileRequest(false, "", false, 0, false); } public static UnityCliLoopPendingCompileRequest Create( string requestId, bool forceRecompile, - long expiresAtUtcTicks) + long expiresAtUtcTicks, + bool reloadObserved) { Debug.Assert(!string.IsNullOrWhiteSpace(requestId), "requestId must not be null or whitespace"); + Debug.Assert(expiresAtUtcTicks > 0, "expiresAtUtcTicks must be positive"); if (string.IsNullOrWhiteSpace(requestId)) { throw new ArgumentException("requestId must not be null or whitespace.", nameof(requestId)); } - return new UnityCliLoopPendingCompileRequest(true, requestId, forceRecompile, expiresAtUtcTicks); + return new UnityCliLoopPendingCompileRequest( + true, + requestId, + forceRecompile, + expiresAtUtcTicks, + reloadObserved); } public bool IsExpiredAt(DateTime utcNow) @@ -83,9 +186,7 @@ public bool IsExpiredAt(DateTime utcNow) /// public sealed class UnityCliLoopEditorSessionStateService { - // Why: accepted compile requests can still be running while the CLI waits for its - // long final-response budget, so recovery must outlive that active wait window. - private const int PendingCompileRequestLifetimeSeconds = 32 * 60; + private static readonly TimeSpan CompileResultLifetime = TimeSpan.FromMinutes(32); private readonly IUnityCliLoopEditorSessionStatePort _sessionStatePort; public UnityCliLoopEditorSessionStateService(IUnityCliLoopEditorSessionStatePort sessionStatePort) @@ -175,37 +276,14 @@ public void SetShouldAutoScanThirdPartyToolMigration(bool shouldAutoScanThirdPar _sessionStatePort.SetShouldAutoScanThirdPartyToolMigration(shouldAutoScanThirdPartyToolMigration); } - public UnityCliLoopPendingCompileRequest GetPendingCompileRequest() + private static (bool IsValid, long Value) ParseUtcTicks(string utcTicksText) { - string requestId = _sessionStatePort.GetPendingCompileRequestId(); - if (string.IsNullOrWhiteSpace(requestId)) - { - return UnityCliLoopPendingCompileRequest.None(); - } - - string expiresAtUtcTicksText = _sessionStatePort.GetPendingCompileExpiresAtUtcTicks(); - (bool isValid, long expiresAtUtcTicks) = - ParsePendingCompileExpiresAtUtcTicks(expiresAtUtcTicksText); - if (!isValid) - { - ClearPendingCompileRequest(); - return UnityCliLoopPendingCompileRequest.None(); - } - - return UnityCliLoopPendingCompileRequest.Create( - requestId, - _sessionStatePort.GetPendingCompileForceRecompile(), - expiresAtUtcTicks); - } - - private static (bool IsValid, long Value) ParsePendingCompileExpiresAtUtcTicks(string expiresAtUtcTicksText) - { - if (string.IsNullOrWhiteSpace(expiresAtUtcTicksText)) + if (string.IsNullOrWhiteSpace(utcTicksText)) { return (true, 0); } - string trimmedText = expiresAtUtcTicksText.Trim(); + string trimmedText = utcTicksText.Trim(); long value = 0; foreach (char character in trimmedText) { @@ -223,69 +301,535 @@ private static (bool IsValid, long Value) ParsePendingCompileExpiresAtUtcTicks(s value = value * 10 + digit; } + if (value > DateTime.MaxValue.Ticks) + { + return (false, 0); + } + return (true, value); } - public void MarkPendingCompileRequest(string requestId, bool forceRecompile) + private static string[] ParseRequestIdIndex(string requestIdIndex) { - long expiresAtUtcTicks = DateTime.UtcNow - .AddSeconds(PendingCompileRequestLifetimeSeconds) - .Ticks; - MarkPendingCompileRequestWithExpiration(requestId, forceRecompile, expiresAtUtcTicks); + if (string.IsNullOrWhiteSpace(requestIdIndex)) + { + return Array.Empty(); + } + + string[] rawRequestIds = requestIdIndex.Split( + new[] { '\n' }, + StringSplitOptions.RemoveEmptyEntries); + List requestIds = new List(); + foreach (string rawRequestId in rawRequestIds) + { + string requestId = rawRequestId.Trim(); + if (string.IsNullOrWhiteSpace(requestId) || requestIds.Contains(requestId)) + { + continue; + } + + requestIds.Add(requestId); + } + + return requestIds.ToArray(); } - public void MarkPendingCompileRequestWithExpiration( + private static string FormatRequestIdIndex(List requestIds) + { + Debug.Assert(requestIds != null, "requestIds must not be null"); + return string.Join("\n", requestIds.ToArray()); + } + + private static string AddRequestIdToIndex(string requestIdIndex, string requestId) + { + Debug.Assert(!string.IsNullOrWhiteSpace(requestId), "requestId must not be null or whitespace"); + + List requestIds = new List(ParseRequestIdIndex(requestIdIndex)); + if (!requestIds.Contains(requestId)) + { + requestIds.Add(requestId); + } + + return FormatRequestIdIndex(requestIds); + } + + private static string RemoveRequestIdFromIndex(string requestIdIndex, string requestId) + { + Debug.Assert(!string.IsNullOrWhiteSpace(requestId), "requestId must not be null or whitespace"); + + List requestIds = new List(); + foreach (string indexedRequestId in ParseRequestIdIndex(requestIdIndex)) + { + if (indexedRequestId == requestId) + { + continue; + } + + requestIds.Add(indexedRequestId); + } + + return FormatRequestIdIndex(requestIds); + } + + public void StoreCompileResult( string requestId, bool forceRecompile, - long expiresAtUtcTicks) + string resultJson, + DateTime completedAtUtc) + { + Debug.Assert(!string.IsNullOrWhiteSpace(requestId), "requestId must not be null or whitespace"); + Debug.Assert(!string.IsNullOrWhiteSpace(resultJson), "resultJson must not be null or whitespace"); + Debug.Assert(completedAtUtc.Kind == DateTimeKind.Utc, "completedAtUtc must be UTC"); + + _sessionStatePort.SetCompileResultRequestIds( + AddRequestIdToIndex(_sessionStatePort.GetCompileResultRequestIds(), requestId)); + _sessionStatePort.SetCompileResultForceRecompile(requestId, forceRecompile); + _sessionStatePort.SetCompileResultJson(requestId, resultJson); + _sessionStatePort.SetCompileResultCompletedAtUtcTicks(requestId, completedAtUtc.Ticks.ToString()); + } + + public UnityCliLoopStoredCompileResult GetCompileResult(string requestId) + { + Debug.Assert(!string.IsNullOrWhiteSpace(requestId), "requestId must not be null or whitespace"); + + string resultJson = _sessionStatePort.GetCompileResultJson(requestId); + if (string.IsNullOrWhiteSpace(resultJson)) + { + UnityCliLoopStoredCompileResult legacyResult = + GetLegacyCompileResultForRequestId(requestId); + if (legacyResult.HasResult) + { + return legacyResult; + } + + ClearCompileResultForRequestId(requestId); + return UnityCliLoopStoredCompileResult.None(); + } + + string completedAtUtcTicksText = _sessionStatePort.GetCompileResultCompletedAtUtcTicks(requestId); + (bool isValid, long completedAtUtcTicks) = + ParseUtcTicks(completedAtUtcTicksText); + if (!isValid || completedAtUtcTicks <= 0) + { + ClearCompileResultForRequestId(requestId); + return UnityCliLoopStoredCompileResult.None(); + } + + return UnityCliLoopStoredCompileResult.Create( + requestId, + _sessionStatePort.GetCompileResultForceRecompile(requestId), + resultJson, + completedAtUtcTicks); + } + + public UnityCliLoopStoredCompileResult GetStoredCompileResult() + { + UnityCliLoopStoredCompileResult[] storedResults = GetStoredCompileResults(); + if (storedResults.Length == 0) + { + return UnityCliLoopStoredCompileResult.None(); + } + + return storedResults[0]; + } + + public UnityCliLoopStoredCompileResult[] GetStoredCompileResults() + { + string[] requestIds = ParseRequestIdIndex(_sessionStatePort.GetCompileResultRequestIds()); + List storedResults = + new List(); + foreach (string requestId in requestIds) + { + UnityCliLoopStoredCompileResult storedResult = GetCompileResult(requestId); + if (storedResult.HasResult) + { + storedResults.Add(storedResult); + } + } + + UnityCliLoopStoredCompileResult legacyResult = GetLegacyCompileResult(); + if (legacyResult.HasResult && !ContainsCompileResult(storedResults, legacyResult.RequestId)) + { + storedResults.Add(legacyResult); + } + + return storedResults.ToArray(); + } + + public void ClearCompileResult() + { + foreach (string requestId in ParseRequestIdIndex(_sessionStatePort.GetCompileResultRequestIds())) + { + ClearCompileResultValues(requestId); + } + + _sessionStatePort.SetCompileResultRequestIds(""); + ClearLegacyCompileResult(); + } + + private void ClearCompileResultForRequestId(string requestId) + { + Debug.Assert(!string.IsNullOrWhiteSpace(requestId), "requestId must not be null or whitespace"); + + ClearCompileResultValues(requestId); + _sessionStatePort.SetCompileResultRequestIds( + RemoveRequestIdFromIndex(_sessionStatePort.GetCompileResultRequestIds(), requestId)); + } + + private void ClearCompileResultValues(string requestId) + { + Debug.Assert(!string.IsNullOrWhiteSpace(requestId), "requestId must not be null or whitespace"); + + _sessionStatePort.SetCompileResultForceRecompile(requestId, false); + _sessionStatePort.SetCompileResultJson(requestId, ""); + _sessionStatePort.SetCompileResultCompletedAtUtcTicks(requestId, ""); + } + + private static bool ContainsCompileResult( + List storedResults, + string requestId) + { + Debug.Assert(storedResults != null, "storedResults must not be null"); + + foreach (UnityCliLoopStoredCompileResult storedResult in storedResults) + { + if (storedResult.RequestId == requestId) + { + return true; + } + } + + return false; + } + + private UnityCliLoopStoredCompileResult GetLegacyCompileResult() + { + string requestId = _sessionStatePort.GetLegacyCompileResultRequestId(); + if (string.IsNullOrWhiteSpace(requestId)) + { + return UnityCliLoopStoredCompileResult.None(); + } + + return GetLegacyCompileResultForRequestId(requestId); + } + + private UnityCliLoopStoredCompileResult GetLegacyCompileResultForRequestId(string requestId) + { + Debug.Assert(!string.IsNullOrWhiteSpace(requestId), "requestId must not be null or whitespace"); + + string legacyRequestId = _sessionStatePort.GetLegacyCompileResultRequestId(); + if (legacyRequestId != requestId) + { + return UnityCliLoopStoredCompileResult.None(); + } + + string resultJson = _sessionStatePort.GetLegacyCompileResultJson(); + if (string.IsNullOrWhiteSpace(resultJson)) + { + ClearLegacyCompileResult(); + return UnityCliLoopStoredCompileResult.None(); + } + + string completedAtUtcTicksText = + _sessionStatePort.GetLegacyCompileResultCompletedAtUtcTicks(); + (bool isValid, long completedAtUtcTicks) = + ParseUtcTicks(completedAtUtcTicksText); + if (!isValid || completedAtUtcTicks <= 0) + { + ClearLegacyCompileResult(); + return UnityCliLoopStoredCompileResult.None(); + } + + bool forceRecompile = _sessionStatePort.GetLegacyCompileResultForceRecompile(); + StoreCompileResult( + requestId, + forceRecompile, + resultJson, + new DateTime(completedAtUtcTicks, DateTimeKind.Utc)); + ClearLegacyCompileResult(); + return UnityCliLoopStoredCompileResult.Create( + requestId, + forceRecompile, + resultJson, + completedAtUtcTicks); + } + + private void ClearLegacyCompileResult() + { + _sessionStatePort.SetLegacyCompileResultRequestId(""); + _sessionStatePort.SetLegacyCompileResultForceRecompile(false); + _sessionStatePort.SetLegacyCompileResultJson(""); + _sessionStatePort.SetLegacyCompileResultCompletedAtUtcTicks(""); + } + + public bool ClearExpiredCompileResult(DateTime utcNow) + { + Debug.Assert(utcNow.Kind == DateTimeKind.Utc, "utcNow must be UTC"); + + bool cleared = false; + UnityCliLoopStoredCompileResult[] storedResults = GetStoredCompileResults(); + foreach (UnityCliLoopStoredCompileResult storedResult in storedResults) + { + if (!storedResult.IsExpiredAt(utcNow, CompileResultLifetime)) + { + continue; + } + + ClearCompileResultForRequestId(storedResult.RequestId); + cleared = true; + } + + return cleared; + } + + public void MarkPendingCompileRequest( + string requestId, + bool forceRecompile, + DateTime markedAtUtc) + { + Debug.Assert(!string.IsNullOrWhiteSpace(requestId), "requestId must not be null or whitespace"); + Debug.Assert(markedAtUtc.Kind == DateTimeKind.Utc, "markedAtUtc must be UTC"); + + StorePendingCompileRequest( + requestId, + forceRecompile, + markedAtUtc.Add(CompileResultLifetime).Ticks, + reloadObserved: false); + } + + public void StorePendingCompileRequest( + string requestId, + bool forceRecompile, + long expiresAtUtcTicks, + bool reloadObserved) { Debug.Assert(!string.IsNullOrWhiteSpace(requestId), "requestId must not be null or whitespace"); Debug.Assert(expiresAtUtcTicks > 0, "expiresAtUtcTicks must be positive"); - _sessionStatePort.SetPendingCompileRequestId(requestId); - _sessionStatePort.SetPendingCompileForceRecompile(forceRecompile); - _sessionStatePort.SetPendingCompileExpiresAtUtcTicks(expiresAtUtcTicks.ToString()); + _sessionStatePort.SetPendingCompileRequestIds( + AddRequestIdToIndex(_sessionStatePort.GetPendingCompileRequestIds(), requestId)); + _sessionStatePort.SetPendingCompileForceRecompile(requestId, forceRecompile); + _sessionStatePort.SetPendingCompileExpiresAtUtcTicks(requestId, expiresAtUtcTicks.ToString()); + _sessionStatePort.SetPendingCompileReloadObserved(requestId, reloadObserved); + } + + public UnityCliLoopPendingCompileRequest GetPendingCompileRequest() + { + UnityCliLoopPendingCompileRequest[] pendingRequests = GetPendingCompileRequests(); + if (pendingRequests.Length == 0) + { + return UnityCliLoopPendingCompileRequest.None(); + } + + return pendingRequests[0]; + } + + public UnityCliLoopPendingCompileRequest[] GetPendingCompileRequests() + { + string[] requestIds = ParseRequestIdIndex(_sessionStatePort.GetPendingCompileRequestIds()); + List pendingRequests = + new List(); + foreach (string requestId in requestIds) + { + UnityCliLoopPendingCompileRequest pendingRequest = + GetPendingCompileRequestForRequestId(requestId); + if (pendingRequest.HasRequest) + { + pendingRequests.Add(pendingRequest); + } + } + + UnityCliLoopPendingCompileRequest legacyRequest = GetLegacyPendingCompileRequest(); + if (legacyRequest.HasRequest && !ContainsPendingCompileRequest(pendingRequests, legacyRequest.RequestId)) + { + pendingRequests.Add(legacyRequest); + } + + return pendingRequests.ToArray(); + } + + public bool MarkPendingCompileRequestReloadObserved() + { + UnityCliLoopPendingCompileRequest[] pendingRequests = GetPendingCompileRequests(); + if (pendingRequests.Length == 0) + { + return false; + } + + foreach (UnityCliLoopPendingCompileRequest pendingRequest in pendingRequests) + { + StorePendingCompileRequest( + pendingRequest.RequestId, + pendingRequest.ForceRecompile, + pendingRequest.ExpiresAtUtcTicks, + reloadObserved: true); + } + + return true; + } + + public UnityCliLoopPendingCompileRequest GetPendingCompileRequestForRequestId(string requestId) + { + Debug.Assert(!string.IsNullOrWhiteSpace(requestId), "requestId must not be null or whitespace"); + + string expiresAtUtcTicksText = _sessionStatePort.GetPendingCompileExpiresAtUtcTicks(requestId); + (bool isValid, long expiresAtUtcTicks) = + ParseUtcTicks(expiresAtUtcTicksText); + if (!isValid || expiresAtUtcTicks <= 0) + { + UnityCliLoopPendingCompileRequest legacyRequest = + GetLegacyPendingCompileRequestForRequestId(requestId); + if (legacyRequest.HasRequest) + { + return legacyRequest; + } + + ClearPendingCompileRequestForRequestId(requestId); + return UnityCliLoopPendingCompileRequest.None(); + } + + return UnityCliLoopPendingCompileRequest.Create( + requestId, + _sessionStatePort.GetPendingCompileForceRecompile(requestId), + expiresAtUtcTicks, + _sessionStatePort.GetPendingCompileReloadObserved(requestId)); } public void ClearPendingCompileRequest() { - _sessionStatePort.SetPendingCompileRequestId(""); - _sessionStatePort.SetPendingCompileForceRecompile(false); - _sessionStatePort.SetPendingCompileExpiresAtUtcTicks(""); + foreach (string requestId in ParseRequestIdIndex(_sessionStatePort.GetPendingCompileRequestIds())) + { + ClearPendingCompileRequestValues(requestId); + } + + _sessionStatePort.SetPendingCompileRequestIds(""); + ClearLegacyPendingCompileRequest(); + } + + private void ClearPendingCompileRequestForRequestId(string requestId) + { + Debug.Assert(!string.IsNullOrWhiteSpace(requestId), "requestId must not be null or whitespace"); + + ClearPendingCompileRequestValues(requestId); + _sessionStatePort.SetPendingCompileRequestIds( + RemoveRequestIdFromIndex(_sessionStatePort.GetPendingCompileRequestIds(), requestId)); } - public void ClearPendingCompileRequestIfMatches(string requestId) + private void ClearPendingCompileRequestValues(string requestId) { Debug.Assert(!string.IsNullOrWhiteSpace(requestId), "requestId must not be null or whitespace"); - UnityCliLoopPendingCompileRequest pendingCompileRequest = GetPendingCompileRequest(); - if (!pendingCompileRequest.HasRequest) + _sessionStatePort.SetPendingCompileForceRecompile(requestId, false); + _sessionStatePort.SetPendingCompileExpiresAtUtcTicks(requestId, ""); + _sessionStatePort.SetPendingCompileReloadObserved(requestId, false); + } + + private static bool ContainsPendingCompileRequest( + List pendingRequests, + string requestId) + { + Debug.Assert(pendingRequests != null, "pendingRequests must not be null"); + + foreach (UnityCliLoopPendingCompileRequest pendingRequest in pendingRequests) { - return; + if (pendingRequest.RequestId == requestId) + { + return true; + } } - if (pendingCompileRequest.RequestId != requestId) + return false; + } + + private UnityCliLoopPendingCompileRequest GetLegacyPendingCompileRequest() + { + string requestId = _sessionStatePort.GetLegacyPendingCompileRequestId(); + if (string.IsNullOrWhiteSpace(requestId)) { - return; + return UnityCliLoopPendingCompileRequest.None(); } - ClearPendingCompileRequest(); + return GetLegacyPendingCompileRequestForRequestId(requestId); } - public bool ClearExpiredPendingCompileRequest(DateTime utcNow) + private UnityCliLoopPendingCompileRequest GetLegacyPendingCompileRequestForRequestId(string requestId) { - Debug.Assert(utcNow.Kind == DateTimeKind.Utc, "utcNow must be UTC"); + Debug.Assert(!string.IsNullOrWhiteSpace(requestId), "requestId must not be null or whitespace"); + + string legacyRequestId = _sessionStatePort.GetLegacyPendingCompileRequestId(); + if (legacyRequestId != requestId) + { + return UnityCliLoopPendingCompileRequest.None(); + } - UnityCliLoopPendingCompileRequest pendingCompileRequest = GetPendingCompileRequest(); - if (!pendingCompileRequest.IsExpiredAt(utcNow)) + string expiresAtUtcTicksText = + _sessionStatePort.GetLegacyPendingCompileExpiresAtUtcTicks(); + (bool isValid, long expiresAtUtcTicks) = + ParseUtcTicks(expiresAtUtcTicksText); + if (!isValid || expiresAtUtcTicks <= 0) + { + ClearLegacyPendingCompileRequest(); + return UnityCliLoopPendingCompileRequest.None(); + } + + bool forceRecompile = _sessionStatePort.GetLegacyPendingCompileForceRecompile(); + bool reloadObserved = _sessionStatePort.GetLegacyPendingCompileReloadObserved(); + StorePendingCompileRequest( + requestId, + forceRecompile, + expiresAtUtcTicks, + reloadObserved); + ClearLegacyPendingCompileRequest(); + return UnityCliLoopPendingCompileRequest.Create( + requestId, + forceRecompile, + expiresAtUtcTicks, + reloadObserved); + } + + private void ClearLegacyPendingCompileRequest() + { + _sessionStatePort.SetLegacyPendingCompileRequestId(""); + _sessionStatePort.SetLegacyPendingCompileForceRecompile(false); + _sessionStatePort.SetLegacyPendingCompileExpiresAtUtcTicks(""); + _sessionStatePort.SetLegacyPendingCompileReloadObserved(false); + } + + public bool ClearPendingCompileRequestIfMatches(string requestId) + { + Debug.Assert(!string.IsNullOrWhiteSpace(requestId), "requestId must not be null or whitespace"); + + UnityCliLoopPendingCompileRequest pendingRequest = + GetPendingCompileRequestForRequestId(requestId); + if (!pendingRequest.HasRequest) { return false; } - ClearPendingCompileRequest(); + ClearPendingCompileRequestForRequestId(requestId); return true; } + public bool ClearExpiredPendingCompileRequest(DateTime utcNow) + { + Debug.Assert(utcNow.Kind == DateTimeKind.Utc, "utcNow must be UTC"); + + bool cleared = false; + UnityCliLoopPendingCompileRequest[] pendingRequests = GetPendingCompileRequests(); + foreach (UnityCliLoopPendingCompileRequest pendingRequest in pendingRequests) + { + if (!pendingRequest.IsExpiredAt(utcNow)) + { + continue; + } + + ClearPendingCompileRequestForRequestId(pendingRequest.RequestId); + cleared = true; + } + + return cleared; + } + public bool ConsumeShouldAutoScanThirdPartyToolMigration() { if (!GetShouldAutoScanThirdPartyToolMigration()) @@ -300,6 +844,7 @@ public bool ConsumeShouldAutoScanThirdPartyToolMigration() public void MarkDomainReloadStarted(bool serverIsRunning) { SetIsDomainReloadInProgress(true); + MarkPendingCompileRequestReloadObserved(); if (!serverIsRunning) { return; @@ -364,6 +909,7 @@ public void ClearAll() { ClearServerSession(); ClearDomainReloadRecoveryFlags(); + ClearCompileResult(); ClearPendingCompileRequest(); SetShouldAutoScanThirdPartyToolMigration(false); SetIsServerManuallyStopped(false); diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompilationExecutionService.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompilationExecutionService.cs index efc14b02ec..bc40c61ba6 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompilationExecutionService.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompilationExecutionService.cs @@ -13,12 +13,18 @@ public class CompilationExecutionService /// /// Execute compilation asynchronously /// - /// Force recompile flag + /// Compile request with force and delayed-result settings. /// Compilation result - public async Task ExecuteCompilationAsync(bool forceRecompile, CancellationToken ct) + public async Task ExecuteCompilationAsync(UnityCliLoopCompileRequest request, CancellationToken ct) { + if (request == null) + { + throw new System.ArgumentNullException(nameof(request)); + } + using CompileController compileController = new(); - return await compileController.TryCompileAsync(forceRecompile, ct); + compileController.SetResultRecordingContext(CompileResultRecordingContext.Create(request)); + return await compileController.TryCompileAsync(request.ForceRecompile, ct); } } } diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileController.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileController.cs index 0cfbbc7759..b15f628fe7 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompileController.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileController.cs @@ -6,6 +6,8 @@ using System.Threading.Tasks; using System.Threading; +using io.github.hatayama.UnityCliLoop.Domain; +using io.github.hatayama.UnityCliLoop.Infrastructure; using io.github.hatayama.UnityCliLoop.ToolContracts; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools @@ -20,6 +22,7 @@ public class CompileController : IDisposable private List _compileMessages = new(); private TaskCompletionSource _currentCompileTask; private bool _isForceCompile = false; + private CompileResultRecordingContext _resultRecordingContext = CompileResultRecordingContext.Disabled(); /// /// Event that occurs when compilation is complete. @@ -46,6 +49,14 @@ public class CompileController : IDisposable /// public IReadOnlyList CompileMessages => _compileMessages.AsReadOnly(); + /// + /// Sets delayed compile result storage for the active CLI request. + /// + internal void SetResultRecordingContext(CompileResultRecordingContext resultRecordingContext) + { + _resultRecordingContext = resultRecordingContext; + } + /// /// Executes compilation asynchronously. /// @@ -67,7 +78,10 @@ public async Task TryCompileAsync(bool forceRecompile, Cancellati VibeLogger.LogInfo( "compile_controller_wait_existing_task", "Compilation is already in progress; waiting for the existing task.", - new { force_recompile = forceRecompile }); + BuildCompileControllerStateContext(new Dictionary + { + ["requested_force_recompile"] = forceRecompile + })); return await _currentCompileTask.Task; } throw new InvalidOperationException("Compilation is in progress, but the task could not be found."); @@ -105,15 +119,7 @@ public async Task TryCompileAsync(bool forceRecompile, Cancellati try { // Execute asset refresh. - VibeLogger.LogInfo( - "compile_asset_refresh_start", - "Calling AssetDatabase.Refresh before compile.", - new { force_recompile = forceRecompile }); AssetDatabase.Refresh(); - VibeLogger.LogInfo( - "compile_asset_refresh_complete", - "AssetDatabase.Refresh returned before compile.", - new { force_recompile = forceRecompile }); AssemblyDefinitionConsoleErrorValidationService assemblyDefinitionValidationService = new(); AssemblyDefinitionConsoleErrorResult assemblyDefinitionErrors = @@ -137,18 +143,10 @@ public async Task TryCompileAsync(bool forceRecompile, Cancellati CompilationPipeline.compilationFinished += HandleCompileFinished; CompilationPipeline.assemblyCompilationFinished += HandleAssemblyFinished; eventsRegistered = true; - VibeLogger.LogInfo( - "compile_event_handlers_registered", - "Registered Unity compilation callbacks.", - new { force_recompile = forceRecompile }); string startMessage = forceRecompile ? "Forced recompile started after asset refresh..." : "Compilation started after asset refresh..."; OnCompileStarted?.Invoke(startMessage); - VibeLogger.LogInfo( - "compile_request_script_compilation", - "Requesting Unity script compilation.", - new { force_recompile = forceRecompile }); if (forceRecompile) { CompilationPipeline.RequestScriptCompilation(RequestScriptCompilationOptions.CleanBuildCache); @@ -157,16 +155,8 @@ public async Task TryCompileAsync(bool forceRecompile, Cancellati { CompilationPipeline.RequestScriptCompilation(); } - VibeLogger.LogInfo( - "compile_request_script_compilation_returned", - "Unity script compilation request returned.", - new { force_recompile = forceRecompile }); StartCompileLifecycleWatchdog(compileTask, ct); - VibeLogger.LogInfo( - "compile_controller_waiting_for_finish", - "Waiting for Unity compilationFinished callback.", - new { force_recompile = forceRecompile }); compileTaskTransferred = true; return await compileTask.Task; } @@ -207,7 +197,7 @@ private Task WatchCompileLifecycleAsync(CancellationToken ct) () => EditorApplication.isCompiling, IsCompileRequestCompleted, WaitForCompileWatchdogPollAsync, - HandleCompileStartedObserved, + _ => { }, HandleCompileStartTimeout, HandleCompileStoppedWithoutFinishEvent, AbortCompile); @@ -267,12 +257,32 @@ internal static bool IsCurrentCompileRequest( return currentCompileTask != null && ReferenceEquals(currentCompileTask, compileTask); } - private void HandleCompileStartedObserved(int waitedMs) + private Dictionary BuildCompileControllerStateContext( + Dictionary extraContext) { - VibeLogger.LogInfo( - "compile_editor_compiling_observed", - "EditorApplication.isCompiling became true.", - new { waited_ms = waitedMs }); + Dictionary context = new() + { + ["force_recompile"] = _isForceCompile, + ["controller_compiling"] = _isCompiling, + ["current_task_present"] = _currentCompileTask != null, + ["current_task_completed"] = _currentCompileTask != null && _currentCompileTask.Task.IsCompleted, + ["message_count"] = _compileMessages != null ? _compileMessages.Count : 0, + ["editor_compiling"] = EditorApplication.isCompiling, + ["editor_updating"] = EditorApplication.isUpdating, + ["editor_playing"] = EditorApplication.isPlaying, + ["editor_paused"] = EditorApplication.isPaused + }; + if (extraContext == null) + { + return context; + } + + foreach (KeyValuePair entry in extraContext) + { + context[entry.Key] = entry.Value; + } + + return context; } private void HandleCompileStartTimeout(int waitedMs) @@ -285,7 +295,10 @@ private void HandleCompileStartTimeout(int waitedMs) VibeLogger.LogWarning( "compile_start_timeout_assembly_definition_error", assemblyDefinitionErrors.Message, - new { waited_ms = waitedMs }); + BuildCompileControllerStateContext(new Dictionary + { + ["waited_ms"] = waitedMs + })); AbortCompileWithResult(CreateAssemblyDefinitionFailureResult(assemblyDefinitionErrors)); return; } @@ -297,7 +310,10 @@ private void HandleCompileStartTimeout(int waitedMs) VibeLogger.LogWarning( "compile_start_timeout_duplicate_asmdef", asmdefValidation.ErrorMessage, - new { waited_ms = waitedMs }); + BuildCompileControllerStateContext(new Dictionary + { + ["waited_ms"] = waitedMs + })); AbortCompile(asmdefValidation.ErrorMessage); return; } @@ -305,7 +321,10 @@ private void HandleCompileStartTimeout(int waitedMs) VibeLogger.LogWarning( "compile_start_timeout", "Compilation did not start before the start timeout.", - new { waited_ms = waitedMs }); + BuildCompileControllerStateContext(new Dictionary + { + ["waited_ms"] = waitedMs + })); AbortCompile( "Compilation did not start. Possible causes: editor update/reload locks, Auto Refresh disabled, or no script changes." ); @@ -323,7 +342,11 @@ private void HandleCompileStoppedWithoutFinishEvent(int stoppedMs) { force_recompile = _isForceCompile, stopped_ms = stoppedMs, - message_count = _compileMessages.Count + message_count = _compileMessages.Count, + editor_compiling = EditorApplication.isCompiling, + editor_updating = EditorApplication.isUpdating, + editor_playing = EditorApplication.isPlaying, + editor_paused = EditorApplication.isPaused }); AbortCompileWithResult(CreateIndeterminateCompileResult(message)); } @@ -341,7 +364,13 @@ private void AbortCompileWithResult(CompileResult result) VibeLogger.LogWarning( "compile_aborted", result.Message, - new { force_recompile = _isForceCompile }); + BuildCompileControllerStateContext(new Dictionary + { + ["success"] = result.Success, + ["error_count"] = result.ErrorCount, + ["warning_count"] = result.WarningCount, + ["is_indeterminate"] = result.IsIndeterminate + })); CompleteCompileRequest(result, unregisterEvents: true); } @@ -364,7 +393,10 @@ private void AbortCompile(string reason) VibeLogger.LogWarning( "compile_aborted", reason, - new { force_recompile = _isForceCompile }); + BuildCompileControllerStateContext(new Dictionary + { + ["reason"] = reason + })); CompileResult result = new( success: false, @@ -389,10 +421,20 @@ private void CompleteCompileRequest(CompileResult result, bool unregisterEvents) UnityEngine.Debug.Assert(result != null, "result must not be null"); TaskCompletionSource task = _currentCompileTask; - + Dictionary completionContext = BuildCompileControllerStateContext( + new Dictionary + { + ["unregister_events"] = unregisterEvents, + ["success"] = result.Success, + ["error_count"] = result.ErrorCount, + ["warning_count"] = result.WarningCount, + ["is_indeterminate"] = result.IsIndeterminate + }); // Completion subscribers are outside this controller, so state cleanup cannot depend on them returning. try { + RecordCompileResultIfNeeded(result, completionContext); + if (unregisterEvents) { UnregisterCompilationEvents(); @@ -409,12 +451,39 @@ private void CompleteCompileRequest(CompileResult result, bool unregisterEvents) _currentCompileTask = null; _isCompiling = false; _isForceCompile = false; + _resultRecordingContext = CompileResultRecordingContext.Disabled(); } task?.TrySetResult(result); } } + private void RecordCompileResultIfNeeded( + CompileResult result, + Dictionary completionContext) + { + UnityEngine.Debug.Assert(result != null, "result must not be null"); + UnityEngine.Debug.Assert(completionContext != null, "completionContext must not be null"); + + if (!_resultRecordingContext.Enabled) + { + return; + } + + UnityCliLoopEditorSessionStateService sessionStateService = + new UnityCliLoopEditorSessionStateService(new UnityCliLoopEditorSessionStateRepository()); + UnityCliLoopCompileResult response = + CompileSessionResultService.CreateCompileResult(result, _resultRecordingContext.ForceRecompile); + CompileSessionResultService.StoreCompileResult( + sessionStateService, + _resultRecordingContext.RequestId, + _resultRecordingContext.ForceRecompile, + response, + _resultRecordingContext.RequestId); + completionContext["result_recorded_in_session_state"] = true; + completionContext["request_id"] = _resultRecordingContext.RequestId; + } + /// /// Removes Unity compilation callbacks for the current compile request. /// @@ -439,18 +508,6 @@ public void ClearMessages() private void HandleCompileFinished(object context) { CompileResult result = CreateCompileResult(); - VibeLogger.LogInfo( - "compile_finished_event", - "Unity compilationFinished callback received.", - new - { - force_recompile = _isForceCompile, - success = result.Success, - error_count = result.ErrorCount, - warning_count = result.WarningCount, - message_count = _compileMessages.Count - }); - CompleteCompileRequest(result, unregisterEvents: true); } @@ -468,15 +525,6 @@ private void HandleAssemblyFinished(string asmPath, CompilerMessage[] messages) _compileMessages.Add(message); } - VibeLogger.LogInfo( - "compile_assembly_finished", - "Unity assemblyCompilationFinished callback received.", - new - { - assembly_name = assemblyName, - message_count = messages.Length, - total_message_count = _compileMessages.Count - }); OnAssemblyCompiled?.Invoke(assemblyName, messages); } @@ -489,12 +537,11 @@ private CompileResult CreateCompileResult() int errorCount = _compileMessages.Count(m => m.type == CompilerMessageType.Error); int warningCount = _compileMessages.Count(m => m.type == CompilerMessageType.Warning); - // For force compile, don't include messages in response - // User should use get-logs tool after domain reload completes + // Why: Unity does not expose reliable detailed issue data for this clean compile path. if (_isForceCompile) { return new CompileResult( - success: null, // Success status is indeterminate during force compile + success: null, errorCount: errorCount, warningCount: warningCount, completedAt: DateTime.Now, @@ -502,7 +549,7 @@ private CompileResult CreateCompileResult() errors: new CompilerMessage[0], warnings: new CompilerMessage[0], isIndeterminate: true, - message: "Force compilation executed. Use get-logs tool to retrieve compilation messages." + message: null ); } @@ -598,6 +645,7 @@ public void Cleanup() } _isCompiling = false; _isForceCompile = false; + _resultRecordingContext = CompileResultRecordingContext.Disabled(); } /// diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileDomainReloadRecoveryStartup.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileDomainReloadRecoveryStartup.cs deleted file mode 100644 index 36942ebfa1..0000000000 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompileDomainReloadRecoveryStartup.cs +++ /dev/null @@ -1,58 +0,0 @@ -using UnityEditor; -using System; - -using io.github.hatayama.UnityCliLoop.Domain; -using io.github.hatayama.UnityCliLoop.Infrastructure; -using io.github.hatayama.UnityCliLoop.ToolContracts; - -namespace io.github.hatayama.UnityCliLoop.FirstPartyTools -{ - /// - /// Starts pending compile result recovery after Unity recreates editor assemblies. - /// - internal static class CompileDomainReloadRecoveryStartup - { - private const int RecoveryMaxWaitMs = 5000; - - internal static void Initialize() - { - EditorApplication.delayCall += () => RecoverAfterDomainReload(DateTime.UtcNow); - } - - private static void RecoverAfterDomainReload(DateTime startedAtUtc) - { - PendingCompileResultRecoveryService recoveryService = CreateRecoveryService(); - bool recoverWhileEditorCompiling = - ShouldRecoverWhileEditorCompiling(startedAtUtc, DateTime.UtcNow); - PendingCompileRecoveryStatus status = recoveryService.Recover(recoverWhileEditorCompiling); - if (status == PendingCompileRecoveryStatus.Completed) - { - return; - } - - EditorApplication.delayCall += () => RecoverAfterDomainReload(startedAtUtc); - } - - internal static bool ShouldRecoverWhileEditorCompiling(DateTime startedAtUtc, DateTime utcNow) - { - System.Diagnostics.Debug.Assert(startedAtUtc.Kind == DateTimeKind.Utc, "startedAtUtc must be UTC"); - System.Diagnostics.Debug.Assert(utcNow.Kind == DateTimeKind.Utc, "utcNow must be UTC"); - - TimeSpan elapsed = utcNow - startedAtUtc; - return elapsed.TotalMilliseconds >= RecoveryMaxWaitMs; - } - - private static PendingCompileResultRecoveryService CreateRecoveryService() - { - UnityCliLoopEditorSessionStateService sessionStateService = - new UnityCliLoopEditorSessionStateService(new UnityCliLoopEditorSessionStateRepository()); - return new PendingCompileResultRecoveryService( - sessionStateService, - () => EditorApplication.isCompiling, - CompileResultPersistenceService.ResultExists, - CompileResultPersistenceService.SaveResult, - UnityCliLoopPathResolver.GetProjectRoot, - () => DateTime.UtcNow); - } - } -} diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileResponse.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileResponse.cs index 73776d37de..56391a7b67 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompileResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileResponse.cs @@ -69,8 +69,7 @@ public class CompileResponse : UnityCliLoopToolResponse /// /// Unity project root path (from UnityEngine.Application.dataPath). - /// Set only when WaitForDomainReload=true so that the CLI can locate the compile result - /// in the correct project even when connected to a remote Unity instance. + /// Set only when WaitForDomainReload=true so the CLI can report which project produced the result. /// public string ProjectRoot { get; set; } diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileResultPersistenceService.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileResultPersistenceService.cs deleted file mode 100644 index 3eb3b76d94..0000000000 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompileResultPersistenceService.cs +++ /dev/null @@ -1,133 +0,0 @@ -using System; -using System.IO; -using System.Text; -using Newtonsoft.Json; -using UnityEngine; - -using io.github.hatayama.UnityCliLoop.ToolContracts; - -namespace io.github.hatayama.UnityCliLoop.FirstPartyTools -{ - /// - /// Persist compile responses for delayed retrieval after domain reload. - /// - public static class CompileResultPersistenceService - { - private const string CompletedResultTempFileSuffix = ".tmp"; - private const string InProgressResultTempFileSuffix = ".tmp.write"; - - // Concurrent clients may still be waiting on recent result files. - // Only delete files older than this threshold (longer than the 90-second wait timeout) - // to avoid destroying results that active waiters need. - private static readonly TimeSpan StaleResultThreshold = TimeSpan.FromMinutes(2); - - private static string ProjectRootPath => Path.GetFullPath(Path.Combine(UnityEngine.Application.dataPath, "..")); - private static string CompileResultDirectoryPath => Path.Combine( - ProjectRootPath, - UnityCliLoopConstants.TEMP_DIR, - UnityCliLoopConstants.UNITYCLILOOP_DIR, - UnityCliLoopConstants.COMPILE_RESULTS_DIR - ); - - public static void ClearStaleResults() - { - if (!Directory.Exists(CompileResultDirectoryPath)) - { - return; - } - - string searchPattern = $"*{UnityCliLoopConstants.JSON_FILE_EXTENSION}"; - string[] resultFiles = Directory.GetFiles(CompileResultDirectoryPath, searchPattern); - DateTime staleThreshold = DateTime.UtcNow - StaleResultThreshold; - - foreach (string resultFilePath in resultFiles) - { - FileInfo fileInfo = new(resultFilePath); - if (fileInfo.LastWriteTimeUtc < staleThreshold) - { - File.Delete(resultFilePath); - } - } - } - - public static void SaveResult(string requestId, UnityCliLoopCompileResult response) - { - Debug.Assert(!string.IsNullOrWhiteSpace(requestId), "requestId must not be null or empty"); - Debug.Assert(response != null, "response must not be null"); - - string filePath = CreateResultFilePath(requestId); - if (!Directory.Exists(CompileResultDirectoryPath)) - { - Directory.CreateDirectory(CompileResultDirectoryPath); - } - - string resultJson = JsonConvert.SerializeObject(response, Formatting.None); - PublishResultFile(filePath, resultJson); - } - - public static bool ResultExists(string requestId) - { - Debug.Assert(!string.IsNullOrWhiteSpace(requestId), "requestId must not be null or empty"); - - string filePath = CreateResultFilePath(requestId); - return File.Exists(filePath); - } - - private static string CreateResultFilePath(string requestId) - { - ValidateRequestId(requestId); - - string fileName = $"{requestId}{UnityCliLoopConstants.JSON_FILE_EXTENSION}"; - return Path.Combine(CompileResultDirectoryPath, fileName); - } - - private static void ValidateRequestId(string requestId) - { - Debug.Assert(!string.IsNullOrWhiteSpace(requestId), "requestId must not be null or empty"); - - if (string.IsNullOrWhiteSpace(requestId)) - { - throw new ArgumentException("requestId must not be null or whitespace.", nameof(requestId)); - } - - if (!CompileRequestIdRules.IsSafe(requestId)) - { - throw new ArgumentException( - "requestId may contain only ASCII letters, digits, underscore, or hyphen.", - nameof(requestId)); - } - } - - private static void PublishResultFile(string filePath, string content) - { - Debug.Assert(!string.IsNullOrWhiteSpace(filePath), "filePath must not be null or empty"); - Debug.Assert(content != null, "content must not be null"); - - string inProgressTempFilePath = filePath + InProgressResultTempFileSuffix; - string completedTempFilePath = filePath + CompletedResultTempFileSuffix; - DeleteFileIfExists(inProgressTempFilePath); - DeleteFileIfExists(completedTempFilePath); - - File.WriteAllText(inProgressTempFilePath, content, Encoding.UTF8); - File.Move(inProgressTempFilePath, completedTempFilePath); - - if (File.Exists(filePath)) - { - File.Replace(completedTempFilePath, filePath, null); - return; - } - - File.Move(completedTempFilePath, filePath); - } - - private static void DeleteFileIfExists(string filePath) - { - Debug.Assert(!string.IsNullOrWhiteSpace(filePath), "filePath must not be null or empty"); - - if (File.Exists(filePath)) - { - File.Delete(filePath); - } - } - } -} diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileSessionResultService.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileSessionResultService.cs new file mode 100644 index 0000000000..455d4db0c0 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileSessionResultService.cs @@ -0,0 +1,153 @@ +using System; +using System.Diagnostics; +using System.Linq; +using Newtonsoft.Json; + +using io.github.hatayama.UnityCliLoop.Domain; +using io.github.hatayama.UnityCliLoop.Infrastructure; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Stores compile results in Editor SessionState so the CLI can read them after Domain Reload. + /// + internal static class CompileSessionResultService + { + internal static UnityCliLoopCompileResult CreateCompileResult( + CompileResult result, + bool forceRecompile) + { + Debug.Assert(result != null, "result must not be null"); + + if (forceRecompile) + { + return CreateForceCompileResult(result); + } + + if (result.IsIndeterminate) + { + return new UnityCliLoopCompileResult + { + Success = result.Success, + ErrorCount = result.ErrorCount, + WarningCount = result.WarningCount, + Errors = null, + Warnings = null, + Message = result.Message ?? "Compilation status is unknown. Use get-logs to inspect the compiler output." + }; + } + + return new UnityCliLoopCompileResult + { + Success = result.Success, + ErrorCount = result.Errors?.Length ?? 0, + WarningCount = result.Warnings?.Length ?? 0, + Errors = ToIssues(result.Errors), + Warnings = ToIssues(result.Warnings), + Message = result.Message + }; + } + + internal static void StoreCompileResult( + UnityCliLoopEditorSessionStateService sessionStateService, + string requestId, + bool forceRecompile, + UnityCliLoopCompileResult result, + string correlationId) + { + Debug.Assert(sessionStateService != null, "sessionStateService must not be null"); + Debug.Assert(!string.IsNullOrWhiteSpace(requestId), "requestId must not be null or whitespace"); + Debug.Assert(result != null, "result must not be null"); + + result.ProjectRoot = UnityCliLoopPathResolver.GetProjectRoot(); + string resultJson = JsonConvert.SerializeObject(result, Formatting.None); + sessionStateService.StoreCompileResult( + requestId, + forceRecompile, + resultJson, + DateTime.UtcNow); + bool pendingRequestCleared = + sessionStateService.ClearPendingCompileRequestIfMatches(requestId); + VibeLogger.LogInfo( + "compile_result_session_state_store_complete", + "Stored compile result in SessionState for CLI status polling.", + new + { + request_id = requestId, + force_recompile = forceRecompile, + success = result.Success, + error_count = result.ErrorCount, + warning_count = result.WarningCount, + result_bytes = System.Text.Encoding.UTF8.GetByteCount(resultJson), + pending_request_cleared = pendingRequestCleared + }, + correlationId); + } + + private static UnityCliLoopCompileResult CreateForceCompileResult(CompileResult result) + { + return new UnityCliLoopCompileResult + { + Success = result.Success, + ErrorCount = null, + WarningCount = null, + Errors = null, + Warnings = null, + Message = null + }; + } + + private static UnityCliLoopCompileIssue[] ToIssues(UnityEditor.Compilation.CompilerMessage[] messages) + { + if (messages == null) + { + return null; + } + + return messages.Select(message => new UnityCliLoopCompileIssue + { + Message = message.message, + File = message.file, + Line = message.line + }).ToArray(); + } + } + + /// + /// Describes whether a compile controller should record its result for delayed CLI polling. + /// + internal readonly struct CompileResultRecordingContext + { + private CompileResultRecordingContext(bool enabled, string requestId, bool forceRecompile) + { + Enabled = enabled; + RequestId = requestId; + ForceRecompile = forceRecompile; + } + + internal bool Enabled { get; } + internal string RequestId { get; } + internal bool ForceRecompile { get; } + + internal static CompileResultRecordingContext Disabled() + { + return new CompileResultRecordingContext(false, "", false); + } + + internal static CompileResultRecordingContext Create(UnityCliLoopCompileRequest request) + { + Debug.Assert(request != null, "request must not be null"); + + if (!request.WaitForDomainReload || string.IsNullOrWhiteSpace(request.RequestId)) + { + return Disabled(); + } + + return new CompileResultRecordingContext( + true, + request.RequestId, + request.ForceRecompile); + } + } +} diff --git a/Assets/Tests/Editor/CompileResultPersistenceServiceTests.cs.meta b/Packages/src/Editor/FirstPartyTools/Compile/CompileSessionResultService.cs.meta similarity index 83% rename from Assets/Tests/Editor/CompileResultPersistenceServiceTests.cs.meta rename to Packages/src/Editor/FirstPartyTools/Compile/CompileSessionResultService.cs.meta index 0034d43528..1cbffd724c 100644 --- a/Assets/Tests/Editor/CompileResultPersistenceServiceTests.cs.meta +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileSessionResultService.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 362c0e52a9e04a418f585ab226404984 +guid: 510b88aab72aa4f6d8d2c0a4464f791c MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileUseCase.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileUseCase.cs index 713b5f1756..4962201a57 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompileUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileUseCase.cs @@ -1,8 +1,6 @@ using System; -using System.IO; using System.Threading.Tasks; using System.Threading; -using System.Linq; using UnityEditor; using UnityEngine; @@ -44,158 +42,102 @@ public async Task CompileAsync(UnityCliLoopCompileReq PrepareResultStorage(request); string correlationId = ResolveCorrelationId(request); - bool resultPersistenceCompleted = false; - try - { - _sessionStateService.ClearExpiredPendingCompileRequest(DateTime.UtcNow); - MarkPendingCompileRequestIfNeeded(request); - LogCompileRequestReceived(request, correlationId); - - // 1. Play Mode preparation check - PlayModeCompilationPreparationService preparationService = new(); - PreparationResult preparation = preparationService.DeterminePreparationAction(); - - if (!preparation.CanProceed) - { - VibeLogger.LogWarning( - "compile_preparation_failed", - preparation.ErrorMessage, - BuildCompileLogContext(request), - correlationId); - UnityCliLoopCompileResult response = CreateCompileResult( - false, - 1, - 0, - new[] { CreateIssue(preparation.ErrorMessage, "", 0) }, - Array.Empty(), - null); - UnityCliLoopCompileResult persistedResponse = - PersistResponseIfNeeded(request, response, correlationId); - resultPersistenceCompleted = true; - return persistedResponse; - } + DateTime utcNow = DateTime.UtcNow; + _sessionStateService.ClearExpiredCompileResult(utcNow); + _sessionStateService.ClearExpiredPendingCompileRequest(utcNow); + MarkPendingCompileRequestIfNeeded(request, utcNow); - if (preparation.NeedsPlayModeStop) - { - VibeLogger.LogInfo( - "compile_playmode_stop_requested", - "Stopping Play Mode before compile.", - BuildCompileLogContext(request), - correlationId); - preparationService.StopPlayMode(); - bool exited = await WaitForPlayModeExitAsync(ct); - VibeLogger.LogInfo( - "compile_playmode_exit_observed", - exited ? "Play Mode exited before compile." : "Play Mode did not exit before compile.", - new - { - request_id = request.RequestId, - exited - }, - correlationId); - if (!exited) - { - UnityCliLoopCompileResult response = CreateCompileResult( - false, - 1, - 0, - new[] { CreateIssue("Play Mode did not exit within 5 seconds; compilation aborted.", "", 0) }, - Array.Empty(), - null); - UnityCliLoopCompileResult persistedResponse = - PersistResponseIfNeeded(request, response, correlationId); - resultPersistenceCompleted = true; - return persistedResponse; - } - } + // 1. Play Mode preparation check + PlayModeCompilationPreparationService preparationService = new(); + PreparationResult preparation = preparationService.DeterminePreparationAction(); - // 2. Compilation state validation - CompilationStateValidationService validationService = new(); - ValidationResult validation = validationService.ValidateCompilationState(); - - if (!validation.IsValid) - { - VibeLogger.LogWarning( - "compile_state_validation_failed", - validation.ErrorMessage, - BuildCompileLogContext(request), - correlationId); - UnityCliLoopCompileResult response = CreateCompileResult( - false, - 1, - 0, - new[] { CreateIssue(validation.ErrorMessage, "", 0) }, - Array.Empty(), - null); - UnityCliLoopCompileResult persistedResponse = - PersistResponseIfNeeded(request, response, correlationId); - resultPersistenceCompleted = true; - return persistedResponse; - } + if (!preparation.CanProceed) + { + VibeLogger.LogWarning( + "compile_preparation_failed", + preparation.ErrorMessage, + BuildCompileLogContext(request), + correlationId); + UnityCliLoopCompileResult response = CreateCompileResult( + false, + 1, + 0, + new[] { CreateIssue(preparation.ErrorMessage, "", 0) }, + Array.Empty(), + null); + UnityCliLoopCompileResult persistedResponse = + StoreResponseIfNeeded(request, response, correlationId); + return persistedResponse; + } - // 3. Compilation execution - ct.ThrowIfCancellationRequested(); + if (preparation.NeedsPlayModeStop) + { VibeLogger.LogInfo( - "compile_execution_start", - "Starting Unity compilation execution.", + "compile_playmode_stop_requested", + "Stopping Play Mode before compile.", BuildCompileLogContext(request), correlationId); - CompilationExecutionService executionService = new(); - CompileResult result = await executionService.ExecuteCompilationAsync(request.ForceRecompile, ct); + preparationService.StopPlayMode(); + bool exited = await WaitForPlayModeExitAsync(ct); VibeLogger.LogInfo( - "compile_execution_completed", - "Unity compilation execution completed.", + "compile_playmode_exit_observed", + exited ? "Play Mode exited before compile." : "Play Mode did not exit before compile.", new { request_id = request.RequestId, - success = result.Success, - error_count = result.ErrorCount, - warning_count = result.WarningCount, - is_indeterminate = result.IsIndeterminate + exited }, correlationId); - - // 4. Result formatting - if (result.IsIndeterminate) + if (!exited) { UnityCliLoopCompileResult response = CreateCompileResult( - result.Success, - result.ErrorCount, - result.WarningCount, - null, - null, - result.Message ?? "Compilation status is indeterminate. Use get-logs tool to check results."); + false, + 1, + 0, + new[] { CreateIssue("Play Mode did not exit within 5 seconds; compilation aborted.", "", 0) }, + Array.Empty(), + null); UnityCliLoopCompileResult persistedResponse = - PersistResponseIfNeeded(request, response, correlationId); - resultPersistenceCompleted = true; + StoreResponseIfNeeded(request, response, correlationId); return persistedResponse; } + } - UnityCliLoopCompileIssue[] errors = result.error?.Select(e => CreateIssue(e.message, e.file, e.line)).ToArray(); - UnityCliLoopCompileIssue[] warnings = result.warning?.Select(w => CreateIssue(w.message, w.file, w.line)).ToArray(); + // 2. Compilation state validation + CompilationStateValidationService validationService = new(); + ValidationResult validation = validationService.ValidateCompilationState(); - UnityCliLoopCompileResult successResponse = CreateCompileResult( - result.Success, - result.error?.Length ?? 0, - result.warning?.Length ?? 0, - errors, - warnings, - null); - UnityCliLoopCompileResult persistedSuccessResponse = - PersistResponseIfNeeded(request, successResponse, correlationId); - resultPersistenceCompleted = true; - return persistedSuccessResponse; - } - finally + if (!validation.IsValid) { - ClearPendingCompileRequestAfterCancellation(request, ct, correlationId); - ClearPendingCompileRequestAfterInterruptedCompile( - request, - resultPersistenceCompleted, - ct, + VibeLogger.LogWarning( + "compile_state_validation_failed", + validation.ErrorMessage, + BuildCompileLogContext(request), correlationId); + UnityCliLoopCompileResult response = CreateCompileResult( + false, + 1, + 0, + new[] { CreateIssue(validation.ErrorMessage, "", 0) }, + Array.Empty(), + null); + UnityCliLoopCompileResult persistedResponse = + StoreResponseIfNeeded(request, response, correlationId); + return persistedResponse; } + + // 3. Compilation execution + ct.ThrowIfCancellationRequested(); + CompilationExecutionService executionService = new(); + CompileResult result = await executionService.ExecuteCompilationAsync(request, ct); + + // 4. Result formatting + UnityCliLoopCompileResult successResponse = + CompileSessionResultService.CreateCompileResult(result, request.ForceRecompile); + UnityCliLoopCompileResult persistedSuccessResponse = + StoreResponseIfNeeded(request, successResponse, correlationId); + return persistedSuccessResponse; } private static UnityCliLoopCompileResult CreateCompileResult( @@ -245,8 +187,6 @@ private static void PrepareResultStorage(UnityCliLoopCompileRequest request) { Debug.Assert(request != null, "request must not be null"); - CompileResultPersistenceService.ClearStaleResults(); - if (!request.WaitForDomainReload) { return; @@ -260,7 +200,7 @@ private static void PrepareResultStorage(UnityCliLoopCompileRequest request) request.RequestId = CreateRequestId(); } - private UnityCliLoopCompileResult PersistResponseIfNeeded( + private UnityCliLoopCompileResult StoreResponseIfNeeded( UnityCliLoopCompileRequest request, UnityCliLoopCompileResult response, string correlationId) @@ -270,166 +210,45 @@ private UnityCliLoopCompileResult PersistResponseIfNeeded( if (!request.WaitForDomainReload) { - VibeLogger.LogInfo( - "compile_result_returned_without_domain_reload_wait", - "Returning compile result without delayed domain reload wait.", - new - { - success = response.Success, - error_count = response.ErrorCount, - warning_count = response.WarningCount - }, - correlationId); return response; } - response.ProjectRoot = Path.GetFullPath(Path.Combine(UnityEngine.Application.dataPath, "..")); - if (string.IsNullOrWhiteSpace(request.RequestId)) { VibeLogger.LogWarning( - "compile_result_not_persisted", - "Compile result was not persisted because the request ID is empty.", + "compile_result_not_stored", + "Compile result was not stored because the request ID is empty.", null, correlationId); return response; } - CompileResultPersistenceService.SaveResult(request.RequestId, response); - _sessionStateService.ClearPendingCompileRequestIfMatches(request.RequestId); - VibeLogger.LogInfo( - "compile_result_persisted", - "Compile result persisted for CLI domain reload wait.", - new - { - request_id = request.RequestId, - success = response.Success, - error_count = response.ErrorCount, - warning_count = response.WarningCount - }, + CompileSessionResultService.StoreCompileResult( + _sessionStateService, + request.RequestId, + request.ForceRecompile, + response, correlationId); return response; } - private void MarkPendingCompileRequestIfNeeded(UnityCliLoopCompileRequest request) - { - Debug.Assert(request != null, "request must not be null"); - - if (!request.WaitForDomainReload) - { - return; - } - - if (string.IsNullOrWhiteSpace(request.RequestId)) - { - return; - } - - _sessionStateService.MarkPendingCompileRequest(request.RequestId, request.ForceRecompile); - } - - private void ClearPendingCompileRequestAfterCancellation( - UnityCliLoopCompileRequest request, - CancellationToken ct, - string correlationId) - { - Debug.Assert(request != null, "request must not be null"); - - if (!ShouldClearPendingCompileRequestAfterCancellation( - request, - ct.IsCancellationRequested, - _sessionStateService.GetIsDomainReloadInProgress())) - { - return; - } - - VibeLogger.LogWarning( - "compile_pending_request_cleared_after_cancellation", - "Cleared pending compile recovery because the caller cancelled before Domain Reload started.", - BuildCompileLogContext(request), - correlationId); - _sessionStateService.ClearPendingCompileRequestIfMatches(request.RequestId); - } - - private void ClearPendingCompileRequestAfterInterruptedCompile( - UnityCliLoopCompileRequest request, - bool resultPersistenceCompleted, - CancellationToken ct, - string correlationId) - { - Debug.Assert(request != null, "request must not be null"); - - if (!ShouldClearPendingCompileRequestAfterInterruptedCompile( - request, - resultPersistenceCompleted, - ct.IsCancellationRequested, - _sessionStateService.GetIsDomainReloadInProgress())) - { - return; - } - - VibeLogger.LogWarning( - "compile_pending_request_cleared_after_interruption", - "Cleared pending compile recovery because compilation stopped before Domain Reload started.", - BuildCompileLogContext(request), - correlationId); - _sessionStateService.ClearPendingCompileRequestIfMatches(request.RequestId); - } - - internal static bool ShouldClearPendingCompileRequestAfterCancellation( + private void MarkPendingCompileRequestIfNeeded( UnityCliLoopCompileRequest request, - bool isCancellationRequested, - bool isDomainReloadInProgress) + DateTime markedAtUtc) { Debug.Assert(request != null, "request must not be null"); - - if (!isCancellationRequested) - { - return false; - } - - if (isDomainReloadInProgress) - { - return false; - } + Debug.Assert(markedAtUtc.Kind == DateTimeKind.Utc, "markedAtUtc must be UTC"); if (!request.WaitForDomainReload) { - return false; - } - - return !string.IsNullOrWhiteSpace(request.RequestId); - } - - internal static bool ShouldClearPendingCompileRequestAfterInterruptedCompile( - UnityCliLoopCompileRequest request, - bool resultPersistenceCompleted, - bool isCancellationRequested, - bool isDomainReloadInProgress) - { - Debug.Assert(request != null, "request must not be null"); - - if (resultPersistenceCompleted) - { - return false; - } - - if (isCancellationRequested) - { - return false; - } - - if (isDomainReloadInProgress) - { - return false; - } - - if (!request.WaitForDomainReload) - { - return false; + return; } - return !string.IsNullOrWhiteSpace(request.RequestId); + Debug.Assert(!string.IsNullOrWhiteSpace(request.RequestId), "request.RequestId must not be null or whitespace"); + _sessionStateService.MarkPendingCompileRequest( + request.RequestId, + request.ForceRecompile, + markedAtUtc); } private static string ResolveCorrelationId(UnityCliLoopCompileRequest request) @@ -456,17 +275,6 @@ private static object BuildCompileLogContext(UnityCliLoopCompileRequest request) }; } - private static void LogCompileRequestReceived(UnityCliLoopCompileRequest request, string correlationId) - { - VibeLogger.LogInfo( - "compile_request_received", - "Compile request received.", - BuildCompileLogContext(request), - correlationId, - humanNote: "Compile request entered the Unity-side use case.", - aiTodo: "Compare this point with compile_execution_start, compile_controller_waiting_for_finish, domain_reload_start, and domain_reload_complete."); - } - private static string CreateRequestId() { long unixTimeMilliseconds = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); diff --git a/Packages/src/Editor/FirstPartyTools/Compile/PendingCompileResultRecoveryService.cs b/Packages/src/Editor/FirstPartyTools/Compile/PendingCompileResultRecoveryService.cs deleted file mode 100644 index 726b0214c4..0000000000 --- a/Packages/src/Editor/FirstPartyTools/Compile/PendingCompileResultRecoveryService.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System; -using UnityEngine; - -using io.github.hatayama.UnityCliLoop.Domain; -using io.github.hatayama.UnityCliLoop.ToolContracts; - -namespace io.github.hatayama.UnityCliLoop.FirstPartyTools -{ - internal enum PendingCompileRecoveryStatus - { - Completed, - Retry - } - - /// - /// Completes compile result files for requests that crossed Domain Reload before the live callback persisted them. - /// - internal sealed class PendingCompileResultRecoveryService - { - private readonly UnityCliLoopEditorSessionStateService _sessionStateService; - private readonly Func _isEditorCompiling; - private readonly Func _resultExists; - private readonly Action _saveResult; - private readonly Func _getProjectRoot; - private readonly Func _getUtcNow; - - internal PendingCompileResultRecoveryService( - UnityCliLoopEditorSessionStateService sessionStateService, - Func isEditorCompiling, - Func resultExists, - Action saveResult, - Func getProjectRoot, - Func getUtcNow) - { - Debug.Assert(sessionStateService != null, "sessionStateService must not be null"); - Debug.Assert(isEditorCompiling != null, "isEditorCompiling must not be null"); - Debug.Assert(resultExists != null, "resultExists must not be null"); - Debug.Assert(saveResult != null, "saveResult must not be null"); - Debug.Assert(getProjectRoot != null, "getProjectRoot must not be null"); - Debug.Assert(getUtcNow != null, "getUtcNow must not be null"); - - _sessionStateService = sessionStateService ?? throw new ArgumentNullException(nameof(sessionStateService)); - _isEditorCompiling = isEditorCompiling ?? throw new ArgumentNullException(nameof(isEditorCompiling)); - _resultExists = resultExists ?? throw new ArgumentNullException(nameof(resultExists)); - _saveResult = saveResult ?? throw new ArgumentNullException(nameof(saveResult)); - _getProjectRoot = getProjectRoot ?? throw new ArgumentNullException(nameof(getProjectRoot)); - _getUtcNow = getUtcNow ?? throw new ArgumentNullException(nameof(getUtcNow)); - } - - internal PendingCompileRecoveryStatus Recover(bool recoverWhileEditorCompiling) - { - UnityCliLoopPendingCompileRequest pendingCompileRequest = - _sessionStateService.GetPendingCompileRequest(); - if (!pendingCompileRequest.HasRequest) - { - return PendingCompileRecoveryStatus.Completed; - } - - DateTime utcNow = _getUtcNow(); - if (pendingCompileRequest.IsExpiredAt(utcNow)) - { - VibeLogger.LogInfo( - "compile_pending_result_expired", - "Pending compile recovery expired before Domain Reload recovery could use it.", - new - { - request_id = pendingCompileRequest.RequestId, - force_recompile = pendingCompileRequest.ForceRecompile - }, - pendingCompileRequest.RequestId); - _sessionStateService.ClearPendingCompileRequestIfMatches(pendingCompileRequest.RequestId); - return PendingCompileRecoveryStatus.Completed; - } - - if (_isEditorCompiling() && !recoverWhileEditorCompiling) - { - return PendingCompileRecoveryStatus.Retry; - } - - if (_resultExists(pendingCompileRequest.RequestId)) - { - VibeLogger.LogInfo( - "compile_pending_result_already_persisted", - "Pending compile recovery found an existing result file and cleared SessionState.", - new - { - request_id = pendingCompileRequest.RequestId, - force_recompile = pendingCompileRequest.ForceRecompile - }, - pendingCompileRequest.RequestId); - _sessionStateService.ClearPendingCompileRequestIfMatches(pendingCompileRequest.RequestId); - return PendingCompileRecoveryStatus.Completed; - } - - UnityCliLoopCompileResult result = CreateIndeterminateResult(pendingCompileRequest); - _saveResult(pendingCompileRequest.RequestId, result); - VibeLogger.LogWarning( - "compile_pending_result_recovered_after_domain_reload", - result.Message, - new - { - request_id = pendingCompileRequest.RequestId, - force_recompile = pendingCompileRequest.ForceRecompile - }, - pendingCompileRequest.RequestId); - _sessionStateService.ClearPendingCompileRequestIfMatches(pendingCompileRequest.RequestId); - return PendingCompileRecoveryStatus.Completed; - } - - private UnityCliLoopCompileResult CreateIndeterminateResult( - UnityCliLoopPendingCompileRequest pendingCompileRequest) - { - string message = pendingCompileRequest.ForceRecompile - ? "Force compilation crossed Domain Reload before Unity CLI Loop could persist the live result. Use get-logs to inspect the compiler output." - : "Compilation crossed Domain Reload before Unity CLI Loop could persist the live result. Use get-logs to inspect the compiler output."; - - return new UnityCliLoopCompileResult - { - Success = null, - ErrorCount = null, - WarningCount = null, - Errors = null, - Warnings = null, - Message = message, - ProjectRoot = _getProjectRoot() - }; - } - } -} diff --git a/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs index d092a729e6..78ad1a04eb 100644 --- a/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs +++ b/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs @@ -8,7 +8,6 @@ public static class FirstPartyToolsEditorStartup { public static void Initialize() { - CompileDomainReloadRecoveryStartup.Initialize(); ExecuteDynamicCodeEditorStartup.Initialize(); GetLogsEditorStartup.Initialize(); #if ULOOP_HAS_TEST_FRAMEWORK diff --git a/Packages/src/Editor/Infrastructure/Api/CompileStatusBridgeCommand.cs b/Packages/src/Editor/Infrastructure/Api/CompileStatusBridgeCommand.cs new file mode 100644 index 0000000000..b13c19482f --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/CompileStatusBridgeCommand.cs @@ -0,0 +1,159 @@ +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.Application; +using io.github.hatayama.UnityCliLoop.Domain; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Reports compile completion state for the CLI without entering the normal tool execution slot. + /// + internal static class CompileStatusBridgeCommand + { + private const string RequestIdParamName = "RequestId"; + private const string RecoveredCompileResultMessage = + "Compilation completed, but Unity reloaded scripts before Unity CLI Loop could record detailed errors or warnings. Use get-logs to inspect the compiler output."; + + public static GetCompileStatusResponse Execute(JToken paramsToken) + { + string requestId = ReadRequestId(paramsToken); + UnityCliLoopEditorSessionStateService sessionStateService = + new UnityCliLoopEditorSessionStateService(new UnityCliLoopEditorSessionStateRepository()); + sessionStateService.ClearExpiredCompileResult(DateTime.UtcNow); + bool isCompiling = EditorApplication.isCompiling; + bool isUpdating = EditorApplication.isUpdating; + bool isDomainReloadInProgress = + sessionStateService.GetIsDomainReloadInProgress() || + DomainReloadStateRegistry.IsDomainReloadInProgress(); + return BuildResponse( + requestId, + isCompiling, + isUpdating, + isDomainReloadInProgress, + sessionStateService); + } + + internal static GetCompileStatusResponse BuildResponse( + string requestId, + bool isCompiling, + bool isUpdating, + bool isDomainReloadInProgress, + UnityCliLoopEditorSessionStateService sessionStateService) + { + Debug.Assert(sessionStateService != null, "sessionStateService must not be null"); + + bool ready = !isCompiling && !isUpdating && !isDomainReloadInProgress; + sessionStateService.ClearExpiredPendingCompileRequest(DateTime.UtcNow); + UnityCliLoopStoredCompileResult storedResult = string.IsNullOrWhiteSpace(requestId) + ? UnityCliLoopStoredCompileResult.None() + : sessionStateService.GetCompileResult(requestId); + if (ready && !storedResult.HasResult) + { + storedResult = RecoverPendingCompileResult(requestId, sessionStateService); + } + + JToken result = storedResult.HasResult ? JToken.Parse(storedResult.ResultJson) : null; + return new GetCompileStatusResponse + { + Ready = ready, + HasResult = storedResult.HasResult, + IsCompiling = isCompiling, + IsUpdating = isUpdating, + IsDomainReloadInProgress = isDomainReloadInProgress, + Result = result, + Message = CreateMessage(ready, storedResult.HasResult) + }; + } + + private static string ReadRequestId(JToken paramsToken) + { + if (paramsToken is not JObject paramsObject) + { + return ""; + } + + JToken requestIdToken = paramsObject.GetValue(RequestIdParamName, StringComparison.OrdinalIgnoreCase); + return requestIdToken?.ToString() ?? ""; + } + + private static UnityCliLoopStoredCompileResult RecoverPendingCompileResult( + string requestId, + UnityCliLoopEditorSessionStateService sessionStateService) + { + Debug.Assert(sessionStateService != null, "sessionStateService must not be null"); + + if (string.IsNullOrWhiteSpace(requestId)) + { + return UnityCliLoopStoredCompileResult.None(); + } + + UnityCliLoopPendingCompileRequest pendingRequest = + sessionStateService.GetPendingCompileRequestForRequestId(requestId); + if (!pendingRequest.HasRequest) + { + return UnityCliLoopStoredCompileResult.None(); + } + + if (!pendingRequest.ReloadObserved) + { + return UnityCliLoopStoredCompileResult.None(); + } + + JObject recoveredResult = CreateRecoveredCompileResult(pendingRequest); + sessionStateService.StoreCompileResult( + requestId, + pendingRequest.ForceRecompile, + recoveredResult.ToString(Formatting.None), + DateTime.UtcNow); + sessionStateService.ClearPendingCompileRequestIfMatches(requestId); + return sessionStateService.GetCompileResult(requestId); + } + + private static JObject CreateRecoveredCompileResult( + UnityCliLoopPendingCompileRequest pendingRequest) + { + Debug.Assert(pendingRequest != null, "pendingRequest must not be null"); + + return new JObject + { + ["Success"] = JValue.CreateNull(), + ["ErrorCount"] = JValue.CreateNull(), + ["WarningCount"] = JValue.CreateNull(), + ["Errors"] = JValue.CreateNull(), + ["Warnings"] = JValue.CreateNull(), + ["Message"] = CreateRecoveredCompileMessage(pendingRequest.ForceRecompile), + ["ProjectRoot"] = UnityCliLoopPathResolver.GetProjectRoot() + }; + } + + private static JToken CreateRecoveredCompileMessage(bool forceRecompile) + { + if (forceRecompile) + { + return JValue.CreateNull(); + } + + return new JValue(RecoveredCompileResultMessage); + } + + private static string CreateMessage(bool ready, bool hasResult) + { + if (!ready) + { + return "Unity is still compiling, updating assets, or reloading scripts."; + } + + if (!hasResult) + { + return "Compile result is not available for this request. Unity may have restarted or the request may not have completed."; + } + + return "Compile result is available."; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Compile/PendingCompileResultRecoveryService.cs.meta b/Packages/src/Editor/Infrastructure/Api/CompileStatusBridgeCommand.cs.meta similarity index 83% rename from Packages/src/Editor/FirstPartyTools/Compile/PendingCompileResultRecoveryService.cs.meta rename to Packages/src/Editor/Infrastructure/Api/CompileStatusBridgeCommand.cs.meta index 4ed2a9b464..871f075baa 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/PendingCompileResultRecoveryService.cs.meta +++ b/Packages/src/Editor/Infrastructure/Api/CompileStatusBridgeCommand.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: d3c7e98b41b0b4650bd8d3c43a83ffa5 +guid: 4e36be928cfec47aa96f8a7129acbb02 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Packages/src/Editor/Infrastructure/Api/GetCompileStatusResponse.cs b/Packages/src/Editor/Infrastructure/Api/GetCompileStatusResponse.cs new file mode 100644 index 0000000000..7bc8db0d27 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Api/GetCompileStatusResponse.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json.Linq; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Infrastructure +{ + /// + /// Compile status payload returned by the internal CLI polling bridge command. + /// + public class GetCompileStatusResponse : UnityCliLoopToolResponse + { + public bool Ready { get; set; } + public bool HasResult { get; set; } + public bool IsCompiling { get; set; } + public bool IsUpdating { get; set; } + public bool IsDomainReloadInProgress { get; set; } + public JToken Result { get; set; } + public string Message { get; set; } + } +} diff --git a/Assets/Tests/Editor/PendingCompileResultRecoveryServiceTests.cs.meta b/Packages/src/Editor/Infrastructure/Api/GetCompileStatusResponse.cs.meta similarity index 83% rename from Assets/Tests/Editor/PendingCompileResultRecoveryServiceTests.cs.meta rename to Packages/src/Editor/Infrastructure/Api/GetCompileStatusResponse.cs.meta index 4c8908b51c..3c6abaa6d6 100644 --- a/Assets/Tests/Editor/PendingCompileResultRecoveryServiceTests.cs.meta +++ b/Packages/src/Editor/Infrastructure/Api/GetCompileStatusResponse.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 756c4d7b62bc24a8ba9411388d621197 +guid: e09fd1acb33bc4e579d7b00a3394f3f4 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Packages/src/Editor/Infrastructure/Api/InternalBridgeCommandRouter.cs b/Packages/src/Editor/Infrastructure/Api/InternalBridgeCommandRouter.cs index 0b36e21bfd..d3c9d8ccec 100644 --- a/Packages/src/Editor/Infrastructure/Api/InternalBridgeCommandRouter.cs +++ b/Packages/src/Editor/Infrastructure/Api/InternalBridgeCommandRouter.cs @@ -14,6 +14,7 @@ internal static class InternalBridgeCommandRouter public static bool IsInternalCommand(string commandName) { return commandName == UnityCliLoopConstants.COMMAND_NAME_GET_VERSION || + commandName == UnityCliLoopConstants.COMMAND_NAME_GET_COMPILE_STATUS || commandName == UnityCliLoopConstants.COMMAND_NAME_GET_TOOL_DETAILS; } @@ -31,6 +32,11 @@ public static UnityCliLoopToolResponse Execute(string commandName, JToken params return GetToolDetailsBridgeCommand.Execute(paramsToken); } + if (commandName == UnityCliLoopConstants.COMMAND_NAME_GET_COMPILE_STATUS) + { + return CompileStatusBridgeCommand.Execute(paramsToken); + } + throw new ArgumentException($"Unknown internal bridge command: {commandName}", nameof(commandName)); } } diff --git a/Packages/src/Editor/Infrastructure/Settings/UnityCliLoopEditorSessionStateRepository.cs b/Packages/src/Editor/Infrastructure/Settings/UnityCliLoopEditorSessionStateRepository.cs index 14bafe959c..338ebab21e 100644 --- a/Packages/src/Editor/Infrastructure/Settings/UnityCliLoopEditorSessionStateRepository.cs +++ b/Packages/src/Editor/Infrastructure/Settings/UnityCliLoopEditorSessionStateRepository.cs @@ -19,9 +19,26 @@ public sealed class UnityCliLoopEditorSessionStateRepository : IUnityCliLoopEdit private const string ShowPostCompileReconnectingUIKey = KeyPrefix + "showPostCompileReconnectingUI"; private const string ShouldAutoScanThirdPartyToolMigrationKey = KeyPrefix + "shouldAutoScanThirdPartyToolMigration"; - private const string PendingCompileRequestIdKey = KeyPrefix + "pendingCompileRequestId"; - private const string PendingCompileForceRecompileKey = KeyPrefix + "pendingCompileForceRecompile"; - private const string PendingCompileExpiresAtUtcTicksKey = KeyPrefix + "pendingCompileExpiresAtUtcTicks"; + private const string CompileResultRequestIdsKey = KeyPrefix + "compileResultRequestIds"; + private const string LegacyCompileResultRequestIdKey = KeyPrefix + "compileResultRequestId"; + private const string LegacyCompileResultForceRecompileKey = KeyPrefix + "compileResultForceRecompile"; + private const string LegacyCompileResultJsonKey = KeyPrefix + "compileResultJson"; + private const string LegacyCompileResultCompletedAtUtcTicksKey = + KeyPrefix + "compileResultCompletedAtUtcTicks"; + private const string CompileResultKeyPrefix = KeyPrefix + "compileResult."; + private const string CompileResultForceRecompileKeySuffix = ".forceRecompile"; + private const string CompileResultJsonKeySuffix = ".json"; + private const string CompileResultCompletedAtUtcTicksKeySuffix = ".completedAtUtcTicks"; + private const string PendingCompileRequestIdsKey = KeyPrefix + "pendingCompileRequestIds"; + private const string LegacyPendingCompileRequestIdKey = KeyPrefix + "pendingCompileRequestId"; + private const string LegacyPendingCompileForceRecompileKey = KeyPrefix + "pendingCompileForceRecompile"; + private const string LegacyPendingCompileExpiresAtUtcTicksKey = + KeyPrefix + "pendingCompileExpiresAtUtcTicks"; + private const string LegacyPendingCompileReloadObservedKey = KeyPrefix + "pendingCompileReloadObserved"; + private const string PendingCompileKeyPrefix = KeyPrefix + "pendingCompile."; + private const string PendingCompileForceRecompileKeySuffix = ".forceRecompile"; + private const string PendingCompileExpiresAtUtcTicksKeySuffix = ".expiresAtUtcTicks"; + private const string PendingCompileReloadObservedKeySuffix = ".reloadObserved"; public bool GetIsServerRunning() { @@ -103,34 +120,174 @@ public void SetShouldAutoScanThirdPartyToolMigration(bool shouldAutoScanThirdPar SetBool(ShouldAutoScanThirdPartyToolMigrationKey, shouldAutoScanThirdPartyToolMigration); } - public string GetPendingCompileRequestId() + public string GetCompileResultRequestIds() { - return GetString(PendingCompileRequestIdKey); + return GetString(CompileResultRequestIdsKey); } - public void SetPendingCompileRequestId(string pendingCompileRequestId) + public void SetCompileResultRequestIds(string compileResultRequestIds) { - SetString(PendingCompileRequestIdKey, pendingCompileRequestId); + SetString(CompileResultRequestIdsKey, compileResultRequestIds); } - public bool GetPendingCompileForceRecompile() + public string GetLegacyCompileResultRequestId() { - return GetBool(PendingCompileForceRecompileKey); + return GetString(LegacyCompileResultRequestIdKey); } - public void SetPendingCompileForceRecompile(bool pendingCompileForceRecompile) + public void SetLegacyCompileResultRequestId(string compileResultRequestId) { - SetBool(PendingCompileForceRecompileKey, pendingCompileForceRecompile); + SetString(LegacyCompileResultRequestIdKey, compileResultRequestId); } - public string GetPendingCompileExpiresAtUtcTicks() + public bool GetLegacyCompileResultForceRecompile() { - return GetString(PendingCompileExpiresAtUtcTicksKey); + return GetBool(LegacyCompileResultForceRecompileKey); } - public void SetPendingCompileExpiresAtUtcTicks(string pendingCompileExpiresAtUtcTicks) + public void SetLegacyCompileResultForceRecompile(bool compileResultForceRecompile) { - SetString(PendingCompileExpiresAtUtcTicksKey, pendingCompileExpiresAtUtcTicks); + SetBool(LegacyCompileResultForceRecompileKey, compileResultForceRecompile); + } + + public string GetLegacyCompileResultJson() + { + return GetString(LegacyCompileResultJsonKey); + } + + public void SetLegacyCompileResultJson(string compileResultJson) + { + SetString(LegacyCompileResultJsonKey, compileResultJson); + } + + public string GetLegacyCompileResultCompletedAtUtcTicks() + { + return GetString(LegacyCompileResultCompletedAtUtcTicksKey); + } + + public void SetLegacyCompileResultCompletedAtUtcTicks(string compileResultCompletedAtUtcTicks) + { + SetString(LegacyCompileResultCompletedAtUtcTicksKey, compileResultCompletedAtUtcTicks); + } + + public bool GetCompileResultForceRecompile(string requestId) + { + return GetBool(CreateCompileResultKey(requestId, CompileResultForceRecompileKeySuffix)); + } + + public void SetCompileResultForceRecompile(string requestId, bool compileResultForceRecompile) + { + SetBool(CreateCompileResultKey(requestId, CompileResultForceRecompileKeySuffix), compileResultForceRecompile); + } + + public string GetCompileResultJson(string requestId) + { + return GetString(CreateCompileResultKey(requestId, CompileResultJsonKeySuffix)); + } + + public void SetCompileResultJson(string requestId, string compileResultJson) + { + SetString(CreateCompileResultKey(requestId, CompileResultJsonKeySuffix), compileResultJson); + } + + public string GetCompileResultCompletedAtUtcTicks(string requestId) + { + return GetString(CreateCompileResultKey(requestId, CompileResultCompletedAtUtcTicksKeySuffix)); + } + + public void SetCompileResultCompletedAtUtcTicks(string requestId, string compileResultCompletedAtUtcTicks) + { + SetString(CreateCompileResultKey(requestId, CompileResultCompletedAtUtcTicksKeySuffix), compileResultCompletedAtUtcTicks); + } + + public string GetPendingCompileRequestIds() + { + return GetString(PendingCompileRequestIdsKey); + } + + public void SetPendingCompileRequestIds(string pendingCompileRequestIds) + { + SetString(PendingCompileRequestIdsKey, pendingCompileRequestIds); + } + + public string GetLegacyPendingCompileRequestId() + { + return GetString(LegacyPendingCompileRequestIdKey); + } + + public void SetLegacyPendingCompileRequestId(string pendingCompileRequestId) + { + SetString(LegacyPendingCompileRequestIdKey, pendingCompileRequestId); + } + + public bool GetLegacyPendingCompileForceRecompile() + { + return GetBool(LegacyPendingCompileForceRecompileKey); + } + + public void SetLegacyPendingCompileForceRecompile(bool pendingCompileForceRecompile) + { + SetBool(LegacyPendingCompileForceRecompileKey, pendingCompileForceRecompile); + } + + public string GetLegacyPendingCompileExpiresAtUtcTicks() + { + return GetString(LegacyPendingCompileExpiresAtUtcTicksKey); + } + + public void SetLegacyPendingCompileExpiresAtUtcTicks(string pendingCompileExpiresAtUtcTicks) + { + SetString(LegacyPendingCompileExpiresAtUtcTicksKey, pendingCompileExpiresAtUtcTicks); + } + + public bool GetLegacyPendingCompileReloadObserved() + { + return GetBool(LegacyPendingCompileReloadObservedKey); + } + + public void SetLegacyPendingCompileReloadObserved(bool pendingCompileReloadObserved) + { + SetBool(LegacyPendingCompileReloadObservedKey, pendingCompileReloadObserved); + } + + public bool GetPendingCompileForceRecompile(string requestId) + { + return GetBool(CreatePendingCompileKey(requestId, PendingCompileForceRecompileKeySuffix)); + } + + public void SetPendingCompileForceRecompile(string requestId, bool pendingCompileForceRecompile) + { + SetBool(CreatePendingCompileKey(requestId, PendingCompileForceRecompileKeySuffix), pendingCompileForceRecompile); + } + + public string GetPendingCompileExpiresAtUtcTicks(string requestId) + { + return GetString(CreatePendingCompileKey(requestId, PendingCompileExpiresAtUtcTicksKeySuffix)); + } + + public void SetPendingCompileExpiresAtUtcTicks(string requestId, string pendingCompileExpiresAtUtcTicks) + { + SetString(CreatePendingCompileKey(requestId, PendingCompileExpiresAtUtcTicksKeySuffix), pendingCompileExpiresAtUtcTicks); + } + + public bool GetPendingCompileReloadObserved(string requestId) + { + return GetBool(CreatePendingCompileKey(requestId, PendingCompileReloadObservedKeySuffix)); + } + + public void SetPendingCompileReloadObserved(string requestId, bool pendingCompileReloadObserved) + { + SetBool(CreatePendingCompileKey(requestId, PendingCompileReloadObservedKeySuffix), pendingCompileReloadObserved); + } + + private static string CreateCompileResultKey(string requestId, string suffix) + { + return CompileResultKeyPrefix + requestId + suffix; + } + + private static string CreatePendingCompileKey(string requestId, string suffix) + { + return PendingCompileKeyPrefix + requestId + suffix; } private static bool GetBool(string key) diff --git a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs index 86771569df..c5f5539e3a 100644 --- a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs +++ b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs @@ -51,7 +51,6 @@ public static UnityEditor.PackageManager.PackageInfo PackageInfo // Package path constants public const string TEMP_DIR = "Temp"; public const string UNITYCLILOOP_DIR = "UnityCliLoop"; - public const string COMPILE_RESULTS_DIR = "compile-results"; public const string JSON_FILE_EXTENSION = ".json"; // .uloop directory @@ -68,6 +67,7 @@ public static UnityEditor.PackageManager.PackageInfo PackageInfo public const string TOOL_NAME_RUN_TESTS = "run-tests"; public const string COMMAND_NAME_GET_TOOL_DETAILS = "get-tool-details"; public const string COMMAND_NAME_GET_VERSION = "get-version"; + public const string COMMAND_NAME_GET_COMPILE_STATUS = "get-compile-status"; // Optional package names public const string PACKAGE_NAME_TEST_FRAMEWORK = "com.unity.test-framework"; diff --git a/scripts/test-use-local-uloop.sh b/scripts/test-use-local-uloop.sh new file mode 100755 index 0000000000..ea61fd27bc --- /dev/null +++ b/scripts/test-use-local-uloop.sh @@ -0,0 +1,79 @@ +#!/bin/sh +set -eu + +ROOT_DIR=$(CDPATH= cd "$(dirname "$0")/.." && pwd) +WORK_DIR="${TMPDIR:-/tmp}/uloop-use-local-test-$$" +BIN_DIR="$WORK_DIR/bin" + +case "$(uname -s)" in + Darwin) + ;; + *) + echo "use-local-uloop test skipped on unsupported platform" + exit 0 + ;; +esac + +cleanup() { + rm -rf "$WORK_DIR" +} + +trap cleanup EXIT INT HUP TERM + +mkdir -p "$BIN_DIR" +printf '%s\n' '#!/bin/sh' 'echo old-global-uloop' > "$BIN_DIR/uloop" +chmod +x "$BIN_DIR/uloop" + +ULOOP_GLOBAL_BIN_DIR="$BIN_DIR" "$ROOT_DIR/scripts/use-local-uloop.sh" link >/dev/null + +if [ ! -L "$BIN_DIR/uloop" ]; then + echo "Expected global uloop to be a symlink after link." >&2 + exit 1 +fi + +expected_target="$ROOT_DIR/Packages/src/Cli~/dist/darwin-arm64/uloop" +if [ "$(uname -m)" = "x86_64" ] || [ "$(uname -m)" = "amd64" ]; then + expected_target="$ROOT_DIR/Packages/src/Cli~/dist/darwin-amd64/uloop" +fi + +actual_target=$(readlink "$BIN_DIR/uloop") +if [ "$actual_target" != "$expected_target" ]; then + echo "Unexpected symlink target: $actual_target" >&2 + echo "Expected: $expected_target" >&2 + exit 1 +fi + +if [ ! -x "$BIN_DIR/uloop.before-local-link" ]; then + echo "Expected original global uloop backup to exist." >&2 + exit 1 +fi + +ULOOP_GLOBAL_BIN_DIR="$BIN_DIR" "$ROOT_DIR/scripts/use-local-uloop.sh" restore >/dev/null + +if [ -L "$BIN_DIR/uloop" ]; then + echo "Expected restored global uloop to no longer be a symlink." >&2 + exit 1 +fi + +if [ "$("$BIN_DIR/uloop")" != "old-global-uloop" ]; then + echo "Restored global uloop did not execute the original script." >&2 + exit 1 +fi + +if [ -e "$BIN_DIR/uloop.before-local-link" ]; then + echo "Expected backup to be consumed after restore." >&2 + exit 1 +fi + +TRAILING_HOME="$WORK_DIR/trailing-home" +TRAILING_BIN="$TRAILING_HOME/.local/bin" +mkdir -p "$TRAILING_BIN" + +HOME="$TRAILING_HOME" PATH="$TRAILING_BIN/:/usr/bin:/bin:/usr/sbin:/sbin" "$ROOT_DIR/scripts/use-local-uloop.sh" link >/dev/null + +if [ ! -L "$TRAILING_BIN/uloop" ]; then + echo "Expected PATH entry with trailing slash to be selected for global uloop." >&2 + exit 1 +fi + +echo "use-local-uloop link/restore test passed" diff --git a/scripts/use-local-uloop.sh b/scripts/use-local-uloop.sh new file mode 100755 index 0000000000..c8e235399f --- /dev/null +++ b/scripts/use-local-uloop.sh @@ -0,0 +1,181 @@ +#!/bin/sh +set -eu + +ROOT_DIR=$(CDPATH= cd "$(dirname "$0")/.." && pwd) +ACTION="${1:-status}" + +command_name="uloop" + +normalize_path_dir() { + path_dir="$1" + while [ "$path_dir" != "/" ] && [ "${path_dir%/}" != "$path_dir" ]; do + path_dir=${path_dir%/} + done + printf '%s\n' "$path_dir" +} + +select_local_uloop() { + os_name=$(uname -s) + arch_name=$(uname -m) + + case "$os_name:$arch_name" in + Darwin:arm64|Darwin:aarch64) + local_uloop="$ROOT_DIR/Packages/src/Cli~/dist/darwin-arm64/uloop" + ;; + Darwin:x86_64|Darwin:amd64) + local_uloop="$ROOT_DIR/Packages/src/Cli~/dist/darwin-amd64/uloop" + ;; + *) + echo "No checked-in local uloop binary for $os_name $arch_name." >&2 + exit 1 + ;; + esac + + if [ ! -x "$local_uloop" ]; then + echo "Local uloop binary is missing or not executable: $local_uloop" >&2 + exit 1 + fi +} + +path_contains_dir() { + candidate_dir=$(normalize_path_dir "$1") + old_ifs=$IFS + IFS=: + for path_dir in $PATH; do + normalized_path_dir=$(normalize_path_dir "$path_dir") + if [ "$normalized_path_dir" = "$candidate_dir" ]; then + IFS=$old_ifs + return 0 + fi + done + IFS=$old_ifs + return 1 +} + +select_global_bin_dir() { + if [ "${ULOOP_GLOBAL_BIN_DIR:-}" ]; then + global_bin_dir=$ULOOP_GLOBAL_BIN_DIR + return + fi + + existing_uloop=$(command -v "$command_name" 2>/dev/null || true) + if [ "$existing_uloop" ]; then + global_bin_dir=$(dirname "$existing_uloop") + return + fi + + npm_global_bin="$HOME/.npm-global/bin" + if path_contains_dir "$npm_global_bin" || [ -e "$npm_global_bin/$command_name" ]; then + global_bin_dir=$npm_global_bin + return + fi + + local_bin="$HOME/.local/bin" + if path_contains_dir "$local_bin"; then + global_bin_dir=$local_bin + return + fi + + echo "No PATH directory found for global uloop. Set ULOOP_GLOBAL_BIN_DIR." >&2 + exit 1 +} + +print_status() { + echo "Global uloop path: $global_uloop" + if [ -L "$global_uloop" ]; then + echo "Global uloop symlink target: $(readlink "$global_uloop")" + elif [ -e "$global_uloop" ]; then + echo "Global uloop is a regular file or directory." + else + echo "Global uloop does not exist." + fi + + if [ -e "$backup_uloop" ] || [ -L "$backup_uloop" ]; then + echo "Backup path: $backup_uloop" + else + echo "Backup path: none" + fi + + echo "Local branch uloop: $local_uloop" + if [ -x "$global_uloop" ]; then + echo "Selected global uloop version:" + "$global_uloop" --version || true + fi + + if command -v "$command_name" >/dev/null 2>&1; then + echo "Resolved uloop: $(command -v "$command_name")" + fi +} + +link_local_uloop() { + mkdir -p "$global_bin_dir" + + if [ -L "$global_uloop" ] && [ "$(readlink "$global_uloop")" = "$local_uloop" ]; then + echo "Global uloop already points to this checkout." + print_status + return + fi + + if [ -e "$global_uloop" ] || [ -L "$global_uloop" ]; then + if [ -e "$backup_uloop" ] || [ -L "$backup_uloop" ]; then + echo "Backup already exists, refusing to overwrite it: $backup_uloop" >&2 + echo "Run '$0 restore' first or move the backup manually." >&2 + exit 1 + fi + + mv "$global_uloop" "$backup_uloop" + echo "Backed up existing global uloop to $backup_uloop" + fi + + ln -s "$local_uloop" "$global_uloop" + print_status +} + +restore_global_uloop() { + if [ ! -e "$backup_uloop" ] && [ ! -L "$backup_uloop" ]; then + echo "No backup found: $backup_uloop" >&2 + exit 1 + fi + + if [ -L "$global_uloop" ]; then + current_target=$(readlink "$global_uloop") + if [ "$current_target" != "$local_uloop" ]; then + echo "Refusing to remove unexpected uloop symlink: $global_uloop -> $current_target" >&2 + exit 1 + fi + + rm "$global_uloop" + elif [ -e "$global_uloop" ]; then + echo "Refusing to overwrite unexpected global uloop: $global_uloop" >&2 + exit 1 + fi + + mv "$backup_uloop" "$global_uloop" + print_status +} + +usage() { + echo "Usage: $0 link|restore|status" >&2 + exit 1 +} + +select_local_uloop +select_global_bin_dir + +global_uloop="$global_bin_dir/$command_name" +backup_uloop="$global_bin_dir/$command_name.before-local-link" + +case "$ACTION" in + link) + link_local_uloop + ;; + restore) + restore_global_uloop + ;; + status) + print_status + ;; + *) + usage + ;; +esac