diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml
index 2a0f8b66c3..c89827ab91 100644
--- a/.github/workflows/build-and-test.yml
+++ b/.github/workflows/build-and-test.yml
@@ -72,6 +72,20 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
+ - name: Setup Go
+ uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c
+ with:
+ go-version-file: cli/.go-version
+ cache: false
+
+ # Windows-only code paths (named pipe transport, winio deadlines, typed error
+ # classification) are otherwise never executed by CI. Scoped to the transport
+ # package: the wider test suite still has Windows-incompatible tests (path
+ # separators, missing .exe suffixes, TCP-only fixtures) that predate this job.
+ - name: Test native Go CLI transport on Windows
+ shell: bash
+ run: cd cli && go test ./internal/unityipc/
+
- name: Test install release filter in Git Bash
shell: bash
run: sh scripts/test-install-release-filter.sh
diff --git a/Assets/Tests/Editor/BridgeTransportListenerTests.cs b/Assets/Tests/Editor/BridgeTransportListenerTests.cs
new file mode 100644
index 0000000000..426dcfe26e
--- /dev/null
+++ b/Assets/Tests/Editor/BridgeTransportListenerTests.cs
@@ -0,0 +1,108 @@
+using System;
+using System.IO;
+using System.Threading;
+using NUnit.Framework;
+
+using io.github.hatayama.UnityCliLoop.Infrastructure;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor
+{
+ ///
+ /// Test fixture that verifies the Unix domain socket listener's stop behavior.
+ ///
+ public class BridgeTransportListenerTests
+ {
+ private string _tempProjectRoot;
+ private BridgeTransportEndpoint _endpoint;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _tempProjectRoot = Path.Combine(Path.GetTempPath(), "uloop-listener-test-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_tempProjectRoot);
+ _endpoint = BridgeTransportEndpoint.CreateProjectIpc(_tempProjectRoot);
+ if (_endpoint.Kind != BridgeTransportKind.UnixDomainSocket)
+ {
+ Assert.Ignore("Unix domain socket listener tests only run on macOS/Linux.");
+ }
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ if (_endpoint != null && File.Exists(_endpoint.Path))
+ {
+ File.Delete(_endpoint.Path);
+ }
+
+ if (Directory.Exists(_tempProjectRoot))
+ {
+ Directory.Delete(_tempProjectRoot, recursive: true);
+ }
+ }
+
+ [Test]
+ public void Stop_WhenCalledTwice_DoesNotThrowAndRemovesSocketFile()
+ {
+ // Tests that Stop is idempotent: Dispose() also calls Stop, so double stops happen
+ // routinely during shutdown and must not throw on the already-closed socket.
+ UnixDomainSocketBridgeTransportListener listener = new(_endpoint);
+ listener.Start();
+ Assert.That(File.Exists(_endpoint.Path), Is.True);
+
+ listener.Stop();
+ Assert.DoesNotThrow(() => listener.Stop());
+ Assert.That(File.Exists(_endpoint.Path), Is.False);
+ }
+
+ [Test]
+ public void AcceptClient_AfterStop_ThrowsObjectDisposedException()
+ {
+ // Tests that accepting on a stopped listener surfaces as disposal, which the server
+ // loop treats as an exit-and-recover signal, instead of NullReferenceException
+ // which would make the accept loop spin.
+ UnixDomainSocketBridgeTransportListener listener = new(_endpoint);
+ listener.Start();
+ listener.Stop();
+
+ Assert.Throws(
+ () => listener.AcceptClient(CancellationToken.None));
+ }
+ }
+
+ ///
+ /// Test fixture that verifies the Windows named pipe listener's stopped-state behavior.
+ /// These paths run before any pipe is created, so they are platform-independent.
+ ///
+ public class WindowsNamedPipeBridgeTransportListenerTests
+ {
+ [Test]
+ public void Stop_WhenCalledTwice_DoesNotThrow()
+ {
+ // Tests that Stop is idempotent: Dispose() also calls Stop, so double stops happen
+ // routinely during shutdown and only one caller may dispose the active pipe.
+ BridgeTransportEndpoint endpoint = BridgeTransportEndpoint.CreateProjectIpc(
+ System.IO.Path.GetTempPath());
+ WindowsNamedPipeBridgeTransportListener listener = new(endpoint);
+ listener.Start();
+
+ listener.Stop();
+ Assert.DoesNotThrow(() => listener.Stop());
+ }
+
+ [Test]
+ public void AcceptClient_AfterStop_ThrowsObjectDisposedException()
+ {
+ // Tests that a stopped listener refuses to accept instead of creating a fresh
+ // pipe nobody can wake, so the server loop exits and recovers.
+ BridgeTransportEndpoint endpoint = BridgeTransportEndpoint.CreateProjectIpc(
+ System.IO.Path.GetTempPath());
+ WindowsNamedPipeBridgeTransportListener listener = new(endpoint);
+ listener.Start();
+ listener.Stop();
+
+ Assert.Throws(
+ () => listener.AcceptClient(CancellationToken.None));
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/BridgeTransportListenerTests.cs.meta b/Assets/Tests/Editor/BridgeTransportListenerTests.cs.meta
new file mode 100644
index 0000000000..ed894ca1aa
--- /dev/null
+++ b/Assets/Tests/Editor/BridgeTransportListenerTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 3ff6bd689fdbb4f2f9600db948281d9c
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/JsonRpcHeartbeatTests.cs b/Assets/Tests/Editor/JsonRpcHeartbeatTests.cs
new file mode 100644
index 0000000000..303671bbdd
--- /dev/null
+++ b/Assets/Tests/Editor/JsonRpcHeartbeatTests.cs
@@ -0,0 +1,106 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Newtonsoft.Json.Linq;
+using NUnit.Framework;
+
+using io.github.hatayama.UnityCliLoop.Infrastructure;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor
+{
+ ///
+ /// Test fixture that verifies heartbeat negotiation and frame emission for the bridge server.
+ ///
+ public class JsonRpcHeartbeatTests
+ {
+ [Test]
+ public void CreateDispatchAcceptedResponse_WhenHeartbeatNegotiated_AdvertisesInterval()
+ {
+ // Tests that the dispatch ack tells a heartbeat-capable CLI which interval to expect.
+ string response = JsonRpcProcessor.CreateDispatchAcceptedResponse(1, 10);
+
+ JObject parsed = JObject.Parse(response);
+ Assert.That(parsed["uloop"]["phase"].ToString(), Is.EqualTo(JsonRpcResponsePhases.Accepted));
+ Assert.That(parsed["uloop"]["heartbeatIntervalSeconds"].Value(), Is.EqualTo(10));
+ }
+
+ [Test]
+ public void CreateDispatchAcceptedResponse_WithoutHeartbeat_OmitsInterval()
+ {
+ // Tests that older CLIs that did not negotiate heartbeats get the legacy ack shape,
+ // because they would treat unexpected extra frames as the final response.
+ string response = JsonRpcProcessor.CreateDispatchAcceptedResponse(1, 0);
+
+ JObject parsed = JObject.Parse(response);
+ Assert.That(parsed["uloop"]["phase"].ToString(), Is.EqualTo(JsonRpcResponsePhases.Accepted));
+ Assert.That(parsed["uloop"]["heartbeatIntervalSeconds"], Is.Null);
+ }
+
+ [Test]
+ public void CreateHeartbeatResponse_WhenSerialized_CarriesPhaseAndStallSeconds()
+ {
+ // Tests the heartbeat frame shape the CLI parses for freeze diagnosis.
+ string response = JsonRpcProcessor.CreateHeartbeatResponse(7, 12.5);
+
+ JObject parsed = JObject.Parse(response);
+ Assert.That(parsed["uloop"]["phase"].ToString(), Is.EqualTo(JsonRpcResponsePhases.Heartbeat));
+ Assert.That(parsed["uloop"]["mainThreadStallSeconds"].Value(), Is.EqualTo(12.5));
+ Assert.That(parsed["id"].Value(), Is.EqualTo(7));
+ }
+
+ [Test]
+ public async Task SendHeartbeatsAsync_WhenRunning_WritesFramesUntilCancelled()
+ {
+ // Tests that the heartbeat loop emits frames on the interval and stops on cancellation
+ // without leaving background work behind.
+ int writtenFrameCount = 0;
+ using CancellationTokenSource cancellationSource = new();
+
+ Task heartbeatTask = UnityCliLoopBridgeServer.SendHeartbeatsAsync(
+ () => "{}",
+ _ =>
+ {
+ Interlocked.Increment(ref writtenFrameCount);
+ return Task.CompletedTask;
+ },
+ TimeSpan.FromMilliseconds(10),
+ cancellationSource.Token);
+
+ await Task.Delay(100);
+ cancellationSource.Cancel();
+ await heartbeatTask;
+
+ Assert.That(Volatile.Read(ref writtenFrameCount), Is.GreaterThanOrEqualTo(1));
+ }
+
+ [Test]
+ public async Task SendHeartbeatsAsync_WhenWriteThrowsIOException_StopsWithoutFaulting()
+ {
+ // Tests that a broken connection ends the heartbeat loop silently; teardown is
+ // owned by the read loop, not the heartbeat writer.
+ using CancellationTokenSource cancellationSource = new();
+
+ Task heartbeatTask = UnityCliLoopBridgeServer.SendHeartbeatsAsync(
+ () => "{}",
+ _ => throw new System.IO.IOException("broken pipe"),
+ TimeSpan.FromMilliseconds(1),
+ cancellationSource.Token);
+
+ await heartbeatTask;
+
+ Assert.That(heartbeatTask.IsCompletedSuccessfully, Is.True);
+ }
+
+ [Test]
+ public void EditorMainThreadLivenessTracker_AfterRegistration_ReportsSmallStall()
+ {
+ // Tests that the tracker reports near-zero stall right after a recorded tick.
+ EditorMainThreadLivenessTracker.RegisterForEditorStartup();
+
+ double stallSeconds = EditorMainThreadLivenessTracker.SecondsSinceLastMainThreadTick();
+
+ Assert.That(stallSeconds, Is.GreaterThanOrEqualTo(0));
+ Assert.That(stallSeconds, Is.LessThan(60));
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/JsonRpcHeartbeatTests.cs.meta b/Assets/Tests/Editor/JsonRpcHeartbeatTests.cs.meta
new file mode 100644
index 0000000000..9ff6ce01e9
--- /dev/null
+++ b/Assets/Tests/Editor/JsonRpcHeartbeatTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 07f19653643224ffaaa45e4a954dfc13
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/JsonRpcProcessorCliVersionGateTests.cs b/Assets/Tests/Editor/JsonRpcProcessorCliVersionGateTests.cs
index d77efe7df4..0ea907e602 100644
--- a/Assets/Tests/Editor/JsonRpcProcessorCliVersionGateTests.cs
+++ b/Assets/Tests/Editor/JsonRpcProcessorCliVersionGateTests.cs
@@ -105,14 +105,14 @@ public async Task ProcessRequest_WhenFirstToolWaitsForMainThread_ReturnsServerBu
firstResponseTask = JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync(
BuildToolRequest(SingleFlightTestTool.Name, 1),
CancellationToken.None,
- (_, _) => Task.CompletedTask);
+ (_, _, _) => Task.CompletedTask);
Assert.That(dispatcher.PendingContinuationCount, Is.EqualTo(1));
secondResponseTask = JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync(
BuildToolRequest(SingleFlightTestTool.Name, 2),
CancellationToken.None,
- (_, _) => Task.CompletedTask);
+ (_, _, _) => Task.CompletedTask);
string secondResponse = await AwaitWithTimeout(secondResponseTask, TimeSpan.FromMilliseconds(200));
JObject error = ParseError(secondResponse);
@@ -163,14 +163,14 @@ public async Task ProcessRequest_WhenExecuteDynamicCodeWaitsForMainThread_Allows
firstDynamicCodeTask = JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync(
BuildToolRequest(UnityCliLoopConstants.TOOL_NAME_EXECUTE_DYNAMIC_CODE, 1),
CancellationToken.None,
- (_, _) => Task.CompletedTask);
+ (_, _, _) => Task.CompletedTask);
Assert.That(dispatcher.PendingContinuationCount, Is.EqualTo(1));
secondDynamicCodeTask = JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync(
BuildToolRequest(UnityCliLoopConstants.TOOL_NAME_EXECUTE_DYNAMIC_CODE, 2),
CancellationToken.None,
- (_, _) => Task.CompletedTask);
+ (_, _, _) => Task.CompletedTask);
Assert.That(dispatcher.PendingContinuationCount, Is.EqualTo(2));
Assert.That(secondDynamicCodeTask.IsCompleted, Is.False);
@@ -178,7 +178,7 @@ public async Task ProcessRequest_WhenExecuteDynamicCodeWaitsForMainThread_Allows
otherToolTask = JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync(
BuildToolRequest(SingleFlightTestTool.Name, 3),
CancellationToken.None,
- (_, _) => Task.CompletedTask);
+ (_, _, _) => Task.CompletedTask);
string otherToolResponse = await AwaitWithTimeout(otherToolTask, TimeSpan.FromMilliseconds(200));
JObject error = ParseError(otherToolResponse);
@@ -227,7 +227,7 @@ public async Task ProcessRequest_WhenCompileWaitsForDomainReload_KeepsAcceptedRe
"{\"WaitForDomainReload\":true}",
1),
CancellationToken.None,
- (_, shouldCancelOnClientDisconnect) =>
+ (_, shouldCancelOnClientDisconnect, _) =>
{
cancelOnClientDisconnect = shouldCancelOnClientDisconnect;
return Task.CompletedTask;
@@ -263,7 +263,7 @@ public async Task ProcessRequest_WhenCompileOmitsReloadWait_KeepsAcceptedRequest
string response = await JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync(
BuildToolRequest(UnityCliLoopConstants.TOOL_NAME_COMPILE, 1),
CancellationToken.None,
- (_, shouldCancelOnClientDisconnect) =>
+ (_, shouldCancelOnClientDisconnect, _) =>
{
cancelOnClientDisconnect = shouldCancelOnClientDisconnect;
return Task.CompletedTask;
@@ -302,7 +302,7 @@ public async Task ProcessRequest_WhenCompileDoesNotWaitForDomainReload_CancelsOn
"{\"WaitForDomainReload\":false}",
1),
CancellationToken.None,
- (_, shouldCancelOnClientDisconnect) =>
+ (_, shouldCancelOnClientDisconnect, _) =>
{
cancelOnClientDisconnect = shouldCancelOnClientDisconnect;
return Task.CompletedTask;
@@ -341,7 +341,7 @@ public async Task ProcessRequest_WhenCompileUsesCamelCaseNoReloadWait_CancelsOnC
"{\"waitForDomainReload\":false}",
1),
CancellationToken.None,
- (_, shouldCancelOnClientDisconnect) =>
+ (_, shouldCancelOnClientDisconnect, _) =>
{
cancelOnClientDisconnect = shouldCancelOnClientDisconnect;
return Task.CompletedTask;
@@ -384,7 +384,7 @@ public async Task ProcessRequest_AfterGetHierarchyReturns_AllowsImmediateGetLogs
"{\"MaxDepth\":0,\"IncludeComponents\":false}",
1),
CancellationToken.None,
- (_, _) => Task.CompletedTask);
+ (_, _, _) => Task.CompletedTask);
Assert.That(dispatcher.PendingContinuationCount, Is.EqualTo(1));
dispatcher.RunContinuations();
@@ -400,7 +400,7 @@ public async Task ProcessRequest_AfterGetHierarchyReturns_AllowsImmediateGetLogs
"{\"MaxCount\":0}",
2),
CancellationToken.None,
- (_, _) => Task.CompletedTask);
+ (_, _, _) => Task.CompletedTask);
Assert.That(dispatcher.PendingContinuationCount, Is.EqualTo(1));
dispatcher.RunContinuations();
@@ -434,7 +434,7 @@ public async Task ProcessRequest_WhenInternalBridgeCommandRuns_SwitchesToMainThr
responseTask = JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync(
BuildToolRequest(UnityCliLoopConstants.COMMAND_NAME_GET_VERSION, 1),
CancellationToken.None,
- (_, _) => Task.CompletedTask);
+ (_, _, _) => Task.CompletedTask);
Assert.That(dispatcher.PendingContinuationCount, Is.EqualTo(1));
Assert.That(responseTask.IsCompleted, Is.False);
@@ -478,7 +478,7 @@ public async Task ProcessRequest_WhenMainThreadSwitchIsCanceled_ReleasesExecutio
canceledResponseTask = JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync(
BuildToolRequest(SingleFlightTestTool.Name, 1),
cancellationSource.Token,
- (_, _) => Task.CompletedTask);
+ (_, _, _) => Task.CompletedTask);
Assert.That(dispatcher.PendingContinuationCount, Is.EqualTo(1));
@@ -488,7 +488,7 @@ public async Task ProcessRequest_WhenMainThreadSwitchIsCanceled_ReleasesExecutio
secondResponseTask = JsonRpcProcessor.ProcessRequestWithEarlyResponseAsync(
BuildToolRequest(SingleFlightTestTool.Name, 2),
CancellationToken.None,
- (_, _) => Task.CompletedTask);
+ (_, _, _) => Task.CompletedTask);
Assert.That(secondResponseTask.IsCompleted, Is.False);
diff --git a/Assets/Tests/Editor/UnityCliLoopBridgeServerShutdownTests.cs b/Assets/Tests/Editor/UnityCliLoopBridgeServerShutdownTests.cs
new file mode 100644
index 0000000000..a94a9b479a
--- /dev/null
+++ b/Assets/Tests/Editor/UnityCliLoopBridgeServerShutdownTests.cs
@@ -0,0 +1,51 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using NUnit.Framework;
+
+using io.github.hatayama.UnityCliLoop.Infrastructure;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor
+{
+ ///
+ /// Test fixture that verifies shutdown disposal of the bridge server's cancellation source.
+ ///
+ public class UnityCliLoopBridgeServerShutdownTests
+ {
+ [Test]
+ public async Task DisposeCancellationSourceAfterServerTaskAsync_WhenAllTasksComplete_DisposesSource()
+ {
+ // Tests that the cancellation source is disposed once every shutdown-wait task has finished.
+ CancellationTokenSource cancellationTokenSource = new();
+
+ await UnityCliLoopBridgeServer.DisposeCancellationSourceAfterServerTaskAsync(
+ Task.CompletedTask,
+ new[] { Task.CompletedTask },
+ cancellationTokenSource,
+ TimeSpan.FromSeconds(1));
+
+ Assert.Throws(() => cancellationTokenSource.Cancel());
+ }
+
+ [Test]
+ public async Task DisposeCancellationSourceAfterServerTaskAsync_WhenTaskOutlivesTimeout_SkipsDisposal()
+ {
+ // Tests that a straggling client task keeps the cancellation source alive so the task
+ // can still observe its token without hitting ObjectDisposedException.
+ CancellationTokenSource cancellationTokenSource = new();
+ TaskCompletionSource stragglingTask = new();
+
+ await UnityCliLoopBridgeServer.DisposeCancellationSourceAfterServerTaskAsync(
+ Task.CompletedTask,
+ new[] { stragglingTask.Task },
+ cancellationTokenSource,
+ TimeSpan.FromMilliseconds(50));
+
+ Assert.DoesNotThrow(() => cancellationTokenSource.Cancel());
+
+ // Complete the straggler so the test leaves no pending work behind.
+ stragglingTask.SetResult(true);
+ cancellationTokenSource.Dispose();
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/UnityCliLoopBridgeServerShutdownTests.cs.meta b/Assets/Tests/Editor/UnityCliLoopBridgeServerShutdownTests.cs.meta
new file mode 100644
index 0000000000..ebeb169a05
--- /dev/null
+++ b/Assets/Tests/Editor/UnityCliLoopBridgeServerShutdownTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 02618fbff103949eb85531d97c686af6
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/UnityCliLoopServerControllerStartupLockTests.cs b/Assets/Tests/Editor/UnityCliLoopServerControllerStartupLockTests.cs
index ac5f3112cb..7dddb63154 100644
--- a/Assets/Tests/Editor/UnityCliLoopServerControllerStartupLockTests.cs
+++ b/Assets/Tests/Editor/UnityCliLoopServerControllerStartupLockTests.cs
@@ -220,6 +220,93 @@ public async Task ProbeReadinessWithTimeoutAsync_WhenProbeDoesNotComplete_Throws
Assert.That(readinessProbe.CallCount, Is.EqualTo(1));
}
+ [Test]
+ public async Task ExecuteTrackedRecoveryAsync_WhenRecoveryFailsOnce_RetriesAfterBackoffAndSucceeds()
+ {
+ // Tests that a transient recovery failure (e.g. readiness timeout during a heavy import)
+ // is retried with backoff instead of leaving the server down until the next domain reload.
+ System.Collections.Generic.List recordedWaits = new();
+ int recoveryAttempts = 0;
+ UnityCliLoopServerControllerService service = CreateControllerService(
+ new TestReadinessProbe(),
+ (delayMilliseconds, ct) =>
+ {
+ recordedWaits.Add(delayMilliseconds);
+ return Task.CompletedTask;
+ });
+
+ await service.ExecuteTrackedRecoveryAsync(() =>
+ {
+ recoveryAttempts++;
+ if (recoveryAttempts == 1)
+ {
+ throw new System.TimeoutException("readiness probe timed out");
+ }
+
+ return Task.CompletedTask;
+ });
+
+ Assert.That(recoveryAttempts, Is.EqualTo(2));
+ Assert.That(recordedWaits, Is.EqualTo(new[]
+ {
+ UnityCliLoopServerConfig.RECOVERY_RETRY_DELAYS_MS[0]
+ }));
+ }
+
+ [Test]
+ public void ExecuteTrackedRecoveryAsync_WhenRecoveryKeepsFailing_GivesUpAfterAllRetriesAndClearsSession()
+ {
+ // Tests that persistent recovery failure exhausts the full backoff schedule before
+ // surfacing the error and clearing the server session.
+ _sessionStateService.MarkServerStarted();
+ System.Collections.Generic.List recordedWaits = new();
+ int recoveryAttempts = 0;
+ UnityCliLoopServerControllerService service = CreateControllerService(
+ new TestReadinessProbe(),
+ (delayMilliseconds, ct) =>
+ {
+ recordedWaits.Add(delayMilliseconds);
+ return Task.CompletedTask;
+ });
+
+ System.InvalidOperationException exception =
+ Assert.ThrowsAsync(async () =>
+ await service.ExecuteTrackedRecoveryAsync(() =>
+ {
+ recoveryAttempts++;
+ throw new System.TimeoutException("readiness probe timed out");
+ }));
+
+ Assert.That(exception.Message, Does.Contain("recovery failed"));
+ Assert.That(recoveryAttempts, Is.EqualTo(UnityCliLoopServerConfig.RECOVERY_RETRY_DELAYS_MS.Length + 1));
+ Assert.That(
+ recordedWaits,
+ Is.EqualTo(UnityCliLoopServerConfig.RECOVERY_RETRY_DELAYS_MS));
+ Assert.That(_sessionStateService.GetIsServerRunning(), Is.False);
+ }
+
+ [Test]
+ public async Task ExecuteTrackedRecoveryAsync_WhenServerManuallyStoppedDuringBackoff_AbandonsRetry()
+ {
+ // Tests that an explicit Stop Server during the retry backoff wins over automatic recovery.
+ int recoveryAttempts = 0;
+ UnityCliLoopServerControllerService service = CreateControllerService(
+ new TestReadinessProbe(),
+ (delayMilliseconds, ct) =>
+ {
+ _sessionStateService.MarkServerManuallyStopped();
+ return Task.CompletedTask;
+ });
+
+ await service.ExecuteTrackedRecoveryAsync(() =>
+ {
+ recoveryAttempts++;
+ throw new System.TimeoutException("readiness probe timed out");
+ });
+
+ Assert.That(recoveryAttempts, Is.EqualTo(1));
+ }
+
[Test]
public async Task RestoreServerStateIfNeeded_WhenServerWasManuallyStopped_ShouldSkipStartupRecovery()
{
@@ -311,12 +398,9 @@ public async Task StartServerWithUseCaseAsync_WhenRestartCleanupStopFails_Should
Assert.That(_sessionStateService.GetIsServerManuallyStopped(), Is.False);
}
- private UnityCliLoopServerControllerService CreateControllerService()
- {
- return CreateControllerService(new TestReadinessProbe());
- }
-
- private UnityCliLoopServerControllerService CreateControllerService(TestReadinessProbe readinessProbe)
+ private UnityCliLoopServerControllerService CreateControllerService(
+ TestReadinessProbe readinessProbe = null,
+ System.Func waitBeforeRecoveryRetryAsync = null)
{
TestServerInstanceFactory serverInstanceFactory = new();
UnityCliLoopServerLifecycleRegistryService lifecycleRegistry =
@@ -326,8 +410,9 @@ private UnityCliLoopServerControllerService CreateControllerService(TestReadines
lifecycleRegistry,
new DomainReloadDetectionFileService(_sessionStateService),
_sessionStateService,
- readinessProbe,
- new TestDomainReloadLifecycle());
+ readinessProbe ?? new TestReadinessProbe(),
+ new TestDomainReloadLifecycle(),
+ waitBeforeRecoveryRetryAsync: waitBeforeRecoveryRetryAsync);
}
///
diff --git a/Packages/src/Editor/Domain/CliConstants.cs b/Packages/src/Editor/Domain/CliConstants.cs
index 4d96111a46..79443441dd 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.29";
+ public const string MINIMUM_REQUIRED_CLI_VERSION = "3.0.0-beta.30";
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/Infrastructure/Api/JsonRpcProcessor.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcProcessor.cs
index 4a395b0d1d..f35d2458e3 100644
--- a/Packages/src/Editor/Infrastructure/Api/JsonRpcProcessor.cs
+++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcProcessor.cs
@@ -35,7 +35,8 @@ public static class JsonRpcProcessor
internal delegate Task JsonRpcEarlyResponseWriter(
string responseJson,
- bool cancelOnClientDisconnect);
+ bool cancelOnClientDisconnect,
+ Func createHeartbeatJson);
///
/// Process JSON-RPC request and generate response
@@ -85,6 +86,7 @@ private static JsonRpcRequest ParseRequest(string jsonRequest)
Params = request["params"],
ClientCliVersion = ReadClientCliVersion(request),
AcceptsDispatchAck = ReadAcceptsDispatchAck(request),
+ AcceptsHeartbeat = ReadAcceptsHeartbeat(request),
Id = request["id"]?.ToObject