Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions Assets/Tests/Editor/AutoTickPumpControllerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using NUnit.Framework;

using io.github.hatayama.UnityCliLoop.Infrastructure;

namespace io.github.hatayama.UnityCliLoop.Tests.Editor
{
public sealed class AutoTickPumpControllerTests
{
private const double TrailingWindowSeconds = 10.0;

/// <summary>
/// Verifies that a freshly constructed controller does not request pumping.
/// </summary>
[Test]
public void ShouldPump_WhenInitial_ReturnsFalse()
{
AutoTickPumpController controller = new AutoTickPumpController(TrailingWindowSeconds);

Assert.That(controller.ShouldPump(0.0), Is.False);
Assert.That(controller.ShouldPump(100.0), Is.False);
}

/// <summary>
/// Verifies that an open scope keeps ShouldPump true regardless of elapsed time.
/// </summary>
[Test]
public void ShouldPump_AfterScopeStarted_ReturnsTrue()
{
AutoTickPumpController controller = new AutoTickPumpController(TrailingWindowSeconds);

controller.NotifyScopeStarted();

Assert.That(controller.ShouldPump(0.0), Is.True);
Assert.That(controller.ShouldPump(1000.0), Is.True);
}

/// <summary>
/// Verifies that nested scopes use reference counting and stay active until the last ends.
/// </summary>
[Test]
public void ShouldPump_WhenNestedScopesPartiallyEnded_ReturnsTrue()
{
AutoTickPumpController controller = new AutoTickPumpController(TrailingWindowSeconds);

controller.NotifyScopeStarted();
controller.NotifyScopeStarted();
controller.NotifyScopeEnded(1.0);

Assert.That(controller.ShouldPump(1.0), Is.True);
Assert.That(controller.ShouldPump(100.0), Is.True);
}

/// <summary>
/// Verifies that the trailing window keeps pumping shortly after the last scope ends.
/// </summary>
[Test]
public void ShouldPump_WithinTrailingWindowAfterLastScopeEnded_ReturnsTrue()
{
AutoTickPumpController controller = new AutoTickPumpController(TrailingWindowSeconds);

controller.NotifyScopeStarted();
controller.NotifyScopeEnded(5.0);

Assert.That(controller.ShouldPump(5.0 + 9.9), Is.True);
}

/// <summary>
/// Verifies that the trailing window is exclusive at the boundary (elapsed == window => false).
/// </summary>
[Test]
public void ShouldPump_AtTrailingWindowBoundaryAfterLastScopeEnded_ReturnsFalse()
{
AutoTickPumpController controller = new AutoTickPumpController(TrailingWindowSeconds);

controller.NotifyScopeStarted();
controller.NotifyScopeEnded(5.0);

Assert.That(controller.ShouldPump(5.0 + TrailingWindowSeconds), Is.False);
}

/// <summary>
/// Verifies that startup completion opens a trailing window that later expires.
/// </summary>
[Test]
public void ShouldPump_AfterStartupCompleted_FollowsTrailingWindow()
{
AutoTickPumpController controller = new AutoTickPumpController(TrailingWindowSeconds);

controller.NotifyStartupCompleted(2.0);

Assert.That(controller.ShouldPump(2.0 + 9.9), Is.True);
Assert.That(controller.ShouldPump(2.0 + TrailingWindowSeconds), Is.False);
}
}
}
11 changes: 11 additions & 0 deletions Assets/Tests/Editor/AutoTickPumpControllerTests.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

109 changes: 57 additions & 52 deletions Packages/src/Editor/Infrastructure/Api/JsonRpcRequestProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,63 +132,68 @@ private async Task<string> ProcessRpcRequest(
CancellationToken ct,
JsonRpcEarlyResponseWriter earlyResponseWriter)
{
try
// Why: keep an unfocused editor ticking for the request duration plus a trailing window
// so compile/test work continues in the background without an OS focus kick.
using (AutoTickPumpService.BeginScope())
{
ct.ThrowIfCancellationRequested();
if (IsCliProtocolMismatch(request.ClientProtocolVersion))
try
{
return JsonRpcResponseFactory.CreateCliProtocolMismatchResponse(
request.Id,
request.ClientProjectRunnerVersion,
request.ClientProtocolVersion);
ct.ThrowIfCancellationRequested();
if (IsCliProtocolMismatch(request.ClientProtocolVersion))
{
return JsonRpcResponseFactory.CreateCliProtocolMismatchResponse(
request.Id,
request.ClientProjectRunnerVersion,
request.ClientProtocolVersion);
}

if (request.AcceptsDispatchAck && earlyResponseWriter != null)
{
int heartbeatIntervalSeconds = request.AcceptsHeartbeat
? UnityCliLoopServerConfig.HEARTBEAT_INTERVAL_SECONDS
: 0;
Func<string> createHeartbeatJson = request.AcceptsHeartbeat
? () => JsonRpcResponseFactory.CreateHeartbeatResponse(
request.Id,
EditorMainThreadLivenessTracker.SecondsSinceLastMainThreadTick())
: null;
await earlyResponseWriter(
JsonRpcResponseFactory.CreateDispatchAcceptedResponse(request.Id, heartbeatIntervalSeconds),
ShouldCancelAcceptedRequestOnClientDisconnect(request),
createHeartbeatJson);
}

Stopwatch requestStopwatch = Stopwatch.StartNew();

Stopwatch executeMethodStopwatch = Stopwatch.StartNew();
UnityCliLoopToolResponse result = await ExecuteMethod(request.Method, request.Params, ct);
executeMethodStopwatch.Stop();

JsonRpcResponseFactory.AppendTimingIfRequested(
result,
$"[Perf] RpcExecuteMethod: {executeMethodStopwatch.Elapsed.TotalMilliseconds:F1}ms");
JsonRpcResponseFactory.AppendTimingIfRequested(
result,
$"[Perf] RpcBeforeSerializeTotal: {requestStopwatch.Elapsed.TotalMilliseconds:F1}ms");

string response = JsonRpcResponseFactory.CreateSuccessResponse(request.Id, result);
return response;
}

if (request.AcceptsDispatchAck && earlyResponseWriter != null)
catch (JsonSerializationException ex)
{
int heartbeatIntervalSeconds = request.AcceptsHeartbeat
? UnityCliLoopServerConfig.HEARTBEAT_INTERVAL_SECONDS
: 0;
Func<string> createHeartbeatJson = request.AcceptsHeartbeat
? () => JsonRpcResponseFactory.CreateHeartbeatResponse(
request.Id,
EditorMainThreadLivenessTracker.SecondsSinceLastMainThreadTick())
: null;
await earlyResponseWriter(
JsonRpcResponseFactory.CreateDispatchAcceptedResponse(request.Id, heartbeatIntervalSeconds),
ShouldCancelAcceptedRequestOnClientDisconnect(request),
createHeartbeatJson);
UnityEngine.Debug.LogError($"[JsonRpcRequestProcessor] JSON serialization error: {ex.Message}\nStack trace: {ex.StackTrace}");
return JsonRpcResponseFactory.CreateErrorResponse(request.Id, ex);
}
catch (UnityCliLoopToolParameterValidationException ex)
{
LogUnityCliLoopToolParameterValidationException(ex);
return JsonRpcResponseFactory.CreateErrorResponse(request.Id, ex);
}
catch (Exception ex) when (!(ex is OperationCanceledException))
{
LogRpcExceptionIfNeeded(ex);
return JsonRpcResponseFactory.CreateErrorResponse(request.Id, ex);
}

Stopwatch requestStopwatch = Stopwatch.StartNew();

Stopwatch executeMethodStopwatch = Stopwatch.StartNew();
UnityCliLoopToolResponse result = await ExecuteMethod(request.Method, request.Params, ct);
executeMethodStopwatch.Stop();

JsonRpcResponseFactory.AppendTimingIfRequested(
result,
$"[Perf] RpcExecuteMethod: {executeMethodStopwatch.Elapsed.TotalMilliseconds:F1}ms");
JsonRpcResponseFactory.AppendTimingIfRequested(
result,
$"[Perf] RpcBeforeSerializeTotal: {requestStopwatch.Elapsed.TotalMilliseconds:F1}ms");

string response = JsonRpcResponseFactory.CreateSuccessResponse(request.Id, result);
return response;
}
catch (JsonSerializationException ex)
{
UnityEngine.Debug.LogError($"[JsonRpcRequestProcessor] JSON serialization error: {ex.Message}\nStack trace: {ex.StackTrace}");
return JsonRpcResponseFactory.CreateErrorResponse(request.Id, ex);
}
catch (UnityCliLoopToolParameterValidationException ex)
{
LogUnityCliLoopToolParameterValidationException(ex);
return JsonRpcResponseFactory.CreateErrorResponse(request.Id, ex);
}
catch (Exception ex) when (!(ex is OperationCanceledException))
{
LogRpcExceptionIfNeeded(ex);
return JsonRpcResponseFactory.CreateErrorResponse(request.Id, ex);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ internal static void Initialize(IUnityCliLoopEditorSettingsPort editorSettingsPo
packageRemovalSettingsResetter.RegisterForEditorStartup();
UnityCliLoopEditorSettingsRecoveryScheduler.ScheduleForEditorStartup(editorSettingsPort);
EditorMainThreadLivenessTracker.RegisterForEditorStartup();
AutoTickPumpService.RegisterForEditorStartup();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

using io.github.hatayama.UnityCliLoop.Application;
using io.github.hatayama.UnityCliLoop.Domain;
using io.github.hatayama.UnityCliLoop.InternalAPIBridge;
using io.github.hatayama.UnityCliLoop.ToolContracts;

namespace io.github.hatayama.UnityCliLoop.Infrastructure
Expand Down Expand Up @@ -364,6 +365,11 @@ private void OnServerLoopUnexpectedlyExited()
_bridgeServer = null;
_recoveryTrackingService.ScheduleTrackedRecovery(() => StartRecoveryIfNeededAsync(false, CancellationToken.None));
};

// delayCall only runs on the next editor tick, and a backgrounded idle editor may never
// tick again on its own — the recovery would then wait forever. Signal one tick so the
// scheduled recovery actually executes even while the editor is unfocused.
EditorApplicationTickBridge.SignalTick();
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace io.github.hatayama.UnityCliLoop.Infrastructure
{
/// <summary>
/// Timing constants for the scoped SignalTick pump that keeps the editor alive while unfocused.
/// </summary>
internal static class AutoTickPumpConstants
{
// Why: ~60Hz matches a focused editor and com.unity.pipeline's AutoTickCommand default.
// Smaller intervals waste CPU; larger ones slow frame-dependent compile/test progress.
internal const int PUMP_INTERVAL_MS = 16;

// Why: command teardown and back-to-back CLI polling leave brief idle gaps; without a
// trailing window the editor would re-throttle between requests and stall again.
// Domain-reload recovery also needs a short awake period after Infrastructure startup.
internal const double TRAILING_WINDOW_SECONDS = 10.0;
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using System.Diagnostics;

namespace io.github.hatayama.UnityCliLoop.Infrastructure
{
/// <summary>
/// Pure state machine that decides whether the editor SignalTick pump should keep running.
/// Why: scopes cover in-flight CLI work; a trailing window covers teardown and inter-command gaps
/// without leaving the pump on permanently (which would burn CPU while idle and unfocused).
/// </summary>
internal sealed class AutoTickPumpController
{
private readonly double _trailingWindowSeconds;
private readonly object _gate = new object();
private int _activeScopeCount;
private double _lastActivitySeconds;
private bool _hasActivity;

public AutoTickPumpController(double trailingWindowSeconds)
{
Debug.Assert(trailingWindowSeconds > 0, "trailingWindowSeconds must be greater than 0");
_trailingWindowSeconds = trailingWindowSeconds;
}

public void NotifyScopeStarted()
{
lock (_gate)
{
_activeScopeCount++;
}
}

public void NotifyScopeEnded(double nowSeconds)
{
lock (_gate)
{
Debug.Assert(_activeScopeCount > 0, "NotifyScopeEnded called with no active scope");
_activeScopeCount--;
_lastActivitySeconds = nowSeconds;
_hasActivity = true;
}
}

public void NotifyStartupCompleted(double nowSeconds)
{
lock (_gate)
{
_lastActivitySeconds = nowSeconds;
_hasActivity = true;
}
}

public bool ShouldPump(double nowSeconds)
{
lock (_gate)
{
if (_activeScopeCount > 0)
{
return true;
}

if (!_hasActivity)
{
return false;
}

return (nowSeconds - _lastActivitySeconds) < _trailingWindowSeconds;
}
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading