diff --git a/Assets/Tests/Editor/AutoTickPumpControllerTests.cs b/Assets/Tests/Editor/AutoTickPumpControllerTests.cs
new file mode 100644
index 000000000..8ea0a7515
--- /dev/null
+++ b/Assets/Tests/Editor/AutoTickPumpControllerTests.cs
@@ -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;
+
+ ///
+ /// Verifies that a freshly constructed controller does not request pumping.
+ ///
+ [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);
+ }
+
+ ///
+ /// Verifies that an open scope keeps ShouldPump true regardless of elapsed time.
+ ///
+ [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);
+ }
+
+ ///
+ /// Verifies that nested scopes use reference counting and stay active until the last ends.
+ ///
+ [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);
+ }
+
+ ///
+ /// Verifies that the trailing window keeps pumping shortly after the last scope ends.
+ ///
+ [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);
+ }
+
+ ///
+ /// Verifies that the trailing window is exclusive at the boundary (elapsed == window => false).
+ ///
+ [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);
+ }
+
+ ///
+ /// Verifies that startup completion opens a trailing window that later expires.
+ ///
+ [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);
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/AutoTickPumpControllerTests.cs.meta b/Assets/Tests/Editor/AutoTickPumpControllerTests.cs.meta
new file mode 100644
index 000000000..c21f09dc7
--- /dev/null
+++ b/Assets/Tests/Editor/AutoTickPumpControllerTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 9e102ca622de44e4d98fd7b03aa823a2
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcRequestProcessor.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcRequestProcessor.cs
index fb50105b3..7eeb26abb 100644
--- a/Packages/src/Editor/Infrastructure/Api/JsonRpcRequestProcessor.cs
+++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcRequestProcessor.cs
@@ -132,63 +132,68 @@ private async Task 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 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 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);
}
}
diff --git a/Packages/src/Editor/Infrastructure/InfrastructureEditorStartup.cs b/Packages/src/Editor/Infrastructure/InfrastructureEditorStartup.cs
index e04cc9715..6f129b310 100644
--- a/Packages/src/Editor/Infrastructure/InfrastructureEditorStartup.cs
+++ b/Packages/src/Editor/Infrastructure/InfrastructureEditorStartup.cs
@@ -14,6 +14,7 @@ internal static void Initialize(IUnityCliLoopEditorSettingsPort editorSettingsPo
packageRemovalSettingsResetter.RegisterForEditorStartup();
UnityCliLoopEditorSettingsRecoveryScheduler.ScheduleForEditorStartup(editorSettingsPort);
EditorMainThreadLivenessTracker.RegisterForEditorStartup();
+ AutoTickPumpService.RegisterForEditorStartup();
}
}
}
diff --git a/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs b/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs
index 29b1a983b..0c42a9782 100644
--- a/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs
+++ b/Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs
@@ -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
@@ -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();
}
///
diff --git a/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpConstants.cs b/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpConstants.cs
new file mode 100644
index 000000000..208440616
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpConstants.cs
@@ -0,0 +1,17 @@
+namespace io.github.hatayama.UnityCliLoop.Infrastructure
+{
+ ///
+ /// Timing constants for the scoped SignalTick pump that keeps the editor alive while unfocused.
+ ///
+ 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;
+ }
+}
diff --git a/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpConstants.cs.meta b/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpConstants.cs.meta
new file mode 100644
index 000000000..99f03ed39
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpConstants.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: a8e80c9cc53b8493e98bd8284e7719e0
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpController.cs b/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpController.cs
new file mode 100644
index 000000000..7b6117909
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpController.cs
@@ -0,0 +1,70 @@
+using System.Diagnostics;
+
+namespace io.github.hatayama.UnityCliLoop.Infrastructure
+{
+ ///
+ /// 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).
+ ///
+ 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;
+ }
+ }
+ }
+}
diff --git a/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpController.cs.meta b/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpController.cs.meta
new file mode 100644
index 000000000..6fea9ed84
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpController.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 588e3ffcf8d5f452182ef1ba0b33ca84
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpService.cs b/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpService.cs
new file mode 100644
index 000000000..a9aa42aa0
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpService.cs
@@ -0,0 +1,97 @@
+using System;
+using System.Diagnostics;
+using UnityEditor;
+
+using io.github.hatayama.UnityCliLoop.InternalAPIBridge;
+
+namespace io.github.hatayama.UnityCliLoop.Infrastructure
+{
+ ///
+ /// Editor glue that pumps SignalTick while CLI work is in scope (plus a trailing window).
+ /// Why: a always-on full-rate tick would keep an unfocused editor as expensive as a focused one;
+ /// scoping to in-flight requests and a short trailing window restores normal throttling when idle.
+ ///
+ internal static class AutoTickPumpService
+ {
+ private static AutoTickPumpController _controller;
+ private static Stopwatch _clock;
+ private static Stopwatch _throttle;
+
+ internal static void RegisterForEditorStartup()
+ {
+ _controller = new AutoTickPumpController(AutoTickPumpConstants.TRAILING_WINDOW_SECONDS);
+ _clock = Stopwatch.StartNew();
+ // Why: leave unstarted so the first Pump after an external SignalTick is not throttled.
+ // If that first tick were swallowed, an unfocused editor would never start the pump chain.
+ _throttle = new Stopwatch();
+
+ // Same dual-registration pattern as EditorMainThreadDispatcher.Initialize:
+ // update covers the normal editor loop; tick covers SignalTick-driven wakeups.
+ EditorApplication.update -= Pump;
+ EditorApplication.update += Pump;
+ EditorApplicationTickBridge.RemoveTickHandler(Pump);
+ EditorApplicationTickBridge.AddTickHandler(Pump);
+
+ _controller.NotifyStartupCompleted(NowSeconds());
+ // Why: after domain reload the editor may already be unfocused; reserve one tick so the
+ // trailing-window pump (and delayCall recovery) can start without an OS focus kick.
+ EditorApplicationTickBridge.SignalTick();
+ }
+
+ internal static IDisposable BeginScope()
+ {
+ Debug.Assert(_controller != null, "AutoTickPumpService must be registered before BeginScope");
+ _controller.NotifyScopeStarted();
+ // Why: wake a sleeping unfocused editor as soon as a CLI command arrives (same one-shot
+ // wake pattern as EditorMainThreadDispatcher.AddContinuation).
+ EditorApplicationTickBridge.SignalTick();
+ return new AutoTickScope();
+ }
+
+ private static void Pump()
+ {
+ if (_controller == null)
+ {
+ return;
+ }
+
+ if (!_controller.ShouldPump(NowSeconds()))
+ {
+ return;
+ }
+
+ // Why: !IsRunning covers the first tick after Register/BeginScope wake-ups. Swallowing
+ // that tick under the interval gate would leave an unfocused editor without a follow-up
+ // SignalTick, so the self-sustaining pump chain would never start.
+ if (_throttle.IsRunning &&
+ _throttle.ElapsedMilliseconds < AutoTickPumpConstants.PUMP_INTERVAL_MS)
+ {
+ return;
+ }
+
+ _throttle.Restart();
+ EditorApplicationTickBridge.SignalTick();
+ }
+
+ private static double NowSeconds()
+ {
+ return _clock.Elapsed.TotalSeconds;
+ }
+
+ private sealed class AutoTickScope : IDisposable
+ {
+ private bool _disposed;
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ _controller.NotifyScopeEnded(NowSeconds());
+ }
+ }
+ }
+}
diff --git a/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpService.cs.meta b/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpService.cs.meta
new file mode 100644
index 000000000..4af0ec3fe
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpService.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 3b9a6ac63858b4a08be1d69eadf122e3
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant: