diff --git a/Assets/Tests/Editor/CompileResultPersistenceServiceTests.cs b/Assets/Tests/Editor/CompileResultPersistenceServiceTests.cs new file mode 100644 index 0000000000..3044d291d3 --- /dev/null +++ b/Assets/Tests/Editor/CompileResultPersistenceServiceTests.cs @@ -0,0 +1,153 @@ +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/CompileResultPersistenceServiceTests.cs.meta b/Assets/Tests/Editor/CompileResultPersistenceServiceTests.cs.meta new file mode 100644 index 0000000000..0034d43528 --- /dev/null +++ b/Assets/Tests/Editor/CompileResultPersistenceServiceTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 362c0e52a9e04a418f585ab226404984 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/JsonRpcProcessorCliVersionGateTests.cs b/Assets/Tests/Editor/JsonRpcProcessorCliVersionGateTests.cs index 462f55bd50..77e2ddd1a1 100644 --- a/Assets/Tests/Editor/JsonRpcProcessorCliVersionGateTests.cs +++ b/Assets/Tests/Editor/JsonRpcProcessorCliVersionGateTests.cs @@ -104,14 +104,14 @@ public async Task ProcessRequest_WhenFirstToolWaitsForMainThread_ReturnsServerBu firstResponseTask = JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync( BuildToolRequest(SingleFlightTestTool.Name, 1), CancellationToken.None, - _ => Task.CompletedTask); + (_, _) => Task.CompletedTask); Assert.That(dispatcher.PendingContinuationCount, Is.EqualTo(1)); secondResponseTask = JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync( BuildToolRequest(SingleFlightTestTool.Name, 2), CancellationToken.None, - _ => Task.CompletedTask); + (_, _) => Task.CompletedTask); string secondResponse = await AwaitWithTimeout(secondResponseTask, TimeSpan.FromMilliseconds(200)); JObject error = ParseError(secondResponse); @@ -158,14 +158,14 @@ public async Task ProcessRequest_WhenExecuteDynamicCodeWaitsForMainThread_Allows firstDynamicCodeTask = JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync( BuildToolRequest(UnityCliLoopConstants.TOOL_NAME_EXECUTE_DYNAMIC_CODE, 1), CancellationToken.None, - _ => Task.CompletedTask); + (_, _) => Task.CompletedTask); Assert.That(dispatcher.PendingContinuationCount, Is.EqualTo(1)); secondDynamicCodeTask = JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync( BuildToolRequest(UnityCliLoopConstants.TOOL_NAME_EXECUTE_DYNAMIC_CODE, 2), CancellationToken.None, - _ => Task.CompletedTask); + (_, _) => Task.CompletedTask); Assert.That(dispatcher.PendingContinuationCount, Is.EqualTo(2)); Assert.That(secondDynamicCodeTask.IsCompleted, Is.False); @@ -173,7 +173,7 @@ public async Task ProcessRequest_WhenExecuteDynamicCodeWaitsForMainThread_Allows otherToolTask = JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync( BuildToolRequest(SingleFlightTestTool.Name, 3), CancellationToken.None, - _ => Task.CompletedTask); + (_, _) => Task.CompletedTask); string otherToolResponse = await AwaitWithTimeout(otherToolTask, TimeSpan.FromMilliseconds(200)); JObject error = ParseError(otherToolResponse); @@ -197,6 +197,159 @@ public async Task ProcessRequest_WhenExecuteDynamicCodeWaitsForMainThread_Allows } } + [Test] + public async Task ProcessRequest_WhenCompileWaitsForDomainReload_KeepsAcceptedRequestAliveAfterDisconnect() + { + // Verifies long compile waits are allowed to persist their result after the CLI response deadline closes. + UnityCliLoopToolRegistrarService previousService = UnityCliLoopToolRegistrar.Service; + ToolSettingsService toolSettingsService = new(new ToolSettingsRepository()); + UnityCliLoopToolRegistrarService service = new( + new EmptyInternalToolNameProvider(), + toolSettingsService, + new UnityCliLoopToolExecutionService()); + UnityCliLoopToolRegistrar.RegisterService(service); + service.RegisterCustomTool(new CompileDispatchPolicyTestTool()); + + bool cancelOnClientDisconnect = true; + try + { + string response = await JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync( + BuildToolRequestWithParams( + UnityCliLoopConstants.TOOL_NAME_COMPILE, + "{\"WaitForDomainReload\":true}", + 1), + CancellationToken.None, + (_, shouldCancelOnClientDisconnect) => + { + cancelOnClientDisconnect = shouldCancelOnClientDisconnect; + return Task.CompletedTask; + }); + JObject parsed = JObject.Parse(response); + + Assert.That(parsed["error"], Is.Null); + Assert.That(parsed["result"], Is.Not.Null); + Assert.That(cancelOnClientDisconnect, Is.False); + } + finally + { + UnityCliLoopToolRegistrar.RegisterService(previousService); + } + } + + [Test] + public async Task ProcessRequest_WhenCompileOmitsReloadWait_KeepsAcceptedRequestAliveAfterDisconnect() + { + // Verifies missing compile reload-wait params preserve the default wait contract. + UnityCliLoopToolRegistrarService previousService = UnityCliLoopToolRegistrar.Service; + ToolSettingsService toolSettingsService = new(new ToolSettingsRepository()); + UnityCliLoopToolRegistrarService service = new( + new EmptyInternalToolNameProvider(), + toolSettingsService, + new UnityCliLoopToolExecutionService()); + UnityCliLoopToolRegistrar.RegisterService(service); + service.RegisterCustomTool(new CompileDispatchPolicyTestTool()); + + bool cancelOnClientDisconnect = true; + try + { + string response = await JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync( + BuildToolRequest(UnityCliLoopConstants.TOOL_NAME_COMPILE, 1), + CancellationToken.None, + (_, shouldCancelOnClientDisconnect) => + { + cancelOnClientDisconnect = shouldCancelOnClientDisconnect; + return Task.CompletedTask; + }); + JObject parsed = JObject.Parse(response); + + Assert.That(parsed["error"], Is.Null); + Assert.That(parsed["result"], Is.Not.Null); + Assert.That(cancelOnClientDisconnect, Is.False); + } + finally + { + UnityCliLoopToolRegistrar.RegisterService(previousService); + } + } + + [Test] + public async Task ProcessRequest_WhenCompileDoesNotWaitForDomainReload_CancelsOnClientDisconnect() + { + // Verifies fire-and-forget compile requests still cancel when the CLI connection goes away. + UnityCliLoopToolRegistrarService previousService = UnityCliLoopToolRegistrar.Service; + ToolSettingsService toolSettingsService = new(new ToolSettingsRepository()); + UnityCliLoopToolRegistrarService service = new( + new EmptyInternalToolNameProvider(), + toolSettingsService, + new UnityCliLoopToolExecutionService()); + UnityCliLoopToolRegistrar.RegisterService(service); + service.RegisterCustomTool(new CompileDispatchPolicyTestTool()); + + bool cancelOnClientDisconnect = false; + try + { + string response = await JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync( + BuildToolRequestWithParams( + UnityCliLoopConstants.TOOL_NAME_COMPILE, + "{\"WaitForDomainReload\":false}", + 1), + CancellationToken.None, + (_, shouldCancelOnClientDisconnect) => + { + cancelOnClientDisconnect = shouldCancelOnClientDisconnect; + return Task.CompletedTask; + }); + JObject parsed = JObject.Parse(response); + + Assert.That(parsed["error"], Is.Null); + Assert.That(parsed["result"], Is.Not.Null); + Assert.That(cancelOnClientDisconnect, Is.True); + } + finally + { + UnityCliLoopToolRegistrar.RegisterService(previousService); + } + } + + [Test] + public async Task ProcessRequest_WhenCompileUsesCamelCaseNoReloadWait_CancelsOnClientDisconnect() + { + // Verifies JSON-RPC compile dispatch policy matches the camelCase tool deserializer contract. + UnityCliLoopToolRegistrarService previousService = UnityCliLoopToolRegistrar.Service; + ToolSettingsService toolSettingsService = new(new ToolSettingsRepository()); + UnityCliLoopToolRegistrarService service = new( + new EmptyInternalToolNameProvider(), + toolSettingsService, + new UnityCliLoopToolExecutionService()); + UnityCliLoopToolRegistrar.RegisterService(service); + service.RegisterCustomTool(new CompileDispatchPolicyTestTool()); + + bool cancelOnClientDisconnect = false; + try + { + string response = await JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync( + BuildToolRequestWithParams( + UnityCliLoopConstants.TOOL_NAME_COMPILE, + "{\"waitForDomainReload\":false}", + 1), + CancellationToken.None, + (_, shouldCancelOnClientDisconnect) => + { + cancelOnClientDisconnect = shouldCancelOnClientDisconnect; + return Task.CompletedTask; + }); + JObject parsed = JObject.Parse(response); + + Assert.That(parsed["error"], Is.Null); + Assert.That(parsed["result"], Is.Not.Null); + Assert.That(cancelOnClientDisconnect, Is.True); + } + finally + { + UnityCliLoopToolRegistrar.RegisterService(previousService); + } + } + [Test] public async Task ProcessRequest_AfterGetHierarchyReturns_AllowsImmediateGetLogs() { @@ -223,7 +376,7 @@ public async Task ProcessRequest_AfterGetHierarchyReturns_AllowsImmediateGetLogs "{\"MaxDepth\":0,\"IncludeComponents\":false}", 1), CancellationToken.None, - _ => Task.CompletedTask); + (_, _) => Task.CompletedTask); Assert.That(dispatcher.PendingContinuationCount, Is.EqualTo(1)); dispatcher.RunContinuations(); @@ -239,7 +392,7 @@ public async Task ProcessRequest_AfterGetHierarchyReturns_AllowsImmediateGetLogs "{\"MaxCount\":0}", 2), CancellationToken.None, - _ => Task.CompletedTask); + (_, _) => Task.CompletedTask); Assert.That(dispatcher.PendingContinuationCount, Is.EqualTo(1)); dispatcher.RunContinuations(); @@ -273,7 +426,7 @@ public async Task ProcessRequest_WhenInternalBridgeCommandRuns_SwitchesToMainThr responseTask = JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync( BuildToolRequest(UnityCliLoopConstants.COMMAND_NAME_GET_VERSION, 1), CancellationToken.None, - _ => Task.CompletedTask); + (_, _) => Task.CompletedTask); Assert.That(dispatcher.PendingContinuationCount, Is.EqualTo(1)); Assert.That(responseTask.IsCompleted, Is.False); @@ -317,7 +470,7 @@ public async Task ProcessRequest_WhenMainThreadSwitchIsCanceled_ReleasesExecutio canceledResponseTask = JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync( BuildToolRequest(SingleFlightTestTool.Name, 1), cancellationSource.Token, - _ => Task.CompletedTask); + (_, _) => Task.CompletedTask); Assert.That(dispatcher.PendingContinuationCount, Is.EqualTo(1)); @@ -327,7 +480,7 @@ public async Task ProcessRequest_WhenMainThreadSwitchIsCanceled_ReleasesExecutio secondResponseTask = JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync( BuildToolRequest(SingleFlightTestTool.Name, 2), CancellationToken.None, - _ => Task.CompletedTask); + (_, _) => Task.CompletedTask); Assert.That(secondResponseTask.IsCompleted, Is.False); @@ -516,6 +669,18 @@ public Task ExecuteAsync(JToken paramsToken, Cancellat } } + private sealed class CompileDispatchPolicyTestTool : IUnityCliLoopTool + { + public string ToolName => UnityCliLoopConstants.TOOL_NAME_COMPILE; + + public ToolParameterSchema ParameterSchema => new(); + + public Task ExecuteAsync(JToken paramsToken, CancellationToken ct) + { + return Task.FromResult(new SingleFlightTestResponse()); + } + } + private sealed class SingleFlightTestResponse : UnityCliLoopToolResponse { public bool Success { get; set; } = true; diff --git a/Assets/Tests/Editor/PendingCompileResultRecoveryServiceTests.cs b/Assets/Tests/Editor/PendingCompileResultRecoveryServiceTests.cs new file mode 100644 index 0000000000..535111963b --- /dev/null +++ b/Assets/Tests/Editor/PendingCompileResultRecoveryServiceTests.cs @@ -0,0 +1,299 @@ +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/PendingCompileResultRecoveryServiceTests.cs.meta b/Assets/Tests/Editor/PendingCompileResultRecoveryServiceTests.cs.meta new file mode 100644 index 0000000000..4c8908b51c --- /dev/null +++ b/Assets/Tests/Editor/PendingCompileResultRecoveryServiceTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 756c4d7b62bc24a8ba9411388d621197 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/UnityCliLoopEditorSessionStateRepositoryTests.cs b/Assets/Tests/Editor/UnityCliLoopEditorSessionStateRepositoryTests.cs index 879951721e..2799d53398 100644 --- a/Assets/Tests/Editor/UnityCliLoopEditorSessionStateRepositoryTests.cs +++ b/Assets/Tests/Editor/UnityCliLoopEditorSessionStateRepositoryTests.cs @@ -1,4 +1,5 @@ using NUnit.Framework; +using System; using io.github.hatayama.UnityCliLoop.Domain; using io.github.hatayama.UnityCliLoop.Infrastructure; @@ -39,6 +40,7 @@ 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] @@ -71,6 +73,76 @@ public void GetShouldAutoScanThirdPartyToolMigration_WhenServiceIsRecreated_Read Assert.That(recreatedService.GetShouldAutoScanThirdPartyToolMigration(), Is.True); } + [Test] + public void GetPendingCompileRequest_WhenServiceIsRecreated_ReadsExistingSessionValue() + { + // Verifies pending compile recovery data survives Domain Reload service recreation. + _sessionStateService.MarkPendingCompileRequest("compile_test_request", forceRecompile: true); + + UnityCliLoopEditorSessionStateService recreatedService = + UnityCliLoopEditorSessionStateTestFactory.CreateService(); + + 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 GetPendingCompileRequest_WhenExpirationTicksAreMalformed_ClearsSessionValue() + { + // Verifies malformed compile recovery data self-heals instead of breaking startup recovery. + UnityCliLoopEditorSessionStateRepository repository = new UnityCliLoopEditorSessionStateRepository(); + repository.SetPendingCompileRequestId("compile_test_request"); + repository.SetPendingCompileForceRecompile(true); + repository.SetPendingCompileExpiresAtUtcTicks("not_ticks"); + UnityCliLoopEditorSessionStateService recreatedService = + new UnityCliLoopEditorSessionStateService(repository); + + UnityCliLoopPendingCompileRequest pendingCompileRequest = + recreatedService.GetPendingCompileRequest(); + + Assert.That(pendingCompileRequest.HasRequest, Is.False); + Assert.That(recreatedService.GetPendingCompileRequest().HasRequest, Is.False); + } + + [Test] + public void ClearExpiredPendingCompileRequest_WhenRequestIsExpired_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( + "compile_test_request", + forceRecompile: false, + expiresAtUtcTicks: now.AddSeconds(-1).Ticks); + + bool cleared = _sessionStateService.ClearExpiredPendingCompileRequest(now); + + Assert.That(cleared, Is.True); + Assert.That(_sessionStateService.GetPendingCompileRequest().HasRequest, Is.False); + } + + [Test] + public void ClearExpiredPendingCompileRequest_WhenRequestIsFresh_KeepsSessionValue() + { + // 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( + "compile_test_request", + forceRecompile: false, + expiresAtUtcTicks: now.AddSeconds(1).Ticks); + + bool cleared = _sessionStateService.ClearExpiredPendingCompileRequest(now); + + UnityCliLoopPendingCompileRequest pendingCompileRequest = + _sessionStateService.GetPendingCompileRequest(); + Assert.That(cleared, Is.False); + Assert.That(pendingCompileRequest.HasRequest, Is.True); + Assert.That(pendingCompileRequest.RequestId, Is.EqualTo("compile_test_request")); + } + [Test] public void ConsumeShouldAutoScanThirdPartyToolMigration_WhenFlagIsSet_ReturnsTrueOnce() { @@ -92,6 +164,7 @@ public void ClearAll_WhenFlagsAreSet_ClearsEveryTransientFlag() _sessionStateService.MarkDomainReloadStarted(serverIsRunning: true); _sessionStateService.SetShouldAutoScanThirdPartyToolMigration(true); _sessionStateService.SetIsServerManuallyStopped(true); + _sessionStateService.MarkPendingCompileRequest("compile_test_request", forceRecompile: false); _sessionStateService.ClearAll(); @@ -103,6 +176,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.GetPendingCompileRequest().HasRequest, Is.False); } [Test] diff --git a/Assets/Tests/Editor/UnityCliLoopEditorSessionStateTestFactory.cs b/Assets/Tests/Editor/UnityCliLoopEditorSessionStateTestFactory.cs index 52bcc6d3d4..53313c0b7c 100644 --- a/Assets/Tests/Editor/UnityCliLoopEditorSessionStateTestFactory.cs +++ b/Assets/Tests/Editor/UnityCliLoopEditorSessionStateTestFactory.cs @@ -33,6 +33,7 @@ internal readonly struct UnityCliLoopEditorSessionStateSnapshot private readonly bool _showReconnectingUI; private readonly bool _showPostCompileReconnectingUI; private readonly bool _shouldAutoScanThirdPartyToolMigration; + private readonly UnityCliLoopPendingCompileRequest _pendingCompileRequest; private UnityCliLoopEditorSessionStateSnapshot(UnityCliLoopEditorSessionStateService service) { @@ -44,6 +45,7 @@ private UnityCliLoopEditorSessionStateSnapshot(UnityCliLoopEditorSessionStateSer _showReconnectingUI = service.GetShowReconnectingUI(); _showPostCompileReconnectingUI = service.GetShowPostCompileReconnectingUI(); _shouldAutoScanThirdPartyToolMigration = service.GetShouldAutoScanThirdPartyToolMigration(); + _pendingCompileRequest = service.GetPendingCompileRequest(); } internal static UnityCliLoopEditorSessionStateSnapshot Capture( @@ -62,6 +64,16 @@ internal void Restore(UnityCliLoopEditorSessionStateService service) service.SetShowReconnectingUI(_showReconnectingUI); service.SetShowPostCompileReconnectingUI(_showPostCompileReconnectingUI); service.SetShouldAutoScanThirdPartyToolMigration(_shouldAutoScanThirdPartyToolMigration); + if (_pendingCompileRequest.HasRequest) + { + service.MarkPendingCompileRequestWithExpiration( + _pendingCompileRequest.RequestId, + _pendingCompileRequest.ForceRecompile, + _pendingCompileRequest.ExpiresAtUtcTicks); + return; + } + + service.ClearPendingCompileRequest(); } } } diff --git a/Packages/src/Cli~/dist/darwin-amd64/uloop b/Packages/src/Cli~/dist/darwin-amd64/uloop index a4741c64b0..2a8dde71f3 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 a3fd2337dc..dd3ce7d2ab 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 138ed0fa76..d03d372e4e 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 489b8c9221..bf64d892c2 100644 --- a/Packages/src/Cli~/internal/cli/compile_wait.go +++ b/Packages/src/Cli~/internal/cli/compile_wait.go @@ -150,7 +150,10 @@ func shouldWaitForCompileResult(err error, outcome unityipc.UnitySendOutcome) bo if !outcome.RequestDispatched { return false } - return isTransportDisconnectError(err) + if isTransportDisconnectError(err) { + return true + } + return outcome.RequestAccepted && isFinalResponseTimeoutError(err) } func isTransportDisconnectError(err error) bool { @@ -161,3 +164,7 @@ func isTransportDisconnectError(err error) bool { strings.Contains(message, "broken pipe") || strings.Contains(message, "use of closed network connection") } + +func isFinalResponseTimeoutError(err error) bool { + return strings.Contains(err.Error(), "i/o timeout") +} diff --git a/Packages/src/Cli~/internal/cli/compile_wait_test.go b/Packages/src/Cli~/internal/cli/compile_wait_test.go index 388dfa83b7..088a476018 100644 --- a/Packages/src/Cli~/internal/cli/compile_wait_test.go +++ b/Packages/src/Cli~/internal/cli/compile_wait_test.go @@ -198,6 +198,19 @@ func TestShouldWaitForCompileResultRequiresDispatchedTransportError(t *testing.T } } +// Verifies accepted final-response timeouts can fall back to result-file polling. +func TestShouldWaitForCompileResultAllowsAcceptedFinalResponseTimeout(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) { + 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) { + t.Fatal("unaccepted timeout should not wait") + } +} + // Verifies compile readiness wait decisions include indeterminate forced-compile results. func TestCompileResultReadinessWaitMode(t *testing.T) { cases := map[string]compileReadinessWaitMode{ diff --git a/Packages/src/Editor/Domain/UnityCliLoopEditorSessionStateService.cs b/Packages/src/Editor/Domain/UnityCliLoopEditorSessionStateService.cs index aa8c6c3458..d3467d0a30 100644 --- a/Packages/src/Editor/Domain/UnityCliLoopEditorSessionStateService.cs +++ b/Packages/src/Editor/Domain/UnityCliLoopEditorSessionStateService.cs @@ -21,6 +21,61 @@ 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); + } + + /// + /// Records the compile request that must still produce a CLI result file after Domain Reload. + /// + public sealed class UnityCliLoopPendingCompileRequest + { + private UnityCliLoopPendingCompileRequest( + bool hasRequest, + string requestId, + bool forceRecompile, + long expiresAtUtcTicks) + { + HasRequest = hasRequest; + RequestId = requestId; + ForceRecompile = forceRecompile; + ExpiresAtUtcTicks = expiresAtUtcTicks; + } + + public bool HasRequest { get; } + public string RequestId { get; } + public bool ForceRecompile { get; } + public long ExpiresAtUtcTicks { get; } + + public static UnityCliLoopPendingCompileRequest None() + { + return new UnityCliLoopPendingCompileRequest(false, "", false, 0); + } + + public static UnityCliLoopPendingCompileRequest Create( + string requestId, + bool forceRecompile, + long expiresAtUtcTicks) + { + Debug.Assert(!string.IsNullOrWhiteSpace(requestId), "requestId must not be null or whitespace"); + + if (string.IsNullOrWhiteSpace(requestId)) + { + throw new ArgumentException("requestId must not be null or whitespace.", nameof(requestId)); + } + + return new UnityCliLoopPendingCompileRequest(true, requestId, forceRecompile, expiresAtUtcTicks); + } + + public bool IsExpiredAt(DateTime utcNow) + { + Debug.Assert(utcNow.Kind == DateTimeKind.Utc, "utcNow must be UTC"); + return HasRequest && ExpiresAtUtcTicks <= utcNow.Ticks; + } } /// @@ -28,6 +83,9 @@ public interface IUnityCliLoopEditorSessionStatePort /// 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 readonly IUnityCliLoopEditorSessionStatePort _sessionStatePort; public UnityCliLoopEditorSessionStateService(IUnityCliLoopEditorSessionStatePort sessionStatePort) @@ -117,6 +175,117 @@ public void SetShouldAutoScanThirdPartyToolMigration(bool shouldAutoScanThirdPar _sessionStatePort.SetShouldAutoScanThirdPartyToolMigration(shouldAutoScanThirdPartyToolMigration); } + public UnityCliLoopPendingCompileRequest GetPendingCompileRequest() + { + 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)) + { + return (true, 0); + } + + string trimmedText = expiresAtUtcTicksText.Trim(); + long value = 0; + foreach (char character in trimmedText) + { + if (character < '0' || character > '9') + { + return (false, 0); + } + + int digit = character - '0'; + if (value > (long.MaxValue - digit) / 10) + { + return (false, 0); + } + + value = value * 10 + digit; + } + + return (true, value); + } + + public void MarkPendingCompileRequest(string requestId, bool forceRecompile) + { + long expiresAtUtcTicks = DateTime.UtcNow + .AddSeconds(PendingCompileRequestLifetimeSeconds) + .Ticks; + MarkPendingCompileRequestWithExpiration(requestId, forceRecompile, expiresAtUtcTicks); + } + + public void MarkPendingCompileRequestWithExpiration( + string requestId, + bool forceRecompile, + long expiresAtUtcTicks) + { + 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()); + } + + public void ClearPendingCompileRequest() + { + _sessionStatePort.SetPendingCompileRequestId(""); + _sessionStatePort.SetPendingCompileForceRecompile(false); + _sessionStatePort.SetPendingCompileExpiresAtUtcTicks(""); + } + + public void ClearPendingCompileRequestIfMatches(string requestId) + { + Debug.Assert(!string.IsNullOrWhiteSpace(requestId), "requestId must not be null or whitespace"); + + UnityCliLoopPendingCompileRequest pendingCompileRequest = GetPendingCompileRequest(); + if (!pendingCompileRequest.HasRequest) + { + return; + } + + if (pendingCompileRequest.RequestId != requestId) + { + return; + } + + ClearPendingCompileRequest(); + } + + public bool ClearExpiredPendingCompileRequest(DateTime utcNow) + { + Debug.Assert(utcNow.Kind == DateTimeKind.Utc, "utcNow must be UTC"); + + UnityCliLoopPendingCompileRequest pendingCompileRequest = GetPendingCompileRequest(); + if (!pendingCompileRequest.IsExpiredAt(utcNow)) + { + return false; + } + + ClearPendingCompileRequest(); + return true; + } + public bool ConsumeShouldAutoScanThirdPartyToolMigration() { if (!GetShouldAutoScanThirdPartyToolMigration()) @@ -195,6 +364,7 @@ public void ClearAll() { ClearServerSession(); ClearDomainReloadRecoveryFlags(); + ClearPendingCompileRequest(); SetShouldAutoScanThirdPartyToolMigration(false); SetIsServerManuallyStopped(false); } diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileDomainReloadRecoveryStartup.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileDomainReloadRecoveryStartup.cs new file mode 100644 index 0000000000..36942ebfa1 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileDomainReloadRecoveryStartup.cs @@ -0,0 +1,58 @@ +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/CompileDomainReloadRecoveryStartup.cs.meta b/Packages/src/Editor/FirstPartyTools/Compile/CompileDomainReloadRecoveryStartup.cs.meta new file mode 100644 index 0000000000..0b964930d6 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileDomainReloadRecoveryStartup.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fc95b66d9c2d249d2ac7869b1fac0a05 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileRequestIdRules.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileRequestIdRules.cs new file mode 100644 index 0000000000..ef055a5481 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileRequestIdRules.cs @@ -0,0 +1,26 @@ +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Defines the request ID character contract shared by compile wait and result persistence. + /// + internal static class CompileRequestIdRules + { + internal static bool IsSafe(string requestId) + { + foreach (char character in requestId) + { + bool isSafe = (character >= 'a' && character <= 'z') || + (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || + character == '_' || + character == '-'; + if (!isSafe) + { + return false; + } + } + + return true; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileRequestIdRules.cs.meta b/Packages/src/Editor/FirstPartyTools/Compile/CompileRequestIdRules.cs.meta new file mode 100644 index 0000000000..fbd7bab7de --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileRequestIdRules.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 46e073166f6f44f596308ffd81e068f6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileResultPersistenceService.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileResultPersistenceService.cs index eb0c9ab141..3eb3b76d94 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompileResultPersistenceService.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileResultPersistenceService.cs @@ -13,6 +13,9 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// 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. @@ -52,19 +55,79 @@ public static void SaveResult(string requestId, UnityCliLoopCompileResult respon 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 sanitizedFileName = Path.GetFileName(requestId); - Debug.Assert(sanitizedFileName == requestId, - $"requestId must not contain path separators: '{requestId}'"); - string resultJson = JsonConvert.SerializeObject(response, Formatting.None); - string fileName = $"{sanitizedFileName}{UnityCliLoopConstants.JSON_FILE_EXTENSION}"; - string filePath = Path.Combine(CompileResultDirectoryPath, fileName); - File.WriteAllText(filePath, resultJson, Encoding.UTF8); + 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/CompileTool.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileTool.cs index ff6d341957..be347c37dd 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompileTool.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileTool.cs @@ -13,7 +13,7 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools [UnityCliLoopTool] public class CompileTool : UnityCliLoopTool { - public override string ToolName => "compile"; + public override string ToolName => UnityCliLoopConstants.TOOL_NAME_COMPILE; protected override async Task ExecuteAsync(CompileSchema parameters, CancellationToken ct) { diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileUseCase.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileUseCase.cs index 04f2c77983..713b5f1756 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompileUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileUseCase.cs @@ -6,6 +6,8 @@ using UnityEditor; using UnityEngine; +using io.github.hatayama.UnityCliLoop.Domain; +using io.github.hatayama.UnityCliLoop.Infrastructure; using io.github.hatayama.UnityCliLoop.ToolContracts; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools @@ -19,6 +21,13 @@ public class CompileUseCase : IUnityCliLoopCompilationService { private const int MAX_WAIT_MS = 5000; private const int POLL_INTERVAL_MS = 50; + private readonly UnityCliLoopEditorSessionStateService _sessionStateService; + + public CompileUseCase() + { + _sessionStateService = + new UnityCliLoopEditorSessionStateService(new UnityCliLoopEditorSessionStateRepository()); + } /// /// Executes compilation processing @@ -35,127 +44,158 @@ public async Task CompileAsync(UnityCliLoopCompileReq PrepareResultStorage(request); string correlationId = ResolveCorrelationId(request); - LogCompileRequestReceived(request, correlationId); + bool resultPersistenceCompleted = false; - // 1. Play Mode preparation check - PlayModeCompilationPreparationService preparationService = new(); - PreparationResult preparation = preparationService.DeterminePreparationAction(); - - if (!preparation.CanProceed) + try { - VibeLogger.LogWarning( - "compile_preparation_failed", - preparation.ErrorMessage, - BuildCompileLogContext(request), - correlationId); - UnityCliLoopCompileResult response = CreateCompileResult( - false, - 1, - 0, - new[] { CreateIssue(preparation.ErrorMessage, "", 0) }, - Array.Empty(), - null); - return PersistResponseIfNeeded(request, response, correlationId); - } + _sessionStateService.ClearExpiredPendingCompileRequest(DateTime.UtcNow); + MarkPendingCompileRequestIfNeeded(request); + LogCompileRequestReceived(request, correlationId); - if (preparation.NeedsPlayModeStop) - { + // 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; + } + + 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; + } + } + + // 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; + } + + // 3. Compilation execution + ct.ThrowIfCancellationRequested(); VibeLogger.LogInfo( - "compile_playmode_stop_requested", - "Stopping Play Mode before compile.", + "compile_execution_start", + "Starting Unity compilation execution.", BuildCompileLogContext(request), correlationId); - preparationService.StopPlayMode(); - bool exited = await WaitForPlayModeExitAsync(ct); + CompilationExecutionService executionService = new(); + CompileResult result = await executionService.ExecuteCompilationAsync(request.ForceRecompile, ct); VibeLogger.LogInfo( - "compile_playmode_exit_observed", - exited ? "Play Mode exited before compile." : "Play Mode did not exit before compile.", + "compile_execution_completed", + "Unity compilation execution completed.", new { request_id = request.RequestId, - exited + success = result.Success, + error_count = result.ErrorCount, + warning_count = result.WarningCount, + is_indeterminate = result.IsIndeterminate }, correlationId); - if (!exited) + + // 4. Result formatting + if (result.IsIndeterminate) { UnityCliLoopCompileResult response = CreateCompileResult( - false, - 1, - 0, - new[] { CreateIssue("Play Mode did not exit within 5 seconds; compilation aborted.", "", 0) }, - Array.Empty(), - null); - return PersistResponseIfNeeded(request, response, correlationId); + result.Success, + result.ErrorCount, + result.WarningCount, + null, + null, + result.Message ?? "Compilation status is indeterminate. Use get-logs tool to check results."); + UnityCliLoopCompileResult persistedResponse = + PersistResponseIfNeeded(request, response, correlationId); + resultPersistenceCompleted = true; + return persistedResponse; } - } - // 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(), + 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(); + + UnityCliLoopCompileResult successResponse = CreateCompileResult( + result.Success, + result.error?.Length ?? 0, + result.warning?.Length ?? 0, + errors, + warnings, null); - return PersistResponseIfNeeded(request, response, correlationId); + UnityCliLoopCompileResult persistedSuccessResponse = + PersistResponseIfNeeded(request, successResponse, correlationId); + resultPersistenceCompleted = true; + return persistedSuccessResponse; } - - // 3. Compilation execution - ct.ThrowIfCancellationRequested(); - VibeLogger.LogInfo( - "compile_execution_start", - "Starting Unity compilation execution.", - BuildCompileLogContext(request), - correlationId); - CompilationExecutionService executionService = new(); - CompileResult result = await executionService.ExecuteCompilationAsync(request.ForceRecompile, ct); - VibeLogger.LogInfo( - "compile_execution_completed", - "Unity compilation execution completed.", - new - { - request_id = request.RequestId, - success = result.Success, - error_count = result.ErrorCount, - warning_count = result.WarningCount, - is_indeterminate = result.IsIndeterminate - }, - correlationId); - - // 4. Result formatting - if (result.IsIndeterminate) + finally { - UnityCliLoopCompileResult response = CreateCompileResult( - result.Success, - result.ErrorCount, - result.WarningCount, - null, - null, - result.Message ?? "Compilation status is indeterminate. Use get-logs tool to check results."); - return PersistResponseIfNeeded(request, response, correlationId); + ClearPendingCompileRequestAfterCancellation(request, ct, correlationId); + ClearPendingCompileRequestAfterInterruptedCompile( + request, + resultPersistenceCompleted, + ct, + correlationId); } - - 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(); - - UnityCliLoopCompileResult successResponse = CreateCompileResult( - result.Success, - result.error?.Length ?? 0, - result.warning?.Length ?? 0, - errors, - warnings, - null); - return PersistResponseIfNeeded(request, successResponse, correlationId); } private static UnityCliLoopCompileResult CreateCompileResult( @@ -212,7 +252,7 @@ private static void PrepareResultStorage(UnityCliLoopCompileRequest request) return; } - if (!string.IsNullOrWhiteSpace(request.RequestId) && IsRequestIdSafe(request.RequestId)) + if (!string.IsNullOrWhiteSpace(request.RequestId) && CompileRequestIdRules.IsSafe(request.RequestId)) { return; } @@ -220,21 +260,7 @@ private static void PrepareResultStorage(UnityCliLoopCompileRequest request) request.RequestId = CreateRequestId(); } - private static bool IsRequestIdSafe(string requestId) - { - foreach (char c in requestId) - { - bool isSafe = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') - || (c >= '0' && c <= '9') || c == '_' || c == '-'; - if (!isSafe) - { - return false; - } - } - return true; - } - - private static UnityCliLoopCompileResult PersistResponseIfNeeded( + private UnityCliLoopCompileResult PersistResponseIfNeeded( UnityCliLoopCompileRequest request, UnityCliLoopCompileResult response, string correlationId) @@ -270,6 +296,7 @@ private static UnityCliLoopCompileResult PersistResponseIfNeeded( } CompileResultPersistenceService.SaveResult(request.RequestId, response); + _sessionStateService.ClearPendingCompileRequestIfMatches(request.RequestId); VibeLogger.LogInfo( "compile_result_persisted", "Compile result persisted for CLI domain reload wait.", @@ -284,6 +311,127 @@ private static UnityCliLoopCompileResult PersistResponseIfNeeded( 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( + UnityCliLoopCompileRequest request, + bool isCancellationRequested, + bool isDomainReloadInProgress) + { + Debug.Assert(request != null, "request must not be null"); + + if (!isCancellationRequested) + { + return false; + } + + if (isDomainReloadInProgress) + { + return false; + } + + 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 !string.IsNullOrWhiteSpace(request.RequestId); + } + private static string ResolveCorrelationId(UnityCliLoopCompileRequest request) { Debug.Assert(request != null, "request must not be null"); diff --git a/Packages/src/Editor/FirstPartyTools/Compile/PendingCompileResultRecoveryService.cs b/Packages/src/Editor/FirstPartyTools/Compile/PendingCompileResultRecoveryService.cs new file mode 100644 index 0000000000..726b0214c4 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Compile/PendingCompileResultRecoveryService.cs @@ -0,0 +1,129 @@ +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/Compile/PendingCompileResultRecoveryService.cs.meta b/Packages/src/Editor/FirstPartyTools/Compile/PendingCompileResultRecoveryService.cs.meta new file mode 100644 index 0000000000..4ed2a9b464 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Compile/PendingCompileResultRecoveryService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d3c7e98b41b0b4650bd8d3c43a83ffa5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Compile/UnityCLILoop.FirstPartyTools.Compile.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/Compile/UnityCLILoop.FirstPartyTools.Compile.Editor.asmdef index ebaf7ac929..822de9a7ee 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/UnityCLILoop.FirstPartyTools.Compile.Editor.asmdef +++ b/Packages/src/Editor/FirstPartyTools/Compile/UnityCLILoop.FirstPartyTools.Compile.Editor.asmdef @@ -2,6 +2,8 @@ "name": "UnityCLILoop.FirstPartyTools.Compile.Editor", "rootNamespace": "io.github.hatayama.UnityCliLoop.FirstPartyTools", "references": [ + "GUID:5c4588558a3624eacbce0f50007cf1eb", + "GUID:18e6dd30a99d14f32a045f79a2956f46", "GUID:e5952ef560e1641f68b2687e6045bf5b", "GUID:d427b32aad9cb44fc8e962437c9dbcd8", "GUID:fc3fd32eddbee40e39c2d76dc184957b" diff --git a/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs index 78ad1a04eb..d092a729e6 100644 --- a/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs +++ b/Packages/src/Editor/FirstPartyTools/FirstPartyToolsEditorStartup.cs @@ -8,6 +8,7 @@ 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/JsonRpcProcessor.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcProcessor.cs index 4b95f3e053..8ed8d81baf 100644 --- a/Packages/src/Editor/Infrastructure/Api/JsonRpcProcessor.cs +++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcProcessor.cs @@ -31,6 +31,12 @@ namespace io.github.hatayama.UnityCliLoop.Infrastructure /// public static class JsonRpcProcessor { + private const string WaitForDomainReloadParamName = "WaitForDomainReload"; + + internal delegate Task JsonRpcEarlyResponseWriter( + string responseJson, + bool cancelOnClientDisconnect); + /// /// Process JSON-RPC request and generate response /// @@ -42,7 +48,7 @@ public static async Task ProcessRequest(string jsonRequest, Cancellation internal static async Task ProcessRequestWithEarlyResponseAsync( string jsonRequest, CancellationToken ct, - Func earlyResponseWriter) + JsonRpcEarlyResponseWriter earlyResponseWriter) { try { @@ -148,7 +154,7 @@ private static async Task ProcessRpcRequest( JsonRpcRequest request, string originalJson, CancellationToken ct, - Func earlyResponseWriter) + JsonRpcEarlyResponseWriter earlyResponseWriter) { try { @@ -160,7 +166,9 @@ private static async Task ProcessRpcRequest( if (request.AcceptsDispatchAck && earlyResponseWriter != null) { - await earlyResponseWriter(CreateDispatchAcceptedResponse(request.Id)); + await earlyResponseWriter( + CreateDispatchAcceptedResponse(request.Id), + ShouldCancelAcceptedRequestOnClientDisconnect(request)); } Stopwatch requestStopwatch = Stopwatch.StartNew(); @@ -206,6 +214,40 @@ private static void LogRpcExceptionIfNeeded(Exception ex) UnityEngine.Debug.LogError($"[JsonRpcProcessor] Error: {ex.Message}\nStack trace: {ex.StackTrace}"); } + private static bool ShouldCancelAcceptedRequestOnClientDisconnect(JsonRpcRequest request) + { + System.Diagnostics.Debug.Assert(request != null, "request must not be null"); + + if (request.Method != UnityCliLoopConstants.TOOL_NAME_COMPILE) + { + return true; + } + + return !CompileRequestWaitsForDomainReload(request.Params); + } + + private static bool CompileRequestWaitsForDomainReload(JToken paramsToken) + { + if (paramsToken is not JObject paramsObject) + { + return true; + } + + JToken waitForDomainReloadToken = + paramsObject.GetValue(WaitForDomainReloadParamName, StringComparison.OrdinalIgnoreCase); + if (waitForDomainReloadToken == null) + { + return true; + } + + if (waitForDomainReloadToken.Type != JTokenType.Boolean) + { + return true; + } + + return waitForDomainReloadToken.Value(); + } + private static bool IsCliUpdateRequired(string currentCliVersion) { if (string.IsNullOrWhiteSpace(currentCliVersion)) diff --git a/Packages/src/Editor/Infrastructure/Settings/UnityCliLoopEditorSessionStateRepository.cs b/Packages/src/Editor/Infrastructure/Settings/UnityCliLoopEditorSessionStateRepository.cs index c5fe267dd1..14bafe959c 100644 --- a/Packages/src/Editor/Infrastructure/Settings/UnityCliLoopEditorSessionStateRepository.cs +++ b/Packages/src/Editor/Infrastructure/Settings/UnityCliLoopEditorSessionStateRepository.cs @@ -19,6 +19,9 @@ 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"; public bool GetIsServerRunning() { @@ -100,6 +103,36 @@ public void SetShouldAutoScanThirdPartyToolMigration(bool shouldAutoScanThirdPar SetBool(ShouldAutoScanThirdPartyToolMigrationKey, shouldAutoScanThirdPartyToolMigration); } + public string GetPendingCompileRequestId() + { + return GetString(PendingCompileRequestIdKey); + } + + public void SetPendingCompileRequestId(string pendingCompileRequestId) + { + SetString(PendingCompileRequestIdKey, pendingCompileRequestId); + } + + public bool GetPendingCompileForceRecompile() + { + return GetBool(PendingCompileForceRecompileKey); + } + + public void SetPendingCompileForceRecompile(bool pendingCompileForceRecompile) + { + SetBool(PendingCompileForceRecompileKey, pendingCompileForceRecompile); + } + + public string GetPendingCompileExpiresAtUtcTicks() + { + return GetString(PendingCompileExpiresAtUtcTicksKey); + } + + public void SetPendingCompileExpiresAtUtcTicks(string pendingCompileExpiresAtUtcTicks) + { + SetString(PendingCompileExpiresAtUtcTicksKey, pendingCompileExpiresAtUtcTicks); + } + private static bool GetBool(string key) { return SessionState.GetBool(key, false); @@ -109,5 +142,15 @@ private static void SetBool(string key, bool value) { SessionState.SetBool(key, value); } + + private static string GetString(string key) + { + return SessionState.GetString(key, ""); + } + + private static void SetString(string key, string value) + { + SessionState.SetString(key, value ?? ""); + } } } diff --git a/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeServer.cs b/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeServer.cs index 992ef16ef3..ef36537570 100644 --- a/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeServer.cs +++ b/Packages/src/Editor/Infrastructure/UnityCliLoopBridgeServer.cs @@ -587,9 +587,14 @@ private async Task ProcessRequestFrameAsync( string responseJson = await JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync( requestJson, requestCancellationTokenSource.Token, - async responseJsonValue => + async (responseJsonValue, cancelOnClientDisconnect) => { await WriteJsonResponseAsync(stream, responseJsonValue, serverCancellationToken); + if (!cancelOnClientDisconnect) + { + return; + } + clientDisconnectMonitorTask = MonitorClientDisconnectAsync(client, requestCancellationTokenSource); }); diff --git a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs index b38c39f5e4..86771569df 100644 --- a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs +++ b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs @@ -60,6 +60,7 @@ public static UnityEditor.PackageManager.PackageInfo PackageInfo public const string ULOOP_TOOL_SETTINGS_FILE_NAME = "settings.tools.json"; // Command name constants + public const string TOOL_NAME_COMPILE = "compile"; public const string TOOL_NAME_CONTROL_PLAY_MODE = "control-play-mode"; public const string TOOL_NAME_EXECUTE_DYNAMIC_CODE = "execute-dynamic-code"; public const string TOOL_NAME_GET_HIERARCHY = "get-hierarchy";