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";