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: