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