From 2e0eae6d6e89e329977303c01725c3ec8ef1f04a Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 23 Jul 2026 23:04:19 +0900 Subject: [PATCH 1/3] Add scoped auto-tick pump controller with trailing window Unfocused Unity editors throttle their update loop, which stalls CLI-driven compile/test work. A pure state machine decides when SignalTick pumping should stay active (open scopes or a short trailing window) so the later editor glue can keep ticks flowing without leaving the pump on permanently. Co-authored-by: Cursor --- .../Editor/AutoTickPumpControllerTests.cs | 95 +++++++++++++++++++ .../AutoTickPumpControllerTests.cs.meta | 11 +++ .../Threading/AutoTickPumpConstants.cs | 17 ++++ .../Threading/AutoTickPumpConstants.cs.meta | 11 +++ .../Threading/AutoTickPumpController.cs | 70 ++++++++++++++ .../Threading/AutoTickPumpController.cs.meta | 11 +++ 6 files changed, 215 insertions(+) create mode 100644 Assets/Tests/Editor/AutoTickPumpControllerTests.cs create mode 100644 Assets/Tests/Editor/AutoTickPumpControllerTests.cs.meta create mode 100644 Packages/src/Editor/Infrastructure/Threading/AutoTickPumpConstants.cs create mode 100644 Packages/src/Editor/Infrastructure/Threading/AutoTickPumpConstants.cs.meta create mode 100644 Packages/src/Editor/Infrastructure/Threading/AutoTickPumpController.cs create mode 100644 Packages/src/Editor/Infrastructure/Threading/AutoTickPumpController.cs.meta 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/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: From 468fe9cc0d1151c8caafe3b2ef98e22be2f9ad8f Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 23 Jul 2026 23:04:19 +0900 Subject: [PATCH 2/3] Keep editor ticking during CLI commands and wake it for server recovery Wire the scoped SignalTick pump around each JSON-RPC request so unfocused editors keep advancing while CLI work runs, and fire a one-shot SignalTick after scheduling unexpected server-loop recovery so delayCall is not stranded while the editor is asleep. Co-authored-by: Cursor --- .../Api/JsonRpcRequestProcessor.cs | 109 +++++++++--------- .../InfrastructureEditorStartup.cs | 1 + .../Server/UnityCliLoopServerController.cs | 6 + .../Threading/AutoTickPumpService.cs | 91 +++++++++++++++ .../Threading/AutoTickPumpService.cs.meta | 11 ++ 5 files changed, 166 insertions(+), 52 deletions(-) create mode 100644 Packages/src/Editor/Infrastructure/Threading/AutoTickPumpService.cs create mode 100644 Packages/src/Editor/Infrastructure/Threading/AutoTickPumpService.cs.meta 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/AutoTickPumpService.cs b/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpService.cs new file mode 100644 index 000000000..cd1836090 --- /dev/null +++ b/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpService.cs @@ -0,0 +1,91 @@ +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(); + _throttle = Stopwatch.StartNew(); + + // 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; + } + + if (_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: From a0e021dc0bb0e0c1593ed8ea5aec3dc5a937ded4 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 23 Jul 2026 23:20:21 +0900 Subject: [PATCH 3/3] Allow first SignalTick wake to bypass pump throttle An unfocused editor has no natural update loop, so if the one-shot wake from BeginScope/Register is swallowed by the 16ms throttle, the self- sustaining SignalTick chain never starts. Leave the throttle stopwatch unstarted until the first successful pump. Co-authored-by: Cursor --- .../Infrastructure/Threading/AutoTickPumpService.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpService.cs b/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpService.cs index cd1836090..a9aa42aa0 100644 --- a/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpService.cs +++ b/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpService.cs @@ -21,7 +21,9 @@ internal static void RegisterForEditorStartup() { _controller = new AutoTickPumpController(AutoTickPumpConstants.TRAILING_WINDOW_SECONDS); _clock = Stopwatch.StartNew(); - _throttle = 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. @@ -58,7 +60,11 @@ private static void Pump() return; } - if (_throttle.ElapsedMilliseconds < AutoTickPumpConstants.PUMP_INTERVAL_MS) + // 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; }